From 7853057219c515b4a9973a6ed6a2cc981981090e Mon Sep 17 00:00:00 2001 From: Andy Mikhailenko Date: Wed, 4 Oct 2023 16:02:42 +0200 Subject: [PATCH] Remove pre_call, expose finer control over dispatching (#193) This addresses #184 while providing an alternative solution for #63. --- CHANGES.rst | 20 +++++++- src/argh/__init__.py | 4 ++ src/argh/dispatching.py | 105 +++++++++++++++++++++++++------------- tests/test_dispatching.py | 49 ++++++++++++++++++ tests/test_integration.py | 2 +- 5 files changed, 142 insertions(+), 38 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 610ce1c..16d12a6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,9 +14,18 @@ Backwards incompatible changes: - `argh.safe_input()`; - previously renamed arguments for `add_commands()`: `namespace`, `namespace_kwargs`, `title`, `description`, `help`; + - `pre_call` argument in `dispatch()`. The basic usage remains simple but + more granular functions are now available for more control. - Note: the `pre_call` hack was scheduled to be removed but due to requests it - will remain until a replacement is implemented. + Instead of this:: + + argh.dispatch(..., pre_call=pre_call_hook) + + please use this:: + + func, ns = argh.parse_and_resolve(...) + pre_call_hook(ns) + argh.run_endpoint_function(func, ns, ...) Deprecated: @@ -33,6 +42,13 @@ Deprecated: Enhancements: - Added type annotations to existing Argh code (#185 → #189). +- The `dispatch()` function has been refactored, so in case you need finer + control over the process, two new, more granular functions can be used: + + - `endpoint_function, namespace = argh.parse_and_resolve(...)` + - `argh.run_endpoint_function(endpoint_function, namespace, ...)` + + Please note that the names may change in the upcoming versions. Version 0.29.4 -------------- diff --git a/src/argh/__init__.py b/src/argh/__init__.py index 954c96d..fa54828 100644 --- a/src/argh/__init__.py +++ b/src/argh/__init__.py @@ -20,6 +20,8 @@ dispatch, dispatch_command, dispatch_commands, + parse_and_resolve, + run_endpoint_function, ) from .exceptions import AssemblingError, CommandError, DispatchingError from .helpers import ArghParser @@ -45,4 +47,6 @@ "DispatchingError", "ArghParser", "confirm", + "parse_and_resolve", + "run_endpoint_function", ) diff --git a/src/argh/dispatching.py b/src/argh/dispatching.py index 8985efb..1ad6a03 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -16,7 +16,7 @@ import sys import warnings from types import GeneratorType -from typing import IO, Any, Callable, Dict, Iterator, List, Optional +from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Tuple from argh.assembling import add_commands, set_default_command from argh.completion import autocomplete @@ -35,6 +35,8 @@ "dispatch", "dispatch_command", "dispatch_commands", + "parse_and_resolve", + "run_endpoint_function", "PARSER_FORMATTER", "EntryPoint", ] @@ -77,15 +79,13 @@ def dispatch( raw_output: bool = False, namespace: Optional[argparse.Namespace] = None, skip_unknown_args: bool = False, - # deprecated args: - pre_call: Optional[Callable] = None, ) -> Optional[str]: """ Parses given list of arguments using given parser, calls the relevant function and prints the result. - The target function should expect one positional argument: the - :class:`argparse.Namespace` object. + Internally calls :func:`~argh.dispatching.parse_and_resolve` and then + :func:`~argh.dispatching.run_endpoint_function`. :param parser: @@ -151,11 +151,6 @@ def dispatch( Wrapped exceptions, or other "expected errors" like parse failures, will cause a SystemExit to be raised. """ - if completion: - autocomplete(parser) - - if argv is None: - argv = sys.argv[1:] # TODO: remove in v0.31+/v1.0 if add_help_command: # pragma: nocover @@ -169,6 +164,45 @@ def dispatch( argv.pop(0) argv.append("--help") + endpoint_function, namespace_obj = parse_and_resolve( + parser=parser, + completion=completion, + argv=argv, + namespace=namespace, + skip_unknown_args=skip_unknown_args, + ) + + if not endpoint_function: + parser.print_usage(output_file) + return None + + return run_endpoint_function( + function=endpoint_function, + namespace_obj=namespace_obj, + output_file=output_file, + errors_file=errors_file, + raw_output=raw_output, + ) + + +def parse_and_resolve( + parser: argparse.ArgumentParser, + argv: Optional[List[str]] = None, + completion: bool = True, + namespace: Optional[argparse.Namespace] = None, + skip_unknown_args: bool = False, +) -> Tuple[Optional[Callable], argparse.Namespace]: + """ + .. versionadded: 0.30 + + Parses CLI arguments and resolves the endpoint function. + """ + if completion: + autocomplete(parser) + + if argv is None: + argv = sys.argv[1:] + if not namespace: namespace = ArghNamespace() @@ -182,13 +216,31 @@ def dispatch( function = _get_function_from_namespace_obj(namespace_obj) - if function: - lines = _execute_command( - function, namespace_obj, errors_file, pre_call=pre_call - ) - else: - # no commands declared, can't dispatch; display help message - lines = iter([parser.format_usage()]) + return function, namespace_obj + + +def run_endpoint_function( + function: Callable, + namespace_obj: argparse.Namespace, + output_file: IO = sys.stdout, + errors_file: IO = sys.stderr, + raw_output: bool = False, +) -> Optional[str]: + """ + .. versionadded: 0.30 + + Extracts arguments from the namespace object, calls the endpoint function + and processes its output. + """ + lines = _execute_command(function, namespace_obj, errors_file) + + return _process_command_output(lines, output_file, raw_output) + + +def _process_command_output( + lines: Iterator[str], output_file: Optional[IO], raw_output: bool +) -> Optional[str]: + out_io: IO if output_file is None: # user wants a string; we create an internal temporary file-like object @@ -239,10 +291,7 @@ def _get_function_from_namespace_obj( def _execute_command( - function: Callable, - namespace_obj: argparse.Namespace, - errors_file: IO, - pre_call: Optional[Callable] = None, + function: Callable, namespace_obj: argparse.Namespace, errors_file: IO ) -> Iterator[str]: """ Assumes that `function` is a callable. Tries different approaches @@ -254,20 +303,6 @@ def _execute_command( All other exceptions propagate unless marked as wrappable by :func:`wrap_errors`. """ - # TODO: remove in v.0.30 - if pre_call: # pragma: no cover - # This is NOT a documented and recommended API. - # The common use case for this hack is to inject shared arguments. - # Such approach would promote an approach which is not in line with the - # purpose of the library, i.e. to keep things natural and "pythonic". - # Argh is about keeping CLI in line with function signatures. - # The `pre_call` hack effectively destroys this mapping. - # There should be a better solution, e.g. decorators and/or some shared - # objects. - # - # See discussion here: https://github.com/neithere/argh/issues/63 - pre_call(namespace_obj) - # the function is nested to catch certain exceptions (see below) def _call(): # Actually call the function diff --git a/tests/test_dispatching.py b/tests/test_dispatching.py index 0c03549..f14ffc0 100644 --- a/tests/test_dispatching.py +++ b/tests/test_dispatching.py @@ -2,6 +2,7 @@ Dispatching tests ~~~~~~~~~~~~~~~~~ """ +import argparse import io from unittest.mock import Mock, patch @@ -53,6 +54,54 @@ def func(): mock_dispatch.assert_called_with(mock_parser) +@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): + def func() -> str: + return "function output" + + mock_parser = Mock(argparse.ArgumentParser) + mock_parser.parse_args.return_value = argparse.Namespace(foo=123) + argv = ["foo", "bar", "baz"] + completion = False + mock_output_file = Mock(io.TextIOBase) + mock_errors_file = Mock(io.TextIOBase) + raw_output = False + skip_unknown_args = False + mock_endpoint_function = Mock() + mock_namespace = Mock(argparse.Namespace) + mock_namespace_obj = Mock(argparse.Namespace) + mock_parse_and_resolve.return_value = (mock_endpoint_function, mock_namespace_obj) + mock_run_endpoint_function.return_value = "run_endpoint_function retval" + + retval = argh.dispatching.dispatch( + parser=mock_parser, + argv=argv, + completion=completion, + namespace=mock_namespace, + skip_unknown_args=skip_unknown_args, + output_file=mock_output_file, + errors_file=mock_errors_file, + raw_output=raw_output, + ) + + mock_parse_and_resolve.assert_called_with( + parser=mock_parser, + argv=argv, + completion=completion, + namespace=mock_namespace, + skip_unknown_args=skip_unknown_args, + ) + mock_run_endpoint_function.assert_called_with( + function=mock_endpoint_function, + namespace_obj=mock_namespace_obj, + output_file=mock_output_file, + errors_file=mock_errors_file, + raw_output=raw_output, + ) + assert retval == "run_endpoint_function retval" + + @patch("argh.dispatching.argparse.ArgumentParser") @patch("argh.dispatching.dispatch") @patch("argh.dispatching.add_commands") diff --git a/tests/test_integration.py b/tests/test_integration.py index 4ab379c..b18de7d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -344,7 +344,7 @@ def test_commands_not_defined(): p = DebugArghParser() assert run(p, "", {"raw_output": True}).out == p.format_usage() - assert run(p, "").out == p.format_usage() + "\n" + assert run(p, "").out == p.format_usage() assert "unrecognized arguments" in run(p, "foo", exit=True) assert "unrecognized arguments" in run(p, "--foo", exit=True)