Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ability to define custom action callable. #27

Merged
merged 1 commit into from
Oct 20, 2023
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
5 changes: 5 additions & 0 deletions docs/source/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@
.. autoapimodule:: cappa.testing
:members: CommandRunner, RunnerArgs
```

```{eval-rst}
.. autoapimodule:: cappa.parser
:members: Value, RawOption
```
70 changes: 70 additions & 0 deletions docs/source/arg.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
DanCardin marked this conversation as resolved.
Show resolved Hide resolved
:noindex:
```

## Environment Variable Fallback

```{eval-rst}
Expand Down
22 changes: 19 additions & 3 deletions src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@

@enum.unique
class ArgAction(enum.Enum):
"""`Arg` action typee.
DanCardin marked this conversation as resolved.
Show resolved Hide resolved

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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
46 changes: 36 additions & 10 deletions src/cappa/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
8 changes: 0 additions & 8 deletions src/cappa/completion/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion src/cappa/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading