Skip to content

Commit

Permalink
Merge pull request #150 from DanCardin/dc/destructure-arg
Browse files Browse the repository at this point in the history
feat: Introduce `Arg.destructure()` and `Arg.has_value`.
  • Loading branch information
DanCardin authored Oct 1, 2024
2 parents 347b8a6 + 554123a commit 49cd483
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 18 deletions.
63 changes: 63 additions & 0 deletions docs/source/arg.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ The `group` argument can be any of:
Argument 'arg1' is not allowed with argument 'arg2'
```

(exclusive-group-syntax)=

### Dedicated Mutual Exclusion Syntax

A potentially common use of mutually exclusive arguments would be two distinct
Expand Down Expand Up @@ -344,3 +346,64 @@ Natively (at present), cappa doesn't have any specific `dict` type inference bec
ambiguous what CLI input shape that ought to map to. However, by combining that with
a dedicated `parse=json.loads` annotation, `example.py '{"foo": "bar"}'` now yields
`Example({'foo': 'bar'})`.

### `Arg.has_value`

`Arg(has_value=True/False)` can be used to explicitly control whether the argument in question
corresponds to a destination-type attribute as a value.

For example the `Arg` that produces the `--help` option has a dedicated action which produces
help text, and as such has a `has_value=False`.

While there may not be much point in manually setting this attribute to `True` (because it will default
to `True` in most cases), you **could** conceivably manually combine `has_value=False` and a
custom [action](#Action), to avoid cappa trying to map your `Arg` back to a specific attribute.

### `Arg.destructured`/`Arg.destructure()`

**Generally** a single class/type corresponds to a command, and that type's attributes correspond to
the arguments/options for that command.

The [exclusive group syntax](#exclusive-group-syntax) is one counter example, where a single
class attribute maps to more than one CLI argument.

"Destructured" arguments are essentially the inverse, in that they allow multiple attributes
(and thus CLI arguments) to be mapped back to a single command's class attribute.

```python
from __future__ import annotations
from typing import Annotated

@dataclass
class Args:
sub_object: Annotated[SubObject, Arg.destructured()]


@dataclass
class SubObject:
arg1: Annotated[str, Arg(long=True)]
arg2: Annotated[int, Arg(long=True)]
```

This essentially fans out the `--arg1=foo --arg2=2` CLI arguments up into the parent `Args` command,
while ultimately mapping the resultant values back into the expected output structure of:
`Args(sub_object=SubObject(arg1='foo', arg2=2))`.

This concept has a couple of practical uses:

- Code/argument reuse: In the above example `SubObject` can now be shared between multiple subcommands
to provide the same set of arguments in different places **without** requiring subclassing.

- Logical grouping/organization: This allows grouping of logically related fields in the **python**
code without affecting how those arguments are represented in the CLI shape.

```{note}
Currently `Arg.destructure()` only works with **singular concrete** type annotations. That is,
in the above example `Annotated[SubObject, Arg.destructured()]`;
whereas it will raise a `ValueError` if given `SubObject | None` or other more exotic annotations.

Principally, `Annotated[SubObject | None, Arg.destructured()]` **could** make sense to imply that all
child options are therefore optional, or that if any child attributes are missing, then that implies
`sub_object=None` at the top level. However both of these are mechanically much more complex than the
feature, as it exists today.
```
48 changes: 43 additions & 5 deletions src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import enum
import typing
from collections.abc import Callable
from typing import Union

from typing_extensions import TypeAlias

from cappa.class_inspect import Field, extract_dataclass_metadata
from cappa.completion.completers import complete_choices
Expand Down Expand Up @@ -71,6 +74,9 @@ def is_bool_action(self):
return self in {self.store_true, self.store_false}


ArgActionType: TypeAlias = Union[ArgAction, Callable]


@dataclasses.dataclass(order=True)
class Group:
order: int = 0
Expand Down Expand Up @@ -128,6 +134,11 @@ class Arg(typing.Generic[T]):
deprecated: If supplied, the argument will be marked as deprecated. If given `True`,
a default message will be generated, otherwise a supplied string will be
used as the deprecation message.
destructured: When set, destructures the annotated type into current-level arguments.
See `Arg.destructure`.
has_value: Whether the argument has a value that should be saved back to the destination
type. For most `Arg`, this will default to `True`, however `--help` is an example
of an `Arg` for which it is false.
"""

value_name: str | EmptyType = Empty
Expand All @@ -141,14 +152,17 @@ class Arg(typing.Generic[T]):
group: str | tuple[int, str] | Group | EmptyType = Empty

hidden: bool = False
action: ArgAction | Callable | None = None
action: ArgActionType | None = None
num_args: int | None = None
choices: list[str] | None = None
completion: Callable[..., list[Completion]] | None = None
required: bool | None = None
field_name: str | EmptyType = Empty
deprecated: bool | str = False

destructured: Destructured | None = None
has_value: bool | None = None

annotations: list[type] = dataclasses.field(default_factory=list)

@classmethod
Expand Down Expand Up @@ -190,15 +204,20 @@ def collect(
default_long=default_long,
exclusive=exclusive,
)
result.append(normalized_arg)

if arg.destructured:
destructured_args = destructure(normalized_arg, type_view)
result.extend(destructured_args)
else:
result.append(normalized_arg)

return list(explode_negated_bool_args(result))

def normalize(
self,
type_view: TypeView | None = None,
fallback_help: str | None = None,
action: ArgAction | Callable | None = None,
action: ArgActionType | None = None,
default: typing.Any = Empty,
field_name: str | None = None,
default_short: bool = False,
Expand Down Expand Up @@ -226,6 +245,7 @@ def normalize(
group = infer_group(self, short, long, exclusive)

value_name = infer_value_name(self, field_name, num_args)
has_value = infer_has_value(self, action)

return dataclasses.replace(
self,
Expand All @@ -242,8 +262,13 @@ def normalize(
help=help,
completion=completion,
group=group,
has_value=has_value,
)

@classmethod
def destructure(cls, settings: Destructured | None = None):
return cls(destructured=settings or Destructured())

def names(self, *, n=0) -> list[str]:
short_names = typing.cast(list, self.short or [])
long_names = typing.cast(list, self.long or [])
Expand Down Expand Up @@ -415,7 +440,7 @@ def infer_choices(arg: Arg, type_view: TypeView) -> list[str] | None:

def infer_action(
arg: Arg, type_view: TypeView, long, default: typing.Any
) -> ArgAction | Callable:
) -> ArgActionType:
if arg.count:
return ArgAction.count

Expand Down Expand Up @@ -458,7 +483,7 @@ def infer_action(
def infer_num_args(
arg: Arg,
type_view: TypeView,
action: ArgAction | Callable,
action: ArgActionType,
long,
) -> int:
if arg.num_args is not None:
Expand Down Expand Up @@ -607,3 +632,16 @@ def explode_negated_bool_args(args: typing.Sequence[Arg]) -> typing.Iterable[Arg

if not yielded:
yield arg


def infer_has_value(arg: Arg, action: ArgActionType):
if arg.has_value is not None:
return arg.has_value

if isinstance(action, ArgAction) and action in ArgAction.value_actions():
return False

return True


from cappa.destructure import Destructured, destructure # noqa: E402
9 changes: 4 additions & 5 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections.abc import Callable

from cappa import class_inspect
from cappa.arg import Arg, ArgAction, Group
from cappa.arg import Arg, Group
from cappa.docstring import ClassHelpText
from cappa.env import Env
from cappa.help import HelpFormatable, HelpFormatter, format_short_help
Expand Down Expand Up @@ -199,7 +199,7 @@ def map_result(self, command: Command[T], prog: str, parsed_args) -> T:
if is_subcommand:
continue

assert arg.default is not Empty
assert arg.default is not Empty, arg
value = arg.default

else:
Expand Down Expand Up @@ -231,9 +231,8 @@ def map_result(self, command: Command[T], prog: str, parsed_args) -> T:

def value_arguments(self):
for arg in self.arguments:
if isinstance(arg, Arg):
if arg.action in ArgAction.value_actions():
continue
if isinstance(arg, Arg) and not arg.has_value:
continue

yield arg

Expand Down
70 changes: 70 additions & 0 deletions src/cappa/destructure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

from dataclasses import dataclass

from type_lens import TypeView

from cappa.arg import Arg, ArgActionType
from cappa.invoke import fulfill_deps
from cappa.output import Output
from cappa.parser import ParseContext, Value, determine_action_handler
from cappa.typing import assert_type


@dataclass
class Destructured: ...


def destructure(arg: Arg, type_view: TypeView):
if not isinstance(type_view.annotation, type):
raise ValueError(
"Destructured arguments currently only support singular concrete types."
)

command: Command = Command.get(type_view.annotation)
virtual_args = Command.collect(command).arguments

arg.parse = lambda v: command.cmd_cls(**v)

result = [arg]
for virtual_arg in virtual_args:
if isinstance(virtual_arg, Subcommand):
raise ValueError(
"Subcommands are unsupported in the context of a destructured argument"
)

assert virtual_arg.action
virtual_arg.action = restructure(arg, virtual_arg.action)
virtual_arg.has_value = False

result.append(virtual_arg)

return result


def restructure(root_arg: Arg, action: ArgActionType):
action_handler = determine_action_handler(action)

def restructure_action(context: ParseContext, arg: Arg, value: Value):
root_field_name = assert_type(root_arg.field_name, str)
result = context.result.setdefault(root_field_name, {})

fulfilled_deps: dict = {
Command: context.command,
Output: context.output,
ParseContext: context,
Arg: arg,
Value: value,
}
kwargs = fulfill_deps(action_handler, fulfilled_deps)
action_result = action_handler(**kwargs)

assert arg.parse
result[arg.field_name] = arg.parse(action_result)
return result

return restructure_action


from cappa.command import Command # noqa: E402
from cappa.subcommand import Subcommand # noqa: E402
3 changes: 3 additions & 0 deletions src/cappa/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def create_version_arg(version: str | Arg | None = None) -> Arg | None:
long=["--version"],
help="Show the version and exit.",
group=(4, "Help"),
action=ArgAction.version,
)

if version.value_name is Empty:
Expand All @@ -66,6 +67,7 @@ def create_help_arg(help: bool | Arg | None = True) -> Arg | None:
long=["--help"],
help="Show this message and exit.",
group=(4, "Help"),
action=ArgAction.help,
)

return help.normalize(action=ArgAction.help, field_name="help", default=None)
Expand All @@ -81,6 +83,7 @@ def create_completion_arg(completion: bool | Arg = True) -> Arg | None:
choices=["generate", "complete"],
group=(4, "Help"),
help="Use `--completion generate` to print shell-specific completion source.",
action=ArgAction.completion,
)

return completion.normalize(
Expand Down
20 changes: 12 additions & 8 deletions src/cappa/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import typing
from collections import deque

from cappa.arg import Arg, ArgAction, Group
from cappa.arg import Arg, ArgAction, ArgActionType, Group
from cappa.command import Command, Subcommand
from cappa.completion.types import Completion, FileCompletion
from cappa.help import format_subcommand_names
Expand Down Expand Up @@ -180,6 +180,7 @@ def collect_arguments(command: Command) -> list[Arg | Subcommand]:
isinstance(arg, Arg)
and not arg.short
and not arg.long
and not arg.destructured
or isinstance(arg, Subcommand)
):
result.append(arg)
Expand Down Expand Up @@ -576,13 +577,7 @@ def consume_arg(
if option and field_name in context.missing_options:
context.missing_options.remove(field_name)

action = arg.action
assert action

if isinstance(action, ArgAction):
action_handler = process_options[action]
else:
action_handler = action
action_handler = determine_action_handler(arg.action)

fulfilled_deps: dict = {
Command: context.command,
Expand Down Expand Up @@ -651,6 +646,15 @@ def store_append(context: ParseContext, arg: Arg, value: Value[typing.Any]):
return result


def determine_action_handler(action: ArgActionType | None):
assert action

if isinstance(action, ArgAction):
return process_options[action]

return action


process_options: dict[ArgAction, typing.Callable] = {
ArgAction.help: HelpAction.from_context,
ArgAction.version: VersionAction.from_arg,
Expand Down
Loading

0 comments on commit 49cd483

Please sign in to comment.