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: 8 additions & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +24 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: check for validator/choices guarding python_version format.
sed -n '1,30p' copier.yml

Repository: leynos/agent-template-python

Length of output: 822


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the full copier config and any repo-level constraints around python_version.
sed -n '1,120p' copier.yml

printf '\n---\n'

# Search for other mentions of python_version, validators, choices, or related constraints.
rg -n --hidden --glob '!**/.git/**' -e 'python_version' -e 'choices' -e 'validator' -e 'validate' -e 'mutmut_supported' .

Repository: leynos/agent-template-python

Length of output: 8588


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the tests that feed python_version into the template.
sed -n '490,610p' tests/test_template.py

printf '\n---\n'

sed -n '40,100p' tests/helpers/rendering.py

Repository: leynos/agent-template-python

Length of output: 5761


Validate python_version before splitting it. python_version.split('.')[1] assumes a dotted X.Y value, so a malformed answer can break rendering. Add a validator or parse the version more defensively before setting mutmut_supported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@copier.yml` around lines 24 - 29, The mutmut_supported default in copier.yml
assumes python_version always contains a dotted X.Y value, so rendering can fail
on malformed input. Update the computed default to validate or parse
python_version defensively before calling split, and add a copier validator for
the python_version field if needed so the mutmut_supported expression in this
template cannot break on unexpected version formats.

Original file line number Diff line number Diff line change
@@ -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 }}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a 3.13+ interpreter for mutation scripts

When the template uses its default python_version (3.10 in copier.yml), this passes 3.10 into the pinned reusable workflow's setup-uv step, which sets UV_PYTHON; the shared workflow then runs uv run --script for mutation_detect_changes.py, whose inline script metadata requires Python >=3.13. With UV_PYTHON=3.10, uv selects/installs 3.10 and the detection step fails before any mutation testing runs, so default-generated repositories get a broken scheduled mutation workflow. Keep the reusable workflow's script runner on 3.13+ or avoid overriding it below the script requirement.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Has this now been resolved in the latest commit?

Use codegraph analysis to determine your answer.

If this comment is now resolved, please mark it as such using the API. Otherwise, please provide an AI agent prompt for the remaining work to be done to address this comment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find copier.yml
fd -a copier.yml

echo "---- copier.yml python_version / mutmut_supported ----"
rg -n -A5 -B5 'mutmut_supported|python_version' copier.yml

echo "---- workflow template filename and content ----"
fd -a 'mutation-testing.yml.jinja'

echo "---- full workflow template content ----"
fd -a 'mutation-testing.yml.jinja' --exec cat -n {}

Length of output: 1167


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- find workflow files with fd literal ----"
fd -a -H 'mutation-testing'

echo "---- find via find ----"
find . -iname '*mutation-testing*'

echo "---- list workflows dir ----"
find . -path '*workflows*' -iname '*.jinja'

Length of output: 969


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n './template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja'

Length of output: 3102


@leynos, confirmed via codegraph analysis — this is resolved.

The gating now prevents the broken combination entirely:

  • copier.yml computes mutmut_supported as python_version.split('.')[1] | int >= 13 (Line 26-29), a when: false derived flag, not a prompt.
  • The mutation job in the workflow template is wrapped in {%- if mutmut_supported %} … {%- endif %} (Line 28-43), and the python-version: "{{ python_version }}" input (Line 42) only renders inside that block.
  • The default python_version is "3.12" (raised from 3.10, but still < 3.13), so mutmut_supported evaluates to false by default and the mutation job — indeed the whole file, per the {% if mutmut_supported or use_rust %} filename guard — does not render for default-baseline repositories.
  • Whenever a repository does opt into a python_version >= 3.13, mutmut_supported flips true and the rendered python-version value is guaranteed to satisfy the shared workflow's mutation_detect_changes.py >=3.13 script requirement.

So the failure mode codex flagged — UV_PYTHON=3.10 breaking the detection step — can no longer occur; the job simply isn't emitted until the baseline interpreter is high enough.

One note: I can't mark this thread resolved via the API myself, since the original comment was posted by chatgpt-codex-connector[bot], not by me — I can only resolve threads I've authored. Go ahead and resolve it manually on your end.

(=^・ω・^=) ノ resolved

{%- 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 %}
11 changes: 11 additions & 0 deletions template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/helpers/pyproject_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions tests/helpers/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions tests/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Comment on lines +513 to +520

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ruff flags boolean-typed positional parameters (FBT001).

use_rust: bool and expect_mutmut: bool trip FBT001 since the signature permits positional bool args, even though pytest always injects parametrize values by name. A blanket restructure isn't warranted here; add a narrow, justified suppression instead of leaving the lint failing.

🔧 Proposed fix
 def test_generated_mutation_testing_gating(
     copier: CopierFixture,
     tmp_path: Path,
     target_dir: str,
-    use_rust: bool,
+    use_rust: bool,  # noqa: FBT001 -- pytest.mark.parametrize always passes by keyword
     python_version: str,
-    expect_mutmut: bool,
+    expect_mutmut: bool,  # noqa: FBT001 -- pytest.mark.parametrize always passes by keyword
 ) -> None:

As per coding guidelines, "Only narrow in-line disables (# noqa: XYZ) are permitted, must be accompanied by justification and used only as a last resort."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_generated_mutation_testing_gating(
copier: CopierFixture,
tmp_path: Path,
target_dir: str,
use_rust: bool,
python_version: str,
expect_mutmut: bool,
) -> None:
def test_generated_mutation_testing_gating(
copier: CopierFixture,
tmp_path: Path,
target_dir: str,
use_rust: bool, # noqa: FBT001 -- pytest.mark.parametrize always passes by keyword
python_version: str,
expect_mutmut: bool, # noqa: FBT001 -- pytest.mark.parametrize always passes by keyword
) -> None:
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 517-517: Boolean-typed positional argument in function definition

(FBT001)


[warning] 519-519: Boolean-typed positional argument in function definition

(FBT001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_template.py` around lines 513 - 520, Add a narrow Ruff suppression
for the boolean positional parameters in test_generated_mutation_testing_gating
by annotating the test signature with an inline FBT001 waiver and a brief
justification, since pytest parametrization already binds use_rust and
expect_mutmut by name. Keep the fix localized to this test function only and
avoid broader lint disables or signature restructuring.

Sources: Path instructions, Linters/SAST tools

"""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,
)
Comment on lines +547 to +553

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicates render_project's copier invocation instead of reusing it.

This manually re-implements render_project's body (project_name/package_name/use_rust/python_version → copier.copy) purely to pass a per-case subdirectory. Extend render_project with an optional destination/subdirectory argument and reuse it here to avoid drift between the two call sites.

♻️ Proposed direction
 def render_project(
     tmp_path: Path,
     copier: CopierFixture,
     *,
     project_name: str,
     package_name: str,
     use_rust: bool = False,
     python_version: str = "3.12",
+    destination: Path | None = None,
 ) -> CopierProject:
     ...
     return copier.copy(
-        tmp_path,
+        destination or tmp_path,
         ...
     )

Then in the new test: render_project(tmp_path / target_dir, copier, project_name=..., ...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_template.py` around lines 547 - 553, The test is duplicating the
copier invocation instead of reusing render_project, which risks the two call
sites drifting apart. Update render_project to accept an optional
destination/subdirectory argument and have it call copier.copy with that path
internally, then change this test case to call render_project(tmp_path /
target_dir, copier, project_name=..., package_name=..., use_rust=...,
python_version=...) so the shared logic stays in one place.

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"
)
Loading