summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Pentchev <roam@debian.org>2023-02-08 08:49:47 +0200
committerPeter Pentchev <roam@debian.org>2023-02-08 08:49:47 +0200
commit6ca0d0d63be0e08f2ae0cecd36b18a3a1f1ab8e0 (patch)
treeb14d5b96618813742de09c443c7b291ffde2242e
New upstream version 0.1.1
-rw-r--r--.config/ruff-all/pyproject.toml3
-rw-r--r--.config/ruff-base/pyproject.toml32
-rw-r--r--.editorconfig36
-rw-r--r--CHANGELOG.md25
-rw-r--r--MANIFEST.in7
-rw-r--r--PKG-INFO81
-rw-r--r--README.md47
-rw-r--r--pyproject.toml116
-rw-r--r--requirements/install.txt8
-rw-r--r--requirements/test.txt1
-rw-r--r--setup.cfg12
-rw-r--r--src/test_stages.egg-info/PKG-INFO81
-rw-r--r--src/test_stages.egg-info/SOURCES.txt31
-rw-r--r--src/test_stages.egg-info/dependency_links.txt1
-rw-r--r--src/test_stages.egg-info/entry_points.txt5
-rw-r--r--src/test_stages.egg-info/requires.txt13
-rw-r--r--src/test_stages.egg-info/top_level.txt2
-rw-r--r--src/test_stages.egg-info/zip-safe1
-rw-r--r--src/test_stages/__init__.py27
-rw-r--r--src/test_stages/cmd.py218
-rw-r--r--src/test_stages/py.typed0
-rw-r--r--src/test_stages/tox_stages/__init__.py5
-rw-r--r--src/test_stages/tox_stages/__main__.py132
-rw-r--r--src/tox_trivtags/__init__.py52
-rw-r--r--src/tox_trivtags/parse.py135
-rw-r--r--src/tox_trivtags/py.typed0
-rw-r--r--stubs/contextlib_chdir.pyi11
-rw-r--r--stubs/tox/__init__.pyi11
-rw-r--r--stubs/tox/config.pyi24
-rw-r--r--tox.ini186
-rw-r--r--unit_tests/__init__.py1
-rw-r--r--unit_tests/test_functional.py113
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: ...
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..807c9f3
--- /dev/null
+++ b/tox.ini
@@ -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)