diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7dd41..46683d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - fix: Display Args with the same group identity as alternatives in helptext on the same line. - fix: Ensure automatic bool variants (--foo/--no-foo) are mutually exclusive. - fix: Allow negative number arguments and option values. +- fix: Precalculate implicit deps during class construction rather than traversing the output shape. ## 0.31.0 - fix: Allow options accepting zero-length unbounded num_args. diff --git a/src/cappa/base.py b/src/cappa/base.py index 0e1e799..a4734c2 100644 --- a/src/cappa/base.py +++ b/src/cappa/base.py @@ -19,7 +19,7 @@ from cappa import argparse, parser from cappa.class_inspect import detect -from cappa.command import Command +from cappa.command import Command, ParseResult from cappa.help import HelpFormattable, HelpFormatter from cappa.invoke import DepTypes, InvokeCallable, InvokeCallableSpec, resolve_callable from cappa.output import Output @@ -162,7 +162,7 @@ def parse( help_formatter: Override the default help formatter. state: Optional initial State object. """ - _, _, instance, _, _ = parse_command( + result = parse_command( obj=obj, argv=argv, input=input, @@ -176,7 +176,7 @@ def parse( help_formatter=help_formatter, state=state, ) - return instance + return result.instance def invoke( @@ -228,7 +228,7 @@ def invoke( help_formatter: Override the default help formatter. state: Optional initial State object. """ - command, parsed_command, instance, concrete_output, state = parse_command( + result = parse_command( obj=obj, argv=argv, input=input, @@ -243,18 +243,19 @@ def invoke( state=state, ) resolved, global_deps = resolve_callable( - command, - parsed_command, - instance, - output=concrete_output, - state=state, + result.command, + result.parsed_command, + result.instance, + implicit_deps=result.implicit_deps, + output=result.output, + state=result.state, deps=deps, ) for dep in global_deps: - with dep.get(output=concrete_output): + with dep.get(output=result.output): pass - return resolved.call(output=concrete_output) + return resolved.call(output=result.output) async def invoke_async( @@ -306,7 +307,7 @@ async def invoke_async( help_formatter: Override the default help formatter. state: Optional initial State object. """ - command, parsed_command, instance, concrete_output, state = parse_command( + result = parse_command( obj=obj, argv=argv, input=input, @@ -321,18 +322,19 @@ async def invoke_async( state=state, ) resolved, global_deps = resolve_callable( - command, - parsed_command, - instance, - output=concrete_output, - state=state, + result.command, + result.parsed_command, + result.instance, + implicit_deps=result.implicit_deps, + output=result.output, + state=result.state, deps=deps, ) for dep in global_deps: - async with dep.get_async(output=concrete_output): + async with dep.get_async(output=result.output): pass - async with resolved.get_async(output=concrete_output) as value: + async with resolved.get_async(output=result.output) as value: return value @@ -350,7 +352,7 @@ def parse_command( output: Output | None = None, help_formatter: HelpFormattable | None = None, state: State[S] | None = None, -) -> tuple[Command[T], Command[T], T, Output, State[Any]]: +) -> ParseResult[T, S]: concrete_backend = _coalesce_backend(backend) concrete_output = _coalesce_output(output, theme, color) concrete_state: State[S] = State.ensure(state) # type: ignore @@ -364,7 +366,7 @@ def parse_command( help_formatter=help_formatter, state=concrete_state, ) - command, parsed_command, instance, state = Command.parse_command( # pyright: ignore + return Command.parse_command( # pyright: ignore command, argv=argv, input=input, @@ -372,7 +374,6 @@ def parse_command( output=concrete_output, state=concrete_state, ) - return command, parsed_command, instance, concrete_output, concrete_state # pyright: ignore class FuncOrClassDecorator(Protocol): diff --git a/src/cappa/command.py b/src/cappa/command.py index 90a8bfd..4d68ca0 100644 --- a/src/cappa/command.py +++ b/src/cappa/command.py @@ -8,6 +8,7 @@ Any, ClassVar, Generic, + Hashable, Iterable, Protocol, TextIO, @@ -49,6 +50,27 @@ class CommandArgs(TypedDict, total=False): default_long: bool +@dataclasses.dataclass +class ParseResult(Generic[T, S]): + """Result of parsing a command with all collected metadata. + + Attributes: + command: The root command that was parsed. + parsed_command: The selected command (may be a subcommand if one was invoked). + instance: The instantiated command object. + implicit_deps: Mapping of command classes to their instances, collected during parsing. + output: The output handler for the command. + state: The state object for the command. + """ + + command: Command[T] + parsed_command: Command[T] + instance: T + implicit_deps: dict[Hashable, Any] + output: Output + state: State[S] + + @dataclasses.dataclass class Command(Generic[T]): """Register a cappa CLI command/subcomment. @@ -238,19 +260,19 @@ def parse_command( argv: list[str] | None = None, input: TextIO | None = None, state: State[S] | None = None, - ) -> tuple[Command[T], Command[T], T, State[S]]: + ) -> ParseResult[T, S]: if argv is None: # pragma: no cover argv = sys.argv[1:] prog = command.real_name() - result_state = State.ensure(state) # pyright: ignore + result_state: State[S] = State.ensure(state) # type: ignore try: parser, parsed_command, parsed_args = backend( command, argv, output=output, prog=prog ) prog = parser.prog - result = command.map_result( + result, implicit_deps = command.map_result( command, prog, parsed_args, state=state, input=input ) except BaseException as e: @@ -272,7 +294,14 @@ def parse_command( raise - return command, parsed_command, result, result_state # type: ignore + return ParseResult( + command=command, + parsed_command=parsed_command, + instance=result, + implicit_deps=implicit_deps, + output=output, + state=result_state, + ) def map_result( self, @@ -281,7 +310,7 @@ def map_result( parsed_args: dict[str, Any], state: State[Any] | None = None, input: TextIO | None = None, - ) -> T: + ) -> tuple[T, dict[Hashable, Any]]: state = State.ensure(state) # pyright: ignore kwargs: dict[str, Any] = {} @@ -311,15 +340,23 @@ def map_result( kwargs[field_name] = value + # Collect all subcommand instances during construction + subcommand_deps: dict[Hashable, Any] = {} subcommand = self.subcommand if subcommand: field_name = cast(str, subcommand.field_name) if field_name in parsed_args: value = parsed_args[field_name] - value = subcommand.map_result(prog, value, state=state) + value, subcommand_deps = subcommand.map_result(prog, value, state=state) kwargs[field_name] = value - return command.cmd_cls(**kwargs) + instance = command.cmd_cls(**kwargs) + + # Add this command instance to deps + key = cast(Hashable, instance.__class__) + deps: dict[Hashable, Any] = {key: instance, **subcommand_deps} + + return instance, deps @property def subcommand(self) -> Subcommand | None: diff --git a/src/cappa/invoke.py b/src/cappa/invoke.py index 1357d00..2aa2570 100644 --- a/src/cappa/invoke.py +++ b/src/cappa/invoke.py @@ -26,7 +26,6 @@ from cappa.command import Command from cappa.output import Exit, Output from cappa.state import State -from cappa.subcommand import Subcommand from cappa.type_view import CallableView, Empty, EmptyType, TypeView from cappa.typing import find_annotations, get_method_class @@ -189,12 +188,12 @@ def resolve_callable( parsed_command: Command[C], instance: C, *, + implicit_deps: dict[Hashable, Any], output: Output, state: State[Any], deps: DepTypes = None, ) -> tuple[Resolved[C], Sequence[Resolved[Any]]]: try: - implicit_deps = resolve_implicit_deps(command, instance) fn: Callable[..., Any] = resolve_invoke_handler(parsed_command, implicit_deps) implicit_deps[Output] = output @@ -292,31 +291,6 @@ def resolve_callable_reference(fn: InvokeCallableSpec[C] | None) -> InvokeCallab return cast(Callable[..., Any], fn) -def resolve_implicit_deps(command: Command[T], instance: T) -> dict[Hashable, Any]: - key = cast(Hashable, instance.__class__) - deps: dict[Hashable, Any] = {key: instance} - - for arg in command.arguments: - if not isinstance(arg, Subcommand): - # Args do not produce dependencies themselves. - continue - - option_instance = getattr(instance, cast(str, arg.field_name)) - if option_instance is None: - # None is a valid subcommand instance value, but it won't exist as a dependency - # where an actual command has been selected. - continue - - # This **should** always end up producing a value (type). In order to have produced - # a subcommand instance value of a given type, it would need to exist in the options. - option = next( # pragma: no branch - o for o in arg.options.values() if isinstance(option_instance, o.cmd_cls) - ) - deps.update(resolve_implicit_deps(option, option_instance)) - - return deps - - def fulfill_deps( fn: Callable[..., C], fulfilled_deps: dict[Hashable, Any], allow_empty: bool = False ) -> Resolved[C]: diff --git a/src/cappa/subcommand.py b/src/cappa/subcommand.py index fcfee89..c37485a 100644 --- a/src/cappa/subcommand.py +++ b/src/cappa/subcommand.py @@ -101,7 +101,7 @@ def map_result( parsed_args: dict[str, Any], state: State[Any] | None = None, input: TextIO | None = None, - ): + ) -> tuple[Any, dict[Any, Any]]: option_name = parsed_args.pop("__name__") option = self.options[option_name] return option.map_result(option, prog, parsed_args, state=state, input=input)