diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7cf4098..4e4c6bc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,22 +14,11 @@ repos: - id: check-yaml files: .*\.(yaml|yml)$ - - repo: https://github.com/pycqa/isort - rev: 5.12.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.9 hooks: - - id: isort - name: isort - - - repo: https://github.com/psf/black - rev: 23.9.1 - hooks: - - id: black - language_version: python3 - - - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 - hooks: - - id: flake8 + - id: ruff + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.5.1 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ef2a548..a640a82 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog ========= +Version 0.31.3 (2024-07-13) +--------------------------- + +Bugs fixed: + +- wrong type annotation of `errors` in `wrap_errors` (PR #229 by @laazy) +- tests were failing under Python 3.13 (issue #228 by @mgorny) +- regression: can't set argument name with `dest` via decorator + (issue #224 by @mathieulongtin) + Version 0.31.2 (2024-01-24) --------------------------- diff --git a/docs/conf.py b/docs/conf.py index c54bf35..bb8c1ea 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,5 @@ """Configuration file for the Sphinx documentation builder.""" + import os import sys from datetime import date diff --git a/docs/index.rst b/docs/index.rst index e4047b1..ffe657b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,7 @@ Dependencies ------------ The `argh` library is supported (and tested unless otherwise specified) -on the following versions of Python: 3.8, 3.9, 3.10, 3.11, 3.12. +on the following versions of Python: 3.8, 3.9, 3.10, 3.11, 3.12, 3.13. If you need support for ancient Pythons, please use the following versions of Argh (the numeric puns were semi-intentional): diff --git a/docs/similar.rst b/docs/similar.rst index 97fd7d6..6862067 100644 --- a/docs/similar.rst +++ b/docs/similar.rst @@ -50,6 +50,10 @@ supports Python3. Not every "yes" in this table would count as pro. questionable practice); it does not derive the CLI arguments from the function signature but entirely relies on additional decorators, while Argh strives for the opposite. +* typer_ is a wrapper on top of `click`, which works with type hints instead + of decorators. This is very similar to argh's new 2024 design. Typer also + adds a lot of bells and whistles, and optional color support with `rich`, + so it is a full-featured package with several dependencies. .. _argdeclare: http://code.activestate.com/recipes/576935-argdeclare-declarative-interface-to-argparse/ .. _argparse-cli: http://code.google.com/p/argparse-cli/ @@ -69,3 +73,4 @@ supports Python3. Not every "yes" in this table would count as pro. .. _cement: http://builtoncement.com/2.0/ .. _autocommand: https://pypi.python.org/pypi/autocommand/ .. _click: https://click.palletsprojects.com +.. _typer: https://typer.tiangolo.com diff --git a/pyproject.toml b/pyproject.toml index 90f9f80..83e4761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "argh" -version = "0.31.2" +version = "0.31.3" description = "Plain Python functions as CLI commands without boilerplate" readme = "README.rst" requires-python = ">=3.8" @@ -36,6 +36,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: User Interfaces", @@ -71,13 +72,6 @@ linters = [ [tool.distutils.bdist_wheel] universal = 1 -[tool.isort] -multi_line_output = 3 -profile = "black" - -[tool.black] -target-version = ["py38", "py39", "py310", "py311", "py312"] - [tool.bandit] exclude_dirs = ["tests"] diff --git a/src/argh/__init__.py b/src/argh/__init__.py index 5d6b048..875ec44 100644 --- a/src/argh/__init__.py +++ b/src/argh/__init__.py @@ -2,6 +2,7 @@ Argh ~~~~ """ + # # Copyright © 2010—2023 Andrey Mikhaylenko and contributors # diff --git a/src/argh/assembling.py b/src/argh/assembling.py index 7a690ea..fdfc4a0 100644 --- a/src/argh/assembling.py +++ b/src/argh/assembling.py @@ -13,6 +13,7 @@ Functions and classes to properly assemble your commands in a parser. """ + import inspect import textwrap import warnings @@ -517,9 +518,9 @@ def _merge_inferred_and_declared_args( # arguments inferred from function signature for parser_add_argument_spec in inferred_args: - specs_by_func_arg_name[ - parser_add_argument_spec.func_arg_name - ] = parser_add_argument_spec + specs_by_func_arg_name[parser_add_argument_spec.func_arg_name] = ( + parser_add_argument_spec + ) # arguments declared via @arg decorator for declared_spec in declared_args: @@ -734,8 +735,7 @@ def add_subcommands( add_commands(parser, functions, group_name=group_name, group_kwargs=group_kwargs) -class ArgumentNameMappingError(AssemblingError): - ... +class ArgumentNameMappingError(AssemblingError): ... class TypingHintArgSpecGuesser: diff --git a/src/argh/completion.py b/src/argh/completion.py index 93b5f37..f2069be 100644 --- a/src/argh/completion.py +++ b/src/argh/completion.py @@ -57,6 +57,7 @@ def func(...): ... """ + import logging import os from argparse import ArgumentParser diff --git a/src/argh/constants.py b/src/argh/constants.py index 9bf1672..07a21d8 100644 --- a/src/argh/constants.py +++ b/src/argh/constants.py @@ -11,6 +11,7 @@ Constants ~~~~~~~~~ """ + import argparse __all__ = ( diff --git a/src/argh/decorators.py b/src/argh/decorators.py index 8913a95..0de1c98 100644 --- a/src/argh/decorators.py +++ b/src/argh/decorators.py @@ -11,7 +11,9 @@ Command decorators ~~~~~~~~~~~~~~~~~~ """ -from typing import Callable, List, Optional + +from typing import Callable, List, Optional, Type + from argh.constants import ( ATTR_ALIASES, @@ -138,7 +140,11 @@ def wrapper(func: Callable) -> Callable: if not args: raise CliArgToFuncArgGuessingError("at least one CLI arg must be defined") - func_arg_name = naive_guess_func_arg_name(args) + if "dest" in kwargs: + func_arg_name = kwargs.pop("dest") + else: + func_arg_name = naive_guess_func_arg_name(args) + cli_arg_names = [name.replace("_", "-") for name in args] completer = kwargs.pop("completer", None) spec = ParserAddArgumentSpec.make_from_kwargs( @@ -161,7 +167,7 @@ def wrapper(func: Callable) -> Callable: def wrap_errors( - errors: Optional[List[Exception]] = None, + errors: Optional[List[Type[Exception]]] = None, processor: Optional[Callable] = None, *args, ) -> Callable: diff --git a/src/argh/dispatching.py b/src/argh/dispatching.py index ef413f6..a5233d4 100644 --- a/src/argh/dispatching.py +++ b/src/argh/dispatching.py @@ -11,6 +11,7 @@ Dispatching ~~~~~~~~~~~ """ + import argparse import inspect import io diff --git a/src/argh/dto.py b/src/argh/dto.py index e4a32cd..eb2d1c6 100644 --- a/src/argh/dto.py +++ b/src/argh/dto.py @@ -1,6 +1,7 @@ """ Data transfer objects for internal usage. """ + from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Type, Union diff --git a/src/argh/helpers.py b/src/argh/helpers.py index 154ff7c..770fdca 100644 --- a/src/argh/helpers.py +++ b/src/argh/helpers.py @@ -11,6 +11,7 @@ Helpers ~~~~~~~ """ + import argparse from typing import Optional, Sequence diff --git a/src/argh/interaction.py b/src/argh/interaction.py index ab8f0e3..d06ce28 100644 --- a/src/argh/interaction.py +++ b/src/argh/interaction.py @@ -11,6 +11,7 @@ Interaction ~~~~~~~~~~~ """ + from typing import Optional __all__ = ["confirm"] diff --git a/src/argh/utils.py b/src/argh/utils.py index 3f7d42d..40aa40d 100644 --- a/src/argh/utils.py +++ b/src/argh/utils.py @@ -11,6 +11,7 @@ Utilities ~~~~~~~~~ """ + import argparse import re from typing import Tuple @@ -59,8 +60,7 @@ def unindent(text: str) -> str: return re.sub(rf"(^|\n) {{{depth}}}", "\\1", text) -class SubparsersNotDefinedError(Exception): - ... +class SubparsersNotDefinedError(Exception): ... def naive_guess_func_arg_name(option_strings: Tuple[str, ...]) -> str: @@ -89,17 +89,13 @@ def _opt_to_func_arg_name(opt: str) -> str: ) -class ArghError(Exception): - ... +class ArghError(Exception): ... -class CliArgToFuncArgGuessingError(ArghError): - ... +class CliArgToFuncArgGuessingError(ArghError): ... -class TooManyPositionalArgumentNames(CliArgToFuncArgGuessingError): - ... +class TooManyPositionalArgumentNames(CliArgToFuncArgGuessingError): ... -class MixedPositionalAndOptionalArgsError(CliArgToFuncArgGuessingError): - ... +class MixedPositionalAndOptionalArgsError(CliArgToFuncArgGuessingError): ... diff --git a/tests/base.py b/tests/base.py index d8e68c7..7710443 100644 --- a/tests/base.py +++ b/tests/base.py @@ -2,6 +2,7 @@ Common stuff for tests ~~~~~~~~~~~~~~~~~~~~~~ """ + import io import os import sys diff --git a/tests/test_assembling.py b/tests/test_assembling.py index 23c17a0..0b82eb5 100644 --- a/tests/test_assembling.py +++ b/tests/test_assembling.py @@ -2,6 +2,7 @@ Unit Tests For Assembling Phase ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """ + import argparse from typing import Literal, Optional from unittest.mock import MagicMock, call, patch @@ -85,8 +86,7 @@ def test_guess_action_from_default(): def test_positional_with_default_int(): - def func(pos_int_default=123): - ... + def func(pos_int_default=123): ... parser = argh.ArghParser(prog="test") parser.set_default_command( @@ -97,8 +97,7 @@ def func(pos_int_default=123): def test_positional_with_default_bool(): - def func(pos_bool_default=False): - ... + def func(pos_bool_default=False): ... parser = argh.ArghParser(prog="test") parser.set_default_command( @@ -500,8 +499,7 @@ def func(): def test_set_default_command__varkwargs_sharing_prefix(): - def func(*, alpha: str = "Alpha", aleph: str = "Aleph"): - ... + def func(*, alpha: str = "Alpha", aleph: str = "Aleph"): ... parser = argh.ArghParser() parser.add_argument = MagicMock() @@ -767,11 +765,9 @@ def test_is_positional(): def test_typing_hints_only_used_when_arg_deco_not_used(): @argh.arg("foo", type=int) - def func_decorated(foo: Optional[float]): - ... + def func_decorated(foo: Optional[float]): ... - def func_undecorated(bar: Optional[float]): - ... + def func_undecorated(bar: Optional[float]): ... parser = argparse.ArgumentParser() parser.add_argument = MagicMock() diff --git a/tests/test_completion.py b/tests/test_completion.py index afb4b37..70548ba 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -3,6 +3,7 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """ + from unittest.mock import patch import argh diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 7c9b7cb..9bf9e67 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -2,6 +2,7 @@ Unit Tests For Decorators ~~~~~~~~~~~~~~~~~~~~~~~~~ """ + import pytest import argh diff --git a/tests/test_dispatching.py b/tests/test_dispatching.py index 1bfaf72..d60556c 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 @@ -198,8 +199,7 @@ def hit(): def test_dispatch_command_naming_policy( parser_cls_mock, set_default_command_mock, dispatch_mock ): - def func(): - ... + def func(): ... parser_mock = Mock() parser_cls_mock.return_value = parser_mock @@ -234,8 +234,7 @@ def func(): def test_dispatch_commands_naming_policy( parser_cls_mock, add_commands_mock, dispatch_mock ): - def func(): - ... + def func(): ... parser_mock = Mock() parser_cls_mock.return_value = parser_mock diff --git a/tests/test_dto.py b/tests/test_dto.py index b5e3c3a..86c4b06 100644 --- a/tests/test_dto.py +++ b/tests/test_dto.py @@ -2,12 +2,12 @@ Unit Tests For the Argument DTO ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """ + from argh.dto import ParserAddArgumentSpec def test_update_empty_dto() -> None: - def stub_completer(): - ... + def stub_completer(): ... dto = ParserAddArgumentSpec( func_arg_name="foo", @@ -37,11 +37,9 @@ def stub_completer(): def test_update_full_dto() -> None: - def stub_completer_one(): - ... + def stub_completer_one(): ... - def stub_completer_two(): - ... + def stub_completer_two(): ... dto = ParserAddArgumentSpec( func_arg_name="foo", @@ -75,8 +73,7 @@ def stub_completer_two(): ) -class TestGetAllKwargs: - ... +class TestGetAllKwargs: ... def test_make_from_kwargs_minimal() -> None: diff --git a/tests/test_integration.py b/tests/test_integration.py index f863386..8839265 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,6 +2,7 @@ Integration Tests ~~~~~~~~~~~~~~~~~ """ + import argparse import re import sys @@ -724,18 +725,33 @@ def remind( help_normalised = re.sub(r"\s+", " ", parser.format_help()) assert "name 'Basil'" in help_normalised - assert "-t TASK, --task TASK 'hang the Moose'" in help_normalised - assert ( - "-r REASON, --reason REASON 'there are creatures living in it'" - in help_normalised - ) - # explicit help message is not obscured by the implicit one - # but is still present - assert ( - "-n NOTE, --note NOTE why is it a remarkable animal? " - "(default: 'it can speak English')" - ) in help_normalised + # argh#228 — argparse in Python before 3.13 duplicated the placeholder in help + if sys.version_info < (3, 13): + assert "-t TASK, --task TASK 'hang the Moose'" in help_normalised + assert ( + "-r REASON, --reason REASON 'there are creatures living in it'" + in help_normalised + ) + + # explicit help message is not obscured by the implicit one + # but is still present + assert ( + "-n NOTE, --note NOTE why is it a remarkable animal? " + "(default: 'it can speak English')" + ) in help_normalised + else: + assert "-t, --task TASK 'hang the Moose'" in help_normalised + assert ( + "-r, --reason REASON 'there are creatures living in it'" in help_normalised + ) + + # explicit help message is not obscured by the implicit one + # but is still present + assert ( + "-n, --note NOTE why is it a remarkable animal? " + "(default: 'it can speak English')" + ) in help_normalised def test_default_arg_values_in_help__regression(): @@ -750,9 +766,16 @@ def foo(*, bar=""): # doesn't break parser.format_help() + # argh#228 — argparse in Python before 3.13 duplicated the placeholder in help + if sys.version_info < (3, 13): + expected_line = "-b BAR, --bar BAR ''" + # note the empty str repr ^^^ + else: + expected_line = "-b, --bar BAR ''" + # note the empty str repr ^^^ + # now check details - assert "-b BAR, --bar BAR ''" in parser.format_help() - # note the empty str repr ^^^ + assert expected_line in parser.format_help() def test_help_formatting_is_preserved(): @@ -809,8 +832,7 @@ def cmd(*, foo=1): def test_add_commands_unknown_name_mapping_policy(): - def func(foo): - ... + def func(foo): ... parser = argh.ArghParser(prog="myapp") @@ -868,6 +890,18 @@ def second_func(): run(parser, "first-func --help", exit=True) captured = capsys.readouterr() + + # argh#228 — argparse in Python before 3.13 duplicated the placeholder in help + if sys.version_info < (3, 13): + arg_help_lines = ( + " -h, --help show this help message and exit\n" + " -f FOO, --foo FOO 123" + ) + else: + arg_help_lines = ( + " -h, --help show this help message and exit\n" " -f, --foo FOO 123" + ) + assert ( captured.out == unindent( @@ -877,8 +911,7 @@ def second_func(): Owl stretching time {HELP_OPTIONS_LABEL}: - -h, --help show this help message and exit - -f FOO, --foo FOO 123 + {arg_help_lines} """ )[1:] ) @@ -997,6 +1030,18 @@ def second_func(): run(parser, "my-group first-func --help", exit=True) captured = capsys.readouterr() + + # argh#228 — argparse in Python before 3.13 duplicated the placeholder in help + if sys.version_info < (3, 13): + arg_help_lines = ( + " -h, --help show this help message and exit\n" + " -f FOO, --foo FOO 123" + ) + else: + arg_help_lines = ( + " -h, --help show this help message and exit\n" " -f, --foo FOO 123" + ) + assert ( captured.out == unindent( @@ -1006,8 +1051,7 @@ def second_func(): Owl stretching time {HELP_OPTIONS_LABEL}: - -h, --help show this help message and exit - -f FOO, --foo FOO 123 + {arg_help_lines} """ )[1:] ) @@ -1079,6 +1123,18 @@ def second_func(): run(parser, "first-func --help", exit=True) captured = capsys.readouterr() + + # argh#228 — argparse in Python before 3.13 duplicated the placeholder in help + if sys.version_info < (3, 13): + arg_help_lines = ( + " -h, --help show this help message and exit\n" + " -f FOO, --foo FOO 123" + ) + else: + arg_help_lines = ( + " -h, --help show this help message and exit\n" " -f, --foo FOO 123" + ) + assert ( captured.out == unindent( @@ -1088,8 +1144,7 @@ def second_func(): func description override {HELP_OPTIONS_LABEL}: - -h, --help show this help message and exit - -f FOO, --foo FOO 123 + {arg_help_lines} """ )[1:] ) diff --git a/tests/test_interaction.py b/tests/test_interaction.py index f1dee89..5d903e6 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -2,6 +2,7 @@ Interaction Tests ~~~~~~~~~~~~~~~~~ """ + import unittest.mock as mock import argh diff --git a/tests/test_mapping_policies.py b/tests/test_mapping_policies.py index db67e71..e77e122 100644 --- a/tests/test_mapping_policies.py +++ b/tests/test_mapping_policies.py @@ -15,8 +15,7 @@ @pytest.mark.parametrize("name_mapping_policy", POLICIES) def test_no_args(name_mapping_policy) -> None: - def func() -> None: - ... + def func() -> None: ... parser = _make_parser_for_function(func, name_mapping_policy=name_mapping_policy) assert_usage(parser, "usage: test [-h]") diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 562a042..2d5b7fd 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -2,6 +2,7 @@ Regression tests ~~~~~~~~~~~~~~~~ """ + import sys from typing import List, Optional, TextIO @@ -166,8 +167,7 @@ def test_regression_issue204(): We should avoid `deepcopy()` in standard operations. """ - def func(*, x: TextIO = sys.stdout) -> None: - ... + def func(*, x: TextIO = sys.stdout) -> None: ... parser = DebugArghParser() parser.set_default_command(func) @@ -242,3 +242,22 @@ def func(*, paths: Optional[List[str]] = ["one", "two"]): assert run(parser, "").out == "['one', 'two']\n" assert run(parser, "--paths alpha").out == "['alpha']\n" assert run(parser, "--paths alpha beta gamma").out == "['alpha', 'beta', 'gamma']\n" + + +def test_regression_issue224(): + """ + Issue #224: @arg param `dest` was ignored and Argh was unable to map the + declaration onto the function signature. + + Use case: expose a function argument with a different name in the CLI. + """ + + @argh.arg("-l", dest="list_files") + def func(*, list_files=False): + return f"list_files={list_files}" + + parser = DebugArghParser() + parser.set_default_command(func) + + assert run(parser, "").out == "list_files=False\n" + assert run(parser, "-l").out == "list_files=True\n" diff --git a/tests/test_utils.py b/tests/test_utils.py index df91f47..2b9d17f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,7 @@ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """ + from argparse import ArgumentParser, _SubParsersAction import pytest diff --git a/tox.ini b/tox.ini index c3eb094..52615c0 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = py310 py311 py312 + py313 pypy3 as-module lint @@ -21,6 +22,7 @@ python = 3.10: py310 3.11: py311,lint,as-module 3.12: py312 + 3.13: py313 pypy-3.9: pypy3 pypy-3.10: pypy3