Skip to content

Commit

Permalink
Improve internals: caching, pure functions, docs
Browse files Browse the repository at this point in the history
  • Loading branch information
brentyi committed Oct 20, 2021
1 parent e2d984d commit 7c82570
Show file tree
Hide file tree
Showing 12 changed files with 177 additions and 122 deletions.
38 changes: 26 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Args:

if __name__ == "__main__":
args = dcargs.parse(Args)
print(args)
```

Running `python simple.py --help` would print:
Expand All @@ -48,28 +49,41 @@ required arguments:
--field2 INT A numeric field.
```

And, from `python simple.py --field1 string --field2 4`:

```
Args(field1='string', field2=4)
```

### Feature list

The parse function automatically generates helptext from comments/docstrings,
and supports a wide range of dataclass definitions. Our unit tests cover classes
The parse function supports a wide range of dataclass definitions, while
automatically generating helptext from comments/docstrings. Some of the basic
features are shown in the [example below](#example-usage).

Our unit tests cover many more complex type annotations, including classes
containing:

- Types natively accepted by `argparse`: str, int, float, pathlib.Path, etc
- Default values for optional parameters
- Booleans, which can have different behaviors based on default values (eg
`action="store_true"` or `action="store_false"`)
- Enums (via `enum.Enum`)
- Booleans, which are automatically converted to flags when provided a default
value (eg `action="store_true"` or `action="store_false"`; in the latter case,
we prefix names with `no-`)
- Enums (via `enum.Enum`; argparse's `choices` is populated and arguments are
converted automatically)
- Various container types. Some examples:
- `typing.ClassVar` types (omitted from parser)
- `typing.Optional` types
- `typing.Literal` types (populates `choices`)
- `typing.Sequence` types (populates `nargs`)
- `typing.List` types (populates `nargs`)
- `typing.Tuple` types (populates `nargs`; must contain just one child type)
- `typing.Literal` types (populates argparse's `choices`)
- `typing.Sequence` types (populates argparse's `nargs`)
- `typing.List` types (populates argparse's `nargs`)
- `typing.Tuple` types, such as `typing.Tuple[T, T, T]` or
`typing.Tuple[T, ...]` (populates argparse's `nargs`, and converts
automatically)
- `typing.Final` types and `typing.Annotated` (for parsing, these are
effectively no-ops)
- Nested combinations of the above: `Optional[Literal[...]]`,
`Final[Optional[Sequence[...]]]`, etc
- Nested combinations of the above: `Optional[Literal[T]]`,
`Final[Optional[Sequence[T]]]`, etc
- Nested dataclasses
- Simple nesting (see `OptimizerConfig` example below)
- Unions over nested dataclasses (subparsers)
Expand Down Expand Up @@ -98,7 +112,7 @@ some of them:
Some other distinguishing factors that `dcargs` has put effort into:

- Robust handling of forward references
- Support for nested containers+generics
- Support for nested containers and generics
- Strong typing: we actively avoid relying on strings or dynamic namespace
objects (eg `argparse.Namespace`)
- Simplicity + strict abstractions: we're focused on a single function API, and
Expand Down
30 changes: 15 additions & 15 deletions dcargs/_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import enum
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union

from typing_extensions import Final, Literal, _AnnotatedAlias # Backward compat
from typing_extensions import Final, Literal, _AnnotatedAlias # Backward compatibility.

from . import _construction, _docstrings, _strings

Expand All @@ -14,13 +14,13 @@ class ArgumentDefinition:
"""Options for defining arguments. Contains all necessary arguments for argparse's
add_argument() method."""

# Fields that will be populated initially
# Fields that will be populated initially.
name: str
field: dataclasses.Field
parent_class: Type
type: Optional[Union[Type, TypeVar]]

# Fields that will be handled by argument transformations
# Fields that will be handled by argument transformations.
required: Optional[bool] = None
action: Optional[str] = None
nargs: Optional[Union[int, str]] = None
Expand Down Expand Up @@ -60,15 +60,15 @@ def make_from_field(

assert field.init, "Field must be in class constructor"

# Create initial argument
# Create initial argument.
arg = ArgumentDefinition(
name=field.name,
field=field,
parent_class=parent_class,
type=field.type,
)

# Propagate argument through transforms until stable
# Propagate argument through transforms until stable.
prev_arg = arg
role: _construction.FieldRole = _construction.FieldRole.VANILLA_FIELD

Expand All @@ -86,26 +86,26 @@ def _handle_generics(arg: ArgumentDefinition) -> _ArgumentTransformOutput:

while True:
for transform in [_handle_generics] + _argument_transforms: # type: ignore
# Apply transform
# Apply transform.
arg, new_role = transform(arg)

# Update field role
# Update field role.
if new_role is not None:
assert (
role == _construction.FieldRole.VANILLA_FIELD
), "Something went wrong -- only one field role can be specified per argument!"
role = new_role

# Stability check
# Stability check.
if arg == prev_arg:
break
prev_arg = arg
return arg, role


# Argument transformations
# Argument transformations.
# Each transform returns an argument definition and (optionall) a special role for
# reconstruction -- note that a field can only ever have one role
# reconstruction -- note that a field can only ever have one role.

_ArgumentTransformOutput = Tuple[ArgumentDefinition, Optional[_construction.FieldRole]]

Expand Down Expand Up @@ -165,7 +165,7 @@ def _handle_optionals(arg: ArgumentDefinition) -> _ArgumentTransformOutput:
def _populate_defaults(arg: ArgumentDefinition) -> _ArgumentTransformOutput:
"""Populate default values."""
if arg.default is not None:
# Skip if another handler has already populated the default
# Skip if another handler has already populated the default.
return arg, None

default = None
Expand Down Expand Up @@ -255,9 +255,9 @@ def _nargs_from_tuples(arg: ArgumentDefinition) -> _ArgumentTransformOutput:
assert len(argset_no_ellipsis) == 1, "Tuples must be of a single type!"

if argset != argset_no_ellipsis:
# `*` is >=0 values, `+` is >=1 values
# `*` is >=0 values, `+` is >=1 values.
# We're going to require at least 1 value; if a user wants to accept no
# input, they can use Optional[Tuple[...]]
# input, they can use Optional[Tuple[...]].
nargs = "+"
else:
nargs = len(arg.type.__args__) # type: ignore
Expand Down Expand Up @@ -326,10 +326,10 @@ def _generate_helptext(arg: ArgumentDefinition) -> _ArgumentTransformOutput:
help_parts.append(docstring_help)

if arg.default is not None and hasattr(arg.default, "name"):
# Special case for enums
# Special case for enums.
help_parts.append(f"(default: {arg.default.name})")
elif arg.default is not None:
# General case
# General case.
help_parts.append("(default: %(default)s)")

return dataclasses.replace(arg, help=" ".join(help_parts)), None
Expand Down
51 changes: 31 additions & 20 deletions dcargs/_construction.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import dataclasses
import enum
from typing import Any, Dict, Type, TypeVar, Union
from typing import Any, Dict, Set, Tuple, Type, TypeVar, Union

from typing_extensions import _GenericAlias # type: ignore

Expand All @@ -15,8 +15,8 @@ class FieldRole(enum.Enum):
VANILLA_FIELD = enum.auto()
TUPLE = enum.auto()
ENUM = enum.auto()
NESTED_DATACLASS = enum.auto() # Singular nested dataclass
SUBPARSERS = enum.auto() # Unions over dataclasses
NESTED_DATACLASS = enum.auto() # Singular nested dataclass.
SUBPARSERS = enum.auto() # Unions over dataclasses.


@dataclasses.dataclass
Expand All @@ -38,15 +38,25 @@ def construct_dataclass(
value_from_arg: Dict[str, Any],
metadata: ConstructionMetadata,
field_name_prefix: str = "",
) -> DataclassType:
) -> Tuple[DataclassType, Set[str]]:
"""Construct a dataclass object from a dictionary of values from argparse.
Mutates `value_from_arg`."""
Returns dataclass object and set of used arguments."""

assert _resolver.is_dataclass(cls)

cls, _type_from_typevar = _resolver.resolve_generic_dataclasses(cls)

kwargs: Dict[str, Any] = {}
consumed_keywords: Set[str] = set()

def get_value_from_arg(arg: str) -> Any:
"""Helper for getting values from `value_from_arg` + doing some extra
asserts."""
assert arg in value_from_arg
assert arg not in consumed_keywords
consumed_keywords.add(arg)
return value_from_arg[arg]

for field in _resolver.resolved_fields(cls): # type: ignore
if not field.init:
Expand All @@ -58,27 +68,27 @@ def construct_dataclass(
prefixed_field_name = field_name_prefix + field.name

if role is FieldRole.ENUM:
# Handle enums
value = field.type[value_from_arg.pop(prefixed_field_name)]
# Handle enums.
value = field.type[get_value_from_arg(prefixed_field_name)]
elif role is FieldRole.NESTED_DATACLASS:
# Nested dataclasses
value = construct_dataclass(
# Nested dataclasses.
value, consumed_keywords_child = construct_dataclass(
field.type,
value_from_arg,
metadata,
field_name_prefix=prefixed_field_name
+ _strings.NESTED_DATACLASS_DELIMETER,
) # TODO: need to strip prefixes here. not sure how
)
consumed_keywords |= consumed_keywords_child
elif role is FieldRole.SUBPARSERS:
# Unions over dataclasses (subparsers)
# Unions over dataclasses (subparsers).
subparser_dest = _strings.SUBPARSER_DEST_FMT.format(
name=prefixed_field_name
)
subparser_name = value_from_arg.pop(subparser_dest)
subparser_name = get_value_from_arg(subparser_dest)
if subparser_name is None:
# No subparser selected -- this should only happen when we do either
# Optional[Union[A, B, ...]] or Union[A, B, None] -- note that these are
# equivalent
# Optional[Union[A, B, ...]] or Union[A, B, None].
assert type(None) in field.type.__args__
value = None
else:
Expand All @@ -89,23 +99,24 @@ def construct_dataclass(
chosen_cls = option
break
assert chosen_cls is not None
value = construct_dataclass(
value, consumed_keywords_child = construct_dataclass(
chosen_cls,
value_from_arg,
metadata,
)
consumed_keywords |= consumed_keywords_child
elif role is FieldRole.TUPLE:
# For sequences, argparse always gives us lists -- sometimes we want tuples
value = value_from_arg.pop(prefixed_field_name)
# For sequences, argparse always gives us lists. Sometimes we want tuples.
value = get_value_from_arg(prefixed_field_name)
if value is not None:
value = tuple(value)

elif role is FieldRole.VANILLA_FIELD:
# General case
value = value_from_arg.pop(prefixed_field_name)
# General case.
value = get_value_from_arg(prefixed_field_name)
else:
assert False

kwargs[field.name] = value

return cls(**kwargs) # type: ignore
return cls(**kwargs), consumed_keywords # type: ignore
Loading

0 comments on commit 7c82570

Please sign in to comment.