Skip to content

Commit

Permalink
Feature: Subcommand Parsing (#9)
Browse files Browse the repository at this point in the history
* First method of recursive subcommand parsing.
* Fixed non-recursive mistake.
* Renamed `parent_command` to `command`.
* Renamed `commands` to `subcommands`
* Fixed malformed docstring arg.
* Fixed future imports in unit tests
* Unified handling of combining command strings for namespacing.
* Fixed mangled metavars.
* Fixed pylint no-self-use.
* Added check for dest in kwargs before augmenting.
* Remove 'parent command' method.
* Updated dependencies.
* Added custom SubParserAction to easily parse commands.
* Rearranged functions
* Improved SubParsersAction docstring.
* Added full propagation of unrecognized args to top level namespace.
* Guarded unrecognized args propagation with check.
* Refactored __version__ and unit test structure.
* Restructured unit tests again.
* Linting and styleguide changes.
* Updated pyproject.toml
* Bumped back to 0.3.1 after merge.
* Added unit tests for 'utils' module.
* Completed full unit test coverage of commands.
  • Loading branch information
SupImDos authored Jan 23, 2022
1 parent 2a2636a commit 3407555
Show file tree
Hide file tree
Showing 25 changed files with 924 additions and 532 deletions.
187 changes: 94 additions & 93 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions pydantic_argparse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
@author Hayden Richards <[email protected]>
"""


# Local
from .__version__ import __app__, __description__, __version__, __authors__
from .__version__ import __title__, __description__, __version__, __author__, __license__
from .argparse import ArgumentParser
17 changes: 11 additions & 6 deletions pydantic_argparse/__version__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
"""__version__.py
Exports the name, version, description and authors of the package
Exports the title, description, version, author and license of the package
@author Hayden Richards <[email protected]>
"""


# Duplicated from `pyproject.toml`
__app__ = "pydantic-argparse"
__description__ = "Typed Argument Parsing with Pydantic"
__version__ = "0.3.1"
__authors__ = ["Hayden Richards <[email protected]>"]
# Standard
from importlib import metadata


# Retrieve Metadata from Package
__title__ = metadata.metadata(__package__)["name"]
__description__ = metadata.metadata(__package__)["summary"]
__version__ = metadata.metadata(__package__)["version"]
__author__ = metadata.metadata(__package__)["author"]
__license__ = metadata.metadata(__package__)["license"]
1 change: 0 additions & 1 deletion pydantic_argparse/argparse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,5 @@
@author Hayden Richards <[email protected]>
"""


# Local
from .parser import ArgumentParser
105 changes: 105 additions & 0 deletions pydantic_argparse/argparse/actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""actions.py
Provides custom Actions classes.
@author Hayden Richards <[email protected]>
"""


# Standard
import argparse

# Typing
from typing import Any, Optional, Sequence, Union, cast


class SubParsersAction(argparse._SubParsersAction): # pylint: disable=protected-access
"""Custom SubParsersAction."""
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: Union[str, Sequence[Any], None],
option_string: Optional[str]=None,
) -> None:
"""Parses arguments with the specified subparser, then embeds the
resultant sub-namespace into the supplied parent namespace.
This subclass differs in functionality from the existing standard
argparse SubParsersAction because it nests the resultant sub-namespace
directly into the parent namespace, rather than iterating through and
updating the parent namespace object with each argument individually.
Example:
# Create Argument Parser
parser = argparse.ArgumentParser()
# Add Example Global Argument
parser.add_argument("--time")
# Add SubParsersAction
subparsers = parser.add_subparsers()
# Add Example 'walk' Command with Arguments
walk = subparsers.add_parser("walk")
walk.add_argument("--speed")
walk.add_argument("--distance")
# Add Example 'talk' Command with Arguments
talk = subparsers.add_parser("talk")
talk.add_argument("--volume")
talk.add_argument("--topic")
Parsing the arguments:
* --time 3 walk --speed 7 --distance 42
Resultant namespaces:
* Original: Namespace(time=3, speed=7, distance=42)
* Custom: Namespace(time=3, walk=Namespace(speed=7, distance=42))
This behaviour results in a final namespace structure which is much
easier to parse, where subcommands are easily identified and nested
into their own namespace recursively.
Args:
parser (argparse.ArgumentParser): Parent argument parser object.
namespace (argparse.Namespace): Parent namespace being parsed to.
values (Union[str, Sequence[Any], None]): Arguments to parse.
option_string (Optional[str]): Optional option string (not used).
Raises:
argparse.ArgumentError: Raised if subparser name does not exist.
"""
# Check values object is a sequence
# In order to not violate the Liskov Substitution Principle (LSP), the
# function signature for __call__ must match the base Action class. As
# such, this function signature also accepts 'str' and 'None' types for
# the values argument. However, in reality, this should only ever be a
# list of strings here, so we just do a type cast.
values = cast(list[str], values)

# Get Parser Name and Remaining Argument Strings
parser_name, *arg_strings = values

# Try select the parser
try:
# Select the parser
parser = self._name_parser_map[parser_name]

except KeyError as exc:
# Parser doesn't exist, raise an exception
raise argparse.ArgumentError(
self,
f"unknown parser {parser_name} (choices: {', '.join(self._name_parser_map)})"
) from exc

# Parse all the remaining options into a sub-namespace, then embed this
# sub-namespace into the parent namespace
subnamespace, arg_strings = parser.parse_known_args(arg_strings)
setattr(namespace, parser_name, subnamespace)

# Store any unrecognized options on the parent namespace, so that the
# top level parser can decide what to do with them
if arg_strings:
vars(namespace).setdefault(argparse._UNRECOGNIZED_ARGS_ATTR, [])
getattr(namespace, argparse._UNRECOGNIZED_ARGS_ATTR).extend(arg_strings)
69 changes: 32 additions & 37 deletions pydantic_argparse/argparse/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@
"""


from __future__ import annotations


# Standard
import argparse
import collections
Expand All @@ -20,14 +17,9 @@
import typing_inspect

# Local
from ..parsers import (
parse_boolean_field,
parse_container_field,
parse_enum_field,
parse_json_field,
parse_literal_field,
parse_standard_field,
)
from pydantic_argparse import parsers
from pydantic_argparse import utils
from . import actions

# Typing
from typing import Any, Generic, Literal, NoReturn, Optional, TypeVar # pylint: disable=wrong-import-order
Expand All @@ -40,6 +32,7 @@
class ArgumentParser(argparse.ArgumentParser, Generic[PydanticModelT]):
"""Custom Typed Argument Parser."""
# Argument Group Names
COMMANDS = "commands"
REQUIRED = "required arguments"
OPTIONAL = "optional arguments"
HELP = "help"
Expand Down Expand Up @@ -80,6 +73,9 @@ def __init__(
add_help=False, # Always disable the automatic help flag.
)

# Set Model
self.model = model

# Set Add Help and Exit on Error Flag
self.add_help = add_help
self.exit_on_error = exit_on_error
Expand All @@ -89,6 +85,7 @@ def __init__(
self.model = model

# Add Arguments Groups
self._subcommands: Optional[argparse._SubParsersAction] = None
self._required_group = self.add_argument_group(ArgumentParser.REQUIRED)
self._optional_group = self.add_argument_group(ArgumentParser.OPTIONAL)
self._help_group = self.add_argument_group(ArgumentParser.HELP)
Expand All @@ -115,10 +112,10 @@ def parse_typed_args(
PydanticModelT: Typed arguments.
"""
# Call Super Class Method
namespace = self.parse_args(args, None)
namespace = self.parse_args(args)

# Restructure Namespace
arguments = self._restructure_namespace(namespace)
# Convert Namespace to Dictionary
arguments = utils.namespace_to_dict(namespace)

# Handle Possible Validation Errors
try:
Expand Down Expand Up @@ -223,42 +220,40 @@ def _add_field(self, field: pydantic.fields.ModelField) -> None:
# Switch on Field Type
if field_type is bool:
# Add Boolean Field
parse_boolean_field(self, field)
parsers.parse_boolean_field(self, field)

elif field_origin in (list, tuple, set, frozenset, collections.deque):
# Add Container Field
parse_container_field(self, field)
parsers.parse_container_field(self, field)

elif field_origin is dict:
# Add Dictionary (JSON) Field
parse_json_field(self, field)
parsers.parse_json_field(self, field)

elif field_origin is Literal:
# Add Literal Field
parse_literal_field(self, field)
parsers.parse_literal_field(self, field)

elif isinstance(field_type, enum.EnumMeta):
# Add Enum Field
parse_enum_field(self, field)

else:
# Add Other Standard Field
parse_standard_field(self, field)
parsers.parse_enum_field(self, field)

def _restructure_namespace( # pylint: disable=no-self-use
self,
namespace: argparse.Namespace,
) -> dict[str, Any]:
"""Restructures namespace to a nested dictionary.
elif isinstance(field_type, pydantic.main.ModelMetaclass):
# Check for Sub-Commands Group
if not self._subcommands:
# Add Sub-Commands Group
self._subcommands = self.add_subparsers(
title=ArgumentParser.COMMANDS,
action=actions.SubParsersAction,
required=True,
)

Args:
namespace (argparse.Namespace): Namespace to restructure.
# Shuffle it to the top
self._action_groups.insert(0, self._action_groups.pop())

Returns:
dict[str, Any]: Nested dictionary constructed from namespace.
"""
# Get Arguments from Vars
arguments = vars(namespace)
# Add Command
parsers.parse_command_field(self._subcommands, field)

# Return
return arguments
else:
# Add Other Standard Field
parsers.parse_standard_field(self, field)
2 changes: 1 addition & 1 deletion pydantic_argparse/parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
@author Hayden Richards <[email protected]>
"""


# Local
from .boolean import parse_boolean_field
from .command import parse_command_field
from .container import parse_container_field
from .enum import parse_enum_field
from .json import parse_json_field
Expand Down
24 changes: 10 additions & 14 deletions pydantic_argparse/parsers/boolean.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,14 @@
"""


from __future__ import annotations


# Standard
import argparse

# Third-Party
import pydantic

# Local
from ..utils import argument_description, argument_name

from pydantic_argparse import utils


def parse_boolean_field(
Expand All @@ -27,7 +23,7 @@ def parse_boolean_field(
"""Adds boolean pydantic field to argument parser.
Args:
parser: (argparse.ArgumentParser): Argument parser to add to.
parser (argparse.ArgumentParser): Argument parser to add to.
field (pydantic.fields.ModelField): Field to be added to parser.
"""
# Booleans can be treated as required or optional flags
Expand All @@ -47,14 +43,14 @@ def _parse_boolean_field_required(
"""Adds required boolean pydantic field to argument parser.
Args:
parser: (argparse.ArgumentParser): Argument parser to add to.
parser (argparse.ArgumentParser): Argument parser to add to.
field (pydantic.fields.ModelField): Field to be added to parser.
"""
# Add Required Boolean Field
parser.add_argument(
argument_name(field.name),
utils.argument_name(field.name),
action=argparse.BooleanOptionalAction,
help=argument_description(field.field_info.description),
help=utils.argument_description(field.field_info.description),
dest=field.name,
required=True,
)
Expand All @@ -67,7 +63,7 @@ def _parse_boolean_field_optional(
"""Adds optional boolean pydantic field to argument parser.
Args:
parser: (argparse.ArgumentParser): Argument parser to add to.
parser (argparse.ArgumentParser): Argument parser to add to.
field (pydantic.fields.ModelField): Field to be added to parser.
"""
# Get Default
Expand All @@ -77,21 +73,21 @@ def _parse_boolean_field_optional(
if default:
# Optional (Default True)
parser.add_argument(
argument_name("no-" + field.name),
utils.argument_name("no-" + field.name),
action=argparse._StoreFalseAction, # pylint: disable=protected-access
default=default,
help=argument_description(field.field_info.description, default),
help=utils.argument_description(field.field_info.description, default),
dest=field.name,
required=False,
)

else:
# Optional (Default False)
parser.add_argument(
argument_name(field.name),
utils.argument_name(field.name),
action=argparse._StoreTrueAction, # pylint: disable=protected-access
default=default,
help=argument_description(field.field_info.description, default),
help=utils.argument_description(field.field_info.description, default),
dest=field.name,
required=False,
)
Loading

0 comments on commit 3407555

Please sign in to comment.