Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 23 additions & 22 deletions src/cappa/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -176,7 +176,7 @@ def parse(
help_formatter=help_formatter,
state=state,
)
return instance
return result.instance


def invoke(
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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


Expand All @@ -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
Expand All @@ -364,15 +366,14 @@ 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,
backend=concrete_backend,
output=concrete_output,
state=concrete_state,
)
return command, parsed_command, instance, concrete_output, concrete_state # pyright: ignore


class FuncOrClassDecorator(Protocol):
Expand Down
51 changes: 44 additions & 7 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Any,
ClassVar,
Generic,
Hashable,
Iterable,
Protocol,
TextIO,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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] = {}
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 1 addition & 27 deletions src/cappa/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
2 changes: 1 addition & 1 deletion src/cappa/subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading