From eb5e5cae263361cbf603791851f5abc12f619726 Mon Sep 17 00:00:00 2001 From: Peter Pentchev Date: Fri, 9 Feb 2024 19:31:06 +0200 Subject: New upstream version 0.1.6 --- PKG-INFO | 3 +- config/ruff-all/pyproject.toml | 4 +- config/ruff-base/pyproject.toml | 16 +- docs/changes.md | 26 +++- docs/cmd/tox-stages.md | 13 +- docs/download.md | 13 ++ docs/man/tox-stages.1 | 13 ++ nix/cleanpy.sh | 17 +++ nix/mkdocs.nix | 27 ++++ nix/python-vetox.nix | 21 +++ nix/reformat.sh | 10 ++ nix/run-vetox.sh | 17 +++ pyproject.toml | 7 +- requirements/docs.txt | 2 +- requirements/ruff.txt | 2 +- src/selftest/__main__.py | 36 ++++- src/test_stages/__init__.py | 2 +- src/test_stages/cmd.py | 33 ++++- src/test_stages/tox_stages/__main__.py | 4 +- src/tox_trivtags/__init__.py | 5 +- tests/unit/__init__.py | 3 + tests/unit/test_functional.py | 113 ++++++++++++++ tests/vetox.py | 263 +++++++++++++++++++++++++++++++++ tox.ini | 8 +- unit_tests/__init__.py | 3 - unit_tests/test_functional.py | 113 -------------- 26 files changed, 625 insertions(+), 149 deletions(-) create mode 100755 nix/cleanpy.sh create mode 100644 nix/mkdocs.nix create mode 100644 nix/python-vetox.nix create mode 100755 nix/reformat.sh create mode 100755 nix/run-vetox.sh create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_functional.py create mode 100644 tests/vetox.py delete mode 100644 unit_tests/__init__.py delete mode 100644 unit_tests/test_functional.py diff --git a/PKG-INFO b/PKG-INFO index f12d7b4..d8c9d48 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: test_stages -Version: 0.1.5 +Version: 0.1.6 Summary: Group Tox, Nox, etc environments into stages, run them in parallel Project-URL: Homepage, https://devel.ringlet.net/devel/test-stages Project-URL: Changes, https://devel.ringlet.net/devel/test-stages/changes/ @@ -26,6 +26,7 @@ Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Software Development Classifier: Topic :: Software Development :: Testing Classifier: Topic :: Software Development :: Testing :: Unit diff --git a/config/ruff-all/pyproject.toml b/config/ruff-all/pyproject.toml index 3463fb8..32a941b 100644 --- a/config/ruff-all/pyproject.toml +++ b/config/ruff-all/pyproject.toml @@ -3,5 +3,7 @@ [tool.ruff] extend = "../ruff-base/pyproject.toml" -select = ["ALL"] preview = true + +[tool.ruff.lint] +select = ["ALL"] diff --git a/config/ruff-base/pyproject.toml b/config/ruff-base/pyproject.toml index bd952ab..f3db562 100644 --- a/config/ruff-base/pyproject.toml +++ b/config/ruff-base/pyproject.toml @@ -4,14 +4,10 @@ [tool.ruff] target-version = "py38" line-length = 100 + +[tool.ruff.lint] 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", @@ -19,16 +15,16 @@ ignore = [ "D213", ] -[tool.ruff.flake8-copyright] +[tool.ruff.lint.flake8-copyright] notice-rgx = "(?x) SPDX-FileCopyrightText: \\s \\S" -[tool.ruff.isort] +[tool.ruff.lint.isort] force-single-line = true known-first-party = ["test_stages", "tox_trivtags"] lines-after-imports = 2 single-line-exclusions = ["collections.abc", "typing"] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] # The self-test tool uses subprocess responsibly. "src/selftest/__main__.py" = ["S404", "S603", "S607"] @@ -38,4 +34,4 @@ single-line-exclusions = ["collections.abc", "typing"] # This is a unit test suite, it can output diagnostic messages. # Also, we try to use subprocess responsibly. -"unit_tests/**.py" = ["S101", "S404", "S603", "S607", "T201"] +"tests/unit/**.py" = ["S101", "S404", "S603", "S607", "T201"] diff --git a/docs/changes.md b/docs/changes.md index b2867e4..23c2b99 100644 --- a/docs/changes.md +++ b/docs/changes.md @@ -12,6 +12,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.6] - 2024-02-08 + +### Additions + +- add the `--match-spec` / `-m` command-line option to further limit + the Tox environments that will be run +- add a Nix expression that builds the documentation +- tentatively declare Python 3.13 as supported +- testing framework: + - vendor-import [the vetox testing tool](https://devel.ringlet.net/devel/vetox/) + - add a Nix expression that runs `vetox` + +### Other changes + +- documentation: + - use mkdocstrings 0.24 with no changes +- testing framework: + - use Ruff 0.2.1: + - push some Ruff configuration settings into the `ruff.lint.*` hierarchy + - let Ruff insist on trailing commas, reformat the source files accordingly + - push the unit tests into the `tests/unit/` directory + - put the Tox stage specifications in the `pyproject.toml` file on separate lines + ## [0.1.5] - 2024-01-19 ### Fixes @@ -215,7 +238,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [ringlet-test-stages]: https://devel.ringlet.net/devel/test-stages/ "The Ringlet test-stages homepage" [tool-mkdocs]: https://www.mkdocs.org/ "Project documentation with Markdown" -[Unreleased]: https://gitlab.com/ppentchev/test-stages/-/compare/release%2F0.1.5...main +[Unreleased]: https://gitlab.com/ppentchev/test-stages/-/compare/release%2F0.1.6...main +[0.1.6]: https://gitlab.com/ppentchev/test-stages/-/compare/release%2F0.1.5...release%2F0.1.6 [0.1.5]: https://gitlab.com/ppentchev/test-stages/-/compare/release%2F0.1.4...release%2F0.1.5 [0.1.4]: https://gitlab.com/ppentchev/test-stages/-/compare/release%2F0.1.3...release%2F0.1.4 [0.1.3]: https://gitlab.com/ppentchev/test-stages/-/compare/release%2F0.1.2...release%2F0.1.3 diff --git a/docs/cmd/tox-stages.md b/docs/cmd/tox-stages.md index c98bdea..0603188 100644 --- a/docs/cmd/tox-stages.md +++ b/docs/cmd/tox-stages.md @@ -9,7 +9,7 @@ SPDX-License-Identifier: BSD-2-Clause ``` sh tox-stages [-f filename] available -tox-stages [-f filename] run [-A arg...] [-p spec] stage... +tox-stages [-f filename] run [-A arg...] [-m match_spec] [-p spec] stage... ``` ## Description @@ -64,6 +64,9 @@ The `run` subcommand accepts the following options: Pass an additional command-line argument to each Tox invocation. This option may be specified more than once, and the arguments will be passed in the order given. +- `--match-spec spec` / `-m spec`
+ Pass an additional specification for Tox environments to satisfy, + e.g. `-m '@check'` to only run static checkers and not unit tests. - `--parallel spec` / `-p spec`
Specify which stages to run in parallel. The `spec` parameter is a list of stage indices (1, 2, etc.) or @@ -97,6 +100,14 @@ Run all the stages as defined in the `pyproject.toml` file's tox-stages run ``` +Group Tox environments into stages as defined in the `pyproject.toml` file, +but then only run the ones marked with the "check" tag that also have +names containing the string "format": + +``` sh +tox-stages run -m '@check and format' +``` + Run a specific set of stages, passing `-- -k slug` as additional Tox arguments so that e.g. a `pytest` environment that uses the Tox `{posargs}` variable may only run a selected subset of tests: diff --git a/docs/download.md b/docs/download.md index 8e50175..4d990e6 100644 --- a/docs/download.md +++ b/docs/download.md @@ -7,6 +7,18 @@ SPDX-License-Identifier: BSD-2-Clause These are the released versions of [test-stages](index.md) available for download. +## [0.1.6] - 2024-01-19 + +### Source tarball + +- [test_stages-0.1.6.tar.gz](https://devel.ringlet.net/files/devel/test-stages/test_stages-0.1.6.tar.gz) + (with [a PGP signature](https://devel.ringlet.net/files/devel/test-stages/test_stages-0.1.6.tar.gz.asc)) + +### Python wheel + +- [test_stages-0.1.6-py3-none-any.whl](https://devel.ringlet.net/files/devel/test-stages/test_stages-0.1.6-py3-none-any.whl) + (with [a PGP signature](https://devel.ringlet.net/files/devel/test-stages/test_stages-0.1.6-py3-none-any.whl.asc)) + ## [0.1.5] - 2024-01-19 ### Source tarball @@ -31,5 +43,6 @@ These are the released versions of [test-stages](index.md) available for downloa - [test_stages-0.1.4-py3-none-any.whl](https://devel.ringlet.net/files/devel/test-stages/test_stages-0.1.4-py3-none-any.whl) (with [a PGP signature](https://devel.ringlet.net/files/devel/test-stages/test_stages-0.1.4-py3-none-any.whl.asc)) +[0.1.6]: https://gitlab.com/ppentchev/test-stages/-/tags/release%2F0.1.6 [0.1.5]: https://gitlab.com/ppentchev/test-stages/-/tags/release%2F0.1.5 [0.1.4]: https://gitlab.com/ppentchev/test-stages/-/tags/release%2F0.1.4 diff --git a/docs/man/tox-stages.1 b/docs/man/tox-stages.1 index 8e05bb7..8b04815 100644 --- a/docs/man/tox-stages.1 +++ b/docs/man/tox-stages.1 @@ -14,6 +14,7 @@ .Op Fl f Ar filename .Cm run .Op Fl Fl arg Ar arg... | Fl A Ar arg... +.Op Fl Fl match\-spec Ar spec | Fl m Ar spec .Op Fl Fl parallel Ar spec | Fl p Ar spec .Op Ar stage... .Sh DESCRIPTION @@ -83,6 +84,11 @@ subcommand accepts the following options: Pass an additional command-line argument to each Tox invocation. This option may be specified more than once, and the arguments will be passed in the order given. +.It Fl Fl match\-spec Ar spec | Fl m Ar spec +Pass an additional specification for Tox environments to satisfy, +e.g. +.Dq -m '@check' +to only run static checkers and not unit tests. .It Fl Fl parallel Ar spec | Fl p Ar spec Specify which stages to run in parallel. The @@ -136,6 +142,13 @@ parameter: .Pp .Dl tox-stages run .Pp +Group Tox environments into stages as defined in the +.Pa pyproject.toml +file, but then only run the ones marked with the "check" tag that also have +names containing the string "format": +.Pp +.Dl tox-stages run -m '@check and format' +.Pp Run a specific set of stages, passing .Ar -- -k slug as additional diff --git a/nix/cleanpy.sh b/nix/cleanpy.sh new file mode 100755 index 0000000..7a5490b --- /dev/null +++ b/nix/cleanpy.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# +# SPDX-FileCopyrightText: Peter Pentchev +# SPDX-License-Identifier: BSD-2-Clause + +set -e + +find . -mindepth 1 -maxdepth 1 -type d \( \ + -name '.tox' \ + -or -name '.mypy_cache' \ + -or -name '.pytest_cache' \ + -or -name '.nox' \ + -or -name '.ruff_cache' \ +\) -exec rm -rf -- '{}' + +find . -type d -name '__pycache__' -exec rm -rfv -- '{}' + +find . -type f -name '*.pyc' -delete -print +find . -mindepth 1 -maxdepth 2 -type d -name '*.egg-info' -exec rm -rfv -- '{}' + diff --git a/nix/mkdocs.nix b/nix/mkdocs.nix new file mode 100644 index 0000000..b445ea3 --- /dev/null +++ b/nix/mkdocs.nix @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Peter Pentchev +# SPDX-License-Identifier: BSD-2-Clause + +{ pkgs ? import { } +, py-ver ? 311 +}: +let + python-name = "python${toString py-ver}"; + python = builtins.getAttr python-name pkgs; + python-pkgs = python.withPackages (p: with p; + [ + mkdocs + mkdocs-material + mkdocstrings + mkdocstrings-python + ] + ); +in +pkgs.mkShell { + buildInputs = [ python-pkgs ]; + shellHook = '' + set -e + rm -rf site + mkdocs build + exit + ''; +} diff --git a/nix/python-vetox.nix b/nix/python-vetox.nix new file mode 100644 index 0000000..9b1b648 --- /dev/null +++ b/nix/python-vetox.nix @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Peter Pentchev +# SPDX-License-Identifier: BSD-2-Clause + +{ pkgs ? import { } +, py-ver ? 311 +}: +let + python-name = "python${toString py-ver}"; + python = builtins.getAttr python-name pkgs; +in +pkgs.mkShell { + buildInputs = [ + pkgs.gitMinimal + python + ]; + shellHook = '' + set -e + python3 tests/vetox.py run-parallel + exit + ''; +} diff --git a/nix/reformat.sh b/nix/reformat.sh new file mode 100755 index 0000000..83bd84d --- /dev/null +++ b/nix/reformat.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# +# SPDX-FileCopyrightText: Peter Pentchev +# SPDX-License-Identifier: BSD-2-Clause + +set -e + +script_path="$(readlink -f -- "$0")" +nix_dir="$(dirname -- "$script_path")" +nix-shell --pure -p nixpkgs-fmt --run "nixpkgs-fmt '$nix_dir'/*.nix" diff --git a/nix/run-vetox.sh b/nix/run-vetox.sh new file mode 100755 index 0000000..6b4d93e --- /dev/null +++ b/nix/run-vetox.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# +# SPDX-FileCopyrightText: Peter Pentchev +# SPDX-License-Identifier: BSD-2-Clause + +set -e + +: "${PY_MINVER_MIN:=8}" +: "${PY_MINVER_MAX:=13}" + +for minor in $(seq -- "$PY_MINVER_MIN" "$PY_MINVER_MAX"); do + pyver="3$minor" + nix/cleanpy.sh + printf -- '\n===== Running tests for %s\n\n\n' "$pyver" + nix-shell --pure --arg py-ver "$pyver" nix/python-vetox.nix + printf -- '\n===== Done with %s\n\n' "$pyver" +done diff --git a/pyproject.toml b/pyproject.toml index b3e5111..58a1095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development", "Topic :: Software Development :: Testing", "Topic :: Software Development :: Testing :: Unit", @@ -73,4 +74,8 @@ path = "src/test_stages/__init__.py" strict = true [tool.test-stages] -stages = ["@check and @quick and not @manual", "@check and not @manual", "@tests and not @manual"] +stages = [ + "@check and @quick and not @manual", + "@check and not @manual", + "@tests and not @manual", +] diff --git a/requirements/docs.txt b/requirements/docs.txt index e961640..d94c288 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -3,5 +3,5 @@ mkdocs >= 1.4.2, < 2 mkdocs-material >= 9.1.2, < 10 -mkdocstrings >= 0.23, < 0.24 +mkdocstrings >= 0.24, < 0.25 mkdocstrings-python >= 1, < 2 diff --git a/requirements/ruff.txt b/requirements/ruff.txt index 4639591..7ed062c 100644 --- a/requirements/ruff.txt +++ b/requirements/ruff.txt @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause -ruff == 0.1.13 +ruff == 0.2.1 diff --git a/src/selftest/__main__.py b/src/selftest/__main__.py index f16a433..f0e9a6a 100644 --- a/src/selftest/__main__.py +++ b/src/selftest/__main__.py @@ -35,7 +35,7 @@ def validate_srcdir(srcdir: pathlib.Path) -> None: for relpath in ( "requirements/install.txt", "src/test_stages/tox_stages/__main__.py", - "unit_tests/test_functional.py", + "tests/unit/test_functional.py", ): path = srcdir / relpath if not path.is_file(): @@ -74,7 +74,7 @@ def safe_extract_all(star: tarfile.TarFile, topdir: pathlib.Path) -> None: if bad_paths: sys.exit( f"Bad paths in the source archive, expected all of them to " - f"start with {base_path}: {bad_paths}" + f"start with {base_path}: {bad_paths}", ) bad_dirs: Final = [path for path in paths if ".." in path.parts] @@ -145,21 +145,47 @@ def run_tox(testdir: pathlib.Path) -> None: if marker.is_symlink() or marker.exists(): sys.exit(f"A `--notest` run still created {marker}") + subprocess.check_call( + ["python3", "-m", "test_stages.tox_stages", "run", "(@docs or not @manual) and @selftest"], + cwd=testdir, + env=env, + ) + if not marker.is_file(): + sys.exit(f"`tox-stages run (@docs or not @manual) and @selftest` did not create {marker}") + + marker.unlink() + subprocess.check_call( + ["python3", "-m", "test_stages.tox_stages", "run", "-m", "@selftest", "not @manual"], + cwd=testdir, + env=env, + ) + if not marker.is_file(): + sys.exit(f"`tox-stages run -m @selftest not @manual` did not create {marker}") + utf8_env = dict(env) utf8_env.update(utf8_locale.UTF8Detect().detect().env_vars) blurb = "import pathlib" if blurb in subprocess.check_output( - ["tox-stages", "run", "@selftest"], cwd=testdir, encoding="UTF-8", env=utf8_env + ["tox-stages", "run", "@selftest"], + cwd=testdir, + encoding="UTF-8", + env=utf8_env, ): sys.exit("A run without any -p option output {blurb!r}") if blurb in subprocess.check_output( - ["tox-stages", "run", "@selftest", "-p", "1"], cwd=testdir, encoding="UTF-8", env=utf8_env + ["tox-stages", "run", "@selftest", "-p", "1"], + cwd=testdir, + encoding="UTF-8", + env=utf8_env, ): sys.exit("A `-p 1` run did not output {blurb!r}") if blurb not in subprocess.check_output( - ["tox-stages", "run", "@selftest", "-p", "7"], cwd=testdir, encoding="UTF-8", env=utf8_env + ["tox-stages", "run", "@selftest", "-p", "7"], + cwd=testdir, + encoding="UTF-8", + env=utf8_env, ): sys.exit("A `-p 7` run output {blurb!r}") diff --git a/src/test_stages/__init__.py b/src/test_stages/__init__.py index 9a4543d..1534e84 100644 --- a/src/test_stages/__init__.py +++ b/src/test_stages/__init__.py @@ -2,4 +2,4 @@ # SPDX-License-Identifier: BSD-2-Clause """Run `tox` on several groups of environments, stopping on errors.""" -VERSION = "0.1.5" +VERSION = "0.1.6" diff --git a/src/test_stages/cmd.py b/src/test_stages/cmd.py index 68e9723..4c90cdb 100644 --- a/src/test_stages/cmd.py +++ b/src/test_stages/cmd.py @@ -65,9 +65,10 @@ class Config: filename: pathlib.Path get_all_envs: Callable[[Config], list[TestEnv]] + match_spec: parse.BoolExpr | None = None stages: list[Stage] = dataclasses.field(default_factory=list) utf8_env: dict[str, str] = dataclasses.field( - default_factory=lambda: utf8_locale.UTF8Detect().detect().env + default_factory=lambda: utf8_locale.UTF8Detect().detect().env, ) @@ -93,7 +94,8 @@ def select_stages(cfg: Config, all_stages: list[TestEnv]) -> list[TestStage]: """Group the stages as specified.""" def process_stage( - acc: tuple[list[TestStage], list[TestEnv]], stage: Stage + acc: tuple[list[TestStage], list[TestEnv]], + stage: Stage, ) -> tuple[list[TestStage], list[TestEnv]]: """Stash the environments matched by a stage specification.""" res, current = acc @@ -106,7 +108,19 @@ def select_stages(cfg: Config, all_stages: list[TestEnv]) -> list[TestStage]: return res, left res_init: Final[list[TestStage]] = [] - return functools.reduce(process_stage, cfg.stages, (res_init, list(all_stages)))[0] + selected: Final = functools.reduce(process_stage, cfg.stages, (res_init, list(all_stages)))[0] + match_spec: Final = cfg.match_spec + if match_spec is None: + return selected + + matched_all: Final = [ + stage._replace(envlist=[env for env in stage.envlist if match_spec.evaluate(env)]) + for stage in selected + ] + matched: Final = [stage for stage in matched_all if stage.envlist] + if not matched: + sys.exit("None of the selected environments satisfied the additional match condition") + return matched def extract_cfg(ctx: click.Context) -> Config: @@ -174,6 +188,12 @@ def click_run() -> Callable[[Callable[[Config, list[TestStage], list[str]], None "may be specified multiple times" ), ) + @click.option( + "-m", + "--match-spec", + type=str, + help="additional stage specifications for the tests to run", + ) @click.option( "-p", "--parallel", @@ -183,7 +203,11 @@ def click_run() -> Callable[[Callable[[Config, list[TestStage], list[str]], None @click.argument("stages_spec", nargs=-1, required=False, type=str) @click.pass_context def real_run( - ctx: click.Context, arg: list[str], parallel: StagesList | None, stages_spec: list[str] + ctx: click.Context, + arg: list[str], + match_spec: str | None, + parallel: StagesList | None, + stages_spec: list[str], ) -> None: """Run the test environments in stages.""" cfg_base: Final = extract_cfg(ctx) @@ -196,6 +220,7 @@ def click_run() -> Callable[[Callable[[Config, list[TestStage], list[str]], None pstages: Final = set(range(len(stages_spec))) if parallel is None else parallel.stages cfg: Final = dataclasses.replace( cfg_base, + match_spec=parse.parse_spec(match_spec) if match_spec is not None else None, stages=[ Stage(spec, parse.parse_spec(spec), idx in pstages) for idx, spec in enumerate(stages_spec) diff --git a/src/test_stages/tox_stages/__main__.py b/src/test_stages/tox_stages/__main__.py index ec53985..e30c04f 100644 --- a/src/test_stages/tox_stages/__main__.py +++ b/src/test_stages/tox_stages/__main__.py @@ -79,7 +79,9 @@ def _tox_get_envs(cfg: cmd.Config) -> list[cmd.TestEnv]: 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 + filename=cfg.filename, + env=cfg.utf8_env, + tox_invoke=cfg.tox_program, ) return [cmd.TestEnv(name, env.tags) for name, env in tcfg.items()] diff --git a/src/tox_trivtags/__init__.py b/src/tox_trivtags/__init__.py index e0e54e5..0b74e48 100644 --- a/src/tox_trivtags/__init__.py +++ b/src/tox_trivtags/__init__.py @@ -46,7 +46,10 @@ if HAVE_MOD_TOX_3: 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=[] + "tags", + "line-list", + "A list of tags describing this test environment", + default=[], ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..f32c1ef --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: Peter Pentchev +# SPDX-License-Identifier: BSD-2-Clause +"""Unit tests for the `test-stages` library and its runner implementations.""" diff --git a/tests/unit/test_functional.py b/tests/unit/test_functional.py new file mode 100644 index 0000000..2ea478e --- /dev/null +++ b/tests/unit/test_functional.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: Peter Pentchev +# SPDX-License-Identifier: BSD-2-Clause +"""Load the Tox configuration, look for our tags thing.""" + +from __future__ import annotations + +import contextlib +import pathlib +import shutil +import subprocess +import sys +import tempfile +from typing import TYPE_CHECKING + +import pytest +import utf8_locale + +import tox_trivtags +import tox_trivtags.parse as ttt_parse + + +if sys.version_info >= (3, 11): + import contextlib as contextlib_chdir +else: + import contextlib_chdir + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + from contextlib import AbstractContextManager + from typing import Final + + +_EXPECTED: Final[dict[str, list[str]]] = { + "format": ["check", "quick"], + "reformat": ["format", "manual"], + "unit-tests-no-tox": ["tests"], + "unit-tests-tox-3": ["tests"], + "unit-tests-tox-4": ["tests"], + (".package" if tox_trivtags.HAVE_MOD_TOX_3 else ".pkg"): [], + "t-single": ["something"], + "t-several": ["all", "the", "things"], + "t-special": ["So,", "how many", "$tags", 'is "too many",', "'eh\"?"], +} + + +def copy_and_adapt(srcdir: pathlib.Path, dstdir: pathlib.Path) -> None: + """Copy some files over, adapt the tox.ini file.""" + src_tox = srcdir / "tox.ini" + dst_tox = dstdir / "tox.ini" + lines: Final = src_tox.read_text(encoding="UTF-8").splitlines() + adapted: Final = [ + line.replace(" \\", "") for line in lines if not line.startswith("minversion") + ] + dst_tox.write_text("".join(line + "\n" for line in adapted), encoding="UTF-8") + res: Final = subprocess.run(["diff", "-u", "--", src_tox, dst_tox], check=False) + assert res.returncode != 0 + + shutil.copytree(srcdir / "config", dstdir / "config") + shutil.copytree(srcdir / "requirements", dstdir / "requirements") + shutil.copy2(srcdir / "pyproject.toml", dstdir / "pyproject.toml") + + +@contextlib.contextmanager +def adapt_tox_cwd() -> Iterator[pathlib.Path]: + """If using Tox 3.x, copy the files over and adapt them.""" + cwd: Final = pathlib.Path().absolute() + if not tox_trivtags.HAVE_MOD_TOX_3: + print("Not using Tox 3.x, no need to copy or adapt any files") + yield cwd + return + + with tempfile.TemporaryDirectory() as toxd_name: + toxd = pathlib.Path(toxd_name).absolute() + print(f"Adapting files for Tox 3.x from {cwd} to {toxd}") + copy_and_adapt(cwd, toxd) + with contextlib_chdir.chdir(toxd): + yield toxd + + +@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 adapt_tox_cwd(), cfg_filename() as filename: + _do_test_run_showconfig(filename) diff --git a/tests/vetox.py b/tests/vetox.py new file mode 100644 index 0000000..11b4a86 --- /dev/null +++ b/tests/vetox.py @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: Peter Pentchev +# SPDX-License-Identifier: BSD-2-Clause +"""Create a virtual environment, install Tox, run it.""" + +from __future__ import annotations + +import argparse +import configparser +import dataclasses +import functools +import json +import logging +import pathlib +import shlex +import subprocess +import sys +import tempfile +import typing +import venv + + +if typing.TYPE_CHECKING: + from collections.abc import Callable + from typing import Final + + +VERSION: Final = "0.1.2" +"""The vetox library version.""" + + +TOX_MIN_VERSION: Final = "4.1" +"""The minimum version of Tox needed to run our tests.""" + + +@dataclasses.dataclass(frozen=True) +class Config: + """Runtime configuration for the venv-tox tool.""" + + conf: pathlib.Path + """The path to the `tox.ini` file to use.""" + + log: logging.Logger + """The logger to send diagnostic, informational, warning, and error messages to.""" + + tempd: pathlib.Path + """The temporary directory to operate in.""" + + tox_req: str | None + """The PEP508 version requirements for Tox itself if specified.""" + + +# Shamelessly stolen from the logging-std module +@functools.lru_cache +def build_logger() -> logging.Logger: + """Build a logger object, send info messages to stdout, everything else to stderr.""" + logger: Final = logging.getLogger("vetox") + logger.setLevel(logging.DEBUG) + logger.propagate = False + + h_out: Final = logging.StreamHandler(sys.stdout) + h_out.setLevel(logging.INFO) + h_out.addFilter(lambda rec: rec.levelno == logging.INFO) + logger.addHandler(h_out) + + h_err: Final = logging.StreamHandler(sys.stderr) + h_err.setLevel(logging.INFO) + h_err.addFilter(lambda rec: rec.levelno != logging.INFO) + logger.addHandler(h_err) + + return logger + + +def create_and_update_venv(cfg: Config) -> pathlib.Path: + """Create a virtual environment, update all the packages within.""" + penv: pathlib.Path = cfg.tempd / "venv" + cfg.log.info("About to create the %(penv)s virtual environment", {"penv": penv}) + if sys.version_info >= (3, 9): + cfg.log.info("- using venv.create(upgrade_deps) directly") + venv.create(penv, with_pip=True, upgrade_deps=True) + return penv + + cfg.log.info("- no venv.create(upgrade_deps)") + venv.create(penv, with_pip=True) + + cfg.log.info("- obtaining the list of packages in the virtual environment") + contents: Final = subprocess.check_output( + [penv / "bin/python3", "-m", "pip", "list", "--format=json"], + encoding="UTF-8", + ) + pkgs: Final = json.loads(contents) + if ( + not isinstance(pkgs, list) + or not pkgs + or not all(isinstance(pkg, dict) and "name" in pkg for pkg in pkgs) + ): + sys.exit(f"Unexpected `pip list --format=json` output: {pkgs!r}") + + names: Final = sorted(pkg["name"] for pkg in pkgs) + cfg.log.info( + "- upgrading the %(names)s package%(plu)s in the virtual environment", + {"names": ", ".join(names), "plu": "" if len(names) == 1 else "s"}, + ) + subprocess.check_call([penv / "bin/python3", "-m", "pip", "install", "-U", "--", *names]) + return penv + + +@functools.lru_cache +def get_tox_min_version(cfg: Config) -> str: + """Look for a minimum Tox version in the tox.ini file, fall back to TOX_MIN_VERSION.""" + cfgp: Final = configparser.ConfigParser(interpolation=None) + with cfg.conf.open(encoding="UTF-8") as tox_ini: + cfgp.read_file(tox_ini) + + return cfgp["tox"].get("min_version", cfgp["tox"].get("minversion", TOX_MIN_VERSION)) + + +def install_tox(cfg: Config, penv: pathlib.Path) -> None: + """Install Tox into the virtual environment.""" + if cfg.tox_req is not None: + tox_req = f"tox {cfg.tox_req}" + else: + minver: Final = get_tox_min_version(cfg) + tox_req = f"tox >= {minver}" + + cfg.log.info("Installing Tox %(tox_req)s", {"tox_req": tox_req}) + subprocess.check_call([penv / "bin/python3", "-m", "pip", "install", tox_req]) + + +def get_tox_cmdline( + cfg: Config, + penv: pathlib.Path, + *, + parallel: bool = True, + args: list[str], +) -> list[pathlib.Path | str]: + """Get the Tox command with arguments.""" + penv_py3: Final = penv / "bin/python3" + + def get_run_command() -> list[str]: + """Get the appropriate command to run Tox in parallel or not.""" + if not parallel: + return ["run"] + + vers: Final = subprocess.check_output( + [penv_py3, "-m", "tox", "--version"], + encoding="UTF-8", + ) + if vers.startswith("3"): + return ["run", "-p", "all"] + + return ["run-parallel"] + + cfg.log.info( + "Running Tox%(parallel)s with %(args)s", + { + "parallel": " in parallel" if parallel else "", + "args": ("additional arguments: " + shlex.join(args)) + if args + else "no additional arguments", + }, + ) + run_cmd: Final = get_run_command() + return [penv_py3, "-m", "tox", "-c", cfg.conf, *run_cmd, *args] + + +def run_tox(cfg: Config, penv: pathlib.Path, *, parallel: bool = True, args: list[str]) -> None: + """Run Tox from the virtual environment.""" + subprocess.check_call(get_tox_cmdline(cfg, penv, parallel=parallel, args=args)) + + +def run(cfg_no_tempd: Config, *, parallel: bool, args: list[str]) -> None: + """Create the virtual environment, install Tox, run it.""" + with tempfile.TemporaryDirectory() as tempd_obj: + cfg: Final = dataclasses.replace(cfg_no_tempd, tempd=pathlib.Path(tempd_obj)) + penv: Final = create_and_update_venv(cfg) + install_tox(cfg, penv) + run_tox(cfg, penv, parallel=parallel, args=args) + + +def cmd_run(cfg_no_tempd: Config, args: list[str]) -> None: + """Run the Tox tests sequentially.""" + run(cfg_no_tempd, parallel=False, args=args) + + +def cmd_run_parallel(cfg_no_tempd: Config, args: list[str]) -> None: + """Run the Tox tests in parallel.""" + run(cfg_no_tempd, parallel=True, args=args) + + +def cmd_features(_cfg_no_tempd: Config, _args: list[str]) -> None: + """Display the list of features supported by the program.""" + print(f"Features: vetox={VERSION} tox=0.1 tox-parallel=0.1") + + +def cmd_version(_cfg_no_tempd: Config, _args: list[str]) -> None: + """Display the vetox version.""" + print(f"vetox {VERSION}") + + +def parse_args() -> tuple[Config, Callable[[Config, list[str]], None], list[str]]: + """Parse the command-line arguments.""" + parser: Final = argparse.ArgumentParser(prog="vetox") + parser.add_argument( + "-c", + "--conf", + type=pathlib.Path, + default=pathlib.Path.cwd() / "tox.ini", + help="The path to the tox.ini file", + ) + + subp: Final = parser.add_subparsers() + p_run: Final = subp.add_parser("run", help="Run tests sequentially") + p_run.add_argument( + "-t", + "--tox-req", + type=str, + help="specify the PEP508 version requirement for Tox itself", + ) + p_run.add_argument("args", type=str, nargs="*", help="Additional arguments to pass to Tox") + p_run.set_defaults(func=cmd_run) + + p_run_p: Final = subp.add_parser("run-parallel", help="Run tests in parallel") + p_run_p.add_argument( + "-t", + "--tox-req", + type=str, + help="specify the PEP508 version requirement for Tox itself", + ) + p_run_p.add_argument("args", type=str, nargs="*", help="Additional arguments to pass to Tox") + p_run_p.set_defaults(func=cmd_run_parallel) + + p_features: Final = subp.add_parser("features", help="Display the supported program features") + p_features.set_defaults(func=cmd_features) + + p_version: Final = subp.add_parser("version", help="Display the vetox version") + p_version.set_defaults(func=cmd_version) + + args: Final = parser.parse_args() + + func: Final[Callable[[Config, list[str]], None] | None] = getattr(args, "func", None) + if func is None: + sys.exit("No subcommand specified; use `--help` for a list") + + return ( + Config( + conf=args.conf, + log=build_logger(), + tempd=pathlib.Path("/nonexistent"), + tox_req=args.tox_req, + ), + func, + getattr(args, "args", []), + ) + + +def main() -> None: + """Parse command-line arguments, create a virtual environment, run Tox.""" + cfg_no_tempd, func, args = parse_args() + func(cfg_no_tempd, args) + + +if __name__ == "__main__": + main() diff --git a/tox.ini b/tox.ini index 31052c4..6768bd4 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ isolated_build = True pyfiles_mypy = src/selftest \ src/test_stages \ - unit_tests + tests/unit pyfiles = {[defs]pyfiles_mypy} \ @@ -100,7 +100,7 @@ deps = commands = tox-stages --help tox-stages available - pytest {posargs} unit_tests + pytest {posargs} tests/unit [testenv:unit-tests-tox-4] tags = @@ -114,7 +114,7 @@ allowlist_externals = commands = tox-stages --help tox-stages available - pytest {posargs} unit_tests + pytest {posargs} tests/unit [testenv:selftest] tags = @@ -140,7 +140,7 @@ deps = 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' + sh -c 'pyupgrade --py38-plus src/test_stages/*.py src/test_stages/tox_stages/*.py src/tox_trivtags/*.py tests/unit/*.py' [testenv:reuse] skip_install = True diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py deleted file mode 100644 index f32c1ef..0000000 --- a/unit_tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: Peter Pentchev -# SPDX-License-Identifier: BSD-2-Clause -"""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 deleted file mode 100644 index 2ea478e..0000000 --- a/unit_tests/test_functional.py +++ /dev/null @@ -1,113 +0,0 @@ -# SPDX-FileCopyrightText: Peter Pentchev -# SPDX-License-Identifier: BSD-2-Clause -"""Load the Tox configuration, look for our tags thing.""" - -from __future__ import annotations - -import contextlib -import pathlib -import shutil -import subprocess -import sys -import tempfile -from typing import TYPE_CHECKING - -import pytest -import utf8_locale - -import tox_trivtags -import tox_trivtags.parse as ttt_parse - - -if sys.version_info >= (3, 11): - import contextlib as contextlib_chdir -else: - import contextlib_chdir - -if TYPE_CHECKING: - from collections.abc import Callable, Iterator - from contextlib import AbstractContextManager - from typing import Final - - -_EXPECTED: Final[dict[str, list[str]]] = { - "format": ["check", "quick"], - "reformat": ["format", "manual"], - "unit-tests-no-tox": ["tests"], - "unit-tests-tox-3": ["tests"], - "unit-tests-tox-4": ["tests"], - (".package" if tox_trivtags.HAVE_MOD_TOX_3 else ".pkg"): [], - "t-single": ["something"], - "t-several": ["all", "the", "things"], - "t-special": ["So,", "how many", "$tags", 'is "too many",', "'eh\"?"], -} - - -def copy_and_adapt(srcdir: pathlib.Path, dstdir: pathlib.Path) -> None: - """Copy some files over, adapt the tox.ini file.""" - src_tox = srcdir / "tox.ini" - dst_tox = dstdir / "tox.ini" - lines: Final = src_tox.read_text(encoding="UTF-8").splitlines() - adapted: Final = [ - line.replace(" \\", "") for line in lines if not line.startswith("minversion") - ] - dst_tox.write_text("".join(line + "\n" for line in adapted), encoding="UTF-8") - res: Final = subprocess.run(["diff", "-u", "--", src_tox, dst_tox], check=False) - assert res.returncode != 0 - - shutil.copytree(srcdir / "config", dstdir / "config") - shutil.copytree(srcdir / "requirements", dstdir / "requirements") - shutil.copy2(srcdir / "pyproject.toml", dstdir / "pyproject.toml") - - -@contextlib.contextmanager -def adapt_tox_cwd() -> Iterator[pathlib.Path]: - """If using Tox 3.x, copy the files over and adapt them.""" - cwd: Final = pathlib.Path().absolute() - if not tox_trivtags.HAVE_MOD_TOX_3: - print("Not using Tox 3.x, no need to copy or adapt any files") - yield cwd - return - - with tempfile.TemporaryDirectory() as toxd_name: - toxd = pathlib.Path(toxd_name).absolute() - print(f"Adapting files for Tox 3.x from {cwd} to {toxd}") - copy_and_adapt(cwd, toxd) - with contextlib_chdir.chdir(toxd): - yield toxd - - -@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 adapt_tox_cwd(), cfg_filename() as filename: - _do_test_run_showconfig(filename) -- cgit v1.2.3