diff options
author | Peter Pentchev <roam@debian.org> | 2023-02-08 08:49:47 +0200 |
---|---|---|
committer | Peter Pentchev <roam@debian.org> | 2023-02-08 08:49:47 +0200 |
commit | 6ca0d0d63be0e08f2ae0cecd36b18a3a1f1ab8e0 (patch) | |
tree | b14d5b96618813742de09c443c7b291ffde2242e |
New upstream version 0.1.1
32 files changed, 1417 insertions, 0 deletions
diff --git a/.config/ruff-all/pyproject.toml b/.config/ruff-all/pyproject.toml new file mode 100644 index 0000000..3823c31 --- /dev/null +++ b/.config/ruff-all/pyproject.toml @@ -0,0 +1,3 @@ +[tool.ruff] +extend = "../ruff-base/pyproject.toml" +select = ["ALL"] diff --git a/.config/ruff-base/pyproject.toml b/.config/ruff-base/pyproject.toml new file mode 100644 index 0000000..97d6cf1 --- /dev/null +++ b/.config/ruff-base/pyproject.toml @@ -0,0 +1,32 @@ +[tool.ruff] +target-version = "py38" +line-length = 100 +select = [] +ignore = [ + # We know what "self" is... I hope + "ANN101", + + # We let the "black" tool take care of most of the formatting + "COM812", + + # No blank lines before the class docstring, TYVM + "D203", + + # The multi-line docstring summary starts on the same line + "D213", + + # Our exceptions are simple enough + "EM", + + # ruff does not seem to like the empty line before "from typing import ..." + "I", + + # The Tagged and TaggedFrozen classes need to be typedload-compatible + "TCH", + + # We are fine with relative imports + "TID", + + # Much too restrictive + "TRY", +] diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5d9a133 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,36 @@ +# https://editorconfig.org/ + +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[*.md] +indent_style = space +indent_size = 2 + +[*.py] +indent_style = space +indent_size = 4 + +[*.pyi] +indent_style = space +indent_size = 4 + +[*.sh] +indent_style = tab +tab_size = 8 + +[*.toml] +indent_style = space +indent_size = 2 + +[setup.cfg] +indent_style = space +indent_size = 4 + +[tox.ini] +indent_style = space +indent_size = 2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0cc69fd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to the test-stages project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.1] - 2023-02-07 + +### Fixes + +- Include the changelog file and the `.config/ruff-*/pyproject.toml` files in + the PyPI source distribution tarball. + +## [0.1.0] - 2023-02-07 + +### Started + +- First public release. + +[Unreleased]: https://gitlab.com/ppentchev/test-stages/-/compare/release%2F0.1.1...main +[0.1.1]: https://gitlab.com/ppentchev/test-stages/-/compare/release%2F0.1.0...release%2F0.1.1 +[0.1.0]: https://gitlab.com/ppentchev/test-stages/-/tags/release%2F0.1.0 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..d10c2e6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include CHANGELOG.md +recursive-include .config pyproject.toml +include .editorconfig +recursive-include requirements *.txt +recursive-include stubs *.pyi +include tox.ini +recursive-include unit_tests *.py diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..7dc71f5 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,81 @@ +Metadata-Version: 2.1 +Name: test_stages +Version: 0.1.1 +Summary: Group Tox, Nox, etc environments into stages, run them in parallel +Author-email: Peter Pentchev <roam@ringlet.net> +Project-URL: Homepage, https://gitlab.com/ppentchev/test-stages +Project-URL: Changes, https://gitlab.com/ppentchev/test-stages/-/blob/main/CHANGELOG.md +Project-URL: Issue Tracker, https://gitlab.com/ppentchev/test-stages/-/issues +Project-URL: Source Code, https://gitlab.com/ppentchev/test-stages +Classifier: Development Status :: 4 - Beta +Classifier: Environment :: Console +Classifier: Framework :: tox +Classifier: Intended Audience :: Developers +Classifier: License :: DFSG approved +Classifier: License :: Freely Distributable +Classifier: License :: OSI Approved +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: POSIX +Classifier: Operating System :: Unix +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Topic :: Software Development +Classifier: Topic :: Software Development :: Testing +Classifier: Topic :: Software Development :: Testing :: Unit +Classifier: Topic :: Utilities +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +Provides-Extra: tox + +# Run Tox tests in groups, stopping on errors + +The `test-stages` library provides command-line tools that wrap +Python test environment runners such as [Tox][tox] or [Nox][nox], +invoking them so as the various tests are run in parallel, in groups, +as specified on the command line. This allows the fastest tests to be run +first, and the slower ones to only be started if it makes sense (e.g. if +tools like [ruff] or [flake8] did not uncover any trivial syntax errors). + +The `tox-stages` tool runs Tox with the specified groups of test +environments, stopping if any of the tests in a group should fail. +This allows quick static check tools like e.g. `ruff` to stop +the testing process early, and also allows scenarios like running +all the static check tools before the package's unit or functional +tests to avoid unnecessary failures on simple errors. + +The syntax for grouping the test environments to be run is described in +the [parse-stages] library's documentation. + +## Running Tox tests in groups + +The `tox-stages` tool may be invoked with a list of stages specified on +the command line: + + tox-stages run @check @tests + +If the `tox-stages run` command is invoked without any stage specifications, +the tool looks for the `stages` list of strings in the `[tool.test-stages]` +section of the `pyproject.toml` file: + + [tool.test-stages] + stages = ["ruff and not @manual", "@check", "@tests"] + +Note that the `tox-stages` tool only supports Tox version 3 for the present. + +## Author + +The `test-stages` library is developed by [Peter Pentchev][roam] in +[a GitLab repository][gitlab]. + +[flake8]: https://github.com/pycqa/flake8 "The flake8 Python syntax and style checker" +[gitlab]: https://gitlab.com/ppentchev/test-stages "The test-stages GitLab repository" +[nox]: https://nox.thea.codes/ "The Nox test runner" +[parse-stages]: https://gitlab.com/ppentchev/parse-stages "Parse a mini-language for selecting objects by tag or name" +[roam]: mailto:roam@ringlet.net "Peter Pentchev" +[ruff]: https://github.com/charliermarsh/ruff "Ruff, the extremely fast Python linter" +[tox]: https://tox.wiki/ "The Tox automation project" diff --git a/README.md b/README.md new file mode 100644 index 0000000..b29b544 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Run Tox tests in groups, stopping on errors + +The `test-stages` library provides command-line tools that wrap +Python test environment runners such as [Tox][tox] or [Nox][nox], +invoking them so as the various tests are run in parallel, in groups, +as specified on the command line. This allows the fastest tests to be run +first, and the slower ones to only be started if it makes sense (e.g. if +tools like [ruff] or [flake8] did not uncover any trivial syntax errors). + +The `tox-stages` tool runs Tox with the specified groups of test +environments, stopping if any of the tests in a group should fail. +This allows quick static check tools like e.g. `ruff` to stop +the testing process early, and also allows scenarios like running +all the static check tools before the package's unit or functional +tests to avoid unnecessary failures on simple errors. + +The syntax for grouping the test environments to be run is described in +the [parse-stages] library's documentation. + +## Running Tox tests in groups + +The `tox-stages` tool may be invoked with a list of stages specified on +the command line: + + tox-stages run @check @tests + +If the `tox-stages run` command is invoked without any stage specifications, +the tool looks for the `stages` list of strings in the `[tool.test-stages]` +section of the `pyproject.toml` file: + + [tool.test-stages] + stages = ["ruff and not @manual", "@check", "@tests"] + +Note that the `tox-stages` tool only supports Tox version 3 for the present. + +## Author + +The `test-stages` library is developed by [Peter Pentchev][roam] in +[a GitLab repository][gitlab]. + +[flake8]: https://github.com/pycqa/flake8 "The flake8 Python syntax and style checker" +[gitlab]: https://gitlab.com/ppentchev/test-stages "The test-stages GitLab repository" +[nox]: https://nox.thea.codes/ "The Nox test runner" +[parse-stages]: https://gitlab.com/ppentchev/parse-stages "Parse a mini-language for selecting objects by tag or name" +[roam]: mailto:roam@ringlet.net "Peter Pentchev" +[ruff]: https://github.com/charliermarsh/ruff "Ruff, the extremely fast Python linter" +[tox]: https://tox.wiki/ "The Tox automation project" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..434d27e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,116 @@ +[build-system] +requires = ["setuptools >= 61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "test_stages" +description = "Group Tox, Nox, etc environments into stages, run them in parallel" +readme = "README.md" +requires-python = ">= 3.8" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Framework :: tox", + "Intended Audience :: Developers", + "License :: DFSG approved", + "License :: Freely Distributable", + "License :: OSI Approved", + "License :: OSI Approved :: BSD License", + "Operating System :: POSIX", + "Operating System :: Unix", + "Programming Language :: Python", + "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", + "Topic :: Software Development", + "Topic :: Software Development :: Testing", + "Topic :: Software Development :: Testing :: Unit", + "Topic :: Utilities", +] +dynamic = ["dependencies", "version"] + +[[project.authors]] +name = "Peter Pentchev" +email = "roam@ringlet.net" + +[project.entry-points.tox] +trivtags = "tox_trivtags" + +[project.optional-dependencies] +tox = ["tox >= 3, < 4"] + +[project.scripts] +tox-stages = "test_stages.tox_stages.__main__:main" + +[project.urls] +Homepage = "https://gitlab.com/ppentchev/test-stages" +Changes = "https://gitlab.com/ppentchev/test-stages/-/blob/main/CHANGELOG.md" +"Issue Tracker" = "https://gitlab.com/ppentchev/test-stages/-/issues" +"Source Code" = "https://gitlab.com/ppentchev/test-stages" + +[tool.setuptools] +zip-safe = true +package-dir = {"" = "src"} +packages = ["test_stages", "test_stages.tox_stages", "tox_trivtags"] + +[tool.setuptools.package-data] +test_stages = ["py.typed"] +tox_trivtags = ["py.typed"] + +[tool.setuptools.dynamic] +dependencies = {file = "requirements/install.txt"} +version = {attr = "test_stages.VERSION"} + +[tool.black] +line-length = 100 + +[tool.mypy] +strict = true +python_version = "3.8" + +# This is the list of the Pylint 2.16.1 default plugins. +[tool.pylint] +load-plugins = [ + "pylint.extensions.bad_builtin", + "pylint.extensions.broad_try_clause", + "pylint.extensions.check_elif", + "pylint.extensions.code_style", + # "pylint.extensions.comparetozero", # clarity + "pylint.extensions.comparison_placement", + "pylint.extensions.confusing_elif", + "pylint.extensions.consider_refactoring_into_while_condition", + "pylint.extensions.consider_ternary_expression", + "pylint.extensions.dict_init_mutate", + "pylint.extensions.docparams", + "pylint.extensions.docstyle", + "pylint.extensions.dunder", + # "pylint.extensions.empty_comment", # the copyright notices trigger that one + "pylint.extensions.emptystring", + "pylint.extensions.eq_without_hash", + "pylint.extensions.for_any_all", + "pylint.extensions.magic_value", + "pylint.extensions.mccabe", + "pylint.extensions.no_self_use", + "pylint.extensions.overlapping_exceptions", + "pylint.extensions.private_import", + "pylint.extensions.redefined_loop_name", + "pylint.extensions.redefined_variable_type", + "pylint.extensions.set_membership", + "pylint.extensions.typing", + "pylint.extensions.while_used", +] + +[tool.pylint.main] +disable = [ + "consider-using-assignment-expr", +] + +[tool.ruff] +extend = ".config/ruff-base/pyproject.toml" +select = ["E", "F"] + +[tool.test-stages] +stages = ["ruff", "@check", "@tests"] diff --git a/requirements/install.txt b/requirements/install.txt new file mode 100644 index 0000000..b7d8d3f --- /dev/null +++ b/requirements/install.txt @@ -0,0 +1,8 @@ +click >= 8, < 9 +contextlib-chdir >= 1, < 2; python_version < '3.11' +packaging >= 17, < 24 +parse-stages >= 0.1, < 0.2 +pyparsing >= 3, < 4 +setuptools +tomli >= 2, < 3; python_version < '3.11' +utf8-locale >= 1, < 2 diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000..422bfeb --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1 @@ +pytest >= 6, < 8 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..31a7fbd --- /dev/null +++ b/setup.cfg @@ -0,0 +1,12 @@ +[flake8] +extend_ignore = C812 +max_line_length = 100 +inline_quotes = double + +[pycodestyle] +max-line-length = 100 + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/src/test_stages.egg-info/PKG-INFO b/src/test_stages.egg-info/PKG-INFO new file mode 100644 index 0000000..973d0ec --- /dev/null +++ b/src/test_stages.egg-info/PKG-INFO @@ -0,0 +1,81 @@ +Metadata-Version: 2.1 +Name: test-stages +Version: 0.1.1 +Summary: Group Tox, Nox, etc environments into stages, run them in parallel +Author-email: Peter Pentchev <roam@ringlet.net> +Project-URL: Homepage, https://gitlab.com/ppentchev/test-stages +Project-URL: Changes, https://gitlab.com/ppentchev/test-stages/-/blob/main/CHANGELOG.md +Project-URL: Issue Tracker, https://gitlab.com/ppentchev/test-stages/-/issues +Project-URL: Source Code, https://gitlab.com/ppentchev/test-stages +Classifier: Development Status :: 4 - Beta +Classifier: Environment :: Console +Classifier: Framework :: tox +Classifier: Intended Audience :: Developers +Classifier: License :: DFSG approved +Classifier: License :: Freely Distributable +Classifier: License :: OSI Approved +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: POSIX +Classifier: Operating System :: Unix +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Topic :: Software Development +Classifier: Topic :: Software Development :: Testing +Classifier: Topic :: Software Development :: Testing :: Unit +Classifier: Topic :: Utilities +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +Provides-Extra: tox + +# Run Tox tests in groups, stopping on errors + +The `test-stages` library provides command-line tools that wrap +Python test environment runners such as [Tox][tox] or [Nox][nox], +invoking them so as the various tests are run in parallel, in groups, +as specified on the command line. This allows the fastest tests to be run +first, and the slower ones to only be started if it makes sense (e.g. if +tools like [ruff] or [flake8] did not uncover any trivial syntax errors). + +The `tox-stages` tool runs Tox with the specified groups of test +environments, stopping if any of the tests in a group should fail. +This allows quick static check tools like e.g. `ruff` to stop +the testing process early, and also allows scenarios like running +all the static check tools before the package's unit or functional +tests to avoid unnecessary failures on simple errors. + +The syntax for grouping the test environments to be run is described in +the [parse-stages] library's documentation. + +## Running Tox tests in groups + +The `tox-stages` tool may be invoked with a list of stages specified on +the command line: + + tox-stages run @check @tests + +If the `tox-stages run` command is invoked without any stage specifications, +the tool looks for the `stages` list of strings in the `[tool.test-stages]` +section of the `pyproject.toml` file: + + [tool.test-stages] + stages = ["ruff and not @manual", "@check", "@tests"] + +Note that the `tox-stages` tool only supports Tox version 3 for the present. + +## Author + +The `test-stages` library is developed by [Peter Pentchev][roam] in +[a GitLab repository][gitlab]. + +[flake8]: https://github.com/pycqa/flake8 "The flake8 Python syntax and style checker" +[gitlab]: https://gitlab.com/ppentchev/test-stages "The test-stages GitLab repository" +[nox]: https://nox.thea.codes/ "The Nox test runner" +[parse-stages]: https://gitlab.com/ppentchev/parse-stages "Parse a mini-language for selecting objects by tag or name" +[roam]: mailto:roam@ringlet.net "Peter Pentchev" +[ruff]: https://github.com/charliermarsh/ruff "Ruff, the extremely fast Python linter" +[tox]: https://tox.wiki/ "The Tox automation project" diff --git a/src/test_stages.egg-info/SOURCES.txt b/src/test_stages.egg-info/SOURCES.txt new file mode 100644 index 0000000..d3b0266 --- /dev/null +++ b/src/test_stages.egg-info/SOURCES.txt @@ -0,0 +1,31 @@ +.editorconfig +CHANGELOG.md +MANIFEST.in +README.md +pyproject.toml +setup.cfg +tox.ini +.config/ruff-all/pyproject.toml +.config/ruff-base/pyproject.toml +requirements/install.txt +requirements/test.txt +src/test_stages/__init__.py +src/test_stages/cmd.py +src/test_stages/py.typed +src/test_stages.egg-info/PKG-INFO +src/test_stages.egg-info/SOURCES.txt +src/test_stages.egg-info/dependency_links.txt +src/test_stages.egg-info/entry_points.txt +src/test_stages.egg-info/requires.txt +src/test_stages.egg-info/top_level.txt +src/test_stages.egg-info/zip-safe +src/test_stages/tox_stages/__init__.py +src/test_stages/tox_stages/__main__.py +src/tox_trivtags/__init__.py +src/tox_trivtags/parse.py +src/tox_trivtags/py.typed +stubs/contextlib_chdir.pyi +stubs/tox/__init__.pyi +stubs/tox/config.pyi +unit_tests/__init__.py +unit_tests/test_functional.py
\ No newline at end of file diff --git a/src/test_stages.egg-info/dependency_links.txt b/src/test_stages.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/test_stages.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/test_stages.egg-info/entry_points.txt b/src/test_stages.egg-info/entry_points.txt new file mode 100644 index 0000000..6761724 --- /dev/null +++ b/src/test_stages.egg-info/entry_points.txt @@ -0,0 +1,5 @@ +[console_scripts] +tox-stages = test_stages.tox_stages.__main__:main + +[tox] +trivtags = tox_trivtags diff --git a/src/test_stages.egg-info/requires.txt b/src/test_stages.egg-info/requires.txt new file mode 100644 index 0000000..0504ff4 --- /dev/null +++ b/src/test_stages.egg-info/requires.txt @@ -0,0 +1,13 @@ +click<9,>=8 +packaging<24,>=17 +parse-stages<0.2,>=0.1 +pyparsing<4,>=3 +setuptools +utf8-locale<2,>=1 + +[:python_version < "3.11"] +contextlib-chdir<2,>=1 +tomli<3,>=2 + +[tox] +tox<4,>=3 diff --git a/src/test_stages.egg-info/top_level.txt b/src/test_stages.egg-info/top_level.txt new file mode 100644 index 0000000..a7bff51 --- /dev/null +++ b/src/test_stages.egg-info/top_level.txt @@ -0,0 +1,2 @@ +test_stages +tox_trivtags diff --git a/src/test_stages.egg-info/zip-safe b/src/test_stages.egg-info/zip-safe new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/test_stages.egg-info/zip-safe @@ -0,0 +1 @@ + diff --git a/src/test_stages/__init__.py b/src/test_stages/__init__.py new file mode 100644 index 0000000..3136379 --- /dev/null +++ b/src/test_stages/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) Peter Pentchev <roam@ringlet.net> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +"""Run `tox` on several groups of environments, stopping on errors.""" + +VERSION = "0.1.1" diff --git a/src/test_stages/cmd.py b/src/test_stages/cmd.py new file mode 100644 index 0000000..b917f91 --- /dev/null +++ b/src/test_stages/cmd.py @@ -0,0 +1,218 @@ +# Copyright (c) Peter Pentchev <roam@ringlet.net> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +"""Command-line tool helpers for the various test-stages implementations.""" + +from __future__ import annotations + +import dataclasses +import functools +import pathlib +import sys + +from collections.abc import Callable +from typing import Any, Final, NamedTuple, TypeVar + +import click +import parse_stages as parse +import utf8_locale + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +TestEnv = parse.TaggedFrozen + + +class Stage(NamedTuple): + """A stage specification and its boolean expression.""" + + spec: str + expr: parse.BoolExpr + + +@dataclasses.dataclass(frozen=True) +class Config: + """Runtime configuration for the test runner tool.""" + + filename: pathlib.Path + get_all_envs: Callable[[Config], list[TestEnv]] + stages: list[Stage] = dataclasses.field(default_factory=list) + utf8_env: dict[str, str] = dataclasses.field( + default_factory=lambda: utf8_locale.UTF8Detect().detect().env + ) + + +@dataclasses.dataclass +class ConfigHolder: + """Hold a Config object.""" + + cfg: Config | None = None + + +# pylint: disable-next=invalid-name +_T = TypeVar("_T") + + +def _split_by(current: list[_T], func: Callable[[_T], bool]) -> tuple[list[_T], list[_T]]: + """Split an ordered list of items in two by the given predicate.""" + res: Final[tuple[list[_T], list[_T]]] = ([], []) + for stage in current: + if func(stage): + res[1].append(stage) + else: + res[0].append(stage) + return res + + +def select_stages(cfg: Config, all_stages: list[TestEnv]) -> list[list[TestEnv]]: + """Group the stages as specified.""" + + def process_stage( + acc: tuple[list[list[TestEnv]], list[TestEnv]], stage: Stage + ) -> tuple[list[list[TestEnv]], list[TestEnv]]: + """Stash the environments matched by a stage specification.""" + res, current = acc + if not current: + sys.exit(f"No test environments left for {stage.spec}") + left, matched = _split_by(current, stage.expr.evaluate) + if not matched: + sys.exit(f"No test environments matched by {stage.spec}") + res.append(matched) + return res, left + + res_init: Final[list[list[TestEnv]]] = [] + return functools.reduce(process_stage, cfg.stages, (res_init, list(all_stages)))[0] + + +def extract_cfg(ctx: click.Context) -> Config: + """Extract the Config object from the ConfigHolder.""" + cfg_hold: Final = ctx.find_object(ConfigHolder) + # mypy needs these assertions + assert cfg_hold is not None # noqa: S101 + cfg: Final = cfg_hold.cfg + assert cfg is not None # noqa: S101 + return cfg + + +def _find_and_load_pyproject(startdir: pathlib.Path) -> dict[str, Any]: + """Look for a pyproject.toml file, load it if found.""" + + def _find_and_load(path: pathlib.Path) -> dict[str, Any] | None: + """Check for a pyproject.toml file in the specified directory.""" + proj_file: Final = path / "pyproject.toml" + if not proj_file.is_file(): + return None + + return tomllib.loads(proj_file.read_text(encoding="UTF-8")) + + # Maybe we should look in the parent directories, too... later. + for path in (startdir,): + found = _find_and_load(path) + if found is not None: + return found + + # No pyproject.toml file found, nothing to parse + return {} + + +def click_available() -> Callable[[Callable[[Config], bool]], click.Command]: + """Wrap an available() function, checking whether the test runner can be invoked.""" + + def inner(handler: Callable[[Config], bool]) -> click.Command: + """Wrap the available check function.""" + + @click.command(name="available") + @click.pass_context + def real_available(ctx: click.Context) -> None: + """Check whether the test runner is available.""" + sys.exit(0 if handler(extract_cfg(ctx)) else 1) + + return real_available + + return inner + + +def click_run() -> Callable[[Callable[[Config, list[list[TestEnv]]], None]], click.Command]: + """Wrap a run() function, preparing the configuration.""" + + def inner(handler: Callable[[Config, list[list[TestEnv]]], None]) -> click.Command: + """Wrap the run function.""" + + @click.command(name="run") + @click.argument("stages_spec", nargs=-1, required=False, type=str) + @click.pass_context + def real_run(ctx: click.Context, stages_spec: list[str]) -> None: + """Run the test environments in stages.""" + cfg_base: Final = extract_cfg(ctx) + if not stages_spec: + pyproj: Final = _find_and_load_pyproject(cfg_base.filename.parent) + stages_spec = pyproj.get("tool", {}).get("test-stages", {}).get("stages", []) + if not stages_spec: + sys.exit("No stages specified either on the command line or in pyproject.toml") + + cfg: Final = dataclasses.replace( + cfg_base, + stages=[Stage(spec, parse.parse_spec(spec)) for spec in stages_spec], + ) + ctx.obj.cfg = cfg + + handler(cfg, select_stages(cfg, cfg.get_all_envs(cfg))) + + return real_run + + return inner + + +def click_main( + prog: str, + prog_help: str, + filename: str, + filename_help: str, + get_all_envs: Callable[[Config], list[TestEnv]], +) -> Callable[[Callable[[Config], Config]], click.Group]: + """Wrap a main() function, parsing the top-level options.""" + + def inner(main: Callable[[Config], Config]) -> click.Group: + """Wrap the main function.""" + + @click.group(name=prog, help=prog_help) + @click.option( + "-f", + "--filename", + type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=pathlib.Path), + default=filename, + help=filename_help, + ) + @click.pass_context + def real_main(ctx: click.Context, filename: pathlib.Path) -> None: + """Run Tox environments in groups, stop on failure.""" + ctx.ensure_object(ConfigHolder) + ctx.obj.cfg = main(Config(filename=filename, get_all_envs=get_all_envs)) + + return real_main + + return inner diff --git a/src/test_stages/py.typed b/src/test_stages/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/test_stages/py.typed diff --git a/src/test_stages/tox_stages/__init__.py b/src/test_stages/tox_stages/__init__.py new file mode 100644 index 0000000..451db5d --- /dev/null +++ b/src/test_stages/tox_stages/__init__.py @@ -0,0 +1,5 @@ +"""A `test-stages` implementation for the Tox test runner. + +This module contains the configuration parsing and runtime glue to +let Tox run its test environments grouped in stages. +""" diff --git a/src/test_stages/tox_stages/__main__.py b/src/test_stages/tox_stages/__main__.py new file mode 100644 index 0000000..1ac1587 --- /dev/null +++ b/src/test_stages/tox_stages/__main__.py @@ -0,0 +1,132 @@ +# Copyright (c) Peter Pentchev <roam@ringlet.net> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +"""The main tox-stages command-line executable.""" + +# This is a command-line tool, output is part of its job. +# flake8: noqa: T201 + +from __future__ import annotations + +import dataclasses +import pathlib +import subprocess +import sys + +from typing import Final + +import tox_trivtags + +from .. import cmd + +if tox_trivtags.HAVE_MOD_TOX_3: + from tox_trivtags import parse as ttt_parse + + +@dataclasses.dataclass(frozen=True) +class Config(cmd.Config): + """Also store the path to the Tox executable if found.""" + + tox_program: list[str | pathlib.Path] | None = None + + +@cmd.click_available() +def _cmd_available(cfg: cmd.Config) -> bool: + """Check whether we can parse the Tox configuration in any of the supported ways. + + Currently the only supported way is `tox --showconfig`. + """ + assert isinstance(cfg, Config) + return cfg.tox_program is not None + + +@cmd.click_run() +def _cmd_run(cfg: cmd.Config, stages: list[list[cmd.TestEnv]]) -> None: + """Run the Tox environments in groups.""" + toxdir = cfg.filename.parent + + def run_group(group: list[cmd.TestEnv]) -> None: + """Run the stages in a single group.""" + if not isinstance(cfg, Config) or cfg.tox_program is None: + # _tox_get_envs() really should have taken care of that + sys.exit(f"Internal error: tox-stages run_group: Config? {cfg!r}") + + names: Final = ",".join(env.name for env in group) + print(f"\n=== Running Tox environments: {names}\n") + res: Final = subprocess.run( + cfg.tox_program + ["-p", "all", "-e", names], + check=False, + cwd=toxdir, + env=cfg.utf8_env, + shell=False, + ) + if res.returncode != 0: + sys.exit(f"Tox failed for the {names} environments") + + for group in stages: + run_group(group) + + print("\n=== All Tox environment groups passed!") + + +def _tox_get_envs(cfg: cmd.Config) -> list[cmd.TestEnv]: + """Get all the Tox environments from the config file.""" + assert isinstance(cfg, Config) + if cfg.tox_program is None: + sys.exit("No tox program found or specified") + tcfg: Final = ttt_parse.parse_showconfig( + filename=cfg.filename, env=cfg.utf8_env, tox_invoke=cfg.tox_program + ) + return [cmd.TestEnv(name, env.tags) for name, env in tcfg.items()] + + +def _find_tox_program() -> list[str | pathlib.Path] | None: + """Figure out how to invoke Tox. + + For the present, only a Tox installation in the current Python interpreter's + package directories is supported, since we need to be sure that we can rely on + the `tox-trivtags` package being installed. + + Also, we only support Tox 3.x for the present. + """ + if not tox_trivtags.HAVE_MOD_TOX_3: + return None + + return [sys.executable, "-m", "tox"] + + +@cmd.click_main( + prog="tox-stages", + prog_help="Run Tox environments in groups, stop on failure.", + filename="tox.ini", + filename_help="the path to the Tox config file to parse", + get_all_envs=_tox_get_envs, +) +def main(cfg: cmd.Config) -> cmd.Config: + """Return our `Config` object with the path to Tox if found.""" + return Config(**dataclasses.asdict(cfg), tox_program=_find_tox_program()) + + +main.add_command(_cmd_available) +main.add_command(_cmd_run) diff --git a/src/tox_trivtags/__init__.py b/src/tox_trivtags/__init__.py new file mode 100644 index 0000000..c53e709 --- /dev/null +++ b/src/tox_trivtags/__init__.py @@ -0,0 +1,52 @@ +# Copyright (c) Peter Pentchev <roam@ringlet.net> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +"""Parse a list of tags in the Tox configuration. + +Inspired by https://github.com/tox-dev/tox-tags +""" + +import packaging.version +import pkg_resources + + +try: + HAVE_MOD_TOX_3 = ( + packaging.version.Version("3") + <= packaging.version.Version(pkg_resources.get_distribution("tox").version) + < packaging.version.Version("4") + ) +except pkg_resources.DistributionNotFound: + HAVE_MOD_TOX_3 = False + + +if HAVE_MOD_TOX_3: + import tox + import tox.config + + @tox.hookimpl + def tox_addoption(parser: tox.config.Parser) -> None: + """Parse a testenv's "tags" attribute as a list of lines.""" + parser.add_testenv_attribute( + "tags", "line-list", "A list of tags describing this test environment", default=[] + ) diff --git a/src/tox_trivtags/parse.py b/src/tox_trivtags/parse.py new file mode 100644 index 0000000..70488f3 --- /dev/null +++ b/src/tox_trivtags/parse.py @@ -0,0 +1,135 @@ +# Copyright (c) Peter Pentchev <roam@ringlet.net> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +"""Query Tox for the tags defined in the specified file.""" + +# mypy needs these assertions, and they are better expressed in a compact manner +# flake8: noqa: PT018 + +from __future__ import annotations + +import ast +import configparser +import pathlib +import subprocess +import sys + +from typing import Final, NamedTuple + +import tox.config + + +DEFAULT_FILENAME = pathlib.Path("tox.ini") + + +class TestenvTags(NamedTuple): + """A Tox environment along with its tags.""" + + cfg_name: str + name: str + tags: list[str] + + +def parse_config(filename: pathlib.Path = DEFAULT_FILENAME) -> dict[str, TestenvTags]: + """Use `tox.config.parseconfig()` to parse the Tox config file.""" + tox_cfg: Final = tox.config.parseconfig(["-c", str(filename)]) + return { + name: TestenvTags(cfg_name=f"testenv:{name}", name=name, tags=env.tags) + for name, env in tox_cfg.envconfigs.items() + } + + +def _validate_parsed_bool(value: ast.expr) -> bool: + """Make sure a boolean value is indeed a boolean value.""" + assert isinstance(value, ast.Constant) and isinstance(value.value, bool) + return value.value + + +def _validate_parsed_str(value: ast.expr) -> str: + """Make sure a string is indeed a string.""" + assert isinstance(value, ast.Constant) and isinstance(value.value, str) + return value.value + + +def _validate_parsed_strlist(value: ast.expr) -> list[str]: + """Make sure a list of strings is indeed a list of strings.""" + assert isinstance(value, ast.List) + return [_validate_parsed_str(value) for value in value.elts] + + +def _parse_bool(value: str) -> bool: + """Parse a Python-esque representation of a boolean value without eval().""" + a_body: Final = ast.parse(value).body + assert len(a_body) == 1 and isinstance(a_body[0], ast.Expr) + return _validate_parsed_bool(a_body[0].value) + + +def _parse_strlist(value: str) -> list[str]: + """Parse a Python-esque representation of a list of strings without eval().""" + a_body: Final = ast.parse(value).body + assert len(a_body) == 1 and isinstance(a_body[0], ast.Expr) + return _validate_parsed_strlist(a_body[0].value) + + +def remove_prefix(value: str, prefix: str) -> str: + """Remove a string's prefix if it is there. + + Will be replaced with str.removeprefix() once we can depend on Python 3.9+. + """ + parts: Final = value.partition(prefix) + return parts[2] if parts[1] and not parts[0] else value + + +def parse_showconfig( + filename: pathlib.Path = DEFAULT_FILENAME, + *, + env: dict[str, str] | None = None, + tox_invoke: list[str | pathlib.Path] | None = None, +) -> dict[str, TestenvTags]: + """Run `tox --showconfig` and look for tags in its output.""" + if tox_invoke is None: + tox_invoke = [sys.executable, "-u", "-m", "tox"] + contents: Final = subprocess.run( + tox_invoke + ["--showconfig", "-c", filename], + check=True, + encoding="UTF-8", + env=env, + shell=False, + stdout=subprocess.PIPE, + ).stdout + assert isinstance(contents, str) + + cfgp: Final = configparser.ConfigParser(interpolation=None) + cfgp.read_string(contents) + + return { + name: TestenvTags(cfg_name=cfg_name, name=name, tags=_parse_strlist(tags)) + for cfg_name, name, tags in ( + (cfg_name, name, env["tags"]) + for cfg_name, name, env in ( + (cfg_name, remove_prefix(cfg_name, "testenv:"), env) + for cfg_name, env in cfgp.items() + ) + if cfg_name != name + ) + } diff --git a/src/tox_trivtags/py.typed b/src/tox_trivtags/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/tox_trivtags/py.typed diff --git a/stubs/contextlib_chdir.pyi b/stubs/contextlib_chdir.pyi new file mode 100644 index 0000000..8bcaac5 --- /dev/null +++ b/stubs/contextlib_chdir.pyi @@ -0,0 +1,11 @@ +from _typeshed import StrOrBytesPath +from typing import Generic, TypeVar +from contextlib import AbstractContextManager + +_T_fd_or_any_path = TypeVar("_T_fd_or_any_path", bound=int | StrOrBytesPath) + +class chdir(AbstractContextManager[None], Generic[_T_fd_or_any_path]): + path: _T_fd_or_any_path + def __init__(self, path: _T_fd_or_any_path) -> None: ... + def __enter__(self) -> None: ... + def __exit__(self, *excinfo: object) -> None: ... diff --git a/stubs/tox/__init__.pyi b/stubs/tox/__init__.pyi new file mode 100644 index 0000000..b982201 --- /dev/null +++ b/stubs/tox/__init__.pyi @@ -0,0 +1,11 @@ +from collections.abc import Callable +from typing import TypeVar + +import tox.config + + +TParserHook = Callable[[tox.config.Parser], None] + + +# This only handles the parser hook right now. +def hookimpl(func: TParserHook) -> TParserHook: ... diff --git a/stubs/tox/config.pyi b/stubs/tox/config.pyi new file mode 100644 index 0000000..798feb7 --- /dev/null +++ b/stubs/tox/config.pyi @@ -0,0 +1,24 @@ +from collections.abc import Iterable +from typing import Any, Dict, List + + +class Parser: + def add_testenv_attribute( + self, + name: str, + type: str, + help: str, + default: Any = None, + postprocess: Any = None, + ) -> None: ... + + +class TestenvConfig: + tags: List[str] + + +class Config: + envconfigs: Dict[str, TestenvConfig] + + +def parseconfig(args: List[str], plugins: Iterable[str] = ()) -> Config: ... @@ -0,0 +1,186 @@ +[tox] +envlist = + ruff + black + pep8 + mypy + pylint + unit_tests-no-tox + unit_tests-tox-3 + unit_tests-tox-4 +isolated_build = True + +[defs] +pyfiles = + src/test_stages + src/tox_trivtags + unit_tests + +[testenv:ruff] +skip_install = True +tags = + check +deps = + ruff >= 0.0.243, < 0.1 +commands = + ruff -- {[defs]pyfiles} + +[testenv:ruff-all] +skip_install = True +tags = + check +deps = + ruff == 0.0.243 +commands = + ruff --config .config/ruff-all/pyproject.toml -- {[defs]pyfiles} + +[testenv:black] +skip_install = True +tags = + check +deps = + black >= 23, < 24 +commands = + black --check {[defs]pyfiles} + +[testenv:black-reformat] +skip_install = True +tags = + format +deps = + black >= 23, < 24 +commands = + black {[defs]pyfiles} + +[testenv:pep8] +skip_install = True +tags = + check +deps = + flake8 >= 6, < 7 + flake8-2020 >= 1, < 2 + flake8-annotations >= 3, < 4 + flake8-blind-except >= 0.2, < 0.3 + flake8-bugbear >= 23, < 24 + flake8-builtins >= 2, < 3 + flake8-commas >= 2, < 3 + flake8-comprehensions >= 3, < 4 + flake8-datetimez >= 20, < 21 + flake8-debugger >= 4, < 5 + flake8-executable >= 2, < 3 + flake8-implicit-str-concat >= 0.3, < 0.4 + flake8-no-pep420 >= 2, < 3 + flake8-pie >= 0.16, < 0.17 + flake8-print >= 5, < 6 + flake8-pytest-style >= 1, < 2 + flake8-quotes >= 3, < 4 + flake8-return >= 1, < 2 + flake8-simplify >= 0.19, < 0.20 + flake8-use-pathlib >= 0.3, < 0.4 + mccabe >= 0.7, < 0.8 + pep8-naming >= 0.13, < 0.14 + pycodestyle >= 2.10, < 3 +commands = + flake8 {[defs]pyfiles} + pycodestyle {[defs]pyfiles} + +[testenv:mypy] +skip_install = True +tags = + check +deps = + -r requirements/install.txt + -r requirements/test.txt + mypy >= 0.942 + tomli >= 2, < 3 + tox >= 3, < 4 + types-setuptools >= 20 +setenv = + MYPYPATH = {toxinidir}/stubs +commands = + mypy {[defs]pyfiles} + +[testenv:pylint] +skip_install = True +tags = + check +deps = + -r requirements/install.txt + -r requirements/test.txt + pylint >= 2.16, < 2.17 + tox >= 3, < 4 +commands = + pylint {[defs]pyfiles} + +[testenv:unit-tests-no-tox] +tags = + tests +deps = + -r requirements/install.txt + -r requirements/test.txt +allowlist_externals = + sh +commands = + tox-stages --help + sh -c 'if tox-stages available; then echo Waat; exit 1; else echo Not available; fi' + +[testenv:unit-tests-tox-3] +tags = + tests +deps = + -r requirements/install.txt + -r requirements/test.txt + tox >= 3, < 4 +commands = + tox-stages --help + tox-stages available + pytest {posargs} unit_tests + +[testenv:unit-tests-tox-4] +tags = + tests +deps = + -r requirements/install.txt + -r requirements/test.txt + tox >= 4, < 5 +allowlist_externals = + sh +commands = + tox-stages --help + sh -c 'if tox-stages available; then echo Waat; exit 1; else echo Not available; fi' + +# The pyupgrade tool does not seem to have a "check only" mode +[testenv:pyupgrade] +skip_install = True +tags = + check-later +deps = + pyupgrade >= 3, < 4 +allowlist_externals = + sh +commands = + sh -c 'pyupgrade --py38-plus src/test_stages/*.py src/test_stages/tox_stages/*.py src/tox_trivtags/*.py unit_tests/*.py' + +[testenv:t-single] +tags = + something +commands = + python3 -c 'raise NotImplementedError()' + +[testenv:t-several] +tags = + all + the + things +commands = + python3 -c 'raise NotImplementedError()' + +[testenv:t-special] +tags = + So, + how many + $tags + is "too many", + 'eh"? +commands = + python3 -c 'raise NotImplementedError()' diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000..867982a --- /dev/null +++ b/unit_tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the `test-stages` library and its runner implementations.""" diff --git a/unit_tests/test_functional.py b/unit_tests/test_functional.py new file mode 100644 index 0000000..f509e46 --- /dev/null +++ b/unit_tests/test_functional.py @@ -0,0 +1,113 @@ +# Copyright (c) Peter Pentchev <roam@ringlet.net> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +"""Load the Tox configuration, look for our tags thing.""" + +# This is a test suite. +# flake8: noqa: T201 + +from __future__ import annotations + +import contextlib +import pathlib +import sys +import tempfile + +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager +from typing import Final + +import pytest +import utf8_locale + +import tox_trivtags.parse as ttt_parse + +if sys.version_info >= (3, 11): + import contextlib as contextlib_chdir # pylint: disable=reimported +else: + import contextlib_chdir + +_EXPECTED: Final[dict[str, list[str]]] = { + "black": ["check"], + "black-reformat": ["format"], + "unit-tests-no-tox": ["tests"], + "unit-tests-tox-3": ["tests"], + "unit-tests-tox-4": ["tests"], + ".package": [], + "t-single": ["something"], + "t-several": ["all", "the", "things"], + "t-special": ["So,", "how many", "$tags", 'is "too many",', "'eh\"?"], +} + + +@contextlib.contextmanager +def _cfg_filename_cwd() -> Iterator[pathlib.Path]: + """No arguments, parse the tox.ini file in the current directory.""" + yield pathlib.Path("tox.ini") + + +@contextlib.contextmanager +def _cfg_filename_tempdir() -> Iterator[pathlib.Path]: + """Create a temporary directory, enter it, pass `-c` with the original cwd.""" + cwd: Final = pathlib.Path("").absolute() + with tempfile.TemporaryDirectory() as tempd: + print(f"Temporary directory: {tempd}; current directory: {cwd}") + with contextlib_chdir.chdir(tempd): + yield cwd / "tox.ini" + + +def _do_test_run_showconfig(filename: pathlib.Path) -> None: + """Parse the `tox --showconfig` output.""" + u8env: Final = utf8_locale.UTF8Detect().detect().env + print(f"Using {u8env['LC_ALL']} as a UTF-8-capable locale") + + envs: Final = ttt_parse.parse_showconfig(filename, env=u8env) + print(f"Got some Tox config sections: {' '.join(sorted(envs))}") + for envname, expected in _EXPECTED.items(): + print(f"- envname {envname!r} expected {expected!r}") + assert envs[envname].tags == expected + + +@pytest.mark.parametrize("cfg_filename", [_cfg_filename_cwd, _cfg_filename_tempdir]) +def test_run_showconfig(cfg_filename: Callable[[], AbstractContextManager[pathlib.Path]]) -> None: + """Run `tox --showconfig` expecting tox.ini to be in the specified directory.""" + print() + with cfg_filename() as filename: + _do_test_run_showconfig(filename) + + +def _do_test_call_tox_config(filename: pathlib.Path) -> None: + """Invoke tox.config.Config() to parse the Tox configuration.""" + envs: Final = ttt_parse.parse_config(filename) + print(f"Got some Tox environments: {' '.join(sorted(envs))}") + for envname, expected in _EXPECTED.items(): + print(f"- envname {envname!r} expected {expected!r}") + assert envs[envname].tags == expected + + +@pytest.mark.parametrize("cfg_filename", [_cfg_filename_cwd, _cfg_filename_tempdir]) +def test_call_tox_config(cfg_filename: Callable[[], AbstractContextManager[pathlib.Path]]) -> None: + """Parse the tox.ini file in the specified directory.""" + print() + with cfg_filename() as filename: + _do_test_call_tox_config(filename) |