diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ade0159..55b9aed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,13 @@ ci: repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.1 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + - repo: https://github.com/commitizen-tools/commitizen rev: v3.24.0 hooks: @@ -38,44 +45,11 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - - repo: https://github.com/psf/black - rev: 24.4.0 - hooks: - - id: black - - - repo: https://github.com/PyCQA/bandit - rev: 1.7.8 - hooks: - - id: bandit - args: ["-c", "pyproject.toml"] - additional_dependencies: ["bandit[toml]"] - - repo: https://github.com/PyCQA/doc8 rev: v1.1.1 hooks: - id: doc8 - - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 - hooks: - - id: flake8 - - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - - - repo: https://github.com/PyCQA/prospector - rev: v1.10.3 - hooks: - - id: prospector - additional_dependencies: ["dateparser"] - - - repo: https://github.com/PyCQA/pydocstyle - rev: 6.3.0 - hooks: - - id: pydocstyle - - repo: https://github.com/regebro/pyroma rev: "4.2" hooks: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a5901f..f5b4abe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,6 +69,7 @@ pre-commit install --hook-type commit-msg --hook-type pre-push [precommit]: https://pre-commit.com/ The checks we perform are the following: +* Use [ruff][ruff] to lint and format the code and docstrings. * Use [commitizen][commitizen] to ensure commit messages match the [Conventional Commits specification][conventional]. Use the [Conventional Commits extension for VS Code][extension] (or something @@ -84,42 +85,18 @@ The checks we perform are the following: * Trim trailing whitespace. * Ensure we use [type-hinting][typing]. * Check for common mistakes in [reStructuredText][rest] in our documentation. -* Use [black][black] to automatically format the code. -* Use [Bandit][bandit] to find common security issues. * Use [doc8][doc8] to enforce our style for our documentation. -* Lint the code with [flake8][flake8]. -* Use [isort][isort] to ensure `import` statements are sorted correctly. -* Use [prospector][prospector] to check for various errors, potential problems, - convention violations, complexity, etc. This uses the following tools under - the hood: - * [dodgy][dodgy] - * [mccabe][mccabe] - * [pycodestyle][pycodestyle] - * [Pyflakes][pyflakes] - * [Pylint][pylint] -* Use [pydocstyle][pydocstyle] to ensure our docstrings line up with - [PEP 257][pep257]. * Use [pyroma][pyroma] to ensure our package complies with the best practices of the Python packaging ecosystem. +[ruff]: https://docs.astral.sh/ruff/ [commitizen]: https://github.com/commitizen-tools/commitizen [conventional]: https://www.conventionalcommits.org/en/v1.0.0/ [extension]: https://marketplace.visualstudio.com/items?itemName=vivaxy.vscode-conventional-commits [mypy]: https://github.com/python/mypy [typing]: https://docs.python.org/3/library/typing.html -[black]: https://github.com/psf/black -[bandit]: https://github.com/PyCQA/bandit +[rest]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html [doc8]: https://github.com/PyCQA/doc8 -[flake8]: https://github.com/pyCQA/flake8 -[isort]: https://github.com/pyCQA/isort -[prospector]: https://github.com/landscapeio/prospector -[dodgy]: https://github.com/landscapeio/dodgy -[mccabe]: https://github.com/PyCQA/mccabe -[pycodestyle]: https://pycodestyle.pycqa.org/en/latest/ -[pyflakes]: https://launchpad.net/pyflakes -[pylint]: https://www.pylint.org/ -[pydocstyle]: https://github.com/PyCQA/pydocstyle -[pep257]: http://www.python.org/dev/peps/pep-0257/ [pyroma]: https://github.com/regebro/pyroma ### VS Code @@ -183,9 +160,8 @@ search for and install them. These are the ones we recommend: editor. * **autoDocstring - Python Docstring Generator:** Quickly generate docstrings for Python functions. -* **Flake8:** Linting support for Python files using flake8. +* **Mpy Type Checker:** Type checking support for Python. * **Pylance:** Fast, feature-rich language support for Python. -* **Pylint:** Linting support for Python files. * **Pytest IntelliSense:** Adds IntelliSense support for [pytest][pytest] fixtures. * **Python:** Rich support for the Python language. @@ -194,6 +170,7 @@ search for and install them. These are the ones we recommend: or [testplan][testplan] tests with the Test Explorer UI (see **General** above). * **Python Type Hint:** Type hint autocompletion. +* **Ruff:** Automatic linting and formatting. * **Sourcery:** Automatic code review and refactoring. [unittest]: https://docs.python.org/3/library/unittest.html @@ -233,13 +210,6 @@ After installing the various extensions, you'll also want to customize your * **Terminal Git Editor:** Check. * pre-commit-helper * **Run On Save:** Select "all hooks". - * Python - * **Formatting:** Provider: Select "black". - * **Linting: Bandit Enabled:** Check. - * **Linting: Flake8 Enabled:** Check. - * **Linting: Lint On Save:** Check. - * **Linting: Mypy Enabled:** Check. - * **Linting: Prospector Enabled:** Check. * Python Docstring Generator configuration * **Docstring Format:** Select "google-notypes". * **Start On New Line:** Check. @@ -372,7 +342,6 @@ utilize [type-hinting][typing] wherever possible for clarity's sake. [docstrings]: https://www.python.org/dev/peps/pep-0257 [google]: https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings -[rest]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html [docs]: https://reverse-argparse.readthedocs.io [sphinx]: https://www.sphinx-doc.org/en/master/ diff --git a/README.md b/README.md index 30207b9..b8da399 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -[![Code Style: black](https://img.shields.io/badge/Code%20Style-black-000000.svg)](https://github.com/psf/black) [![codecov](https://codecov.io/gh/sandialabs/reverse_argparse/branch/master/graph/badge.svg?token=FmDStZ6FVR)](https://codecov.io/gh/sandialabs/reverse_argparse) [![CodeFactor](https://www.codefactor.io/repository/github/sandialabs/reverse_argparse/badge/master)](https://www.codefactor.io/repository/github/sandialabs/reverse_argparse/overview/master) [![CodeQL](https://github.com/sandialabs/reverse_argparse/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/sandialabs/reverse_argparse/actions/workflows/github-code-scanning/codeql) @@ -8,18 +7,17 @@ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![GitHub contributors](https://img.shields.io/github/contributors/sandialabs/reverse_argparse.svg)](https://github.com/sandialabs/reverse_argparse/graphs/contributors) [![Documentation Status](https://readthedocs.org/projects/reverse-argparse/badge/?version=latest)](https://reverse-argparse.readthedocs.io/en/latest/?badge=latest) -[![Anaconda-Server Badge](https://anaconda.org/conda-forge/reverse-argparse/badges/license.svg)](LICENSE.md) -[![Linting: Pylint](https://img.shields.io/badge/Linting-Pylint-yellowgreen)](https://github.com/pylint-dev/pylint) +[![License](https://anaconda.org/conda-forge/reverse-argparse/badges/license.svg)](LICENSE.md) [![Merged PRs](https://img.shields.io/github/issues-pr-closed-raw/sandialabs/reverse_argparse.svg?label=merged+PRs)](https://github.com/sandialabs/reverse_argparse/pulls?q=is:pr+is:merged) [![OpenSSF Best Practices](https://bestpractices.coreinfrastructure.org/projects/7632/badge)](https://bestpractices.coreinfrastructure.org/projects/7632) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/sandialabs/reverse_argparse/badge)](https://securityscorecards.dev/viewer/?uri=github.com/sandialabs/reverse_argparse) -![Anaconda-Server Badge](https://anaconda.org/conda-forge/reverse-argparse/badges/platforms.svg) +![Platforms](https://anaconda.org/conda-forge/reverse-argparse/badges/platforms.svg) [![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) [![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) -[![Security: Bandit](https://img.shields.io/badge/Security-Bandit-yellow.svg)](https://github.com/PyCQA/bandit) +[![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/conf.py b/doc/source/conf.py index 126cb1b..01bbb40 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -13,9 +13,9 @@ # -- Project information ------------------------------------------------------ project = "reverse_argparse" -copyright = ( - "2023–2024, National Technology & Engineering Solutions of Sandia, LLC " - "(NTESS)" +copyright = ( # noqa: A001 + "2023–2024, National Technology & Engineering Solutions " # noqa: RUF001 + "of Sandia, LLC (NTESS)" ) author = "Jason M. Gates" version = "1.0.6" diff --git a/doc/source/index.rst b/doc/source/index.rst index 23ca0e1..23cb67d 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -10,7 +10,6 @@ reverse_argparse examples reference -|Code Style: black| |codecov| |CodeFactor| |CodeQL| @@ -21,7 +20,6 @@ reverse_argparse |GitHub Contributors| |Documentation Status| |License| -|Linting: Pylint| |Merged PRs| |OpenSSF Best Practices| |OpenSSF Scorecard| @@ -31,10 +29,8 @@ reverse_argparse |PyPI Version| |PyPI Downloads| |Python Version| -|Security: Bandit| +|Ruff| -.. |Code Style: black| image:: https://img.shields.io/badge/Code%20Style-black-000000.svg - :target: https://github.com/psf/black .. |codecov| image:: https://codecov.io/gh/sandialabs/reverse_argparse/branch/master/graph/badge.svg?token=FmDStZ6FVR :target: https://codecov.io/gh/sandialabs/reverse_argparse .. |CodeFactor| image:: https://www.codefactor.io/repository/github/sandialabs/reverse_argparse/badge/master @@ -54,8 +50,6 @@ reverse_argparse :target: https://reverse-argparse.readthedocs.io/en/latest/?badge=latest .. |License| image:: https://anaconda.org/conda-forge/reverse-argparse/badges/license.svg :target: https://github.com/sandialabs/reverse_argparse/blob/master/LICENSE.md -.. |Linting: Pylint| image:: https://img.shields.io/badge/Linting-Pylint-yellowgreen - :target: https://github.com/pylint-dev/pylint .. |Merged PRs| image:: https://img.shields.io/github/issues-pr-closed-raw/sandialabs/reverse_argparse.svg?label=merged+PRs :target: https://github.com/sandialabs/reverse_argparse/pulls?q=is:pr+is:merged .. |OpenSSF Best Practices| image:: https://bestpractices.coreinfrastructure.org/projects/7632/badge @@ -71,8 +65,8 @@ reverse_argparse :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 -.. |Security: Bandit| image:: https://img.shields.io/badge/Security-Bandit-yellow.svg - :target: https://github.com/PyCQA/bandit +.. |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 The ``reverse_argparse`` module provides a means of undoing the argument parsing provided by :mod:`argparse`. That is, it can take a diff --git a/example/post_processing.py b/example/post_processing.py index 3d6ce64..a12907f 100755 --- a/example/post_processing.py +++ b/example/post_processing.py @@ -19,7 +19,7 @@ parser.add_argument("--foo") parser.add_argument("--bar", default="spam") parser.add_argument("--baz", type=int, default=42) -parser.add_argument("--src", type=os.path.abspath) # type: ignore +parser.add_argument("--src", type=os.path.abspath) # type: ignore[arg-type] parser.add_argument("--before") # Parse the command line arguments. diff --git a/example/pretty_printing.py b/example/pretty_printing.py index 3082c52..412a91e 100755 --- a/example/pretty_printing.py +++ b/example/pretty_printing.py @@ -19,7 +19,7 @@ parser.add_argument("--foo") parser.add_argument("--bar", default="spam") parser.add_argument("--baz", type=int, default=42) -parser.add_argument("--src", type=os.path.abspath) # type: ignore +parser.add_argument("--src", type=os.path.abspath) # type: ignore[arg-type] parser.add_argument("--before") # Parse the command line arguments. diff --git a/example/relative_references.py b/example/relative_references.py index d00f1c8..b5e149b 100755 --- a/example/relative_references.py +++ b/example/relative_references.py @@ -17,7 +17,7 @@ parser.add_argument("--foo") parser.add_argument("--bar", default="spam") parser.add_argument("--baz", type=int, default=42) -parser.add_argument("--src", type=os.path.abspath) # type: ignore +parser.add_argument("--src", type=os.path.abspath) # type: ignore[arg-type] # Parse the command line arguments. args = parser.parse_args() diff --git a/example/subparsers.py b/example/subparsers.py index d811bc1..5ad12d7 100755 --- a/example/subparsers.py +++ b/example/subparsers.py @@ -21,7 +21,10 @@ foo_parser.add_argument("--two", default="spam") foo_parser.add_argument("--three", type=int, default=42) bar_parser = subparsers.add_parser("bar") -bar_parser.add_argument("--four", type=os.path.abspath) # type: ignore +bar_parser.add_argument( + "--four", + type=os.path.abspath, # type: ignore[arg-type] +) bar_parser.add_argument("--five") # Parse the command line arguments. diff --git a/example/test_examples.py b/example/test_examples.py index d2c46dd..48b4644 100755 --- a/example/test_examples.py +++ b/example/test_examples.py @@ -10,19 +10,20 @@ import re import shlex import subprocess # nosec B404 -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path def test_basic() -> None: + """Ensure ``basic.py`` produces the expected results.""" example = Path(__file__).parent / "basic.py" result = subprocess.run( - [example, "--foo", "bar"], + [example, "--foo", "bar"], # noqa: S603 capture_output=True, check=True, text=True, - ) # nosec B603 - assert ( + ) + assert ( # noqa: S101 result.stdout == """ The effective command line invocation was: @@ -32,14 +33,15 @@ def test_basic() -> None: def test_default_values() -> None: + """Ensure ``default_values.py`` produces the expected results.""" example = Path(__file__).parent / "default_values.py" result = subprocess.run( - [example, "--foo", "bar"], + [example, "--foo", "bar"], # noqa: S603 capture_output=True, check=True, text=True, - ) # nosec B603 - assert ( + ) + assert ( # noqa: S101 result.stdout == """ The effective command line invocation was: @@ -49,54 +51,67 @@ def test_default_values() -> None: def test_relative_references() -> None: + """Ensure ``relative_references.py`` produces the expected results.""" example = Path(__file__).parent / "relative_references.py" result = subprocess.run( - [example, "--src", "bar.txt"], + [example, "--src", "bar.txt"], # noqa: S603 capture_output=True, check=True, text=True, - ) # nosec B603 - assert ( + ) + assert ( # noqa: S101 """ The effective command line invocation was: relative_references.py --bar spam --baz 42 --src """.strip() in result.stdout ) - assert re.search(r"--src /\S+/bar\.txt", result.stdout) + assert re.search(r"--src /\S+/bar\.txt", result.stdout) # noqa: S101 def test_post_processing() -> None: + """Ensure ``post_processing.py`` produces the expected results.""" example = Path(__file__).parent / "post_processing.py" result = subprocess.run( - [example, "--before", "'30 minutes ago'"], + [example, "--before", "'30 minutes ago'"], # noqa: S603 capture_output=True, check=True, text=True, - ) # nosec B603 - assert ( + ) + assert ( # noqa: S101 """ The effective command line invocation was: post_processing.py --bar spam --baz 42 --before """.strip() in result.stdout ) - thirty_miutes_ago = datetime.now() - timedelta(minutes=30) + thirty_miutes_ago = datetime.now(tz=timezone.utc) - timedelta(minutes=30) time_from_example = datetime.strptime( shlex.split(result.stdout)[-1], "%Y-%m-%d %H:%M:%S.%f" + ).astimezone(timezone.utc) + assert ( # noqa: S101 + thirty_miutes_ago - time_from_example < timedelta(seconds=1) ) - assert thirty_miutes_ago - time_from_example < timedelta(seconds=1) def test_pretty_printing() -> None: + """Ensure ``pretty_printing.py`` produces the expected results.""" example = Path(__file__).parent / "pretty_printing.py" result = subprocess.run( - [example, "--foo", "eggs", "--src", "file.txt", "--before", "'today'"], + [ # noqa: S603 + example, + "--foo", + "eggs", + "--src", + "file.txt", + "--before", + "'today'", + ], capture_output=True, check=True, text=True, - ) # nosec B603 - assert ( + ) + assert ( # noqa: S101 """ The effective command line invocation was: pretty_printing.py \\ @@ -106,24 +121,25 @@ def test_pretty_printing() -> None: """.strip() in result.stdout ) - assert re.search(r"--src /\S+/file\.txt", result.stdout) - today = datetime.now() + assert re.search(r"--src /\S+/file\.txt", result.stdout) # noqa: S101 + today = datetime.now(tz=timezone.utc) time_from_example = datetime.strptime( shlex.split(result.stdout.splitlines()[-1])[-1], "%Y-%m-%d %H:%M:%S.%f", - ) - assert today - time_from_example < timedelta(seconds=1) + ).astimezone(timezone.utc) + assert today - time_from_example < timedelta(seconds=1) # noqa: S101 def test_subparsers() -> None: + """Ensure ``subparsers.py`` produces the expected results.""" example = Path(__file__).parent / "subparsers.py" result = subprocess.run( - [example, "foo", "--one", "eggs"], + [example, "foo", "--one", "eggs"], # noqa: S603 capture_output=True, check=True, text=True, - ) # nosec B603 - assert ( + ) + assert ( # noqa: S101 result.stdout == """ The effective command line invocation was: diff --git a/pyproject.toml b/pyproject.toml index 49c3c7b..f7b2c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,18 +3,10 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" -[pydocstyle] -convention = "google" - - [tool.bandit.assert_used] skips = ["**/test_*.py"] -[tool.black] -line-length = 79 - - [tool.commitizen] name = "cz_customize" @@ -23,11 +15,6 @@ name = "cz_customize" schema_pattern = "(?s)(build|chore|ci|docs|feat|fix|minor|patch|perf|refactor|style|test|revert)(\\(\\S+\\))?!?:( [^\\n\\r]+)((\\n\\n.*)|(\\s*))?$" -[tool.isort] -profile = "black" -line_length = 79 - - [tool.poetry] name = "reverse_argparse" version = "1.0.6" @@ -77,6 +64,49 @@ python = ">=3.8" # list of Poetry development dependencies. +[tool.ruff] +line-length = 79 + + +[tool.ruff.lint] +extend-select = [ + "A", + "B", + "BLE", + "C4", + "C90", + "D", + "DTZ", + "E", + "EM", + "ERA", + "EXE", + "FBT", + "NPY", + "PGH", + "PL", + "PT", + "PTH", + "RET", + "RSE", + "RUF", + "S", + "SIM", + "TID", + "TCH", + "TRY", + "UP", + "W", +] +ignore = [ + "D212", +] + + +[tool.ruff.lint.pydocstyle] +convention = "google" + + [tool.semantic_release] build_command = "python3 -m pip install poetry && poetry build" commit_message = """ diff --git a/reverse_argparse/reverse_argparse.py b/reverse_argparse/reverse_argparse.py index 62f00dd..8974253 100644 --- a/reverse_argparse/reverse_argparse.py +++ b/reverse_argparse/reverse_argparse.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """ The ``reverse_argparse`` module. @@ -20,6 +19,10 @@ from typing import List, Sequence +BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION = 9 +SHORT_OPTION_LENGTH = 2 + + class ReverseArgumentParser: """ Argument parsing in reverse. @@ -91,7 +94,7 @@ def _unparse_args(self) -> None: self._unparse_action(action) self._unparsed[-1] = True - def _unparse_action(self, action: Action) -> None: + def _unparse_action(self, action: Action) -> None: # noqa: C901, PLR0912 """ Unparse a single action. @@ -135,14 +138,15 @@ def _unparse_action(self, action: Action) -> None: return elif ( action_type == "BooleanOptionalAction" - and sys.version_info.minor >= 9 + and sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION ): self._unparse_boolean_optional_action(action) else: # pragma: no cover - raise NotImplementedError( + 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: """ @@ -213,7 +217,7 @@ def _get_long_option_strings( return [ option for option in option_strings - if len(option) > 2 + if len(option) > SHORT_OPTION_LENGTH and option[0] in self._parsers[-1].prefix_chars and option[1] in self._parsers[-1].prefix_chars ] @@ -235,11 +239,12 @@ def _get_short_option_strings( return [ option for option in option_strings - if len(option) == 2 and option[0] in self._parsers[-1].prefix_chars + if len(option) == SHORT_OPTION_LENGTH + and option[0] in self._parsers[-1].prefix_chars ] def _get_option_string( - self, action: Action, prefer_short: bool = False + self, action: Action, *, prefer_short: bool = False ) -> str: """ Get the option string for the `action`. @@ -342,10 +347,10 @@ def _unparse_store_action(self, action: Action) -> None: values = [values] needs_quotes_regex = re.compile(r"(.*\s.*)") for value in values: - value = str(value) - if needs_quotes_regex.search(value): - value = needs_quotes_regex.sub(r"'\1'", value) - result.append(value) + string_value = str(value) + if needs_quotes_regex.search(string_value): + string_value = needs_quotes_regex.sub(r"'\1'", string_value) + result.append(string_value) self._append_list_of_args(result) def _unparse_store_const_action(self, action: Action) -> None: @@ -399,13 +404,13 @@ def _unparse_append_action(self, action: Action) -> None: for entry in values: tmp = [flag] for value in entry: - value = quote_arg_if_necessary(str(value)) - tmp.append(value) + quoted_value = quote_arg_if_necessary(str(value)) + tmp.append(quoted_value) result.append(tmp) else: for value in values: - value = quote_arg_if_necessary(str(value)) - result.append([flag, value]) + quoted_value = quote_arg_if_necessary(str(value)) + result.append([flag, quoted_value]) self._append_list_of_list_of_args(result) def _unparse_append_const_action(self, action: Action) -> None: @@ -429,7 +434,10 @@ def _unparse_count_action(self, action: Action) -> None: value = getattr(self._namespace, action.dest) count = value if action.default is None else (value - action.default) flag = self._get_option_string(action, prefer_short=True) - if len(flag) == 2 and flag[0] in self._parsers[-1].prefix_chars: + if ( + len(flag) == SHORT_OPTION_LENGTH + and flag[0] in self._parsers[-1].prefix_chars + ): self._append_arg(flag[0] + flag[1] * count) else: self._append_list_of_args([flag for _ in range(count)]) @@ -457,10 +465,11 @@ def _unparse_sub_parsers_action(self, action: Action) -> None: if action.choices is None or not isinstance( action.choices, dict ): # pragma: no cover - raise RuntimeError( + message = ( "This subparser action is missing its dictionary of " f"choices: {action}" ) + raise RuntimeError(message) for subcommand, subparser in action.choices.items(): self._parsers.append(subparser) self._unparsed.append(False) @@ -484,7 +493,7 @@ def _unparse_extend_action(self, action: Action) -> None: values = getattr(self._namespace, action.dest) if values is not None: self._append_list_of_args( - [self._get_option_string(action)] + values + [self._get_option_string(action), *values] ) def _unparse_boolean_optional_action(self, action: Action) -> None: diff --git a/setup.py b/setup.py index 1e683a0..02d0e33 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """ Setup file for the ``reverse_argparse`` package. diff --git a/test/requirements.txt b/test/requirements.txt index 4585c8b..9d956b9 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,14 +1,10 @@ # Requirements for testing `reverse_argparse`. -bandit -black dateparser -flake8 -flake8-bugbear mypy pre-commit -prospector pyroma pytest pytest-cov pytest-mock +ruff diff --git a/test/test_reverse_argparse.py b/test/test_reverse_argparse.py index 494fffd..223c328 100644 --- a/test/test_reverse_argparse.py +++ b/test/test_reverse_argparse.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """The unit test suite for the ``reverse_argparse`` package.""" # © 2024 National Technology & Engineering Solutions of Sandia, LLC @@ -11,16 +10,26 @@ import sys from argparse import SUPPRESS, ArgumentParser, Namespace -if sys.version_info.minor >= 9: - from argparse import BooleanOptionalAction - 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: + """ + Pre-populate an ``ArgumentParser`` with a variety of options. + + Returns: + The ``ArgumentParser`` to be used in a number of tests. + """ p = ArgumentParser() p.add_argument("pos1", nargs="*") p.add_argument("pos2") @@ -42,7 +51,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 >= 9: + if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION: p.add_argument( "--bool-opt", action=BooleanOptionalAction, default=False ) @@ -92,43 +101,46 @@ def parser() -> ArgumentParser: ] -def strip_first_entry(input: str) -> str: +def strip_first_entry(input_string: str) -> str: """ Remove the first entry of a space-delimited string. Args: - input: A space-delimited string. + input_string: A space-delimited string. Returns: The input string with the first element (and first space) removed. """ - return " ".join(input.split()[1:]) + return " ".join(input_string.split()[1:]) def test_strip_first_entry() -> None: - assert strip_first_entry("foo bar baz") == "bar baz" + """Ensure :func:`strip_first_entry` works as expected.""" + assert strip_first_entry("foo bar baz") == "bar baz" # noqa: S101 -def strip_first_line(input: str) -> str: +def strip_first_line(input_string: str) -> str: """ Remove the first line of a multi-line string. Args: - input: A multi-line string. + input_string: A multi-line string. Returns: The input string with the first line removed. """ - return "\n".join(input.splitlines()[1:]) + return "\n".join(input_string.splitlines()[1:]) def test_strip_first_line() -> None: - assert strip_first_line("foo\nbar\nbaz") == "bar\nbaz" + """Ensure :func:`strip_first_line` works as expected.""" + assert strip_first_line("foo\nbar\nbaz") == "bar\nbaz" # noqa: S101 @pytest.mark.parametrize("args", COMPLETE_ARGS) def test_get_effective_command_line_invocation(parser, args) -> None: + """Ensure :func:`get_effective_command_line_invoation` works.""" namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) expected = ( @@ -140,17 +152,22 @@ def test_get_effective_command_line_invocation(parser, args) -> None: "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 "") + + ( + "--no-bool-opt " + if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION + else "" + ) + "pos1-val1 pos1-val2 pos2-val" ) result = strip_first_entry( unparser.get_effective_command_line_invocation() ) - assert result == expected + assert result == expected # noqa: S101 @pytest.mark.parametrize("args", COMPLETE_ARGS) def test_get_pretty_command_line_invocation(parser, args) -> None: + """Ensure :func:`get_pretty_command_line_invoation` works as expected.""" namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) expected = """ --opt1 opt1-val \\ @@ -170,30 +187,33 @@ def test_get_pretty_command_line_invocation(parser, args) -> None: --app-const2 \\ -vv \\ --ext ext-val1 ext-val2 ext-val3 \\""" - if sys.version_info.minor >= 9: + if sys.version_info.minor >= BOOLEAN_OPTIONAL_ACTION_MINOR_VERSION: 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 + assert result == expected # noqa: S101 def test_get_command_line_invocation_strip_spaces() -> None: + """Ensure extraneous spaces are stripped.""" parser = ArgumentParser() namespace = Namespace() unparser = ReverseArgumentParser(parser, namespace) unparser._args = ["program_name", " --foo", " ", " --bar"] unparser._unparsed = [True] expected = "program_name --foo --bar" - assert unparser.get_effective_command_line_invocation() == expected + assert ( # noqa: S101 + unparser.get_effective_command_line_invocation() == expected + ) expected = """ --foo \\ --bar""" result = strip_first_line(unparser.get_pretty_command_line_invocation()) - assert result == expected + assert result == expected # noqa: S101 @pytest.mark.parametrize( - "add_args, add_kwargs, args, expected", + ("add_args", "add_kwargs", "args", "expected"), [ (["--foo"], {"action": "store"}, "--foo bar", [" --foo bar"]), ( @@ -233,6 +253,7 @@ def test_get_command_line_invocation_strip_spaces() -> None: ], ) def test__unparse_args(add_args, add_kwargs, args, expected) -> None: + """Ensure :func:`_unparse_args` works as expected.""" parser = ArgumentParser() parser.add_argument(*add_args, **add_kwargs) try: @@ -245,11 +266,17 @@ def test__unparse_args(add_args, add_kwargs, args, expected) -> None: unparser._unparse_args() else: unparser._unparse_args() - assert unparser._args[1:] == expected + assert unparser._args[1:] == expected # noqa: S101 def test__unparse_args_boolean_optional_action() -> None: - if sys.version_info.minor >= 9: + """ + Ensure :func:`_unparse_args` works as expected. + + 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: @@ -258,10 +285,11 @@ def test__unparse_args_boolean_optional_action() -> None: namespace = Namespace() unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_args() - assert unparser._args[1:] == [" --foo"] + assert unparser._args[1:] == [" --foo"] # noqa: S101 def test__unparse_args_already_unparsed() -> None: + """Ensure unparsing is a no-op if the args have already been unparsed.""" parser = ArgumentParser() namespace = Namespace() unparser = ReverseArgumentParser(parser, namespace) @@ -269,10 +297,11 @@ def test__unparse_args_already_unparsed() -> None: args_before = unparser._args.copy() unparser._unparsed = [True] unparser._unparse_args() - assert unparser._args == args_before + assert unparser._args == args_before # noqa: S101 def test__arg_is_default_and_help_is_suppressed() -> None: + """Ensure defaults for suppressed args are suppressed.""" parser = ArgumentParser() parser.add_argument("--suppressed", default=10, help=SUPPRESS) namespace = parser.parse_args(shlex.split("")) @@ -280,11 +309,11 @@ def test__arg_is_default_and_help_is_suppressed() -> None: result = strip_first_entry( unparser.get_effective_command_line_invocation() ) - assert result == "" + assert result == "" # noqa: S101 @pytest.mark.parametrize( - "strings, expected", + ("strings", "expected"), [ (["-v", "--verbose"], ["--verbose"]), (["--foo", "-f", "--foo-bar"], ["--foo", "--foo-bar"]), @@ -292,12 +321,13 @@ def test__arg_is_default_and_help_is_suppressed() -> None: ], ) def test__get_long_option_strings(strings, expected) -> None: + """Ensure the long-form option is selected from a list.""" unparser = ReverseArgumentParser(ArgumentParser(), Namespace()) - assert unparser._get_long_option_strings(strings) == expected + assert unparser._get_long_option_strings(strings) == expected # noqa: S101 @pytest.mark.parametrize( - "strings, expected", + ("strings", "expected"), [ (["-v", "--verbose"], ["-v"]), (["--foo", "-f", "--foo-bar"], ["-f"]), @@ -305,12 +335,15 @@ def test__get_long_option_strings(strings, expected) -> None: ], ) def test__get_short_option_strings(strings, expected) -> None: + """Ensure the short-form option is selected from a list.""" unparser = ReverseArgumentParser(ArgumentParser(), Namespace()) - assert unparser._get_short_option_strings(strings) == expected + assert ( # noqa: S101 + unparser._get_short_option_strings(strings) == expected + ) @pytest.mark.parametrize( - "strings, expected", + ("strings", "expected"), [ (["-v", "--verbose"], "--verbose"), (["--foo", "-f", "--foo-bar"], "--foo"), @@ -318,14 +351,15 @@ def test__get_short_option_strings(strings, expected) -> None: ], ) def test__get_option_string(strings, expected) -> None: + """Ensure long-form options are preferred over short-form ones.""" parser = ArgumentParser() action = parser.add_argument(*strings) unparser = ReverseArgumentParser(parser, Namespace()) - assert unparser._get_option_string(action) == expected + assert unparser._get_option_string(action) == expected # noqa: S101 @pytest.mark.parametrize( - "strings, expected", + ("strings", "expected"), [ (["-v", "--verbose"], "-v"), (["-f", "--foo", "-b"], "-f"), @@ -333,14 +367,17 @@ def test__get_option_string(strings, expected) -> None: ], ) def test__get_option_string_prefer_short(strings, expected) -> None: + """Ensure short-form options are preferred over long-form ones.""" parser = ArgumentParser() action = parser.add_argument(*strings) unparser = ReverseArgumentParser(parser, Namespace()) - assert unparser._get_option_string(action, prefer_short=True) == expected + assert ( # noqa: S101 + unparser._get_option_string(action, prefer_short=True) == expected + ) @pytest.mark.parametrize( - "add_args, add_kwargs, args, expected", + ("add_args", "add_kwargs", "args", "expected"), [ (["positional"], {}, "val", " val"), (["-f", "--foo"], {}, "-f bar", " --foo bar"), @@ -358,16 +395,17 @@ def test__get_option_string_prefer_short(strings, expected) -> None: ], ) def test__unparse_store_action(add_args, add_kwargs, args, expected) -> None: + """Ensure ``store`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument(*add_args, **add_kwargs) namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_store_action(action) - assert unparser._args[1:] == [expected] + assert unparser._args[1:] == [expected] # noqa: S101 @pytest.mark.parametrize( - "add_args, add_kwargs, args, expected", + ("add_args", "add_kwargs", "args", "expected"), [ (["--foo"], {"action": "store_const", "const": 42}, "", None), ( @@ -393,40 +431,49 @@ def test__unparse_store_action(add_args, add_kwargs, args, expected) -> None: def test__unparse_store_const_action( add_args, add_kwargs, args, expected ) -> None: + """Ensure ``store_const`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument(*add_args, **add_kwargs) namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_store_const_action(action) - assert unparser._args[1:] == ([expected] if expected is not None else []) + assert ( # noqa: S101 + unparser._args[1:] == ([expected] if expected is not None else []) + ) @pytest.mark.parametrize( - "args, expected", [(shlex.split("--foo"), " --foo"), ([], None)] + ("args", "expected"), [(shlex.split("--foo"), " --foo"), ([], None)] ) def test__unparse_store_true_action(args, expected) -> None: + """Ensure ``store_true`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument("--foo", action="store_true") namespace = parser.parse_args(args) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_store_true_action(action) - assert unparser._args[1:] == ([expected] if expected is not None else []) + assert ( # noqa: S101 + unparser._args[1:] == ([expected] if expected is not None else []) + ) @pytest.mark.parametrize( - "args, expected", [(shlex.split("--foo"), " --foo"), ([], None)] + ("args", "expected"), [(shlex.split("--foo"), " --foo"), ([], None)] ) def test__unparse_store_false_action(args, expected) -> None: + """Ensure ``store_false`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument("--foo", action="store_false") namespace = parser.parse_args(args) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_store_false_action(action) - assert unparser._args[1:] == ([expected] if expected is not None else []) + assert ( # noqa: S101 + unparser._args[1:] == ([expected] if expected is not None else []) + ) @pytest.mark.parametrize( - "add_args, add_kwargs, args, expected", + ("add_args", "add_kwargs", "args", "expected"), [ ( ["--foo"], @@ -443,18 +490,20 @@ def test__unparse_store_false_action(args, expected) -> None: ], ) def test__unparse_append_action(add_args, add_kwargs, args, expected) -> None: + """Ensure ``append`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument(*add_args, **add_kwargs) namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_append_action(action) - assert unparser._args[1:] == expected + assert unparser._args[1:] == expected # noqa: S101 @pytest.mark.parametrize( - "args, expected", [("--foo", " --foo"), ("", None)] + ("args", "expected"), [("--foo", " --foo"), ("", None)] ) def test__unparse_append_const_action(args, expected) -> None: + """Ensure ``append_const`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument( "--foo", dest="append_const", action="append_const", const=42 @@ -462,11 +511,13 @@ def test__unparse_append_const_action(args, expected) -> None: namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_append_const_action(action) - assert unparser._args[1:] == ([expected] if expected is not None else []) + assert ( # noqa: S101 + unparser._args[1:] == ([expected] if expected is not None else []) + ) @pytest.mark.parametrize( - "add_args, add_kwargs, args, expected", + ("add_args", "add_kwargs", "args", "expected"), [ ( ["--foo"], @@ -489,16 +540,17 @@ def test__unparse_append_const_action(args, expected) -> None: ], ) def test__unparse_count_action(add_args, add_kwargs, args, expected) -> None: + """Ensure ``count`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument(*add_args, **add_kwargs) namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_count_action(action) - assert unparser._args[1:] == [expected] + assert unparser._args[1:] == [expected] # noqa: S101 @pytest.mark.parametrize( - "args, expected, pretty", + ("args", "expected", "pretty"), [ ("a 12", "a 12", " a \\\n 12"), ( @@ -509,6 +561,7 @@ def test__unparse_count_action(add_args, add_kwargs, args, expected) -> None: ], ) def test__unparse_sub_parsers_action(args, expected, pretty) -> None: + """Ensure subparsers are handled appropriately.""" parser = ArgumentParser() parser.add_argument("--foo", action="store_true", help="foo help") subparsers = parser.add_subparsers(help="sub-command help") @@ -522,12 +575,13 @@ def test__unparse_sub_parsers_action(args, expected, pretty) -> None: result = strip_first_entry( unparser.get_effective_command_line_invocation() ) - assert result == expected + assert result == expected # noqa: S101 result = strip_first_line(unparser.get_pretty_command_line_invocation()) - assert result == pretty + assert result == pretty # noqa: S101 def test__unparse_sub_parsers_action_nested() -> None: + """Ensure nested subparsers are handled appropriately.""" parser = ArgumentParser() parser.add_argument("--optional-1", action="store_true") parser.add_argument("--optional-2") @@ -563,23 +617,24 @@ def test__unparse_sub_parsers_action_nested() -> None: result = strip_first_entry( unparser.get_effective_command_line_invocation() ) - assert result == args + assert result == args # noqa: S101 result = strip_first_line(unparser.get_pretty_command_line_invocation()) - assert result == pretty + assert result == pretty # noqa: S101 def test__unparse_extend_action() -> None: + """Ensure ``extend`` actions are handled appropriately.""" parser = ArgumentParser() action = parser.add_argument("--foo", action="extend", nargs="*") namespace = parser.parse_args(shlex.split("--foo bar --foo baz bif")) unparser = ReverseArgumentParser(parser, namespace) expected = " --foo bar baz bif" unparser._unparse_extend_action(action) - assert unparser._args[1:] == [expected] + assert unparser._args[1:] == [expected] # noqa: S101 @pytest.mark.parametrize( - "default, args, expected", + ("default", "args", "expected"), [ (None, "", None), (None, "--bool-opt", " --bool-opt"), @@ -593,7 +648,8 @@ def test__unparse_extend_action() -> None: ], ) def test__unparse_boolean_optional_action(default, args, expected) -> None: - if sys.version_info.minor >= 9: + """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 @@ -601,6 +657,6 @@ def test__unparse_boolean_optional_action(default, args, expected) -> None: namespace = parser.parse_args(shlex.split(args)) unparser = ReverseArgumentParser(parser, namespace) unparser._unparse_boolean_optional_action(action) - assert unparser._args[1:] == ( + assert unparser._args[1:] == ( # noqa: S101 [expected] if expected is not None else [] )