diff --git a/docs/source/api.md b/docs/source/api.md index d861a21..74db470 100644 --- a/docs/source/api.md +++ b/docs/source/api.md @@ -9,3 +9,8 @@ .. autoapimodule:: cappa.testing :members: CommandRunner, RunnerArgs ``` + +```{eval-rst} +.. autoapimodule:: cappa.parser + :members: Value, RawOption +``` diff --git a/docs/source/arg.md b/docs/source/arg.md index 227084a..2d39099 100644 --- a/docs/source/arg.md +++ b/docs/source/arg.md @@ -46,6 +46,76 @@ can be used and are interpreted to handle different kinds of CLI input. :noindex: ``` +## Action + +Obliquely referenced through other `Arg` options like `count`, every `Arg` has a +corrresponding "action". The action is automatically inferred, most of the time, +based on other options (i.e. `count`), the annotated type (i.e. `bool` -> +`ArgAction.set_true/ArgAction.set_false`, `list` -> `ArgAction.append`), etc. + +However the inferred action can always be directly set, in order to override the +default inferred behavior. + +### Custom Actions + +```{note} +This feature is currently experimental, in particular because the parser state +available to either backend's callable is radically different. However, for an +action callable which accepts no arguments, behaviors is unlikely to change. +``` + +In addition to one of the literal `ArgAction` variants, the provided action can +be given as an arbitrary callable. + +The callable will be called as the parser "action" in response to parsing that +argument. + +Similarly to the [invoke][./invoke.md] system, you can use the type system to +automatically inject objects of supported types from the parse context into the +function in question. The return value of the function will be used as the +result of parsing that particular argument. + +The set of available objects to inject include: + +- [Command](cappa.Command): The command currently being parsed (a relevant piece + of context when using subcommands) +- [Arg](cappa.Arg): The argument being parsed. +- [Value](cappa.parser.Value): The raw input value parsed in the context of the + argument. Depending on other settings, this may be a list (when num_args > 1), + or a raw value otherwise. +- [RawOption](cappa.parser.RawOption): In the event the value in question + corresponds to an option value, the representation of that option from the + original input. + +The above set of objects is of potentially limited value. More parser state will +likely be exposed through this interface in the future. + +For example: + +```python +def example(): + return 'foo!' + +def example(arg: Arg): + return 'foo!' + +def example2(value: Value): + return value.value[1:] + + +@dataclass +class Example: + ex1: Annotated[str, cappa.Arg(action=example) + ex2: Annotated[str, cappa.Arg(action=example2) + ex3: Annotated[str, cappa.Arg(action=example3) +``` + +```{eval-rst} +.. autoapimodule:: cappa + :members: Argction + :noindex: +``` + ## Environment Variable Fallback ```{eval-rst} diff --git a/src/cappa/arg.py b/src/cappa/arg.py index 6189896..4dcbfe5 100644 --- a/src/cappa/arg.py +++ b/src/cappa/arg.py @@ -25,6 +25,22 @@ @enum.unique class ArgAction(enum.Enum): + """`Arg` action typee. + + Options: + set: Stores the given CLI value directly. + store_true: Stores a literal `True` value, causing options to not attempt to + consume additional CLI arguments + store_false: Stores a literal `False` value, causing options to not attempt to + consume additional CLI arguments + append: Produces a list, and accumulates the given value on top of prior values. + count: Increments an integer starting at 0 + + help: Cancels argument parsing and prints the help text + version: Cancels argument parsing and prints the CLI version + completion: Cancels argument parsing and enters "completion mode" + """ + set = "store" store_true = "store_true" store_false = "store_false" @@ -94,7 +110,7 @@ class Arg(typing.Generic[T]): group: str | tuple[int, str] | MISSING = missing hidden: bool = False - action: ArgAction | None = None + action: ArgAction | Callable | None = None num_args: int | None = None choices: list[str] | None = None completion: Callable[..., list[Completion]] | None = None @@ -133,7 +149,7 @@ def normalize( self, annotation=NoneType, fallback_help: str | None = None, - action: ArgAction | None = None, + action: ArgAction | Callable | None = None, name: str | None = None, ) -> Arg: origin = typing.get_origin(annotation) or annotation @@ -265,7 +281,7 @@ def infer_choices( def infer_action( arg: Arg, origin: type, type_args: tuple[type, ...], long, default: typing.Any -) -> ArgAction: +) -> ArgAction | Callable: if arg.count: return ArgAction.count diff --git a/src/cappa/argparse.py b/src/cappa/argparse.py index de0f234..3f3f895 100644 --- a/src/cappa/argparse.py +++ b/src/cappa/argparse.py @@ -3,13 +3,16 @@ import argparse import sys import typing +from collections.abc import Callable from typing_extensions import assert_never from cappa.arg import Arg, ArgAction from cappa.command import Command, Subcommand from cappa.help import create_help_arg, create_version_arg, generate_arg_groups +from cappa.invoke import fullfill_deps from cappa.output import Exit, HelpExit +from cappa.parser import RawOption, Value from cappa.typing import assert_not_missing, assert_type, missing if sys.version_info < (3, 9): # pragma: no cover @@ -101,6 +104,26 @@ def format_usage(self): return " | ".join(self.option_strings) +def custom_action(arg: Arg, action: Callable): + class CustomAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + # XXX: This should ideally be able to inject parser state, but here, we dont + # have access to the same state as the native parser. + fullfilled_deps: dict = { + Value: Value(values), + Command: namespace.__command__, + Arg: arg, + } + if option_string: + fullfilled_deps[RawOption] = RawOption.from_str(option_string) + + deps = fullfill_deps(action, fullfilled_deps) + result = action(**deps) + setattr(namespace, self.dest, result) + + return CustomAction + + class Nestedspace(argparse.Namespace): """Write each . separated section as a nested `Nestedspace` instance. @@ -232,13 +255,13 @@ def add_argument( kwargs["required"] = arg.required if arg.default is not missing: - if arg.action and arg.action is arg.action.append: + if arg.action and arg.action is ArgAction.append: kwargs["default"] = list(arg.default) # type: ignore else: kwargs["default"] = arg.default if num_args and ( - arg.action and arg.action not in {arg.action.store_true, arg.action.store_false} + arg.action and arg.action not in {ArgAction.store_true, ArgAction.store_false} ): kwargs["nargs"] = num_args elif is_positional and not arg.required: @@ -310,12 +333,15 @@ def join_help(*segments): return " ".join([s for s in segments if s]) -def get_action(arg: Arg) -> type[argparse.Action] | str: - action = assert_type(arg.action, ArgAction) - if action in {ArgAction.store_true, ArgAction.store_false}: - long = assert_type(arg.long, list) - has_no_option = any("--no-" in i for i in long) - if has_no_option: - return BooleanOptionalAction +def get_action(arg: Arg) -> argparse.Action | type[argparse.Action] | str: + action = arg.action + if isinstance(action, ArgAction): + if action in {ArgAction.store_true, ArgAction.store_false}: + long = assert_type(arg.long, list) + has_no_option = any("--no-" in i for i in long) + if has_no_option: + return BooleanOptionalAction + return action.value - return action.value + action = typing.cast(Callable, action) + return custom_action(arg, action) diff --git a/src/cappa/completion/types.py b/src/cappa/completion/types.py index b2d09c0..7be593f 100644 --- a/src/cappa/completion/types.py +++ b/src/cappa/completion/types.py @@ -34,11 +34,3 @@ class Completion: @dataclasses.dataclass class FileCompletion: text: str - - -class CompletionError(RuntimeError): - def __init__( - self, *completions: Completion | FileCompletion, value="complete", **_ - ) -> None: - self.completions = completions - self.value = value diff --git a/src/cappa/invoke.py b/src/cappa/invoke.py index 6fdf06e..a81b276 100644 --- a/src/cappa/invoke.py +++ b/src/cappa/invoke.py @@ -148,7 +148,7 @@ def resolve_implicit_deps(command: Command, instance: HasCommand) -> dict: return deps -def fullfill_deps(fn: Callable, fullfilled_deps: dict, call: bool = True) -> typing.Any: +def fullfill_deps(fn: Callable, fullfilled_deps: dict) -> typing.Any: result = {} signature = inspect.signature(fn) @@ -170,6 +170,8 @@ def fullfill_deps(fn: Callable, fullfilled_deps: dict, call: bool = True) -> typ dep = object_annotation.obj annotation = object_annotation.annotation + annotation = typing.get_origin(annotation) or annotation + if dep is None: # Non-annotated args are either implicit dependencies (and thus already fullfilled), # or arguments that we cannot fullfill diff --git a/src/cappa/parser.py b/src/cappa/parser.py index e476248..0a3d36f 100644 --- a/src/cappa/parser.py +++ b/src/cappa/parser.py @@ -7,13 +7,14 @@ from cappa.arg import Arg, ArgAction, no_extra_arg_actions from cappa.command import Command, Subcommand -from cappa.completion.types import Completion, CompletionError, FileCompletion +from cappa.completion.types import Completion, FileCompletion from cappa.help import ( create_completion_arg, create_help_arg, create_version_arg, format_help, ) +from cappa.invoke import fullfill_deps from cappa.output import Exit, HelpExit from cappa.typing import T, assert_type @@ -33,14 +34,34 @@ def __init__( self.arg = arg +@dataclasses.dataclass class HelpAction(RuntimeError): - def __init__(self, *, command, **_): - self.command = command + command: Command + + @classmethod + def from_command(cls, command: Command): + raise cls(command) +@dataclasses.dataclass class VersionAction(RuntimeError): - def __init__(self, *, arg, **_): - self.version = arg + version: Arg + + @classmethod + def from_arg(cls, arg: Arg): + raise cls(arg) + + +class CompletionAction(RuntimeError): + def __init__( + self, *completions: Completion | FileCompletion, value="complete", **_ + ) -> None: + self.completions = completions + self.value = value + + @classmethod + def from_value(cls, value: Value[str]): + raise cls(value=value.value) def backend( @@ -80,11 +101,11 @@ def backend( except BadArgumentError as e: if context.provide_completions and e.arg: completions = e.arg.completion(e.value) if e.arg.completion else [] - raise CompletionError(*completions) + raise CompletionAction(*completions) format_help(e.command, prog) raise Exit(str(e), code=2) - except CompletionError as e: + except CompletionAction as e: from cappa.completion.base import execute, format_completions if context.provide_completions: @@ -282,7 +303,7 @@ def parse_option(context: ParseContext, raw: RawOption) -> None: Completion(option, help=context.options[option].help) for option in possible_values ] - raise CompletionError(*options) + raise CompletionAction(*options) message = f"Unrecognized arguments: {raw.name}" if possible_values: @@ -373,6 +394,41 @@ def parse_args(context: ParseContext) -> None: ) +def consume_subcommand(context: ParseContext, arg: Subcommand) -> typing.Any: + try: + value = context.next_value() + except IndexError: + if not arg.required: + return + + raise BadArgumentError( + f"The following arguments are required: {arg.names_str()}", + value="", + command=context.command, + arg=arg, + ) + + assert isinstance(value, RawArg), value + if value.raw not in arg.options: + raise BadArgumentError( + "invalid subcommand", value=value.raw, command=context.command, arg=arg + ) + + command = arg.options[value.raw] + context.selected_command = command + + nested_context = ParseContext.from_command(command, context.remaining_args) + nested_context.provide_completions = context.provide_completions + nested_context.result["__name__"] = value.raw + + parse(nested_context) + + name = typing.cast(str, arg.name) + context.result[name] = nested_context.result + if nested_context.selected_command: + context.selected_command = nested_context.selected_command + + def consume_arg( context: ParseContext, arg: Arg, option: RawOption | None = None ) -> typing.Any: @@ -428,7 +484,7 @@ def consume_arg( ] = arg.completion(result) else: completions = [FileCompletion(result)] - raise CompletionError(*completions) + raise CompletionAction(*completions) else: if not option and not arg.required: return @@ -443,90 +499,63 @@ def consume_arg( if option and arg.name in context.missing_options: context.missing_options.remove(arg.name) - action = typing.cast(ArgAction, arg.action) - name = typing.cast(str, arg.name) - action_handler = process_options[action] - existing_result = context.result.get(name) - context.result[name] = action_handler( - command=context.command, - arg=arg, - option_name=option and option.name, - existing=existing_result, - value=result, - ) - - -def consume_subcommand(context: ParseContext, arg: Subcommand) -> typing.Any: - try: - value = context.next_value() - except IndexError: - if not arg.required: - return - - raise BadArgumentError( - f"The following arguments are required: {arg.names_str()}", - value="", - command=context.command, - arg=arg, - ) - - assert isinstance(value, RawArg), value - if value.raw not in arg.options: - raise BadArgumentError( - "invalid subcommand", value=value.raw, command=context.command, arg=arg - ) - - command = arg.options[value.raw] - context.selected_command = command - - nested_context = ParseContext.from_command(command, context.remaining_args) - nested_context.provide_completions = context.provide_completions - nested_context.result["__name__"] = value.raw + action = arg.action + assert action - parse(nested_context) + if isinstance(action, ArgAction): + action_handler = process_options[action] + else: + action_handler = action - name = typing.cast(str, arg.name) - context.result[name] = nested_context.result - if nested_context.selected_command: - context.selected_command = nested_context.selected_command + fullfilled_deps: dict = { + Command: context.command, + ParseContext: context, + Arg: arg, + Value: Value(result), + } + if option: + fullfilled_deps[RawOption] = option + kwargs = fullfill_deps(action_handler, fullfilled_deps) + context.result[arg.name] = action_handler(**kwargs) -def raises(exc): - def store(**value): - raise exc(**value) - return store +@dataclasses.dataclass +class Value(typing.Generic[T]): + value: T def store_bool(val: bool): - def store(arg, option_name: str, **_): + def store(arg: Arg, option: RawOption): long = assert_type(arg.long, list) has_no_option = any("--no-" in i for i in long) if has_no_option: - return not option_name.startswith("--no-") + return not option.name.startswith("--no-") + return val return store -def store_count(*, existing: int | None, **_): - return (existing or 0) + 1 +def store_count(context: ParseContext, arg: Arg): + return context.result.get(arg.name, 0) + 1 -def store_set(*, value, **_): - return value +def store_set(value: Value[typing.Any]): + return value.value -def store_append(*, existing, value, **_): - return (existing or []) + [value] +def store_append(context: ParseContext, arg: Arg, value: Value[typing.Any]): + result = context.result.setdefault(arg.name, []) + result.append(value.value) + return result process_options = { - ArgAction.help: raises(HelpAction), - ArgAction.version: raises(VersionAction), - ArgAction.completion: raises(CompletionError), + ArgAction.help: HelpAction.from_command, + ArgAction.version: VersionAction.from_arg, + ArgAction.completion: CompletionAction.from_value, ArgAction.set: store_set, - None: store_set, ArgAction.store_true: store_bool(True), ArgAction.store_false: store_bool(False), ArgAction.count: store_count, diff --git a/tests/parser/test_custom_callable_action.py b/tests/parser/test_custom_callable_action.py index 51d47d0..7dc124c 100644 --- a/tests/parser/test_custom_callable_action.py +++ b/tests/parser/test_custom_callable_action.py @@ -1,21 +1,85 @@ from __future__ import annotations from dataclasses import dataclass -from typing import List, Union +from typing import Union import cappa +import pytest +from cappa.parser import Value from typing_extensions import Annotated from tests.utils import backends, parse +################################ +def exit(): + raise cappa.Exit("message") + + +@backends +def test_callable_action_fast_exits(backend): + @dataclass + class Args: + value: Annotated[str, cappa.Arg(action=exit, short=True)] + + with pytest.raises(cappa.Exit) as e: + parse(Args, "-v", "the", "rest", "is", "trash", backend=backend) + assert e.value.message == "message" + + +################################ +def truncate(value: Value[str]): + return value.value[1:] + + +@backends +def test_uses_return_value(backend): + @dataclass + class Args: + value: Annotated[str, cappa.Arg(action=truncate, short=True)] + + args = parse(Args, "-v", "okay", backend=backend) + assert args.value == "kay" + + @backends -def test_callable_action_fast_exit(backend): +def test_custom_arg(backend): @dataclass class Args: - foo: str - raw: Annotated[Union[List[str], None], cappa.Arg(num_args=-1)] = None + value: Annotated[str, cappa.Arg(action=truncate)] + + args = parse(Args, "okay", backend=backend) + assert args.value == "kay" + + +################################ +def command_name(command: cappa.Command): + return command.real_name() + + +@dataclass +class SubBSub: + value: Annotated[str, cappa.Arg(action=command_name, short=True)] + + +@dataclass +class SubA: + value: Annotated[str, cappa.Arg(action=command_name, short=True)] + + +@dataclass +class SubB: + cmd: cappa.Subcommands[SubBSub] + + +@backends +def test_subcommand_name(backend): + @dataclass + class Args: + cmd: cappa.Subcommands[Union[SubA, SubB]] + + args = parse(Args, "sub-a", "-v", "one", backend=backend) + assert args.cmd.value == "sub-a" - test = parse(Args, "foovalue", "--", "--raw", "value", backend=backend) - assert test.foo == "foovalue" - assert test.raw == ["--raw", "value"] + args = parse(Args, "sub-b", "sub-b-sub", "-v", "one", backend=backend) + assert args.cmd.cmd.value == "sub-b-sub"