From 38681278aaf0a60ef4aa81d78302b01b5cbbe3b8 Mon Sep 17 00:00:00 2001 From: DanCardin Date: Tue, 12 Nov 2024 10:55:58 -0500 Subject: [PATCH] fix: Incorrect handling of methods in Arg.parse. --- CHANGELOG.md | 4 +++ pyproject.toml | 2 +- src/cappa/class_inspect.py | 10 +++++++- src/cappa/command.py | 10 ++++---- src/cappa/invoke.py | 8 +++--- tests/arg/test_parse_method.py | 45 ++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 tests/arg/test_parse_method.py diff --git a/CHANGELOG.md b/CHANGELOG.md index befbd82..2bff532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.24 +### 0.24.3 + +- fix: Incorrect handling of methods in Arg.parse. + ### 0.24.2 - fix: Literal contained inside non-variadic tuple should not imply "choices". diff --git a/pyproject.toml b/pyproject.toml index e6c49fc..64b5998 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cappa" -version = "0.24.2" +version = "0.24.3" description = "Declarative CLI argument parser." urls = {repository = "https://github.com/dancardin/cappa"} diff --git a/src/cappa/class_inspect.py b/src/cappa/class_inspect.py index a88eb53..7b9b45b 100644 --- a/src/cappa/class_inspect.py +++ b/src/cappa/class_inspect.py @@ -13,7 +13,7 @@ from cappa.typing import T, find_annotations if typing.TYPE_CHECKING: - pass + from cappa.command import Command __all__ = [ "detect", @@ -309,3 +309,11 @@ def collect_method_subcommands(cls: type) -> tuple[typing.Callable, ...]: for _, method in inspect.getmembers(cls, callable) if hasattr(method, "__cappa__") ) + + +def has_command(obj) -> bool: + return hasattr(obj, "__cappa__") + + +def get_command(obj) -> Command | None: + return getattr(obj, "__cappa__", None) diff --git a/src/cappa/command.py b/src/cappa/command.py index 6521142..bad2a33 100644 --- a/src/cappa/command.py +++ b/src/cappa/command.py @@ -5,8 +5,9 @@ import typing from collections.abc import Callable -from cappa import class_inspect from cappa.arg import Arg, Group +from cappa.class_inspect import fields as get_fields +from cappa.class_inspect import get_command, get_command_capable_object from cappa.docstring import ClassHelpText from cappa.env import Env from cappa.help import HelpFormatable, HelpFormatter, format_short_help @@ -87,9 +88,8 @@ def get( if isinstance(obj, cls): instance = obj else: - obj = class_inspect.get_command_capable_object(obj) - if getattr(obj, "__cappa__", None): - instance = obj.__cappa__ # type: ignore + obj = get_command_capable_object(obj) + instance = get_command(obj) if instance: return dataclasses.replace(instance, help_formatter=help_formatter) @@ -121,7 +121,7 @@ def collect(cls, command: Command[T]) -> Command[T]: if not command.description: kwargs["description"] = help_text.body - fields = class_inspect.fields(command.cmd_cls) + fields = get_fields(command.cmd_cls) function_view = CallableView.from_callable(command.cmd_cls, include_extras=True) if command.arguments: diff --git a/src/cappa/invoke.py b/src/cappa/invoke.py index e3ecf18..e76a702 100644 --- a/src/cappa/invoke.py +++ b/src/cappa/invoke.py @@ -7,6 +7,7 @@ from collections.abc import Callable from dataclasses import dataclass, field +from cappa.class_inspect import has_command from cappa.command import Command, HasCommand from cappa.output import Exit, Output from cappa.subcommand import Subcommand @@ -301,11 +302,12 @@ def fulfill_deps( # Method `self` arguments can be assumed to be typed as the literal class they reside inside, # These classes should always be already fulfilled by the root command structure. - elif inspect.ismethod(fn) and index == 0: + elif index == 0 and inspect.ismethod(fn): cls = get_method_class(fn) - value = fulfilled_deps[cls] - args.append(value) + if has_command(fn.__self__): + value = fulfilled_deps[cls] + args.append(value) # If there's a default, we can just skip it and let the default fulfill the value. # Alternatively, `allow_empty` might be True to indicate we shouldn't error. diff --git a/tests/arg/test_parse_method.py b/tests/arg/test_parse_method.py new file mode 100644 index 0000000..b7df607 --- /dev/null +++ b/tests/arg/test_parse_method.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from typing_extensions import Annotated + +import cappa +from tests.utils import backends, parse + + +@dataclass +class Config: + path: str + + @classmethod + def from_classmethod(cls, path: str): + return cls(path) + + def from_method(self, path: str) -> str: + return "/".join([self.path, path]) + + +config = Config("foo") + + +@backends +def test_from_classmethod(backend): + @cappa.command(name="command") + @dataclass + class Command: + config: Annotated[Config, cappa.Arg(parse=Config.from_classmethod)] + + test = parse(Command, "foo", backend=backend) + assert test == Command(config=Config("foo")) + + +@backends +def test_method(backend): + @cappa.command(name="command") + @dataclass + class Command: + config: Annotated[str, cappa.Arg(parse=config.from_method)] + + test = parse(Command, "bar", backend=backend) + assert test == Command(config="foo/bar") diff --git a/uv.lock b/uv.lock index 4864b0e..4d12574 100644 --- a/uv.lock +++ b/uv.lock @@ -28,7 +28,7 @@ wheels = [ [[package]] name = "cappa" -version = "0.24.1" +version = "0.24.2" source = { editable = "." } dependencies = [ { name = "rich" },