Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor assembling module (closes #197) #198

Merged
merged 15 commits into from
Oct 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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