Skip to content

Commit

Permalink
Add dcargs.conf.OmitSubcommandPrefixes, version bump
Browse files Browse the repository at this point in the history
  • Loading branch information
brentyi committed Oct 3, 2022
1 parent 242b1c5 commit 50b114e
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 24 deletions.
60 changes: 40 additions & 20 deletions dcargs/_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,12 @@
from backports.cached_property import cached_property # type: ignore


class _PatchedList(list):
"""Custom tuple type, for avoiding "default not in choices" errors when the default
is set to MISSING_NONPROP.
This solves a choices error raised by argparse in a very specific edge case:
literals in containers as positional arguments."""

def __init__(self, li):
super(_PatchedList, self).__init__(li)

def __contains__(self, x: Any) -> bool:
return list.__contains__(self, x) or x is _fields.MISSING_NONPROP


@dataclasses.dataclass(frozen=True)
class ArgumentDefinition:
"""Structure containing everything needed to define an argument."""

prefix: str # Prefix for nesting.
subcommand_prefix: str # Prefix for nesting.
field: _fields.FieldDefinition
type_from_typevar: Dict[TypeVar, Type]

Expand All @@ -77,10 +64,7 @@ def add_argument(
# the field default to a string format, then back to the desired type.
kwargs["default"] = _fields.MISSING_NONPROP

if "choices" in kwargs:
kwargs["choices"] = _PatchedList(kwargs["choices"])

# Note that the name must be passed in as a position argument.
# Add argument! Note that the name must be passed in as a position argument.
arg = parser.add_argument(name_or_flag, **kwargs)

# Do our best to tab complete paths.
Expand Down Expand Up @@ -119,8 +103,9 @@ def lowered(self) -> LoweredArgumentDefinition:
_rule_recursive_instantiator_from_type,
_rule_convert_defaults_to_strings,
_rule_generate_helptext,
_rule_set_name_or_flag,
_rule_set_name_or_flag_and_dest,
_rule_positional_special_handling,
_rule_static_cast_choices_to_patched_list,
)
return functools.reduce(
lambda lowered, rule: rule(self, lowered),
Expand Down Expand Up @@ -362,19 +347,30 @@ def _rule_generate_helptext(
return dataclasses.replace(lowered, help=" ".join(help_parts))


def _rule_set_name_or_flag(
def _rule_set_name_or_flag_and_dest(
arg: ArgumentDefinition,
lowered: LoweredArgumentDefinition,
) -> LoweredArgumentDefinition:
# Positional arguments: no -- prefix.
if arg.field.is_positional():
name_or_flag = _strings.make_field_name([arg.prefix, arg.field.name])
# Negated booleans.
elif lowered.action == "store_false":
name_or_flag = "--" + _strings.make_field_name(
[arg.prefix, "no-" + arg.field.name]
)
# Prefix keyword arguments with --.
else:
name_or_flag = "--" + _strings.make_field_name([arg.prefix, arg.field.name])

# Strip.
if name_or_flag.startswith("--") and arg.subcommand_prefix != "":
# This will run even when unused because we want the assert.
strip_prefix = "--" + arg.subcommand_prefix + "."
assert name_or_flag.startswith(strip_prefix)
if _markers.OMIT_SUBCOMMAND_PREFIXES in arg.field.markers:
name_or_flag = "--" + name_or_flag[len(strip_prefix) :]

return dataclasses.replace(
lowered,
name_or_flag=name_or_flag,
Expand Down Expand Up @@ -410,3 +406,27 @@ def _rule_positional_special_handling(
metavar=metavar,
nargs=nargs,
)


class _PatchedList(list):
"""Custom list type, for avoiding "default not in choices" errors when the default
is set to MISSING_NONPROP.
This solves a choices error raised by argparse in a very specific edge case:
literals in containers as positional arguments."""

def __init__(self, li):
super(_PatchedList, self).__init__(li)

def __contains__(self, x: Any) -> bool:
return list.__contains__(self, x) or x is _fields.MISSING_NONPROP


def _rule_static_cast_choices_to_patched_list(
arg: ArgumentDefinition,
lowered: LoweredArgumentDefinition,
) -> LoweredArgumentDefinition:
return dataclasses.replace(
lowered,
choices=_PatchedList(lowered.choices) if lowered.choices is not None else None,
)
11 changes: 9 additions & 2 deletions dcargs/_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import itertools
from typing import Any, Callable, Dict, List, Optional, Set, Type, TypeVar, Union, cast

from typing_extensions import get_args, get_origin
from typing_extensions import Annotated, get_args, get_origin

from . import (
_argparse_formatter,
Expand Down Expand Up @@ -44,6 +44,7 @@ def from_callable_or_type(
T, _fields.PropagatingMissingType, _fields.NonpropagatingMissingType
],
prefix: str,
subcommand_prefix: str = "",
) -> ParserSpecification:
"""Create a parser definition from a callable or type."""

Expand Down Expand Up @@ -131,6 +132,7 @@ def from_callable_or_type(
parent_type_from_typevar=type_from_typevar,
default_instance=field.default,
prefix=_strings.make_field_name([prefix, field.name]),
subcommand_prefix=subcommand_prefix,
)
args.extend(nested_parser.args)

Expand Down Expand Up @@ -159,6 +161,7 @@ def from_callable_or_type(
# (3) Handle primitive or fixed types. These produce a single argument!
arg = _arguments.ArgumentDefinition(
prefix=prefix,
subcommand_prefix=subcommand_prefix,
field=field,
type_from_typevar=type_from_typevar,
)
Expand Down Expand Up @@ -305,12 +308,16 @@ def from_field(
)

subparser = ParserSpecification.from_callable_or_type(
option,
# Recursively apply markers.
Annotated.__class_getitem__((option,) + tuple(field.markers)) # type: ignore
if len(field.markers) > 0
else option,
description=found_subcommand_configs[0].description,
parent_classes=parent_classes,
parent_type_from_typevar=type_from_typevar,
default_instance=found_subcommand_configs[0].default,
prefix=prefix,
subcommand_prefix=prefix,
)

# Apply prefix to helptext in nested classes in subparsers.
Expand Down
10 changes: 9 additions & 1 deletion dcargs/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@
Features here are supported, but generally unnecessary and should be used sparingly.
"""

from ._markers import AvoidSubcommands, Fixed, FlagConversionOff, Positional, Suppress
from ._markers import (
AvoidSubcommands,
Fixed,
FlagConversionOff,
OmitSubcommandPrefixes,
Positional,
Suppress,
)
from ._subcommands import subcommand

__all__ = [
"AvoidSubcommands",
"Fixed",
"FlagConversionOff",
"OmitSubcommandPrefixes",
"Positional",
"Suppress",
"subcommand",
Expand Down
12 changes: 12 additions & 0 deletions dcargs/conf/_markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,15 @@ def __repr__(self):
Can be used directly on union types, `AvoidSubcommands[Union[...]]`, or recursively
applied to nested types."""

OMIT_SUBCOMMAND_PREFIXES = _make_marker("OmitSubcommandPrefixes")
OmitSubcommandPrefixes = Annotated[T, OMIT_SUBCOMMAND_PREFIXES]
"""Make flags used for keyword arguments in subcommands shorter by omitting prefixes.
If we have a structure with the field:
cmd: Union[Commit, Checkout]
By default, --cmd.branch may be generated as a flag for each dataclass in the union.
If subcommand prefixes are omitted, we would instead simply have --branch.
"""
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 = "dcargs"
version = "0.3.14"
version = "0.3.15"
description = "Strongly typed, zero-effort CLI interfaces"
authors = ["brentyi <[email protected]>"]
include = ["./dcargs/**/*"]
Expand Down
43 changes: 43 additions & 0 deletions tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,49 @@
import dcargs


def test_omit_subcommand_prefix():
@dataclasses.dataclass
class DefaultInstanceHTTPServer:
y: int = 0

@dataclasses.dataclass
class DefaultInstanceSMTPServer:
z: int = 0

@dataclasses.dataclass
class DefaultInstanceSubparser:
x: int
# bc: Union[DefaultInstanceHTTPServer, DefaultInstanceSMTPServer]
bc: dcargs.conf.OmitSubcommandPrefixes[
Union[DefaultInstanceHTTPServer, DefaultInstanceSMTPServer]
]

assert (
dcargs.cli(
DefaultInstanceSubparser,
args=["--x", "1", "bc:default-instance-http-server", "--y", "5"],
)
== dcargs.cli(
DefaultInstanceSubparser,
args=["--x", "1", "bc:default-instance-http-server", "--y", "5"],
default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=3)),
)
== DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=5))
)
assert (
dcargs.cli(
DefaultInstanceSubparser,
args=["--x", "1", "bc:default-instance-http-server", "--y", "8"],
)
== dcargs.cli(
DefaultInstanceSubparser,
args=["--x", "1", "bc:default-instance-http-server", "--y", "8"],
default=DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=7)),
)
== DefaultInstanceSubparser(x=1, bc=DefaultInstanceHTTPServer(y=8))
)


def test_avoid_subparser_with_default():
@dataclasses.dataclass
class DefaultInstanceHTTPServer:
Expand Down

0 comments on commit 50b114e

Please sign in to comment.