From c6549fc9df5800221d08151238fb77f48d04942d Mon Sep 17 00:00:00 2001 From: Andy Mikhaylenko Date: Sun, 22 Oct 2023 02:38:25 +0200 Subject: [PATCH 1/8] docs: improve the tutorial --- docs/source/tutorial.rst | 139 ++++++++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 45 deletions(-) diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 03495cd..aa6ac12 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -17,7 +17,7 @@ Assume we need a CLI application which output is modulated by arguments: $ ./greet.py Hello unknown user! - $ ./greet.py --name John + $ ./greet.py John Hello John! This is our business logic: @@ -34,6 +34,19 @@ Let's convert the function into a complete CLI application:: 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]) @@ -75,71 +88,102 @@ Declaring Commands The Natural Way ............... -You've already learned the natural way of declaring commands before even -knowing about `argh`:: +If you are comfortable with the basics of Python, you already knew the natural +way of declaring CLI commands with `Argh` before even learning about the +existence of `Argh`. + +Please read the following snippet carefully. Is there any `Argh`-specific API? + +:: + + def my_command( + alpha: str, beta: int = 1, *args, gamma: int, delta: bool = False + ) -> list[str]: + return [alpha, beta, args, gamma, delta] - def my_command(alpha, beta=1, gamma=False, *delta): - return +The answer is: no. This is a completely generic Python function. -When executed as ``app.py my-command --help``, such application prints:: +Let's make this function available as a CLI command:: - usage: app.py my-command [-h] [-b BETA] [-g] alpha [delta ...] + import argh + + + def my_command( + alpha: str, beta: int = 1, *args, gamma: int, delta: bool = False + ) -> list[str]: + return [alpha, beta, args, gamma, delta] + + + if __name__ == "__main__": + argh.dispatch_commands([my_command]) + +That's all. You don't need to do anything else. + +When executed as ``./app.py my-command --help``, such application prints:: + + usage: app.py my-command [-h] -g GAMMA [-d] alpha [beta] [args ...] positional arguments: - alpha - delta + alpha - + beta 1 + args - - optional arguments: + options: -h, --help show this help message and exit - -b BETA, --beta BETA - -g, --gamma + -g GAMMA, --gamma GAMMA + - + -d, --delta False -The same result can be achieved with this chunk of `argparse` code (with the -exception that in `argh` you don't immediately modify a parser but rather -declare what's to be added to it later):: +Now let's take a look at how we would do it without `Argh`:: - parser.add_argument("alpha") - parser.add_argument("-b", "--beta", default=1, type=int) - parser.add_argument("-g", "--gamma", default=False, action="store_true") - parser.add_argument("delta", nargs="*") + import argparse -Verbose, hardly readable, requires learning another API. -`Argh` allows for more expressive and pythonic code because: + def my_command( + alpha: str, beta: int = 1, *args, gamma: int, delta: bool = False + ) -> list[str]: + return [alpha, beta, args, gamma, delta] -* everything is inferred from the function signature; -* arguments without default values are interpreted as required positional - arguments; -* arguments with default values are interpreted as options; - * options with a `bool` as default value are considered flags and their - presence triggers the action `store_true` (or `store_false`); - * values of options that don't trigger actions are coerced to the same type - as the default value; + if __name__ == "__main__": + parser = argparse.ArgumentParser() -* the ``*args`` entry (function's positional arguments) is interpreted as - a single argument with 0..n values. + subparser = parser.add_subparsers().add_parser("my-command") -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. + subparser.add_argument("alpha") + subparser.add_argument("beta", default=1, nargs="?", type=int) + subparser.add_argument("args", nargs="*") + subparser.add_argument("-g", "--gamma") + subparser.add_argument("-d", "--delta", default=False, action="store_true") -.. note:: + ns = parser.parse_args() - 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. + lines = my_command(ns.alpha, ns.beta, *ns.args, gamma=ns.gamma, delta=ns.delta) - Please check `~argh.assembling.NameMappingPolicy` for details. + for line in lines: + print(line) - Usage example:: +Verbose, hardly readable, requires learning the API. With `Argh` it's just a +single line in addition to your function. - def my_command(alpha, beta=1, *, gamma, delta=False, **kwargs): - ... +`Argh` allows for more expressive and pythonic code because: + +* everything is inferred from the function signature; +* 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 + arguments; + + * keyword-only arguments with a `bool` default value are considered flags + (AKA toggles) and their presence triggers the action `store_true` (or + `store_false`). - argh.dispatch_command( - my_command, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY - ) +* you can ``print()`` but you don't have to — the return value will be printed + for you; it can even be an iterable (feel free to ``yield`` too), then each + element will be printed on its own line. + +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. 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 @@ -158,6 +202,11 @@ Extended argument declaration can be helpful in that case. Extended Argument Declaration ............................. +.. note:: + + This section will be out of date soon. Typing hints will be used for all + the cases described here including argument help. + When function signature isn't enough to fine-tune the argument declarations, the :class:`~argh.decorators.arg` decorator comes in handy:: From a620f2bae82e11cd554325f340946528061854aa Mon Sep 17 00:00:00 2001 From: Andy Mikhailenko Date: Sun, 22 Oct 2023 16:46:33 +0200 Subject: [PATCH 2/8] feat: support realtime output through a pipe (fixes #145) (#202) 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. To do so, set `dispatch(..., always_flush=True)`. --- CHANGES.rst | 7 +++++++ src/argh/dispatching.py | 36 ++++++++++++++++++++++++++++++++++-- tests/test_dispatching.py | 20 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 654cc5e..cc973f3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,13 @@ Changelog ~~~~~~~~~ +Version 0.31.0 +-------------- + +Enhancements: + +- Added `always_flush` argument to `dispatch()` (issue #145) + Version 0.30.0 -------------- diff --git a/src/argh/dispatching.py b/src/argh/dispatching.py index fb4b085..a983f7b 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -79,6 +79,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 @@ -142,6 +143,19 @@ def dispatch( that support for combined default and nested functions may be broken if a different type of object is forced. + :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` which is interpreted as an expected event so the traceback is hidden. @@ -182,6 +196,7 @@ def dispatch( output_file=output_file, errors_file=errors_file, raw_output=raw_output, + always_flush=always_flush, ) @@ -225,20 +240,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 +284,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) diff --git a/tests/test_dispatching.py b/tests/test_dispatching.py index 987b8c8..38fbd1c 100644 --- a/tests/test_dispatching.py +++ b/tests/test_dispatching.py @@ -54,6 +54,23 @@ def func(): 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 +85,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 +101,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 +117,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" From 934998e14e29050a866515984ee65b0c2a96c901 Mon Sep 17 00:00:00 2001 From: Andy Mikhaylenko Date: Thu, 28 Dec 2023 01:11:18 +0100 Subject: [PATCH 3/8] chore: deprecate `dispatch(..., namespace=N)` The argument `namespace` is deprecated for the following public functions: - `dispatch()` - `parse_and_resolve()` --- CHANGES.rst | 8 ++++++++ src/argh/dispatching.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 2f56a00..00d63cf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,14 @@ Enhancements: - Added `always_flush` argument to `dispatch()` (issue #145) +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. + Version 0.30.5 (2023-12-25) --------------------------- diff --git a/src/argh/dispatching.py b/src/argh/dispatching.py index a983f7b..88133ae 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -143,6 +143,10 @@ def dispatch( 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 @@ -211,6 +215,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) From 6180ca9d04ececcc03bab61603d0612bbb72fc5d Mon Sep 17 00:00:00 2001 From: Andy Mikhailenko Date: Sat, 30 Dec 2023 06:12:44 +0100 Subject: [PATCH 4/8] Type hints: basic usage to infer argument types (fixes #203) (#211) * add draft for typing hints inspection * refactor: use inspect.signature() Also drop argh.utils.get_arg_spec(). It is no longer needed because while getfullargspec() did not handle decorated functions properly, signature() does. A backwards incompatible change (an extremely rare edge case) is documented in the changelog. * docs: update changelog * feat: basic argspec guessing from typing hints * chore: remove @expects_obj * fix: edge case with bool and annotations * feat: simple policy switch in dispatch_command(s) * docs: rewrite the tutorial, document hints * docs: fix formatting * docs: add help examples * docs: improve wording * docs: promote new example to readme itself * docs: extract quickstart to a separate doc * docs: named examples in README * feat: support `Literal[a,b]` as `choices=(a,b)` * chore: bump version * refactor: use constant * style: fix minor stuff --- AUTHORS.rst | 2 +- CHANGES.rst | 50 ++++++ README.rst | 34 +++- docs/source/cookbook.rst | 9 +- docs/source/index.rst | 1 + docs/source/projects.rst | 2 +- docs/source/quickstart.rst | 215 +++++++++++++++++++++++ docs/source/reference.rst | 8 +- docs/source/similar.rst | 2 +- docs/source/subparsers.rst | 2 +- docs/source/the_story.rst | 2 +- docs/source/tutorial.rst | 192 ++++++++++----------- pyproject.toml | 2 +- src/argh/__init__.py | 3 +- src/argh/assembling.py | 337 ++++++++++++++++++++++++++----------- src/argh/constants.py | 4 - src/argh/decorators.py | 41 +---- src/argh/dispatching.py | 130 +++++++++----- src/argh/utils.py | 20 +-- tests/test_assembling.py | 166 +++++++++++++++++- tests/test_decorators.py | 10 -- tests/test_dispatching.py | 84 ++++++++- tests/test_integration.py | 26 +-- tests/test_typing_hints.py | 60 +++++++ tests/test_utils.py | 90 +--------- 25 files changed, 1036 insertions(+), 456 deletions(-) create mode 100644 docs/source/quickstart.rst create mode 100644 tests/test_typing_hints.py 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 00d63cf..5f99d35 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,10 +5,56 @@ Changelog Version 0.31.0 -------------- +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``); + - ``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()`. @@ -17,6 +63,10 @@ Deprecated: 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..1727b54 100644 --- a/README.rst +++ b/README.rst @@ -79,7 +79,7 @@ In a nutshell `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! +Sounds good? Check the :doc:`quickstart` and the :doc:`tutorial`! Relation to argparse -------------------- @@ -98,6 +98,9 @@ Installation Examples -------- +Hello World +........... + A very simple application with one command: .. code-block:: python @@ -116,6 +119,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 +159,9 @@ Run it: $ ./app.py echo Hey Hey +Modularity +.......... + A potentially modular application with more control over the process: .. code-block:: python @@ -195,6 +224,9 @@ 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: 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..54235b9 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 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 88133ae..784bf2e 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", @@ -138,11 +137,6 @@ 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. @@ -169,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 @@ -349,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()) - # filter the namespace variables so that only those expected - # by the actual function will pass + # namespace -> dictionary + def _flat_key(key): + return key.replace("-", "_") - 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)): @@ -417,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) @@ -429,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:: @@ -439,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]) @@ -457,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) 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 38fbd1c..1bfaf72 100644 --- a/tests/test_dispatching.py +++ b/tests/test_dispatching.py @@ -50,7 +50,11 @@ 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) @@ -133,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) @@ -182,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(): From b53b34e9871161120a9925ff4fb04fcf29a7356b Mon Sep 17 00:00:00 2001 From: Andy Mikhaylenko Date: Sat, 30 Dec 2023 18:07:25 +0100 Subject: [PATCH 5/8] docs: update feature emphasis in README --- CHANGES.rst | 7 +++---- README.rst | 45 +++++++++++++++++---------------------------- 2 files changed, 20 insertions(+), 32 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5f99d35..fde489f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,9 +1,8 @@ -~~~~~~~~~ Changelog -~~~~~~~~~ +========= -Version 0.31.0 --------------- +Version 0.31.0 (2023-12-30) +--------------------------- Breaking changes: diff --git a/README.rst b/README.rst index 1727b54..cf5d22a 100644 --- a/README.rst +++ b/README.rst @@ -43,14 +43,20 @@ In a nutshell `Argh`-powered applications are *simple* but *flexible*: +:Pythonic: + Commands are plain Python functions. No CLI-specific API to learn. + :Modular: Declaration of commands can be decoupled from assembling and dispatching; -:Pythonic: - Commands are declared naturally, no complex API calls in most cases; - :Reusable: - Commands are plain functions, can be used directly outside of CLI context; + Endpoint functions can be used directly outside of CLI context; + +:Static typing friendly: + 100% of the code including endpoint functions can be type-checked. + Argh relies on type annotations while other libraries tend to rely on + decorators and namespace objects, sometimes even mangling function + signatures; :Layered: The complexity of code raises with requirements; @@ -59,12 +65,8 @@ In a nutshell 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`; + Nested commands are a piece of cake, Argh isolates the complexity of + subparsers; :DRY: Don't Repeat Yourself. The amount of boilerplate code is minimal. @@ -72,13 +74,15 @@ 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. :NIH free: `Argh` supports *completion*, *progress bars* and everything else by being friendly to excellent 3rd-party libraries. No need to reinvent the wheel. +:Compact: + No dependencies apart from Python's standard library. + Sounds good? Check the :doc:`quickstart` and the :doc:`tutorial`! Relation to argparse @@ -236,22 +240,7 @@ enough; in these cases the powerful API of `argparse` is also available: def echo(text: str) -> None: print text -The approaches can be safely combined even up to this level: - -.. code-block:: python - - # 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 ----- From 0f34a27b66513bd8657c1f0a49b406e0e31e8d8a Mon Sep 17 00:00:00 2001 From: Andy Mikhaylenko Date: Sat, 30 Dec 2023 18:35:02 +0100 Subject: [PATCH 6/8] docs: cleanup/enhance README --- README.rst | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index cf5d22a..0f37a9e 100644 --- a/README.rst +++ b/README.rst @@ -43,30 +43,15 @@ In a nutshell `Argh`-powered applications are *simple* but *flexible*: -:Pythonic: - Commands are plain Python functions. No CLI-specific API to learn. - -:Modular: - Declaration of commands can be decoupled from assembling and dispatching; +:Pythonic, KISS: + Commands are plain Python functions. Almost no CLI-specific API to learn. :Reusable: - Endpoint functions can be used directly outside of CLI context; + Endpoint functions can be used directly outside of CLI context. :Static typing friendly: 100% of the code including endpoint functions can be type-checked. - Argh relies on type annotations while other libraries tend to rely on - decorators and namespace objects, sometimes even mangling function - signatures; - -:Layered: - The complexity of code raises with requirements; - -:Transparent: - The full power of argparse is available whenever needed; - -:Namespaced: - Nested commands are a piece of cake, Argh isolates the complexity of - subparsers; + Argh is driven primarily by type annotations. :DRY: Don't Repeat Yourself. The amount of boilerplate code is minimal. @@ -76,6 +61,18 @@ In a nutshell * infer arguments from function signature; * 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. From 684b4a94b3a38ce43026cbcf03c7e76781cfb124 Mon Sep 17 00:00:00 2001 From: Andy Mikhaylenko Date: Sat, 30 Dec 2023 18:50:29 +0100 Subject: [PATCH 7/8] docs: fix outdated examples --- README.rst | 6 +++--- docs/source/subparsers.rst | 4 ++-- src/argh/dispatching.py | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index 0f37a9e..0beb1eb 100644 --- a/README.rst +++ b/README.rst @@ -233,9 +233,9 @@ 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 + @arg("words", default="hello world", nargs="+", help="The message") + def echo(words: list[str]) -> str: + return " ".join(words) Please note that decorators will soon be fully replaced with annotations. diff --git a/docs/source/subparsers.rst b/docs/source/subparsers.rst index 54235b9..1960779 100644 --- a/docs/source/subparsers.rst +++ b/docs/source/subparsers.rst @@ -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/src/argh/dispatching.py b/src/argh/dispatching.py index 784bf2e..ef413f6 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -527,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() From fddce1a5d04f3f3bab3d86c148b5fe151369cc23 Mon Sep 17 00:00:00 2001 From: Andy Mikhaylenko Date: Sat, 30 Dec 2023 18:59:20 +0100 Subject: [PATCH 8/8] docs: mention Literal in changelog --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index fde489f..4fd744b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -40,6 +40,7 @@ Enhancements: - ``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).