diff --git a/src/argh/assembling.py b/src/argh/assembling.py index 6f0041e..468f1f3 100644 --- a/src/argh/assembling.py +++ b/src/argh/assembling.py @@ -13,9 +13,11 @@ Functions and classes to properly assemble your commands in a parser. """ -from argparse import ArgumentParser +import inspect +from argparse import ZERO_OR_MORE, ArgumentParser from collections import OrderedDict -from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple +from dataclasses import asdict +from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Type, Union from argh.completion import COMPLETION_ENABLED from argh.constants import ( @@ -27,6 +29,7 @@ DEST_FUNCTION, PARSER_FORMATTER, ) +from argh.dto import NotDefined, ParserAddArgumentSpec from argh.exceptions import AssemblingError from argh.utils import get_arg_spec, get_subparsers @@ -37,18 +40,20 @@ ] -def _get_args_from_signature(function: Callable) -> Iterator[dict]: +def extract_parser_add_argument_kw_from_signature( + function: Callable, +) -> Iterator[ParserAddArgumentSpec]: if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False): return - spec = get_arg_spec(function) + func_spec = get_arg_spec(function) defaults: Dict[str, Any] = dict( - zip(reversed(spec.args), reversed(spec.defaults or tuple())) + zip(reversed(func_spec.args), reversed(func_spec.defaults or tuple())) ) - defaults.update(getattr(spec, "kwonlydefaults", None) or {}) + defaults.update(getattr(func_spec, "kwonlydefaults", None) or {}) - kwonly = getattr(spec, "kwonlyargs", []) + kwonly = getattr(func_spec, "kwonlyargs", []) # define the list of conflicting option strings # (short forms, i.e. single-character ones) @@ -61,98 +66,95 @@ def _get_args_from_signature(function: Callable) -> Iterator[dict]: char for char in named_arg_char_counts if 1 < named_arg_char_counts[char] ) - for name in spec.args + kwonly: - flags: List[str] = [] # name_or_flags + for name in func_spec.args + kwonly: + cli_arg_names: List[str] = [] akwargs: Dict[str, Any] = {} # keyword arguments for add_argument() + is_required: Union[bool, Type[NotDefined]] = NotDefined + default_value: Any = NotDefined if name in defaults or name in kwonly: if name in defaults: - akwargs.update(default=defaults.get(name)) + default_value = defaults.get(name) else: - akwargs.update(required=True) - flags = [f"-{name[0]}", f"--{name}"] + is_required = True + cli_arg_names = [f"-{name[0]}", f"--{name}"] if name.startswith(conflicting_opts): # remove short name - flags = flags[1:] + cli_arg_names = cli_arg_names[1:] else: # positional argument - flags = [name] + cli_arg_names = [name] # cmd(foo_bar) -> add_argument("foo-bar") - flags = [x.replace("_", "-") if x.startswith("-") else x for x in flags] - - yield {"option_strings": flags, **akwargs} + cli_arg_names = [ + x.replace("_", "-") if x.startswith("-") else x for x in cli_arg_names + ] + + spec = ParserAddArgumentSpec( + name, + cli_arg_names, + default_value=default_value, + other_add_parser_kwargs=akwargs, + ) + if is_required != NotDefined: + spec.is_required = is_required + yield spec - if spec.varargs: + if func_spec.varargs: # *args - yield { - "option_strings": [spec.varargs], - "nargs": "*", - } + yield ParserAddArgumentSpec( + func_spec.varargs, + [func_spec.varargs], + nargs=ZERO_OR_MORE, + ) -def _guess(kwargs: Dict[str, Any]) -> Dict[str, Any]: +def guess_extra_parser_add_argument_spec_kwargs( + parser_add_argument_spec: ParserAddArgumentSpec, +) -> Dict[str, Any]: """ - Adds types, actions, etc. to given argument specification. - For example, ``default=3`` implies ``type=int``. + Given an argument specification, returns types, actions, etc. that could be + guessed from it: + + * ``default=3`` → ``type=int`` + + TODO: deprecate in favour of ``foo: int = 3`` in func signature. + + * ``choices=[3]`` → ``type=int`` + + TODO: deprecate in favour of ``foo: int`` in func signature. + + * ``type=bool`` → ``action="store_false"`` or ``action="store_true"`` + (if action was not explicitly defined). - :param arg: a :class:`argh.utils.Arg` instance """ + other_add_parser_kwargs = parser_add_argument_spec.other_add_parser_kwargs guessed: Dict[str, Any] = {} # Parser actions that accept argument 'type' TYPE_AWARE_ACTIONS = "store", "append" # guess type/action from default value - value = kwargs.get("default") - if value is not None: - if isinstance(value, bool): - if kwargs.get("action") is None: + default_value = parser_add_argument_spec.default_value + if default_value not in (None, NotDefined): + if isinstance(default_value, bool): + if other_add_parser_kwargs.get("action") is None: # infer action from default value - guessed["action"] = "store_false" if value else "store_true" - elif kwargs.get("type") is None: + guessed["action"] = "store_false" if default_value else "store_true" + elif other_add_parser_kwargs.get("type") is None: # infer type from default value # (make sure that action handler supports this keyword) - if kwargs.get("action", "store") in TYPE_AWARE_ACTIONS: - guessed["type"] = type(value) + if other_add_parser_kwargs.get("action", "store") in TYPE_AWARE_ACTIONS: + guessed["type"] = type(default_value) # guess type from choices (first item) - if kwargs.get("choices") and "type" not in list(guessed) + list(kwargs): - guessed["type"] = type(kwargs["choices"][0]) - - return dict(kwargs, **guessed) - - -def _is_positional(args: List[str], prefix_chars: str = "-") -> bool: - if not args or not args[0]: - raise ValueError("Expected at least one argument") - if args[0][0].startswith(tuple(prefix_chars)): - return False - return True + if other_add_parser_kwargs.get("choices") and "type" not in list(guessed) + list( + other_add_parser_kwargs + ): + guessed["type"] = type(other_add_parser_kwargs["choices"][0]) - -def _get_parser_param_kwargs( - parser: ArgumentParser, argspec: Dict[str, Any] -) -> Dict[str, Any]: - argspec = argspec.copy() # parser methods modify source data - args = argspec["option_strings"] - - if _is_positional(args, prefix_chars=parser.prefix_chars): - get_kwargs = parser._get_positional_kwargs - else: - get_kwargs = parser._get_optional_kwargs - - kwargs = get_kwargs(*args, **argspec) - - kwargs["dest"] = kwargs["dest"].replace("-", "_") - - return kwargs - - -def _get_dest(parser: ArgumentParser, argspec) -> str: - kwargs = _get_parser_param_kwargs(parser, argspec) - return kwargs["dest"] + return guessed def set_default_command(parser, function: Callable) -> None: @@ -176,103 +178,62 @@ def set_default_command(parser, function: Callable) -> None: option name ``-h`` is silently removed from any argument. """ - spec = get_arg_spec(function) + func_spec = get_arg_spec(function) + has_varkw = bool(func_spec.varkw) # the **kwargs thing - declared_args: List[dict] = getattr(function, ATTR_ARGS, []) - inferred_args: List[dict] = list(_get_args_from_signature(function)) + declared_args: List[ParserAddArgumentSpec] = getattr(function, ATTR_ARGS, []) + inferred_args: List[ParserAddArgumentSpec] = list( + extract_parser_add_argument_kw_from_signature(function) + ) - if inferred_args and declared_args: - # We've got a mixture of declared and inferred arguments + if declared_args and not inferred_args and not has_varkw: + # XXX breaking change, new behaviour + raise AssemblingError( + f"{function.__name__}: cannot extend argument declarations " + "for an endpoint function that takes no arguments." + ) - # a mapping of "dest" strings to argument declarations. - # - # * a "dest" string is a normalized form of argument name, i.e.: - # - # "-f", "--foo" → "foo" - # "foo-bar" → "foo_bar" - # - # * argument declaration is a dictionary representing an argument; - # it is obtained either from _get_args_from_signature() or from - # an @arg decorator (as is). - # - dests = OrderedDict() - - for argspec in inferred_args: - dest = _get_parser_param_kwargs(parser, argspec)["dest"] - dests[dest] = argspec - - for declared_kw in declared_args: - # an argument is declared via decorator - dest = _get_dest(parser, declared_kw) - if dest in dests: - # the argument is already known from function signature - # - # now make sure that this declared arg conforms to the function - # signature and therefore only refines an inferred arg: - # - # @arg("my-foo") maps to func(my_foo) - # @arg("--my-bar") maps to func(my_bar=...) - - # either both arguments are positional or both are optional - decl_positional = _is_positional(declared_kw["option_strings"]) - infr_positional = _is_positional(dests[dest]["option_strings"]) - if decl_positional != infr_positional: - kinds = {True: "positional", False: "optional"} - kind_inferred = kinds[infr_positional] - kind_declared = kinds[decl_positional] - raise AssemblingError( - f'{function.__name__}: argument "{dest}" declared as {kind_inferred} ' - f"(in function signature) and {kind_declared} (via decorator)" - ) - - # merge explicit argument declaration into the inferred one - # (e.g. `help=...`) - dests[dest].update(**declared_kw) - else: - # the argument is not in function signature - varkw = getattr(spec, "varkw", getattr(spec, "keywords", [])) - if varkw: - # function accepts **kwargs; the argument goes into it - dests[dest] = declared_kw - else: - # there's no way we can map the argument declaration - # to function signature - dest_option_strings = (dests[x]["option_strings"] for x in dests) - msg_flags = ", ".join(declared_kw["option_strings"]) - msg_signature = ", ".join("/".join(x) for x in dest_option_strings) - raise AssemblingError( - f"{function.__name__}: argument {msg_flags} does not fit " - f"function signature: {msg_signature}" - ) - - # pack the modified data back into a list - inferred_args = list(dests.values()) - - command_args = inferred_args or declared_args + if not declared_args: + parser_add_argument_specs = inferred_args + else: + # We've got a mixture of declared and inferred arguments + try: + parser_add_argument_specs = _merge_inferred_and_declared_args( + inferred_args=inferred_args, + declared_args=declared_args, + has_varkw=has_varkw, + parser=parser, + ) + except AssemblingError as exc: + print(exc) + raise AssemblingError(f"{function.__name__}: {exc}") from exc + + # add the fully formed argument specs to the parser + for orig_spec in parser_add_argument_specs: + spec = _prepare_parser_add_argument_spec( + parser_add_argument_spec=orig_spec, parser_adds_help_arg=parser.add_help + ) - # add types, actions, etc. (e.g. default=3 implies type=int) - command_args = [_guess(x) for x in command_args] - - for draft in command_args: - draft = draft.copy() - if "help" not in draft: - draft.update(help=DEFAULT_ARGUMENT_TEMPLATE) - dest_or_opt_strings = draft.pop("option_strings") - if parser.add_help and "-h" in dest_or_opt_strings: - dest_or_opt_strings = [x for x in dest_or_opt_strings if x != "-h"] - completer = draft.pop("completer", None) try: - action = parser.add_argument(*dest_or_opt_strings, **draft) - if COMPLETION_ENABLED and completer: - action.completer = completer + action = parser.add_argument( + *spec.cli_arg_names, + **spec.get_all_kwargs(), + ) except Exception as exc: - err_args = "/".join(dest_or_opt_strings) + err_cli_args = "/".join(spec.cli_arg_names) raise AssemblingError( - f"{function.__name__}: cannot add {err_args}: {exc}" + f"{function.__name__}: cannot add '{spec.func_arg_name}' as {err_cli_args}: {exc}" ) from exc - if function.__doc__ and not parser.description: - parser.description = function.__doc__ + if COMPLETION_ENABLED and spec.completer: + action.completer = spec.completer + + # display endpoint function docstring in command help + docstring = inspect.getdoc(function) + if docstring and not parser.description: + parser.description = docstring + + # add the endpoint function to the parsing result (namespace) parser.set_defaults( **{ DEST_FUNCTION: function, @@ -280,6 +241,118 @@ def set_default_command(parser, function: Callable) -> None: ) +def _prepare_parser_add_argument_spec( + parser_add_argument_spec: ParserAddArgumentSpec, parser_adds_help_arg: bool +) -> ParserAddArgumentSpec: + # deep copy + spec = ParserAddArgumentSpec(**asdict(parser_add_argument_spec)) + + # add types, actions, etc. (e.g. default=3 implies type=int) + spec.other_add_parser_kwargs.update( + guess_extra_parser_add_argument_spec_kwargs(spec) + ) + + # display default value for this argument in command help + if "help" not in spec.other_add_parser_kwargs: + spec.other_add_parser_kwargs.update(help=DEFAULT_ARGUMENT_TEMPLATE) + + # If the parser was created with `add_help=True`, it automatically adds + # the -h/--help argument (on argparse side). If we have added -h for + # another argument (e.g. --host) earlier (inferred or declared), we + # need to remove that short form now. + if parser_adds_help_arg and "-h" in spec.cli_arg_names: + spec.cli_arg_names = [name for name in spec.cli_arg_names if name != "-h"] + + return spec + + +def _merge_inferred_and_declared_args( + inferred_args: List[ParserAddArgumentSpec], + declared_args: List[ParserAddArgumentSpec], + parser: ArgumentParser, + has_varkw: bool, +) -> List[ParserAddArgumentSpec]: + # a mapping of "dest" strings to argument declarations. + # + # * a "dest" string is a normalized form of argument name, i.e.: + # + # "-f", "--foo" → "foo" + # "foo-bar" → "foo_bar" + # + # * argument declaration is a dictionary representing an argument; + # it is obtained either from extract_parser_add_argument_kw_from_signature() + # or from an @arg decorator (as is). + # + specs_by_func_arg_name = OrderedDict() + + # arguments inferred from function signature + for parser_add_argument_spec in inferred_args: + specs_by_func_arg_name[ + parser_add_argument_spec.func_arg_name + ] = parser_add_argument_spec + + # arguments declared via @arg decorator + for declared_spec in declared_args: + parser_add_argument_spec = declared_spec + func_arg_name = parser_add_argument_spec.func_arg_name + + if func_arg_name in specs_by_func_arg_name: + # the argument is already known from function signature + # + # now make sure that this declared arg conforms to the function + # signature and therefore only refines an inferred arg: + # + # @arg("my-foo") maps to func(my_foo) + # @arg("--my-bar") maps to func(my_bar=...) + + # either both arguments are positional or both are optional + decl_positional = _is_positional(declared_spec.cli_arg_names) + infr_positional = _is_positional( + specs_by_func_arg_name[func_arg_name].cli_arg_names + ) + if decl_positional != infr_positional: + kinds = {True: "positional", False: "optional"} + kind_inferred = kinds[infr_positional] + kind_declared = kinds[decl_positional] + raise AssemblingError( + f'argument "{func_arg_name}" declared as {kind_inferred} ' + f"(in function signature) and {kind_declared} (via decorator)" + ) + + # merge explicit argument declaration into the inferred one + # (e.g. `help=...`) + specs_by_func_arg_name[func_arg_name].update(parser_add_argument_spec) + else: + # the argument is not in function signature + if has_varkw: + # function accepts **kwargs; the argument goes into it + specs_by_func_arg_name[func_arg_name] = parser_add_argument_spec + else: + # there's no way we can map the argument declaration + # to function signature + dest_option_strings = ( + specs_by_func_arg_name[x].cli_arg_names + for x in specs_by_func_arg_name + ) + msg_flags = ", ".join(declared_spec.cli_arg_names) + msg_signature = ", ".join("/".join(x) for x in dest_option_strings) + raise AssemblingError( + f"argument {msg_flags} does not fit " + f"function signature: {msg_signature}" + ) + + # pack the modified data back into a list + return list(specs_by_func_arg_name.values()) + + +def _is_positional(args: List[str], prefix_chars: str = "-") -> bool: + if not args or not args[0]: + raise ValueError("Expected at least one argument") + if args[0][0].startswith(tuple(prefix_chars)): + return False + return True + + def add_commands( parser: ArgumentParser, functions: List[Callable], diff --git a/src/argh/decorators.py b/src/argh/decorators.py index a97f4cb..6c0d845 100644 --- a/src/argh/decorators.py +++ b/src/argh/decorators.py @@ -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"] @@ -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. @@ -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). @@ -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 diff --git a/src/argh/dto.py b/src/argh/dto.py new file mode 100644 index 0000000..e4a32cd --- /dev/null +++ b/src/argh/dto.py @@ -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 diff --git a/src/argh/utils.py b/src/argh/utils.py index 8ac08c7..9c232e9 100644 --- a/src/argh/utils.py +++ b/src/argh/utils.py @@ -14,7 +14,7 @@ import argparse import inspect import re -from typing import Callable +from typing import Callable, Tuple def get_subparsers( @@ -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__ @@ -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): + ... diff --git a/tests/test_assembling.py b/tests/test_assembling.py index fa2bd00..5215a39 100644 --- a/tests/test_assembling.py +++ b/tests/test_assembling.py @@ -2,66 +2,97 @@ Unit Tests For Assembling Phase ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """ +import argparse from unittest.mock import MagicMock, call, patch import pytest import argh +from argh.assembling import AssemblingError +from argh.dto import ParserAddArgumentSpec def test_guess_type_from_choices(): - old = dict(option_strings=("foo",), choices=[1, 2]) - new = dict(option_strings=("foo",), choices=[1, 2], type=int) - assert new == argh.assembling._guess(old) - - # ensure no overrides - same = dict(option_strings=("foo",), choices=[1, 2], type="NO_MATTER_WHAT") - assert same == argh.assembling._guess(same) + given = ParserAddArgumentSpec( + "foo", ["foo"], other_add_parser_kwargs={"choices": [1, 2]} + ) + guessed = {"type": int} + assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) + + # do not override a guessable param if already explicitly defined + given = ParserAddArgumentSpec( + "foo", + ["foo"], + other_add_parser_kwargs={ + "option_strings": ["foo"], + "choices": [1, 2], + "type": "NO_MATTER_WHAT", + }, + ) + guessed = {} + assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) def test_guess_type_from_default(): - old = dict(option_strings=("foo",), default=1) - new = dict(option_strings=("foo",), default=1, type=int) - assert new == argh.assembling._guess(old) - - # ensure no overrides - same = dict(option_strings=("foo",), default=1, type="NO_MATTER_WHAT") - assert same == argh.assembling._guess(same) + given = ParserAddArgumentSpec("foo", ["foo"], default_value=1) + guessed = {"type": int} + assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) + + # do not override a guessable param if already explicitly defined + given = ParserAddArgumentSpec( + "foo", + ["foo"], + default_value=1, + other_add_parser_kwargs={ + "type": "NO_MATTER_WHAT", + }, + ) + guessed = {} + assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) def test_guess_action_from_default(): # True → store_false - old = dict(option_strings=("foo",), default=False) - new = dict(option_strings=("foo",), default=False, action="store_true") - assert new == argh.assembling._guess(old) + given = ParserAddArgumentSpec("foo", ["foo"], default_value=False) + guessed = {"action": "store_true"} + assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) # True → store_false - old = dict(option_strings=("foo",), default=True) - new = dict(option_strings=("foo",), default=True, action="store_false") - assert new == argh.assembling._guess(old) - - # ensure no overrides - same = dict(option_strings=("foo",), default=False, action="NO_MATTER_WHAT") - assert same == argh.assembling._guess(same) + given = ParserAddArgumentSpec("foo", ["foo"], default_value=True) + guessed = {"action": "store_false"} + assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) + + # do not override a guessable param if already explicitly defined + given = ParserAddArgumentSpec( + "foo", + ["foo"], + default_value=False, + other_add_parser_kwargs={ + "action": "NO_MATTER_WHAT", + }, + ) + guessed = {} + assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) def test_set_default_command(): - def func(): + def func(**kwargs): pass setattr( func, argh.constants.ATTR_ARGS, - ( - dict(option_strings=("foo",), nargs="+", choices=[1, 2], help="me"), - dict( - option_strings=( - "-b", - "--bar", - ), - default=False, + [ + ParserAddArgumentSpec( + func_arg_name="foo", + cli_arg_names=("foo",), + nargs="+", + other_add_parser_kwargs={"choices": [1, 2], "help": "me"}, ), - ), + ParserAddArgumentSpec( + func_arg_name="bar", cli_arg_names=("-b", "--bar"), default_value=False + ), + ], ) parser = argh.ArghParser() @@ -84,6 +115,183 @@ def func(): assert parser.set_defaults.mock_calls == [call(function=func)] +def test_set_default_command__parser_error(): + def func(foo: str) -> str: + return foo + + parser_mock = MagicMock(spec=argparse.ArgumentParser) + parser_mock.add_help = False + parser_mock.add_argument.side_effect = argparse.ArgumentError( + None, "my hat's on fire!" + ) + + with pytest.raises(argh.AssemblingError): + argh.set_default_command(parser_mock, func) + + +def test_set_default_command__no_func_args(): + # TODO: document in changelog + # XXX breaking change in v0.30! + # Old behaviour: @arg declarations would be passed to add_argument(). + # (how the hell would it look like though?) + # New behaviour: @arg declarations are ignored because there's no func + # arg to map them onto. + def func(): + pass + + setattr( + func, + argh.constants.ATTR_ARGS, + [ParserAddArgumentSpec(func_arg_name="x", cli_arg_names=("-x",))], + ) + + p = argh.ArghParser() + + with pytest.raises(argh.AssemblingError) as excinfo: + p.set_default_command(func) + msg = ( + "func: cannot extend argument declarations for an endpoint " + "function that takes no arguments." + ) + assert msg in str(excinfo.value) + + +def test_set_default_command__varargs_vs_positional(): + def func(*args): + pass + + setattr( + func, + argh.constants.ATTR_ARGS, + [ParserAddArgumentSpec(func_arg_name="x", cli_arg_names=("x",))], + ) + + parser = argh.ArghParser() + + parser.add_argument = MagicMock() + parser.set_defaults = MagicMock() + + with pytest.raises( + AssemblingError, match="func: argument x does not fit function signature: args" + ): + parser.set_default_command(func) + + +def test_set_default_command__varargs_vs_optional(): + def func(*args): + pass + + setattr( + func, + argh.constants.ATTR_ARGS, + [ParserAddArgumentSpec(func_arg_name="x", cli_arg_names=("-x",))], + ) + + parser = argh.ArghParser() + + parser.add_argument = MagicMock() + parser.set_defaults = MagicMock() + + with pytest.raises( + AssemblingError, match="func: argument -x does not fit function signature: args" + ): + parser.set_default_command(func) + + +def test_set_default_command__varkwargs_vs_positional(): + def func(**kwargs): + pass + + setattr( + func, + argh.constants.ATTR_ARGS, + [ParserAddArgumentSpec(func_arg_name="x", cli_arg_names=("x",))], + ) + + parser = argh.ArghParser() + + parser.add_argument = MagicMock() + parser.set_defaults = MagicMock() + + parser.set_default_command(func) + assert parser.add_argument.mock_calls == [call("x", help="%(default)s")] + assert parser.set_defaults.mock_calls == [call(function=func)] + + +def test_set_default_command__varkwargs_vs_optional(): + def func(**kwargs): + pass + + setattr( + func, + argh.constants.ATTR_ARGS, + [ParserAddArgumentSpec(func_arg_name="x", cli_arg_names=("-x",))], + ) + + parser = argh.ArghParser() + + parser.add_argument = MagicMock() + parser.set_defaults = MagicMock() + + parser.set_default_command(func) + assert parser.add_argument.mock_calls == [call("-x", help="%(default)s")] + assert parser.set_defaults.mock_calls == [call(function=func)] + + +def test_set_default_command__declared_vs_signature__names_mismatch(): + def func(bar): + pass + + setattr( + func, + argh.constants.ATTR_ARGS, + ( + ParserAddArgumentSpec( + func_arg_name="x", + cli_arg_names=("foo",), + nargs="+", + other_add_parser_kwargs={"choices": [1, 2], "help": "me"}, + ), + ), + ) + + parser = argh.ArghParser() + + parser.add_argument = MagicMock() + parser.set_defaults = MagicMock() + + with pytest.raises( + AssemblingError, match="func: argument foo does not fit function signature: bar" + ): + argh.set_default_command(parser, func) + + +def test_set_default_command__declared_vs_signature__same_name_pos_vs_opt(): + def func(foo): + pass + + setattr( + func, + argh.constants.ATTR_ARGS, + (ParserAddArgumentSpec(func_arg_name="foo", cli_arg_names=("--foo",)),), + ) + + parser = argh.ArghParser() + + parser.add_argument = MagicMock() + parser.set_defaults = MagicMock() + + import re + + with pytest.raises( + AssemblingError, + match=re.escape( + 'func: argument "foo" declared as positional (in function signature) and optional (via decorator)' + ), + ): + argh.set_default_command(parser, func) + + def test_set_default_command_infer_cli_arg_names_from_func_signature(): # TODO: split into small tests where we'd check each combo and make sure # they interact as expected (e.g. pos opt arg gets the short form even if @@ -94,7 +302,7 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature(): # - positional required (i.e. without a default value) # - positional optional (i.e. with a default value) # - named-only required (i.e. kwonly without a default value) - # - named-only optional (i.e. kwonly with a default valu) + # - named-only optional (i.e. kwonly with a default value) def func( alpha_pos_req, beta_pos_req, @@ -120,6 +328,7 @@ def func( beta_pos_opt_two, gamma_pos_opt, delta_pos_opt, + theta_pos_opt, gamma_kwonly_opt, delta_kwonly_req, epsilon_kwonly_req_one, @@ -245,20 +454,6 @@ def one(): p.add_commands([one], group_kwargs={"help": "foo"}) -def test_set_default_command_mixed_arg_types(): - def func(): - pass - - setattr(func, argh.constants.ATTR_ARGS, [dict(option_strings=("x", "--y"))]) - - p = argh.ArghParser() - - with pytest.raises(argh.AssemblingError) as excinfo: - p.set_default_command(func) - msg = "func: cannot add x/--y: invalid option string" - assert msg in str(excinfo.value) - - def test_set_default_command_varargs(): def func(*file_paths): yield ", ".join(file_paths) @@ -322,7 +517,11 @@ def func(foo): setattr( func, argh.constants.ATTR_ARGS, - [dict(option_strings=("foo",), completer="STUB")], + [ + ParserAddArgumentSpec( + func_arg_name="foo", cli_arg_names=("foo",), completer="STUB" + ) + ], ) p = argh.ArghParser() @@ -341,7 +540,11 @@ def func(foo): setattr( func, argh.constants.ATTR_ARGS, - [dict(option_strings=("foo",), completer="STUB")], + [ + ParserAddArgumentSpec( + func_arg_name="foo", cli_arg_names=("foo",), completer="STUB" + ) + ], ) p = argh.ArghParser() diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 0f62c92..7ac1d84 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -2,7 +2,16 @@ Unit Tests For Decorators ~~~~~~~~~~~~~~~~~~~~~~~~~ """ +import pytest + import argh +from argh.dto import ParserAddArgumentSpec +from argh.utils import ( + CliArgToFuncArgGuessingError, + MixedPositionalAndOptionalArgsError, + TooManyPositionalArgumentNames, + naive_guess_func_arg_name, +) def test_aliases(): @@ -15,15 +24,26 @@ def func(): def test_arg(): - @argh.arg("foo", help="help", nargs="+") + @argh.arg("foo", help="my help", nargs="+") @argh.arg("--bar", default=1) def func(): pass attrs = getattr(func, argh.constants.ATTR_ARGS) assert attrs == [ - dict(option_strings=("foo",), help="help", nargs="+"), - dict(option_strings=("--bar",), default=1), + ParserAddArgumentSpec( + func_arg_name="foo", + cli_arg_names=("foo",), + nargs="+", + other_add_parser_kwargs={ + "help": "my help", + }, + ), + ParserAddArgumentSpec( + func_arg_name="bar", + cli_arg_names=("--bar",), + default_value=1, + ), ] @@ -62,3 +82,39 @@ def func(args): attr = getattr(func, argh.constants.ATTR_EXPECTS_NAMESPACE_OBJECT) assert attr is True + + +def test_naive_guess_func_arg_name() -> None: + # none (error) + with pytest.raises(CliArgToFuncArgGuessingError): + argh.arg()(lambda foo: foo) + + # positional + assert naive_guess_func_arg_name(("foo",)) == "foo" + + # positional — more than one (error) + with pytest.raises(TooManyPositionalArgumentNames): + argh.arg("foo", "bar")(lambda foo: foo) + + # option + assert naive_guess_func_arg_name(("-x",)) == "x" + assert naive_guess_func_arg_name(("--foo",)) == "foo" + assert naive_guess_func_arg_name(("--foo", "-f")) == "foo" + assert naive_guess_func_arg_name(("-f", "--foo")) == "foo" + assert naive_guess_func_arg_name(("-x", "--foo", "--bar")) == "foo" + + with pytest.raises(CliArgToFuncArgGuessingError): + naive_guess_func_arg_name(("-x", "-y")) + + # mixed (errors) + with pytest.raises(MixedPositionalAndOptionalArgsError): + argh.arg("foo", "--foo")(lambda foo: foo) + + with pytest.raises(MixedPositionalAndOptionalArgsError): + argh.arg("--foo", "foo")(lambda foo: foo) + + with pytest.raises(MixedPositionalAndOptionalArgsError): + argh.arg("-f", "foo")(lambda foo: foo) + + with pytest.raises(MixedPositionalAndOptionalArgsError): + argh.arg("foo", "-f")(lambda foo: foo) diff --git a/tests/test_dto.py b/tests/test_dto.py new file mode 100644 index 0000000..b5e3c3a --- /dev/null +++ b/tests/test_dto.py @@ -0,0 +1,123 @@ +""" +Unit Tests For the Argument DTO +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from argh.dto import ParserAddArgumentSpec + + +def test_update_empty_dto() -> None: + def stub_completer(): + ... + + dto = ParserAddArgumentSpec( + func_arg_name="foo", + cli_arg_names=["-f"], + ) + other_dto = ParserAddArgumentSpec( + func_arg_name="bar", + cli_arg_names=["-f", "--foo"], + is_required=True, + default_value=123, + nargs="+", + other_add_parser_kwargs={"knights": "Ni!"}, + completer=stub_completer, + ) + + dto.update(other_dto) + + assert dto == ParserAddArgumentSpec( + func_arg_name="foo", + cli_arg_names=["-f", "--foo"], + is_required=True, + default_value=123, + nargs="+", + other_add_parser_kwargs={"knights": "Ni!"}, + completer=stub_completer, + ) + + +def test_update_full_dto() -> None: + def stub_completer_one(): + ... + + def stub_completer_two(): + ... + + dto = ParserAddArgumentSpec( + func_arg_name="foo", + cli_arg_names=["-f"], + nargs="?", + is_required=True, + default_value=123, + other_add_parser_kwargs={"'tis but a": "scratch"}, + completer=stub_completer_one, + ) + other_dto = ParserAddArgumentSpec( + func_arg_name="bar", + cli_arg_names=["-f", "--foo"], + nargs="+", + is_required=False, + default_value=None, + other_add_parser_kwargs={"knights": "Ni!"}, + completer=stub_completer_two, + ) + + dto.update(other_dto) + + assert dto == ParserAddArgumentSpec( + func_arg_name="foo", + cli_arg_names=["-f", "--foo"], + is_required=False, + default_value=None, + nargs="+", + other_add_parser_kwargs={"knights": "Ni!", "'tis but a": "scratch"}, + completer=stub_completer_two, + ) + + +class TestGetAllKwargs: + ... + + +def test_make_from_kwargs_minimal() -> None: + dto = ParserAddArgumentSpec.make_from_kwargs("foo", ["-f", "--foo"], {}) + + assert dto == ParserAddArgumentSpec( + func_arg_name="foo", cli_arg_names=["-f", "--foo"] + ) + + +def test_make_from_kwargs_full() -> None: + dto = ParserAddArgumentSpec.make_from_kwargs( + "foo", + ["-f", "--foo"], + { + "action": "some action", + "nargs": "?", + "default": None, + "type": str, + "choices": [1, 2, 3], + "required": False, + "help": "some help", + "metavar": "FOOOOO", + "dest": "foo_dest", + "some arbitrary key": "and its value", + }, + ) + + assert dto == ParserAddArgumentSpec( + func_arg_name="foo", + cli_arg_names=["-f", "--foo"], + is_required=False, + default_value=None, + nargs="?", + other_add_parser_kwargs={ + "action": "some action", + "type": str, + "choices": [1, 2, 3], + "help": "some help", + "metavar": "FOOOOO", + "dest": "foo_dest", + "some arbitrary key": "and its value", + }, + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index b18de7d..3ca3067 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -26,12 +26,12 @@ def test_set_default_command_integration(): def cmd(foo=1): return foo - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "") == R(out="1\n", err="") - assert run(p, "--foo 2") == R(out="2\n", err="") - assert run(p, "--help", exit=True) == 0 + assert run(parser, "") == R(out="1\n", err="") + assert run(parser, "--foo 2") == R(out="2\n", err="") + assert run(parser, "--help", exit=True) == 0 def test_set_default_command_integration_merging(): @@ -39,12 +39,12 @@ def test_set_default_command_integration_merging(): def cmd(foo=1): return foo - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "") == R(out="1\n", err="") - assert run(p, "--foo 2") == R(out="2\n", err="") - assert "bar" in p.format_help() + assert run(parser, "") == R(out="1\n", err="") + assert run(parser, "--foo 2") == R(out="2\n", err="") + assert "bar" in parser.format_help() # @@ -56,33 +56,33 @@ def test_simple_function_no_args(): def cmd(): yield 1 - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "") == R(out="1\n", err="") + assert run(parser, "") == R(out="1\n", err="") def test_simple_function_positional(): def cmd(x): yield x - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "", exit=True) == "the following arguments are required: x" - assert run(p, "foo") == R(out="foo\n", err="") + assert run(parser, "", exit=True) == "the following arguments are required: x" + assert run(parser, "foo") == R(out="foo\n", err="") def test_simple_function_defaults(): def cmd(x="foo"): yield x - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "") == R(out="foo\n", err="") - assert run(p, "bar", exit=True) == "unrecognized arguments: bar" - assert run(p, "--x bar") == R(out="bar\n", err="") + assert run(parser, "") == R(out="foo\n", err="") + assert run(parser, "bar", exit=True) == "unrecognized arguments: bar" + assert run(parser, "--x bar") == R(out="bar\n", err="") def test_simple_function_varargs(): @@ -90,12 +90,12 @@ def func(*file_paths): # `paths` is the single positional argument with nargs="*" yield ", ".join(file_paths) - p = DebugArghParser() - p.set_default_command(func) + parser = DebugArghParser() + parser.set_default_command(func) - assert run(p, "") == R(out="\n", err="") - assert run(p, "foo") == R(out="foo\n", err="") - assert run(p, "foo bar") == R(out="foo, bar\n", err="") + assert run(parser, "") == R(out="\n", err="") + assert run(parser, "foo") == R(out="foo\n", err="") + assert run(parser, "foo bar") == R(out="foo, bar\n", err="") def test_simple_function_kwargs(): @@ -107,29 +107,14 @@ def cmd(**kwargs): for k in sorted(kwargs): yield f"{k}: {kwargs[k]}" - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) message = "the following arguments are required: foo" - assert run(p, "", exit=True) == message - assert run(p, "hello") == R(out="bar: None\nfoo: hello\n", err="") - assert run(p, "--bar 123", exit=True) == message - assert run(p, "hello --bar 123") == R(out="bar: 123\nfoo: hello\n", err="") - - -@pytest.mark.xfail -def test_simple_function_multiple(): - raise NotImplementedError - - -@pytest.mark.xfail -def test_simple_function_nested(): - raise NotImplementedError - - -@pytest.mark.xfail -def test_class_method_as_command(): - raise NotImplementedError + assert run(parser, "", exit=True) == message + assert run(parser, "hello") == R(out="bar: None\nfoo: hello\n", err="") + assert run(parser, "--bar 123", exit=True) == message + assert run(parser, "hello --bar 123") == R(out="bar: 123\nfoo: hello\n", err="") def test_all_specs_in_one(): @@ -144,21 +129,21 @@ def cmd(foo, bar=1, *args, **kwargs): for k in sorted(kwargs): yield f"** {k}: {kwargs[k]}" - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) # 1) bar=1 is treated as --bar so positionals from @arg that go **kwargs # will still have higher priority than bar. # 2) *args, a positional with nargs="*", sits between two required # positionals (foo and fox), so it gets nothing. - assert run(p, "one two") == R( + assert run(parser, "one two") == R( out="foo: one\n" "bar: 1\n" "*args: ()\n" "** baz: None\n" "** fox: two\n", err="", ) # two required positionals (foo and fox) get an argument each and one extra # is left; therefore the middle one is given to *args. - assert run(p, "one two three") == R( + assert run(parser, "one two three") == R( out="foo: one\n" "bar: 1\n" "*args: ('two',)\n" @@ -169,7 +154,7 @@ def cmd(foo, bar=1, *args, **kwargs): # two required positionals (foo and fox) get an argument each and two extra # are left; both are given to *args (it's greedy). - assert run(p, "one two three four") == R( + assert run(parser, "one two three four") == R( out="foo: one\n" "bar: 1\n" "*args: ('two', 'three')\n" @@ -187,9 +172,9 @@ def test_arg_merged(): def gumby(my, brain=None): return my, brain, "hurts" - p = DebugArghParser("PROG") - p.set_default_command(gumby) - help_msg = p.format_help() + parser = DebugArghParser("PROG") + parser.set_default_command(gumby) + help_msg = parser.format_help() assert "a moose once bit my sister" in help_msg assert "i am made entirely of wood" in help_msg @@ -202,9 +187,9 @@ def test_arg_mismatch_positional(): def confuse_a_cat(vet, funny_things=123): return vet, funny_things - p = DebugArghParser("PROG") + parser = DebugArghParser("PROG") with pytest.raises(AssemblingError) as excinfo: - p.set_default_command(confuse_a_cat) + parser.set_default_command(confuse_a_cat) msg = ( "confuse_a_cat: argument bogus-argument does not fit " @@ -220,9 +205,9 @@ def test_arg_mismatch_flag(): def confuse_a_cat(vet, funny_things=123): return vet, funny_things - p = DebugArghParser("PROG") + parser = DebugArghParser("PROG") with pytest.raises(AssemblingError) as excinfo: - p.set_default_command(confuse_a_cat) + parser.set_default_command(confuse_a_cat) msg = ( "confuse_a_cat: argument --bogus-argument does not fit " @@ -238,9 +223,9 @@ def test_arg_mismatch_positional_vs_flag(): def func(foo=123): return foo - p = DebugArghParser("PROG") + parser = DebugArghParser("PROG") with pytest.raises(AssemblingError) as excinfo: - p.set_default_command(func) + parser.set_default_command(func) msg = ( 'func: argument "foo" declared as optional (in function signature)' @@ -256,9 +241,9 @@ def test_arg_mismatch_flag_vs_positional(): def func(foo): return foo - p = DebugArghParser("PROG") + parser = DebugArghParser("PROG") with pytest.raises(AssemblingError) as excinfo: - p.set_default_command(func) + parser.set_default_command(func) msg = ( 'func: argument "foo" declared as positional (in function signature)' @@ -280,23 +265,25 @@ def parrot(dead=False): def test_error_raised(self): parrot = self._get_parrot() - p = DebugArghParser() - p.set_default_command(parrot) + parser = DebugArghParser() + parser.set_default_command(parrot) - assert run(p, "") == R("beautiful plumage\n", "") + assert run(parser, "") == R("beautiful plumage\n", "") with pytest.raises(ValueError) as excinfo: - run(p, "--dead") + run(parser, "--dead") assert re.match("this parrot is no more", str(excinfo.value)) def test_error_wrapped(self): parrot = self._get_parrot() wrapped_parrot = argh.wrap_errors([ValueError])(parrot) - p = DebugArghParser() - p.set_default_command(wrapped_parrot) + parser = DebugArghParser() + parser.set_default_command(wrapped_parrot) - assert run(p, "") == R("beautiful plumage\n", "") - assert run(p, "--dead") == R("", "ValueError: this parrot is no more\n", exit=1) + assert run(parser, "") == R("beautiful plumage\n", "") + assert run(parser, "--dead") == R( + "", "ValueError: this parrot is no more\n", exit=1 + ) def test_processor(self): parrot = self._get_parrot() @@ -307,10 +294,10 @@ def failure(err): processed_parrot = argh.wrap_errors(processor=failure)(wrapped_parrot) - p = argh.ArghParser() - p.set_default_command(processed_parrot) + parser = argh.ArghParser() + parser.set_default_command(processed_parrot) - assert run(p, "--dead") == R("", "ERR: this parrot is no more!\n", exit=1) + assert run(parser, "--dead") == R("", "ERR: this parrot is no more!\n", exit=1) def test_stderr_vs_stdout(self): @argh.wrap_errors([KeyError]) @@ -318,47 +305,47 @@ def func(key): db = {"a": 1} return db[key] - p = argh.ArghParser() - p.set_default_command(func) + parser = argh.ArghParser() + parser.set_default_command(func) - assert run(p, "a") == R(out="1\n", err="") - assert run(p, "b") == R(out="", err="KeyError: 'b'\n", exit=1) + assert run(parser, "a") == R(out="1\n", err="") + assert run(parser, "b") == R(out="", err="KeyError: 'b'\n", exit=1) def test_argv(): def echo(text): return f"you said {text}" - p = DebugArghParser() - p.add_commands([echo]) + parser = DebugArghParser() + parser.add_commands([echo]) _argv = sys.argv sys.argv = sys.argv[:1] + ["echo", "hi there"] - assert run(p, None) == R("you said hi there\n", "") + assert run(parser, None) == R("you said hi there\n", "") sys.argv = _argv def test_commands_not_defined(): - p = DebugArghParser() + parser = DebugArghParser() - assert run(p, "", {"raw_output": True}).out == p.format_usage() - assert run(p, "").out == p.format_usage() + assert run(parser, "", {"raw_output": True}).out == parser.format_usage() + assert run(parser, "").out == parser.format_usage() - assert "unrecognized arguments" in run(p, "foo", exit=True) - assert "unrecognized arguments" in run(p, "--foo", exit=True) + assert "unrecognized arguments" in run(parser, "foo", exit=True) + assert "unrecognized arguments" in run(parser, "--foo", exit=True) def test_command_not_chosen(): def cmd(args): return 1 - p = DebugArghParser() - p.add_commands([cmd]) + parser = DebugArghParser() + parser.add_commands([cmd]) # returns a help message and doesn't exit - assert "usage:" in run(p, "").out + assert "usage:" in run(parser, "").out def test_invalid_choice(): @@ -367,23 +354,23 @@ def cmd(args): # root level command - p = DebugArghParser() - p.add_commands([cmd]) + parser = DebugArghParser() + parser.add_commands([cmd]) - assert "invalid choice" in run(p, "bar", exit=True) + assert "invalid choice" in run(parser, "bar", exit=True) # exits with an informative error - assert run(p, "--bar", exit=True) == "unrecognized arguments: --bar" + assert run(parser, "--bar", exit=True) == "unrecognized arguments: --bar" # nested command - p = DebugArghParser() - p.add_commands([cmd], group_name="nest") + parser = DebugArghParser() + parser.add_commands([cmd], group_name="nest") - assert "invalid choice" in run(p, "nest bar", exit=True) + assert "invalid choice" in run(parser, "nest bar", exit=True) # exits with an informative error - assert run(p, "nest --bar", exit=True) == "unrecognized arguments: --bar" + assert run(parser, "nest --bar", exit=True) == "unrecognized arguments: --bar" def test_unrecognized_arguments(): @@ -392,19 +379,19 @@ def cmd(): # single-command parser - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "--bar", exit=True) == "unrecognized arguments: --bar" - assert run(p, "bar", exit=True) == "unrecognized arguments: bar" + assert run(parser, "--bar", exit=True) == "unrecognized arguments: --bar" + assert run(parser, "bar", exit=True) == "unrecognized arguments: bar" # multi-command parser - p = DebugArghParser() - p.add_commands([cmd]) + parser = DebugArghParser() + parser.add_commands([cmd]) - assert run(p, "cmd --bar", exit=True) == "unrecognized arguments: --bar" - assert run(p, "cmd bar", exit=True) == "unrecognized arguments: bar" + assert run(parser, "cmd --bar", exit=True) == "unrecognized arguments: --bar" + assert run(parser, "cmd bar", exit=True) == "unrecognized arguments: bar" def test_echo(): @@ -413,10 +400,10 @@ def test_echo(): def echo(text): return f"you said {text}" - p = DebugArghParser() - p.add_commands([echo]) + parser = DebugArghParser() + parser.add_commands([echo]) - assert run(p, "echo foo") == R(out="you said foo\n", err="") + assert run(parser, "echo foo") == R(out="you said foo\n", err="") def test_bool_action(): @@ -425,11 +412,11 @@ def test_bool_action(): def parrot(dead=False): return "this parrot is no more" if dead else "beautiful plumage" - p = DebugArghParser() - p.add_commands([parrot]) + parser = DebugArghParser() + parser.add_commands([parrot]) - assert run(p, "parrot").out == "beautiful plumage\n" - assert run(p, "parrot --dead").out == "this parrot is no more\n" + assert run(parser, "parrot").out == "beautiful plumage\n" + assert run(parser, "parrot --dead").out == "this parrot is no more\n" def test_bare_group_name(): @@ -438,19 +425,19 @@ def test_bare_group_name(): def hello(): return "hello world" - p = DebugArghParser() - p.add_commands([hello], group_name="greet") + parser = DebugArghParser() + parser.add_commands([hello], group_name="greet") # without arguments # returns a help message and doesn't exit - assert "usage:" in run(p, "greet").out + assert "usage:" in run(parser, "greet").out # with an argument # exits with an informative error message = "unrecognized arguments: --name=world" - assert run(p, "greet --name=world", exit=True) == message + assert run(parser, "greet --name=world", exit=True) == message def test_function_under_group_name(): @@ -462,18 +449,18 @@ def hello(name="world"): def howdy(buddy): return f"Howdy {buddy}?" - p = DebugArghParser() - p.add_commands([hello, howdy], group_name="greet") + parser = DebugArghParser() + parser.add_commands([hello, howdy], group_name="greet") - assert run(p, "greet hello").out == "Hello world!\n" - assert run(p, "greet hello --name=John").out == "Hello John!\n" - assert run(p, "greet hello John", exit=True) == "unrecognized arguments: John" + assert run(parser, "greet hello").out == "Hello world!\n" + assert run(parser, "greet hello --name=John").out == "Hello John!\n" + assert run(parser, "greet hello John", exit=True) == "unrecognized arguments: John" # exits with an informative error message = "the following arguments are required: buddy" - assert message in run(p, "greet howdy --name=John", exit=True) - assert run(p, "greet howdy John").out == "Howdy John?\n" + assert message in run(parser, "greet howdy --name=John", exit=True) + assert run(parser, "greet howdy John").out == "Howdy John?\n" def test_explicit_cmd_name(): @@ -481,10 +468,10 @@ def test_explicit_cmd_name(): def orig_name(): return "ok" - p = DebugArghParser() - p.add_commands([orig_name]) - assert "invalid choice" in run(p, "orig-name", exit=True) - assert run(p, "new-name").out == "ok\n" + parser = DebugArghParser() + parser.add_commands([orig_name]) + assert "invalid choice" in run(parser, "orig-name", exit=True) + assert run(parser, "new-name").out == "ok\n" def test_aliases(): @@ -492,21 +479,21 @@ def test_aliases(): def alias1(): return "ok" - p = DebugArghParser() - p.add_commands([alias1]) + parser = DebugArghParser() + parser.add_commands([alias1]) - assert run(p, "alias1").out == "ok\n" - assert run(p, "alias2").out == "ok\n" - assert run(p, "alias3").out == "ok\n" + assert run(parser, "alias1").out == "ok\n" + assert run(parser, "alias2").out == "ok\n" + assert run(parser, "alias3").out == "ok\n" def test_help(): - p = DebugArghParser() + parser = DebugArghParser() # assert the commands don't fail - assert run(p, "--help", exit=True) == 0 - assert run(p, "greet --help", exit=True) == 0 - assert run(p, "greet hello --help", exit=True) == 0 + assert run(parser, "--help", exit=True) == 0 + assert run(parser, "greet --help", exit=True) == 0 + assert run(parser, "greet hello --help", exit=True) == 0 def test_arg_order(): @@ -517,9 +504,9 @@ def test_arg_order(): def cmd(foo, bar): return foo, bar - p = DebugArghParser() - p.set_default_command(cmd) - assert run(p, "foo bar").out == "foo\nbar\n" + parser = DebugArghParser() + parser.set_default_command(cmd) + assert run(parser, "foo bar").out == "foo\nbar\n" def test_raw_output(): @@ -528,22 +515,22 @@ def test_raw_output(): def cmd(foo, bar): return foo, bar - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "foo bar").out == "foo\nbar\n" - assert run(p, "foo bar", {"raw_output": True}).out == "foobar" + assert run(parser, "foo bar").out == "foo\nbar\n" + assert run(parser, "foo bar", {"raw_output": True}).out == "foobar" def test_output_file(): def cmd(): return "Hello world!" - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "").out == "Hello world!\n" - assert run(p, "", {"output_file": None}).out == "Hello world!\n" + assert run(parser, "").out == "Hello world!\n" + assert run(parser, "", {"output_file": None}).out == "Hello world!\n" def test_command_error(): @@ -554,16 +541,16 @@ def whiner_iterable(): yield "Hello..." raise argh.CommandError("I feel depressed.") - p = DebugArghParser() - p.add_commands([whiner_plain, whiner_iterable]) + parser = DebugArghParser() + parser.add_commands([whiner_plain, whiner_iterable]) - assert run(p, "whiner-plain") == R( + assert run(parser, "whiner-plain") == R( out="", err="CommandError: I feel depressed.\n", exit=1 ) - assert run(p, "whiner-plain --code=127") == R( + assert run(parser, "whiner-plain --code=127") == R( out="", err="CommandError: I feel depressed.\n", exit=127 ) - assert run(p, "whiner-iterable") == R( + assert run(parser, "whiner-iterable") == R( out="Hello...\n", err="CommandError: I feel depressed.\n", exit=1 ) @@ -574,12 +561,12 @@ def test_custom_argparse_namespace(): def cmd(args): return args.custom_value - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) namespace = argparse.Namespace() namespace.custom_value = "foo" - assert run(p, "", {"namespace": namespace}).out == "foo\n" + assert run(parser, "", {"namespace": namespace}).out == "foo\n" @pytest.mark.parametrize( @@ -611,10 +598,10 @@ def test_normalized_keys(): def cmd(a_b): return a_b - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "hello").out == "hello\n" + assert run(parser, "hello").out == "hello\n" @mock.patch("argh.assembling.COMPLETION_ENABLED", True) @@ -625,10 +612,10 @@ def test_custom_argument_completer(): def func(foo): pass - p = argh.ArghParser() - p.set_default_command(func) + parser = argh.ArghParser() + parser.set_default_command(func) - assert p._actions[-1].completer == "STUB" + assert parser._actions[-1].completer == "STUB" def test_class_members(): @@ -654,8 +641,8 @@ def static_meth2(value): controller = Controller() - p = DebugArghParser() - p.add_commands( + parser = DebugArghParser() + parser.add_commands( [ controller.instance_meth, controller.class_meth, @@ -664,10 +651,10 @@ def static_meth2(value): ] ) - assert run(p, "instance-meth foo").out == "foo\n123\n" - assert run(p, "class-meth foo").out == "foo\n123\n" - assert run(p, "static-meth foo").out == "foo\nw00t?\n" - assert run(p, "static-meth2 foo").out == "foo\nhuh!\n" + assert run(parser, "instance-meth foo").out == "foo\n123\n" + assert run(parser, "class-meth foo").out == "foo\n123\n" + assert run(parser, "static-meth foo").out == "foo\nw00t?\n" + assert run(parser, "static-meth2 foo").out == "foo\nhuh!\n" def test_kwonlyargs(): @@ -676,12 +663,15 @@ def test_kwonlyargs(): def cmd(*args, foo="1", bar, baz="3", **kwargs): return " ".join(args), foo, bar, baz, len(kwargs) - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "--baz=done test this --bar=do").out == "test this\n1\ndo\ndone\n0\n" + assert ( + run(parser, "--baz=done test this --bar=do").out + == "test this\n1\ndo\ndone\n0\n" + ) message = "the following arguments are required: --bar" - assert run(p, "test --foo=do", exit=True) == message + assert run(parser, "test --foo=do", exit=True) == message def test_default_arg_values_in_help(): @@ -698,17 +688,17 @@ def remind( ): return "Oh what is it now, can't you leave me in peace..." - p = DebugArghParser() - p.set_default_command(remind) + parser = DebugArghParser() + parser.set_default_command(remind) - assert "Basil" in p.format_help() - assert "Moose" in p.format_help() - assert "creatures" in p.format_help() + assert "Basil" in parser.format_help() + assert "Moose" in parser.format_help() + assert "creatures" in parser.format_help() # explicit help message is not obscured by the implicit one... - assert "remarkable animal" in p.format_help() + assert "remarkable animal" in parser.format_help() # ...but is still present - assert "it can speak" in p.format_help() + assert "it can speak" in parser.format_help() def test_default_arg_values_in_help__regression(): @@ -717,14 +707,14 @@ def test_default_arg_values_in_help__regression(): def foo(bar=""): return bar - p = DebugArghParser() - p.set_default_command(foo) + parser = DebugArghParser() + parser.set_default_command(foo) # doesn't break - p.format_help() + parser.format_help() # now check details - assert "-b BAR, --bar BAR ''" in p.format_help() + assert "-b BAR, --bar BAR ''" in parser.format_help() # note the empty str repr ^^^ @@ -743,10 +733,10 @@ def func(): """ return "hello" - p = DebugArghParser() - p.set_default_command(func) + parser = DebugArghParser() + parser.set_default_command(func) - assert func.__doc__ in p.format_help() + assert unindent(func.__doc__) in parser.format_help() def test_prog(capsys: pytest.CaptureFixture[str]): @@ -755,12 +745,12 @@ def test_prog(capsys: pytest.CaptureFixture[str]): def cmd(foo=1): return foo - p = DebugArghParser() - p.add_commands([cmd]) + parser = DebugArghParser() + parser.add_commands([cmd]) usage = get_usage_string() - assert run(p, "-h", exit=True) == 0 + assert run(parser, "-h", exit=True) == 0 captured = capsys.readouterr() assert captured.out.startswith(usage) @@ -769,14 +759,14 @@ def test_unknown_args(): def cmd(foo=1): return foo - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) get_usage_string("[-f FOO]") - assert run(p, "--foo 1") == R(out="1\n", err="") - assert run(p, "--bar 1", exit=True) == "unrecognized arguments: --bar 1" - assert run(p, "--bar 1", exit=False, kwargs={"skip_unknown_args": True}) == R( + assert run(parser, "--foo 1") == R(out="1\n", err="") + assert run(parser, "--bar 1", exit=True) == "unrecognized arguments: --bar 1" + assert run(parser, "--bar 1", exit=False, kwargs={"skip_unknown_args": True}) == R( out="1\n", err="" ) @@ -789,12 +779,12 @@ def first_func(foo=123): def second_func(): pass - p = argh.ArghParser(prog="myapp") - p.add_commands( + parser = argh.ArghParser(prog="myapp") + parser.add_commands( [first_func, second_func], ) - run(p, "--help", exit=True) + run(parser, "--help", exit=True) captured = capsys.readouterr() assert ( captured.out @@ -822,12 +812,12 @@ def first_func(foo=123): def second_func(): pass - p = argh.ArghParser(prog="myapp") - p.add_commands( + parser = argh.ArghParser(prog="myapp") + parser.add_commands( [first_func, second_func], ) - run(p, "first-func --help", exit=True) + run(parser, "first-func --help", exit=True) captured = capsys.readouterr() assert ( captured.out @@ -858,8 +848,8 @@ def first_func(foo=123): def second_func(): pass - p = argh.ArghParser(prog="myapp") - p.add_commands( + parser = argh.ArghParser(prog="myapp") + parser.add_commands( [first_func, second_func], group_name="my-group", group_kwargs={ @@ -868,7 +858,7 @@ def second_func(): }, ) - run(p, "--help", exit=True) + run(parser, "--help", exit=True) captured = capsys.readouterr() assert ( captured.out @@ -900,8 +890,8 @@ def first_func(foo=123): def second_func(): pass - p = argh.ArghParser(prog="myapp") - p.add_commands( + parser = argh.ArghParser(prog="myapp") + parser.add_commands( [first_func, second_func], group_name="my-group", group_kwargs={ @@ -910,7 +900,7 @@ def second_func(): }, ) - run(p, "my-group --help", exit=True) + run(parser, "my-group --help", exit=True) captured = capsys.readouterr() assert ( captured.out @@ -946,8 +936,8 @@ def first_func(foo=123): def second_func(): pass - p = argh.ArghParser(prog="myapp") - p.add_commands( + parser = argh.ArghParser(prog="myapp") + parser.add_commands( [first_func, second_func], group_name="my-group", group_kwargs={ @@ -956,7 +946,7 @@ def second_func(): }, ) - run(p, "my-group first-func --help", exit=True) + run(parser, "my-group first-func --help", exit=True) captured = capsys.readouterr() assert ( captured.out @@ -987,8 +977,8 @@ def first_func(foo=123): def second_func(): pass - p = argh.ArghParser(prog="myapp") - p.add_commands( + parser = argh.ArghParser(prog="myapp") + parser.add_commands( [first_func, second_func], func_kwargs={ "help": "func help override", @@ -996,7 +986,7 @@ def second_func(): }, ) - run(p, "--help", exit=True) + run(parser, "--help", exit=True) captured = capsys.readouterr() assert ( captured.out @@ -1029,8 +1019,8 @@ def first_func(foo=123): def second_func(): pass - p = argh.ArghParser(prog="myapp") - p.add_commands( + parser = argh.ArghParser(prog="myapp") + parser.add_commands( [first_func, second_func], func_kwargs={ "help": "func help override", @@ -1038,7 +1028,7 @@ def second_func(): }, ) - run(p, "first-func --help", exit=True) + run(parser, "first-func --help", exit=True) captured = capsys.readouterr() assert ( captured.out @@ -1062,12 +1052,12 @@ def func(**kwargs): verbosity = kwargs.get("verbose") return f"verbosity: {verbosity}" - p = DebugArghParser() - p.set_default_command(func) + parser = DebugArghParser() + parser.set_default_command(func) - assert run(p, "").out == "verbosity: 0\n" - assert run(p, "-v").out == "verbosity: 1\n" - assert run(p, "-vvvv").out == "verbosity: 4\n" + assert run(parser, "").out == "verbosity: 0\n" + assert run(parser, "-v").out == "verbosity: 1\n" + assert run(parser, "-vvvv").out == "verbosity: 4\n" def test_action_count__mixed(): @@ -1075,9 +1065,9 @@ def test_action_count__mixed(): def func(verbose=0): return f"verbosity: {verbose}" - p = DebugArghParser() - p.set_default_command(func) + parser = DebugArghParser() + parser.set_default_command(func) - assert run(p, "").out == "verbosity: 0\n" - assert run(p, "-v").out == "verbosity: 1\n" - assert run(p, "-vvvv").out == "verbosity: 4\n" + assert run(parser, "").out == "verbosity: 0\n" + assert run(parser, "-v").out == "verbosity: 1\n" + assert run(parser, "-vvvv").out == "verbosity: 4\n" diff --git a/tests/test_regressions.py b/tests/test_regressions.py index b12e9b8..0424e20 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -19,13 +19,13 @@ def test_regression_issue12(): def cmd(foo=1, fox=2): yield f"foo {foo}, fox {fox}" - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) - assert run(p, "").out == "foo 1, fox 2\n" - assert run(p, "--foo 3").out == "foo 3, fox 2\n" - assert run(p, "--fox 3").out == "foo 1, fox 3\n" - assert "unrecognized" in run(p, "-f 3", exit=True) + assert run(parser, "").out == "foo 1, fox 2\n" + assert run(parser, "--foo 3").out == "foo 3, fox 2\n" + assert run(parser, "--fox 3").out == "foo 1, fox 3\n" + assert "unrecognized" in run(parser, "-f 3", exit=True) def test_regression_issue12_help_flag(): @@ -39,14 +39,14 @@ def ddos(host="localhost"): return f"so be it, {host}!" # no help → no conflict - p = DebugArghParser("PROG", add_help=False) - p.set_default_command(ddos) - assert run(p, "-h 127.0.0.1").out == "so be it, 127.0.0.1!\n" + parser = DebugArghParser("PROG", add_help=False) + parser.set_default_command(ddos) + assert run(parser, "-h 127.0.0.1").out == "so be it, 127.0.0.1!\n" # help added → conflict → short name ignored - p = DebugArghParser("PROG", add_help=True) - p.set_default_command(ddos) - assert run(p, "-h 127.0.0.1", exit=True) == 0 + parser = DebugArghParser("PROG", add_help=True) + parser.set_default_command(ddos) + assert run(parser, "-h 127.0.0.1", exit=True) == 0 def test_regression_issue27(): @@ -67,16 +67,18 @@ def grenade(count=3): else: return "{0!r} is right out".format(count) - p = DebugArghParser() - p.add_commands([parrot, grenade]) + parser = DebugArghParser() + parser.add_commands([parrot, grenade]) # default → type (int) - assert run(p, "grenade").out == ("Three shall be the number " "thou shalt count\n") - assert run(p, "grenade --count 5").out == "5 is right out\n" + assert run(parser, "grenade").out == ( + "Three shall be the number " "thou shalt count\n" + ) + assert run(parser, "grenade --count 5").out == "5 is right out\n" # default → action (store_true) - assert run(p, "parrot").out == "beautiful plumage\n" - assert run(p, "parrot --dead").out == "this parrot is no more\n" + assert run(parser, "parrot").out == "beautiful plumage\n" + assert run(parser, "parrot --dead").out == "this parrot is no more\n" def test_regression_issue31(): @@ -94,11 +96,11 @@ def test_regression_issue31(): def cmd(**kwargs): yield kwargs.get("verbose", -1) - p = DebugArghParser() - p.set_default_command(cmd) - assert "0\n" == run(p, "").out - assert "1\n" == run(p, "-v").out - assert "2\n" == run(p, "-vv").out + parser = DebugArghParser() + parser.set_default_command(cmd) + assert "0\n" == run(parser, "").out + assert "1\n" == run(parser, "-v").out + assert "2\n" == run(parser, "-vv").out def test_regression_issue47(): @@ -106,9 +108,9 @@ def test_regression_issue47(): def func(foo_bar): return "hello" - p = DebugArghParser() + parser = DebugArghParser() with pytest.raises(argh.assembling.AssemblingError) as excinfo: - p.set_default_command(func) + parser.set_default_command(func) msg = ( 'func: argument "foo_bar" declared as positional (in function ' "signature) and optional (via decorator)" @@ -126,9 +128,9 @@ def test_regression_issue76(): def cmd(foo=""): pass - p = DebugArghParser() - p.set_default_command(cmd) - run(p, "--help", exit=True) + parser = DebugArghParser() + parser.set_default_command(cmd) + run(parser, "--help", exit=True) def test_regression_issue104(): @@ -144,7 +146,7 @@ def cmd(foo_foo, bar_bar, baz_baz=5, bip_bip=9, **kwargs): [str(foo_foo), str(bar_bar), str(baz_baz), str(bip_bip), str(kwargs)] ) - p = DebugArghParser() - p.set_default_command(cmd) + parser = DebugArghParser() + parser.set_default_command(cmd) expected = "abc\ndef\n8\n9\n{}\n" - assert run(p, "abc def --baz-baz 8").out == expected + assert run(parser, "abc def --baz-baz 8").out == expected