diff --git a/CHANGES b/CHANGES index 0d24f32..40b25d3 100644 --- a/CHANGES +++ b/CHANGES @@ -14,13 +14,29 @@ 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, ...) 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 8301df8..d2c1bd5 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -15,7 +15,7 @@ import io import sys 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 @@ -34,6 +34,8 @@ "dispatch", "dispatch_command", "dispatch_commands", + "parse_and_resolve", + "run_endpoint_function", "PARSER_FORMATTER", "EntryPoint", ] @@ -76,15 +78,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: @@ -141,17 +141,51 @@ 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:] if add_help_command: if argv and argv[0] == "help": 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() @@ -165,13 +199,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 @@ -222,10 +274,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 @@ -237,20 +286,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..ea2d465 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 13c8ce2..1523068 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)