Skip to content

Commit

Permalink
Refactor assembling module (closes #197) (#198)
Browse files Browse the repository at this point in the history
* refactor: split into better scoped chunks, document
* refactor: use DTO for both inferred and declared args
* refactor: cleanup
  • Loading branch information
neithere authored Oct 13, 2023
1 parent 60694c6 commit 6749056
Show file tree
Hide file tree
Showing 9 changed files with 1,074 additions and 473 deletions.
385 changes: 229 additions & 156 deletions src/argh/assembling.py

Large diffs are not rendered by default.

28 changes: 25 additions & 3 deletions src/argh/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
ATTR_WRAPPED_EXCEPTIONS,
ATTR_WRAPPED_EXCEPTIONS_PROCESSOR,
)
from argh.dto import ParserAddArgumentSpec
from argh.utils import CliArgToFuncArgGuessingError, naive_guess_func_arg_name

__all__ = ["aliases", "named", "arg", "wrap_errors", "expects_obj"]

Expand Down Expand Up @@ -71,7 +73,7 @@ def wrapper(func: Callable) -> Callable:
return wrapper


def arg(*args, **kwargs) -> Callable:
def arg(*args: str, **kwargs) -> Callable:
"""
Declares an argument for given function. Does not register the function
anywhere, nor does it modify the function in any way.
Expand All @@ -81,6 +83,14 @@ def arg(*args, **kwargs) -> Callable:
required if they can be easily guessed (e.g. you don't have to specify type
or action when an `int` or `bool` default value is supplied).
.. note::
`completer` is an exception; it's not accepted by
`add_argument()` but instead meant to be assigned to the
action returned by that method, see
https://kislyuk.github.io/argcomplete/#specifying-completers
for details.
Typical use case: in combination with ordinary function signatures to add
details that cannot be expressed with that syntax (e.g. help message).
Expand Down Expand Up @@ -124,12 +134,24 @@ def load(
"""

def wrapper(func: Callable) -> Callable:
if not args:
raise CliArgToFuncArgGuessingError("at least one CLI arg must be defined")

func_arg_name = naive_guess_func_arg_name(args)
completer = kwargs.pop("completer", None)
spec = ParserAddArgumentSpec.make_from_kwargs(
func_arg_name=func_arg_name,
cli_arg_names=args,
parser_add_argument_kwargs=kwargs,
)
if completer:
spec.completer = completer

declared_args = getattr(func, ATTR_ARGS, [])
# The innermost decorator is called first but appears last in the code.
# We need to preserve the expected order of positional arguments, so
# the outermost decorator inserts its value before the innermost's:
# TODO: validate the args?
declared_args.insert(0, {"option_strings": args, **kwargs})
declared_args.insert(0, spec)
setattr(func, ATTR_ARGS, declared_args)
return func

Expand Down
87 changes: 87 additions & 0 deletions src/argh/dto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""
Data transfer objects for internal usage.
"""
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Type, Union


class NotDefined:
"""
Specifies that an argument should not be passed to
ArgumentParser.add_argument(), even as None
"""


@dataclass
class ParserAddArgumentSpec:
"""
DTO, maps CLI arg(s) onto a function arg.
Ends up in ArgumentParser.add_argument().
"""

func_arg_name: Optional[str] # TODO: make it required (needs rearranging the logic)
cli_arg_names: List[str]
is_required: Union[bool, Type[NotDefined]] = NotDefined
default_value: Any = NotDefined
nargs: Optional[str] = None
other_add_parser_kwargs: Dict[str, Any] = field(default_factory=dict)

# https://kislyuk.github.io/argcomplete/#specifying-completers
completer: Optional[Callable] = None

def update(self, other: "ParserAddArgumentSpec") -> None:
for name in other.cli_arg_names:
if name not in self.cli_arg_names:
self.cli_arg_names.append(name)

if other.is_required != NotDefined:
self.is_required = other.is_required

if other.default_value != NotDefined:
self.default_value = other.default_value

if other.nargs:
self.nargs = other.nargs

if other.completer:
self.completer = other.completer

self.other_add_parser_kwargs.update(other.other_add_parser_kwargs)

def get_all_kwargs(self) -> Dict[str, Any]:
kwargs: Dict[str, Any] = {}

if self.is_required != NotDefined:
kwargs["required"] = self.is_required

if self.default_value != NotDefined:
kwargs["default"] = self.default_value

if self.nargs:
kwargs["nargs"] = self.nargs

return dict(kwargs, **self.other_add_parser_kwargs)

@classmethod
def make_from_kwargs(
cls, func_arg_name, cli_arg_names, parser_add_argument_kwargs: Dict[str, Any]
) -> "ParserAddArgumentSpec":
"""
Constructs and returns a `ParserAddArgumentSpec` instance
according to keyword arguments according to the
`ArgumentParser.add_argument()` signature.
"""
kwargs_copy = parser_add_argument_kwargs.copy()
instance = cls(
func_arg_name=func_arg_name,
cli_arg_names=cli_arg_names,
)
if "required" in kwargs_copy:
instance.is_required = kwargs_copy.pop("required")
if "nargs" in kwargs_copy:
instance.nargs = kwargs_copy.pop("nargs")
if "default" in kwargs_copy:
instance.default_value = kwargs_copy.pop("default")
if kwargs_copy:
instance.other_add_parser_kwargs = kwargs_copy
return instance
53 changes: 49 additions & 4 deletions src/argh/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import argparse
import inspect
import re
from typing import Callable
from typing import Callable, Tuple


def get_subparsers(
Expand Down Expand Up @@ -48,9 +48,12 @@ def get_subparsers(

def get_arg_spec(function: Callable) -> inspect.FullArgSpec:
"""
Returns argument specification for given function. Omits special
arguments of instance methods (`self`) and static methods (usually `cls`
or something like this).
Returns argument specification for given function.
Gets to the innermost function through decorators.
Omits special arguments of instance methods (`self`) and class methods
(usually `cls` or something like this). Supports static methods.
"""
while hasattr(function, "__wrapped__"):
function = function.__wrapped__
Expand All @@ -76,3 +79,45 @@ def unindent(text: str) -> str:

class SubparsersNotDefinedError(Exception):
...


def naive_guess_func_arg_name(option_strings: Tuple[str, ...]) -> str:
def _opt_to_func_arg_name(opt: str) -> str:
return opt.strip("-").replace("-", "_")

if len(option_strings) == 1:
# the only CLI arg name; adapt and use
return _opt_to_func_arg_name(option_strings[0])

are_args_positional = [not arg.startswith("-") for arg in option_strings]

if any(are_args_positional) and not all(are_args_positional):
raise MixedPositionalAndOptionalArgsError

if all(are_args_positional):
raise TooManyPositionalArgumentNames

for option_string in option_strings:
if option_string.startswith("--"):
# prefixed long; adapt and use
return _opt_to_func_arg_name(option_string[2:])

raise CliArgToFuncArgGuessingError(
f"Unable to convert opt strings {option_strings} to func arg name"
)


class ArghError(Exception):
...


class CliArgToFuncArgGuessingError(ArghError):
...


class TooManyPositionalArgumentNames(CliArgToFuncArgGuessingError):
...


class MixedPositionalAndOptionalArgsError(CliArgToFuncArgGuessingError):
...
Loading

0 comments on commit 6749056

Please sign in to comment.