Skip to content
Open
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
2b71fb7
Run Python coverage tooling in a venv
leynos Apr 26, 2026
009141b
Use uv tool run for Ruff make targets
leynos Apr 26, 2026
c6669c4
Resolve tool paths in make checks
leynos Apr 26, 2026
fb502bc
Refine coverage venv command handling
leynos Apr 27, 2026
a93f3b7
Recreate broken coverage venvs
leynos Apr 27, 2026
cb49e90
Bundle coverage format test parameters
leynos Apr 27, 2026
6a8c09b
Mark coverage XML stub parameters unused
leynos Apr 27, 2026
2ab45d6
Document coverage venv lifecycle
leynos Apr 27, 2026
600e710
Stabilise markdownlint for branch docs
leynos Apr 27, 2026
23b373e
Share coverage venv test scaffolding
leynos Apr 27, 2026
ba7e729
Remove duplicate coverage venv cache test
leynos Apr 27, 2026
006b165
Document coverage venv setup in action README
leynos Apr 27, 2026
1a302b8
Fix generate coverage README markdown lint
leynos Apr 27, 2026
89cfd3a
Decompose coverage venv command predicate
leynos Apr 27, 2026
3827e03
Simplify coverage venv setup
leynos Apr 27, 2026
7c9e72f
Sync project dependencies into coverage venv
leynos Apr 27, 2026
c759a4e
Harden coverage venv cache repair
leynos Apr 27, 2026
f4fbdce
Clarify markdownlint fallback warning
leynos Apr 27, 2026
2b60470
Extract coverage venv recreation assertions
leynos Apr 27, 2026
3d8f177
Rename coverage venv rebuild assertion helper
leynos Apr 27, 2026
a9f3b44
Cover coverage venv repair edge cases
leynos Apr 27, 2026
145b90b
Guard coverage command test venv detection
leynos Apr 27, 2026
533fa20
Run action-validator per action metadata file
leynos Apr 27, 2026
ee24905
Extract coverage venv recreation helpers
leynos Apr 27, 2026
3c20bbd
Move markdownlint logic into helper script
leynos Apr 27, 2026
3588178
Prefer Bun action-validator and quote markdownlint command
leynos Apr 27, 2026
ab88554
Extract default coverage venv rebuild assertion
leynos Apr 27, 2026
e7b1ce7
Use executable stems in coverage tests
leynos Apr 27, 2026
aaf1276
Merge non-Cobertura coverage command tests
leynos Apr 28, 2026
13da80f
Document coverage venv migration and add integration tests
leynos Apr 28, 2026
d712151
Fix AGENTS markdown table alignment
leynos Apr 28, 2026
d002a85
Handle invalid coverage venv paths
leynos Apr 28, 2026
394def3
Fix AGENTS documentation links and tool lookup
leynos Apr 28, 2026
d617af4
Fix coverage design wording
leynos Apr 28, 2026
ee1226c
Refactor run_python integration test runner
leynos Apr 28, 2026
a8aa693
Remove unused run_python test helper
leynos Apr 28, 2026
92d0b34
Handle dangling coverage venv symlinks
leynos Apr 29, 2026
dc432bb
Reduce run_python complexity
leynos Apr 29, 2026
bcbc0f1
Parametrize coverage venv failure tests
leynos Apr 29, 2026
e26e9de
Target uv sync at coverage venv
leynos Apr 29, 2026
8fe680e
Validate run_python integration env contract
leynos Apr 29, 2026
217dc00
Bundle uv failure test parameters
leynos Apr 29, 2026
6086cec
Document coverage venv internals
leynos Apr 29, 2026
dad0cf6
Fix developer guide markdownlint
leynos Apr 29, 2026
6865952
Handle coverage venv bootstrap failures
leynos Apr 29, 2026
3db43ba
Extract run_python main helpers
leynos Apr 29, 2026
77ca00c
Combine run_python coverage execution helper
leynos Apr 29, 2026
c811865
Improve run_python observability and baseline test
leynos Apr 29, 2026
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
13 changes: 13 additions & 0 deletions .github/actions/generate-coverage/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## v1.3.14 (2026-04-28)

- Run Python coverage tooling in an isolated, job-local virtual environment
(`.venv-coverage`) instead of using `uv run --with`. The venv is created
once per process, reused across calls within the same job, and repaired
automatically when the Python executable is missing.
- Project dependencies are synced into the venv via `uv sync --inexact
--python`; `slipcover`, `pytest`, and `coverage` are installed via
`uv pip install --python` without `--system`.
- Broken-venv recovery: if `.venv-coverage` exists but its Python executable
is absent or a non-directory placeholder occupies its path, the directory is
removed and recreated before proceeding.

## v1.3.13 (2026-04-16)

- Override Cranelift coverage builds via
Expand Down
11 changes: 8 additions & 3 deletions .github/actions/generate-coverage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ projects. The action uses `cargo llvm-cov` (with `cargo nextest` by default)
when a `Cargo.toml` is present and `slipcover` with `pytest` when a
`pyproject.toml` is present. If the repository root does not contain a Cargo
manifest, set `cargo-manifest` to point to a nested `Cargo.toml`. It installs
`slipcover` and `pytest` automatically via `uv` before running the tests,
leveraging ``uv run --with`` so no system-level Python installs are required.
the project dependencies plus `slipcover`, `pytest`, and `coverage`
automatically via `uv` into an isolated throwaway virtual environment
(`.venv-coverage`) before running the tests, so no system-level Python installs
are required.
When Rust coverage is required, `cargo-llvm-cov` and `cargo-nextest` are
installed automatically. If both configuration files are present, coverage is
run for each language and the Cobertura reports are merged using
Expand Down Expand Up @@ -45,6 +47,7 @@ codegen-backend-related variables first, and then merges
`get_cargo_coverage_env(manifest_path)` on top so workflow-level Cranelift
exports are not treated as the default coverage behaviour.

<!-- markdownlint-disable MD013 -->
```mermaid
sequenceDiagram
actor GitHubActions
Expand Down Expand Up @@ -78,6 +81,7 @@ sequenceDiagram
_run_cargo-->>run_rust_py: stdout
end
```
<!-- markdownlint-enable MD013 -->

## Cranelift codegen backend support

Expand Down Expand Up @@ -123,6 +127,7 @@ Known limitations:

## Inputs

<!-- markdownlint-disable MD013 -->
| Name | Description | Required | Default |
| --- | --- | --- | --- |
| features | Enable Cargo (Rust) features; space- or comma-separated. | no | |
Expand All @@ -134,11 +139,11 @@ Known limitations:
| with-ratchet | Fail if coverage drops below baseline | no | `false` |
| artefact-name-suffix | Additional suffix appended to the uploaded coverage artefact | no | |
| baseline-rust-file | Rust baseline path | no | `.coverage-baseline.rust` |
<!-- markdownlint-disable-next-line MD013 -->
| baseline-python-file | Python baseline path | no | `.coverage-baseline.python` |
| with-cucumber-rs | Run cucumber-rs scenarios under coverage | no | `false` |
| cucumber-rs-features | Path to cucumber feature files | no | |
| cucumber-rs-args | Extra arguments for cucumber | no | |
<!-- markdownlint-enable MD013 -->

\* `lcov` is only supported for Rust projects, while `coveragepy` is only
supported for Python projects. Mixed projects must use `cobertura`.
Expand Down
199 changes: 179 additions & 20 deletions .github/actions/generate-coverage/scripts/run_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@

import collections.abc as cabc # noqa: TC003 - used at runtime
import contextlib
import os
import shutil
import typing as typ
from functools import lru_cache
from pathlib import Path

import typer
from cmd_utils_loader import run_cmd
from coverage_parsers import get_line_coverage_percent_from_cobertura
from plumbum import local
from plumbum.cmd import uv
from plumbum.commands.processes import ProcessExecutionError
from shared_utils import read_previous_coverage
Expand All @@ -27,6 +31,9 @@
FMT_OPT = typer.Option(..., envvar="DETECTED_FMT")
GITHUB_OUTPUT_OPT = typer.Option(..., envvar="GITHUB_OUTPUT")
BASELINE_OPT = typer.Option(None, envvar="BASELINE_PYTHON_FILE")
COVERAGE_VENV = Path(".venv-coverage")
TOOLING_PACKAGES: tuple[str, ...] = ("slipcover", "pytest", "coverage")
PROJECT_SYNC_ARGS: tuple[str, ...] = ("sync", "--inexact", "--python")

SLIPCOVER_ARGS: tuple[str, ...] = (
"-m",
Expand All @@ -40,18 +47,95 @@
)


def _uv_python_cmd() -> BoundCommand:
"""Return ``uv run`` configured with the required coverage tools."""
return uv[
"run",
"--with",
"slipcover",
"--with",
"pytest",
"--with",
"coverage",
"python",
]
def _find_coverage_python() -> Path | None:
"""Return the coverage venv Python executable path when it exists."""
if COVERAGE_VENV.is_symlink() or not COVERAGE_VENV.is_dir():
return None
candidates = (
COVERAGE_VENV / "bin" / "python",
COVERAGE_VENV / "Scripts" / "python.exe",
COVERAGE_VENV / "Scripts" / "python",
)
return next((c for c in candidates if c.is_file()), None)


def _remove_coverage_venv() -> None:
"""Remove the coverage venv directory or non-directory placeholder.

Uses ``shutil.rmtree`` for directories and ``Path.unlink`` for any
other filesystem object (e.g. a symlink or a stale file).
"""
if COVERAGE_VENV.is_dir() and not COVERAGE_VENV.is_symlink():
shutil.rmtree(COVERAGE_VENV)
else:
COVERAGE_VENV.unlink(missing_ok=True)


def _recreate_coverage_venv() -> Path:
"""Remove any existing broken venv, create a fresh one, and return its Python.

Returns
-------
Path
Absolute path to the Python executable inside the newly created venv.

Raises
------
RuntimeError
If the Python executable cannot be located after creation.
"""
if COVERAGE_VENV.exists() or COVERAGE_VENV.is_symlink():
typer.echo(
f"Coverage venv at {COVERAGE_VENV} is missing its Python "
"executable; recreating.",
err=True,
)
_remove_coverage_venv()
else:
typer.echo(f"Creating coverage venv at {COVERAGE_VENV}")
run_cmd(uv["venv", str(COVERAGE_VENV)])
python = _find_coverage_python()
if python is None:
msg = f"Coverage venv Python executable not found in {COVERAGE_VENV}"
raise RuntimeError(msg)
return python

Comment on lines +101 to +106
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Return a resolved interpreter path to satisfy the absolute-path contract.

Align implementation with the documented return contract. Line 101 and Line 160 currently propagate a relative interpreter path, while the docstrings state an absolute path. Resolve once before returning and cache the resolved value.

🔧 Proposed fix
 def _recreate_coverage_venv() -> Path:
@@
-    return python
+    return python.resolve()
@@
 def _ensure_coverage_venv() -> str:
@@
     python = _find_coverage_python()
     if python is None:
         python = _recreate_coverage_venv()
+    else:
+        python = python.resolve()
@@
-    return str(python)
+    return str(python)

Also applies to: 117-121, 160-160

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/actions/generate-coverage/scripts/run_python.py around lines 101 -
106, The helper _find_coverage_python currently returns a potentially relative
interpreter path; update it to resolve the interpreter to an absolute path
(e.g., using Path(...).resolve() or os.path.abspath) before returning and cache
that resolved value so subsequent calls return the same absolute path; apply the
same change at the other return sites referenced (the code paths around where
the local variable python is set and returned) and ensure the raised
RuntimeError still references COVERAGE_VENV but the returned value always
satisfies the documented absolute-path contract.


def _ensure_coverage_venv() -> str:
"""Create or repair the coverage venv and install project/test tooling.

Returns the Python executable path inside the isolated coverage venv.
"""
python = _find_coverage_python()
if python is None:
python = _recreate_coverage_venv()
typer.echo(f"Installing project dependencies into {COVERAGE_VENV}")
os.environ["UV_PROJECT_ENVIRONMENT"] = str(COVERAGE_VENV.resolve())
try:
run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except ProcessExecutionError as exc:
typer.echo(
f"uv sync failed with code {exc.retcode}: {exc.stderr}",
err=True,
)
raise
typer.echo(f"Installing coverage tooling {TOOLING_PACKAGES} into {COVERAGE_VENV}")
try:
run_cmd(uv["pip", "install", "--python", str(python), *TOOLING_PACKAGES])
except ProcessExecutionError as exc:
typer.echo(
f"uv pip install failed with code {exc.retcode}: {exc.stderr}",
err=True,
)
raise
return str(python)
Comment on lines +108 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

❌ New issue: Complex Method
_ensure_coverage_venv has a cyclomatic complexity of 9, threshold = 9

Suppress



@lru_cache(maxsize=1)
def _coverage_python_cmd() -> BoundCommand:
"""Return the coverage venv Python command, creating it on first use."""
python = _ensure_coverage_venv()
return local[python]


def _coverage_args(fmt: str, out: Path) -> list[str]:
Expand All @@ -65,17 +149,55 @@ def _coverage_args(fmt: str, out: Path) -> list[str]:


def coverage_cmd_for_fmt(fmt: str, out: Path) -> BoundCommand:
"""Return the slipcover command for the requested format."""
python_cmd = _uv_python_cmd()
"""Return the slipcover command for the requested coverage format.

Parameters
----------
fmt : str
Coverage format identifier. ``"cobertura"`` adds slipcover's
``--xml`` and ``--out`` flags; all other values produce a bare
slipcover/pytest invocation.
out : Path
Destination path for the coverage output file; passed to slipcover's
``--out`` argument when ``fmt == "cobertura"``.

Returns
-------
plumbum.commands.base.BoundCommand
A plumbum command that runs slipcover via the coverage venv Python.
"""
python_cmd = _coverage_python_cmd()
return python_cmd[_coverage_args(fmt, out)]


@contextlib.contextmanager
def tmp_coveragepy_xml(out: Path) -> cabc.Generator[Path]:
"""Generate a cobertura XML from coverage.py and clean up afterwards."""
"""Generate a Cobertura XML from coverage.py and clean it up afterwards.

Invokes ``python -m coverage xml -o <xml_tmp>`` using the coverage venv
Python, yields the temporary XML path for the caller to consume, and
removes the file on exit - whether the body raised or returned normally.

Parameters
----------
out : Path
Path to the ``.dat`` (coverage.py data) file. The temporary XML is
written to ``out.with_suffix(".xml")``.

Yields
------
Path
Absolute path to the freshly generated temporary Cobertura XML file.

Raises
------
typer.Exit
If ``coverage xml`` exits with a non-zero return code.
"""
xml_tmp = out.with_suffix(".xml")
python_cmd = _coverage_python_cmd()
try:
cmd = _uv_python_cmd()["-m", "coverage", "xml", "-o", str(xml_tmp)]
cmd = python_cmd["-m", "coverage", "xml", "-o", str(xml_tmp)]
run_cmd(cmd)
except ProcessExecutionError as exc:
typer.echo(
Expand All @@ -89,17 +211,54 @@ def tmp_coveragepy_xml(out: Path) -> cabc.Generator[Path]:
xml_tmp.unlink(missing_ok=True)


def _resolve_output_path(output_path: Path, lang: str) -> Path:
"""Return the effective output path, accounting for mixed-language projects.

Parameters
----------
output_path : Path
Base output path supplied by the caller.
lang : str
Detected project language. When ``"mixed"``, a ``.python`` infix
is inserted between the stem and the suffix.

Returns
-------
Path
Adjusted output path.
"""
if lang == "mixed":
return output_path.with_name(f"{output_path.stem}.python{output_path.suffix}")
return output_path


def main(
output_path: Path = OUTPUT_PATH_OPT,
lang: str = LANG_OPT,
fmt: str = FMT_OPT,
github_output: Path = GITHUB_OUTPUT_OPT,
baseline_file: Path | None = BASELINE_OPT,
) -> None:
"""Run slipcover coverage and write the output path to ``GITHUB_OUTPUT``."""
out = output_path
if lang == "mixed":
out = output_path.with_name(f"{output_path.stem}.python{output_path.suffix}")
"""Run slipcover coverage and write the result to ``GITHUB_OUTPUT``.

Parameters
----------
output_path : Path
Destination path for the coverage output file.
lang : str
Detected project language (``"rust"``, ``"python"``, or
``"mixed"``). When ``"mixed"``, the output file is renamed to
include a ``.python`` infix.
fmt : str
Coverage format identifier passed to :func:`coverage_cmd_for_fmt`.
github_output : Path
Path to the ``GITHUB_OUTPUT`` append file where ``file=`` and
``percent=`` are written.
baseline_file : Path or None
Optional path to a previous coverage baseline file. When present,
the previous percentage is echoed to the log.
"""
out = _resolve_output_path(output_path, lang)
out.parent.mkdir(parents=True, exist_ok=True)

cmd = coverage_cmd_for_fmt(fmt, out)
Expand Down
Loading
Loading