Skip to content

Commit

Permalink
Remove pre_call, expose finer control over dispatching (#193)
Browse files Browse the repository at this point in the history
This addresses #184 while providing an alternative solution for #63.
  • Loading branch information
neithere committed Oct 4, 2023
1 parent 52accb8 commit 7853057
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 38 deletions.
20 changes: 18 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
--------------
Expand Down
4 changes: 4 additions & 0 deletions src/argh/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -45,4 +47,6 @@
"DispatchingError",
"ArghParser",
"confirm",
"parse_and_resolve",
"run_endpoint_function",
)
105 changes: 70 additions & 35 deletions src/argh/dispatching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +35,8 @@
"dispatch",
"dispatch_command",
"dispatch_commands",
"parse_and_resolve",
"run_endpoint_function",
"PARSER_FORMATTER",
"EntryPoint",
]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/test_dispatching.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Dispatching tests
~~~~~~~~~~~~~~~~~
"""
import argparse
import io
from unittest.mock import Mock, patch

Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 7853057

Please sign in to comment.