Skip to content

Commit

Permalink
feat: Split name and field_name on Arg object.
Browse files Browse the repository at this point in the history
  • Loading branch information
DanCardin committed Oct 24, 2023
1 parent 60a3fdc commit 3c12e70
Show file tree
Hide file tree
Showing 17 changed files with 124 additions and 73 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.10.0

- Split Arg `name`/`field_name`. `name` controls help/error output naming.
`field_name` controls the the destination field on the dataclass object.

## 0.9.3

- Ensure output of missing required options is deterministically ordered
Expand Down
4 changes: 2 additions & 2 deletions docs/source/manual_construction.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ class Foo:
command = cappa.Command(
Foo,
arguments=[
cappa.Arg(name="bar", parse=str),
cappa.Arg(name="baz", parse=parse_list(int), num_args=-1),
cappa.Arg(field_name="bar", parse=str),
cappa.Arg(field_name="baz", parse=parse_list(int), num_args=-1),
],
help="Short help.",
description="Long description.",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cappa"
version = "0.9.3"
version = "0.10.0"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down
36 changes: 24 additions & 12 deletions src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ class Arg(typing.Generic[T]):
"""Describe a CLI argument.
Arguments:
name: The name of the argument. Defaults to the name of the corresponding class field.
value_name: Placeholder for the argument's value in the help message / usage.
Defaults to the name of the corresponding class field.
short: If `True`, uses first letter of the name to infer a (ex. `-s`) short
flag. If a string is supplied, that will be used instead. If a string is supplied,
it is split on '/' (forward slash), to support multiple options. Additionally
Expand Down Expand Up @@ -96,9 +97,12 @@ class Arg(typing.Generic[T]):
required: Defaults to automatically inferring requiredness, based on whether the
class's value has a default. By setting this, you can force a particular value.
field_name: The name of the class field to populate with this arg. In most usecases,
this field should be left unspecified and automatically inferred.
"""

name: str | MISSING = missing
value_name: str | MISSING = missing
short: bool | str | list[str] = False
long: bool | str | list[str] = False
count: bool = False
Expand All @@ -116,6 +120,8 @@ class Arg(typing.Generic[T]):

required: bool | None = None

field_name: str | MISSING = missing

@classmethod
def collect(
cls, field: Field, type_hint: type, fallback_help: str | None = None
Expand All @@ -138,26 +144,31 @@ def collect(
if field_metadata:
arg = field_metadata

name = infer_name(arg, field)
field_name = infer_field_name(arg, field)
default = infer_default(arg, field)

arg = dataclasses.replace(arg, name=name, default=default)
arg = dataclasses.replace(arg, field_name=field_name, default=default)
return arg.normalize(annotation, fallback_help=fallback_help)

def normalize(
self,
annotation=NoneType,
fallback_help: str | None = None,
action: ArgAction | Callable | None = None,
name: str | None = None,
value_name: str | None = None,
field_name: str | None = None,
) -> Arg:
origin = typing.get_origin(annotation) or annotation
type_args = typing.get_args(annotation)
required = infer_required(self, origin, self.default)

name = typing.cast(str, name or self.name)
short = infer_short(self, name)
long = infer_long(self, origin, name)
field_name = typing.cast(str, field_name or self.field_name)
value_name = value_name or (
self.value_name if self.value_name is not missing else field_name
)

short = infer_short(self, field_name)
long = infer_long(self, origin, field_name)
choices = infer_choices(self, origin, type_args)
action = action or infer_action(self, origin, type_args, long, self.default)
num_args = infer_num_args(self, origin, type_args, action, long)
Expand All @@ -170,7 +181,8 @@ def normalize(

return dataclasses.replace(
self,
name=name,
field_name=field_name,
value_name=value_name,
required=required,
short=short,
long=long,
Expand All @@ -195,11 +207,11 @@ def names_str(self, delimiter: str = ", ", *, n=0) -> str:
if self.long or self.short:
return delimiter.join(self.names(n=n))

return typing.cast(str, self.name)
return typing.cast(str, self.value_name)


def infer_name(arg: Arg, field: Field) -> str:
if not isinstance(arg.name, MISSING):
def infer_field_name(arg: Arg, field: Field) -> str:
if not isinstance(arg.field_name, MISSING):
raise ValueError("Arg 'name' cannot be set when using automatic inference.")

return field.name
Expand Down
12 changes: 5 additions & 7 deletions src/cappa/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
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
from cappa.typing import assert_type, missing

if sys.version_info < (3, 9): # pragma: no cover
# Backport https://github.com/python/cpython/pull/3680
Expand Down Expand Up @@ -153,7 +153,7 @@ def backend(
for a in command.arguments
if isinstance(a, Arg) and a.action is ArgAction.version
)
parser.version = version.name # type: ignore
parser.version = version.value_name # type: ignore
except StopIteration:
pass

Expand Down Expand Up @@ -210,8 +210,6 @@ def add_argument(
dest_prefix="",
**extra_kwargs,
):
name: str = assert_not_missing(arg.name)

names: list[str] = []
if arg.short:
short = assert_type(arg.short, list)
Expand All @@ -226,9 +224,9 @@ def add_argument(
num_args = backend_num_args(arg.num_args)

kwargs: dict[str, typing.Any] = {
"dest": dest_prefix + name,
"dest": dest_prefix + arg.field_name,
"help": arg.help,
"metavar": name,
"metavar": arg.value_name,
"action": get_action(arg),
}

Expand Down Expand Up @@ -260,7 +258,7 @@ def add_subcommands(
subcommands: Subcommand,
dest_prefix="",
):
subcommand_dest = subcommands.name
subcommand_dest = subcommands.field_name
subparsers = parser.add_subparsers(
title=group,
required=assert_type(subcommands.required, bool),
Expand Down
10 changes: 4 additions & 6 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,7 @@ def collect(cls, command: Command[T]) -> Command[T]:
type_hint = type_hints[field.name]
arg_help = arg_help_map.get(field.name)

maybe_subcommand = Subcommand.collect(
field, type_hint, fallback_help=arg_help
)
maybe_subcommand = Subcommand.collect(field, type_hint)
if maybe_subcommand:
arguments.append(maybe_subcommand)
else:
Expand Down Expand Up @@ -143,13 +141,13 @@ def map_result(self, command: Command[T], parsed_args) -> T:
kwargs = {}
for arg in self.value_arguments():
is_subcommand = isinstance(arg, Subcommand)
if arg.name not in parsed_args:
if arg.field_name not in parsed_args:
if is_subcommand:
continue

value = arg.default
else:
value = parsed_args[arg.name]
value = parsed_args[arg.field_name]

if isinstance(value, Env):
value = value.evaluate()
Expand All @@ -170,7 +168,7 @@ def map_result(self, command: Command[T], parsed_args) -> T:
code=2,
)

kwargs[arg.name] = value
kwargs[arg.field_name] = value

return command.cmd_cls(**kwargs)

Expand Down
24 changes: 14 additions & 10 deletions src/cappa/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,22 @@ def create_version_arg(version: str | Arg | None = None) -> Arg | None:

if isinstance(version, str):
version = Arg(
version,
value_name=version,
short=["-v"],
long=["--version"],
help="Show the version and exit.",
group=(4, "Help"),
)

if version.name is missing:
if version.value_name is missing:
raise ValueError(
"Expected explicit version `Arg` to supply version number as its name, like `Arg('1.2.3', ...)`"
)

if version.long is True:
version.long = "--version"

return version.normalize(action=ArgAction.version)
return version.normalize(action=ArgAction.version, field_name="version")


def create_help_arg(help: bool | Arg | None = True) -> Arg | None:
Expand All @@ -51,14 +51,13 @@ def create_help_arg(help: bool | Arg | None = True) -> Arg | None:

if isinstance(help, bool):
help = Arg(
name="help",
short=["-h"],
long=["--help"],
help="Show this message and exit.",
group=(4, "Help"),
)

return help.normalize(action=ArgAction.help, name="help")
return help.normalize(action=ArgAction.help, field_name="help")


def create_completion_arg(completion: bool | Arg = True) -> Arg | None:
Expand All @@ -67,14 +66,14 @@ def create_completion_arg(completion: bool | Arg = True) -> Arg | None:

if isinstance(completion, bool):
return Arg(
name="completion",
field_name="completion",
long=["--completion"],
choices=["generate", "complete"],
group=(4, "Help"),
help="Use `--completion generate` to print shell-specific completion source.",
).normalize(action=ArgAction.completion)

return completion.normalize(name="completion", action=ArgAction.completion)
return completion.normalize(field_name="completion", action=ArgAction.completion)


def format_help(command: Command, prog: str) -> list[Displayable]:
Expand Down Expand Up @@ -142,13 +141,18 @@ def add_long_args(arg_groups: list[ArgGroup]) -> list:

def format_arg_name(arg: Arg | Subcommand, delimiter, *, n=0) -> str:
if isinstance(arg, Arg):
is_option = arg.short or arg.long
has_value = arg.action not in no_extra_arg_actions

arg_names = arg.names_str(delimiter, n=n)
if not is_option:
arg_names = arg_names.replace(" ", "-").upper()

text = f"[cappa.arg]{arg_names}[/cappa.arg]"

is_option = arg.short or arg.long
has_value = arg.action not in no_extra_arg_actions
if is_option and has_value:
text = f"{text} [cappa.arg.name]{arg.name.upper()}[/cappa.arg.name]"
name = arg.value_name.replace(" ", "-").upper()
text = f"{text} [cappa.arg.name]{name}[/cappa.arg.name]"

if not arg.required:
return rf"\[{text}]"
Expand Down
2 changes: 1 addition & 1 deletion src/cappa/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def resolve_implicit_deps(command: Command, instance: HasCommand) -> dict:
# Args do not produce dependencies themselves.
continue

option_instance = getattr(instance, arg.name)
option_instance = getattr(instance, arg.field_name)
if option_instance is None:
# None is a valid subcommand instance value, but it wont exist as a dependency
# where an actual command has been selected.
Expand Down
20 changes: 10 additions & 10 deletions src/cappa/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def backend(
except HelpAction as e:
raise HelpExit(format_help(e.command, e.command_name), code=0)
except VersionAction as e:
raise Exit(e.version.name, code=0)
raise Exit(e.version.value_name, code=0)
except BadArgumentError as e:
if context.provide_completions and e.arg:
completions = e.arg.completion(e.value) if e.arg.completion else []
Expand Down Expand Up @@ -146,8 +146,8 @@ def collect_options(command: Command) -> tuple[dict[str, Arg], set[str]]:

if arg.short or arg.long:
if arg.action not in ArgAction.value_actions():
unique_names.add(arg.name)
result[arg.name] = arg
unique_names.add(arg.field_name)
result[arg.field_name] = arg

assert arg.short is not True
for short in arg.short or []:
Expand Down Expand Up @@ -426,7 +426,7 @@ def consume_subcommand(context: ParseContext, arg: Subcommand) -> typing.Any:

parse(nested_context)

name = typing.cast(str, arg.name)
name = typing.cast(str, arg.field_name)
context.result[name] = nested_context.result


Expand Down Expand Up @@ -491,14 +491,14 @@ def consume_arg(
return

raise BadArgumentError(
f"Option '{arg.name}' requires an argument.",
f"Option '{arg.value_name}' requires an argument.",
value="",
command=context.command,
arg=arg,
)

if option and arg.name in context.missing_options:
context.missing_options.remove(arg.name)
if option and arg.value_name in context.missing_options:
context.missing_options.remove(arg.value_name)

action = arg.action
assert action
Expand All @@ -518,7 +518,7 @@ def consume_arg(
fullfilled_deps[RawOption] = option

kwargs = fullfill_deps(action_handler, fullfilled_deps)
context.result[arg.name] = action_handler(**kwargs)
context.result[arg.value_name] = action_handler(**kwargs)


@dataclasses.dataclass
Expand All @@ -539,15 +539,15 @@ def store(arg: Arg, option: RawOption):


def store_count(context: ParseContext, arg: Arg):
return context.result.get(arg.name, 0) + 1
return context.result.get(arg.value_name, 0) + 1


def store_set(value: Value[typing.Any]):
return value.value


def store_append(context: ParseContext, arg: Arg, value: Value[typing.Any]):
result = context.result.setdefault(arg.name, [])
result = context.result.setdefault(arg.value_name, [])
result.append(value.value)
return result

Expand Down
Loading

0 comments on commit 3c12e70

Please sign in to comment.