From 5c448ec9e02cbb61d7f9872730b7c9b40a731035 Mon Sep 17 00:00:00 2001
From: "Jason M. Gates" <jmgate@sandia.gov>
Date: Mon, 2 Dec 2024 10:12:34 -0700
Subject: [PATCH] chore!: Drop support for Python 3.8

* Use the type-hinting provided out of the box in 3.9.
* Remove version guards around `argparse.BooleanOptionalAction`.
* Update documentation and CI accordingly.
---
 .github/workflows/continuous-integration.yml |  2 +-
 README.md                                    |  2 +-
 doc/source/index.rst                         |  2 +-
 pyproject.toml                               |  1 -
 reverse_argparse/reverse_argparse.py         | 23 +++---
 test/test_reverse_argparse.py                | 80 +++++++-------------
 6 files changed, 40 insertions(+), 70 deletions(-)

diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 6858c8e..70ab0b2 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.8", "3.9", "3.10", "3.11", "3.12"]
+        version: ["3.9", "3.10", "3.11", "3.12"]
     steps:
 
       - name: Harden Runner
diff --git a/README.md b/README.md
index 46f0942..aa481a4 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.8|3.9|3.10|3.11|3.12-blue.svg)
+![Python Version](https://img.shields.io/badge/Python-3.9|3.10|3.11|3.12-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 f8860ae..9f8fca6 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.8|3.9|3.10|3.11|3.12-blue.svg
+.. |Python Version| image:: https://img.shields.io/badge/Python-3.9|3.10|3.11|3.12-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 6e28d51..fecd86e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,6 @@ classifiers = [
     "Operating System :: OS Independent",
     "Programming Language :: Python :: 3",
     "Programming Language :: Python :: 3 :: Only",
-    "Programming Language :: Python :: 3.8",
     "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
diff --git a/reverse_argparse/reverse_argparse.py b/reverse_argparse/reverse_argparse.py
index 44f4a9b..f4530b8 100644
--- a/reverse_argparse/reverse_argparse.py
+++ b/reverse_argparse/reverse_argparse.py
@@ -14,12 +14,10 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 import re
-import sys
 from argparse import SUPPRESS, Action, ArgumentParser, Namespace
-from typing import List, Sequence
+from typing import Sequence
 
 
-BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION = 9
 SHORT_OPTION_LENGTH = 2
 
 
@@ -38,20 +36,20 @@ class ReverseArgumentParser:
     such that they're able to reproduce a prior run of a script exactly.
 
     Attributes:
-        _args (List[str]):  The list of arguments corresponding to each
+        _args (list[str]):  The list of arguments corresponding to each
             :class:`argparse.Action` in the given parser, which is built
             up as the arguments are unparsed.
         _indent (int):  The number of spaces with which to indent
             subsequent lines when pretty-printing the effective command
             line invocation.
         _namespace (Namespace):  The parsed arguments.
-        _parsers (List[argparse.ArgumentParser]):  The parser that was
+        _parsers (list[argparse.ArgumentParser]):  The parser that was
             used to generate the parsed arguments.  This is a ``list``
             (conceptually a stack) to allow for sub-parsers, so the
             outer-most parser is the first item in the list, and
             sub-parsers are pushed onto and popped off of the stack as
             they are processed.
-        _unparsed (List[bool]):  A list in which the elements indicate
+        _unparsed (list[bool]):  A list in which the elements indicate
             whether the corresponding parser in :attr:`parsers` has been
             unparsed.
     """
@@ -136,10 +134,7 @@ def _unparse_action(self, action: Action) -> None:  # noqa: C901, PLR0912
             self._unparse_sub_parsers_action(action)
         elif action_type == "_VersionAction":  # pragma: no cover
             return
-        elif (
-            action_type == "BooleanOptionalAction"
-            and sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION
-        ):
+        elif action_type == "BooleanOptionalAction":
             self._unparse_boolean_optional_action(action)
         else:  # pragma: no cover
             message = (
@@ -202,7 +197,7 @@ def get_pretty_command_line_invocation(self) -> str:
 
     def _get_long_option_strings(
         self, option_strings: Sequence[str]
-    ) -> List[str]:
+    ) -> list[str]:
         """
         Get the long options from a list of options strings.
 
@@ -224,7 +219,7 @@ def _get_long_option_strings(
 
     def _get_short_option_strings(
         self, option_strings: Sequence[str]
-    ) -> List[str]:
+    ) -> list[str]:
         """
         Get the short options from a list of options strings.
 
@@ -278,7 +273,7 @@ def _get_option_string(
                 return short_options[0]
         return ""
 
-    def _append_list_of_list_of_args(self, args: List[List[str]]) -> None:
+    def _append_list_of_list_of_args(self, args: list[list[str]]) -> None:
         """
         Append to the list of unparsed arguments.
 
@@ -293,7 +288,7 @@ def _append_list_of_list_of_args(self, args: List[List[str]]) -> None:
         for line in args:
             self._args.append(self._indent_str + " ".join(line))
 
-    def _append_list_of_args(self, args: List[str]) -> None:
+    def _append_list_of_args(self, args: list[str]) -> None:
         """
         Append to the list of unparsed arguments.
 
diff --git a/test/test_reverse_argparse.py b/test/test_reverse_argparse.py
index bd8f462..f6ec472 100644
--- a/test/test_reverse_argparse.py
+++ b/test/test_reverse_argparse.py
@@ -7,21 +7,13 @@
 # SPDX-License-Identifier: BSD-3-Clause
 
 import shlex
-import sys
-from argparse import SUPPRESS, ArgumentParser, Namespace
+from argparse import SUPPRESS, ArgumentParser, BooleanOptionalAction, Namespace
 
 import pytest
 
 from reverse_argparse import ReverseArgumentParser
 
 
-BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION = 9
-
-
-if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION:
-    from argparse import BooleanOptionalAction
-
-
 @pytest.fixture
 def parser() -> ArgumentParser:
     """
@@ -51,10 +43,7 @@ def parser() -> ArgumentParser:
     )
     p.add_argument("--verbose", "-v", action="count", default=2)
     p.add_argument("--ext", action="extend", nargs="*")
-    if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION:
-        p.add_argument(
-            "--bool-opt", action=BooleanOptionalAction, default=False
-        )
+    p.add_argument("--bool-opt", action=BooleanOptionalAction, default=False)
     return p
 
 
@@ -144,20 +133,12 @@ def test_get_effective_command_line_invocation(parser, args) -> None:
     namespace = parser.parse_args(shlex.split(args))
     unparser = ReverseArgumentParser(parser, namespace)
     expected = (
-        (
-            "--opt1 opt1-val --opt2 opt2-val1 opt2-val2 --store-true "
-            "--store-false --needs-quotes 'hello world' --default 42 --app1 "
-            "app1-val1 --app1 app1-val2 --app2 app2-val1 --app2 app2-val2 "
-            "--app-nargs app-nargs1-val1 app-nargs1-val2 --app-nargs "
-            "app-nargs2-val --const --app-const1 --app-const2 -vv --ext "
-            "ext-val1 ext-val2 ext-val3 "
-        )
-        + (
-            "--no-bool-opt "
-            if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION
-            else ""
-        )
-        + "pos1-val1 pos1-val2 pos2-val"
+        "--opt1 opt1-val --opt2 opt2-val1 opt2-val2 --store-true "
+        "--store-false --needs-quotes 'hello world' --default 42 --app1 "
+        "app1-val1 --app1 app1-val2 --app2 app2-val1 --app2 app2-val2 "
+        "--app-nargs app-nargs1-val1 app-nargs1-val2 --app-nargs "
+        "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()
@@ -186,10 +167,9 @@ def test_get_pretty_command_line_invocation(parser, args) -> None:
     --app-const1 \\
     --app-const2 \\
     -vv \\
-    --ext ext-val1 ext-val2 ext-val3 \\"""
-    if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION:
-        expected += "\n    --no-bool-opt \\"
-    expected += """\n    pos1-val1 pos1-val2 \\
+    --ext ext-val1 ext-val2 ext-val3 \\
+    --no-bool-opt \\
+    pos1-val1 pos1-val2 \\
     pos2-val"""
     result = strip_first_line(unparser.get_pretty_command_line_invocation())
     assert result == expected
@@ -274,16 +254,15 @@ def test__unparse_args_boolean_optional_action() -> None:
     With a ``BooleanOptionalAction``, which became available in Python
     3.9.
     """
-    if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION:
-        parser = ArgumentParser()
-        parser.add_argument("--foo", action=BooleanOptionalAction)
-        try:
-            namespace = parser.parse_args(shlex.split("--foo"))
-        except SystemExit:
-            namespace = Namespace()
-        unparser = ReverseArgumentParser(parser, namespace)
-        unparser._unparse_args()
-        assert unparser._args[1:] == ["    --foo"]
+    parser = ArgumentParser()
+    parser.add_argument("--foo", action=BooleanOptionalAction)
+    try:
+        namespace = parser.parse_args(shlex.split("--foo"))
+    except SystemExit:
+        namespace = Namespace()
+    unparser = ReverseArgumentParser(parser, namespace)
+    unparser._unparse_args()
+    assert unparser._args[1:] == ["    --foo"]
 
 
 def test__unparse_args_already_unparsed() -> None:
@@ -635,14 +614,11 @@ def test__unparse_extend_action() -> None:
 )
 def test__unparse_boolean_optional_action(default, args, expected) -> None:
     """Ensure ``BooleanOptionalAction`` actions are handled appropriately."""
-    if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION:
-        parser = ArgumentParser()
-        action = parser.add_argument(
-            "--bool-opt", action=BooleanOptionalAction, default=default
-        )
-        namespace = parser.parse_args(shlex.split(args))
-        unparser = ReverseArgumentParser(parser, namespace)
-        unparser._unparse_boolean_optional_action(action)
-        assert unparser._args[1:] == (
-            [expected] if expected is not None else []
-        )
+    parser = ArgumentParser()
+    action = parser.add_argument(
+        "--bool-opt", action=BooleanOptionalAction, default=default
+    )
+    namespace = parser.parse_args(shlex.split(args))
+    unparser = ReverseArgumentParser(parser, namespace)
+    unparser._unparse_boolean_optional_action(action)
+    assert unparser._args[1:] == ([expected] if expected is not None else [])