diff --git a/CHANGES.rst b/CHANGES.rst index 1ebe593..e38172e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -27,6 +27,21 @@ Backwards incompatible changes: pre_call_hook(ns) argh.run_endpoint_function(func, ns, ...) + - A new policy for mapping function arguments to CLI arguments is used by + default (see :class:`argh.assembling.NameMappingPolicy`). + In case you need to retain the CLI mapping but cannot modify the function + signature to use kwonly args for options, consider using this:: + + set_default_command( + func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + ) + + - The name mapping policy `BY_NAME_IF_HAS_DEFAULT` slightly deviates from the + old behaviour. Kwonly arguments without default values used to be marked as + required options (``--foo FOO``), now they are treated as positionals + (``foo``). Please consider the new default policy (`BY_NAME_IF_KWONLY`) for + a better treatment of kwonly. + Deprecated: - The `@expects_obj` decorator. Rationale: it used to support the old, @@ -51,6 +66,15 @@ Enhancements: Please note that the names may change in the upcoming versions. +- Configurable name mapping policy has been introduced for function argument + to CLI argument translation (#191 → #199): + + - `BY_NAME_IF_KWONLY` (default and recommended). + - `BY_NAME_IF_HAS_DEFAULT` (close to pre-v.0.30 behaviour); + + Please check API docs on :class:`argh.assembling.NameMappingPolicy` for + details. + Version 0.29.4 -------------- diff --git a/README.rst b/README.rst index 1640583..7f6ff38 100644 --- a/README.rst +++ b/README.rst @@ -145,7 +145,7 @@ A potentially modular application with more control over the process: "Returns given word as is." return text - def greet(name, greeting: str = "Hello") -> str: + def greet(name: str, greeting: str = "Hello") -> str: "Greets the user with given name. The greeting is customizable." return f"{greeting}, {name}!" diff --git a/docs/source/conf.py b/docs/source/conf.py index 68b8399..0d8b552 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -39,3 +39,5 @@ } nitpicky = True + +autodoc_typehints = "both" diff --git a/docs/source/index.rst b/docs/source/index.rst index f4197a7..f643c05 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -26,6 +26,7 @@ Details tutorial reference cookbook + the_story similar projects subparsers diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 2d71d8a..90d11d3 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -25,9 +25,6 @@ API Reference .. automodule:: argh.exceptions :members: -.. automodule:: argh.io - :members: - .. automodule:: argh.utils :members: diff --git a/docs/source/similar.rst b/docs/source/similar.rst index e519a25..f1c4e3e 100644 --- a/docs/source/similar.rst +++ b/docs/source/similar.rst @@ -25,7 +25,7 @@ supports Python3. Not every "yes" in this table would count as pro. * opster_ and finaloption_ support nested commands but are based on the outdated `optparse` library and therefore reimplement some features available in `argparse`. They also introduce decorators that don't just decorate - functions but change their behaviour, which is bad practice. + functions but change their behaviour, which is a questionable practice. * simpleopt_ has an odd API and is rather a simple replacement for standard libraries than an extension. * opterator_ is based on the outdated `optparse` and does not support nested @@ -36,11 +36,20 @@ supports Python3. Not every "yes" in this table would count as pro. worth migrating but it is surely very flexible and easy to use. * baker_ * plumbum_ -* docopt_ +* docopt_ takes an inverted approach: you write the usage docs, it generates a + parser. Then you need to wire the parsing results into you code manually. * aaargh_ * cliff_ * cement_ * autocommand_ +* click_ is a rather popular library, a bit younger than Argh. The authors of + both libraries even gave lightning talks on a PyCon within a few minutes :) + Although I expected it to kill Argh because it comes with Flask, in fact + it takes an approach so different from Argh that they can coexist. + Like Opster, Click's decorator replaces the underlying function (a + questionable practice); it does not derive the CLI arguments from the + function signature but entirely relies on additional decorators, while Argh + strives for the opposite. .. _argdeclare: http://code.activestate.com/recipes/576935-argdeclare-declarative-interface-to-argparse/ .. _argparse-cli: http://code.google.com/p/argparse-cli/ @@ -59,3 +68,4 @@ supports Python3. Not every "yes" in this table would count as pro. .. _cliff: http://pypi.python.org/pypi/cliff .. _cement: http://builtoncement.com/2.0/ .. _autocommand: https://pypi.python.org/pypi/autocommand/ +.. _click: https://click.palletsprojects.com diff --git a/docs/source/the_story.rst b/docs/source/the_story.rst new file mode 100644 index 0000000..45c04ac --- /dev/null +++ b/docs/source/the_story.rst @@ -0,0 +1,123 @@ +The Story of Argh +~~~~~~~~~~~~~~~~~ + +Early history +------------- + +Argh was first drafted by Andy in the airport while waiting for his flight. +The idea was to make a simplified wrapper for Argparse with support for nested +commands. We'll focus on the function arguments vs. CLI arguments here. + +This is what Argh began with (around 2010):: + + @arg("path", description="path to the file to load") + @arg("--file-format", choices=["yaml", "json"], default="json") + @arg("--dry-run", default=False) + def load(args): + do_something(args.path, args.file_format, args.dry_run) + + argh.dispatch_command(load) + +You don't have to remember the details of the underlying Argparse interface +(especially for subparsers); you would still declare almost everything, but in +one place, close to the function itself. + +"The Natural Way" +----------------- + +In late 2012 the behaviour previously available via `@plain_signature` +decorator became standard:: + + + @arg("path", help="path to the file to load") + @arg("--file-format", choices=["yaml", "json"]) + def load(path, file_format="json", dry_run=False): + do_something(path, file_format, dry_run) + + argh.dispatch_command(load) + +This unleashed the killer feature of Argh: now you can write normal functions — +not for argparse but general-purpose ones. Argh would infer the basic CLI +argument definitions straight from the function signature. The types and some +actions (e.g. `store_true`) would be inferred from defaults. You would only need +to use the `@arg` decorator to enhance the information with something that had +no place in function signature of the Python 2.x era. + +There's still an little ugly thing about it: you have to mention the argument +name twice, in function signature and the decorator. Also the type cannot be +inferred if there's no default value, so you'd have to use the decorator even +for that. + +Hiatus +------ + +The primary author's new job required focus on other languages for a number of +years and he had no energy to develop his FOSS projects, although he continued +using Argh for his own purposes on a daily basis. + +A few forks were created by other developers but none survived. (The forks, +not developers.) + +By coincidence, around the beginning of this period a library called Click was +shipped with Flask and it seemed obvious that it will become the new standard +for simple CLI APIs and Argh wouldn't be needed. (Plot twist: it did become +popular but its goals are too different from Argh's to replace it.) + +Revival +------- + +The author returned to his FOSS projects in early 2023. To his surprise, Argh +was not dead at all and its niche as the "natural API" was not occupied by any +other project. It actually made sense to revive it. + +A deep modernisation and refactoring began. + +A number of pending issues were resolved and the last version to support +Python 2.x was released with a bunch of bugfixes. + +The next few releases have deprecated and removed a lot of outdated features +and paved the way to a better Argh. Some design decisions have been revised +and the streamlined. The work continues. + +Goodbye Decorators +------------------ + +As type hints became mature and widespread in Python code, the old approach +with decorators seems to make less and less sense. A lot more can be now +inferred directly from the signature. In fact, possibly everything. + +Here's what Argh is heading for (around 2024). + +A minimal example (note how there's literally nothing CLI-specific here):: + + def load(path: str, *, file_format: str = "json", dry_run: bool = False) -> str: + return do_something(path, file_format, dry_run) + + argh.dispatch_command(load) + +A more complete example:: + + from typing import Annotated + from argh import Choices, Help + + def load( + path: Annotated[str, Help("path to the file to load")], + *, + file_format: Annotated[str, Choices(FORMAT_CHOICES))] = DEFAULT_FORMAT, + dry_run: bool = False + ) -> str: + return do_something(path, file_format, dry_run) + + argh.dispatch_command(load) + +The syntax is subject to change but the essence is clear: + +* as few surprises to the reader as possible; +* the function used as a CLI command is declared and callable in the normal + way, like any other function; +* type hints are used instead of ``@arg("foo", type=int)`` +* additional metadata can be injected into type hints when necessary in a way + that won't confuse type checkers (like in FastAPI_, requires Python 3.9+); +* non-kwonly become CLI positionals, kwonly become CLI options. + +.. _FastAPI: https://fastapi.tiangolo.com/python-types/#type-hints-with-metadata-annotations diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 17b1539..03495cd 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -83,7 +83,7 @@ knowing about `argh`:: When executed as ``app.py my-command --help``, such application prints:: - usage: app.py my-command [-h] [-b BETA] [-g] alpha [delta [delta ...]] + usage: app.py my-command [-h] [-b BETA] [-g] alpha [delta ...] positional arguments: alpha @@ -123,6 +123,24 @@ Verbose, hardly readable, requires learning another API. Hey, that's a lot for such a simple case! But then, that's why the API feels natural: `argh` does a lot of work for you. +.. note:: + + The pattern described above is the "by name if has default" mapping policy. + It used to be *the* policy but starting with Argh v.0.30 there's a better + one, "by name if kwonly". Although the older one will remain the default + policy for a while, it may be eventually dropped in favour of the new one. + + Please check `~argh.assembling.NameMappingPolicy` for details. + + Usage example:: + + def my_command(alpha, beta=1, *, gamma, delta=False, **kwargs): + ... + + argh.dispatch_command( + my_command, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + Well, there's nothing more elegant than a simple function. But simplicity comes at a cost in terms of flexibility. Fortunately, `argh` doesn't stay in the way and offers less natural but more powerful tools. diff --git a/pyproject.toml b/pyproject.toml index 9c5efc7..57c8157 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,3 +90,6 @@ include = [ "tests/", "tox.ini", ] + +[tool.doc8] +max-line-length = 95 diff --git a/src/argh/assembling.py b/src/argh/assembling.py index 66015f8..737b958 100644 --- a/src/argh/assembling.py +++ b/src/argh/assembling.py @@ -14,10 +14,11 @@ Functions and classes to properly assemble your commands in a parser. """ import inspect -from argparse import ZERO_OR_MORE, ArgumentParser +from argparse import OPTIONAL, ZERO_OR_MORE, ArgumentParser from collections import OrderedDict from dataclasses import asdict -from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Type, Union +from enum import Enum +from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple from argh.completion import COMPLETION_ENABLED from argh.constants import ( @@ -37,27 +38,92 @@ "set_default_command", "add_commands", "add_subcommands", + "NameMappingPolicy", ] -def extract_parser_add_argument_kw_from_signature( +class NameMappingPolicy(Enum): + """ + Represents possible approaches to treat default values when inferring argument + specification from function signature. + + * `BY_NAME_IF_KWONLY` is the default and recommended approach introduced + in v0.30. It enables fine control over two aspects: + + * positional vs named; + * required vs optional. + + "Normal" arguments are identified by position, "kwonly" are identified by + name, regardless of the presence of default values. A positional with a + default value becomes optional but still positional (``nargs=OPTIONAL``). + A kwonly argument without a default value becomes a required named + argument. + + Example:: + + def func(alpha, beta=1, *, gamma, delta=2): ... + + is equivalent to:: + + prog alpha [beta] --gamma [--delta DELTA] + + That is, `alpha` and `--gamma` are mandatory while `beta` and `--delta` + are optional (they have default values). + + * `BY_NAME_IF_HAS_DEFAULT` is very close to the the legacy approach + (pre-v0.30). If a function argument has a default value, it becomes an + "option" (called by name, like ``--foo``); otherwise it's treated as a + positional argument. + + Example:: + + def func(alpha, beta=1, *, gamma, delta=2): ... + + is equivalent to:: + + prog [--beta BETA] [--delta DELTA] alpha gamma + + That is, `alpha` and `gamma` are mandatory and positional, while `--beta` + and `--delta` are optional (they have default values). Note that it's + impossible to have an optional positional or a mandatory named argument. + + The difference between this policy and the behaviour of Argh before + v0.30 is in the treatment of kwonly arguments without default values: + they used to become ``--foo FOO`` (required) but for the sake of + simplicity they are treated as positionals. If you are already using + kwonly args, please consider the better suited policy `BY_NAME_IF_KWONLY` + instead. + + It is recommended to migrate any older code to `BY_NAME_IF_KWONLY`. + + .. versionadded:: 0.30 + """ + + BY_NAME_IF_HAS_DEFAULT = "specify CLI argument by name if it has a default value" + BY_NAME_IF_KWONLY = "specify CLI argument by name if it comes from kwonly" + + +def infer_argspecs_from_function( function: Callable, + name_mapping_policy: Optional[ + NameMappingPolicy + ] = NameMappingPolicy.BY_NAME_IF_KWONLY, ) -> Iterator[ParserAddArgumentSpec]: if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False): return + if name_mapping_policy not in NameMappingPolicy: + raise NotImplementedError(f"Unknown name mapping policy {name_mapping_policy}") + func_spec = get_arg_spec(function) - defaults: Dict[str, Any] = dict( + default_by_arg_name: Dict[str, Any] = dict( zip(reversed(func_spec.args), reversed(func_spec.defaults or tuple())) ) - defaults.update(getattr(func_spec, "kwonlydefaults", None) or {}) - - kwonly = getattr(func_spec, "kwonlyargs", []) # define the list of conflicting option strings # (short forms, i.e. single-character ones) - named_args = set(list(defaults) + kwonly) + named_args = set(list(default_by_arg_name) + func_spec.kwonlyargs) named_arg_chars = [a[0] for a in named_args] named_arg_char_counts = dict( (char, named_arg_chars.count(char)) for char in set(named_arg_chars) @@ -66,46 +132,69 @@ def extract_parser_add_argument_kw_from_signature( char for char in named_arg_char_counts if 1 < named_arg_char_counts[char] ) - 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 + def _make_cli_arg_names_options(arg_name) -> Tuple[List[str], List[str]]: + cliified_arg_name = arg_name.replace("_", "-") + positionals = [cliified_arg_name] + can_have_short_opt = arg_name[0] not in conflicting_opts + + if can_have_short_opt: + options = [f"-{cliified_arg_name[0]}", f"--{cliified_arg_name}"] + else: + options = [f"--{cliified_arg_name}"] + + return positionals, options + + default_value: Any + for arg_name in func_spec.args: + cli_arg_names_positional, cli_arg_names_options = _make_cli_arg_names_options( + arg_name + ) + default_value = default_by_arg_name.get(arg_name, NotDefined) + + arg_spec = ParserAddArgumentSpec( + func_arg_name=arg_name, + cli_arg_names=cli_arg_names_positional, + default_value=default_value, + ) - if name in defaults or name in kwonly: - if name in defaults: - default_value = defaults.get(name) + if default_value != NotDefined: + if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_KWONLY: + arg_spec.nargs = OPTIONAL else: - is_required = True - cli_arg_names = [f"-{name[0]}", f"--{name}"] - if name.startswith(conflicting_opts): - # remove short name - cli_arg_names = cli_arg_names[1:] + arg_spec.cli_arg_names = cli_arg_names_options - else: - # positional argument - cli_arg_names = [name] + yield arg_spec + + for arg_name in func_spec.kwonlyargs: + cli_arg_names_positional, cli_arg_names_options = _make_cli_arg_names_options( + arg_name + ) - # cmd(foo_bar) -> add_argument("foo-bar") - cli_arg_names = [ - x.replace("_", "-") if x.startswith("-") else x for x in cli_arg_names - ] + if func_spec.kwonlydefaults and arg_name in func_spec.kwonlydefaults: + default_value = func_spec.kwonlydefaults[arg_name] + else: + default_value = NotDefined - spec = ParserAddArgumentSpec( - name, - cli_arg_names, + arg_spec = ParserAddArgumentSpec( + func_arg_name=arg_name, + cli_arg_names=cli_arg_names_positional, default_value=default_value, - other_add_parser_kwargs=akwargs, ) - if is_required != NotDefined: - spec.is_required = is_required - yield spec + + if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_KWONLY: + arg_spec.cli_arg_names = cli_arg_names_options + if default_value == NotDefined: + arg_spec.is_required = True + else: + if default_value != NotDefined: + arg_spec.cli_arg_names = cli_arg_names_options + + yield arg_spec if func_spec.varargs: - # *args yield ParserAddArgumentSpec( - func_spec.varargs, - [func_spec.varargs], + func_arg_name=func_spec.varargs, + cli_arg_names=[func_spec.varargs.replace("_", "-")], nargs=ZERO_OR_MORE, ) @@ -129,8 +218,10 @@ def guess_extra_parser_add_argument_spec_kwargs( (if action was not explicitly defined). """ + # TODO: use typing to extract other_add_parser_kwargs = parser_add_argument_spec.other_add_parser_kwargs guessed: Dict[str, Any] = {} + is_positional = not parser_add_argument_spec.cli_arg_names[0].startswith("-") # Parser actions that accept argument 'type' TYPE_AWARE_ACTIONS = "store", "append" @@ -139,8 +230,9 @@ def guess_extra_parser_add_argument_spec_kwargs( 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: + if not is_positional and other_add_parser_kwargs.get("action") is None: # infer action from default value + # (not applicable to positionals: _StoreAction doesn't accept `nargs`) guessed["action"] = "store_false" if default_value else "store_true" elif other_add_parser_kwargs.get("type") is None: # infer type from default value @@ -157,13 +249,28 @@ def guess_extra_parser_add_argument_spec_kwargs( return guessed -def set_default_command(parser, function: Callable) -> None: +def set_default_command( + parser, + function: Callable, + name_mapping_policy: NameMappingPolicy = NameMappingPolicy.BY_NAME_IF_KWONLY, +) -> None: """ Sets default command (i.e. a function) for given parser. If `parser.description` is empty and the function has a docstring, it is used as the description. + :param function: + + The function to use as the command. + + :name_mapping_policy: + + The policy to use when mapping function arguments onto CLI arguments. + See :class:`.NameMappingPolicy`. + + .. versionadded:: 0.30 + .. note:: If there are both explicitly declared arguments (e.g. via @@ -183,11 +290,10 @@ def set_default_command(parser, function: Callable) -> None: declared_args: List[ParserAddArgumentSpec] = getattr(function, ATTR_ARGS, []) inferred_args: List[ParserAddArgumentSpec] = list( - extract_parser_add_argument_kw_from_signature(function) + infer_argspecs_from_function(function, name_mapping_policy=name_mapping_policy) ) 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." @@ -278,7 +384,7 @@ def _merge_inferred_and_declared_args( # "foo-bar" → "foo_bar" # # * argument declaration is a dictionary representing an argument; - # it is obtained either from extract_parser_add_argument_kw_from_signature() + # it is obtained either from infer_argspecs_from_function() # or from an @arg decorator (as is). # specs_by_func_arg_name = OrderedDict() @@ -354,6 +460,7 @@ def _is_positional(args: List[str], prefix_chars: str = "-") -> bool: def add_commands( parser: ArgumentParser, functions: List[Callable], + name_mapping_policy: NameMappingPolicy = NameMappingPolicy.BY_NAME_IF_KWONLY, group_name: Optional[str] = None, group_kwargs: Optional[Dict[str, Any]] = None, func_kwargs: Optional[Dict[str, Any]] = None, @@ -375,6 +482,12 @@ def add_commands( function name. Note that the underscores in the name are replaced with hyphens, i.e. function name "foo_bar" becomes command name "foo-bar". + :param name_mapping_policy: + + See :class:`argh.assembling.NameMappingPolicy`. + + .. versionadded:: 0.30 + :param group_name: an optional string representing the group of commands. For example, if @@ -440,7 +553,9 @@ def add_commands( # create and set up the parser for this command command_parser = subparsers_action.add_parser(cmd_name, **func_parser_kwargs) - set_default_command(command_parser, func) + set_default_command( + command_parser, func, name_mapping_policy=name_mapping_policy + ) def _extract_command_meta_from_func(func: Callable) -> Tuple[str, dict]: diff --git a/src/argh/dispatching.py b/src/argh/dispatching.py index 1a62f22..d3e0a60 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -193,7 +193,7 @@ def parse_and_resolve( skip_unknown_args: bool = False, ) -> Tuple[Optional[Callable], argparse.Namespace]: """ - .. versionadded: 0.30 + .. versionadded:: 0.30 Parses CLI arguments and resolves the endpoint function. """ @@ -227,7 +227,7 @@ def run_endpoint_function( raw_output: bool = False, ) -> Optional[str]: """ - .. versionadded: 0.30 + .. versionadded:: 0.30 Extracts arguments from the namespace object, calls the endpoint function and processes its output. @@ -327,16 +327,17 @@ def _flat_key(key): # *args if spec.varargs: - positional += getattr(namespace_obj, spec.varargs) + positional += all_input[spec.varargs] # **kwargs varkw = getattr(spec, "varkw", getattr(spec, "keywords", [])) if varkw: not_kwargs = [DEST_FUNCTION] + spec.args + [spec.varargs] + kwonly for k in vars(namespace_obj): - if k.startswith("_") or k in not_kwargs: + normalized_k = _flat_key(k) + if k.startswith("_") or normalized_k in not_kwargs: continue - keywords[k] = getattr(namespace_obj, k) + keywords[normalized_k] = getattr(namespace_obj, k) result = function(*positional, **keywords) diff --git a/tests/base.py b/tests/base.py index e0daf51..d8e68c7 100644 --- a/tests/base.py +++ b/tests/base.py @@ -11,9 +11,9 @@ # hacky constructor for default exit value -def CmdResult(out, err, exit=None): - _CmdResult = namedtuple("CmdResult", ("out", "err", "exit")) - return _CmdResult(out, err, exit) +def CmdResult(out, err, exit_code=None): + _CmdResult = namedtuple("CmdResult", ("out", "err", "exit_code")) + return _CmdResult(out, err, exit_code) class DebugArghParser(ArghParser): @@ -43,16 +43,16 @@ def call_cmd(parser, command_string, **kwargs): result = parser.dispatch(args, **kwargs) except SystemExit as e: result = None - exit = e.code or 0 # e.code may be None + exit_code = e.code or 0 # e.code may be None else: - exit = None + exit_code = None if kwargs.get("output_file") is None: - return CmdResult(out=result, err=io_err.read(), exit=exit) - else: - io_out.seek(0) - io_err.seek(0) - return CmdResult(out=io_out.read(), err=io_err.read(), exit=exit) + return CmdResult(out=result, err=io_err.read(), exit_code=exit_code) + + io_out.seek(0) + io_err.seek(0) + return CmdResult(out=io_out.read(), err=io_err.read(), exit_code=exit_code) def run(parser, command_string, kwargs=None, exit=False): @@ -67,10 +67,9 @@ def run(parser, command_string, kwargs=None, exit=False): kwargs = kwargs or {} result = call_cmd(parser, command_string, **kwargs) if exit: - if result.exit is None: + if result.exit_code is None: raise AssertionError("Did not exit") - else: - return result.exit + return result.exit_code return result diff --git a/tests/test_assembling.py b/tests/test_assembling.py index 5215a39..ed9d47a 100644 --- a/tests/test_assembling.py +++ b/tests/test_assembling.py @@ -8,7 +8,7 @@ import pytest import argh -from argh.assembling import AssemblingError +from argh.assembling import AssemblingError, NameMappingPolicy from argh.dto import ParserAddArgumentSpec @@ -52,13 +52,21 @@ def test_guess_type_from_default(): def test_guess_action_from_default(): - # True → store_false + # positional, default True → ignore given = ParserAddArgumentSpec("foo", ["foo"], default_value=False) + assert {} == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) + + # named, default True → store_false + 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 + # positional, default False → ignore given = ParserAddArgumentSpec("foo", ["foo"], default_value=True) + assert {} == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) + + # named, True → store_false + given = ParserAddArgumentSpec("foo", ["--foo"], default_value=True) guessed = {"action": "store_false"} assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) @@ -75,6 +83,26 @@ def test_guess_action_from_default(): assert guessed == argh.assembling.guess_extra_parser_add_argument_spec_kwargs(given) +def test_positional_with_default_int(): + def func(pos_int_default=123): + ... + + parser = argh.ArghParser(prog="test") + parser.set_default_command(func) + assert parser.format_usage() == "usage: test [-h] [pos-int-default]\n" + assert "pos-int-default 123" in parser.format_help() + + +def test_positional_with_default_bool(): + def func(pos_bool_default=False): + ... + + parser = argh.ArghParser(prog="test") + parser.set_default_command(func) + assert parser.format_usage() == "usage: test [-h] [pos-bool-default]\n" + assert "pos-bool-default False" in parser.format_help() + + def test_set_default_command(): def func(**kwargs): pass @@ -86,7 +114,7 @@ def func(**kwargs): ParserAddArgumentSpec( func_arg_name="foo", cli_arg_names=("foo",), - nargs="+", + nargs=argparse.ONE_OR_MORE, other_add_parser_kwargs={"choices": [1, 2], "help": "me"}, ), ParserAddArgumentSpec( @@ -103,7 +131,7 @@ def func(**kwargs): argh.set_default_command(parser, func) assert parser.add_argument.mock_calls == [ - call("foo", nargs="+", choices=[1, 2], help="me", type=int), + call("foo", nargs=argparse.ONE_OR_MORE, choices=[1, 2], help="me", type=int), call( "-b", "--bar", @@ -249,7 +277,7 @@ def func(bar): ParserAddArgumentSpec( func_arg_name="x", cli_arg_names=("foo",), - nargs="+", + nargs=argparse.ONE_OR_MORE, other_add_parser_kwargs={"choices": [1, 2], "help": "me"}, ), ), @@ -292,7 +320,8 @@ def func(foo): argh.set_default_command(parser, func) -def test_set_default_command_infer_cli_arg_names_from_func_signature(): +@pytest.fixture() +def big_command_with_everything(): # 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 # there's a pos req arg, etc.) @@ -338,17 +367,27 @@ def func( kwargs, ) + yield func + + +def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_legacy( + big_command_with_everything, +): + name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + parser = argh.ArghParser() parser.add_argument = MagicMock() parser.set_defaults = MagicMock() - argh.set_default_command(parser, func) + argh.set_default_command( + parser, big_command_with_everything, name_mapping_policy=name_mapping_policy + ) help_tmpl = argh.constants.DEFAULT_ARGUMENT_TEMPLATE assert parser.add_argument.mock_calls == [ - call("alpha_pos_req", help="%(default)s"), - call("beta_pos_req", help="%(default)s"), + call("alpha-pos-req", help="%(default)s"), + call("beta-pos-req", help="%(default)s"), call("-a", "--alpha-pos-opt", default="alpha", type=str, help=help_tmpl), call("--beta-pos-opt-one", default="beta one", type=str, help=help_tmpl), call("--beta-pos-opt-two", default="beta two", type=str, help=help_tmpl), @@ -356,15 +395,91 @@ def func( call("--delta-pos-opt", default="delta named", type=str, help=help_tmpl), call("-t", "--theta-pos-opt", default="theta named", type=str, help=help_tmpl), call("--gamma-kwonly-opt", default="gamma kwonly", type=str, help=help_tmpl), + call("delta-kwonly-req", help=help_tmpl), + call("epsilon-kwonly-req-one", help=help_tmpl), + call("epsilon-kwonly-req-two", help=help_tmpl), + call( + "-z", "--zeta-kwonly-opt", default="zeta kwonly", type=str, help=help_tmpl + ), + call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl), + ] + assert parser.set_defaults.mock_calls == [ + call(function=big_command_with_everything) + ] + + +def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_modern( + big_command_with_everything, +): + name_mapping_policy = NameMappingPolicy.BY_NAME_IF_KWONLY + + parser = argh.ArghParser() + + parser.add_argument = MagicMock() + parser.set_defaults = MagicMock() + + argh.set_default_command( + parser, big_command_with_everything, name_mapping_policy=name_mapping_policy + ) + + help_tmpl = argh.constants.DEFAULT_ARGUMENT_TEMPLATE + assert parser.add_argument.mock_calls == [ + call("alpha-pos-req", help="%(default)s"), + call("beta-pos-req", help="%(default)s"), + call( + "alpha-pos-opt", + default="alpha", + nargs=argparse.OPTIONAL, + type=str, + help=help_tmpl, + ), + call( + "beta-pos-opt-one", + default="beta one", + nargs=argparse.OPTIONAL, + type=str, + help=help_tmpl, + ), + call( + "beta-pos-opt-two", + default="beta two", + nargs=argparse.OPTIONAL, + type=str, + help=help_tmpl, + ), + call( + "gamma-pos-opt", + default="gamma named", + nargs=argparse.OPTIONAL, + type=str, + help=help_tmpl, + ), + call( + "delta-pos-opt", + default="delta named", + nargs=argparse.OPTIONAL, + type=str, + help=help_tmpl, + ), + call( + "theta-pos-opt", + default="theta named", + nargs=argparse.OPTIONAL, + type=str, + help=help_tmpl, + ), + call("--gamma-kwonly-opt", default="gamma kwonly", type=str, help=help_tmpl), call("--delta-kwonly-req", required=True, help=help_tmpl), call("--epsilon-kwonly-req-one", required=True, help=help_tmpl), call("--epsilon-kwonly-req-two", required=True, help=help_tmpl), call( "-z", "--zeta-kwonly-opt", default="zeta kwonly", type=str, help=help_tmpl ), - call("args", nargs="*", help=help_tmpl), + call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl), + ] + assert parser.set_defaults.mock_calls == [ + call(function=big_command_with_everything) ] - assert parser.set_defaults.mock_calls == [call(function=func)] def test_set_default_command_docstring(): @@ -379,6 +494,21 @@ def func(): assert parser.description == "docstring" +def test_set_default_command__varkwargs_sharing_prefix(): + def func(*, alpha: str = "Alpha", aleph: str = "Aleph"): + ... + + parser = argh.ArghParser() + parser.add_argument = MagicMock() + + argh.set_default_command(parser, func) + + assert parser.add_argument.mock_calls == [ + call("--alpha", default="Alpha", type=str, help="%(default)s"), + call("--aleph", default="Aleph", type=str, help="%(default)s"), + ] + + def test_add_subparsers_when_default_command_exists(): def one(): return 1 @@ -465,7 +595,11 @@ def func(*file_paths): argh.set_default_command(parser, func) assert parser.add_argument.mock_calls == [ - call("file_paths", nargs="*", help=argh.constants.DEFAULT_ARGUMENT_TEMPLATE), + call( + "file-paths", + nargs=argparse.ZERO_OR_MORE, + help=argh.constants.DEFAULT_ARGUMENT_TEMPLATE, + ), ] @@ -488,22 +622,45 @@ def func(x, **kwargs): ] -def test_kwonlyargs(): +def test_kwonlyargs__policy_legacy(): "Correctly processing required and optional keyword-only arguments" def cmd(foo_pos, bar_pos, *args, foo_kwonly="foo_kwonly", bar_kwonly): return (foo_pos, bar_pos, args, foo_kwonly, bar_kwonly) - p = argh.ArghParser() - p.add_argument = MagicMock() - p.set_default_command(cmd) + parser = argh.ArghParser() + parser.add_argument = MagicMock() + parser.set_default_command( + cmd, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + ) help_tmpl = argh.constants.DEFAULT_ARGUMENT_TEMPLATE - assert p.add_argument.mock_calls == [ - call("foo_pos", help=help_tmpl), - call("bar_pos", help=help_tmpl), + assert parser.add_argument.mock_calls == [ + call("foo-pos", help=help_tmpl), + call("bar-pos", help=help_tmpl), + call("-f", "--foo-kwonly", default="foo_kwonly", type=str, help=help_tmpl), + call("bar-kwonly", help=help_tmpl), + call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl), + ] + + +def test_kwonlyargs__policy_modern(): + "Correctly processing required and optional keyword-only arguments" + + def cmd(foo_pos, bar_pos, *args, foo_kwonly="foo_kwonly", bar_kwonly): + return (foo_pos, bar_pos, args, foo_kwonly, bar_kwonly) + + parser = argh.ArghParser() + parser.add_argument = MagicMock() + parser.set_default_command( + cmd, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + help_tmpl = argh.constants.DEFAULT_ARGUMENT_TEMPLATE + assert parser.add_argument.mock_calls == [ + call("foo-pos", help=help_tmpl), + call("bar-pos", help=help_tmpl), call("-f", "--foo-kwonly", default="foo_kwonly", type=str, help=help_tmpl), call("-b", "--bar-kwonly", required=True, help=help_tmpl), - call("args", nargs="*", help=help_tmpl), + call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl), ] diff --git a/tests/test_integration.py b/tests/test_integration.py index 3ca3067..b5206e7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -6,10 +6,12 @@ import re import sys import unittest.mock as mock +from enum import Enum import pytest import argh +from argh.assembling import NameMappingPolicy from argh.exceptions import AssemblingError from argh.utils import unindent @@ -23,7 +25,7 @@ def test_set_default_command_integration(): - def cmd(foo=1): + def cmd(*, foo=1): return foo parser = DebugArghParser() @@ -36,7 +38,7 @@ def cmd(foo=1): def test_set_default_command_integration_merging(): @argh.arg("--foo", help="bar") - def cmd(foo=1): + def cmd(*, foo=1): return foo parser = DebugArghParser() @@ -74,7 +76,7 @@ def cmd(x): def test_simple_function_defaults(): - def cmd(x="foo"): + def cmd(*, x="foo"): yield x parser = DebugArghParser() @@ -122,7 +124,7 @@ def test_all_specs_in_one(): @argh.arg("--bar") @argh.arg("fox") @argh.arg("--baz") - def cmd(foo, bar=1, *args, **kwargs): + def cmd(foo, *args, bar=1, **kwargs): yield f"foo: {foo}" yield f"bar: {bar}" yield f"*args: {args}" @@ -169,7 +171,7 @@ def test_arg_merged(): @argh.arg("my", help="a moose once bit my sister") @argh.arg("-b", "--brain", help="i am made entirely of wood") - def gumby(my, brain=None): + def gumby(my, *, brain=None): return my, brain, "hurts" parser = DebugArghParser("PROG") @@ -184,7 +186,7 @@ def test_arg_mismatch_positional(): """An `@arg("positional")` must match function signature.""" @argh.arg("bogus-argument") - def confuse_a_cat(vet, funny_things=123): + def confuse_a_cat(vet, *, funny_things=123): return vet, funny_things parser = DebugArghParser("PROG") @@ -202,7 +204,7 @@ def test_arg_mismatch_flag(): """An `@arg("--flag")` must match function signature.""" @argh.arg("--bogus-argument") - def confuse_a_cat(vet, funny_things=123): + def confuse_a_cat(vet, *, funny_things=123): return vet, funny_things parser = DebugArghParser("PROG") @@ -220,7 +222,7 @@ def test_arg_mismatch_positional_vs_flag(): """An `@arg("arg")` must match a positional arg in function signature.""" @argh.arg("foo") - def func(foo=123): + def func(*, foo=123): return foo parser = DebugArghParser("PROG") @@ -254,7 +256,7 @@ def func(foo): class TestErrorWrapping: def _get_parrot(self): - def parrot(dead=False): + def parrot(*, dead=False): if dead: raise ValueError("this parrot is no more") else: @@ -282,7 +284,7 @@ def test_error_wrapped(self): assert run(parser, "") == R("beautiful plumage\n", "") assert run(parser, "--dead") == R( - "", "ValueError: this parrot is no more\n", exit=1 + "", "ValueError: this parrot is no more\n", exit_code=1 ) def test_processor(self): @@ -297,7 +299,9 @@ def failure(err): parser = argh.ArghParser() parser.set_default_command(processed_parrot) - assert run(parser, "--dead") == R("", "ERR: this parrot is no more!\n", exit=1) + assert run(parser, "--dead") == R( + "", "ERR: this parrot is no more!\n", exit_code=1 + ) def test_stderr_vs_stdout(self): @argh.wrap_errors([KeyError]) @@ -309,7 +313,7 @@ def func(key): parser.set_default_command(func) assert run(parser, "a") == R(out="1\n", err="") - assert run(parser, "b") == R(out="", err="KeyError: 'b'\n", exit=1) + assert run(parser, "b") == R(out="", err="KeyError: 'b'\n", exit_code=1) def test_argv(): @@ -409,7 +413,7 @@ def echo(text): def test_bool_action(): "Action `store_true`/`store_false` is inferred from default value." - def parrot(dead=False): + def parrot(*, dead=False): return "this parrot is no more" if dead else "beautiful plumage" parser = DebugArghParser() @@ -443,7 +447,7 @@ def hello(): def test_function_under_group_name(): "A subcommand is resolved to a function." - def hello(name="world"): + def hello(*, name="world"): return f"Hello {name}!" def howdy(buddy): @@ -534,7 +538,7 @@ def cmd(): def test_command_error(): - def whiner_plain(code=1): + def whiner_plain(*, code=1): raise argh.CommandError("I feel depressed.", code=code) def whiner_iterable(): @@ -545,13 +549,13 @@ def whiner_iterable(): parser.add_commands([whiner_plain, whiner_iterable]) assert run(parser, "whiner-plain") == R( - out="", err="CommandError: I feel depressed.\n", exit=1 + out="", err="CommandError: I feel depressed.\n", exit_code=1 ) assert run(parser, "whiner-plain --code=127") == R( - out="", err="CommandError: I feel depressed.\n", exit=127 + out="", err="CommandError: I feel depressed.\n", exit_code=127 ) assert run(parser, "whiner-iterable") == R( - out="Hello...\n", err="CommandError: I feel depressed.\n", exit=1 + out="Hello...\n", err="CommandError: I feel depressed.\n", exit_code=1 ) @@ -657,18 +661,57 @@ def static_meth2(value): assert run(parser, "static-meth2 foo").out == "foo\nhuh!\n" -def test_kwonlyargs(): +def test_kwonlyargs__policy_legacy(): "Correct dispatch in presence of keyword-only arguments" def cmd(*args, foo="1", bar, baz="3", **kwargs): - return " ".join(args), foo, bar, baz, len(kwargs) + return f"foo='{foo}' bar='{bar}' baz='{baz}' args={args} kwargs={kwargs}" - parser = DebugArghParser() - parser.set_default_command(cmd) + parser = DebugArghParser(prog="pytest") + parser.set_default_command( + cmd, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + ) + + expected_usage = "usage: pytest [-h] [-f FOO] [--baz BAZ] bar [args ...]\n" + if sys.version_info < (3, 9): + # https://github.com/python/cpython/issues/82619 + expected_usage = ( + "usage: pytest [-h] [-f FOO] [--baz BAZ] bar [args [args ...]]\n" + ) + assert parser.format_usage() == expected_usage + + assert ( + run(parser, "--baz=done test this --baz=do").out + == "foo='1' bar='test' baz='do' args=('this',) kwargs={}\n" + ) + assert ( + run(parser, "test --foo=do").out + == "foo='do' bar='test' baz='3' args=() kwargs={}\n" + ) + + +def test_kwonlyargs__policy_modern(): + "Correct dispatch in presence of keyword-only arguments" + + def cmd(*args, foo="1", bar, baz="3", **kwargs): + return f"foo='{foo}' bar='{bar}' baz='{baz}' args={args} kwargs={kwargs}" + + parser = DebugArghParser(prog="pytest") + parser.set_default_command( + cmd, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + + expected_usage = "usage: pytest [-h] [-f FOO] --bar BAR [--baz BAZ] [args ...]\n" + if sys.version_info < (3, 9): + # https://github.com/python/cpython/issues/82619 + expected_usage = ( + "usage: pytest [-h] [-f FOO] --bar BAR [--baz BAZ] [args [args ...]]\n" + ) + assert parser.format_usage() == expected_usage assert ( run(parser, "--baz=done test this --bar=do").out - == "test this\n1\ndo\ndone\n0\n" + == "foo='1' bar='do' baz='done' args=('test', 'this') kwargs={}\n" ) message = "the following arguments are required: --bar" assert run(parser, "test --foo=do", exit=True) == message @@ -682,6 +725,7 @@ def test_default_arg_values_in_help(): @argh.arg("--note", help="why is it a remarkable animal?") def remind( name, + *, task=None, reason="there are creatures living in it", note="it can speak English", @@ -704,7 +748,7 @@ def remind( def test_default_arg_values_in_help__regression(): "Empty string as default value → empty help string → broken argparse" - def foo(bar=""): + def foo(*, bar=""): return bar parser = DebugArghParser() @@ -756,7 +800,7 @@ def cmd(foo=1): def test_unknown_args(): - def cmd(foo=1): + def cmd(*, foo=1): return foo parser = DebugArghParser() @@ -771,6 +815,22 @@ def cmd(foo=1): ) +def test_add_commands_unknown_name_mapping_policy(): + def func(foo): + ... + + parser = argh.ArghParser(prog="myapp") + + class UnsuitablePolicyContainer(Enum): + FOO = "Ni!!!" + + with pytest.raises( + NotImplementedError, + match="Unknown name mapping policy UnsuitablePolicyContainer.FOO", + ): + parser.add_commands([func], name_mapping_policy=UnsuitablePolicyContainer.FOO) + + def test_add_commands_no_overrides1(capsys: pytest.CaptureFixture[str]): def first_func(foo=123): """Owl stretching time""" @@ -805,7 +865,7 @@ def second_func(): def test_add_commands_no_overrides2(capsys: pytest.CaptureFixture[str]): - def first_func(foo=123): + def first_func(*, foo=123): """Owl stretching time""" pass @@ -813,9 +873,7 @@ def second_func(): pass parser = argh.ArghParser(prog="myapp") - parser.add_commands( - [first_func, second_func], - ) + parser.add_commands([first_func, second_func]) run(parser, "first-func --help", exit=True) captured = capsys.readouterr() @@ -883,7 +941,7 @@ def test_add_commands_group_overrides2(capsys: pytest.CaptureFixture[str]): whatever was specified on function level. """ - def first_func(foo=123): + def first_func(*, foo=123): """Owl stretching time""" return foo @@ -929,7 +987,7 @@ def test_add_commands_group_overrides3(capsys: pytest.CaptureFixture[str]): whatever was specified on function level. """ - def first_func(foo=123): + def first_func(*, foo=123): """Owl stretching time""" return foo @@ -970,7 +1028,7 @@ def test_add_commands_func_overrides1(capsys: pytest.CaptureFixture[str]): whatever was specified on function level. """ - def first_func(foo=123): + def first_func(*, foo=123): """Owl stretching time""" pass @@ -1012,7 +1070,7 @@ def test_add_commands_func_overrides2(capsys: pytest.CaptureFixture[str]): whatever was specified on function level. """ - def first_func(foo=123): + def first_func(*, foo=123): """Owl stretching time""" pass @@ -1062,7 +1120,7 @@ def func(**kwargs): def test_action_count__mixed(): @argh.arg("-v", "--verbose", action="count") - def func(verbose=0): + def func(*, verbose=0): return f"verbosity: {verbose}" parser = DebugArghParser() diff --git a/tests/test_mapping_policies.py b/tests/test_mapping_policies.py new file mode 100644 index 0000000..7635e14 --- /dev/null +++ b/tests/test_mapping_policies.py @@ -0,0 +1,170 @@ +import sys +from argparse import ArgumentParser, Namespace +from typing import Callable, List + +import pytest + +from argh.assembling import NameMappingPolicy, infer_argspecs_from_function + + +@pytest.mark.parametrize("name_mapping_policy", list(NameMappingPolicy)) +def test_no_args(name_mapping_policy) -> None: + def func() -> None: + ... + + parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) + assert_usage(parser, "usage: test [-h]\n") + + +@pytest.mark.parametrize("name_mapping_policy", list(NameMappingPolicy)) +def test_one_positional(name_mapping_policy) -> None: + def func(alpha: str) -> str: + return f"{alpha}" + + parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) + assert_usage(parser, "usage: test [-h] alpha\n") + assert_parsed(parser, ["hello"], Namespace(alpha="hello")) + + +@pytest.mark.parametrize("name_mapping_policy", list(NameMappingPolicy)) +def test_two_positionals(name_mapping_policy) -> None: + def func(alpha: str, beta: str) -> str: + return f"{alpha}, {beta}" + + parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) + assert_usage(parser, "usage: test [-h] alpha beta\n") + assert_parsed(parser, ["one", "two"], Namespace(alpha="one", beta="two")) + + +@pytest.mark.parametrize( + "name_mapping_policy,expected_usage", + [ + ( + NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + "usage: test [-h] [-b BETA] alpha\n", + ), + (NameMappingPolicy.BY_NAME_IF_KWONLY, "usage: test [-h] alpha [beta]\n"), + ], +) +def test_two_positionals_one_with_default(name_mapping_policy, expected_usage) -> None: + def func(alpha: str, beta: int = 123) -> str: + return f"{alpha}, {beta}" + + parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) + assert_usage(parser, expected_usage) + + assert_parsed(parser, ["one"], Namespace(alpha="one", beta=123)) + if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT: + assert_parsed( + parser, ["one", "--beta", "two"], Namespace(alpha="one", beta="two") + ) + elif name_mapping_policy == NameMappingPolicy.BY_NAME_IF_KWONLY: + assert_parsed(parser, ["one", "two"], Namespace(alpha="one", beta="two")) + + +@pytest.mark.parametrize("name_mapping_policy", list(NameMappingPolicy)) +def test_varargs(name_mapping_policy) -> None: + def func(*file_paths) -> str: + return f"{file_paths}" + + parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) + expected_usage = "usage: test [-h] [file-paths ...]\n" + if sys.version_info < (3, 9): + # https://github.com/python/cpython/issues/82619 + expected_usage = "usage: test [-h] [file-paths [file-paths ...]]\n" + assert_usage(parser, expected_usage) + + +@pytest.mark.parametrize( + "name_mapping_policy,expected_usage", + [ + (NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, "usage: test [-h] alpha beta\n"), + (NameMappingPolicy.BY_NAME_IF_KWONLY, "usage: test [-h] -b BETA alpha\n"), + ], +) +def test_varargs_between_positional_and_kwonly__no_defaults( + name_mapping_policy, expected_usage +) -> None: + def func(alpha, *, beta) -> str: + return f"{alpha}, {beta}" + + parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) + assert_usage(parser, expected_usage) + + +@pytest.mark.parametrize( + "name_mapping_policy,expected_usage", + [ + ( + NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + "usage: test [-h] [-a ALPHA] [-b BETA]\n", + ), + (NameMappingPolicy.BY_NAME_IF_KWONLY, "usage: test [-h] [-b BETA] [alpha]\n"), + ], +) +def test_varargs_between_positional_and_kwonly__with_defaults( + name_mapping_policy, expected_usage +) -> None: + def func(alpha: int = 1, *, beta: int = 2) -> str: + return f"{alpha}, {beta}" + + parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) + assert_usage(parser, expected_usage) + + +def test_kwargs() -> None: + def func(**kwargs) -> str: + return f"{kwargs}" + + parser = _make_parser_for_function( + func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + assert_usage(parser, "usage: test [-h]\n") + + +@pytest.mark.parametrize( + "name_mapping_policy,expected_usage", + [ + ( + NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + "usage: test [-h] [-b BETA] [-d DELTA] alpha gamma\n", + ), + ( + NameMappingPolicy.BY_NAME_IF_KWONLY, + "usage: test [-h] -g GAMMA [-d DELTA] alpha [beta]\n", + ), + ], +) +def test_all_types_mixed_no_named_varargs(name_mapping_policy, expected_usage) -> None: + def func(alpha: str, beta: int = 1, *, gamma: str, delta: int = 2) -> str: + return f"{alpha}, {beta}, {gamma}, {delta}" + + parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) + assert_usage(parser, expected_usage) + + +def _make_parser_for_function( + func: Callable, + name_mapping_policy: NameMappingPolicy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, +) -> ArgumentParser: + parser = ArgumentParser(prog="test") + parser_add_argument_specs = infer_argspecs_from_function( + function=func, name_mapping_policy=name_mapping_policy + ) + for parser_add_argument_spec in parser_add_argument_specs: + parser.add_argument( + *parser_add_argument_spec.cli_arg_names, + **parser_add_argument_spec.get_all_kwargs(), + ) + return parser + + +def assert_usage(parser: ArgumentParser, expected_usage: str) -> None: + assert expected_usage == parser.format_usage() + + +def assert_parsed( + parser: ArgumentParser, argv: List[str], expected_result: Namespace +) -> None: + parsed = parser.parse_args(argv) + assert parsed == expected_result diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 0424e20..5d10cdf 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -16,7 +16,7 @@ def test_regression_issue12(): incorrectly). """ - def cmd(foo=1, fox=2): + def cmd(*, foo=1, fox=2): yield f"foo {foo}, fox {fox}" parser = DebugArghParser() @@ -35,7 +35,7 @@ def test_regression_issue12_help_flag(): without decorators. """ - def ddos(host="localhost"): + def ddos(*, host="localhost"): return f"so be it, {host}!" # no help → no conflict @@ -58,10 +58,10 @@ def test_regression_issue27(): default→action) were made. """ - def parrot(dead=False): + def parrot(*, dead=False): return "this parrot is no more" if dead else "beautiful plumage" - def grenade(count=3): + def grenade(*, count=3): if count == 3: return "Three shall be the number thou shalt count" else: @@ -141,7 +141,7 @@ def test_regression_issue104(): value) positional argument names contained underscores. """ - def cmd(foo_foo, bar_bar, baz_baz=5, bip_bip=9, **kwargs): + def cmd(foo_foo, bar_bar, *, baz_baz=5, bip_bip=9, **kwargs): return "\n".join( [str(foo_foo), str(bar_bar), str(baz_baz), str(bip_bip), str(kwargs)] ) diff --git a/tox.ini b/tox.ini index 16a182a..55bb690 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ envlist = pypy3 as-module lint + docs skipdist = true isolated_build = true skip_missing_interpreters = true