diff --git a/copier.yml b/copier.yml index 65a53e7..988fd12 100644 --- a/copier.yml +++ b/copier.yml @@ -18,5 +18,12 @@ use_rust: python_version: type: str - default: "3.10" + default: "3.12" help: Minimum supported Python version + +# Computed: mutmut mutation testing needs a base interpreter of 3.13 or +# greater because the shared workflow's helper scripts require >= 3.13. +mutmut_supported: + type: bool + default: "{{ python_version.split('.')[1] | int >= 13 }}" + when: false diff --git a/template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja b/template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja new file mode 100644 index 0000000..e2de1f6 --- /dev/null +++ b/template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja @@ -0,0 +1,62 @@ +name: Mutation testing + +# Thin caller of the shared mutation-testing reusable workflows; caller +# guides: leynos/shared-actions docs/mutation-mutmut-workflow.md and +# docs/mutation-cargo-workflow.md. +# Scheduled runs mutate only files changed within the detection window; +# manual dispatch runs mutate everything. To test a branch, select it in +# the Actions "Run workflow" control. + +on: + schedule: + # Stagger this cron per repository so estate runs do not bunch up; + # existing repositories occupy the 03:05-09:05 UTC band. + - cron: "30 9 * * *" # Daily, 09:30 UTC + workflow_dispatch: + +# Default the token to no scopes; jobs opt in explicitly. +permissions: {} + +# Serialize runs per ref: a dispatch during a scheduled run (or vice +# versa) queues rather than racing. Runs are informational, so queued +# runs wait instead of cancelling. +concurrency: + group: mutation-testing-${{ "{{" }} github.ref {{ "}}" }} + cancel-in-progress: false + +jobs: +{%- if mutmut_supported %} + mutation: + permissions: + contents: read + id-token: write # OIDC workflow-source resolution + uses: leynos/shared-actions/.github/workflows/mutation-mutmut.yml@859416a90eb3987b46a57682c5d6b8964ad3f0a6 + with: + # Flat package layout: the package directory sits at the + # repository root, so no module prefix is stripped. + paths: "{{ package_name }}/" + module-prefix-strip: "" + # Always >= 3.13 here: this job renders only when the project's + # baseline interpreter satisfies the shared workflow's helper + # scripts. + python-version: "{{ python_version }}" +{%- endif %} +{%- if use_rust %} + + mutation-rust: + permissions: + contents: read + id-token: write # OIDC workflow-source resolution + uses: leynos/shared-actions/.github/workflows/mutation-cargo.yml@859416a90eb3987b46a57682c5d6b8964ad3f0a6 + with: + # The extension crate is the only Cargo target and lives outside + # the repository root, so it is declared as an extra crate and the + # root path list is emptied. + paths: "" + extra-crate-dirs: "rust_extension" + # The shared workflow always fans the root target out on manual + # dispatch, and this repository has no root crate, so that leg + # fails; keep it to a single shard. Scheduled scoped runs and the + # rust_extension target are unaffected. + shard-count: 1 +{%- endif %} diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 9f2fddf..e3510ff 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -294,6 +294,17 @@ enable = [ "too-many-statements", ] +{%- if mutmut_supported %} + +[tool.mutmut] +# mutmut copies the tree before running the suite: tests that read +# repository-root paths outside tests/ must either add those paths to +# `also_copy` or guard themselves with a skipif. See the shared-actions +# caller guide (docs/mutation-mutmut-workflow.md). +source_paths = ["{{ package_name }}/"] +pytest_add_cli_args_test_selection = ["tests/"] +{%- endif %} + [tool.pytest.ini_options] # Tests automatically killed after seconds elapsed timeout = 30 diff --git a/tests/helpers/pyproject_contracts.py b/tests/helpers/pyproject_contracts.py index 4425daf..1e37b8a 100644 --- a/tests/helpers/pyproject_contracts.py +++ b/tests/helpers/pyproject_contracts.py @@ -20,7 +20,7 @@ def _assert_pyproject_contracts( """Assert generated Python packaging contracts.""" project = require_mapping(pyproject, "project", "pyproject.toml") assert project.get("name"), "expected generated project metadata to include a name" - assert project.get("requires-python") == ">=3.10", ( + assert project.get("requires-python") == ">=3.12", ( "expected generated pyproject.toml to use the requested Python version" ) dependency_groups = require_mapping( diff --git a/tests/helpers/rendering.py b/tests/helpers/rendering.py index 2409931..169abe0 100644 --- a/tests/helpers/rendering.py +++ b/tests/helpers/rendering.py @@ -49,7 +49,7 @@ def render_project( project_name: str, package_name: str, use_rust: bool = False, - python_version: str = "3.10", + python_version: str = "3.12", ) -> CopierProject: """Render a generated Python project with explicit template answers. @@ -65,7 +65,7 @@ def render_project( Python import package name answer passed to Copier. use_rust : bool, default=False Whether to include the optional PyO3 extension. - python_version : str, default="3.10" + python_version : str, default="3.12" Minimum supported Python version answer passed to Copier. Returns diff --git a/tests/test_template.py b/tests/test_template.py index a266f11..ad28e96 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -24,7 +24,9 @@ from tests.helpers.generated_files import ( parse_toml_file, + parse_yaml_mapping, read_generated_text, + require_mapping, ) from tests.helpers.rendering import ( check_generated_import, @@ -494,3 +496,97 @@ def test_generated_github_workflows_match_act_validation_contract( package_name=package_name, use_rust=use_rust, ) + + +MUTATION_WORKFLOW_PIN = "859416a90eb3987b46a57682c5d6b8964ad3f0a6" + + +@pytest.mark.parametrize( + ("target_dir", "use_rust", "python_version", "expect_mutmut"), + [ + ("mutation-312-pure", False, "3.12", False), + ("mutation-312-rust", True, "3.12", False), + ("mutation-313-pure", False, "3.13", True), + ("mutation-314-rust", True, "3.14", True), + ], +) +def test_generated_mutation_testing_gating( + copier: CopierFixture, + tmp_path: Path, + target_dir: str, + use_rust: bool, + python_version: str, + expect_mutmut: bool, +) -> None: + """Rendered mutation testing follows the interpreter and Rust gates. + + 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. + python_version + Minimum supported Python version answer passed to Copier. + expect_mutmut + Whether the baseline interpreter supports the mutmut workflow + (3.13 or greater). + + Returns + ------- + None + The test passes when the mutmut job and ``[tool.mutmut]`` section + render only for baselines of 3.13 or greater, the cargo-mutants job + renders only with the Rust extension, and the workflow file is + absent when both gates are off. + """ + project = copier.copy( + tmp_path / target_dir, + project_name="MutationProj", + package_name="mutation_pkg", + use_rust=use_rust, + python_version=python_version, + ) + pyproject = parse_toml_file(project / "pyproject.toml") + mutmut_config = pyproject.get("tool", {}).get("mutmut") + if expect_mutmut: + assert mutmut_config == { + "source_paths": ["mutation_pkg/"], + "pytest_add_cli_args_test_selection": ["tests/"], + }, "expected mutmut configuration for baselines of 3.13 or greater" + else: + assert mutmut_config is None, ( + "expected no mutmut configuration below a 3.13 baseline" + ) + + workflow_path = project / ".github" / "workflows" / "mutation-testing.yml" + if not expect_mutmut and not use_rust: + assert not workflow_path.exists(), ( + "expected no mutation workflow when both gates are off" + ) + return + workflow = parse_yaml_mapping( + read_generated_text(workflow_path), "mutation workflow" + ) + jobs = require_mapping(workflow, "jobs", "mutation workflow") + assert ("mutation" in jobs) == expect_mutmut, ( + "expected the mutmut job only for baselines of 3.13 or greater" + ) + assert ("mutation-rust" in jobs) == use_rust, ( + "expected the cargo-mutants job only for Rust variants" + ) + if expect_mutmut: + mutation_job = require_mapping(jobs, "mutation", "mutation workflow jobs") + mutation_inputs = require_mapping(mutation_job, "with", "mutation job") + assert mutation_inputs.get("python-version") == python_version, ( + "expected the mutmut job to run on the project's baseline Python" + ) + for job_name, job in jobs.items(): + uses = str(job.get("uses", "")) if isinstance(job, dict) else "" + assert uses.endswith(f"@{MUTATION_WORKFLOW_PIN}"), ( + f"expected {job_name} to pin the shared mutation workflow" + )