Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions template/.github/workflows/ci.yml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
146 changes: 146 additions & 0 deletions tests/helpers/ci_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers/tooling_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +33,7 @@

__all__ = [
"assert_ci_coverage_action_contract",
"assert_ci_python_matrix_contract",
"assert_common_make_targets",
"assert_generated_tooling_contracts",
]
Expand Down
59 changes: 59 additions & 0 deletions tests/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Loading