diff --git a/AUTHORS.rst b/AUTHORS.rst index 00f7831..66c12cb 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,5 +1,5 @@ Contributors -~~~~~~~~~~~~ +============ .. note:: diff --git a/CHANGES.rst b/CHANGES.rst index 52cd3a2..4fd744b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,71 @@ -~~~~~~~~~ Changelog -~~~~~~~~~ +========= + +Version 0.31.0 (2023-12-30) +--------------------------- + +Breaking changes: + +- The typing hints introspection feature is automatically enabled for any + command (function) which does **not** have any arguments specified via `@arg` + decorator. + + This means that, for example, the following function used to fail and now + it will pass:: + + def main(count: int): + assert isinstance(count, int) + + This may lead to unexpected behaviour in some rare cases. + +- A small change in the legacy argument mapping policy `BY_NAME_IF_HAS_DEFAULT` + concerning the order of variadic positional vs. keyword-only arguments. + + The following function now results in ``main alpha [args ...] beta`` instead of + ``main alpha beta [args ...]``:: + + def main(alpha, *args, beta): ... + + This does **not** concern the default name mapping policy. Even for the + legacy one it's an edge case which is extremely unlikely to appear in any + real-life application. + +- Removed the previously deprecated decorator `@expects_obj`. + +Enhancements: + +- Added experimental support for basic typing hints (issue #203) + + The following hints are currently supported: + + - ``str``, ``int``, ``float``, ``bool`` (goes to ``type``); + - ``list`` (affects ``nargs``), ``list[T]`` (first subtype goes into ``type``); + - ``Literal[T1, T2, ...]`` (interpreted as ``choices``); + - ``Optional[T]`` AKA ``T | None`` (currently interpreted as + ``required=False`` for optional and ``nargs="?"`` for positional + arguments; likely to change in the future as use cases accumulate). + + The exact interpretation of the type hints is subject to change in the + upcoming versions of Argh. + +- Added `always_flush` argument to `dispatch()` (issue #145) + +- High-level functions `argh.dispatch_command()` and `argh.dispatch_commands()` + now accept a new parameter `old_name_mapping_policy`. The behaviour hasn't + changed because the parameter is `True` by default. It will change to + `False` in Argh v.0.33 or v.1.0. + +Deprecated: + +- the `namespace` argument in `argh.dispatch()` and `argh.parse_and_resolve()`. + Rationale: continued API cleanup. It's already possible to mutate the + namespace object between parsing and calling the endpoint; it's unlikely that + anyone would need to specify a custom namespace class or pre-populate it + before parsing. Please file an issue if you have a valid use case. + +Other changes: + +- Refactoring. Version 0.30.5 (2023-12-25) --------------------------- diff --git a/README.rst b/README.rst index 3c8ed04..0beb1eb 100644 --- a/README.rst +++ b/README.rst @@ -43,28 +43,15 @@ In a nutshell `Argh`-powered applications are *simple* but *flexible*: -:Modular: - Declaration of commands can be decoupled from assembling and dispatching; - -:Pythonic: - Commands are declared naturally, no complex API calls in most cases; +:Pythonic, KISS: + Commands are plain Python functions. Almost no CLI-specific API to learn. :Reusable: - Commands are plain functions, can be used directly outside of CLI context; - -:Layered: - The complexity of code raises with requirements; + Endpoint functions can be used directly outside of CLI context. -:Transparent: - The full power of argparse is available whenever needed; - -:Namespaced: - Nested commands are a piece of cake, no messing with subparsers (though - they are of course used under the hood); - -:Unobtrusive: - `Argh` can dispatch a subset of pure-`argparse` code, and pure-`argparse` - code can update and dispatch a parser assembled with `Argh`; +:Static typing friendly: + 100% of the code including endpoint functions can be type-checked. + Argh is driven primarily by type annotations. :DRY: Don't Repeat Yourself. The amount of boilerplate code is minimal. @@ -72,14 +59,28 @@ In a nutshell * infer command name from function name; * infer arguments from function signature; - * infer argument type from the default value; - * infer argument action from the default value (for booleans); + * infer argument types, actions and much more from annotations. + +:Modular: + Declaration of commands can be decoupled from assembling and dispatching. + +:Layered: + The complexity of code raises with requirements. + +:Transparent: + You can directly access `argparse.ArgumentParser` if needed. + +:Subcommands: + Easily nested commands. Argh isolates the complexity of subparsers. :NIH free: `Argh` supports *completion*, *progress bars* and everything else by being friendly to excellent 3rd-party libraries. No need to reinvent the wheel. -Sounds good? Check the tutorial! +:Compact: + No dependencies apart from Python's standard library. + +Sounds good? Check the :doc:`quickstart` and the :doc:`tutorial`! Relation to argparse -------------------- @@ -98,6 +99,9 @@ Installation Examples -------- +Hello World +........... + A very simple application with one command: .. code-block:: python @@ -116,6 +120,29 @@ Run it: $ ./app.py Hello world +Type Annotations +................ + +Type annotations are used to infer argument types: + +.. code-block:: python + + def summarise(numbers: list[int]) -> int: + return sum(numbers) + + argh.dispatch_command(summarise) + +Run it (note that ``nargs="+"`` + ``type=int`` were inferred from the +annotation): + +.. code-block:: bash + + $ ./app.py 1 2 3 + 6 + +Multiple Commands +................. + An app with multiple commands: .. code-block:: python @@ -133,6 +160,9 @@ Run it: $ ./app.py echo Hey Hey +Modularity +.......... + A potentially modular application with more control over the process: .. code-block:: python @@ -195,31 +225,19 @@ to CLI arguments):: (The help messages have been simplified a bit for brevity.) +Decorators +.......... + `Argh` easily maps plain Python functions to CLI. Sometimes this is not enough; in these cases the powerful API of `argparse` is also available: .. code-block:: python - @arg("text", default="hello world", nargs="+", help="The message") - def echo(text: str) -> None: - print text - -The approaches can be safely combined even up to this level: - -.. code-block:: python + @arg("words", default="hello world", nargs="+", help="The message") + def echo(words: list[str]) -> str: + return " ".join(words) - # adding help to `foo` which is in the function signature: - @arg("foo", help="blah") - # these are not in the signature so they go to **kwargs: - @arg("baz") - @arg("-q", "--quux") - # the function itself: - def cmd(foo: str, bar: int = 1, *args, **kwargs) -> Iterator[str]: - yield foo - yield bar - yield ", ".join(args) - yield kwargs["baz"] - yield kwargs["quux"] +Please note that decorators will soon be fully replaced with annotations. Links ----- diff --git a/docs/source/cookbook.rst b/docs/source/cookbook.rst index d5d6c55..e6fb1da 100644 --- a/docs/source/cookbook.rst +++ b/docs/source/cookbook.rst @@ -1,5 +1,5 @@ Cookbook -~~~~~~~~ +======== Multiple values per argument ---------------------------- @@ -46,9 +46,4 @@ will be eventually the default one): distros = ("abc", "xyz") return [d for d in distros if any(p in d for p in patterns)] - if __name__ == "__main__": - parser = argh.ArghParser() - parser.set_default_command( - cmd, name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY - ) - argh.dispatch(parser) + argh.dispatch_command(cmd, old_name_mapping_policy=False) diff --git a/docs/source/index.rst b/docs/source/index.rst index f643c05..4aa4b67 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -23,6 +23,7 @@ Details .. toctree:: :maxdepth: 2 + quickstart tutorial reference cookbook diff --git a/docs/source/projects.rst b/docs/source/projects.rst index 570adf4..64828b7 100644 --- a/docs/source/projects.rst +++ b/docs/source/projects.rst @@ -1,5 +1,5 @@ Real-life usage -~~~~~~~~~~~~~~~ +=============== Below are some examples of applications using `argh`, grouped by supported version of Python. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst new file mode 100644 index 0000000..7981567 --- /dev/null +++ b/docs/source/quickstart.rst @@ -0,0 +1,215 @@ +Quick Start +=========== + +Command-Line Interface +---------------------- + +CLI is a very efficient way to interact with an application. +If GUI is like pointing your finger at things, then CLI is like talking. + +Building a good CLI may require quite a bit of effort. You need to connect two +worlds: your Python API and the command-line interface which has its own rules. + +At a closer inspection you may notice that a CLI command is very similar to a function. +You have positional and named arguments, you pass them into the function and +get a return value — and the same happens with a command. However, the mapping is not +exactly straightforward and a lot of boilerplate is required to make it work. + +The intent of Argh is to radically streamline this function-to-CLI mapping. + +We'll try to demonstrate it with a few examples here. + +Passing name as positional argument +----------------------------------- + +Assume we need a CLI application which output is modulated by arguments: + +.. code-block:: bash + + $ ./greet.py + Hello unknown user! + + $ ./greet.py John + Hello John! + +Let's start with a simple function: + +.. code-block:: python + + def main(name: str = "unknown user") -> str: + return f"Hello {name}!" + +Now make it a CLI command: + +.. code-block:: python + + #!/usr/bin/env python3 + + import argh + + def main(name: str = "unknown user") -> str: + return f"Hello {name}!" + + argh.dispatch_command(main, old_name_mapping_policy=False) + +Save it as `greet.py` and try to run it:: + + $ chmod +x greet.py + $ ./greet.py + Hello unknown user! + +It works! Now try passing arguments. Use ``--help`` if unsure:: + + $ ./greet.py --help + + usage: greet.py [-h] [name] + + positional arguments: + name 'unknown user' + + options: + -h, --help show this help message and exit + +Multiple positional arguments; limitations +------------------------------------------ + +You can add more positional arguments. They are determined by their position +in the function signature:: + + def main(first, second, third): + print(f"second: {second}") + + main(1, 2, 3) # prints "two: 2" + +Same will happen if we dispatch this function as a CLI command:: + + $ ./app.py 1 2 3 + two: 2 + +This is fine, but it's usually hard to remember the order of arguments when +their number is over three or so. + +Moreover, you may want to omit the first one and specify the rest — but it's +impossible. How would the computer know if the element you are skipping is +supposed to be the first, the last or somewhere in the middle? There's no way. + +If only it was possible to pass such arguments by name! + +Indeed, a good command-line interface is likely to have one or two positional +arguments but the rest should be named. + +In Python you can do it by calling your function this way:: + + main(first=1, second=2, third=3) + +In CLI named arguments are called "options". Please see the next section to +learn how to use them. + +Passing name as an option +------------------------- + +Let's return to our small application and see if we can make the name +an "option" AKA named CLI argument, like this:: + + $ ./greet.py --name John + +In that case it's enough to make the function argument `name` "keyword-only" +(see :pep:`3102` for explanation):: + + def main(*, name: str = "unknown user") -> str: + ... + +We just took the previous function and added ``*,`` before the first argument. + +Let's check how the app help now looks like:: + + $ ./greet.py --help + + usage: greet.py [-h] [-n NAME] + + options: + -h, --help show this help message and exit + -n NAME, --name NAME 'unknown user' + +Positional vs options: recap +---------------------------- + +Here's a function with one positional argument and one "option":: + + def main(name: str, *, age: int = 0) -> str: + ... + +* All arguments to the left of ``*`` are considered positional. +* All arguments to the right of ``*`` are considered named (or "options"). + +Multiple Commands +----------------- + +We used `argh.dispatch_command()` to run a single command. + +In order to enable multiple commands we simply use a sister function +`argh.dispatch_commands()` and pass a list of functions to it:: + + argh.dispatch_commands([load, dump]) + +Bam! Now we can call our script like this:: + + $ ./app.py dump + $ ./app.py load fixture.json + $ ./app.py load fixture.yaml --format=yaml + \______/ \__/ \________________________/ + | | | + | | `-- command arguments + | | + | `-- command name (function name) + | + `-- script file name + +Typing Hints +------------ + +Typing hints are picked up when it makes sense too. Consider this:: + + def summarise(numbers: list[int]) -> int: + return sum(numbers) + + argh.dispatch_command(summarise) + +Call it:: + + $ ./app 1 2 3 + 6 + +It worked exactly as you would expect. Argh looked at the annotation and +understood that you want a list of integers. This information was then +reworded for `argparse`. + +Quick Start Wrap-Up +------------------- + +To sum up, the commands are **ordinary functions** with ordinary signatures: + +* Declare them somewhere, dispatch them elsewhere. This ensures **loose + coupling** of components in your application. +* They are **natural** and pythonic. No fiddling with the parser and the + related intricacies like ``action="store_true"`` which you could never + remember. + +Next: Tutorial +-------------- + +Still, there's much more to commands than this. + +The examples above raise some questions, including: + +* do we have to ``return``, or ``print`` and ``yield`` are also supported? +* what's the difference between ``dispatch_command()`` + and ``dispatch_commands()``? What's going on under the hood? +* how do I add help for each argument? +* how do I access the parser to fine-tune its behaviour? +* how to keep the code as DRY as possible? +* how do I expose the function under custom name and/or define aliases? +* how do I have values converted to given type? +* can I use a namespace object instead of the natural way? + +Please check the :doc:`tutorial` for answers. diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 90d11d3..3ff1f7a 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -1,18 +1,18 @@ API Reference -~~~~~~~~~~~~~ +============= .. automodule:: argh :members: -.. automodule:: argh.decorators - :members: - .. automodule:: argh.assembling :members: .. automodule:: argh.dispatching :members: +.. automodule:: argh.decorators + :members: + .. automodule:: argh.interaction :members: diff --git a/docs/source/similar.rst b/docs/source/similar.rst index f1c4e3e..97fd7d6 100644 --- a/docs/source/similar.rst +++ b/docs/source/similar.rst @@ -1,5 +1,5 @@ Similar projects -~~~~~~~~~~~~~~~~ +================ Obviously, `Argh` is not the only CLI helper library in the Python world. It was created when some similar solutions already existed; more appeared diff --git a/docs/source/subparsers.rst b/docs/source/subparsers.rst index 8840ac2..1960779 100644 --- a/docs/source/subparsers.rst +++ b/docs/source/subparsers.rst @@ -1,5 +1,5 @@ Subparsers -~~~~~~~~~~ +========== The statement ``parser.add_commands([bar, quux])`` builds two subparsers named `bar` and `quux`. A "subparser" is an argument parser bound to a group name. In @@ -26,7 +26,7 @@ The equivalent code without `Argh` would be:: bar_parser.set_defaults(function=bar) args = parser.parse_args() - print args.function(args) + print(args.function(args)) Now consider this expression:: @@ -54,7 +54,7 @@ to write something like this (generic argparse API):: foo_quux_parser.set_defaults(function=quux) args = parser.parse_args() - print args.function(args) + print(args.function(args)) .. note:: diff --git a/docs/source/the_story.rst b/docs/source/the_story.rst index 45c04ac..ecce529 100644 --- a/docs/source/the_story.rst +++ b/docs/source/the_story.rst @@ -1,5 +1,5 @@ The Story of Argh -~~~~~~~~~~~~~~~~~ +================= Early history ------------- diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index aa6ac12..b0f0934 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -1,5 +1,5 @@ Tutorial -~~~~~~~~ +======== `Argh` is a small library that provides several layers of abstraction on top of `argparse`. You are free to use any layer that fits given task best. @@ -7,80 +7,7 @@ The layers can be mixed. It is always possible to declare a command with the highest possible (and least flexible) layer and then tune the behaviour with any of the lower layers including the native API of `argparse`. -Dive In -------- - -Assume we need a CLI application which output is modulated by arguments: - -.. code-block:: bash - - $ ./greet.py - Hello unknown user! - - $ ./greet.py John - Hello John! - -This is our business logic: - -.. code-block:: python - - def main(name: str = "unknown user") -> str: - return f"Hello {name}!" - -That was plain Python, nothing CLI-specific. -Let's convert the function into a complete CLI application:: - - argh.dispatch_command(main) - -Done. Dead simple. - -You may want to make the name an "option" AKA named CLI argument, like this:: - - $ ./greet.py --name John - -In that case it's enough to make the function argument `name` "keyword-only" -(see :pep:`3102` for explanation):: - - def main(*, name: str = "unknown user") -> str: - ... - -Everything to the left of ``*`` becomes a positional CLI argument. Everything -to the right of ``*`` becomes a named one. - -What about multiple commands? Easy:: - - argh.dispatch_commands([load, dump]) - -And then call your script like this:: - - $ ./app.py dump - $ ./app.py load fixture.json - $ ./app.py load fixture.yaml --format=yaml - -I guess you get the picture. The commands are **ordinary functions** -with ordinary signatures: - -* Declare them somewhere, dispatch them elsewhere. This ensures **loose - coupling** of components in your application. -* They are **natural** and pythonic. No fiddling with the parser and the - related intricacies like ``action="store_true"`` which you could never - remember. - -Still, there's much more to commands than this. - -The examples above raise some questions, including: - -* do we have to ``return``, or ``print`` and ``yield`` are also supported? -* what's the difference between ``dispatch_command()`` - and ``dispatch_commands()``? What's going on under the hood? -* how do I add help for each argument? -* how do I access the parser to fine-tune its behaviour? -* how to keep the code as DRY as possible? -* how do I expose the function under custom name and/or define aliases? -* how do I have values converted to given type? -* can I use a namespace object instead of the natural way? - -Just read on. +Please make sure you have read the :doc:`quickstart` before proceeding. Declaring Commands ------------------ @@ -115,10 +42,26 @@ Let's make this function available as a CLI command:: if __name__ == "__main__": - argh.dispatch_commands([my_command]) + argh.dispatch_commands([my_command], old_name_mapping_policy=False) That's all. You don't need to do anything else. +.. note:: + + Note that we're using ``old_name_mapping_policy=False`` here and in some + other examples. This has to do with the recent changes in the default way + Argh maps function arguments to CLI arguments. We're currently in a + transitional period. + + In most cases Argh can guess what you want but there are edge cases, and + the `beta` argument is one of them. It's a positional argument with + default value. Usually you will not need those but it's shown here for the + sake of completeness. Argh does not know how you want to treat it, so you + should specify the name mapping policy explicitly. This issue will go away + when `BY_NAME_IF_KWONLY` becomes the default policy (v.1.0 or earlier). + + See :class:`~argh.assembling.NameMappingPolicy` for details. + When executed as ``./app.py my-command --help``, such application prints:: usage: app.py my-command [-h] -g GAMMA [-d] alpha [beta] [args ...] @@ -168,7 +111,7 @@ single line in addition to your function. `Argh` allows for more expressive and pythonic code because: -* everything is inferred from the function signature; +* everything is inferred from the function signature and type annotations; * regular function arguments are represented as positional CLI arguments; * varargs (``*args``) are represented as a "zero or more" positional CLI argument; * kwonly (keyword-only arguments, see :pep:`3102`) are represented as named CLI @@ -189,6 +132,72 @@ 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. +Annotations +........... + +Since v.0.31 `Argh` can use type annotations to infer the argument types and +some other properties. This approach will eventually replace the `@arg` +decorator. + +Inferring the type +~~~~~~~~~~~~~~~~~~ + +Let's consider this example:: + + def increment(n: int) -> int: + return n + 1 + +The `n` argument will be automatically converted to `int`. + +Currently supported types are: + +- `str` +- `int` +- `float` +- `bool` + +Inferring choices +~~~~~~~~~~~~~~~~~ + +Use `Literal` to specify the choices:: + + from typing import Literal + import argh + + def greet(name: Literal["Alice", "Bob"]) -> str: + return f"Hello, {name}!" + + argh.dispatch_command(greet) + +Let's explore this CLI:: + + $ ./greet.py foo + usage: greet.py [-h] {Alice,Bob} + greet.py: error: argument name: invalid choice: 'foo' (choose from 'Alice', 'Bob') + + $ ./greet.py Alice + Hello, Alice! + +Inferring nargs and nested type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Here's another example:: + + def summarise(numbers: list[int]) -> int: + return sum(numbers) + + argh.dispatch_command(summarise) + +Let's call it:: + + $ ./app.py 1 2 3 + 6 + +The ``list[int]`` hint was interpreted as ``nargs="+"`` + ``type=int``. + +Please note that this part of the API is experimental and may change in the +future releases. + Documenting Your Commands ......................... @@ -253,35 +262,6 @@ Mixing ``**kwargs`` with straightforward signatures is also possible:: declared via decorators because the results can be pretty confusing (though predictable). See `argh` tests for details. -Namespace Objects -................. - -The default approach of `argparse` is similar to ``**kwargs``: the function -expects a single object and the CLI arguments are defined elsewhere. - -In order to dispatch such "argparse-style" command via `argh`, you need to -tell the latter that the function expects a namespace object. This is done by -wrapping the function into the :func:`~argh.decorators.expects_obj` decorator:: - - @expects_obj - def cmd(args) -> str: - return args.foo - -This way arguments cannot be defined in the Natural Way but the -:class:`~argh.decorators.arg` decorator works as usual. - -.. deprecated:: 0.30 - The `@expects_obj` decorator will removed in v0.31 or a later version. - Please consider using the main feature Argh offers — the mapping of - function signature to CLI. Otherwise you are basically using vanilla - Argparse. - -.. note:: - - In both cases — ``**kwargs``-only and `@expects_obj` — the arguments - **must** be declared via decorators or directly via the `argparse` API. - Otherwise the command has zero arguments (apart from ``--help``). - Assembling Commands ------------------- diff --git a/pyproject.toml b/pyproject.toml index 6b64c58..159e27b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "argh" -version = "0.30.5" +version = "0.31.0" description = "An unobtrusive argparse wrapper with natural syntax" readme = "README.rst" requires-python = ">=3.8" diff --git a/src/argh/__init__.py b/src/argh/__init__.py index fa54828..5d6b048 100644 --- a/src/argh/__init__.py +++ b/src/argh/__init__.py @@ -12,7 +12,7 @@ # Software Foundation. See the file README.rst for copying conditions. # from .assembling import add_commands, add_subcommands, set_default_command -from .decorators import aliases, arg, expects_obj, named, wrap_errors +from .decorators import aliases, arg, named, wrap_errors from .dispatching import ( PARSER_FORMATTER, ArghNamespace, @@ -33,7 +33,6 @@ "set_default_command", "aliases", "arg", - "expects_obj", "named", "wrap_errors", "PARSER_FORMATTER", diff --git a/src/argh/assembling.py b/src/argh/assembling.py index c239fce..2dc554f 100644 --- a/src/argh/assembling.py +++ b/src/argh/assembling.py @@ -19,13 +19,32 @@ from argparse import OPTIONAL, ZERO_OR_MORE, ArgumentParser from collections import OrderedDict from enum import Enum -from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple +from typing import ( + Any, + Callable, + Dict, + Iterator, + List, + Literal, + Optional, + Tuple, + Union, + get_args, + get_origin, +) + +# types.UnionType was introduced in Python < 3.10 +try: # pragma: no cover + from types import UnionType + + UNION_TYPES = [Union, UnionType] +except ImportError: # pragma: no cover + UNION_TYPES = [Union] from argh.completion import COMPLETION_ENABLED from argh.constants import ( ATTR_ALIASES, ATTR_ARGS, - ATTR_EXPECTS_NAMESPACE_OBJECT, ATTR_NAME, DEFAULT_ARGUMENT_TEMPLATE, DEST_FUNCTION, @@ -33,7 +52,7 @@ ) from argh.dto import NotDefined, ParserAddArgumentSpec from argh.exceptions import AssemblingError -from argh.utils import get_arg_spec, get_subparsers +from argh.utils import get_subparsers __all__ = [ "set_default_command", @@ -108,23 +127,23 @@ def func(alpha, beta=1, *, gamma, delta=2): ... def infer_argspecs_from_function( function: Callable, name_mapping_policy: Optional[NameMappingPolicy] = None, + can_use_hints: bool = False, ) -> Iterator[ParserAddArgumentSpec]: - if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False): - return - if name_mapping_policy and name_mapping_policy not in NameMappingPolicy: raise NotImplementedError(f"Unknown name mapping policy {name_mapping_policy}") - func_spec = get_arg_spec(function) - has_kwonly = bool(func_spec.kwonlyargs) - - default_by_arg_name: Dict[str, Any] = dict( - zip(reversed(func_spec.args), reversed(func_spec.defaults or tuple())) + func_signature = inspect.signature(function) + has_kwonly = any( + p.kind == p.KEYWORD_ONLY for p in func_signature.parameters.values() ) # define the list of conflicting option strings # (short forms, i.e. single-character ones) - named_args = set(list(default_by_arg_name) + func_spec.kwonlyargs) + named_args = [ + p.name + for p in func_signature.parameters.values() + if p.default is not p.empty or p.kind == p.KEYWORD_ONLY + ] 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) @@ -146,99 +165,146 @@ def _make_cli_arg_names_options(arg_name) -> Tuple[List[str], List[str]]: 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 + for parameter in func_signature.parameters.values(): + (cli_arg_names_positional, cli_arg_names_options) = _make_cli_arg_names_options( + parameter.name ) - default_value = default_by_arg_name.get(arg_name, NotDefined) - - if default_value != NotDefined and not name_mapping_policy: - message = textwrap.dedent( - f""" - Argument "{arg_name}" in function "{function.__name__}" - is not keyword-only but has a default value. - - Please note that since Argh v.0.30 the default name mapping - policy has changed. - - More information: - https://argh.readthedocs.io/en/latest/changes.html#version-0-30-0-2023-10-21 - - You need to upgrade your functions so that the arguments - that have default values become keyword-only: - - f(x=1) -> f(*, x=1) - - If you actually want an optional positional argument, - please set the name mapping policy explicitly to `BY_NAME_IF_KWONLY`. + if parameter.default is not parameter.empty: + default_value = parameter.default + else: + default_value = NotDefined - If you choose to postpone the migration, you have two options: + extra_spec_kwargs = {} - a) set the policy explicitly to `BY_NAME_IF_HAS_DEFAULT`; - b) pin Argh version to 0.29 until you are ready to migrate. + if can_use_hints: + hints = function.__annotations__ + if parameter.name in hints: + extra_spec_kwargs = ( + TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params( + hints[parameter.name] + ) + ) - Thank you for understanding! - """ - ).strip() + if parameter.kind in ( + parameter.POSITIONAL_ONLY, + parameter.POSITIONAL_OR_KEYWORD, + ): + if default_value != NotDefined and not name_mapping_policy: + message = textwrap.dedent( + f""" + Argument "{parameter.name}" in function "{function.__name__}" + is not keyword-only but has a default value. + + Please note that since Argh v.0.30 the default name mapping + policy has changed. + + More information: + https://argh.readthedocs.io/en/latest/changes.html#version-0-30-0-2023-10-21 + + You need to upgrade your functions so that the arguments + that have default values become keyword-only: + + f(x=1) -> f(*, x=1) + + If you actually want an optional positional argument, + please set the name mapping policy explicitly to `BY_NAME_IF_KWONLY`. + + If you choose to postpone the migration, you have two options: + + a) set the policy explicitly to `BY_NAME_IF_HAS_DEFAULT`; + b) pin Argh version to 0.29 until you are ready to migrate. + + Thank you for understanding! + """ + ).strip() + + # Assume legacy policy and show a warning if the signature is + # simple (without kwonly args) so that the script continues working + # but the author is urged to upgrade it. + # When it cannot be auto-resolved (kwonly args mixed with old-style + # ones but no policy specified), throw an error. + # + # TODO: remove in v.0.33 if it happens, otherwise in v1.0. + if has_kwonly: + raise ArgumentNameMappingError(message) + warnings.warn(DeprecationWarning(message)) + name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + + arg_spec = ParserAddArgumentSpec( + func_arg_name=parameter.name, + cli_arg_names=cli_arg_names_positional, + default_value=default_value, + other_add_parser_kwargs=extra_spec_kwargs, + ) - # Assume legacy policy and show a warning if the signature is - # simple (without kwonly args) so that the script continues working - # but the author is urged to upgrade it. - # When it cannot be auto-resolved (kwonly args mixed with old-style - # ones but no policy specified), throw an error. - # - # TODO: remove in v.0.33 if it happens, otherwise in v1.0. - if has_kwonly: - raise ArgumentNameMappingError(message) - warnings.warn(DeprecationWarning(message)) - name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT - - arg_spec = ParserAddArgumentSpec( - func_arg_name=arg_name, - cli_arg_names=cli_arg_names_positional, - default_value=default_value, - ) + if default_value != NotDefined: + if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT: + arg_spec.cli_arg_names = cli_arg_names_options + else: + arg_spec.nargs = OPTIONAL + + # annotations are interpreted without regard to the broader + # context, e.g. default values; in some cases argparse requires + # pretty specific combinations of props, so we need to adjust them + if can_use_hints: + # "required" is invalid for positional CLI argument; + # it may have been set from Optional[...] hint above. + # Reinterpret it as "optional positional" instead. + if "required" in arg_spec.other_add_parser_kwargs: + value = arg_spec.other_add_parser_kwargs.pop("required") + if value is False: + arg_spec.nargs = OPTIONAL + + if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT: + # The guesser yields `type=bool` from `foo: bool = False` + # but `type` is incompatible with `action="store_true"` which + # is added by guess_extra_parser_add_argument_spec_kwargs(). + if ( + isinstance(arg_spec.default_value, bool) + and arg_spec.other_add_parser_kwargs.get("type") == bool + ): + del arg_spec.other_add_parser_kwargs["type"] + + yield arg_spec + + elif parameter.kind == parameter.KEYWORD_ONLY: + arg_spec = ParserAddArgumentSpec( + func_arg_name=parameter.name, + cli_arg_names=cli_arg_names_positional, + default_value=default_value, + other_add_parser_kwargs=extra_spec_kwargs, + ) - if default_value != NotDefined: if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT: - arg_spec.cli_arg_names = cli_arg_names_options + if default_value != NotDefined: + arg_spec.cli_arg_names = cli_arg_names_options else: - arg_spec.nargs = OPTIONAL - - 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 - ) - - if func_spec.kwonlydefaults and arg_name in func_spec.kwonlydefaults: - default_value = func_spec.kwonlydefaults[arg_name] - else: - default_value = NotDefined - - arg_spec = ParserAddArgumentSpec( - func_arg_name=arg_name, - cli_arg_names=cli_arg_names_positional, - default_value=default_value, - ) - - if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT: - if default_value != NotDefined: arg_spec.cli_arg_names = cli_arg_names_options - else: - arg_spec.cli_arg_names = cli_arg_names_options - if default_value == NotDefined: - arg_spec.is_required = True - - yield arg_spec - - if func_spec.varargs: - yield ParserAddArgumentSpec( - func_arg_name=func_spec.varargs, - cli_arg_names=[func_spec.varargs.replace("_", "-")], - nargs=ZERO_OR_MORE, - ) + if default_value == NotDefined: + arg_spec.is_required = True + + # annotations are interpreted without regard to the broader + # context, e.g. default values; in some cases argparse requires + # pretty specific combinations of props, so we need to adjust them + if can_use_hints: + # The guesser yields `type=bool` from `foo: bool = False` + # but `type` is incompatible with `action="store_true"` which + # is added by guess_extra_parser_add_argument_spec_kwargs(). + if ( + isinstance(arg_spec.default_value, bool) + and arg_spec.other_add_parser_kwargs.get("type") == bool + ): + del arg_spec.other_add_parser_kwargs["type"] + + yield arg_spec + + elif parameter.kind == parameter.VAR_POSITIONAL: + yield ParserAddArgumentSpec( + func_arg_name=parameter.name, + cli_arg_names=[parameter.name.replace("_", "-")], + nargs=ZERO_OR_MORE, + other_add_parser_kwargs=extra_spec_kwargs, + ) def guess_extra_parser_add_argument_spec_kwargs( @@ -341,12 +407,22 @@ def set_default_command( option name ``-h`` is silently removed from any argument. """ - func_spec = get_arg_spec(function) - has_varkw = bool(func_spec.varkw) # the **kwargs thing + func_signature = inspect.signature(function) + + # the **kwargs thing + has_varkw = any(p.kind == p.VAR_KEYWORD for p in func_signature.parameters.values()) declared_args: List[ParserAddArgumentSpec] = getattr(function, ATTR_ARGS, []) + + # transitional period: hints are used for types etc. only if @arg is not used + can_use_hints = not declared_args + inferred_args: List[ParserAddArgumentSpec] = list( - infer_argspecs_from_function(function, name_mapping_policy=name_mapping_policy) + infer_argspecs_from_function( + function, + name_mapping_policy=name_mapping_policy, + can_use_hints=can_use_hints, + ) ) if declared_args and not inferred_args and not has_varkw: @@ -660,3 +736,66 @@ def add_subcommands( class ArgumentNameMappingError(AssemblingError): ... + + +class TypingHintArgSpecGuesser: + BASIC_TYPES = (str, int, float, bool) + + @classmethod + def typing_hint_to_arg_spec_params( + cls, type_def: type, is_positional: bool = False + ) -> Dict[str, Any]: + origin = get_origin(type_def) + args = get_args(type_def) + + # `str` + if type_def in cls.BASIC_TYPES: + return { + "type": type_def + # "type": _parse_basic_type(type_def) + } + + # `list` + if type_def == list: + return {"nargs": ZERO_OR_MORE} + + # `Literal["a", "b"]` + if origin == Literal: + return {"choices": args, "type": type(args[0])} + + # `str | int` + if any(origin is t for t in UNION_TYPES): + retval = {} + first_subtype = args[0] + if first_subtype in cls.BASIC_TYPES: + retval["type"] = first_subtype + + if first_subtype == list: + retval["nargs"] = ZERO_OR_MORE + + if get_origin(first_subtype) == list: + retval["nargs"] = ZERO_OR_MORE + item_type = cls._extract_item_type_from_list_type(first_subtype) + if item_type: + retval["type"] = item_type + + if type(None) in args: + retval["required"] = False + return retval + + # `list[str]` + if origin == list: + retval = {} + retval["nargs"] = ZERO_OR_MORE + if args[0] in cls.BASIC_TYPES: + retval["type"] = args[0] + return retval + + return {} + + @classmethod + def _extract_item_type_from_list_type(cls, type_def) -> Optional[type]: + args = get_args(type_def) + if args[0] in cls.BASIC_TYPES: + return args[0] + return None diff --git a/src/argh/constants.py b/src/argh/constants.py index f98a539..9bf1672 100644 --- a/src/argh/constants.py +++ b/src/argh/constants.py @@ -19,7 +19,6 @@ "ATTR_ARGS", "ATTR_WRAPPED_EXCEPTIONS", "ATTR_WRAPPED_EXCEPTIONS_PROCESSOR", - "ATTR_EXPECTS_NAMESPACE_OBJECT", "PARSER_FORMATTER", "DEFAULT_ARGUMENT_TEMPLATE", "DEST_FUNCTION", @@ -46,9 +45,6 @@ #: a function to preprocess the exception object when it is wrapped ATTR_WRAPPED_EXCEPTIONS_PROCESSOR = "argh_wrap_errors_processor" -#: forcing argparse.Namespace object instead of signature introspection -ATTR_EXPECTS_NAMESPACE_OBJECT = "argh_expects_namespace_object" - # # Dest names in parser defaults # diff --git a/src/argh/decorators.py b/src/argh/decorators.py index 10ede10..8913a95 100644 --- a/src/argh/decorators.py +++ b/src/argh/decorators.py @@ -11,13 +11,11 @@ Command decorators ~~~~~~~~~~~~~~~~~~ """ -import warnings from typing import Callable, List, Optional from argh.constants import ( ATTR_ALIASES, ATTR_ARGS, - ATTR_EXPECTS_NAMESPACE_OBJECT, ATTR_NAME, ATTR_WRAPPED_EXCEPTIONS, ATTR_WRAPPED_EXCEPTIONS_PROCESSOR, @@ -25,7 +23,7 @@ from argh.dto import ParserAddArgumentSpec from argh.utils import CliArgToFuncArgGuessingError, naive_guess_func_arg_name -__all__ = ["aliases", "named", "arg", "wrap_errors", "expects_obj"] +__all__ = ["aliases", "named", "arg", "wrap_errors"] def named(new_name: str) -> Callable: @@ -205,40 +203,3 @@ def wrapper(func: Callable): return func return wrapper - - -# TODO: deprecated — remove in v0.31+ -def expects_obj(func: Callable) -> Callable: - """ - Marks given function as expecting a namespace object. - - .. deprecated:: 0.30 - Will removed in v0.31 or a later version. - - Please consider using the main feature Argh offers — the mapping of - function signature to CLI. Otherwise you are basically using vanilla - Argparse. - - Usage:: - - @arg("bar") - @arg("--quux", default=123) - @expects_obj - def foo(args): - yield args.bar, args.quux - - This is equivalent to:: - - def foo(bar, quux=123): - yield bar, quux - - In most cases you don't need this decorator. - """ - warnings.warn( - DeprecationWarning( - "The @expects_obj decorator is deprecated. Please update " - 'your code to use the standard approach AKA "Natural Way".' - ) - ) - setattr(func, ATTR_EXPECTS_NAMESPACE_OBJECT, True) - return func diff --git a/src/argh/dispatching.py b/src/argh/dispatching.py index fb4b085..ef413f6 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -12,23 +12,22 @@ ~~~~~~~~~~~ """ import argparse +import inspect import io import sys import warnings from types import GeneratorType from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Tuple -from argh.assembling import add_commands, set_default_command +from argh.assembling import NameMappingPolicy, add_commands, set_default_command from argh.completion import autocomplete from argh.constants import ( - ATTR_EXPECTS_NAMESPACE_OBJECT, ATTR_WRAPPED_EXCEPTIONS, ATTR_WRAPPED_EXCEPTIONS_PROCESSOR, DEST_FUNCTION, PARSER_FORMATTER, ) from argh.exceptions import CommandError, DispatchingError -from argh.utils import get_arg_spec __all__ = [ "ArghNamespace", @@ -79,6 +78,7 @@ def dispatch( raw_output: bool = False, namespace: Optional[argparse.Namespace] = None, skip_unknown_args: bool = False, + always_flush: bool = False, ) -> Optional[str]: """ Parses given list of arguments using given parser, calls the relevant @@ -137,10 +137,22 @@ def dispatch( :param namespace: - An `argparse.Namespace`-like object. By default an - :class:`argh.dispatching.ArghNamespace` object is used. Please note - that support for combined default and nested functions may be broken - if a different type of object is forced. + .. deprecated:: 0.31 + + This argument will be removed soon after v0.31. + + :param always_flush: + + If the output stream is not a terminal (i.e. redirected to a file or + another process), it's probably buffered. In most cases it doesn't + matter. + + However, if the output of your program is generated with delays + between the lines and you may want to redirect them to another process + and immediately see the results (e.g. `my_app.py | grep something`), + it's a good idea to force flushing of the buffer. + + .. versionadded:: 0.31 By default the exceptions are not wrapped and will propagate. The only exception that is always wrapped is :class:`~argh.exceptions.CommandError` @@ -151,6 +163,13 @@ def dispatch( Wrapped exceptions, or other "expected errors" like parse failures, will cause a SystemExit to be raised. """ + if namespace: + warnings.warn( + DeprecationWarning( + "The argument `namespace` in `dispatch()` is deprecated. " + "It will be removed in the next minor version after v0.31." + ) + ) # TODO: remove in v0.31+/v1.0 if add_help_command: # pragma: nocover @@ -182,6 +201,7 @@ def dispatch( output_file=output_file, errors_file=errors_file, raw_output=raw_output, + always_flush=always_flush, ) @@ -196,6 +216,12 @@ def parse_and_resolve( .. versionadded:: 0.30 Parses CLI arguments and resolves the endpoint function. + + :param namespace: + + .. deprecated:: 0.31 + + This argument will be removed soon after v0.31. """ if completion: autocomplete(parser) @@ -225,20 +251,29 @@ def run_endpoint_function( output_file: IO = sys.stdout, errors_file: IO = sys.stderr, raw_output: bool = False, + always_flush: bool = False, ) -> Optional[str]: """ .. versionadded:: 0.30 Extracts arguments from the namespace object, calls the endpoint function and processes its output. + + :param always_flush: + + Flush the buffer after every line even if `output_file` is not a TTY. + Turn this off if you don't need dynamic output) """ lines = _execute_command(function, namespace_obj, errors_file) - return _process_command_output(lines, output_file, raw_output) + return _process_command_output(lines, output_file, raw_output, always_flush) def _process_command_output( - lines: Iterator[str], output_file: Optional[IO], raw_output: bool + lines: Iterator[str], + output_file: Optional[IO], + raw_output: bool, + always_flush: bool, ) -> Optional[str]: out_io: IO @@ -260,6 +295,14 @@ def _process_command_output( # in most cases user wants one message per line out_io.write("\n") + # If it's not a terminal (i.e. redirected to a file or another + # process), it's probably buffered. In most cases it doesn't matter + # but if the output is generated with delays between the lines and we + # may want to monitor it (e.g. `my_app.py | grep something`), it's a + # good idea to force flushing. + if always_flush: + out_io.flush() + if output_file is None: # user wanted a string; return contents of our temporary file-like obj out_io.seek(0) @@ -307,39 +350,48 @@ def _execute_command( # the function is nested to catch certain exceptions (see below) def _call(): # Actually call the function - if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False): - result = function(namespace_obj) - else: - # namespace -> dictionary - def _flat_key(key): - return key.replace("-", "_") - all_input = dict((_flat_key(k), v) for k, v in vars(namespace_obj).items()) + # namespace -> dictionary + def _flat_key(key): + return key.replace("-", "_") - # filter the namespace variables so that only those expected - # by the actual function will pass - - spec = get_arg_spec(function) - - positional = [all_input[k] for k in spec.args] - kwonly = getattr(spec, "kwonlyargs", []) - keywords = dict((k, all_input[k]) for k in kwonly) - - # *args - if spec.varargs: - positional += all_input[spec.varargs] + values_by_arg_name = dict( + (_flat_key(k), v) for k, v in vars(namespace_obj).items() + ) - # **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): - normalized_k = _flat_key(k) - if k.startswith("_") or normalized_k in not_kwargs: - continue - keywords[normalized_k] = getattr(namespace_obj, k) + # filter the namespace variables so that only those expected + # by the actual function will pass + + func_signature = inspect.signature(function) + func_params = func_signature.parameters.values() + + positional_names = [ + p.name + for p in func_params + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + kwonly_names = [p.name for p in func_params if p.kind == p.KEYWORD_ONLY] + varargs_names = [p.name for p in func_params if p.kind == p.VAR_POSITIONAL] + positional_values = [values_by_arg_name[name] for name in positional_names] + values_by_name = dict((k, values_by_arg_name[k]) for k in kwonly_names) + + # *args + if varargs_names: + value = varargs_names[0] + positional_values += values_by_arg_name[value] + + # **kwargs + if any(p for p in func_params if p.kind == p.VAR_KEYWORD): + not_kwargs = ( + [DEST_FUNCTION] + positional_names + varargs_names + kwonly_names + ) + for k in vars(namespace_obj): + normalized_k = _flat_key(k) + if k.startswith("_") or normalized_k in not_kwargs: + continue + values_by_name[normalized_k] = getattr(namespace_obj, k) - result = function(*positional, **keywords) + result = function(*positional_values, **values_by_name) # Yield the results if isinstance(result, (GeneratorType, list, tuple)): @@ -375,11 +427,23 @@ def default_exception_processor(exc: Exception) -> str: sys.exit(code) -def dispatch_command(function: Callable, *args, **kwargs) -> None: +def dispatch_command( + function: Callable, *args, old_name_mapping_policy=True, **kwargs +) -> None: """ A wrapper for :func:`dispatch` that creates a one-command parser. Uses :attr:`argh.constants.PARSER_FORMATTER`. + :param old_name_mapping_policy: + + .. versionadded:: 0.31 + + If `True`, sets the default argument naming policy to + `~argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT`, otherwise + to `~argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY`. + + .. warning:: tho default will be changed to `False` in v.0.33 (or v.1.0). + This:: dispatch_command(foo) @@ -387,7 +451,7 @@ def dispatch_command(function: Callable, *args, **kwargs) -> None: ...is a shortcut for:: parser = ArgumentParser() - set_default_command(parser, foo) + set_default_command(parser, foo, name_mapping_policy=...) dispatch(parser) This function can be also used as a decorator:: @@ -397,17 +461,34 @@ def main(foo: int = 123) -> int: return foo + 1 """ + if old_name_mapping_policy: + name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + else: + name_mapping_policy = NameMappingPolicy.BY_NAME_IF_KWONLY + parser = argparse.ArgumentParser(formatter_class=PARSER_FORMATTER) - set_default_command(parser, function) + set_default_command(parser, function, name_mapping_policy=name_mapping_policy) dispatch(parser, *args, **kwargs) -def dispatch_commands(functions: List[Callable], *args, **kwargs) -> None: +def dispatch_commands( + functions: List[Callable], *args, old_name_mapping_policy=True, **kwargs +) -> None: """ A wrapper for :func:`dispatch` that creates a parser, adds commands to the parser and dispatches them. Uses :attr:`PARSER_FORMATTER`. + :param old_name_mapping_policy: + + .. versionadded:: 0.31 + + If `True`, sets the default argument naming policy to + `~argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT`, otherwise + to `~argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY`. + + .. warning:: tho default will be changed to `False` in v.0.33 (or v.1.0). + This:: dispatch_commands([foo, bar]) @@ -415,12 +496,17 @@ def dispatch_commands(functions: List[Callable], *args, **kwargs) -> None: ...is a shortcut for:: parser = ArgumentParser() - add_commands(parser, [foo, bar]) + add_commands(parser, [foo, bar], name_mapping_policy=...) dispatch(parser) """ + if old_name_mapping_policy: + name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + else: + name_mapping_policy = NameMappingPolicy.BY_NAME_IF_KWONLY + parser = argparse.ArgumentParser(formatter_class=PARSER_FORMATTER) - add_commands(parser, functions) + add_commands(parser, functions, name_mapping_policy=name_mapping_policy) dispatch(parser, *args, **kwargs) @@ -441,13 +527,13 @@ class EntryPoint: app = EntryPoint("main", {"description": "This is a cool app"}) @app - def ls() -> None: + def ls() -> Iterator[int]: for i in range(10): - print i + yield i @app - def greet() -> None: - print "hello" + def greet() -> str: + return "hello" if __name__ == "__main__": app() diff --git a/src/argh/utils.py b/src/argh/utils.py index 9c232e9..3f7d42d 100644 --- a/src/argh/utils.py +++ b/src/argh/utils.py @@ -12,9 +12,8 @@ ~~~~~~~~~ """ import argparse -import inspect import re -from typing import Callable, Tuple +from typing import Tuple def get_subparsers( @@ -46,23 +45,6 @@ def get_subparsers( raise SubparsersNotDefinedError() -def get_arg_spec(function: Callable) -> inspect.FullArgSpec: - """ - 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__ - spec = inspect.getfullargspec(function) - if inspect.ismethod(function): - spec = spec._replace(args=spec.args[1:]) - return spec - - def unindent(text: str) -> str: """ Given a multi-line string, decreases indentation of all lines so that the diff --git a/tests/test_assembling.py b/tests/test_assembling.py index dd38586..23c17a0 100644 --- a/tests/test_assembling.py +++ b/tests/test_assembling.py @@ -3,6 +3,7 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """ import argparse +from typing import Literal, Optional from unittest.mock import MagicMock, call, patch import pytest @@ -398,6 +399,7 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_leg call("--gamma-pos-opt", default="gamma named", type=str, help=help_tmpl), 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("args", nargs=argparse.ZERO_OR_MORE, 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), @@ -405,7 +407,6 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_leg 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) @@ -472,6 +473,7 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_mod type=str, help=help_tmpl, ), + call("args", nargs=argparse.ZERO_OR_MORE, 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), @@ -479,7 +481,6 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_mod 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) @@ -641,9 +642,9 @@ def cmd(foo_pos, bar_pos, *args, foo_kwonly="foo_kwonly", bar_kwonly): assert parser.add_argument.mock_calls == [ call("foo-pos", help=help_tmpl), call("bar-pos", help=help_tmpl), + call("args", nargs=argparse.ZERO_OR_MORE, 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), ] @@ -662,9 +663,9 @@ def cmd(foo_pos, bar_pos, *args, foo_kwonly="foo_kwonly", bar_kwonly): assert parser.add_argument.mock_calls == [ call("foo-pos", help=help_tmpl), call("bar-pos", help=help_tmpl), + call("args", nargs=argparse.ZERO_OR_MORE, 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=argparse.ZERO_OR_MORE, help=help_tmpl), ] @@ -762,3 +763,160 @@ def test_is_positional(): # this spec is invalid but validation is out of scope of the function # as it only checks if the first argument has the leading dash assert argh.assembling._is_positional(["-f", "foo"]) is False + + +def test_typing_hints_only_used_when_arg_deco_not_used(): + @argh.arg("foo", type=int) + def func_decorated(foo: Optional[float]): + ... + + def func_undecorated(bar: Optional[float]): + ... + + parser = argparse.ArgumentParser() + parser.add_argument = MagicMock() + argh.set_default_command(parser, func_decorated) + assert parser.add_argument.mock_calls == [ + call("foo", type=int, help=argh.constants.DEFAULT_ARGUMENT_TEMPLATE), + ] + + parser = argparse.ArgumentParser() + parser.add_argument = MagicMock() + argh.set_default_command(parser, func_undecorated) + assert parser.add_argument.mock_calls == [ + call( + "bar", + nargs="?", + type=float, + help=argh.constants.DEFAULT_ARGUMENT_TEMPLATE, + ), + ] + + +def test_typing_hints_overview(): + def func( + alpha, + beta: str, + gamma: Optional[int] = None, + *, + delta: float = 1.5, + epsilon: Optional[int] = 42, + zeta: bool = False, + ) -> str: + return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}, epsilon={epsilon}, zeta={zeta}" + + parser = argparse.ArgumentParser() + parser.add_argument = MagicMock() + argh.set_default_command( + parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE} + assert parser.add_argument.mock_calls == [ + call("alpha", **_extra_kw), + call("beta", type=str, **_extra_kw), + call("gamma", default=None, nargs="?", type=int, **_extra_kw), + call("-d", "--delta", type=float, default=1.5, **_extra_kw), + call("-e", "--epsilon", type=int, default=42, required=False, **_extra_kw), + call("-z", "--zeta", default=False, action="store_true", **_extra_kw), + ] + + +def test_typing_hints_str__policy_by_name_if_has_default(): + def func(alpha: str, beta: str = "N/A", *, gamma: str, delta: str = "N/A") -> str: + return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}" + + parser = argparse.ArgumentParser() + parser.add_argument = MagicMock() + argh.set_default_command( + parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + ) + _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE} + assert parser.add_argument.mock_calls == [ + call("alpha", type=str, **_extra_kw), + call("-b", "--beta", default="N/A", type=str, **_extra_kw), + call("gamma", type=str, **_extra_kw), + call("-d", "--delta", default="N/A", type=str, **_extra_kw), + ] + + +def test_typing_hints_str__policy_by_name_if_kwonly(): + def func(alpha: str, beta: str = "N/A", *, gamma: str, delta: str = "N/A") -> str: + return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}" + + parser = argparse.ArgumentParser() + parser.add_argument = MagicMock() + argh.set_default_command( + parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE} + assert parser.add_argument.mock_calls == [ + call("alpha", type=str, help=argh.constants.DEFAULT_ARGUMENT_TEMPLATE), + call("beta", type=str, default="N/A", nargs="?", **_extra_kw), + call("-g", "--gamma", required=True, type=str, **_extra_kw), + call("-d", "--delta", default="N/A", type=str, **_extra_kw), + ] + + +def test_typing_hints_bool__policy_by_name_if_has_default(): + def func( + alpha: bool, beta: bool = False, *, gamma: bool, delta: bool = False + ) -> str: + return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}" + + parser = argparse.ArgumentParser() + parser.add_argument = MagicMock() + argh.set_default_command( + parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT + ) + _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE} + assert parser.add_argument.mock_calls == [ + call("alpha", type=bool, **_extra_kw), + call("-b", "--beta", default=False, action="store_true", **_extra_kw), + call("gamma", type=bool, **_extra_kw), + call("-d", "--delta", default=False, action="store_true", **_extra_kw), + ] + + +def test_typing_hints_bool__policy_by_name_if_kwonly(): + def func( + alpha: bool, beta: bool = False, *, gamma: bool, delta: bool = False + ) -> str: + return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}" + + parser = argparse.ArgumentParser() + parser.add_argument = MagicMock() + argh.set_default_command( + parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE} + assert parser.add_argument.mock_calls == [ + call("alpha", type=bool, **_extra_kw), + call("beta", type=bool, default=False, nargs="?", **_extra_kw), + call("-g", "--gamma", required=True, type=bool, **_extra_kw), + call("-d", "--delta", default=False, action="store_true", **_extra_kw), + ] + + +def test_typing_hints_literal(): + def func( + name: Literal["Alice", "Bob"], *, greeting: Literal["Hello", "Hi"] = "Hello" + ) -> str: + return f"{greeting}, {name}!" + + parser = argparse.ArgumentParser() + parser.add_argument = MagicMock() + argh.set_default_command( + parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY + ) + _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE} + assert parser.add_argument.mock_calls == [ + call("name", choices=("Alice", "Bob"), type=str, **_extra_kw), + call( + "-g", + "--greeting", + choices=("Hello", "Hi"), + type=str, + default="Hello", + **_extra_kw, + ), + ] diff --git a/tests/test_decorators.py b/tests/test_decorators.py index b813069..7c9b7cb 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -74,16 +74,6 @@ def func(): assert attr == "STUB" -# TODO: deprecated — remove in v0.31+ -def test_expects_obj(): - @argh.expects_obj - def func(args): - pass - - 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): diff --git a/tests/test_dispatching.py b/tests/test_dispatching.py index 987b8c8..1bfaf72 100644 --- a/tests/test_dispatching.py +++ b/tests/test_dispatching.py @@ -50,10 +50,31 @@ def func(): mock_parser_class.assert_called_once() mock_parser = mock_parser_class.return_value - mock_set_default_command.assert_called_with(mock_parser, func) + mock_set_default_command.assert_called_with( + mock_parser, + func, + name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + ) mock_dispatch.assert_called_with(mock_parser) +@pytest.mark.parametrize("always_flush", [True, False]) +def test_run_endpoint_function__always_flush(always_flush): + def func(): + return ["first line", "second line"] + + out_io = Mock(spec=io.StringIO) + + argh.dispatching.run_endpoint_function( + func, argparse.Namespace(), output_file=out_io, always_flush=always_flush + ) + + if always_flush: + assert out_io.flush.call_count == 2 + else: + out_io.flush.assert_not_called() + + @patch("argh.dispatching.parse_and_resolve") @patch("argh.dispatching.run_endpoint_function") def test_dispatch_command_two_stage(mock_run_endpoint_function, mock_parse_and_resolve): @@ -68,6 +89,7 @@ def func() -> str: mock_errors_file = Mock(io.TextIOBase) raw_output = False skip_unknown_args = False + always_flush = True mock_endpoint_function = Mock() mock_namespace = Mock(argparse.Namespace) mock_namespace_obj = Mock(argparse.Namespace) @@ -83,6 +105,7 @@ def func() -> str: output_file=mock_output_file, errors_file=mock_errors_file, raw_output=raw_output, + always_flush=always_flush, ) mock_parse_and_resolve.assert_called_with( @@ -98,6 +121,7 @@ def func() -> str: output_file=mock_output_file, errors_file=mock_errors_file, raw_output=raw_output, + always_flush=always_flush, ) assert retval == "run_endpoint_function retval" @@ -113,7 +137,11 @@ def func(): mock_parser_class.assert_called_once() mock_parser = mock_parser_class.return_value - mock_add_commands.assert_called_with(mock_parser, [func]) + mock_add_commands.assert_called_with( + mock_parser, + [func], + name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + ) mock_dispatch.assert_called_with(mock_parser) @@ -162,3 +190,75 @@ def hit(): add_commands_mock.assert_called_with(mocked_parser, [greet, hit]) assert dispatch_mock.called dispatch_mock.assert_called_with(mocked_parser) + + +@patch("argh.dispatching.dispatch") +@patch("argh.dispatching.set_default_command") +@patch("argparse.ArgumentParser") +def test_dispatch_command_naming_policy( + parser_cls_mock, set_default_command_mock, dispatch_mock +): + def func(): + ... + + parser_mock = Mock() + parser_cls_mock.return_value = parser_mock + + argh.dispatching.dispatch_command(func) + set_default_command_mock.assert_called_with( + parser_mock, + func, + name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + ) + set_default_command_mock.reset_mock() + + argh.dispatching.dispatch_command(func, old_name_mapping_policy=True) + set_default_command_mock.assert_called_with( + parser_mock, + func, + name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + ) + set_default_command_mock.reset_mock() + + argh.dispatching.dispatch_command(func, old_name_mapping_policy=False) + set_default_command_mock.assert_called_with( + parser_mock, + func, + name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY, + ) + + +@patch("argh.dispatching.dispatch") +@patch("argh.dispatching.add_commands") +@patch("argparse.ArgumentParser") +def test_dispatch_commands_naming_policy( + parser_cls_mock, add_commands_mock, dispatch_mock +): + def func(): + ... + + parser_mock = Mock() + parser_cls_mock.return_value = parser_mock + + argh.dispatching.dispatch_commands(func) + add_commands_mock.assert_called_with( + parser_mock, + func, + name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + ) + add_commands_mock.reset_mock() + + argh.dispatching.dispatch_commands(func, old_name_mapping_policy=True) + add_commands_mock.assert_called_with( + parser_mock, + func, + name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT, + ) + add_commands_mock.reset_mock() + + argh.dispatching.dispatch_commands(func, old_name_mapping_policy=False) + add_commands_mock.assert_called_with( + parser_mock, + func, + name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY, + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index e70db4f..f863386 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -559,20 +559,6 @@ def whiner_iterable(): ) -# TODO: deprecated — remove in v0.31+ -def test_custom_argparse_namespace(): - @argh.expects_obj - def cmd(args): - return args.custom_value - - parser = DebugArghParser() - parser.set_default_command(cmd) - namespace = argparse.Namespace() - namespace.custom_value = "foo" - - assert run(parser, "", {"namespace": namespace}).out == "foo\n" - - @pytest.mark.parametrize( "argparse_namespace_class", [argparse.Namespace, argh.dispatching.ArghNamespace] ) @@ -672,17 +658,17 @@ def cmd(*args, foo="1", bar, baz="3", **kwargs): cmd, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT ) - expected_usage = "usage: pytest [-h] [-f FOO] [--baz BAZ] bar [args ...]\n" + expected_usage = "usage: pytest [-h] [-f FOO] [--baz BAZ] [args ...] bar\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" + "usage: pytest [-h] [-f FOO] [--baz BAZ] [args [args ...]] bar\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" + run(parser, "--baz=baz! one two").out + == "foo='1' bar='two' baz='baz!' args=('one',) kwargs={}\n" ) assert ( run(parser, "test --foo=do").out @@ -710,8 +696,8 @@ def cmd(*args, foo="1", bar, baz="3", **kwargs): assert parser.format_usage() == expected_usage assert ( - run(parser, "--baz=done test this --bar=do").out - == "foo='1' bar='do' baz='done' args=('test', 'this') kwargs={}\n" + run(parser, "--baz=baz! one two --bar=bar!").out + == "foo='1' bar='bar!' baz='baz!' args=('one', 'two') kwargs={}\n" ) message = "the following arguments are required: --bar" assert run(parser, "test --foo=do", exit=True) == message diff --git a/tests/test_typing_hints.py b/tests/test_typing_hints.py new file mode 100644 index 0000000..1b02da7 --- /dev/null +++ b/tests/test_typing_hints.py @@ -0,0 +1,60 @@ +from typing import List, Literal, Optional, Union + +import pytest + +from argh.assembling import TypingHintArgSpecGuesser + + +@pytest.mark.parametrize("arg_type", TypingHintArgSpecGuesser.BASIC_TYPES) +def test_simple_types(arg_type): + guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params + + # just the basic type + assert guess(arg_type) == {"type": arg_type} + + # basic type or None + assert guess(Optional[arg_type]) == { + "type": arg_type, + "required": False, + } + assert guess(Union[None, arg_type]) == {"required": False} + + # multiple basic types: the first one is used and None is looked up + assert guess(Union[arg_type, str, None]) == { + "type": arg_type, + "required": False, + } + assert guess(Union[str, arg_type, None]) == { + "type": str, + "required": False, + } + + +def test_list(): + guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params + + assert guess(list) == {"nargs": "*"} + assert guess(Optional[list]) == {"nargs": "*", "required": False} + + assert guess(List[str]) == {"nargs": "*", "type": str} + assert guess(List[int]) == {"nargs": "*", "type": int} + assert guess(Optional[List[str]]) == {"nargs": "*", "type": str, "required": False} + assert guess(Optional[List[tuple]]) == {"nargs": "*", "required": False} + + assert guess(List[list]) == {"nargs": "*"} + assert guess(List[tuple]) == {"nargs": "*"} + + +def test_literal(): + guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params + + assert guess(Literal["a"]) == {"choices": ("a",), "type": str} + assert guess(Literal["a", "b"]) == {"choices": ("a", "b"), "type": str} + assert guess(Literal[1]) == {"choices": (1,), "type": int} + + +@pytest.mark.parametrize("arg_type", (dict, tuple)) +def test_unusable_types(arg_type): + guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params + + assert guess(arg_type) == {} diff --git a/tests/test_utils.py b/tests/test_utils.py index 02b26e9..df91f47 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,99 +3,11 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """ -import functools from argparse import ArgumentParser, _SubParsersAction import pytest -from argh.utils import SubparsersNotDefinedError, get_arg_spec, get_subparsers, unindent - - -def function(x, y=0): - return - - -def decorated(func): - @functools.wraps(func) - def wrapped(*args, **kwargs): - print("Wrapping function call") - return func(*args, **kwargs) - - return wrapped - - -def _assert_spec(func, **overrides): - spec = get_arg_spec(func) - - defaults = { - "args": ["x", "y"], - "varargs": None, - "varkw": None, - "defaults": (0,), - "kwonlyargs": [], - "annotations": {}, - } - - for k in defaults: - actual = getattr(spec, k) - expected = overrides[k] if k in overrides else defaults[k] - assert actual == expected - - -def test_get_arg_spec__plain_func(): - _assert_spec(function) - - -def test_get_arg_spec__decorated_func(): - def d(_f): - return _f - - decorated = d(function) - - _assert_spec(decorated) - - -def test_get_arg_spec__wrapped(): - wrapped = decorated(function) - _assert_spec(wrapped) - - -def test_get_arg_spec__wrapped_nested(): - wrapped = decorated(decorated(function)) - _assert_spec(wrapped) - - -def test_get_arg_spec__wrapped_complex(): - def wrapper_deco(outer_arg): - def _outer(func): - @functools.wraps(func) - def _inner(*args, **kwargs): - return func(*args, **kwargs) - - return _inner - - return _outer - - wrapped = wrapper_deco(5)(function) - - _assert_spec(wrapped) - - -def test_get_arg_spec__static_method(): - class C: - @staticmethod - def func(x, y=0): - return x - - _assert_spec(C.func) - - -def test_get_arg_spec__method(): - class C: - def func(self, x, y=0): - return x - - _assert_spec(C.func, args=["self", "x", "y"]) +from argh.utils import SubparsersNotDefinedError, get_subparsers, unindent def test_util_unindent():