Skip to content

Commit

Permalink
refactor: Only support Python 3.8+
Browse files Browse the repository at this point in the history
Changes that can be undone when we remove 3.8 support:
* Change certain type hints to work for 3.8
* Use version guard around `BooleanOptionalAction`

Changes that can be undone when we remove 3.9 support:
* Switch match-case statement to if block.
  • Loading branch information
jmgate committed Jun 29, 2023
1 parent 5f5e681 commit 8d15f4a
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 84 deletions.
2 changes: 1 addition & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ source = reverse_argparse

[report]
skip_covered = False
fail_under = 95
fail_under = 90
show_missing = True
exclude_lines =
pragma: no cover
Expand Down
16 changes: 5 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
version: ["3.8", "3.9", "3.10", "3.11"]
steps:

- name: Check out the commit
Expand All @@ -27,19 +27,13 @@ jobs:
with:
python-version: ~${{ matrix.version }}

- name: Add conda to system path
run: |
# $CONDA is an environment variable pointing to the root of the
# miniconda directory.
echo $CONDA/bin >> $GITHUB_PATH
- name: Install test dependencies
run: |
conda install pytest pytest-cov
python3 -m pip install pytest pytest-cov
- name: Test with pytest
run: |
python -m pytest --cov=reverse_argparse test/
python3 -m pytest --cov=reverse_argparse test/
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
Expand All @@ -48,8 +42,8 @@ jobs:

- name: Test install
run: |
pip install .
python3 -m pip install .
- name: Test uninstall
run: |
pip uninstall -y reverse_argparse
python3 -m pip uninstall -y reverse_argparse
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
[![Code Style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Code Style: black](https://img.shields.io/badge/Code%20Style-black-000000.svg)](https://github.com/psf/black)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
[![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)
[![Security: Bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
[![Linting: Pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/pylint-dev/pylint)
[![Security: Bandit](https://img.shields.io/badge/Security-Bandit-yellow.svg)](https://github.com/PyCQA/bandit)
[![Linting: Pylint](https://img.shields.io/badge/Linting-Pylint-yellowgreen)](https://github.com/pylint-dev/pylint)
[![Continuous Integration](https://github.com/sandialabs/reverse_argparse/actions/workflows/ci.yml/badge.svg)](https://github.com/sandialabs/reverse_argparse/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/sandialabs/reverse_argparse/branch/master/graph/badge.svg?token=FmDStZ6FVR)](https://codecov.io/gh/sandialabs/reverse_argparse)
![Python Version](https://img.shields.io/badge/Python-3.8+-blue.svg)

# reverse_argparse

Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
Expand All @@ -65,7 +64,7 @@ Issues = "https://github.com/sandialabs/reverse_argparse/issues"


[tool.poetry.dependencies]
python = ">=3.7"
python = ">=3.8"


[tool.poetry.dev-dependencies]
Expand Down
110 changes: 63 additions & 47 deletions reverse_argparse/reverse_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
"""

import re
import sys
from argparse import SUPPRESS, Action, ArgumentParser, Namespace
from typing import Sequence
from typing import List, Sequence


class ReverseArgumentParser:
Expand All @@ -34,16 +35,16 @@ class ReverseArgumentParser:
such that they're able to reproduce a prior run of a script exactly.
Attributes:
_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.
args (list[str]): The list of arguments corresponding to each
args (List[str]): The list of arguments corresponding to each
:class:`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.
parsers (list[ArgumentParser]): The parser that was used to
parsers (List[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
Expand Down Expand Up @@ -78,10 +79,6 @@ def _unparse_args(self) -> None:
Loop over the positional and then optional actions, generating
the command line arguments associated with each, and appending
them to the list of arguments.
Raises:
NotImplementedError: If there is not currently an
implementation for unparsing the given action.
"""
if self._unparsed[-1]:
return
Expand All @@ -91,43 +88,62 @@ def _unparse_args(self) -> None:
+ psr._get_positional_actions() # pylint: disable=protected-access
)
for action in actions:
if type(action).__name__ != "_SubParsersAction" and (
not hasattr(self.namespace, action.dest)
or self._arg_is_default_and_help_is_suppressed(action)
):
continue
match type(action).__name__:
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
continue
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
continue
case "BooleanOptionalAction":
self._unparse_boolean_optional_action(action)
case _: # pragma: no cover
raise NotImplementedError(
f"{self.__class__.__name__} does not yet support the "
f"unparsing of {type(action).__name__} objects."
)
self._unparse_action(action)
self._unparsed[-1] = True

def _unparse_action(self, action: Action) -> None:
"""
Unparse a single action.
Generate the command line arguments associated with the given
``action``, and append them to the list of arguments.
Args:
action: The :class:`argparse.Action` to unparse.
Raises:
NotImplementedError: If there is not currently an
implementation for unparsing the given action.
"""
action_type = type(action).__name__
if action_type != "_SubParsersAction" and (
not hasattr(self.namespace, action.dest)
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"
and sys.version_info.minor >= 9
):
self._unparse_boolean_optional_action(action)
else: # pragma: no cover
raise NotImplementedError(
f"{self.__class__.__name__} does not yet support the "
f"unparsing of {action_type} objects."
)

def _arg_is_default_and_help_is_suppressed(self, action: Action) -> bool:
"""
See if the argument should be skipped.
Expand Down Expand Up @@ -181,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.
Expand All @@ -203,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.
Expand Down Expand Up @@ -256,7 +272,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.
Expand All @@ -271,7 +287,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.
Expand Down
67 changes: 47 additions & 20 deletions test/test_reverse_argparse.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
#!/usr/bin/env python3

import shlex
from argparse import SUPPRESS, ArgumentParser, BooleanOptionalAction, Namespace
import sys
from argparse import SUPPRESS, ArgumentParser, Namespace

if sys.version_info.minor >= 9:
from argparse import BooleanOptionalAction

import pytest

Expand Down Expand Up @@ -31,7 +35,10 @@ def parser() -> ArgumentParser:
)
p.add_argument("--verbose", "-v", action="count", default=2)
p.add_argument("--ext", action="extend", nargs="*")
p.add_argument("--bool-opt", action=BooleanOptionalAction, default=False)
if sys.version_info.minor >= 9:
p.add_argument(
"--bool-opt", action=BooleanOptionalAction, default=False
)
return p


Expand Down Expand Up @@ -118,12 +125,16 @@ 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 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 " if sys.version_info.minor >= 9 else "")
+ "pos1-val1 pos1-val2 pos2-val"
)
result = strip_first_entry(
unparser.get_effective_command_line_invocation()
Expand Down Expand Up @@ -151,9 +162,10 @@ def test_get_pretty_command_line_invocation(parser, args) -> None:
--app-const1 \\
--app-const2 \\
-vv \\
--ext ext-val1 ext-val2 ext-val3 \\
--no-bool-opt \\
pos1-val1 pos1-val2 \\
--ext ext-val1 ext-val2 ext-val3 \\"""
if sys.version_info.minor >= 9:
expected += "\n --no-bool-opt \\"
expected += """\n pos1-val1 pos1-val2 \\
pos2-val"""
result = strip_first_line(unparser.get_pretty_command_line_invocation())
assert result == expected
Expand Down Expand Up @@ -211,7 +223,6 @@ def test_get_command_line_invocation_strip_spaces() -> None:
"--foo bar --foo baz bif",
[" --foo bar baz bif"],
),
(["--foo"], {"action": BooleanOptionalAction}, "--foo", [" --foo"]),
],
)
def test__unparse_args(add_args, add_kwargs, args, expected) -> None:
Expand All @@ -230,6 +241,19 @@ def test__unparse_args(add_args, add_kwargs, args, expected) -> None:
assert unparser.args[1:] == expected


def test__unparse_args_boolean_optional_action() -> None:
if sys.version_info.minor >= 9:
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:
parser = ArgumentParser()
namespace = Namespace()
Expand Down Expand Up @@ -562,11 +586,14 @@ def test__unparse_extend_action() -> None:
],
)
def test__unparse_boolean_optional_action(default, args, expected) -> None:
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 [])
if sys.version_info.minor >= 9:
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 []
)

0 comments on commit 8d15f4a

Please sign in to comment.