From b9e5ffb3b3472a29b3585950a2de40863ce1f6d2 Mon Sep 17 00:00:00 2001 From: "Jason M. Gates" Date: Mon, 20 Oct 2025 16:01:40 -0600 Subject: [PATCH] patch: Drop support for Python 3.9 Undo the last of 8d15f4ab124bb067f5422538050dd7c70b414aa8. * Use a match case statement in place of an if block. * Replace Union types with X | Y syntax. Also introduce support for Python 3.14. Closes #16. --- .github/workflows/continuous-integration.yml | 2 +- .readthedocs.yaml | 2 +- README.md | 2 +- doc/source/index.rst | 2 +- pyproject.toml | 4 +- reverse_argparse/reverse_argparse.py | 61 ++++++++++---------- test/test_reverse_argparse.py | 57 +++++++++++++----- 7 files changed, 78 insertions(+), 52 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 9ff76b8..16ad1d7 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Harden Runner diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 3269866..4d8191b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -9,7 +9,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.9" + python: "3.10" # Build documentation in the "doc/" directory with Sphinx. sphinx: diff --git a/README.md b/README.md index 283883b..2b48887 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [![pre-commit.ci Status](https://results.pre-commit.ci/badge/github/sandialabs/reverse_argparse/master.svg)](https://results.pre-commit.ci/latest/github/sandialabs/reverse_argparse/master) [![PyPI - Version](https://img.shields.io/pypi/v/reverse-argparse?label=PyPI)](https://pypi.org/project/reverse-argparse/) ![PyPI - Downloads](https://img.shields.io/pypi/dm/reverse-argparse?label=PyPI%20downloads) -![Python Version](https://img.shields.io/badge/Python-3.9|3.10|3.11|3.12|3.13-blue.svg) +![Python Version](https://img.shields.io/badge/Python-3.10|3.11|3.12|3.13|3.14-blue.svg) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) # reverse_argparse diff --git a/doc/source/index.rst b/doc/source/index.rst index a1442c4..61cb7c4 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -66,7 +66,7 @@ reverse_argparse .. |PyPI Version| image:: https://img.shields.io/pypi/v/reverse-argparse?label=PyPI :target: https://pypi.org/project/reverse-argparse/ .. |PyPI Downloads| image:: https://img.shields.io/pypi/dm/reverse-argparse?label=PyPI%20downloads -.. |Python Version| image:: https://img.shields.io/badge/Python-3.9|3.10|3.11|3.12|3.13-blue.svg +.. |Python Version| image:: https://img.shields.io/badge/Python-3.10|3.11|3.12|3.13|3.14-blue.svg .. |Ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json :target: https://github.com/astral-sh/ruff diff --git a/pyproject.toml b/pyproject.toml index a9b3fb5..08b4eb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,11 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development", "Topic :: Software Development :: Debuggers", "Topic :: Software Development :: Documentation", @@ -44,7 +44,7 @@ Issues = "https://github.com/sandialabs/reverse_argparse/issues" [tool.poetry.dependencies] -python = ">=3.8" +python = ">=3.10" [tool.poetry.dev-dependencies] diff --git a/reverse_argparse/reverse_argparse.py b/reverse_argparse/reverse_argparse.py index ebcc80d..fd89047 100644 --- a/reverse_argparse/reverse_argparse.py +++ b/reverse_argparse/reverse_argparse.py @@ -114,36 +114,37 @@ def _unparse_action(self, action: Action) -> None: # noqa: C901, PLR0912 or self._arg_is_default_and_help_is_suppressed(action) ): return - if action_type == "_AppendAction": - self._unparse_append_action(action) - elif action_type == "_AppendConstAction": - self._unparse_append_const_action(action) - elif action_type == "_CountAction": - self._unparse_count_action(action) - elif action_type == "_ExtendAction": - self._unparse_extend_action(action) - elif action_type == "_HelpAction": # pragma: no cover - return - elif action_type == "_StoreAction": - self._unparse_store_action(action) - elif action_type == "_StoreConstAction": - self._unparse_store_const_action(action) - elif action_type == "_StoreFalseAction": - self._unparse_store_false_action(action) - elif action_type == "_StoreTrueAction": - self._unparse_store_true_action(action) - elif action_type == "_SubParsersAction": - self._unparse_sub_parsers_action(action) - elif action_type == "_VersionAction": # pragma: no cover - return - elif action_type == "BooleanOptionalAction": - self._unparse_boolean_optional_action(action) - else: # pragma: no cover - message = ( - f"{self.__class__.__name__} does not yet support the " - f"unparsing of {action_type} objects." - ) - raise NotImplementedError(message) + match action_type: + case "_AppendAction": + self._unparse_append_action(action) + case "_AppendConstAction": + self._unparse_append_const_action(action) + case "_CountAction": + self._unparse_count_action(action) + case "_ExtendAction": + self._unparse_extend_action(action) + case "_HelpAction": # pragma: no cover + return + case "_StoreAction": + self._unparse_store_action(action) + case "_StoreConstAction": + self._unparse_store_const_action(action) + case "_StoreFalseAction": + self._unparse_store_false_action(action) + case "_StoreTrueAction": + self._unparse_store_true_action(action) + case "_SubParsersAction": + self._unparse_sub_parsers_action(action) + case "_VersionAction": # pragma: no cover + return + case "BooleanOptionalAction": + self._unparse_boolean_optional_action(action) + case _: # pragma: no cover + message = ( + f"{self.__class__.__name__} does not yet support the " + f"unparsing of {action_type} objects." + ) + raise NotImplementedError(message) def _arg_is_default_and_help_is_suppressed(self, action: Action) -> bool: """ diff --git a/test/test_reverse_argparse.py b/test/test_reverse_argparse.py index 11e414a..da4fb60 100644 --- a/test/test_reverse_argparse.py +++ b/test/test_reverse_argparse.py @@ -6,9 +6,11 @@ # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + import shlex from argparse import SUPPRESS, ArgumentParser, BooleanOptionalAction, Namespace -from typing import Any, Optional +from typing import Any import pytest @@ -128,6 +130,31 @@ def test_strip_first_line() -> None: assert strip_first_line("foo\nbar\nbaz") == "bar\nbaz" +def remove_pytest_prefix(input_string: str) -> str: + """ + Remove ``-m pytest`` from the start of a space-delimited string. + + In Python 3.14, pytest now uses the ``__main__`` module's + ``__file__`` attribute to determine the entry point of the test run. + This causes a command line invocation to include the pytest runner's + arguments, which we then need to remove before comparing to expected + output. + + Args: + input_string: The string to remove the prefix from. + + Returns: + The string without the prefix. + """ + return input_string.removeprefix("-m pytest").strip() + + +def test_remove_pytest_prefix() -> None: + """Ensure :func:`remove_pytest_prefix` works as expected.""" + assert remove_pytest_prefix("-m pytest foo bar baz") == "foo bar baz" + assert remove_pytest_prefix("-m pytest") == "" + + @pytest.mark.parametrize("args", COMPLETE_ARGS) def test_get_effective_command_line_invocation( parser: ArgumentParser, args: str @@ -143,8 +170,8 @@ def test_get_effective_command_line_invocation( "app-nargs2-val --const --app-const1 --app-const2 -vv --ext ext-val1 " "ext-val2 ext-val3 --no-bool-opt pos1-val1 pos1-val2 pos2-val" ) - result = strip_first_entry( - unparser.get_effective_command_line_invocation() + result = remove_pytest_prefix( + strip_first_entry(unparser.get_effective_command_line_invocation()) ) assert result == expected @@ -293,8 +320,8 @@ def test__arg_is_default_and_help_is_suppressed() -> None: parser.add_argument("--suppressed", default=10, help=SUPPRESS) namespace = parser.parse_args(shlex.split("")) unparser = ReverseArgumentParser(parser, namespace) - result = strip_first_entry( - unparser.get_effective_command_line_invocation() + result = remove_pytest_prefix( + strip_first_entry(unparser.get_effective_command_line_invocation()) ) assert result == "" @@ -435,7 +462,7 @@ def test__unparse_store_const_action( ("args", "expected"), [(shlex.split("--foo"), " --foo"), ([], None)] ) def test__unparse_store_true_action( - args: list[str], expected: Optional[str] + args: list[str], expected: str | None ) -> None: """Ensure ``store_true`` actions are handled appropriately.""" parser = ArgumentParser() @@ -450,7 +477,7 @@ def test__unparse_store_true_action( ("args", "expected"), [(shlex.split("--foo"), " --foo"), ([], None)] ) def test__unparse_store_false_action( - args: list[str], expected: Optional[str] + args: list[str], expected: str | None ) -> None: """Ensure ``store_false`` actions are handled appropriately.""" parser = ArgumentParser() @@ -496,9 +523,7 @@ def test__unparse_append_action( @pytest.mark.parametrize( ("args", "expected"), [("--foo", " --foo"), ("", None)] ) -def test__unparse_append_const_action( - args: str, expected: Optional[str] -) -> None: +def test__unparse_append_const_action(args: str, expected: str | None) -> None: """Ensure ``append_const`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument( @@ -570,8 +595,8 @@ def test__unparse_sub_parsers_action( namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_args() - result = strip_first_entry( - unparser.get_effective_command_line_invocation() + result = remove_pytest_prefix( + strip_first_entry(unparser.get_effective_command_line_invocation()) ) assert result == expected result = strip_first_line(unparser.get_pretty_command_line_invocation()) @@ -612,8 +637,8 @@ def test__unparse_sub_parsers_action_nested() -> None: namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_args() - result = strip_first_entry( - unparser.get_effective_command_line_invocation() + result = remove_pytest_prefix( + strip_first_entry(unparser.get_effective_command_line_invocation()) ) assert result == args result = strip_first_line(unparser.get_pretty_command_line_invocation()) @@ -646,9 +671,9 @@ def test__unparse_extend_action() -> None: ], ) def test__unparse_boolean_optional_action( - default: Optional[bool], # noqa: FBT001 + default: bool | None, # noqa: FBT001 args: str, - expected: Optional[str], + expected: str | None, ) -> None: """Ensure ``BooleanOptionalAction`` actions are handled appropriately.""" parser = ArgumentParser()