Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove pre_call, expose finer control over dispatching (re #63) #193

Merged
merged 3 commits into from
Oct 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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