From beb50227f975ed66c1c45df9c1a5bbcab5dcff27 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 7 Jul 2026 12:36:47 +0200 Subject: [PATCH] Add optional multi-version Python test matrix Generated repositories can now opt into CI testing across multiple Python versions via a new multi_python_tests boolean answer (default false). When enabled, the generated CI workflow gains a typecheck-test matrix job modelled on the estate's library repositories (femtologging, falcon-correlate, and cuprum): one leg per Python minor version from the project's python_version baseline through 3.14, plus an experimental 3.15 lane that installs prerelease interpreters and sets continue-on-error so it never gates merges. Lint, audit, and coverage stay single-legged in lint-test, and Rust-enabled variants set up the toolchain in every leg because make build compiles the extension. When the toggle is off, the rendered CI workflow is byte-identical to the previous output. Contract tests cover both toggle states and a reduced matrix for a 3.14 baseline, and the developers' guide documents the behaviour. --- copier.yml | 9 ++ docs/developers-guide.md | 12 ++ template/.github/workflows/ci.yml.jinja | 60 ++++++++++ tests/helpers/ci_contracts.py | 146 ++++++++++++++++++++++++ tests/helpers/tooling_contracts.py | 2 + tests/test_template.py | 59 ++++++++++ 6 files changed, 288 insertions(+) diff --git a/copier.yml b/copier.yml index 65a53e7..7220d75 100644 --- a/copier.yml +++ b/copier.yml @@ -20,3 +20,12 @@ python_version: type: str default: "3.10" help: Minimum supported Python version + +multi_python_tests: + type: bool + default: false + help: >- + Test across multiple Python versions in CI (baseline through 3.14, + plus an experimental 3.15 lane). Suits libraries for external + consumption; the estate baselines libraries at 3.12 and tests + 3.12-3.14. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 0f9869b..b90bd4c 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -71,6 +71,18 @@ sets up Python, optionally sets up Rust, runs `make check-fmt`, `make lint`, `make typecheck`, and `make audit`, then delegates coverage to `leynos/shared-actions/.github/actions/generate-coverage`. +When `multi_python_tests` is enabled, the generated CI workflow adds a +`typecheck-test` matrix job modelled on the estate's library repositories +(femtologging, falcon-correlate, and cuprum). The matrix derives one leg per +Python minor version from the project's `python_version` baseline through +3.14, plus an experimental 3.15 lane that installs prerelease interpreters and +sets `continue-on-error: true` so it never gates merges. Each leg runs +`make build`, `make typecheck`, and `make test`; lint, audit, and coverage +stay single-legged in `lint-test`. Rust-enabled variants set up the Rust +toolchain in every leg because `make build` compiles the extension. The +toggle suits libraries for external consumption; the estate baselines +libraries at 3.12 and tests 3.12-3.14. + The shared coverage action runs Python coverage through xdist-backed SlipCover support. Generated pytest discovery is therefore constrained to the top-level `tests/` tree via `tool.pytest.ini_options.testpaths`; do not add pytest unit diff --git a/template/.github/workflows/ci.yml.jinja b/template/.github/workflows/ci.yml.jinja index c9ec18a..e16baeb 100644 --- a/template/.github/workflows/ci.yml.jinja +++ b/template/.github/workflows/ci.yml.jinja @@ -110,3 +110,63 @@ jobs: --format "cobertura" \ --metric "line-coverage" \ coverage.xml +{%- if multi_python_tests %} + + typecheck-test: + name: Typecheck and test (Python ${{ "{{" }} matrix.python-label {{ "}}" }}) + # The experimental lane must never gate merges; stable lanes gate as + # normal required checks. + continue-on-error: ${{ "{{" }} matrix.experimental {{ "}}" }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: +{%- for minor in range(python_version.split('.')[1] | int, 15) %} + - python-version: '3.{{ minor }}' + python-label: '3.{{ minor }}' + allow-prereleases: false + experimental: false +{%- endfor %} + - python-version: '3.15' + python-label: '3.15a' + allow-prereleases: true + experimental: true + {% if use_rust %} + env: + CARGO_TERM_COLOR: always + {% endif %} + steps: + - name: Check out repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ "{{" }} matrix.python-version {{ "}}" }} + allow-prereleases: ${{ "{{" }} matrix.allow-prereleases {{ "}}" }} + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + {% if use_rust %} + - name: Set up Rust + uses: leynos/shared-actions/.github/actions/setup-rust@455d9ed03477c0026da96c2541ca26569a74acac + with: + toolchain: stable + + - name: Cache Rust build artefacts + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2 + + {% endif %} + - name: Install code + run: make build + + - name: Run typechecker + run: make typecheck + + - name: Run tests + run: make test +{%- endif %} diff --git a/tests/helpers/ci_contracts.py b/tests/helpers/ci_contracts.py index 2226dec..97e619a 100644 --- a/tests/helpers/ci_contracts.py +++ b/tests/helpers/ci_contracts.py @@ -93,6 +93,152 @@ def assert_ci_coverage_action_contract( ) +def _expected_python_matrix_legs(python_version: str) -> list[dict[str, Any]]: + """Build the expected CI matrix legs for a baseline Python version. + + Parameters + ---------- + python_version : str + Minimum supported Python version answer (for example ``"3.10"``). + + Returns + ------- + list[dict[str, Any]] + Stable legs from the baseline through 3.14, followed by the + experimental 3.15 leg. + """ + baseline_minor = int(python_version.split(".")[1]) + legs: list[dict[str, Any]] = [ + { + "python-version": f"3.{minor}", + "python-label": f"3.{minor}", + "allow-prereleases": False, + "experimental": False, + } + for minor in range(baseline_minor, 15) + ] + legs.append( + { + "python-version": "3.15", + "python-label": "3.15a", + "allow-prereleases": True, + "experimental": True, + } + ) + return legs + + +def assert_ci_python_matrix_contract( + *, + ci_workflow: str, + multi_python_tests: bool, + use_rust: bool, + python_version: str = "3.10", +) -> None: + """Assert the generated CI Python version matrix contract. + + Parameters + ---------- + ci_workflow : str + UTF-8 text of the generated CI workflow. + multi_python_tests : bool + Whether the rendered variant enables the multi-version test matrix. + use_rust : bool + Whether the rendered variant includes the optional Rust extension. + python_version : str, default="3.10" + Minimum supported Python version answer passed to Copier. + + Returns + ------- + None + The helper returns after the matrix contract matches expectations. + + Raises + ------ + AssertionError + Raised when the matrix job is unexpectedly present or absent, when + matrix legs diverge from the derived version list, when the + experimental lane gates merges, or when per-leg steps are wrong. + + Examples + -------- + Validate a rendered matrix-enabled CI workflow:: + + assert_ci_python_matrix_contract( + ci_workflow=ci_workflow, + multi_python_tests=True, + use_rust=False, + python_version="3.12", + ) + """ + parsed_ci_workflow = _parse_ci_workflow(ci_workflow) + jobs = require_mapping(parsed_ci_workflow, "jobs", "CI workflow") + if not multi_python_tests: + assert "typecheck-test" not in jobs, ( + "expected matrix-off CI workflow to omit the typecheck-test job" + ) + return + job = require_mapping(jobs, "typecheck-test", "CI workflow jobs") + assert job.get("name") == ( + "Typecheck and test (Python ${{ matrix.python-label }})" + ), "expected matrix legs to be named after their Python label" + assert job.get("continue-on-error") == "${{ matrix.experimental }}", ( + "expected experimental matrix legs to avoid gating merges" + ) + strategy = require_mapping(job, "strategy", "typecheck-test job") + assert strategy.get("fail-fast") is False, ( + "expected matrix legs to run to completion independently" + ) + matrix = require_mapping(strategy, "matrix", "typecheck-test strategy") + legs = require_sequence(matrix, "include", "typecheck-test matrix") + assert legs == _expected_python_matrix_legs(python_version), ( + "expected matrix legs from the baseline through 3.14 plus an " + "experimental 3.15 leg" + ) + _assert_python_matrix_steps(job, use_rust=use_rust) + + +def _assert_python_matrix_steps(job: dict[str, Any], *, use_rust: bool) -> None: + """Assert the per-leg steps of the generated matrix job. + + Parameters + ---------- + job : dict[str, Any] + Parsed ``typecheck-test`` job mapping. + use_rust : bool + Whether the rendered variant includes the optional Rust extension. + """ + steps = require_sequence(job, "steps", "typecheck-test job") + assert _checkout_steps_disable_credentials(steps), ( + "expected matrix checkout steps to disable credential persistence" + ) + step_names = [step.get("name") for step in steps if isinstance(step, dict)] + for required in ("Set up Python", "Install code", "Run typechecker", "Run tests"): + assert required in step_names, ( + f"expected matrix legs to include the {required!r} step" + ) + setup_python = next( + step + for step in steps + if isinstance(step, dict) and step.get("name") == "Set up Python" + ) + setup_python_inputs = require_mapping(setup_python, "with", "Set up Python step") + assert setup_python_inputs.get("python-version") == ( + "${{ matrix.python-version }}" + ), "expected matrix legs to install the leg's Python version" + assert setup_python_inputs.get("allow-prereleases") == ( + "${{ matrix.allow-prereleases }}" + ), "expected the experimental leg to allow prerelease interpreters" + if use_rust: + assert "Set up Rust" in step_names, ( + "expected Rust variant matrix legs to set up the Rust toolchain" + ) + else: + assert "Set up Rust" not in step_names, ( + "expected pure-Python matrix legs to omit Rust setup" + ) + + def _parse_ci_workflow(ci_workflow: str) -> dict[str, Any]: """Parse a generated CI workflow as a YAML mapping.""" return parse_yaml_mapping(ci_workflow, "CI workflow") diff --git a/tests/helpers/tooling_contracts.py b/tests/helpers/tooling_contracts.py index 19569a9..e81c6b6 100644 --- a/tests/helpers/tooling_contracts.py +++ b/tests/helpers/tooling_contracts.py @@ -21,6 +21,7 @@ _assert_ci_workflow_contracts, _parse_ci_workflow, assert_ci_coverage_action_contract, + assert_ci_python_matrix_contract, ) from tests.helpers.makefile_contracts import ( _assert_makefile_contracts, @@ -32,6 +33,7 @@ __all__ = [ "assert_ci_coverage_action_contract", + "assert_ci_python_matrix_contract", "assert_common_make_targets", "assert_generated_tooling_contracts", ] diff --git a/tests/test_template.py b/tests/test_template.py index a266f11..4a72dd9 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -34,6 +34,7 @@ ) from tests.helpers.tooling_contracts import ( assert_ci_coverage_action_contract, + assert_ci_python_matrix_contract, assert_common_make_targets, assert_generated_tooling_contracts, ) @@ -494,3 +495,61 @@ def test_generated_github_workflows_match_act_validation_contract( package_name=package_name, use_rust=use_rust, ) + + +@pytest.mark.parametrize( + ("target_dir", "use_rust", "multi_python_tests", "python_version"), + [ + ("matrix-off-pure", False, False, "3.10"), + ("matrix-off-rust", True, False, "3.10"), + ("matrix-on-pure", False, True, "3.10"), + ("matrix-on-rust", True, True, "3.10"), + ("matrix-on-small", False, True, "3.14"), + ], +) +def test_generated_ci_python_matrix_contract( + copier: CopierFixture, + tmp_path: Path, + target_dir: str, + use_rust: bool, + multi_python_tests: bool, + python_version: str, +) -> None: + """Rendered CI matches the optional Python version matrix contract. + + Parameters + ---------- + copier + ``pytest-copier`` fixture used to render the template. + tmp_path + Temporary directory where the rendered project is created. + target_dir + Temporary project directory name for the rendered variant. + use_rust + Whether the rendered variant includes the optional Rust extension. + multi_python_tests + Whether the rendered variant enables the multi-version test matrix. + python_version + Minimum supported Python version answer passed to Copier. + + Returns + ------- + None + The test passes when the matrix job is present with derived legs and + a non-gating experimental lane, or absent when the toggle is off. + """ + project = copier.copy( + tmp_path / target_dir, + project_name="MatrixProj", + package_name="matrix_pkg", + use_rust=use_rust, + multi_python_tests=multi_python_tests, + python_version=python_version, + ) + ci_workflow = read_generated_text(project / ".github" / "workflows" / "ci.yml") + assert_ci_python_matrix_contract( + ci_workflow=ci_workflow, + multi_python_tests=multi_python_tests, + use_rust=use_rust, + python_version=python_version, + )