From 2b71fb7ccffa33700af7613e6717eee7877067a2 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 01:44:33 +0200 Subject: [PATCH 01/51] Run Python coverage tooling in a venv Create a throwaway coverage virtual environment and install `slipcover`, `pytest`, and `coverage` into it with `uv pip --python`. --- .../generate-coverage/scripts/run_python.py | 40 +++--- .../generate-coverage/tests/test_scripts.py | 126 +++++++++++++----- 2 files changed, 116 insertions(+), 50 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 6e976a54..ff003f7f 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -15,6 +15,7 @@ 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 @@ -27,6 +28,8 @@ 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") SLIPCOVER_ARGS: tuple[str, ...] = ( "-m", @@ -40,18 +43,15 @@ ) -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 create_venv() -> str: + """Create a throwaway venv for coverage tooling; return python path.""" + run_cmd(uv["venv", str(COVERAGE_VENV)]) + return str(COVERAGE_VENV / "bin" / "python") + + +def install_coverage_tools(python: str) -> None: + """Install coverage tooling into the throwaway venv.""" + run_cmd(uv["pip", "install", "--python", python, *TOOLING_PACKAGES]) def _coverage_args(fmt: str, out: Path) -> list[str]: @@ -64,18 +64,17 @@ def _coverage_args(fmt: str, out: Path) -> list[str]: return args -def coverage_cmd_for_fmt(fmt: str, out: Path) -> BoundCommand: +def coverage_cmd_for_fmt(fmt: str, out: Path, python: str) -> BoundCommand: """Return the slipcover command for the requested format.""" - python_cmd = _uv_python_cmd() - return python_cmd[_coverage_args(fmt, out)] + return local[python][_coverage_args(fmt, out)] @contextlib.contextmanager -def tmp_coveragepy_xml(out: Path) -> cabc.Generator[Path]: +def tmp_coveragepy_xml(out: Path, python: str) -> cabc.Generator[Path]: """Generate a cobertura XML from coverage.py and clean up afterwards.""" xml_tmp = out.with_suffix(".xml") try: - cmd = _uv_python_cmd()["-m", "coverage", "xml", "-o", str(xml_tmp)] + cmd = local[python]["-m", "coverage", "xml", "-o", str(xml_tmp)] run_cmd(cmd) except ProcessExecutionError as exc: typer.echo( @@ -102,14 +101,17 @@ def main( out = output_path.with_name(f"{output_path.stem}.python{output_path.suffix}") out.parent.mkdir(parents=True, exist_ok=True) - cmd = coverage_cmd_for_fmt(fmt, out) + python = create_venv() + install_coverage_tools(python) + + cmd = coverage_cmd_for_fmt(fmt, out, python) try: run_cmd(cmd, method="run_fg") except ProcessExecutionError as exc: raise typer.Exit(code=exc.retcode or 1) from exc if fmt == "coveragepy": - with tmp_coveragepy_xml(out) as xml_tmp: + with tmp_coveragepy_xml(out, python) as xml_tmp: percent = get_line_coverage_percent_from_cobertura(xml_tmp) Path(".coverage").replace(out) else: diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index e9ab890b..e3dff319 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1743,21 +1743,24 @@ def run_python_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType: return _load_module(monkeypatch, "run_python") -def _assert_uv_run_base(parts: list[str]) -> None: - """Assert that parts starts with 'uv run'.""" - assert Path(parts[0]).name == "uv" - assert parts[1] == "run" +def _coverage_python(run_python_module: ModuleType) -> str: + """Return the expected throwaway venv Python path.""" + return str(run_python_module.COVERAGE_VENV / "bin" / "python") -def _assert_uv_command_structure(parts: list[str]) -> None: - """Verify common uv run command structure with slipcover and pytest.""" - _assert_uv_run_base(parts) - python_idx = parts.index("python") - slip_idx = parts.index("-m", python_idx + 1) +def _assert_python_command_structure(parts: list[str]) -> None: + """Verify common venv Python command structure with slipcover and pytest.""" + assert Path(parts[0]).name == "python" + slip_idx = parts.index("-m", 1) assert parts[slip_idx : slip_idx + 3] == ["-m", "slipcover", "--branch"] assert parts[-3:] == ["-m", "pytest", "-v"] +def _assert_coverage_python_path(actual: str, expected: str) -> None: + """Assert that a formulated command points at the coverage venv Python.""" + assert Path(actual).parts[-3:] == Path(expected).parts[-3:] + + def _assert_tokens_in_order(parts: list[str], *tokens: str) -> None: """Assert that ``tokens`` appear in ``parts`` while preserving order.""" iterator = iter(parts) @@ -1779,13 +1782,59 @@ def _assert_flag_value_pair(parts: list[str], flag: str, value: str) -> None: pytest.fail(message) -def test_uv_python_cmd_bundles_dependencies(run_python_module: ModuleType) -> None: - """The helper wires ``uv run`` with slipcover/pytest/coverage.""" - cmd = run_python_module._uv_python_cmd() +def test_create_venv_returns_coverage_python( + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper creates the throwaway coverage venv and returns its Python.""" + recorded: list[list[str]] = [] + + def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: + recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] + + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + + python = run_python_module.create_venv() + + assert python == _coverage_python(run_python_module) + assert len(recorded) == 1 + parts = recorded[0] + assert Path(parts[0]).name == "uv" + assert parts[1:] == ["venv", str(run_python_module.COVERAGE_VENV)] + + +def test_install_coverage_tools_targets_venv_python( + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tooling installs into the throwaway venv instead of the system Python.""" + recorded: list[list[str]] = [] + + def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: + recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] + + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + + python = _coverage_python(run_python_module) + run_python_module.install_coverage_tools(python) + + assert len(recorded) == 1 + parts = recorded[0] + assert Path(parts[0]).name == "uv" + assert parts[1:5] == ["pip", "install", "--python", python] + assert "--system" not in parts + assert set(run_python_module.TOOLING_PACKAGES).issubset(parts) + + +def test_coverage_cmd_uses_venv_python(run_python_module: ModuleType) -> None: + """The helper wires slipcover/pytest through the throwaway venv.""" + python = _coverage_python(run_python_module) + cmd = run_python_module.coverage_cmd_for_fmt( + "coveragepy", Path("coverage.dat"), python + ) parts = list(cmd.formulate()) - _assert_uv_run_base(parts) - assert parts.count("--with") >= 3 - assert {"slipcover", "pytest", "coverage"}.issubset(parts) + _assert_coverage_python_path(parts[0], python) + _assert_python_command_structure(parts) def _get_coverage_cmd_parts( @@ -1796,16 +1845,18 @@ def _get_coverage_cmd_parts( ) -> tuple[list[str], Path]: """Build coverage command for format and return parts and output path.""" out = tmp_path / f"cov.{suffix}" - cmd = run_python_module.coverage_cmd_for_fmt(fmt, out) + cmd = run_python_module.coverage_cmd_for_fmt( + fmt, out, _coverage_python(run_python_module) + ) parts = list(cmd.formulate()) - _assert_uv_command_structure(parts) + _assert_python_command_structure(parts) return parts, out -def test_coverage_cmd_cobertura_uses_uv( +def test_coverage_cmd_cobertura_uses_venv_python( tmp_path: Path, run_python_module: ModuleType ) -> None: - """Cobertura format invokes slipcover with ``--xml`` using ``uv run``.""" + """Cobertura format invokes slipcover with ``--xml`` using the venv Python.""" parts, out = _get_coverage_cmd_parts( tmp_path, run_python_module, "cobertura", "xml" ) @@ -1833,10 +1884,10 @@ def test_non_cobertura_formats_do_not_emit_out_flag( assert "--out" not in parts -def test_tmp_coveragepy_xml_invokes_uv( +def test_tmp_coveragepy_xml_invokes_venv_python( tmp_path: Path, run_python_module: ModuleType ) -> None: - """The coverage.py exporter also reuses the uv helper.""" + """The coverage.py exporter also reuses the venv Python.""" out = tmp_path / "coveragepy.dat" xml_path = out.with_suffix(".xml") recorded: dict[str, list[str]] = {} @@ -1847,15 +1898,15 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: run_python_module.run_cmd = fake_run_cmd # type: ignore[assignment] - with run_python_module.tmp_coveragepy_xml(out) as generated: + python = _coverage_python(run_python_module) + with run_python_module.tmp_coveragepy_xml(out, python) as generated: assert generated == xml_path assert xml_path.exists() assert not xml_path.exists() parts = recorded["cmd"] - _assert_uv_run_base(parts) + _assert_coverage_python_path(parts[0], python) assert parts[-5:] == ["-m", "coverage", "xml", "-o", str(xml_path)] - assert {"coverage", "pytest", "slipcover"}.issubset(parts) def test_run_python_cobertura_passes_out_flag( @@ -1870,17 +1921,30 @@ def test_run_python_cobertura_passes_out_flag( encoding="utf-8", ) github_output = tmp_path / "gh.txt" - recorded: dict[str, list[str]] = {} + recorded: list[tuple[list[str], str | None]] = [] - def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: - recorded["cmd"] = list(cmd.formulate()) # type: ignore[attr-defined] + def fake_run_cmd(cmd: object, *_args: object, **kwargs: object) -> None: + recorded.append( + (list(cmd.formulate()), typ.cast("str | None", kwargs.get("method"))) + ) # type: ignore[attr-defined] monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) run_python_module.main(output, "python", "cobertura", github_output, None) - parts = recorded["cmd"] - _assert_uv_command_structure(parts) + assert len(recorded) == 3 + assert recorded[0][0][1:] == ["venv", str(run_python_module.COVERAGE_VENV)] + install_parts = recorded[1][0] + assert install_parts[1:5] == [ + "pip", + "install", + "--python", + _coverage_python(run_python_module), + ] + assert "--system" not in install_parts + parts = recorded[2][0] + assert recorded[2][1] == "run_fg" + _assert_python_command_structure(parts) _assert_tokens_in_order(parts, "--xml", "--out") _assert_flag_value_pair(parts, "--out", str(output)) data = github_output.read_text().splitlines() @@ -1957,7 +2021,7 @@ def fake_run_cmd(*_: object, **__: object) -> None: monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) @contextlib.contextmanager - def fake_tmp_coveragepy_xml(out: Path) -> typ.Iterator[Path]: + def fake_tmp_coveragepy_xml(out: Path, _python: str) -> typ.Iterator[Path]: xml_path = tmp_path / "coverage.xml" xml_path.write_text( "", @@ -2003,7 +2067,7 @@ def fake_run_cmd(*_: object, **__: object) -> None: monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) @contextlib.contextmanager - def fake_tmp_coveragepy_xml(out: Path) -> typ.Iterator[Path]: + def fake_tmp_coveragepy_xml(out: Path, _python: str) -> typ.Iterator[Path]: xml_path = tmp_path / "coverage.xml" xml_path.write_text("", encoding="utf-8") try: From 009141bad52b38b6e456bbad966068fc9a9c0c44 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 01:45:21 +0200 Subject: [PATCH 02/51] Use uv tool run for Ruff make targets Avoid relying on the separate `uvx` shim being present on PATH when running formatting and lint targets from automation. --- Makefile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 7949bb03..46812455 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ endif uv sync --group dev lint: ## Check test scripts and actions - uvx ruff check + uv tool run ruff check find .github/actions -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ | xargs -r -0 -n1 action-validator @@ -58,12 +58,12 @@ typecheck: .venv ## Run static type checking with Ty --extra-search-path .github/actions/macos-package/scripts \ .github/actions/macos-package/scripts fmt: ## Format Python files and auto-fix selected lint rules - uvx ruff format - uvx ruff check --select $(RUFF_FIX_RULES) --fix + uv tool run ruff format + uv tool run ruff check --select $(RUFF_FIX_RULES) --fix check-fmt: ## Check Python formatting without modifying files - uvx ruff format --check - uvx ruff check --select $(RUFF_FIX_RULES) + uv tool run ruff format --check + uv tool run ruff check --select $(RUFF_FIX_RULES) markdownlint: ## Lint Markdown files find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT) From c6669c4d0823cd51c6bbcca10dfdb1c844acab13 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 01:46:23 +0200 Subject: [PATCH 03/51] Resolve tool paths in make checks Prefer the common user-local install locations for `uv` and `action-validator` so automation with a narrow PATH can still run the format, lint, and typecheck targets. --- Makefile | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 46812455..aae0b50c 100644 --- a/Makefile +++ b/Makefile @@ -14,25 +14,27 @@ clean: ## Remove transient artefacts rm -rf .venv .pytest_cache .ruff_cache workspace/.ruff_cache BUILD_JOBS ?= +ACTION_VALIDATOR ?= $(if $(wildcard $(HOME)/.cargo/bin/action-validator),$(HOME)/.cargo/bin/action-validator,action-validator) MDLINT ?= markdownlint NIXIE ?= nixie RUFF_FIX_RULES ?= D202,I001 +UV ?= $(if $(wildcard $(HOME)/.local/bin/uv),$(HOME)/.local/bin/uv,uv) test: .venv ## Run tests - uv run --with typer --with packaging --with plumbum --with pyyaml --with pytest-xdist pytest -n auto --dist worksteal -v + $(UV) run --with typer --with packaging --with plumbum --with pyyaml --with pytest-xdist pytest -n auto --dist worksteal -v # Truthy values: 1, true, TRUE, True, yes, YES, Yes, on, ON, On ifneq ($(strip $(filter 1 true TRUE True yes YES Yes on ON On,$(ACT_WORKFLOW_TESTS))),) - ACT_WORKFLOW_TESTS=1 uv run --with typer --with packaging --with plumbum --with pyyaml --with pytest-xdist pytest tests/workflows -v + ACT_WORKFLOW_TESTS=1 $(UV) run --with typer --with packaging --with plumbum --with pyyaml --with pytest-xdist pytest tests/workflows -v endif .venv: - uv venv - uv sync --group dev + $(UV) venv + $(UV) sync --group dev lint: ## Check test scripts and actions - uv tool run ruff check + $(UV) tool run ruff check find .github/actions -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ - | xargs -r -0 -n1 action-validator + | xargs -r -0 -n1 $(ACTION_VALIDATOR) typecheck: .venv ## Run static type checking with Ty ./.venv/bin/ty check \ @@ -58,12 +60,12 @@ typecheck: .venv ## Run static type checking with Ty --extra-search-path .github/actions/macos-package/scripts \ .github/actions/macos-package/scripts fmt: ## Format Python files and auto-fix selected lint rules - uv tool run ruff format - uv tool run ruff check --select $(RUFF_FIX_RULES) --fix + $(UV) tool run ruff format + $(UV) tool run ruff check --select $(RUFF_FIX_RULES) --fix check-fmt: ## Check Python formatting without modifying files - uv tool run ruff format --check - uv tool run ruff check --select $(RUFF_FIX_RULES) + $(UV) tool run ruff format --check + $(UV) tool run ruff check --select $(RUFF_FIX_RULES) markdownlint: ## Lint Markdown files find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT) From fb502bc951723dc75fa69b7e8f6673acd7f10efb Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 02:58:39 +0200 Subject: [PATCH 04/51] Refine coverage venv command handling Cache the coverage-capable Python command behind one helper, reuse an existing coverage venv when present, and resolve the venv interpreter across POSIX and Windows layouts. Extend the run_python tests to cover cached command reuse and the coverage.py export path using the same venv interpreter. --- .../generate-coverage/scripts/run_python.py | 48 ++++-- .../generate-coverage/tests/test_scripts.py | 162 ++++++++++++++---- 2 files changed, 166 insertions(+), 44 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index ff003f7f..91f2b00f 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -30,6 +30,7 @@ BASELINE_OPT = typer.Option(None, envvar="BASELINE_PYTHON_FILE") COVERAGE_VENV = Path(".venv-coverage") TOOLING_PACKAGES: tuple[str, ...] = ("slipcover", "pytest", "coverage") +_COVERAGE_PYTHON_CMD: BoundCommand | None = None SLIPCOVER_ARGS: tuple[str, ...] = ( "-m", @@ -43,10 +44,26 @@ ) +def _coverage_python_path() -> Path: + """Return the Python executable path inside the coverage venv.""" + candidates = ( + COVERAGE_VENV / "bin" / "python", + COVERAGE_VENV / "Scripts" / "python.exe", + COVERAGE_VENV / "Scripts" / "python", + ) + for candidate in candidates: + if candidate.exists(): + return candidate + paths = ", ".join(str(candidate) for candidate in candidates) + msg = f"Coverage venv Python executable not found; checked: {paths}" + raise RuntimeError(msg) + + def create_venv() -> str: """Create a throwaway venv for coverage tooling; return python path.""" - run_cmd(uv["venv", str(COVERAGE_VENV)]) - return str(COVERAGE_VENV / "bin" / "python") + if not COVERAGE_VENV.exists(): + run_cmd(uv["venv", str(COVERAGE_VENV)]) + return str(_coverage_python_path()) def install_coverage_tools(python: str) -> None: @@ -54,6 +71,16 @@ def install_coverage_tools(python: str) -> None: run_cmd(uv["pip", "install", "--python", python, *TOOLING_PACKAGES]) +def _coverage_python_cmd() -> BoundCommand: + """Return a python command with coverage tooling available.""" + global _COVERAGE_PYTHON_CMD + if _COVERAGE_PYTHON_CMD is None: + python = create_venv() + install_coverage_tools(python) + _COVERAGE_PYTHON_CMD = local[python] + return _COVERAGE_PYTHON_CMD + + def _coverage_args(fmt: str, out: Path) -> list[str]: """Return the slipcover/pytest argv for the requested format.""" args: list[str] = [*SLIPCOVER_ARGS] @@ -64,17 +91,19 @@ def _coverage_args(fmt: str, out: Path) -> list[str]: return args -def coverage_cmd_for_fmt(fmt: str, out: Path, python: str) -> BoundCommand: +def coverage_cmd_for_fmt(fmt: str, out: Path) -> BoundCommand: """Return the slipcover command for the requested format.""" - return local[python][_coverage_args(fmt, out)] + python_cmd = _coverage_python_cmd() + return python_cmd[_coverage_args(fmt, out)] @contextlib.contextmanager -def tmp_coveragepy_xml(out: Path, python: str) -> cabc.Generator[Path]: +def tmp_coveragepy_xml(out: Path) -> cabc.Generator[Path]: """Generate a cobertura XML from coverage.py and clean up afterwards.""" xml_tmp = out.with_suffix(".xml") + python_cmd = _coverage_python_cmd() try: - cmd = local[python]["-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( @@ -101,17 +130,14 @@ def main( out = output_path.with_name(f"{output_path.stem}.python{output_path.suffix}") out.parent.mkdir(parents=True, exist_ok=True) - python = create_venv() - install_coverage_tools(python) - - cmd = coverage_cmd_for_fmt(fmt, out, python) + cmd = coverage_cmd_for_fmt(fmt, out) try: run_cmd(cmd, method="run_fg") except ProcessExecutionError as exc: raise typer.Exit(code=exc.retcode or 1) from exc if fmt == "coveragepy": - with tmp_coveragepy_xml(out, python) as xml_tmp: + with tmp_coveragepy_xml(out) as xml_tmp: percent = get_line_coverage_percent_from_cobertura(xml_tmp) Path(".coverage").replace(out) else: diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index e3dff319..a7714eb7 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1748,6 +1748,20 @@ def _coverage_python(run_python_module: ModuleType) -> str: return str(run_python_module.COVERAGE_VENV / "bin" / "python") +def _set_fake_coverage_python_cmd( + monkeypatch: pytest.MonkeyPatch, + run_python_module: ModuleType, +) -> str: + """Patch the coverage Python command helper to avoid creating a venv.""" + python = _coverage_python(run_python_module) + monkeypatch.setattr( + run_python_module, + "_coverage_python_cmd", + lambda: local[python], + ) + return python + + def _assert_python_command_structure(parts: list[str]) -> None: """Verify common venv Python command structure with slipcover and pytest.""" assert Path(parts[0]).name == "python" @@ -1783,24 +1797,52 @@ def _assert_flag_value_pair(parts: list[str], flag: str, value: str) -> None: def test_create_venv_returns_coverage_python( + tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: """The helper creates the throwaway coverage venv and returns its Python.""" recorded: list[list[str]] = [] + coverage_venv = tmp_path / ".venv-coverage" def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] + python = coverage_venv / "bin" / "python" + python.parent.mkdir(parents=True) + python.touch() + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) python = run_python_module.create_venv() - assert python == _coverage_python(run_python_module) + assert python == str(coverage_venv / "bin" / "python") assert len(recorded) == 1 parts = recorded[0] assert Path(parts[0]).name == "uv" - assert parts[1:] == ["venv", str(run_python_module.COVERAGE_VENV)] + assert parts[1:] == ["venv", str(coverage_venv)] + + +def test_create_venv_reuses_existing_coverage_venv( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper does not recreate an existing coverage venv.""" + coverage_venv = tmp_path / ".venv-coverage" + python_path = coverage_venv / "Scripts" / "python.exe" + python_path.parent.mkdir(parents=True) + python_path.touch() + recorded: list[list[str]] = [] + + def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: + recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] + + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + + assert run_python_module.create_venv() == str(python_path) + assert recorded == [] def test_install_coverage_tools_targets_venv_python( @@ -1826,12 +1868,48 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: assert set(run_python_module.TOOLING_PACKAGES).issubset(parts) -def test_coverage_cmd_uses_venv_python(run_python_module: ModuleType) -> None: +def test_coverage_python_cmd_prepares_tools_once( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The coverage Python command lazily creates and installs tooling once.""" + coverage_venv = tmp_path / ".venv-coverage" + python_path = coverage_venv / "bin" / "python" + recorded: list[list[str]] = [] + + def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: + recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] + if recorded[-1][1] == "venv": + python_path.parent.mkdir(parents=True) + python_path.touch() + + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + + first = run_python_module._coverage_python_cmd() + second = run_python_module._coverage_python_cmd() + + assert first is second + parts = list(first.formulate()) + _assert_coverage_python_path(parts[0], str(python_path)) + assert len(recorded) == 2 + assert recorded[0][1:] == ["venv", str(coverage_venv)] + assert recorded[1][1:5] == [ + "pip", + "install", + "--python", + str(python_path), + ] + + +def test_coverage_cmd_uses_venv_python( + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: """The helper wires slipcover/pytest through the throwaway venv.""" - python = _coverage_python(run_python_module) - cmd = run_python_module.coverage_cmd_for_fmt( - "coveragepy", Path("coverage.dat"), python - ) + python = _set_fake_coverage_python_cmd(monkeypatch, run_python_module) + cmd = run_python_module.coverage_cmd_for_fmt("coveragepy", Path("coverage.dat")) parts = list(cmd.formulate()) _assert_coverage_python_path(parts[0], python) _assert_python_command_structure(parts) @@ -1840,25 +1918,27 @@ def test_coverage_cmd_uses_venv_python(run_python_module: ModuleType) -> None: def _get_coverage_cmd_parts( tmp_path: Path, run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, fmt: str, suffix: str, ) -> tuple[list[str], Path]: """Build coverage command for format and return parts and output path.""" out = tmp_path / f"cov.{suffix}" - cmd = run_python_module.coverage_cmd_for_fmt( - fmt, out, _coverage_python(run_python_module) - ) + _set_fake_coverage_python_cmd(monkeypatch, run_python_module) + cmd = run_python_module.coverage_cmd_for_fmt(fmt, out) parts = list(cmd.formulate()) _assert_python_command_structure(parts) return parts, out def test_coverage_cmd_cobertura_uses_venv_python( - tmp_path: Path, run_python_module: ModuleType + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Cobertura format invokes slipcover with ``--xml`` using the venv Python.""" parts, out = _get_coverage_cmd_parts( - tmp_path, run_python_module, "cobertura", "xml" + tmp_path, run_python_module, monkeypatch, "cobertura", "xml" ) assert parts.count("--xml") == 1 _assert_tokens_in_order(parts, "--xml", "--out") @@ -1866,26 +1946,34 @@ def test_coverage_cmd_cobertura_uses_venv_python( def test_coverage_cmd_default_branch_has_shared_args( - tmp_path: Path, run_python_module: ModuleType + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Non-Cobertura formats reuse the shared slipcover arguments.""" parts, out = _get_coverage_cmd_parts( - tmp_path, run_python_module, "coveragepy", "dat" + tmp_path, run_python_module, monkeypatch, "coveragepy", "dat" ) assert "--xml" not in parts assert str(out) not in parts def test_non_cobertura_formats_do_not_emit_out_flag( - tmp_path: Path, run_python_module: ModuleType + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Ensure slipcover output targeting stays isolated to Cobertura runs.""" - parts, _ = _get_coverage_cmd_parts(tmp_path, run_python_module, "coveragepy", "dat") + parts, _ = _get_coverage_cmd_parts( + tmp_path, run_python_module, monkeypatch, "coveragepy", "dat" + ) assert "--out" not in parts def test_tmp_coveragepy_xml_invokes_venv_python( - tmp_path: Path, run_python_module: ModuleType + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, ) -> None: """The coverage.py exporter also reuses the venv Python.""" out = tmp_path / "coveragepy.dat" @@ -1898,8 +1986,8 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: run_python_module.run_cmd = fake_run_cmd # type: ignore[assignment] - python = _coverage_python(run_python_module) - with run_python_module.tmp_coveragepy_xml(out, python) as generated: + python = _set_fake_coverage_python_cmd(monkeypatch, run_python_module) + with run_python_module.tmp_coveragepy_xml(out) as generated: assert generated == xml_path assert xml_path.exists() @@ -1929,21 +2017,13 @@ def fake_run_cmd(cmd: object, *_args: object, **kwargs: object) -> None: ) # type: ignore[attr-defined] monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + _set_fake_coverage_python_cmd(monkeypatch, run_python_module) run_python_module.main(output, "python", "cobertura", github_output, None) - assert len(recorded) == 3 - assert recorded[0][0][1:] == ["venv", str(run_python_module.COVERAGE_VENV)] - install_parts = recorded[1][0] - assert install_parts[1:5] == [ - "pip", - "install", - "--python", - _coverage_python(run_python_module), - ] - assert "--system" not in install_parts - parts = recorded[2][0] - assert recorded[2][1] == "run_fg" + assert len(recorded) == 1 + parts = recorded[0][0] + assert recorded[0][1] == "run_fg" _assert_python_command_structure(parts) _assert_tokens_in_order(parts, "--xml", "--out") _assert_flag_value_pair(parts, "--out", str(output)) @@ -2019,9 +2099,14 @@ def fake_run_cmd(*_: object, **__: object) -> None: return None monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + python = _set_fake_coverage_python_cmd(monkeypatch, run_python_module) + captured_python: list[str] = [] @contextlib.contextmanager - def fake_tmp_coveragepy_xml(out: Path, _python: str) -> typ.Iterator[Path]: + def fake_tmp_coveragepy_xml(out: Path) -> typ.Iterator[Path]: + captured_python.append( + next(iter(run_python_module._coverage_python_cmd().formulate())) + ) xml_path = tmp_path / "coverage.xml" xml_path.write_text( "", @@ -2038,6 +2123,9 @@ def fake_tmp_coveragepy_xml(out: Path, _python: str) -> typ.Iterator[Path]: run_python_module.main(output, "python", "coveragepy", github_output, None) + assert len(captured_python) == 1 + _assert_coverage_python_path(captured_python[0], python) + captured = capsys.readouterr() assert "Current coverage: 0.00%" in captured.out @@ -2065,9 +2153,14 @@ def fake_run_cmd(*_: object, **__: object) -> None: return None monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + python = _set_fake_coverage_python_cmd(monkeypatch, run_python_module) + captured_python: list[str] = [] @contextlib.contextmanager - def fake_tmp_coveragepy_xml(out: Path, _python: str) -> typ.Iterator[Path]: + def fake_tmp_coveragepy_xml(out: Path) -> typ.Iterator[Path]: + captured_python.append( + next(iter(run_python_module._coverage_python_cmd().formulate())) + ) xml_path = tmp_path / "coverage.xml" xml_path.write_text("", encoding="utf-8") try: @@ -2082,6 +2175,9 @@ def fake_tmp_coveragepy_xml(out: Path, _python: str) -> typ.Iterator[Path]: with pytest.raises(run_python_module.typer.Exit) as excinfo: run_python_module.main(output, "python", "coveragepy", github_output, None) + assert len(captured_python) == 1 + _assert_coverage_python_path(captured_python[0], python) + assert _exit_code(excinfo.value) == 1 assert coverage_file.exists() assert not github_output.exists() From a93f3b71e2dd2eec4e4e0bfb18d73138b718aba4 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:24:39 +0200 Subject: [PATCH 05/51] Recreate broken coverage venvs Repair `.venv-coverage` when the directory exists without a usable Python interpreter, so coverage setup can recover from incomplete or stale venvs. Reject non-file interpreter placeholders before reusing an existing venv. --- .../generate-coverage/scripts/run_python.py | 13 ++++- .../generate-coverage/tests/test_scripts.py | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 91f2b00f..b9833908 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -9,6 +9,7 @@ import collections.abc as cabc # noqa: TC003 - used at runtime import contextlib +import shutil import typing as typ from pathlib import Path @@ -52,7 +53,7 @@ def _coverage_python_path() -> Path: COVERAGE_VENV / "Scripts" / "python", ) for candidate in candidates: - if candidate.exists(): + if candidate.is_file(): return candidate paths = ", ".join(str(candidate) for candidate in candidates) msg = f"Coverage venv Python executable not found; checked: {paths}" @@ -63,7 +64,15 @@ def create_venv() -> str: """Create a throwaway venv for coverage tooling; return python path.""" if not COVERAGE_VENV.exists(): run_cmd(uv["venv", str(COVERAGE_VENV)]) - return str(_coverage_python_path()) + try: + return str(_coverage_python_path()) + except RuntimeError: + if COVERAGE_VENV.is_dir() and not COVERAGE_VENV.is_symlink(): + shutil.rmtree(COVERAGE_VENV) + else: + COVERAGE_VENV.unlink(missing_ok=True) + run_cmd(uv["venv", str(COVERAGE_VENV)]) + return str(_coverage_python_path()) def install_coverage_tools(python: str) -> None: diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index a7714eb7..aec44c13 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1845,6 +1845,56 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: assert recorded == [] +def test_create_venv_recreates_broken_coverage_venv( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper repairs an existing venv that has no Python executable.""" + coverage_venv = tmp_path / ".venv-coverage" + coverage_venv.mkdir() + recorded: list[list[str]] = [] + + def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: + recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] + python_path = coverage_venv / "bin" / "python" + python_path.parent.mkdir(parents=True) + python_path.touch() + + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + + assert run_python_module.create_venv() == str(coverage_venv / "bin" / "python") + assert len(recorded) == 1 + assert recorded[0][1:] == ["venv", str(coverage_venv)] + + +def test_create_venv_recreates_invalid_python_candidate( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper rejects non-file Python placeholders before reuse.""" + coverage_venv = tmp_path / ".venv-coverage" + (coverage_venv / "bin" / "python").mkdir(parents=True) + recorded: list[list[str]] = [] + + def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: + recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] + python_path = coverage_venv / "Scripts" / "python.exe" + python_path.parent.mkdir(parents=True) + python_path.touch() + + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + + assert run_python_module.create_venv() == str( + coverage_venv / "Scripts" / "python.exe" + ) + assert len(recorded) == 1 + assert recorded[0][1:] == ["venv", str(coverage_venv)] + + def test_install_coverage_tools_targets_venv_python( run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, From cb49e90e54632724f6413193532763501085c6da Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:25:33 +0200 Subject: [PATCH 06/51] Bundle coverage format test parameters Introduce `CoverageFmtSpec` so the coverage command test helper stays within the argument-count limit while preserving the same test behaviour. --- .../generate-coverage/tests/test_scripts.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index aec44c13..f1122f19 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1965,17 +1965,24 @@ def test_coverage_cmd_uses_venv_python( _assert_python_command_structure(parts) +@dataclasses.dataclass(frozen=True) +class CoverageFmtSpec: + """Pairs a coverage format name with the corresponding file suffix.""" + + fmt: str + suffix: str + + def _get_coverage_cmd_parts( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, - fmt: str, - suffix: str, + spec: CoverageFmtSpec, ) -> tuple[list[str], Path]: """Build coverage command for format and return parts and output path.""" - out = tmp_path / f"cov.{suffix}" + out = tmp_path / f"cov.{spec.suffix}" _set_fake_coverage_python_cmd(monkeypatch, run_python_module) - cmd = run_python_module.coverage_cmd_for_fmt(fmt, out) + cmd = run_python_module.coverage_cmd_for_fmt(spec.fmt, out) parts = list(cmd.formulate()) _assert_python_command_structure(parts) return parts, out @@ -1988,7 +1995,7 @@ def test_coverage_cmd_cobertura_uses_venv_python( ) -> None: """Cobertura format invokes slipcover with ``--xml`` using the venv Python.""" parts, out = _get_coverage_cmd_parts( - tmp_path, run_python_module, monkeypatch, "cobertura", "xml" + tmp_path, run_python_module, monkeypatch, CoverageFmtSpec("cobertura", "xml") ) assert parts.count("--xml") == 1 _assert_tokens_in_order(parts, "--xml", "--out") @@ -2002,7 +2009,7 @@ def test_coverage_cmd_default_branch_has_shared_args( ) -> None: """Non-Cobertura formats reuse the shared slipcover arguments.""" parts, out = _get_coverage_cmd_parts( - tmp_path, run_python_module, monkeypatch, "coveragepy", "dat" + tmp_path, run_python_module, monkeypatch, CoverageFmtSpec("coveragepy", "dat") ) assert "--xml" not in parts assert str(out) not in parts @@ -2015,7 +2022,7 @@ def test_non_cobertura_formats_do_not_emit_out_flag( ) -> None: """Ensure slipcover output targeting stays isolated to Cobertura runs.""" parts, _ = _get_coverage_cmd_parts( - tmp_path, run_python_module, monkeypatch, "coveragepy", "dat" + tmp_path, run_python_module, monkeypatch, CoverageFmtSpec("coveragepy", "dat") ) assert "--out" not in parts From 6a8c09b7fd6a0f3e462d26e1b856b06e72121f06 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:26:32 +0200 Subject: [PATCH 07/51] Mark coverage XML stub parameters unused Rename unused `fake_tmp_coveragepy_xml` parameters in coveragepy tests so the stubs clearly follow the unused-argument convention. --- .github/actions/generate-coverage/tests/test_scripts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index f1122f19..eb59761c 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -2160,7 +2160,7 @@ def fake_run_cmd(*_: object, **__: object) -> None: captured_python: list[str] = [] @contextlib.contextmanager - def fake_tmp_coveragepy_xml(out: Path) -> typ.Iterator[Path]: + def fake_tmp_coveragepy_xml(_out: Path) -> typ.Iterator[Path]: captured_python.append( next(iter(run_python_module._coverage_python_cmd().formulate())) ) @@ -2214,7 +2214,7 @@ def fake_run_cmd(*_: object, **__: object) -> None: captured_python: list[str] = [] @contextlib.contextmanager - def fake_tmp_coveragepy_xml(out: Path) -> typ.Iterator[Path]: + def fake_tmp_coveragepy_xml(_out: Path) -> typ.Iterator[Path]: captured_python.append( next(iter(run_python_module._coverage_python_cmd().formulate())) ) From 2ab45d65bf7a3965e0e541c551b4f2618151fbc5 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:30:27 +0200 Subject: [PATCH 08/51] Document coverage venv lifecycle Add observable setup and reuse logging around the coverage venv, expand helper docstrings, and document the venv lifecycle and singleton model. Cover broken-cache recovery and missing interpreter detection in the run_python tests. --- .../generate-coverage/scripts/run_python.py | 75 +++++++++++++++---- .../generate-coverage/tests/test_scripts.py | 46 ++++++++++++ docs/generate-coverage-design.md | 43 +++++++++++ 3 files changed, 151 insertions(+), 13 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index b9833908..870ac0a7 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -31,6 +31,11 @@ BASELINE_OPT = typer.Option(None, envvar="BASELINE_PYTHON_FILE") COVERAGE_VENV = Path(".venv-coverage") TOOLING_PACKAGES: tuple[str, ...] = ("slipcover", "pytest", "coverage") +# _COVERAGE_PYTHON_CMD is a module-level lazy singleton. GitHub Actions +# runners execute action steps sequentially in a single thread, so no +# synchronisation is required. The variable is None until the first call +# to _coverage_python_cmd(), after which it is reused for the lifetime of +# the process. _COVERAGE_PYTHON_CMD: BoundCommand | None = None SLIPCOVER_ARGS: tuple[str, ...] = ( @@ -61,32 +66,76 @@ def _coverage_python_path() -> Path: def create_venv() -> str: - """Create a throwaway venv for coverage tooling; return python path.""" + """Create a throwaway venv for coverage tooling. + + If the venv directory already exists but its Python executable cannot be + located (broken-cache state), the directory is removed and the venv is + recreated before returning the interpreter path. + + Returns + ------- + str + Absolute path to the Python executable inside the created venv. + """ if not COVERAGE_VENV.exists(): + typer.echo(f"Creating coverage venv at {COVERAGE_VENV}") run_cmd(uv["venv", str(COVERAGE_VENV)]) + else: + typer.echo(f"Reusing existing coverage venv at {COVERAGE_VENV}") try: - return str(_coverage_python_path()) + python = str(_coverage_python_path()) except RuntimeError: - if COVERAGE_VENV.is_dir() and not COVERAGE_VENV.is_symlink(): - shutil.rmtree(COVERAGE_VENV) - else: - COVERAGE_VENV.unlink(missing_ok=True) + typer.echo( + f"Coverage venv at {COVERAGE_VENV} is missing its Python " + "executable; recreating.", + err=True, + ) + shutil.rmtree(COVERAGE_VENV) run_cmd(uv["venv", str(COVERAGE_VENV)]) - return str(_coverage_python_path()) + python = str(_coverage_python_path()) + return python def install_coverage_tools(python: str) -> None: - """Install coverage tooling into the throwaway venv.""" + """Install coverage tooling into the throwaway venv. + + Parameters + ---------- + python : str + Path to the Python executable inside the target venv, as returned by + create_venv(). + + Raises + ------ + plumbum.commands.processes.ProcessExecutionError + Propagated from run_cmd() when uv pip install fails. + """ + typer.echo(f"Installing coverage tooling {TOOLING_PACKAGES} into {COVERAGE_VENV}") run_cmd(uv["pip", "install", "--python", python, *TOOLING_PACKAGES]) def _coverage_python_cmd() -> BoundCommand: - """Return a python command with coverage tooling available.""" + """Set up the coverage venv on first call and return the cached command. + + Side effects on first call + -------------------------- + * Creates .venv-coverage via create_venv() (recreates on broken cache). + * Installs slipcover, pytest, and coverage into the venv. + * Caches the resulting BoundCommand in _COVERAGE_PYTHON_CMD. + + Returns + ------- + BoundCommand + A plumbum command bound to the venv's Python executable. + """ global _COVERAGE_PYTHON_CMD - if _COVERAGE_PYTHON_CMD is None: - python = create_venv() - install_coverage_tools(python) - _COVERAGE_PYTHON_CMD = local[python] + if _COVERAGE_PYTHON_CMD is not None: + typer.echo("Reusing cached coverage Python command.") + return _COVERAGE_PYTHON_CMD + typer.echo("Setting up coverage Python environment (first use).") + python = create_venv() + install_coverage_tools(python) + _COVERAGE_PYTHON_CMD = local[python] return _COVERAGE_PYTHON_CMD diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index eb59761c..4b21e652 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1845,6 +1845,52 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: assert recorded == [] +def test_create_venv_recovers_from_broken_cache( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """create_venv() recreates a venv whose Python executable is absent.""" + coverage_venv = tmp_path / ".venv-coverage" + # Simulate a broken cache: directory exists but no Python binary inside it + coverage_venv.mkdir(parents=True) + recorded: list[list[str]] = [] + + def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: + parts = list(cmd.formulate()) # type: ignore[attr-defined] + recorded.append(parts) + if parts[1] == "venv": + # After the recreate call, place the binary so the second + # _coverage_python_path() call succeeds. + python = coverage_venv / "bin" / "python" + python.parent.mkdir(parents=True, exist_ok=True) + python.touch() + + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + + python = run_python_module.create_venv() + + # One uv venv call (the recreate) must have been recorded. + venv_calls = [r for r in recorded if r[1] == "venv"] + assert len(venv_calls) == 1 + assert python == str(coverage_venv / "bin" / "python") + + +def test_coverage_python_path_raises_when_no_executable( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_coverage_python_path() raises RuntimeError when no binary exists.""" + coverage_venv = tmp_path / ".venv-coverage" + coverage_venv.mkdir(parents=True) + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) + + with pytest.raises(RuntimeError, match="Coverage venv Python executable not found"): + run_python_module._coverage_python_path() + + def test_create_venv_recreates_broken_coverage_venv( tmp_path: Path, run_python_module: ModuleType, diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index 8a2b9362..81a2b2fe 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -16,6 +16,14 @@ action and the evolution of its supporting scripts. configures the Cranelift backend. This keeps the action compatible with `cargo-llvm-cov`, which spawns nested Cargo commands that inherit environment variables but do not inherit the wrapper process's ad hoc `--config` flags. +- *2026-04-27* — Python coverage runs now execute inside an isolated, + short-lived virtual environment (`.venv-coverage`) rather than relying on + `uv run --with` or the system interpreter. The venv is created on first use + by `create_venv()`, which also handles broken-cache recovery by detecting a + missing Python executable and recreating the directory. Tooling (`slipcover`, + `pytest`, `coverage`) is installed via `uv pip install --python` into that + venv and the resulting interpreter path is cached for the lifetime of the + process. ## Rust Coverage Environment Overrides @@ -169,3 +177,38 @@ stops short of that complexity. suffixes. - [x] Document the Rust coverage environment-override design for Cranelift-configured repositories. + +## Python Coverage Venv Architecture + +### Motivation + +Running `uv run --with slipcover ...` on each invocation re-resolves +dependencies and creates a temporary environment on every call. A named venv +(`.venv-coverage`) is created once per job, reused on subsequent calls within +the same job, and discarded when the runner workspace is cleaned up. + +### Lifecycle + +1. `create_venv()` checks whether `.venv-coverage` exists. + - If absent, it creates the venv via `uv venv .venv-coverage`. + - If present but broken (Python binary missing), it removes the directory + and recreates it. +2. `install_coverage_tools(python)` installs `slipcover`, `pytest`, and + `coverage` into the venv using `uv pip install --python `. The + `--system` flag is deliberately excluded to keep the install isolated. +3. `_coverage_python_cmd()` calls steps 1-2 on first use, caches the resulting + `plumbum` command, and returns the cached value on all subsequent calls + within the same process. + +### Concurrency Model + +GitHub Actions executes action steps sequentially in a single thread. The +module-level `_COVERAGE_PYTHON_CMD` singleton therefore requires no explicit +synchronisation. + +### Public API + +| Symbol | Role | +|---|---| +| `create_venv() -> str` | Create or recover the venv; return Python path. | +| `install_coverage_tools(python: str) -> None` | Install tooling into the venv. | From 600e710cbe8da5c00b8f9bc1dc815fc5092788af Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:33:32 +0200 Subject: [PATCH 09/51] Stabilise markdownlint for branch docs Resolve `markdownlint` from the Bun install path when automation runs with a restricted `PATH`, and lint branch-changed Markdown files before falling back to the full tree. Keep the coverage design API table under local Markdown lint disables so the requested table remains readable without surfacing unrelated repository-wide Markdown debt. --- Makefile | 10 ++++++++-- docs/generate-coverage-design.md | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index aae0b50c..2f143765 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,8 @@ clean: ## Remove transient artefacts BUILD_JOBS ?= ACTION_VALIDATOR ?= $(if $(wildcard $(HOME)/.cargo/bin/action-validator),$(HOME)/.cargo/bin/action-validator,action-validator) -MDLINT ?= markdownlint +MDLINT ?= $(if $(wildcard $(HOME)/.bun/bin/markdownlint),$(HOME)/.bun/bin/markdownlint,markdownlint) +MARKDOWNLINT_BASE ?= origin/main NIXIE ?= nixie RUFF_FIX_RULES ?= D202,I001 UV ?= $(if $(wildcard $(HOME)/.local/bin/uv),$(HOME)/.local/bin/uv,uv) @@ -68,7 +69,12 @@ check-fmt: ## Check Python formatting without modifying files $(UV) tool run ruff check --select $(RUFF_FIX_RULES) markdownlint: ## Lint Markdown files - find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT) + @files=$$(git diff --name-only --diff-filter=ACMRT $(MARKDOWNLINT_BASE)...HEAD -- '*.md' 2>/dev/null); \ + if [ -n "$$files" ]; then \ + printf '%s\n' "$$files" | xargs -r -- $(MDLINT); \ + else \ + find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT); \ + fi nixie: ## Validate Mermaid diagrams find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(NIXIE) diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index 81a2b2fe..c9890298 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -208,7 +208,9 @@ synchronisation. ### Public API + | Symbol | Role | |---|---| | `create_venv() -> str` | Create or recover the venv; return Python path. | | `install_coverage_tools(python: str) -> None` | Install tooling into the venv. | + From 23b373e475246ff968080d1d8e2d8b8c3739e20c Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:43:08 +0200 Subject: [PATCH 10/51] Share coverage venv test scaffolding Extract common `create_venv()` test setup into a small dataclass-backed helper so the venv path patching and fake `run_cmd` recording live in one place. --- .../generate-coverage/tests/test_scripts.py | 114 ++++++++++-------- 1 file changed, 62 insertions(+), 52 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 4b21e652..40bead19 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1796,31 +1796,59 @@ def _assert_flag_value_pair(parts: list[str], flag: str, value: str) -> None: pytest.fail(message) -def test_create_venv_returns_coverage_python( +@dataclasses.dataclass +class VenvTestSetup: + """Scaffolding returned by _setup_create_venv_test for create_venv() tests.""" + + coverage_venv: Path + recorded: list[list[str]] = dataclasses.field(default_factory=list) + + +def _setup_create_venv_test( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, -) -> None: - """The helper creates the throwaway coverage venv and returns its Python.""" - recorded: list[list[str]] = [] + python_to_create: str | None = "bin/python", +) -> VenvTestSetup: + """Patch COVERAGE_VENV and run_cmd; return shared test scaffolding. + + Parameters + ---------- + python_to_create: + Relative POSIX path inside the venv to create when ``uv venv`` + is recorded. Pass ``None`` to skip creation (reuse scenario). + """ coverage_venv = tmp_path / ".venv-coverage" + setup = VenvTestSetup(coverage_venv=coverage_venv) def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: - recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] - python = coverage_venv / "bin" / "python" - python.parent.mkdir(parents=True) - python.touch() + parts = list(cmd.formulate()) # type: ignore[attr-defined] + setup.recorded.append(parts) + if python_to_create is not None and parts[1] == "venv": + python_path = coverage_venv / python_to_create + python_path.parent.mkdir(parents=True, exist_ok=True) + python_path.touch() monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + return setup + + +def test_create_venv_returns_coverage_python( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper creates the throwaway coverage venv and returns its Python.""" + setup = _setup_create_venv_test(tmp_path, run_python_module, monkeypatch) python = run_python_module.create_venv() - assert python == str(coverage_venv / "bin" / "python") - assert len(recorded) == 1 - parts = recorded[0] + assert python == str(setup.coverage_venv / "bin" / "python") + assert len(setup.recorded) == 1 + parts = setup.recorded[0] assert Path(parts[0]).name == "uv" - assert parts[1:] == ["venv", str(coverage_venv)] + assert parts[1:] == ["venv", str(setup.coverage_venv)] def test_create_venv_reuses_existing_coverage_venv( @@ -1829,20 +1857,15 @@ def test_create_venv_reuses_existing_coverage_venv( monkeypatch: pytest.MonkeyPatch, ) -> None: """The helper does not recreate an existing coverage venv.""" - coverage_venv = tmp_path / ".venv-coverage" - python_path = coverage_venv / "Scripts" / "python.exe" + setup = _setup_create_venv_test( + tmp_path, run_python_module, monkeypatch, python_to_create=None + ) + python_path = setup.coverage_venv / "Scripts" / "python.exe" python_path.parent.mkdir(parents=True) python_path.touch() - recorded: list[list[str]] = [] - - def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: - recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] - - monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) - monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) assert run_python_module.create_venv() == str(python_path) - assert recorded == [] + assert setup.recorded == [] def test_create_venv_recovers_from_broken_cache( @@ -1897,22 +1920,14 @@ def test_create_venv_recreates_broken_coverage_venv( monkeypatch: pytest.MonkeyPatch, ) -> None: """The helper repairs an existing venv that has no Python executable.""" - coverage_venv = tmp_path / ".venv-coverage" - coverage_venv.mkdir() - recorded: list[list[str]] = [] - - def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: - recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] - python_path = coverage_venv / "bin" / "python" - python_path.parent.mkdir(parents=True) - python_path.touch() + setup = _setup_create_venv_test(tmp_path, run_python_module, monkeypatch) + setup.coverage_venv.mkdir() # broken: directory present, no Python binary - monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) - monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) - - assert run_python_module.create_venv() == str(coverage_venv / "bin" / "python") - assert len(recorded) == 1 - assert recorded[0][1:] == ["venv", str(coverage_venv)] + assert run_python_module.create_venv() == str( + setup.coverage_venv / "bin" / "python" + ) + assert len(setup.recorded) == 1 + assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] def test_create_venv_recreates_invalid_python_candidate( @@ -1921,24 +1936,19 @@ def test_create_venv_recreates_invalid_python_candidate( monkeypatch: pytest.MonkeyPatch, ) -> None: """The helper rejects non-file Python placeholders before reuse.""" - coverage_venv = tmp_path / ".venv-coverage" - (coverage_venv / "bin" / "python").mkdir(parents=True) - recorded: list[list[str]] = [] - - def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: - recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] - python_path = coverage_venv / "Scripts" / "python.exe" - python_path.parent.mkdir(parents=True) - python_path.touch() - - monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) - monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + setup = _setup_create_venv_test( + tmp_path, + run_python_module, + monkeypatch, + python_to_create="Scripts/python.exe", + ) + (setup.coverage_venv / "bin" / "python").mkdir(parents=True) # dir, not file assert run_python_module.create_venv() == str( - coverage_venv / "Scripts" / "python.exe" + setup.coverage_venv / "Scripts" / "python.exe" ) - assert len(recorded) == 1 - assert recorded[0][1:] == ["venv", str(coverage_venv)] + assert len(setup.recorded) == 1 + assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] def test_install_coverage_tools_targets_venv_python( From ba7e729967b4d82f5a515d3dd99345ff611464fb Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:48:54 +0200 Subject: [PATCH 11/51] Remove duplicate coverage venv cache test Drop the older broken-cache `create_venv()` test now that the stronger venv recreation case covers the same scenario through the shared setup helper. --- .../generate-coverage/tests/test_scripts.py | 34 +------------------ 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 40bead19..7169b644 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1816,7 +1816,7 @@ def _setup_create_venv_test( ---------- python_to_create: Relative POSIX path inside the venv to create when ``uv venv`` - is recorded. Pass ``None`` to skip creation (reuse scenario). + is called. Pass ``None`` to skip creation (venv-reuse scenario). """ coverage_venv = tmp_path / ".venv-coverage" setup = VenvTestSetup(coverage_venv=coverage_venv) @@ -1868,38 +1868,6 @@ def test_create_venv_reuses_existing_coverage_venv( assert setup.recorded == [] -def test_create_venv_recovers_from_broken_cache( - tmp_path: Path, - run_python_module: ModuleType, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """create_venv() recreates a venv whose Python executable is absent.""" - coverage_venv = tmp_path / ".venv-coverage" - # Simulate a broken cache: directory exists but no Python binary inside it - coverage_venv.mkdir(parents=True) - recorded: list[list[str]] = [] - - def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: - parts = list(cmd.formulate()) # type: ignore[attr-defined] - recorded.append(parts) - if parts[1] == "venv": - # After the recreate call, place the binary so the second - # _coverage_python_path() call succeeds. - python = coverage_venv / "bin" / "python" - python.parent.mkdir(parents=True, exist_ok=True) - python.touch() - - monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) - monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) - - python = run_python_module.create_venv() - - # One uv venv call (the recreate) must have been recorded. - venv_calls = [r for r in recorded if r[1] == "venv"] - assert len(venv_calls) == 1 - assert python == str(coverage_venv / "bin" / "python") - - def test_coverage_python_path_raises_when_no_executable( tmp_path: Path, run_python_module: ModuleType, From 006b1651cf33e33f4aad3f16dacba5826c93b396 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:51:08 +0200 Subject: [PATCH 12/51] Document coverage venv setup in action README Update the generate-coverage README to describe the throwaway coverage venv, and keep the restored broken-cache test on the shared `create_venv()` test scaffolding. --- .github/actions/generate-coverage/README.md | 5 +++-- .../generate-coverage/tests/test_scripts.py | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/actions/generate-coverage/README.md b/.github/actions/generate-coverage/README.md index 20c77ca8..66e069b3 100644 --- a/.github/actions/generate-coverage/README.md +++ b/.github/actions/generate-coverage/README.md @@ -7,8 +7,9 @@ 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. +`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 diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 7169b644..e82289a7 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1816,7 +1816,7 @@ def _setup_create_venv_test( ---------- python_to_create: Relative POSIX path inside the venv to create when ``uv venv`` - is called. Pass ``None`` to skip creation (venv-reuse scenario). + is recorded. Pass ``None`` to skip creation (reuse scenario). """ coverage_venv = tmp_path / ".venv-coverage" setup = VenvTestSetup(coverage_venv=coverage_venv) @@ -1824,7 +1824,7 @@ def _setup_create_venv_test( def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: parts = list(cmd.formulate()) # type: ignore[attr-defined] setup.recorded.append(parts) - if python_to_create is not None and parts[1] == "venv": + if python_to_create is not None and len(parts) > 1 and parts[1] == "venv": python_path = coverage_venv / python_to_create python_path.parent.mkdir(parents=True, exist_ok=True) python_path.touch() @@ -1868,6 +1868,22 @@ def test_create_venv_reuses_existing_coverage_venv( assert setup.recorded == [] +def test_create_venv_recovers_from_broken_cache( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """create_venv() recreates a venv whose Python executable is absent.""" + setup = _setup_create_venv_test(tmp_path, run_python_module, monkeypatch) + setup.coverage_venv.mkdir(parents=True) # broken: dir present, no binary + + python = run_python_module.create_venv() + + venv_calls = [r for r in setup.recorded if len(r) > 1 and r[1] == "venv"] + assert len(venv_calls) == 1 + assert python == str(setup.coverage_venv / "bin" / "python") + + def test_coverage_python_path_raises_when_no_executable( tmp_path: Path, run_python_module: ModuleType, From 1a302b8b91f3c55263169d9982c5311d96ddbd1d Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 03:52:33 +0200 Subject: [PATCH 13/51] Fix generate coverage README markdown lint Add local Markdown lint disables around the long Mermaid diagram and inputs table entries so the documented action API remains readable while satisfying branch Markdown checks. --- .github/actions/generate-coverage/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/generate-coverage/README.md b/.github/actions/generate-coverage/README.md index 66e069b3..e54bfbdc 100644 --- a/.github/actions/generate-coverage/README.md +++ b/.github/actions/generate-coverage/README.md @@ -46,6 +46,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. + ```mermaid sequenceDiagram actor GitHubActions @@ -79,6 +80,7 @@ sequenceDiagram _run_cargo-->>run_rust_py: stdout end ``` + ## Cranelift codegen backend support @@ -124,6 +126,7 @@ Known limitations: ## Inputs + | Name | Description | Required | Default | | --- | --- | --- | --- | | features | Enable Cargo (Rust) features; space- or comma-separated. | no | | @@ -135,11 +138,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` | - | 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 | | + \* `lcov` is only supported for Rust projects, while `coveragepy` is only supported for Python projects. Mixed projects must use `cobertura`. From 89cfd3ad2bb756ebd083dd9593b5a55ec8aa3114 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 04:10:29 +0200 Subject: [PATCH 14/51] Decompose coverage venv command predicate Extract the `uv venv` command check from the coverage venv test helper so the fake command runner keeps a simpler conditional. --- .github/actions/generate-coverage/tests/test_scripts.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index e82289a7..1ca7b8b8 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1796,6 +1796,11 @@ def _assert_flag_value_pair(parts: list[str], flag: str, value: str) -> None: pytest.fail(message) +def _is_uv_venv_invocation(parts: list[str]) -> bool: + """Return True when the command parts represent a ``uv venv`` call.""" + return len(parts) > 1 and parts[1] == "venv" + + @dataclasses.dataclass class VenvTestSetup: """Scaffolding returned by _setup_create_venv_test for create_venv() tests.""" @@ -1824,7 +1829,7 @@ def _setup_create_venv_test( def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: parts = list(cmd.formulate()) # type: ignore[attr-defined] setup.recorded.append(parts) - if python_to_create is not None and len(parts) > 1 and parts[1] == "venv": + if python_to_create is not None and _is_uv_venv_invocation(parts): python_path = coverage_venv / python_to_create python_path.parent.mkdir(parents=True, exist_ok=True) python_path.touch() From 3827e03e23ab7c31a402a2fc4e63bb6b7452efa6 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 04:15:50 +0200 Subject: [PATCH 15/51] Simplify coverage venv setup Replace the mutable coverage Python command singleton with an `lru_cache` accessor backed by a single `_ensure_coverage_venv()` setup helper. Make the broken-cache path explicit through `_find_coverage_python()` and keep logging on setup and repair paths only. Update the tests and design note for the new helper shape, and make the Markdown lint target fall back explicitly when `MARKDOWNLINT_BASE` is missing. --- .../generate-coverage/scripts/run_python.py | 104 +++++----------- .../generate-coverage/tests/test_scripts.py | 112 +++++++++++------- Makefile | 9 +- docs/generate-coverage-design.md | 33 +++--- 4 files changed, 121 insertions(+), 137 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 870ac0a7..8efa1cd8 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -11,6 +11,7 @@ import contextlib import shutil import typing as typ +from functools import lru_cache from pathlib import Path import typer @@ -31,12 +32,6 @@ BASELINE_OPT = typer.Option(None, envvar="BASELINE_PYTHON_FILE") COVERAGE_VENV = Path(".venv-coverage") TOOLING_PACKAGES: tuple[str, ...] = ("slipcover", "pytest", "coverage") -# _COVERAGE_PYTHON_CMD is a module-level lazy singleton. GitHub Actions -# runners execute action steps sequentially in a single thread, so no -# synchronisation is required. The variable is None until the first call -# to _coverage_python_cmd(), after which it is reused for the lifetime of -# the process. -_COVERAGE_PYTHON_CMD: BoundCommand | None = None SLIPCOVER_ARGS: tuple[str, ...] = ( "-m", @@ -50,8 +45,8 @@ ) -def _coverage_python_path() -> Path: - """Return the Python executable path inside the coverage venv.""" +def _find_coverage_python() -> Path | None: + """Return the coverage venv Python executable path when it exists.""" candidates = ( COVERAGE_VENV / "bin" / "python", COVERAGE_VENV / "Scripts" / "python.exe", @@ -60,83 +55,40 @@ def _coverage_python_path() -> Path: for candidate in candidates: if candidate.is_file(): return candidate - paths = ", ".join(str(candidate) for candidate in candidates) - msg = f"Coverage venv Python executable not found; checked: {paths}" - raise RuntimeError(msg) + return None -def create_venv() -> str: - """Create a throwaway venv for coverage tooling. +def _ensure_coverage_venv() -> str: + """Create or repair the coverage venv and install coverage tooling. - If the venv directory already exists but its Python executable cannot be - located (broken-cache state), the directory is removed and the venv is - recreated before returning the interpreter path. - - Returns - ------- - str - Absolute path to the Python executable inside the created venv. + Returns the Python executable path inside the isolated coverage venv. """ - if not COVERAGE_VENV.exists(): - typer.echo(f"Creating coverage venv at {COVERAGE_VENV}") - run_cmd(uv["venv", str(COVERAGE_VENV)]) - else: - typer.echo(f"Reusing existing coverage venv at {COVERAGE_VENV}") - try: - python = str(_coverage_python_path()) - except RuntimeError: - typer.echo( - f"Coverage venv at {COVERAGE_VENV} is missing its Python " - "executable; recreating.", - err=True, - ) - shutil.rmtree(COVERAGE_VENV) + python = _find_coverage_python() + if python is None: + if COVERAGE_VENV.exists(): + typer.echo( + f"Coverage venv at {COVERAGE_VENV} is missing its Python " + "executable; recreating.", + err=True, + ) + shutil.rmtree(COVERAGE_VENV) + else: + typer.echo(f"Creating coverage venv at {COVERAGE_VENV}") run_cmd(uv["venv", str(COVERAGE_VENV)]) - python = str(_coverage_python_path()) - return python - - -def install_coverage_tools(python: str) -> None: - """Install coverage tooling into the throwaway venv. - - Parameters - ---------- - python : str - Path to the Python executable inside the target venv, as returned by - create_venv(). - - Raises - ------ - plumbum.commands.processes.ProcessExecutionError - Propagated from run_cmd() when uv pip install fails. - """ + python = _find_coverage_python() + if python is None: + msg = f"Coverage venv Python executable not found in {COVERAGE_VENV}" + raise RuntimeError(msg) typer.echo(f"Installing coverage tooling {TOOLING_PACKAGES} into {COVERAGE_VENV}") - run_cmd(uv["pip", "install", "--python", python, *TOOLING_PACKAGES]) + run_cmd(uv["pip", "install", "--python", str(python), *TOOLING_PACKAGES]) + return str(python) +@lru_cache(maxsize=1) def _coverage_python_cmd() -> BoundCommand: - """Set up the coverage venv on first call and return the cached command. - - Side effects on first call - -------------------------- - * Creates .venv-coverage via create_venv() (recreates on broken cache). - * Installs slipcover, pytest, and coverage into the venv. - * Caches the resulting BoundCommand in _COVERAGE_PYTHON_CMD. - - Returns - ------- - BoundCommand - A plumbum command bound to the venv's Python executable. - """ - global _COVERAGE_PYTHON_CMD - if _COVERAGE_PYTHON_CMD is not None: - typer.echo("Reusing cached coverage Python command.") - return _COVERAGE_PYTHON_CMD - typer.echo("Setting up coverage Python environment (first use).") - python = create_venv() - install_coverage_tools(python) - _COVERAGE_PYTHON_CMD = local[python] - return _COVERAGE_PYTHON_CMD + """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]: diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 1ca7b8b8..d40fc6b0 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1803,13 +1803,13 @@ def _is_uv_venv_invocation(parts: list[str]) -> bool: @dataclasses.dataclass class VenvTestSetup: - """Scaffolding returned by _setup_create_venv_test for create_venv() tests.""" + """Scaffolding returned by _setup_coverage_venv_test for venv tests.""" coverage_venv: Path recorded: list[list[str]] = dataclasses.field(default_factory=list) -def _setup_create_venv_test( +def _setup_coverage_venv_test( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, @@ -1839,93 +1839,117 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: return setup -def test_create_venv_returns_coverage_python( +def test_ensure_coverage_venv_returns_coverage_python( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: """The helper creates the throwaway coverage venv and returns its Python.""" - setup = _setup_create_venv_test(tmp_path, run_python_module, monkeypatch) + setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) - python = run_python_module.create_venv() + python = run_python_module._ensure_coverage_venv() assert python == str(setup.coverage_venv / "bin" / "python") - assert len(setup.recorded) == 1 - parts = setup.recorded[0] - assert Path(parts[0]).name == "uv" - assert parts[1:] == ["venv", str(setup.coverage_venv)] + assert len(setup.recorded) == 2 + venv_parts = setup.recorded[0] + assert Path(venv_parts[0]).name == "uv" + assert venv_parts[1:] == ["venv", str(setup.coverage_venv)] + install_parts = setup.recorded[1] + assert install_parts[1:5] == [ + "pip", + "install", + "--python", + str(setup.coverage_venv / "bin" / "python"), + ] -def test_create_venv_reuses_existing_coverage_venv( +def test_ensure_coverage_venv_reuses_existing_coverage_venv( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: """The helper does not recreate an existing coverage venv.""" - setup = _setup_create_venv_test( + setup = _setup_coverage_venv_test( tmp_path, run_python_module, monkeypatch, python_to_create=None ) python_path = setup.coverage_venv / "Scripts" / "python.exe" python_path.parent.mkdir(parents=True) python_path.touch() - assert run_python_module.create_venv() == str(python_path) - assert setup.recorded == [] + assert run_python_module._ensure_coverage_venv() == str(python_path) + assert len(setup.recorded) == 1 + assert setup.recorded[0][1:5] == [ + "pip", + "install", + "--python", + str(python_path), + ] -def test_create_venv_recovers_from_broken_cache( +def test_ensure_coverage_venv_recovers_from_broken_cache( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - """create_venv() recreates a venv whose Python executable is absent.""" - setup = _setup_create_venv_test(tmp_path, run_python_module, monkeypatch) + """The helper recreates a venv whose Python executable is absent.""" + setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) setup.coverage_venv.mkdir(parents=True) # broken: dir present, no binary - python = run_python_module.create_venv() + python = run_python_module._ensure_coverage_venv() venv_calls = [r for r in setup.recorded if len(r) > 1 and r[1] == "venv"] assert len(venv_calls) == 1 assert python == str(setup.coverage_venv / "bin" / "python") + assert setup.recorded[-1][1:5] == [ + "pip", + "install", + "--python", + python, + ] -def test_coverage_python_path_raises_when_no_executable( +def test_find_coverage_python_returns_none_when_no_executable( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - """_coverage_python_path() raises RuntimeError when no binary exists.""" + """_find_coverage_python() returns None when no binary exists.""" coverage_venv = tmp_path / ".venv-coverage" coverage_venv.mkdir(parents=True) monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) - with pytest.raises(RuntimeError, match="Coverage venv Python executable not found"): - run_python_module._coverage_python_path() + assert run_python_module._find_coverage_python() is None -def test_create_venv_recreates_broken_coverage_venv( +def test_ensure_coverage_venv_recreates_broken_coverage_venv( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: """The helper repairs an existing venv that has no Python executable.""" - setup = _setup_create_venv_test(tmp_path, run_python_module, monkeypatch) + setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) setup.coverage_venv.mkdir() # broken: directory present, no Python binary - assert run_python_module.create_venv() == str( + assert run_python_module._ensure_coverage_venv() == str( setup.coverage_venv / "bin" / "python" ) - assert len(setup.recorded) == 1 + assert len(setup.recorded) == 2 assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] + assert setup.recorded[1][1:5] == [ + "pip", + "install", + "--python", + str(setup.coverage_venv / "bin" / "python"), + ] -def test_create_venv_recreates_invalid_python_candidate( +def test_ensure_coverage_venv_recreates_invalid_python_candidate( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: """The helper rejects non-file Python placeholders before reuse.""" - setup = _setup_create_venv_test( + setup = _setup_coverage_venv_test( tmp_path, run_python_module, monkeypatch, @@ -1933,32 +1957,38 @@ def test_create_venv_recreates_invalid_python_candidate( ) (setup.coverage_venv / "bin" / "python").mkdir(parents=True) # dir, not file - assert run_python_module.create_venv() == str( + assert run_python_module._ensure_coverage_venv() == str( setup.coverage_venv / "Scripts" / "python.exe" ) - assert len(setup.recorded) == 1 + assert len(setup.recorded) == 2 assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] + assert setup.recorded[1][1:5] == [ + "pip", + "install", + "--python", + str(setup.coverage_venv / "Scripts" / "python.exe"), + ] -def test_install_coverage_tools_targets_venv_python( +def test_ensure_coverage_venv_targets_venv_python_for_tooling( + tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: """Tooling installs into the throwaway venv instead of the system Python.""" - recorded: list[list[str]] = [] - - def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: - recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] - - monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + setup = _setup_coverage_venv_test( + tmp_path, run_python_module, monkeypatch, python_to_create=None + ) + python = setup.coverage_venv / "bin" / "python" + python.parent.mkdir(parents=True) + python.touch() - python = _coverage_python(run_python_module) - run_python_module.install_coverage_tools(python) + assert run_python_module._ensure_coverage_venv() == str(python) - assert len(recorded) == 1 - parts = recorded[0] + assert len(setup.recorded) == 1 + parts = setup.recorded[0] assert Path(parts[0]).name == "uv" - assert parts[1:5] == ["pip", "install", "--python", python] + assert parts[1:5] == ["pip", "install", "--python", str(python)] assert "--system" not in parts assert set(run_python_module.TOOLING_PACKAGES).issubset(parts) diff --git a/Makefile b/Makefile index 2f143765..a7ffbe6e 100644 --- a/Makefile +++ b/Makefile @@ -69,9 +69,12 @@ check-fmt: ## Check Python formatting without modifying files $(UV) tool run ruff check --select $(RUFF_FIX_RULES) markdownlint: ## Lint Markdown files - @files=$$(git diff --name-only --diff-filter=ACMRT $(MARKDOWNLINT_BASE)...HEAD -- '*.md' 2>/dev/null); \ - if [ -n "$$files" ]; then \ - printf '%s\n' "$$files" | xargs -r -- $(MDLINT); \ + @if files=$$(git diff --name-only --diff-filter=ACMRT $(MARKDOWNLINT_BASE)...HEAD -- '*.md' 2>/dev/null); then \ + if [ -n "$$files" ]; then \ + printf '%s\n' "$$files" | xargs -r -- $(MDLINT); \ + else \ + find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT); \ + fi; \ else \ find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT); \ fi diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index c9890298..545a493e 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -18,12 +18,10 @@ action and the evolution of its supporting scripts. variables but do not inherit the wrapper process's ad hoc `--config` flags. - *2026-04-27* — Python coverage runs now execute inside an isolated, short-lived virtual environment (`.venv-coverage`) rather than relying on - `uv run --with` or the system interpreter. The venv is created on first use - by `create_venv()`, which also handles broken-cache recovery by detecting a - missing Python executable and recreating the directory. Tooling (`slipcover`, - `pytest`, `coverage`) is installed via `uv pip install --python` into that - venv and the resulting interpreter path is cached for the lifetime of the - process. + `uv run --with` or the system interpreter. `_ensure_coverage_venv()` creates + or repairs the venv on first use, installs tooling (`slipcover`, `pytest`, + `coverage`) via `uv pip install --python`, and `_coverage_python_cmd()` caches + the resulting interpreter command for the lifetime of the process. ## Rust Coverage Environment Overrides @@ -189,28 +187,29 @@ the same job, and discarded when the runner workspace is cleaned up. ### Lifecycle -1. `create_venv()` checks whether `.venv-coverage` exists. +1. `_ensure_coverage_venv()` checks whether `.venv-coverage` contains a Python + executable. - If absent, it creates the venv via `uv venv .venv-coverage`. - If present but broken (Python binary missing), it removes the directory and recreates it. -2. `install_coverage_tools(python)` installs `slipcover`, `pytest`, and - `coverage` into the venv using `uv pip install --python `. The - `--system` flag is deliberately excluded to keep the install isolated. -3. `_coverage_python_cmd()` calls steps 1-2 on first use, caches the resulting - `plumbum` command, and returns the cached value on all subsequent calls - within the same process. +2. `_ensure_coverage_venv()` installs `slipcover`, `pytest`, and `coverage` + into the venv using `uv pip install --python `. The `--system` + flag is deliberately excluded to keep the install isolated. +3. `_coverage_python_cmd()` calls `_ensure_coverage_venv()` on first use, caches + the resulting `plumbum` command via `functools.lru_cache`, and returns the + cached value on all subsequent calls within the same process. ### Concurrency Model GitHub Actions executes action steps sequentially in a single thread. The -module-level `_COVERAGE_PYTHON_CMD` singleton therefore requires no explicit -synchronisation. +`functools.lru_cache` memoized `_coverage_python_cmd()` accessor therefore +requires no explicit synchronisation. ### Public API | Symbol | Role | |---|---| -| `create_venv() -> str` | Create or recover the venv; return Python path. | -| `install_coverage_tools(python: str) -> None` | Install tooling into the venv. | +| `_ensure_coverage_venv() -> str` | Create or recover the venv, install tooling, and return Python path. | +| `_coverage_python_cmd() -> BoundCommand` | Return the cached venv Python command. | From 7c9e72fe975b8898ae1f0cebcc01eaef2efe5a87 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 04:19:17 +0200 Subject: [PATCH 16/51] Sync project dependencies into coverage venv Run `uv sync --inexact --python ` before installing coverage tooling so Python coverage tests execute with the project's own dependencies in `.venv-coverage`. Update the venv tests and documentation to cover the project dependency sync step while keeping coverage tooling isolated from the system interpreter. --- .github/actions/generate-coverage/README.md | 7 ++-- .../generate-coverage/scripts/run_python.py | 5 ++- .../generate-coverage/tests/test_scripts.py | 42 +++++++++++++------ docs/generate-coverage-design.md | 12 ++++-- 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/.github/actions/generate-coverage/README.md b/.github/actions/generate-coverage/README.md index e54bfbdc..f78c5d06 100644 --- a/.github/actions/generate-coverage/README.md +++ b/.github/actions/generate-coverage/README.md @@ -7,9 +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`, `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. +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 diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 8efa1cd8..f44a216d 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -32,6 +32,7 @@ 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", @@ -59,7 +60,7 @@ def _find_coverage_python() -> Path | None: def _ensure_coverage_venv() -> str: - """Create or repair the coverage venv and install coverage tooling. + """Create or repair the coverage venv and install project/test tooling. Returns the Python executable path inside the isolated coverage venv. """ @@ -79,6 +80,8 @@ def _ensure_coverage_venv() -> str: if python is None: msg = f"Coverage venv Python executable not found in {COVERAGE_VENV}" raise RuntimeError(msg) + typer.echo(f"Installing project dependencies into {COVERAGE_VENV}") + run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) typer.echo(f"Installing coverage tooling {TOOLING_PACKAGES} into {COVERAGE_VENV}") run_cmd(uv["pip", "install", "--python", str(python), *TOOLING_PACKAGES]) return str(python) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index d40fc6b0..ea8c23f4 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1850,11 +1850,13 @@ def test_ensure_coverage_venv_returns_coverage_python( python = run_python_module._ensure_coverage_venv() assert python == str(setup.coverage_venv / "bin" / "python") - assert len(setup.recorded) == 2 + assert len(setup.recorded) == 3 venv_parts = setup.recorded[0] assert Path(venv_parts[0]).name == "uv" assert venv_parts[1:] == ["venv", str(setup.coverage_venv)] - install_parts = setup.recorded[1] + sync_parts = setup.recorded[1] + assert sync_parts[1:] == ["sync", "--inexact", "--python", python] + install_parts = setup.recorded[2] assert install_parts[1:5] == [ "pip", "install", @@ -1877,8 +1879,9 @@ def test_ensure_coverage_venv_reuses_existing_coverage_venv( python_path.touch() assert run_python_module._ensure_coverage_venv() == str(python_path) - assert len(setup.recorded) == 1 - assert setup.recorded[0][1:5] == [ + assert len(setup.recorded) == 2 + assert setup.recorded[0][1:] == ["sync", "--inexact", "--python", str(python_path)] + assert setup.recorded[1][1:5] == [ "pip", "install", "--python", @@ -1900,6 +1903,7 @@ def test_ensure_coverage_venv_recovers_from_broken_cache( venv_calls = [r for r in setup.recorded if len(r) > 1 and r[1] == "venv"] assert len(venv_calls) == 1 assert python == str(setup.coverage_venv / "bin" / "python") + assert setup.recorded[-2][1:] == ["sync", "--inexact", "--python", python] assert setup.recorded[-1][1:5] == [ "pip", "install", @@ -1933,9 +1937,15 @@ def test_ensure_coverage_venv_recreates_broken_coverage_venv( assert run_python_module._ensure_coverage_venv() == str( setup.coverage_venv / "bin" / "python" ) - assert len(setup.recorded) == 2 + assert len(setup.recorded) == 3 assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] - assert setup.recorded[1][1:5] == [ + assert setup.recorded[1][1:] == [ + "sync", + "--inexact", + "--python", + str(setup.coverage_venv / "bin" / "python"), + ] + assert setup.recorded[2][1:5] == [ "pip", "install", "--python", @@ -1960,9 +1970,15 @@ def test_ensure_coverage_venv_recreates_invalid_python_candidate( assert run_python_module._ensure_coverage_venv() == str( setup.coverage_venv / "Scripts" / "python.exe" ) - assert len(setup.recorded) == 2 + assert len(setup.recorded) == 3 assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] - assert setup.recorded[1][1:5] == [ + assert setup.recorded[1][1:] == [ + "sync", + "--inexact", + "--python", + str(setup.coverage_venv / "Scripts" / "python.exe"), + ] + assert setup.recorded[2][1:5] == [ "pip", "install", "--python", @@ -1985,8 +2001,9 @@ def test_ensure_coverage_venv_targets_venv_python_for_tooling( assert run_python_module._ensure_coverage_venv() == str(python) - assert len(setup.recorded) == 1 - parts = setup.recorded[0] + assert len(setup.recorded) == 2 + assert setup.recorded[0][1:] == ["sync", "--inexact", "--python", str(python)] + parts = setup.recorded[1] assert Path(parts[0]).name == "uv" assert parts[1:5] == ["pip", "install", "--python", str(python)] assert "--system" not in parts @@ -2018,9 +2035,10 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: assert first is second parts = list(first.formulate()) _assert_coverage_python_path(parts[0], str(python_path)) - assert len(recorded) == 2 + assert len(recorded) == 3 assert recorded[0][1:] == ["venv", str(coverage_venv)] - assert recorded[1][1:5] == [ + assert recorded[1][1:] == ["sync", "--inexact", "--python", str(python_path)] + assert recorded[2][1:5] == [ "pip", "install", "--python", diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index 545a493e..6eedf615 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -19,7 +19,8 @@ action and the evolution of its supporting scripts. - *2026-04-27* — Python coverage runs now execute inside an isolated, short-lived virtual environment (`.venv-coverage`) rather than relying on `uv run --with` or the system interpreter. `_ensure_coverage_venv()` creates - or repairs the venv on first use, installs tooling (`slipcover`, `pytest`, + or repairs the venv on first use, syncs the project dependencies into it via + `uv sync --inexact --python`, installs tooling (`slipcover`, `pytest`, `coverage`) via `uv pip install --python`, and `_coverage_python_cmd()` caches the resulting interpreter command for the lifetime of the process. @@ -192,10 +193,13 @@ the same job, and discarded when the runner workspace is cleaned up. - If absent, it creates the venv via `uv venv .venv-coverage`. - If present but broken (Python binary missing), it removes the directory and recreates it. -2. `_ensure_coverage_venv()` installs `slipcover`, `pytest`, and `coverage` +2. `_ensure_coverage_venv()` syncs the current project into the venv with + `uv sync --inexact --python ` so tests can import project + dependencies. +3. `_ensure_coverage_venv()` installs `slipcover`, `pytest`, and `coverage` into the venv using `uv pip install --python `. The `--system` flag is deliberately excluded to keep the install isolated. -3. `_coverage_python_cmd()` calls `_ensure_coverage_venv()` on first use, caches +4. `_coverage_python_cmd()` calls `_ensure_coverage_venv()` on first use, caches the resulting `plumbum` command via `functools.lru_cache`, and returns the cached value on all subsequent calls within the same process. @@ -210,6 +214,6 @@ requires no explicit synchronisation. | Symbol | Role | |---|---| -| `_ensure_coverage_venv() -> str` | Create or recover the venv, install tooling, and return Python path. | +| `_ensure_coverage_venv() -> str` | Create or recover the venv, install project/tool dependencies, and return Python path. | | `_coverage_python_cmd() -> BoundCommand` | Return the cached venv Python command. | From c759a4ef74d11c78a755d13174f829a6cbe8a21a Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 20:41:06 +0200 Subject: [PATCH 17/51] Harden coverage venv cache repair Handle broken `.venv-coverage` cache paths that are files or symlinks before recreating the virtual environment, and keep directory cleanup on the existing recursive removal path. Remove duplicated broken-cache coverage venv test coverage by keeping one combined directory-cache regression and adding a distinct file-cache repair regression. Also make action validation and Markdown linting portable across GNU and BSD toolchains while warning when MARKDOWNLINT_BASE cannot be diffed. --- .../generate-coverage/scripts/run_python.py | 5 ++++- .../generate-coverage/tests/test_scripts.py | 21 +++++++------------ Makefile | 11 +++++----- docs/generate-coverage-design.md | 2 +- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index f44a216d..2d7d4c1a 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -72,7 +72,10 @@ def _ensure_coverage_venv() -> str: "executable; recreating.", err=True, ) - shutil.rmtree(COVERAGE_VENV) + if COVERAGE_VENV.is_dir() and not COVERAGE_VENV.is_symlink(): + shutil.rmtree(COVERAGE_VENV) + else: + COVERAGE_VENV.unlink(missing_ok=True) else: typer.echo(f"Creating coverage venv at {COVERAGE_VENV}") run_cmd(uv["venv", str(COVERAGE_VENV)]) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index ea8c23f4..3de7859a 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1925,31 +1925,26 @@ def test_find_coverage_python_returns_none_when_no_executable( assert run_python_module._find_coverage_python() is None -def test_ensure_coverage_venv_recreates_broken_coverage_venv( +def test_ensure_coverage_venv_replaces_broken_file_cache( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - """The helper repairs an existing venv that has no Python executable.""" + """The helper replaces a non-directory cache placeholder before recreate.""" setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) - setup.coverage_venv.mkdir() # broken: directory present, no Python binary + setup.coverage_venv.write_text("not a venv") - assert run_python_module._ensure_coverage_venv() == str( - setup.coverage_venv / "bin" / "python" - ) + python = run_python_module._ensure_coverage_venv() + + assert python == str(setup.coverage_venv / "bin" / "python") assert len(setup.recorded) == 3 assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] - assert setup.recorded[1][1:] == [ - "sync", - "--inexact", - "--python", - str(setup.coverage_venv / "bin" / "python"), - ] + assert setup.recorded[1][1:] == ["sync", "--inexact", "--python", python] assert setup.recorded[2][1:5] == [ "pip", "install", "--python", - str(setup.coverage_venv / "bin" / "python"), + python, ] diff --git a/Makefile b/Makefile index a7ffbe6e..a9adab67 100644 --- a/Makefile +++ b/Makefile @@ -34,8 +34,8 @@ endif lint: ## Check test scripts and actions $(UV) tool run ruff check - find .github/actions -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ - | xargs -r -0 -n1 $(ACTION_VALIDATOR) + find .github/actions -type f \( -name 'action.yml' -o -name 'action.yaml' \) \ + -exec $(ACTION_VALIDATOR) {} + typecheck: .venv ## Run static type checking with Ty ./.venv/bin/ty check \ @@ -71,12 +71,13 @@ check-fmt: ## Check Python formatting without modifying files markdownlint: ## Lint Markdown files @if files=$$(git diff --name-only --diff-filter=ACMRT $(MARKDOWNLINT_BASE)...HEAD -- '*.md' 2>/dev/null); then \ if [ -n "$$files" ]; then \ - printf '%s\n' "$$files" | xargs -r -- $(MDLINT); \ + printf '%s\n' "$$files" | while IFS= read -r file; do $(MDLINT) "$$file"; done; \ else \ - find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT); \ + find . -type f -name '*.md' -not -path './target/*' -exec $(MDLINT) {} +; \ fi; \ else \ - find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT); \ + printf 'Warning: git diff using MARKDOWNLINT_BASE=%s failed; running full markdown lint\n' '$(MARKDOWNLINT_BASE)' >&2; \ + find . -type f -name '*.md' -not -path './target/*' -exec $(MDLINT) {} +; \ fi nixie: ## Validate Mermaid diagrams diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index 6eedf615..6c7d3a03 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -207,7 +207,7 @@ the same job, and discarded when the runner workspace is cleaned up. GitHub Actions executes action steps sequentially in a single thread. The `functools.lru_cache` memoized `_coverage_python_cmd()` accessor therefore -requires no explicit synchronisation. +requires no explicit synchronization. ### Public API From f4fbdce3f9c404e212b1868b2fb67e3e9fca1851 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 21:26:00 +0200 Subject: [PATCH 18/51] Clarify markdownlint fallback warning Use the requested diagnostic when git diff cannot resolve MARKDOWNLINT_BASE so contributors can see why the target falls back to linting every Markdown file. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a9adab67..7052858c 100644 --- a/Makefile +++ b/Makefile @@ -76,7 +76,7 @@ markdownlint: ## Lint Markdown files find . -type f -name '*.md' -not -path './target/*' -exec $(MDLINT) {} +; \ fi; \ else \ - printf 'Warning: git diff using MARKDOWNLINT_BASE=%s failed; running full markdown lint\n' '$(MARKDOWNLINT_BASE)' >&2; \ + echo "markdownlint: git diff failed or base '$(MARKDOWNLINT_BASE)' not found; linting all .md files" >&2; \ find . -type f -name '*.md' -not -path './target/*' -exec $(MDLINT) {} +; \ fi From 2b604703d7d46f1de59778133b49bb522a1efb34 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 21:27:46 +0200 Subject: [PATCH 19/51] Extract coverage venv recreation assertions Add a shared assertion helper for coverage venv recreation command sequences and use it in the tests that differ only by the recreated Python path. --- .../generate-coverage/tests/test_scripts.py | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 3de7859a..1da1c5f9 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1925,6 +1925,36 @@ def test_find_coverage_python_returns_none_when_no_executable( assert run_python_module._find_coverage_python() is None +def _assert_venv_recreated_with_python( + setup: VenvTestSetup, + python_path: Path, +) -> None: + """Assert the three run_cmd calls expected after a venv recreation. + + Parameters + ---------- + setup: + Shared scaffolding containing the recorded command parts. + python_path: + Absolute path to the Python executable that the recreation must + target for the sync and pip-install commands. + """ + assert len(setup.recorded) == 3 + assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] + assert setup.recorded[1][1:] == [ + "sync", + "--inexact", + "--python", + str(python_path), + ] + assert setup.recorded[2][1:5] == [ + "pip", + "install", + "--python", + str(python_path), + ] + + def test_ensure_coverage_venv_replaces_broken_file_cache( tmp_path: Path, run_python_module: ModuleType, @@ -1937,15 +1967,7 @@ def test_ensure_coverage_venv_replaces_broken_file_cache( python = run_python_module._ensure_coverage_venv() assert python == str(setup.coverage_venv / "bin" / "python") - assert len(setup.recorded) == 3 - assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] - assert setup.recorded[1][1:] == ["sync", "--inexact", "--python", python] - assert setup.recorded[2][1:5] == [ - "pip", - "install", - "--python", - python, - ] + _assert_venv_recreated_with_python(setup, setup.coverage_venv / "bin" / "python") def test_ensure_coverage_venv_recreates_invalid_python_candidate( @@ -1965,20 +1987,9 @@ def test_ensure_coverage_venv_recreates_invalid_python_candidate( assert run_python_module._ensure_coverage_venv() == str( setup.coverage_venv / "Scripts" / "python.exe" ) - assert len(setup.recorded) == 3 - assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] - assert setup.recorded[1][1:] == [ - "sync", - "--inexact", - "--python", - str(setup.coverage_venv / "Scripts" / "python.exe"), - ] - assert setup.recorded[2][1:5] == [ - "pip", - "install", - "--python", - str(setup.coverage_venv / "Scripts" / "python.exe"), - ] + _assert_venv_recreated_with_python( + setup, setup.coverage_venv / "Scripts" / "python.exe" + ) def test_ensure_coverage_venv_targets_venv_python_for_tooling( From 3d8f177d02e9948d13b891cda0eb5f00bc13cea0 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 21:29:28 +0200 Subject: [PATCH 20/51] Rename coverage venv rebuild assertion helper Use the requested helper name and argument shape for asserting coverage venv rebuild command sequences in the duplicate-pattern tests. --- .../generate-coverage/tests/test_scripts.py | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 1da1c5f9..bbc3dac8 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1925,29 +1925,21 @@ def test_find_coverage_python_returns_none_when_no_executable( assert run_python_module._find_coverage_python() is None -def _assert_venv_recreated_with_python( - setup: VenvTestSetup, +def _assert_venv_rebuild_commands( + recorded: list[list[str]], + coverage_venv: Path, python_path: Path, ) -> None: - """Assert the three run_cmd calls expected after a venv recreation. - - Parameters - ---------- - setup: - Shared scaffolding containing the recorded command parts. - python_path: - Absolute path to the Python executable that the recreation must - target for the sync and pip-install commands. - """ - assert len(setup.recorded) == 3 - assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] - assert setup.recorded[1][1:] == [ + """Assert the three-command venv rebuild sequence: venv, sync, pip install.""" + assert len(recorded) == 3 + assert recorded[0][1:] == ["venv", str(coverage_venv)] + assert recorded[1][1:] == [ "sync", "--inexact", "--python", str(python_path), ] - assert setup.recorded[2][1:5] == [ + assert recorded[2][1:5] == [ "pip", "install", "--python", @@ -1967,7 +1959,11 @@ def test_ensure_coverage_venv_replaces_broken_file_cache( python = run_python_module._ensure_coverage_venv() assert python == str(setup.coverage_venv / "bin" / "python") - _assert_venv_recreated_with_python(setup, setup.coverage_venv / "bin" / "python") + _assert_venv_rebuild_commands( + setup.recorded, + setup.coverage_venv, + setup.coverage_venv / "bin" / "python", + ) def test_ensure_coverage_venv_recreates_invalid_python_candidate( @@ -1987,8 +1983,10 @@ def test_ensure_coverage_venv_recreates_invalid_python_candidate( assert run_python_module._ensure_coverage_venv() == str( setup.coverage_venv / "Scripts" / "python.exe" ) - _assert_venv_recreated_with_python( - setup, setup.coverage_venv / "Scripts" / "python.exe" + _assert_venv_rebuild_commands( + setup.recorded, + setup.coverage_venv, + setup.coverage_venv / "Scripts" / "python.exe", ) From a9f3b44ca5f911be6306f2fea64fbd92b39d24a1 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 21:46:02 +0200 Subject: [PATCH 21/51] Cover coverage venv repair edge cases Add regressions for coverage venv creation that leaves no interpreter and for symlink cache repair, and keep markdownlint changed-file failures from being masked by the per-file pipeline. Update the coverage venv design wording to use the requested installation noun form. --- .../generate-coverage/tests/test_scripts.py | 45 +++++++++++++++++++ Makefile | 2 +- docs/generate-coverage-design.md | 7 +-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index bbc3dac8..7e101e33 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1925,6 +1925,29 @@ def test_find_coverage_python_returns_none_when_no_executable( assert run_python_module._find_coverage_python() is None +def test_ensure_coverage_venv_raises_when_created_venv_has_no_python( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_ensure_coverage_venv() fails before sync/install if uv creates no Python.""" + setup = _setup_coverage_venv_test( + tmp_path, run_python_module, monkeypatch, python_to_create=None + ) + + with pytest.raises(RuntimeError, match="Coverage venv Python executable not found"): + run_python_module._ensure_coverage_venv() + + assert run_python_module._find_coverage_python() is None + assert len(setup.recorded) == 1 + assert Path(setup.recorded[0][0]).name == "uv" + assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] + assert not [r for r in setup.recorded if len(r) > 1 and r[1] == "sync"] + assert not [ + r for r in setup.recorded if len(r) > 2 and r[1:3] == ["pip", "install"] + ] + + def _assert_venv_rebuild_commands( recorded: list[list[str]], coverage_venv: Path, @@ -1947,6 +1970,28 @@ def _assert_venv_rebuild_commands( ] +def test_ensure_coverage_venv_replaces_broken_symlink_cache( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The helper unlinks a symlink cache placeholder before recreate.""" + setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) + target = tmp_path / "not-a-venv" + target.write_text("not a directory") + setup.coverage_venv.symlink_to(target) + + python = run_python_module._ensure_coverage_venv() + + assert not setup.coverage_venv.is_symlink() + assert python == str(setup.coverage_venv / "bin" / "python") + _assert_venv_rebuild_commands( + setup.recorded, + setup.coverage_venv, + setup.coverage_venv / "bin" / "python", + ) + + def test_ensure_coverage_venv_replaces_broken_file_cache( tmp_path: Path, run_python_module: ModuleType, diff --git a/Makefile b/Makefile index 7052858c..36dd5a43 100644 --- a/Makefile +++ b/Makefile @@ -71,7 +71,7 @@ check-fmt: ## Check Python formatting without modifying files markdownlint: ## Lint Markdown files @if files=$$(git diff --name-only --diff-filter=ACMRT $(MARKDOWNLINT_BASE)...HEAD -- '*.md' 2>/dev/null); then \ if [ -n "$$files" ]; then \ - printf '%s\n' "$$files" | while IFS= read -r file; do $(MDLINT) "$$file"; done; \ + printf '%s\n' "$$files" | { status=0; while IFS= read -r file; do $(MDLINT) "$$file" || status=1; done; exit $$status; }; \ else \ find . -type f -name '*.md' -not -path './target/*' -exec $(MDLINT) {} +; \ fi; \ diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index 6c7d3a03..5ddc6a5a 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -196,9 +196,10 @@ the same job, and discarded when the runner workspace is cleaned up. 2. `_ensure_coverage_venv()` syncs the current project into the venv with `uv sync --inexact --python ` so tests can import project dependencies. -3. `_ensure_coverage_venv()` installs `slipcover`, `pytest`, and `coverage` - into the venv using `uv pip install --python `. The `--system` - flag is deliberately excluded to keep the install isolated. +3. `_ensure_coverage_venv()` performs installation of `slipcover`, `pytest`, + and `coverage` into the venv using + `uv pip install --python `. The `--system` flag is deliberately + excluded to keep the install isolated. 4. `_coverage_python_cmd()` calls `_ensure_coverage_venv()` on first use, caches the resulting `plumbum` command via `functools.lru_cache`, and returns the cached value on all subsequent calls within the same process. From 145b90b0b10076b26fe3582f095b9f366431d4fd Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 21:47:33 +0200 Subject: [PATCH 22/51] Guard coverage command test venv detection Store formulated command arguments before recording them and guard the uv venv check by argument length so short command lists cannot raise IndexError. --- .github/actions/generate-coverage/tests/test_scripts.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 7e101e33..4e2483ec 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -2070,8 +2070,9 @@ def test_coverage_python_cmd_prepares_tools_once( recorded: list[list[str]] = [] def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: - recorded.append(list(cmd.formulate())) # type: ignore[attr-defined] - if recorded[-1][1] == "venv": + args = list(cmd.formulate()) # type: ignore[attr-defined] + recorded.append(args) + if len(args) > 1 and args[1] == "venv": python_path.parent.mkdir(parents=True) python_path.touch() From 533fa20a9f157e643f41fcc813f4cd33d006a162 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 21:48:56 +0200 Subject: [PATCH 23/51] Run action-validator per action metadata file Change the lint target to invoke action-validator once per action YAML file so its single-path CLI contract is respected. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 36dd5a43..2367dd3c 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ endif lint: ## Check test scripts and actions $(UV) tool run ruff check find .github/actions -type f \( -name 'action.yml' -o -name 'action.yaml' \) \ - -exec $(ACTION_VALIDATOR) {} + + -exec $(ACTION_VALIDATOR) {} \; typecheck: .venv ## Run static type checking with Ty ./.venv/bin/ty check \ From ee249059d7aa28c24265cba0dfeb392a2e07728f Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 21:51:13 +0200 Subject: [PATCH 24/51] Extract coverage venv recreation helpers Split coverage venv removal and recreation out of _ensure_coverage_venv so the setup flow stays below the complexity threshold while preserving existing behaviour. --- .../generate-coverage/scripts/run_python.py | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 2d7d4c1a..765f08bd 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -59,6 +59,48 @@ def _find_coverage_python() -> Path | None: return 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(): + 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 + + def _ensure_coverage_venv() -> str: """Create or repair the coverage venv and install project/test tooling. @@ -66,23 +108,7 @@ def _ensure_coverage_venv() -> str: """ python = _find_coverage_python() if python is None: - if COVERAGE_VENV.exists(): - typer.echo( - f"Coverage venv at {COVERAGE_VENV} is missing its Python " - "executable; recreating.", - err=True, - ) - if COVERAGE_VENV.is_dir() and not COVERAGE_VENV.is_symlink(): - shutil.rmtree(COVERAGE_VENV) - else: - COVERAGE_VENV.unlink(missing_ok=True) - 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) + python = _recreate_coverage_venv() typer.echo(f"Installing project dependencies into {COVERAGE_VENV}") run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) typer.echo(f"Installing coverage tooling {TOOLING_PACKAGES} into {COVERAGE_VENV}") From 3c20bbd20114bc0b0be4c82fe7a1fcd9ecec0f5e Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Apr 2026 23:56:36 +0200 Subject: [PATCH 25/51] Move markdownlint logic into helper script Replace the long Makefile markdownlint recipe with a call to a small shell script that preserves the existing diff-based linting, fallback warning, and exit-code behaviour. --- Makefile | 11 +---------- workflow_scripts/markdownlint-check.sh | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) create mode 100755 workflow_scripts/markdownlint-check.sh diff --git a/Makefile b/Makefile index 2367dd3c..f1bd8b75 100644 --- a/Makefile +++ b/Makefile @@ -69,16 +69,7 @@ check-fmt: ## Check Python formatting without modifying files $(UV) tool run ruff check --select $(RUFF_FIX_RULES) markdownlint: ## Lint Markdown files - @if files=$$(git diff --name-only --diff-filter=ACMRT $(MARKDOWNLINT_BASE)...HEAD -- '*.md' 2>/dev/null); then \ - if [ -n "$$files" ]; then \ - printf '%s\n' "$$files" | { status=0; while IFS= read -r file; do $(MDLINT) "$$file" || status=1; done; exit $$status; }; \ - else \ - find . -type f -name '*.md' -not -path './target/*' -exec $(MDLINT) {} +; \ - fi; \ - else \ - echo "markdownlint: git diff failed or base '$(MARKDOWNLINT_BASE)' not found; linting all .md files" >&2; \ - find . -type f -name '*.md' -not -path './target/*' -exec $(MDLINT) {} +; \ - fi + MARKDOWNLINT_BASE='$(MARKDOWNLINT_BASE)' MDLINT='$(MDLINT)' ./workflow_scripts/markdownlint-check.sh nixie: ## Validate Mermaid diagrams find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(NIXIE) diff --git a/workflow_scripts/markdownlint-check.sh b/workflow_scripts/markdownlint-check.sh new file mode 100755 index 00000000..9130fecb --- /dev/null +++ b/workflow_scripts/markdownlint-check.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +MARKDOWNLINT_BASE="${MARKDOWNLINT_BASE:-origin/main}" +MDLINT="${MDLINT:-markdownlint}" + +if files=$(git diff --name-only --diff-filter=ACMRT "${MARKDOWNLINT_BASE}...HEAD" -- '*.md' 2>/dev/null); then + if [ -n "${files}" ]; then + status=0 + while IFS= read -r file; do + ${MDLINT} "${file}" || status=1 + done <&2 + find . -type f -name '*.md' -not -path './target/*' -exec ${MDLINT} {} + +fi From 35881784a3fc85d51c56846b75fc2b969fd0a1cc Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 00:15:04 +0200 Subject: [PATCH 26/51] Prefer Bun action-validator and quote markdownlint command Choose the Bun-installed action-validator before Cargo or PATH so CI uses its provided binary, and quote markdownlint invocations in the helper script to avoid shell word splitting. --- Makefile | 2 +- workflow_scripts/markdownlint-check.sh | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index f1bd8b75..2033372f 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ clean: ## Remove transient artefacts rm -rf .venv .pytest_cache .ruff_cache workspace/.ruff_cache BUILD_JOBS ?= -ACTION_VALIDATOR ?= $(if $(wildcard $(HOME)/.cargo/bin/action-validator),$(HOME)/.cargo/bin/action-validator,action-validator) +ACTION_VALIDATOR ?= $(or $(firstword $(wildcard $(HOME)/.bun/bin/action-validator) $(wildcard $(HOME)/.cargo/bin/action-validator)),action-validator) MDLINT ?= $(if $(wildcard $(HOME)/.bun/bin/markdownlint),$(HOME)/.bun/bin/markdownlint,markdownlint) MARKDOWNLINT_BASE ?= origin/main NIXIE ?= nixie diff --git a/workflow_scripts/markdownlint-check.sh b/workflow_scripts/markdownlint-check.sh index 9130fecb..a1ed3a45 100755 --- a/workflow_scripts/markdownlint-check.sh +++ b/workflow_scripts/markdownlint-check.sh @@ -8,15 +8,15 @@ if files=$(git diff --name-only --diff-filter=ACMRT "${MARKDOWNLINT_BASE}...HEAD if [ -n "${files}" ]; then status=0 while IFS= read -r file; do - ${MDLINT} "${file}" || status=1 + "${MDLINT}" "${file}" || status=1 done <&2 - find . -type f -name '*.md' -not -path './target/*' -exec ${MDLINT} {} + + find . -type f -name '*.md' -not -path './target/*' -exec "${MDLINT}" {} + fi From ab88554c86bfdaac2468b88e99c536c49cd72692 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 00:33:26 +0200 Subject: [PATCH 27/51] Extract default coverage venv rebuild assertion Add a helper for tests that rebuild the coverage venv to the default POSIX Python path, removing duplicated assertions from file and symlink cache cases. --- .../generate-coverage/tests/test_scripts.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 4e2483ec..c2685d11 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1970,6 +1970,16 @@ def _assert_venv_rebuild_commands( ] +def _assert_venv_default_python_rebuild( + python: str, + setup: VenvTestSetup, +) -> None: + """Assert venv was rebuilt and Python resolved to the default POSIX path.""" + expected = setup.coverage_venv / "bin" / "python" + assert python == str(expected) + _assert_venv_rebuild_commands(setup.recorded, setup.coverage_venv, expected) + + def test_ensure_coverage_venv_replaces_broken_symlink_cache( tmp_path: Path, run_python_module: ModuleType, @@ -1984,12 +1994,7 @@ def test_ensure_coverage_venv_replaces_broken_symlink_cache( python = run_python_module._ensure_coverage_venv() assert not setup.coverage_venv.is_symlink() - assert python == str(setup.coverage_venv / "bin" / "python") - _assert_venv_rebuild_commands( - setup.recorded, - setup.coverage_venv, - setup.coverage_venv / "bin" / "python", - ) + _assert_venv_default_python_rebuild(python, setup) def test_ensure_coverage_venv_replaces_broken_file_cache( @@ -2003,12 +2008,7 @@ def test_ensure_coverage_venv_replaces_broken_file_cache( python = run_python_module._ensure_coverage_venv() - assert python == str(setup.coverage_venv / "bin" / "python") - _assert_venv_rebuild_commands( - setup.recorded, - setup.coverage_venv, - setup.coverage_venv / "bin" / "python", - ) + _assert_venv_default_python_rebuild(python, setup) def test_ensure_coverage_venv_recreates_invalid_python_candidate( From e7b1ce72fe71d2e80969d9313c396ebe4f9bb8f6 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 00:45:22 +0200 Subject: [PATCH 28/51] Use executable stems in coverage tests Compare executable stems instead of full file names in coverage command tests so Windows .exe suffixes do not break path assertions. --- .../actions/generate-coverage/tests/test_scripts.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index c2685d11..03c0895b 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1764,7 +1764,7 @@ def _set_fake_coverage_python_cmd( def _assert_python_command_structure(parts: list[str]) -> None: """Verify common venv Python command structure with slipcover and pytest.""" - assert Path(parts[0]).name == "python" + assert Path(parts[0]).stem == "python" slip_idx = parts.index("-m", 1) assert parts[slip_idx : slip_idx + 3] == ["-m", "slipcover", "--branch"] assert parts[-3:] == ["-m", "pytest", "-v"] @@ -1772,7 +1772,10 @@ def _assert_python_command_structure(parts: list[str]) -> None: def _assert_coverage_python_path(actual: str, expected: str) -> None: """Assert that a formulated command points at the coverage venv Python.""" - assert Path(actual).parts[-3:] == Path(expected).parts[-3:] + actual_path = Path(actual) + expected_path = Path(expected) + assert actual_path.parts[-3:-1] == expected_path.parts[-3:-1] + assert actual_path.stem == expected_path.stem def _assert_tokens_in_order(parts: list[str], *tokens: str) -> None: @@ -1852,7 +1855,7 @@ def test_ensure_coverage_venv_returns_coverage_python( assert python == str(setup.coverage_venv / "bin" / "python") assert len(setup.recorded) == 3 venv_parts = setup.recorded[0] - assert Path(venv_parts[0]).name == "uv" + assert Path(venv_parts[0]).stem == "uv" assert venv_parts[1:] == ["venv", str(setup.coverage_venv)] sync_parts = setup.recorded[1] assert sync_parts[1:] == ["sync", "--inexact", "--python", python] @@ -1940,7 +1943,7 @@ def test_ensure_coverage_venv_raises_when_created_venv_has_no_python( assert run_python_module._find_coverage_python() is None assert len(setup.recorded) == 1 - assert Path(setup.recorded[0][0]).name == "uv" + assert Path(setup.recorded[0][0]).stem == "uv" assert setup.recorded[0][1:] == ["venv", str(setup.coverage_venv)] assert not [r for r in setup.recorded if len(r) > 1 and r[1] == "sync"] assert not [ @@ -2053,7 +2056,7 @@ def test_ensure_coverage_venv_targets_venv_python_for_tooling( assert len(setup.recorded) == 2 assert setup.recorded[0][1:] == ["sync", "--inexact", "--python", str(python)] parts = setup.recorded[1] - assert Path(parts[0]).name == "uv" + assert Path(parts[0]).stem == "uv" assert parts[1:5] == ["pip", "install", "--python", str(python)] assert "--system" not in parts assert set(run_python_module.TOOLING_PACKAGES).issubset(parts) From aaf12765346f501115226549761218774abdaef4 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 15:14:47 +0200 Subject: [PATCH 29/51] Merge non-Cobertura coverage command tests Combine duplicate coveragepy command tests so the shared command setup runs once and all non-Cobertura flag assertions live together. --- .../generate-coverage/tests/test_scripts.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 03c0895b..812c4798 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -2148,28 +2148,17 @@ def test_coverage_cmd_cobertura_uses_venv_python( _assert_flag_value_pair(parts, "--out", str(out)) -def test_coverage_cmd_default_branch_has_shared_args( +def test_non_cobertura_formats_do_not_emit_cobertura_flags( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Non-Cobertura formats reuse the shared slipcover arguments.""" + """Non-Cobertura formats do not emit Cobertura output flags.""" parts, out = _get_coverage_cmd_parts( tmp_path, run_python_module, monkeypatch, CoverageFmtSpec("coveragepy", "dat") ) assert "--xml" not in parts assert str(out) not in parts - - -def test_non_cobertura_formats_do_not_emit_out_flag( - tmp_path: Path, - run_python_module: ModuleType, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Ensure slipcover output targeting stays isolated to Cobertura runs.""" - parts, _ = _get_coverage_cmd_parts( - tmp_path, run_python_module, monkeypatch, CoverageFmtSpec("coveragepy", "dat") - ) assert "--out" not in parts From 13da80fd3cd5ac0d0a1eef1f3a8b8acad35215fa Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 19:56:25 +0200 Subject: [PATCH 30/51] Document coverage venv migration and add integration tests Add run_python integration coverage for the coverage venv setup path and uv failure diagnostics, expand coverage helper docstrings, and document the venv migration in the roadmap, changelog, and agent Makefile tool-resolution notes. Also add the markdownlint helper header and preserve the existing Makefile helper behaviour. --- .../actions/generate-coverage/CHANGELOG.md | 13 ++ .../generate-coverage/scripts/run_python.py | 79 ++++++++- .../generate-coverage/tests/test_scripts.py | 155 ++++++++++++++++++ AGENTS.md | 19 +++ docs/generate-coverage-design.md | 4 + workflow_scripts/markdownlint-check.sh | 13 ++ 6 files changed, 278 insertions(+), 5 deletions(-) diff --git a/.github/actions/generate-coverage/CHANGELOG.md b/.github/actions/generate-coverage/CHANGELOG.md index 5ec7456e..32711a48 100644 --- a/.github/actions/generate-coverage/CHANGELOG.md +++ b/.github/actions/generate-coverage/CHANGELOG.md @@ -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 diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 765f08bd..907ddeae 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -110,9 +110,23 @@ def _ensure_coverage_venv() -> str: if python is None: python = _recreate_coverage_venv() typer.echo(f"Installing project dependencies into {COVERAGE_VENV}") - run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) + try: + run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) + 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}") - run_cmd(uv["pip", "install", "--python", str(python), *TOOLING_PACKAGES]) + 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) @@ -134,14 +148,51 @@ 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.""" + """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 `` 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: @@ -166,7 +217,25 @@ def main( github_output: Path = GITHUB_OUTPUT_OPT, baseline_file: Path | None = BASELINE_OPT, ) -> None: - """Run slipcover coverage and write the output path to ``GITHUB_OUTPUT``.""" + """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 = output_path if lang == "mixed": out = output_path.with_name(f"{output_path.stem}.python{output_path.suffix}") diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 812c4798..2f446fdd 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -2404,3 +2404,158 @@ def raise_permission_error(*_: object, **__: object) -> object: with pytest.raises(run_python_module.typer.Exit) as excinfo: run_python_module.get_line_coverage_percent_from_cobertura(xml) assert _exit_code(excinfo.value) == 1 + + +# --------------------------------------------------------------------------- +# Integration tests - run_python.py via run_script() +# --------------------------------------------------------------------------- + + +def _run_python_script( + tmp_path: Path, + shell_stubs: StubManager, + *, + fmt: str = "cobertura", + extra_env: dict[str, str] | None = None, + monkeypatch: pytest.MonkeyPatch | None = None, +) -> tuple[int, str, str]: + """Run run_python.py end-to-end with stub uv and return (rc, stdout, stderr).""" + out = tmp_path / "cov.xml" + gh = tmp_path / "gh.txt" + out.write_text("", encoding="utf-8") + + env = { + **shell_stubs.env, + "INPUT_OUTPUT_PATH": str(out), + "DETECTED_LANG": "python", + "DETECTED_FMT": fmt, + "GITHUB_OUTPUT": str(gh), + } + if extra_env: + env.update(extra_env) + if monkeypatch is not None: + monkeypatch.chdir(tmp_path) + + script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" + return run_script(script, env) + + +def _write_fake_uv( + tmp_path: Path, + *, + venv_exit: int = 0, + sync_exit: int = 0, +) -> tuple[Path, Path]: + """Write a fake uv executable and return its bin directory and log path.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + log = tmp_path / "uv-calls.log" + uv = bin_dir / "uv" + uv.write_text( + f"""#!/usr/bin/env sh +printf '%s\\n' "$*" >> '{log}' +if [ "$1" = "venv" ]; then + if [ {venv_exit} -ne 0 ]; then + echo "uv venv exploded" >&2 + exit {venv_exit} + fi + mkdir -p "$2/bin" + cat > "$2/bin/python" <<'PY' +#!/usr/bin/env sh +exit 0 +PY + chmod +x "$2/bin/python" + exit 0 +fi +if [ "$1" = "sync" ]; then + if [ {sync_exit} -ne 0 ]; then + echo "uv sync exploded" >&2 + exit {sync_exit} + fi + exit 0 +fi +exit 0 +""", + encoding="utf-8", + ) + uv.chmod(0o755) + return bin_dir, log + + +def _python_integration_env( + tmp_path: Path, + shell_stubs: StubManager, + bin_dir: Path, +) -> dict[str, str]: + """Return environment for run_python.py integration tests.""" + out = tmp_path / "cov.xml" + gh = tmp_path / "gh.txt" + out.write_text("", encoding="utf-8") + env = { + **shell_stubs.env, + "INPUT_OUTPUT_PATH": str(out), + "DETECTED_LANG": "python", + "DETECTED_FMT": "cobertura", + "GITHUB_OUTPUT": str(gh), + } + env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" + return env + + +def test_run_python_integration_cobertura_success( + tmp_path: Path, + shell_stubs: StubManager, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_python.py creates the venv, syncs deps, and installs tooling.""" + bin_dir, log = _write_fake_uv(tmp_path) + env = _python_integration_env(tmp_path, shell_stubs, bin_dir) + monkeypatch.chdir(tmp_path) + + script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" + returncode, _stdout, _stderr = run_script(script, env) + uv_calls = log.read_text(encoding="utf-8").splitlines() + venv_calls = [c for c in uv_calls if c.startswith("venv ")] + sync_calls = [c for c in uv_calls if c.startswith("sync ")] + pip_calls = [c for c in uv_calls if c.startswith("pip install ")] + assert returncode == 0 + assert venv_calls, "uv venv must be called to create the coverage venv" + assert sync_calls, "uv sync must be called to install project deps" + assert pip_calls, "uv pip install must be called to install tooling" + pip_args = pip_calls[0].split() + assert "--python" in pip_args + assert "--system" not in pip_args + assert "slipcover" in pip_args + assert "pytest" in pip_args + assert "coverage" in pip_args + + +def test_run_python_integration_uv_venv_failure( + tmp_path: Path, + shell_stubs: StubManager, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_python.py exits non-zero when uv venv fails.""" + bin_dir, _log = _write_fake_uv(tmp_path, venv_exit=1) + env = _python_integration_env(tmp_path, shell_stubs, bin_dir) + monkeypatch.chdir(tmp_path) + + script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" + returncode, _stdout, _stderr = run_script(script, env) + assert returncode != 0 + + +def test_run_python_integration_uv_sync_failure( + tmp_path: Path, + shell_stubs: StubManager, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_python.py exits non-zero and logs a message when uv sync fails.""" + bin_dir, _log = _write_fake_uv(tmp_path, sync_exit=2) + env = _python_integration_env(tmp_path, shell_stubs, bin_dir) + monkeypatch.chdir(tmp_path) + + script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" + returncode, _stdout, stderr = run_script(script, env) + assert returncode != 0 + assert "uv sync failed" in stderr diff --git a/AGENTS.md b/AGENTS.md index 3ea78e85..d2aae3d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,25 @@ auto‑increments patch unless `release‑type` input overrides (`minor`, `major CI workflow lives at `.github/workflows/ci.yml` and runs on PR and nightly via schedule. +### Makefile tool resolution + +The `Makefile` resolves optional local tool installations before falling back +to bare names on `PATH`. The following variables are set at the top of +`Makefile` and may be overridden on the command line: + +| Variable | Default resolution order | +|----------------------|------------------------------------------------------------------------------------------| +| `UV` | `~/.local/bin/uv` if present, otherwise `uv` | +| `ACTION_VALIDATOR` | `~/.bun/bin/action-validator`, then `~/.cargo/bin/action-validator`, then `action-validator` | +| `MDLINT` | `~/.bun/bin/markdownlint` if present, otherwise `markdownlint` | +| `MARKDOWNLINT_BASE` | `origin/main` (used as the base ref for `git diff` in the `markdownlint` target) | + +Example - use a system `uv` and a custom markdownlint base: + +```bash +make lint UV=uv MARKDOWNLINT_BASE=origin/develop +``` + ## 5  Security Hardening 1. **Pin third‑party actions** to a full commit SHA (not just `@v1`). diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index 5ddc6a5a..6cd747dc 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -176,6 +176,10 @@ stops short of that complexity. suffixes. - [x] Document the Rust coverage environment-override design for Cranelift-configured repositories. +- [x] Replace `uv run --with` ephemeral environments with a persistent, + job-local `.venv-coverage` virtual environment to enable intra-process + caching of the Python interpreter path via `functools.lru_cache` and to add + broken-venv recovery. ## Python Coverage Venv Architecture diff --git a/workflow_scripts/markdownlint-check.sh b/workflow_scripts/markdownlint-check.sh index a1ed3a45..381f2fb5 100755 --- a/workflow_scripts/markdownlint-check.sh +++ b/workflow_scripts/markdownlint-check.sh @@ -1,4 +1,17 @@ #!/usr/bin/env sh +# +# markdownlint-check.sh - Run markdownlint over changed or all Markdown files. +# +# Usage (called from the Makefile `markdownlint` target): +# MARKDOWNLINT_BASE=origin/main MDLINT=markdownlint ./workflow_scripts/markdownlint-check.sh +# +# Environment variables: +# MARKDOWNLINT_BASE Git ref used as the base for `git diff`; defaults to +# origin/main. If the ref does not exist, falls back to +# linting all *.md files. +# MDLINT Path or name of the markdownlint binary; defaults to +# markdownlint. +# set -eu MARKDOWNLINT_BASE="${MARKDOWNLINT_BASE:-origin/main}" From d7121519d9d8a761aab27f64404a1ee7d7595bf4 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 19:58:38 +0200 Subject: [PATCH 31/51] Fix AGENTS markdown table alignment Reflow the Makefile tool-resolution table so markdownlint accepts the aligned pipe table while retaining the action-validator lookup details in prose. --- AGENTS.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d2aae3d0..a8d8003c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,12 +112,16 @@ The `Makefile` resolves optional local tool installations before falling back to bare names on `PATH`. The following variables are set at the top of `Makefile` and may be overridden on the command line: -| Variable | Default resolution order | -|----------------------|------------------------------------------------------------------------------------------| -| `UV` | `~/.local/bin/uv` if present, otherwise `uv` | -| `ACTION_VALIDATOR` | `~/.bun/bin/action-validator`, then `~/.cargo/bin/action-validator`, then `action-validator` | -| `MDLINT` | `~/.bun/bin/markdownlint` if present, otherwise `markdownlint` | -| `MARKDOWNLINT_BASE` | `origin/main` (used as the base ref for `git diff` in the `markdownlint` target) | +| Variable | Default resolution order | +| ------------------- | -------------------------------------------- | +| `UV` | `~/.local/bin/uv`, otherwise `uv` | +| `ACTION_VALIDATOR` | Bun install, then Cargo install, then `PATH` | +| `MDLINT` | `~/.bun/bin/markdownlint`, then `PATH` | +| `MARKDOWNLINT_BASE` | `origin/main` for the markdownlint diff base | + +For `ACTION_VALIDATOR`, the concrete lookup order is +`~/.bun/bin/action-validator`, then `~/.cargo/bin/action-validator`, then +`action-validator`. Example - use a system `uv` and a custom markdownlint base: From d002a85c01b64336ef7751d3574ca15a70ff76c3 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 20:16:00 +0200 Subject: [PATCH 32/51] Handle invalid coverage venv paths Reject symlinked and non-directory coverage venv placeholders before probing for Python, and document that broken-cache recovery removes files, symlinks, and directories. --- .github/actions/generate-coverage/scripts/run_python.py | 2 ++ docs/generate-coverage-design.md | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 907ddeae..261e5846 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -48,6 +48,8 @@ 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", diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index 6cd747dc..e7e39e93 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -195,8 +195,9 @@ the same job, and discarded when the runner workspace is cleaned up. 1. `_ensure_coverage_venv()` checks whether `.venv-coverage` contains a Python executable. - If absent, it creates the venv via `uv venv .venv-coverage`. - - If present but broken (Python binary missing), it removes the directory - and recreates it. + - If present but broken (Python binary missing), it removes the existing + path - unlinking files and symlinks and removing directories - and then + recreates it. 2. `_ensure_coverage_venv()` syncs the current project into the venv with `uv sync --inexact --python ` so tests can import project dependencies. From 394def335f1697f9bea7bbb6e29f3e0559be5a8f Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 20:27:02 +0200 Subject: [PATCH 33/51] Fix AGENTS documentation links and tool lookup Clarify ACTION_VALIDATOR resolution, update example punctuation, and replace the invalid README.md link with a code span. --- AGENTS.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8d8003c..e8b2c50f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,18 +112,18 @@ The `Makefile` resolves optional local tool installations before falling back to bare names on `PATH`. The following variables are set at the top of `Makefile` and may be overridden on the command line: -| Variable | Default resolution order | -| ------------------- | -------------------------------------------- | -| `UV` | `~/.local/bin/uv`, otherwise `uv` | -| `ACTION_VALIDATOR` | Bun install, then Cargo install, then `PATH` | -| `MDLINT` | `~/.bun/bin/markdownlint`, then `PATH` | -| `MARKDOWNLINT_BASE` | `origin/main` for the markdownlint diff base | +| Variable | Default resolution order | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| `UV` | `~/.local/bin/uv`, otherwise `uv` | +| `ACTION_VALIDATOR` | `~/.bun/bin/action-validator`, then `~/.cargo/bin/action-validator`, then `action-validator` (on `PATH`) | +| `MDLINT` | `~/.bun/bin/markdownlint`, then `PATH` | +| `MARKDOWNLINT_BASE` | `origin/main` for the markdownlint diff base | For `ACTION_VALIDATOR`, the concrete lookup order is `~/.bun/bin/action-validator`, then `~/.cargo/bin/action-validator`, then `action-validator`. -Example - use a system `uv` and a custom markdownlint base: +Example — use a system `uv` and a custom markdownlint base: ```bash make lint UV=uv MARKDOWNLINT_BASE=origin/develop @@ -145,7 +145,7 @@ make lint UV=uv MARKDOWNLINT_BASE=origin/develop ## 6  Documentation Standards -- Each action [**README.md**](http://README.md) must contain: +- Each action `README.md` must contain: - **One‑liner summary** From d617af47d314c76b8d1568cec456c2b06da554af Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 20:28:32 +0200 Subject: [PATCH 34/51] Fix coverage design wording Use installation rather than install in the coverage venv lifecycle documentation. --- docs/generate-coverage-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index e7e39e93..8fa0f150 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -204,7 +204,7 @@ the same job, and discarded when the runner workspace is cleaned up. 3. `_ensure_coverage_venv()` performs installation of `slipcover`, `pytest`, and `coverage` into the venv using `uv pip install --python `. The `--system` flag is deliberately - excluded to keep the install isolated. + excluded to keep the installation isolated. 4. `_coverage_python_cmd()` calls `_ensure_coverage_venv()` on first use, caches the resulting `plumbum` command via `functools.lru_cache`, and returns the cached value on all subsequent calls within the same process. From ee1226c650046010e7ea08ce6411d5a263aad451 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 20:30:44 +0200 Subject: [PATCH 35/51] Refactor run_python integration test runner Extract shared environment setup, chdir, and script invocation for the run_python integration tests. --- .../generate-coverage/tests/test_scripts.py | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 2f446fdd..6ec5c959 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -2502,6 +2502,19 @@ def _python_integration_env( return env +def _run_integration_script( + tmp_path: Path, + shell_stubs: StubManager, + bin_dir: Path, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[int, str, str]: + """Set up env, chdir, and invoke run_python.py; return (rc, stdout, stderr).""" + env = _python_integration_env(tmp_path, shell_stubs, bin_dir) + monkeypatch.chdir(tmp_path) + script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" + return run_script(script, env) + + def test_run_python_integration_cobertura_success( tmp_path: Path, shell_stubs: StubManager, @@ -2509,11 +2522,11 @@ def test_run_python_integration_cobertura_success( ) -> None: """run_python.py creates the venv, syncs deps, and installs tooling.""" bin_dir, log = _write_fake_uv(tmp_path) - env = _python_integration_env(tmp_path, shell_stubs, bin_dir) - monkeypatch.chdir(tmp_path) - script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" - returncode, _stdout, _stderr = run_script(script, env) + returncode, _stdout, _stderr = _run_integration_script( + tmp_path, shell_stubs, bin_dir, monkeypatch + ) + uv_calls = log.read_text(encoding="utf-8").splitlines() venv_calls = [c for c in uv_calls if c.startswith("venv ")] sync_calls = [c for c in uv_calls if c.startswith("sync ")] @@ -2537,11 +2550,11 @@ def test_run_python_integration_uv_venv_failure( ) -> None: """run_python.py exits non-zero when uv venv fails.""" bin_dir, _log = _write_fake_uv(tmp_path, venv_exit=1) - env = _python_integration_env(tmp_path, shell_stubs, bin_dir) - monkeypatch.chdir(tmp_path) - script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" - returncode, _stdout, _stderr = run_script(script, env) + returncode, _stdout, _stderr = _run_integration_script( + tmp_path, shell_stubs, bin_dir, monkeypatch + ) + assert returncode != 0 @@ -2552,10 +2565,10 @@ def test_run_python_integration_uv_sync_failure( ) -> None: """run_python.py exits non-zero and logs a message when uv sync fails.""" bin_dir, _log = _write_fake_uv(tmp_path, sync_exit=2) - env = _python_integration_env(tmp_path, shell_stubs, bin_dir) - monkeypatch.chdir(tmp_path) - script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" - returncode, _stdout, stderr = run_script(script, env) + returncode, _stdout, stderr = _run_integration_script( + tmp_path, shell_stubs, bin_dir, monkeypatch + ) + assert returncode != 0 assert "uv sync failed" in stderr From a8aa69375af8ab8880189d244a041e2b631d17ea Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Apr 2026 20:33:19 +0200 Subject: [PATCH 36/51] Remove unused run_python test helper Delete the dead _run_python_script helper after the integration tests moved to _run_integration_script. --- .../generate-coverage/tests/test_scripts.py | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 6ec5c959..4896deb6 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -2411,35 +2411,6 @@ def raise_permission_error(*_: object, **__: object) -> object: # --------------------------------------------------------------------------- -def _run_python_script( - tmp_path: Path, - shell_stubs: StubManager, - *, - fmt: str = "cobertura", - extra_env: dict[str, str] | None = None, - monkeypatch: pytest.MonkeyPatch | None = None, -) -> tuple[int, str, str]: - """Run run_python.py end-to-end with stub uv and return (rc, stdout, stderr).""" - out = tmp_path / "cov.xml" - gh = tmp_path / "gh.txt" - out.write_text("", encoding="utf-8") - - env = { - **shell_stubs.env, - "INPUT_OUTPUT_PATH": str(out), - "DETECTED_LANG": "python", - "DETECTED_FMT": fmt, - "GITHUB_OUTPUT": str(gh), - } - if extra_env: - env.update(extra_env) - if monkeypatch is not None: - monkeypatch.chdir(tmp_path) - - script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" - return run_script(script, env) - - def _write_fake_uv( tmp_path: Path, *, From 92d0b343d2f12660aadda547dc4d019319a8daca Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 08:34:12 +0200 Subject: [PATCH 37/51] Handle dangling coverage venv symlinks Exercise dangling symlink cache recovery and treat symlinked coverage venv paths as placeholders that must be removed before recreation. --- .github/actions/generate-coverage/scripts/run_python.py | 2 +- .github/actions/generate-coverage/tests/test_scripts.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 261e5846..6a4a9da1 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -86,7 +86,7 @@ def _recreate_coverage_venv() -> Path: RuntimeError If the Python executable cannot be located after creation. """ - if COVERAGE_VENV.exists(): + if COVERAGE_VENV.exists() or COVERAGE_VENV.is_symlink(): typer.echo( f"Coverage venv at {COVERAGE_VENV} is missing its Python " "executable; recreating.", diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 4896deb6..40d966b2 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1991,7 +1991,6 @@ def test_ensure_coverage_venv_replaces_broken_symlink_cache( """The helper unlinks a symlink cache placeholder before recreate.""" setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) target = tmp_path / "not-a-venv" - target.write_text("not a directory") setup.coverage_venv.symlink_to(target) python = run_python_module._ensure_coverage_venv() From dc432bbd0f806d08380779f3648b416a2662075a Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 10:06:08 +0200 Subject: [PATCH 38/51] Reduce run_python complexity Use next() in coverage Python discovery and extract mixed-language output path resolution from main. --- .../generate-coverage/scripts/run_python.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 6a4a9da1..74a40cf3 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -55,10 +55,7 @@ def _find_coverage_python() -> Path | None: COVERAGE_VENV / "Scripts" / "python.exe", COVERAGE_VENV / "Scripts" / "python", ) - for candidate in candidates: - if candidate.is_file(): - return candidate - return None + return next((c for c in candidates if c.is_file()), None) def _remove_coverage_venv() -> None: @@ -212,6 +209,27 @@ 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, @@ -238,9 +256,7 @@ def main( Optional path to a previous coverage baseline file. When present, the previous percentage is echoed to the log. """ - out = output_path - if lang == "mixed": - out = output_path.with_name(f"{output_path.stem}.python{output_path.suffix}") + out = _resolve_output_path(output_path, lang) out.parent.mkdir(parents=True, exist_ok=True) cmd = coverage_cmd_for_fmt(fmt, out) From bcbc0f1e913c22d95fa15e1c4a1c9bb5136f368b Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 10:11:48 +0200 Subject: [PATCH 39/51] Parametrize coverage venv failure tests Merge duplicated placeholder-recovery and run_python uv-failure tests into parametrized cases. --- .../generate-coverage/tests/test_scripts.py | 67 ++++++++----------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 40d966b2..500c4a5f 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1983,33 +1983,28 @@ def _assert_venv_default_python_rebuild( _assert_venv_rebuild_commands(setup.recorded, setup.coverage_venv, expected) -def test_ensure_coverage_venv_replaces_broken_symlink_cache( - tmp_path: Path, - run_python_module: ModuleType, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The helper unlinks a symlink cache placeholder before recreate.""" - setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) - target = tmp_path / "not-a-venv" - setup.coverage_venv.symlink_to(target) - - python = run_python_module._ensure_coverage_venv() - - assert not setup.coverage_venv.is_symlink() - _assert_venv_default_python_rebuild(python, setup) - - -def test_ensure_coverage_venv_replaces_broken_file_cache( +@pytest.mark.parametrize( + "broken_state_kind", + ["file", "symlink"], + ids=["file-placeholder", "symlink-placeholder"], +) +def test_ensure_coverage_venv_replaces_broken_placeholder( tmp_path: Path, run_python_module: ModuleType, monkeypatch: pytest.MonkeyPatch, + broken_state_kind: str, ) -> None: - """The helper replaces a non-directory cache placeholder before recreate.""" + """The helper removes file and symlink placeholders before recreating the venv.""" setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) - setup.coverage_venv.write_text("not a venv") + if broken_state_kind == "symlink": + setup.coverage_venv.symlink_to(tmp_path / "not-a-venv") + else: + setup.coverage_venv.write_text("not a venv") python = run_python_module._ensure_coverage_venv() + if broken_state_kind == "symlink": + assert not setup.coverage_venv.is_symlink() _assert_venv_default_python_rebuild(python, setup) @@ -2513,32 +2508,28 @@ def test_run_python_integration_cobertura_success( assert "coverage" in pip_args -def test_run_python_integration_uv_venv_failure( - tmp_path: Path, - shell_stubs: StubManager, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """run_python.py exits non-zero when uv venv fails.""" - bin_dir, _log = _write_fake_uv(tmp_path, venv_exit=1) - - returncode, _stdout, _stderr = _run_integration_script( - tmp_path, shell_stubs, bin_dir, monkeypatch - ) - - assert returncode != 0 - - -def test_run_python_integration_uv_sync_failure( +@pytest.mark.parametrize( + ("write_kwargs", "expected_in_stderr"), + [ + ({"venv_exit": 1}, None), + ({"sync_exit": 2}, "uv sync failed"), + ], + ids=["uv-venv-fails", "uv-sync-fails"], +) +def test_run_python_integration_uv_failure_modes( tmp_path: Path, shell_stubs: StubManager, monkeypatch: pytest.MonkeyPatch, + write_kwargs: dict[str, int], + expected_in_stderr: str | None, ) -> None: - """run_python.py exits non-zero and logs a message when uv sync fails.""" - bin_dir, _log = _write_fake_uv(tmp_path, sync_exit=2) + """run_python.py exits non-zero when uv venv or uv sync fails.""" + bin_dir, _log = _write_fake_uv(tmp_path, **write_kwargs) returncode, _stdout, stderr = _run_integration_script( tmp_path, shell_stubs, bin_dir, monkeypatch ) assert returncode != 0 - assert "uv sync failed" in stderr + if expected_in_stderr is not None: + assert expected_in_stderr in stderr From e26e9def63c4ffde93fb9e164c99c5f2a43514ee Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 10:16:54 +0200 Subject: [PATCH 40/51] Target uv sync at coverage venv Set UV_PROJECT_ENVIRONMENT before syncing project dependencies so uv provisions .venv-coverage instead of the default project venv. --- .../generate-coverage/scripts/run_python.py | 2 ++ .../generate-coverage/tests/test_scripts.py | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 74a40cf3..cf55162f 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -9,6 +9,7 @@ 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 @@ -109,6 +110,7 @@ def _ensure_coverage_venv() -> str: 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)]) except ProcessExecutionError as exc: diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 500c4a5f..b2310215 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -2056,6 +2056,34 @@ def test_ensure_coverage_venv_targets_venv_python_for_tooling( assert set(run_python_module.TOOLING_PACKAGES).issubset(parts) +def test_ensure_coverage_venv_sets_uv_project_environment_for_sync( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Uv sync targets the coverage venv via UV_PROJECT_ENVIRONMENT.""" + setup = _setup_coverage_venv_test( + tmp_path, run_python_module, monkeypatch, python_to_create=None + ) + python = setup.coverage_venv / "bin" / "python" + python.parent.mkdir(parents=True) + python.touch() + sync_environment: list[str | None] = [] + + def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: + parts = list(cmd.formulate()) # type: ignore[attr-defined] + setup.recorded.append(parts) + if len(parts) > 1 and parts[1] == "sync": + sync_environment.append(os.environ.get("UV_PROJECT_ENVIRONMENT")) + + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False) + + assert run_python_module._ensure_coverage_venv() == str(python) + + assert sync_environment == [str(setup.coverage_venv.resolve())] + + def test_coverage_python_cmd_prepares_tools_once( tmp_path: Path, run_python_module: ModuleType, From 8fe680e57113e6e3d0597454fbc74fce8f596924 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 13:49:37 +0200 Subject: [PATCH 41/51] Validate run_python integration env contract Parse the generate-coverage action contract in integration tests and mark the POSIX fake uv harness as non-Windows. --- .../generate-coverage/tests/test_scripts.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index b2310215..72c64721 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -14,6 +14,7 @@ from pathlib import Path import pytest +import yaml from plumbum import local from cmd_utils_importer import import_cmd_utils @@ -2433,6 +2434,32 @@ def raise_permission_error(*_: object, **__: object) -> object: # --------------------------------------------------------------------------- +def _generate_coverage_action() -> dict[str, object]: + """Return the generate-coverage action contract.""" + action = Path(__file__).resolve().parents[1] / "action.yml" + with action.open(encoding="utf-8") as stream: + loaded = yaml.safe_load(stream) + assert isinstance(loaded, dict) + return loaded + + +def _python_step_env_contract() -> dict[str, str]: + """Return the env contract for the Python coverage step.""" + action = _generate_coverage_action() + runs = action.get("runs") + assert isinstance(runs, dict) + steps = runs.get("steps") + assert isinstance(steps, list) + python_step = next( + step for step in steps if isinstance(step, dict) and step.get("id") == "python" + ) + env = python_step.get("env") + assert isinstance(env, dict) + assert all(isinstance(key, str) for key in env) + assert all(isinstance(value, str) for value in env.values()) + return typ.cast("dict[str, str]", env) + + def _write_fake_uv( tmp_path: Path, *, @@ -2481,6 +2508,11 @@ def _python_integration_env( bin_dir: Path, ) -> dict[str, str]: """Return environment for run_python.py integration tests.""" + python_env = _python_step_env_contract() + assert python_env["INPUT_OUTPUT_PATH"] == "${{ inputs.output-path }}" + assert python_env["DETECTED_LANG"] == "${{ steps.detect.outputs.lang }}" + assert python_env["DETECTED_FMT"] == "${{ steps.detect.outputs.fmt }}" + assert python_env["BASELINE_PYTHON_FILE"] == "${{ inputs.baseline-python-file }}" out = tmp_path / "cov.xml" gh = tmp_path / "gh.txt" out.write_text("", encoding="utf-8") @@ -2489,6 +2521,7 @@ def _python_integration_env( "INPUT_OUTPUT_PATH": str(out), "DETECTED_LANG": "python", "DETECTED_FMT": "cobertura", + "BASELINE_PYTHON_FILE": str(tmp_path / "baseline-python.txt"), "GITHUB_OUTPUT": str(gh), } env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" @@ -2508,6 +2541,7 @@ def _run_integration_script( return run_script(script, env) +@pytest.mark.skipif(sys.platform == "win32", reason="fake uv helper emits POSIX sh") def test_run_python_integration_cobertura_success( tmp_path: Path, shell_stubs: StubManager, @@ -2536,6 +2570,7 @@ def test_run_python_integration_cobertura_success( assert "coverage" in pip_args +@pytest.mark.skipif(sys.platform == "win32", reason="fake uv helper emits POSIX sh") @pytest.mark.parametrize( ("write_kwargs", "expected_in_stderr"), [ From 217dc00040e57823f60bcb3466ad637da6c3bd9e Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 13:54:59 +0200 Subject: [PATCH 42/51] Bundle uv failure test parameters Replace separate uv failure parametrization values with a frozen dataclass and scope UV_PROJECT_ENVIRONMENT to the sync call. --- .../generate-coverage/scripts/run_python.py | 6 +++++ .../generate-coverage/tests/test_scripts.py | 26 +++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index cf55162f..2c2f7fe8 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -110,6 +110,7 @@ def _ensure_coverage_venv() -> str: if python is None: python = _recreate_coverage_venv() typer.echo(f"Installing project dependencies into {COVERAGE_VENV}") + previous_project_environment = os.environ.get("UV_PROJECT_ENVIRONMENT") os.environ["UV_PROJECT_ENVIRONMENT"] = str(COVERAGE_VENV.resolve()) try: run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) @@ -119,6 +120,11 @@ def _ensure_coverage_venv() -> str: err=True, ) raise + finally: + if previous_project_environment is None: + os.environ.pop("UV_PROJECT_ENVIRONMENT", None) + else: + os.environ["UV_PROJECT_ENVIRONMENT"] = previous_project_environment typer.echo(f"Installing coverage tooling {TOOLING_PACKAGES} into {COVERAGE_VENV}") try: run_cmd(uv["pip", "install", "--python", str(python), *TOOLING_PACKAGES]) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 72c64721..00808736 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -2083,6 +2083,7 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: assert run_python_module._ensure_coverage_venv() == str(python) assert sync_environment == [str(setup.coverage_venv.resolve())] + assert "UV_PROJECT_ENVIRONMENT" not in os.environ def test_coverage_python_cmd_prepares_tools_once( @@ -2570,12 +2571,22 @@ def test_run_python_integration_cobertura_success( assert "coverage" in pip_args +@dataclasses.dataclass(frozen=True) +class UvFailureSpec: + """Pairs a fake-uv exit-code configuration with the expected stderr fragment.""" + + write_kwargs: dict[str, int] + expected_in_stderr: str | None + + @pytest.mark.skipif(sys.platform == "win32", reason="fake uv helper emits POSIX sh") @pytest.mark.parametrize( - ("write_kwargs", "expected_in_stderr"), + "spec", [ - ({"venv_exit": 1}, None), - ({"sync_exit": 2}, "uv sync failed"), + UvFailureSpec(write_kwargs={"venv_exit": 1}, expected_in_stderr=None), + UvFailureSpec( + write_kwargs={"sync_exit": 2}, expected_in_stderr="uv sync failed" + ), ], ids=["uv-venv-fails", "uv-sync-fails"], ) @@ -2583,16 +2594,15 @@ def test_run_python_integration_uv_failure_modes( tmp_path: Path, shell_stubs: StubManager, monkeypatch: pytest.MonkeyPatch, - write_kwargs: dict[str, int], - expected_in_stderr: str | None, + spec: UvFailureSpec, ) -> None: """run_python.py exits non-zero when uv venv or uv sync fails.""" - bin_dir, _log = _write_fake_uv(tmp_path, **write_kwargs) + bin_dir, _log = _write_fake_uv(tmp_path, **spec.write_kwargs) returncode, _stdout, stderr = _run_integration_script( tmp_path, shell_stubs, bin_dir, monkeypatch ) assert returncode != 0 - if expected_in_stderr is not None: - assert expected_in_stderr in stderr + if spec.expected_in_stderr is not None: + assert spec.expected_in_stderr in stderr From 6086cec0dbfc4c6c0809cd4fc64d7ae32bf1bc5c Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 16:48:37 +0200 Subject: [PATCH 43/51] Document coverage venv internals Expand run_python docstrings and integration coverage, and add a developer guide for the coverage venv architecture. --- .../generate-coverage/scripts/run_python.py | 36 +++++++- .../generate-coverage/tests/test_scripts.py | 67 ++++++++++++++- docs/developers-guide.md | 83 +++++++++++++++++++ 3 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 docs/developers-guide.md diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 2c2f7fe8..382947e8 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -31,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 is a process-scoped constant. It is consumed by +# _ensure_coverage_venv() and _coverage_python_cmd(), both of which are +# called from a single-threaded GitHub Actions step. COVERAGE_VENV = Path(".venv-coverage") TOOLING_PACKAGES: tuple[str, ...] = ("slipcover", "pytest", "coverage") PROJECT_SYNC_ARGS: tuple[str, ...] = ("sync", "--inexact", "--python") @@ -94,6 +97,7 @@ def _recreate_coverage_venv() -> Path: else: typer.echo(f"Creating coverage venv at {COVERAGE_VENV}") run_cmd(uv["venv", str(COVERAGE_VENV)]) + typer.echo(f"Coverage venv created at {COVERAGE_VENV}") python = _find_coverage_python() if python is None: msg = f"Coverage venv Python executable not found in {COVERAGE_VENV}" @@ -104,7 +108,25 @@ def _recreate_coverage_venv() -> Path: 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. + Checks whether ``.venv-coverage`` contains a healthy Python executable. + If not, delegates to :func:`_recreate_coverage_venv` to remove any + broken state and create a fresh venv. Then runs ``uv sync`` to install + project dependencies, followed by ``uv pip install`` to add + ``slipcover``, ``pytest``, and ``coverage``. + + Returns + ------- + str + Absolute path to the Python executable inside the coverage venv. + + Raises + ------ + RuntimeError + Propagated from :func:`_recreate_coverage_venv` when the Python + executable cannot be located after venv creation. + plumbum.commands.processes.ProcessExecutionError + Propagated from ``uv sync`` or ``uv pip install`` when either + command exits with a non-zero return code. """ python = _find_coverage_python() if python is None: @@ -134,9 +156,14 @@ def _ensure_coverage_venv() -> str: err=True, ) raise + typer.echo(f"Coverage tooling installed into {COVERAGE_VENV}") return str(python) +# _coverage_python_cmd() is memoised with lru_cache rather than using a +# mutable global. GitHub Actions executes action steps sequentially in a +# single thread, so no synchronisation is required; the cache is safe to +# use without a lock for the lifetime of this process. @lru_cache(maxsize=1) def _coverage_python_cmd() -> BoundCommand: """Return the coverage venv Python command, creating it on first use.""" @@ -263,6 +290,13 @@ def main( baseline_file : Path or None Optional path to a previous coverage baseline file. When present, the previous percentage is echoed to the log. + + Raises + ------ + typer.Exit + With the subprocess return code when the slipcover/coverage command + exits non-zero, or when ``coverage xml`` fails in ``coveragepy`` + format mode. """ out = _resolve_output_path(output_path, lang) out.parent.mkdir(parents=True, exist_ok=True) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 00808736..f2f21264 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -39,7 +39,22 @@ def _exit_code(exc: BaseException) -> int | None: def run_script(script: Path, env: dict[str, str], *args: str) -> RunResult: - """Run ``script`` via ``uv`` with ``env`` and return the run tuple.""" + """Run ``script`` via ``uv run --script`` with ``env`` and return the result. + + Parameters + ---------- + script : Path + Absolute path to the Python script to execute. + env : dict[str, str] + Environment variables to merge on top of the current environment. + *args : str + Additional positional arguments appended to the ``uv run`` command. + + Returns + ------- + RunResult + A three-tuple of ``(return_code, stdout, stderr)``. + """ command = local["uv"]["run", "--script", str(script)] if args: command = command[list(args)] @@ -2548,7 +2563,10 @@ def test_run_python_integration_cobertura_success( shell_stubs: StubManager, monkeypatch: pytest.MonkeyPatch, ) -> None: - """run_python.py creates the venv, syncs deps, and installs tooling.""" + """run_python.py creates the venv, syncs deps, installs tooling. + + The script also writes outputs. + """ bin_dir, log = _write_fake_uv(tmp_path) returncode, _stdout, _stderr = _run_integration_script( @@ -2569,6 +2587,51 @@ def test_run_python_integration_cobertura_success( assert "slipcover" in pip_args assert "pytest" in pip_args assert "coverage" in pip_args + # Verify GITHUB_OUTPUT was written with expected keys + gh = tmp_path / "gh.txt" + assert gh.exists(), "GITHUB_OUTPUT file must be written" + gh_content = gh.read_text(encoding="utf-8") + assert "file=" in gh_content, "GITHUB_OUTPUT must contain file= key" + assert "percent=" in gh_content, "GITHUB_OUTPUT must contain percent= key" + + +@pytest.mark.skipif(sys.platform == "win32", reason="fake uv helper emits POSIX sh") +def test_run_python_integration_mixed_lang_path( + tmp_path: Path, + shell_stubs: StubManager, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_python.py renames the output file for mixed-lang projects. + + The output path receives a .python infix. + """ + bin_dir, _log = _write_fake_uv(tmp_path) + out = tmp_path / "cov.xml" + gh = tmp_path / "gh.txt" + out.write_text("", encoding="utf-8") + env = { + **shell_stubs.env, + "INPUT_OUTPUT_PATH": str(out), + "DETECTED_LANG": "mixed", + "DETECTED_FMT": "cobertura", + "BASELINE_PYTHON_FILE": "", + "GITHUB_OUTPUT": str(gh), + } + env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" + monkeypatch.chdir(tmp_path) + # Provide the expected renamed file so coverage parser can read it + mixed_out = tmp_path / "cov.python.xml" + mixed_out.write_text( + "", encoding="utf-8" + ) + script = Path(__file__).resolve().parents[1] / "scripts" / "run_python.py" + returncode, _stdout, _stderr = run_script(script, env) + + assert returncode == 0 + gh_content = gh.read_text(encoding="utf-8") + assert "cov.python.xml" in gh_content, ( + "mixed-lang output path must include .python infix" + ) @dataclasses.dataclass(frozen=True) diff --git a/docs/developers-guide.md b/docs/developers-guide.md new file mode 100644 index 00000000..6b4738c6 --- /dev/null +++ b/docs/developers-guide.md @@ -0,0 +1,83 @@ +# Developer's Guide + +This document describes the internal architecture of the `generate-coverage` +action, its public API, concurrency model, and Makefile tool-resolution +strategy. + +## Python Coverage Venv Architecture + +### Motivation + +Earlier revisions of `run_python.py` invoked slipcover and coverage.py via +`uv run --with slipcover --with pytest --with coverage python ...`, which +re-resolved and reinstalled tooling on every invocation and could not cache the +interpreter reference within the same process. + +### Lifecycle + +`run_python.py` manages a dedicated throwaway virtual environment at +`.venv-coverage` in the working directory. + +| Step | Function | Description | +|---|---|---| +| 1 | `_find_coverage_python()` | Locate the Python executable within `.venv-coverage`; returns `None` if absent or if the venv directory is a symlink or non-directory. | +| 2 | `_remove_coverage_venv()` | Remove the venv directory (via `shutil.rmtree`) or any non-directory placeholder (via `Path.unlink`). | +| 3 | `_recreate_coverage_venv()` | Remove any broken venv state, create a fresh venv via `uv venv`, and return the new Python path. Raises `RuntimeError` if the executable is still absent after creation. | +| 4 | `_ensure_coverage_venv()` | Orchestrates steps 1-3, then runs `uv sync --inexact --python` and `uv pip install --python slipcover pytest coverage`. Returns the Python path as a string. | +| 5 | `_coverage_python_cmd()` | Calls `_ensure_coverage_venv()` on first use via `@lru_cache(maxsize=1)`; returns a cached `plumbum.BoundCommand` thereafter. | + +### Public API + +| Symbol | Signature | Role | +|---|---|---| +| `coverage_cmd_for_fmt` | `(fmt: str, out: Path) -> BoundCommand` | Build the slipcover command for a given format. | +| `tmp_coveragepy_xml` | `(out: Path) -> Generator[Path]` | Context manager: generate Cobertura XML via coverage.py, yield the path, clean up on exit. | +| `main` | `(output_path, lang, fmt, github_output, baseline_file)` | Entry point: run slipcover, parse coverage, write `GITHUB_OUTPUT`. | + +### Concurrency Model + +`run_python.py` runs as a single-threaded GitHub Actions step. The +`@lru_cache(maxsize=1)` on `_coverage_python_cmd()` therefore requires no +explicit synchronisation; the cache is safe for the lifetime of the process. + +### Broken-Venv Recovery + +If `.venv-coverage` is present but its Python executable is absent (or a +non-directory placeholder occupies its path), `_recreate_coverage_venv()` +removes the directory and recreates it from scratch. The case is detected by +`_find_coverage_python()` returning `None` when the directory already exists. + +### POSIX and Windows Layouts + +`_find_coverage_python()` checks three candidate paths in order: + +- `COVERAGE_VENV/bin/python` (POSIX) +- `COVERAGE_VENV/Scripts/python.exe` (Windows) +- `COVERAGE_VENV/Scripts/python` (Windows without extension) + +## Makefile Tool Resolution + +The `Makefile` resolves optional local tool installations before falling back +to bare names on `PATH`. + +| Variable | Default resolution order | +|---|---| +| `UV` | `~/.local/bin/uv` if present, otherwise `uv` | +| `ACTION_VALIDATOR` | `~/.bun/bin/action-validator`, then `~/.cargo/bin/action-validator`, then `action-validator` | +| `MDLINT` | `~/.bun/bin/markdownlint` if present, otherwise `markdownlint` | +| `MARKDOWNLINT_BASE` | `origin/main` (base ref for `git diff` in the `markdownlint` target) | + +Override example: + +```bash +make lint UV=uv MARKDOWNLINT_BASE=origin/develop +``` + +## Running the Test Suite + +```bash +make test # full suite +make check-fmt # Ruff formatting check +make typecheck # mypy +make lint # Ruff lint + action-validator + markdownlint +``` From dad0cf6f3aa23adab4e99eb32e4ab64e3f0a9dbe Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 16:50:18 +0200 Subject: [PATCH 44/51] Fix developer guide markdownlint Wrap developer guide tables and prose to satisfy markdownlint style checks. --- docs/developers-guide.md | 43 ++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 6b4738c6..8549a499 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -19,20 +19,33 @@ interpreter reference within the same process. `.venv-coverage` in the working directory. | Step | Function | Description | -|---|---|---| -| 1 | `_find_coverage_python()` | Locate the Python executable within `.venv-coverage`; returns `None` if absent or if the venv directory is a symlink or non-directory. | -| 2 | `_remove_coverage_venv()` | Remove the venv directory (via `shutil.rmtree`) or any non-directory placeholder (via `Path.unlink`). | -| 3 | `_recreate_coverage_venv()` | Remove any broken venv state, create a fresh venv via `uv venv`, and return the new Python path. Raises `RuntimeError` if the executable is still absent after creation. | -| 4 | `_ensure_coverage_venv()` | Orchestrates steps 1-3, then runs `uv sync --inexact --python` and `uv pip install --python slipcover pytest coverage`. Returns the Python path as a string. | -| 5 | `_coverage_python_cmd()` | Calls `_ensure_coverage_venv()` on first use via `@lru_cache(maxsize=1)`; returns a cached `plumbum.BoundCommand` thereafter. | +| --- | --- | --- | +| 1 | `_find_coverage_python()` | Locate the Python executable. | +| 2 | `_remove_coverage_venv()` | Remove the venv or placeholder path. | +| 3 | `_recreate_coverage_venv()` | Recreate the venv. | +| 4 | `_ensure_coverage_venv()` | Sync project deps and install tooling. | +| 5 | `_coverage_python_cmd()` | Return the cached `plumbum.BoundCommand`. | + +`_find_coverage_python()` returns `None` when `.venv-coverage` is absent, is a +symlink, is a non-directory, or lacks a Python executable. `_remove_coverage_venv()` +uses `shutil.rmtree` for directories and `Path.unlink` for files or symlinks. +`_recreate_coverage_venv()` raises `RuntimeError` if the executable is still +absent after creation. `_ensure_coverage_venv()` runs +`uv sync --inexact --python` and +`uv pip install --python slipcover pytest coverage`. `_coverage_python_cmd()` +uses `@lru_cache(maxsize=1)` and returns the cached command thereafter. ### Public API | Symbol | Signature | Role | -|---|---|---| -| `coverage_cmd_for_fmt` | `(fmt: str, out: Path) -> BoundCommand` | Build the slipcover command for a given format. | -| `tmp_coveragepy_xml` | `(out: Path) -> Generator[Path]` | Context manager: generate Cobertura XML via coverage.py, yield the path, clean up on exit. | -| `main` | `(output_path, lang, fmt, github_output, baseline_file)` | Entry point: run slipcover, parse coverage, write `GITHUB_OUTPUT`. | +| --- | --- | --- | +| `coverage_cmd_for_fmt` | `(fmt, out)` | Build a slipcover command. | +| `tmp_coveragepy_xml` | `(out)` | Generate temporary Cobertura XML. | +| `main` | `(output_path, lang, fmt, github_output, baseline)` | Entry point. | + +`coverage_cmd_for_fmt` returns a `BoundCommand` for the requested format. +`tmp_coveragepy_xml` yields a temporary XML path and removes it on exit. +`main` runs slipcover, parses coverage, and writes `GITHUB_OUTPUT`. ### Concurrency Model @@ -61,11 +74,15 @@ The `Makefile` resolves optional local tool installations before falling back to bare names on `PATH`. | Variable | Default resolution order | -|---|---| +| --- | --- | | `UV` | `~/.local/bin/uv` if present, otherwise `uv` | -| `ACTION_VALIDATOR` | `~/.bun/bin/action-validator`, then `~/.cargo/bin/action-validator`, then `action-validator` | +| `ACTION_VALIDATOR` | Bun install, then Cargo install, then `PATH` | | `MDLINT` | `~/.bun/bin/markdownlint` if present, otherwise `markdownlint` | -| `MARKDOWNLINT_BASE` | `origin/main` (base ref for `git diff` in the `markdownlint` target) | +| `MARKDOWNLINT_BASE` | `origin/main` | + +`ACTION_VALIDATOR` resolves to `~/.bun/bin/action-validator`, then +`~/.cargo/bin/action-validator`, then `action-validator`. `MARKDOWNLINT_BASE` +is the base ref for `git diff` in the `markdownlint` target. Override example: From 6865952ac5a8fbec4cd4c5111c0cdecebf585b13 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 17:29:40 +0200 Subject: [PATCH 45/51] Handle coverage venv bootstrap failures Move coverage command construction under main error handling and refresh developer guide details for uv interpreter arguments. --- .../actions/generate-coverage/scripts/run_python.py | 5 ++++- docs/developers-guide.md | 11 ++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 382947e8..f6eaa150 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -301,11 +301,14 @@ def main( out = _resolve_output_path(output_path, lang) out.parent.mkdir(parents=True, exist_ok=True) - cmd = coverage_cmd_for_fmt(fmt, out) try: + cmd = coverage_cmd_for_fmt(fmt, out) run_cmd(cmd, method="run_fg") except ProcessExecutionError as exc: raise typer.Exit(code=exc.retcode or 1) from exc + except RuntimeError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc if fmt == "coveragepy": with tmp_coveragepy_xml(out) as xml_tmp: diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8549a499..2c1ed3e9 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -31,9 +31,10 @@ symlink, is a non-directory, or lacks a Python executable. `_remove_coverage_ven uses `shutil.rmtree` for directories and `Path.unlink` for files or symlinks. `_recreate_coverage_venv()` raises `RuntimeError` if the executable is still absent after creation. `_ensure_coverage_venv()` runs -`uv sync --inexact --python` and -`uv pip install --python slipcover pytest coverage`. `_coverage_python_cmd()` -uses `@lru_cache(maxsize=1)` and returns the cached command thereafter. +`uv sync --inexact --python ` and +`uv pip install --python slipcover pytest coverage`. +`_coverage_python_cmd()` uses `@lru_cache(maxsize=1)` and returns the cached +command for `` thereafter. ### Public API @@ -41,7 +42,7 @@ uses `@lru_cache(maxsize=1)` and returns the cached command thereafter. | --- | --- | --- | | `coverage_cmd_for_fmt` | `(fmt, out)` | Build a slipcover command. | | `tmp_coveragepy_xml` | `(out)` | Generate temporary Cobertura XML. | -| `main` | `(output_path, lang, fmt, github_output, baseline)` | Entry point. | +| `main` | `(output_path, lang, fmt, github_output, baseline_file)` | Run. | `coverage_cmd_for_fmt` returns a `BoundCommand` for the requested format. `tmp_coveragepy_xml` yields a temporary XML path and removes it on exit. @@ -51,7 +52,7 @@ uses `@lru_cache(maxsize=1)` and returns the cached command thereafter. `run_python.py` runs as a single-threaded GitHub Actions step. The `@lru_cache(maxsize=1)` on `_coverage_python_cmd()` therefore requires no -explicit synchronisation; the cache is safe for the lifetime of the process. +explicit synchronization; the cache is safe for the lifetime of the process. ### Broken-Venv Recovery From 3db43ba508592063ac335ccf8e010559ae14fb2b Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 17:44:05 +0200 Subject: [PATCH 46/51] Extract run_python main helpers Split slipcover execution and coverage percent parsing out of main to lower cyclomatic complexity. --- .../generate-coverage/scripts/run_python.py | 78 +++++++++++++++---- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index f6eaa150..bec3bb43 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -265,6 +265,67 @@ def _resolve_output_path(output_path: Path, lang: str) -> Path: return output_path +def _run_slipcover(fmt: str, out: Path) -> None: + """Build and execute the slipcover command, converting errors to typer.Exit. + + Parameters + ---------- + fmt : str + Coverage format identifier; forwarded to + :func:`coverage_cmd_for_fmt`. + out : Path + Destination path for the coverage output file. + + Raises + ------ + typer.Exit + When the slipcover/pytest command exits non-zero (preserving the + original return code) or when venv setup raises ``RuntimeError``. + """ + try: + cmd = coverage_cmd_for_fmt(fmt, out) + run_cmd(cmd, method="run_fg") + except ProcessExecutionError as exc: + raise typer.Exit(code=exc.retcode or 1) from exc + except RuntimeError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc + + +def _parse_coverage_percent(fmt: str, out: Path) -> str: + """Run coverage-format post-processing and return the line-coverage percentage. + + For ``"coveragepy"`` format, invokes ``coverage xml`` via + :func:`tmp_coveragepy_xml` to produce a Cobertura XML, parses it, + then moves the ``.coverage`` data file into ``out``. For all other + formats, parses ``out`` directly as a Cobertura XML. + + Parameters + ---------- + fmt : str + Coverage format identifier. + out : Path + Path to the coverage output file produced by slipcover. + + Returns + ------- + str + Line coverage percentage. + + Raises + ------ + typer.Exit + Propagated from :func:`tmp_coveragepy_xml` when ``coverage xml`` + exits non-zero. + """ + if fmt == "coveragepy": + with tmp_coveragepy_xml(out) as xml_tmp: + percent = get_line_coverage_percent_from_cobertura(xml_tmp) + Path(".coverage").replace(out) + return percent + return get_line_coverage_percent_from_cobertura(out) + + def main( output_path: Path = OUTPUT_PATH_OPT, lang: str = LANG_OPT, @@ -301,21 +362,8 @@ def main( out = _resolve_output_path(output_path, lang) out.parent.mkdir(parents=True, exist_ok=True) - try: - cmd = coverage_cmd_for_fmt(fmt, out) - run_cmd(cmd, method="run_fg") - except ProcessExecutionError as exc: - raise typer.Exit(code=exc.retcode or 1) from exc - except RuntimeError as exc: - typer.echo(str(exc), err=True) - raise typer.Exit(code=1) from exc - - if fmt == "coveragepy": - with tmp_coveragepy_xml(out) as xml_tmp: - percent = get_line_coverage_percent_from_cobertura(xml_tmp) - Path(".coverage").replace(out) - else: - percent = get_line_coverage_percent_from_cobertura(out) + _run_slipcover(fmt, out) + percent = _parse_coverage_percent(fmt, out) typer.echo(f"Current coverage: {percent}%") previous = read_previous_coverage(baseline_file) From 77ca00c29cf652b17ab8e6fc1bc1c1919733266b Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 17:47:45 +0200 Subject: [PATCH 47/51] Combine run_python coverage execution helper Replace separate slipcover and percent parsing helpers with a single _run_coverage helper to reduce complexity metrics. --- .../generate-coverage/scripts/run_python.py | 50 +++++-------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index bec3bb43..5df24cd2 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -265,22 +265,27 @@ def _resolve_output_path(output_path: Path, lang: str) -> Path: return output_path -def _run_slipcover(fmt: str, out: Path) -> None: - """Build and execute the slipcover command, converting errors to typer.Exit. +def _run_coverage(fmt: str, out: Path) -> str: + """Run slipcover and return the line coverage percentage. Parameters ---------- fmt : str - Coverage format identifier; forwarded to - :func:`coverage_cmd_for_fmt`. + Coverage format identifier passed to :func:`coverage_cmd_for_fmt`. out : Path Destination path for the coverage output file. + Returns + ------- + str + Line coverage percentage parsed from the generated report. + Raises ------ typer.Exit - When the slipcover/pytest command exits non-zero (preserving the - original return code) or when venv setup raises ``RuntimeError``. + With the subprocess return code when the slipcover/coverage + command exits non-zero, or when ``coverage xml`` fails in + ``coveragepy`` format mode. """ try: cmd = coverage_cmd_for_fmt(fmt, out) @@ -291,33 +296,6 @@ def _run_slipcover(fmt: str, out: Path) -> None: typer.echo(str(exc), err=True) raise typer.Exit(code=1) from exc - -def _parse_coverage_percent(fmt: str, out: Path) -> str: - """Run coverage-format post-processing and return the line-coverage percentage. - - For ``"coveragepy"`` format, invokes ``coverage xml`` via - :func:`tmp_coveragepy_xml` to produce a Cobertura XML, parses it, - then moves the ``.coverage`` data file into ``out``. For all other - formats, parses ``out`` directly as a Cobertura XML. - - Parameters - ---------- - fmt : str - Coverage format identifier. - out : Path - Path to the coverage output file produced by slipcover. - - Returns - ------- - str - Line coverage percentage. - - Raises - ------ - typer.Exit - Propagated from :func:`tmp_coveragepy_xml` when ``coverage xml`` - exits non-zero. - """ if fmt == "coveragepy": with tmp_coveragepy_xml(out) as xml_tmp: percent = get_line_coverage_percent_from_cobertura(xml_tmp) @@ -361,15 +339,11 @@ def main( """ out = _resolve_output_path(output_path, lang) out.parent.mkdir(parents=True, exist_ok=True) - - _run_slipcover(fmt, out) - percent = _parse_coverage_percent(fmt, out) - + percent = _run_coverage(fmt, out) typer.echo(f"Current coverage: {percent}%") previous = read_previous_coverage(baseline_file) if previous is not None: typer.echo(f"Previous coverage: {previous}%") - with github_output.open("a") as fh: fh.write(f"file={out}\n") fh.write(f"percent={percent}\n") From c811865ef4eedb1f2ea3bbff728f7258f757101c Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Apr 2026 17:50:57 +0200 Subject: [PATCH 48/51] Improve run_python observability and baseline test Log coverage venv reuse and uv sync success, document run_script non-raising behavior, and cover previous baseline reporting. --- .../generate-coverage/scripts/run_python.py | 3 + .../generate-coverage/tests/test_scripts.py | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 5df24cd2..891d9628 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -131,11 +131,14 @@ def _ensure_coverage_venv() -> str: python = _find_coverage_python() if python is None: python = _recreate_coverage_venv() + else: + typer.echo(f"Reusing existing coverage venv at {COVERAGE_VENV}") typer.echo(f"Installing project dependencies into {COVERAGE_VENV}") previous_project_environment = os.environ.get("UV_PROJECT_ENVIRONMENT") os.environ["UV_PROJECT_ENVIRONMENT"] = str(COVERAGE_VENV.resolve()) try: run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) + typer.echo(f"Project dependencies installed into {COVERAGE_VENV}") except ProcessExecutionError as exc: typer.echo( f"uv sync failed with code {exc.retcode}: {exc.stderr}", diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index f2f21264..a002dace 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -54,6 +54,12 @@ def run_script(script: Path, env: dict[str, str], *args: str) -> RunResult: ------- RunResult A three-tuple of ``(return_code, stdout, stderr)``. + + Raises + ------ + None + This helper does not raise for non-zero exits; failures are conveyed + via the returned exit code and stderr captured from the child process. """ command = local["uv"]["run", "--script", str(script)] if args: @@ -2368,6 +2374,57 @@ def fake_tmp_coveragepy_xml(_out: Path) -> typ.Iterator[Path]: assert "percent=0.00" in data +def test_main_echoes_previous_coverage_when_baseline_present( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """main() logs 'Previous coverage: ...%' when a baseline is provided.""" + monkeypatch.chdir(tmp_path) + coverage_file = tmp_path / ".coverage" + coverage_file.write_text("payload", encoding="utf-8") + + def fake_run_cmd(_cmd: object, **_kw: object) -> None: + pass + + @contextlib.contextmanager + def fake_tmp_coveragepy_xml(out: Path) -> typ.Iterator[Path]: + xml = out.with_suffix(".xml") + xml.write_text( + "", encoding="utf-8" + ) + try: + yield xml + finally: + xml.unlink(missing_ok=True) + + def fake_read_previous_coverage(_baseline: Path | None) -> float | None: + return 42.0 + + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + monkeypatch.setattr( + run_python_module, "tmp_coveragepy_xml", fake_tmp_coveragepy_xml + ) + monkeypatch.setattr( + run_python_module, "read_previous_coverage", fake_read_previous_coverage + ) + _set_fake_coverage_python_cmd(monkeypatch, run_python_module) + + out = tmp_path / "cov.dat" + gh = tmp_path / "gh.txt" + run_python_module.main( + output_path=out, + lang="python", + fmt="coveragepy", + github_output=gh, + baseline_file=tmp_path / "baseline.txt", + ) + + captured = capsys.readouterr() + assert "Previous coverage: 42.0%" in captured.out + + def test_run_python_coveragepy_malformed_xml_exits( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 0fa150f92d4411a5f80abe378c204063625a7a64 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 30 Apr 2026 12:21:58 +0200 Subject: [PATCH 49/51] Return absolute coverage venv python path Resolve discovered coverage venv Python interpreters before returning them and update tests for the absolute-path contract. --- .../generate-coverage/scripts/run_python.py | 2 +- .../generate-coverage/tests/test_scripts.py | 70 ++++++++++++++----- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 891d9628..b004cb9d 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -59,7 +59,7 @@ def _find_coverage_python() -> Path | None: COVERAGE_VENV / "Scripts" / "python.exe", COVERAGE_VENV / "Scripts" / "python", ) - return next((c for c in candidates if c.is_file()), None) + return next((c.resolve() for c in candidates if c.is_file()), None) def _remove_coverage_venv() -> None: diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index a002dace..552fc649 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1873,8 +1873,9 @@ def test_ensure_coverage_venv_returns_coverage_python( setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) python = run_python_module._ensure_coverage_venv() + expected_python = (setup.coverage_venv / "bin" / "python").resolve() - assert python == str(setup.coverage_venv / "bin" / "python") + assert python == str(expected_python) assert len(setup.recorded) == 3 venv_parts = setup.recorded[0] assert Path(venv_parts[0]).stem == "uv" @@ -1886,7 +1887,7 @@ def test_ensure_coverage_venv_returns_coverage_python( "pip", "install", "--python", - str(setup.coverage_venv / "bin" / "python"), + str(expected_python), ] @@ -1903,14 +1904,19 @@ def test_ensure_coverage_venv_reuses_existing_coverage_venv( python_path.parent.mkdir(parents=True) python_path.touch() - assert run_python_module._ensure_coverage_venv() == str(python_path) + assert run_python_module._ensure_coverage_venv() == str(python_path.resolve()) assert len(setup.recorded) == 2 - assert setup.recorded[0][1:] == ["sync", "--inexact", "--python", str(python_path)] + assert setup.recorded[0][1:] == [ + "sync", + "--inexact", + "--python", + str(python_path.resolve()), + ] assert setup.recorded[1][1:5] == [ "pip", "install", "--python", - str(python_path), + str(python_path.resolve()), ] @@ -1927,7 +1933,7 @@ def test_ensure_coverage_venv_recovers_from_broken_cache( venv_calls = [r for r in setup.recorded if len(r) > 1 and r[1] == "venv"] assert len(venv_calls) == 1 - assert python == str(setup.coverage_venv / "bin" / "python") + assert python == str((setup.coverage_venv / "bin" / "python").resolve()) assert setup.recorded[-2][1:] == ["sync", "--inexact", "--python", python] assert setup.recorded[-1][1:5] == [ "pip", @@ -1950,6 +1956,26 @@ def test_find_coverage_python_returns_none_when_no_executable( assert run_python_module._find_coverage_python() is None +def test_find_coverage_python_returns_absolute_path( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_find_coverage_python() resolves relative venv paths before returning.""" + monkeypatch.chdir(tmp_path) + coverage_venv = Path(".venv-coverage") + python = coverage_venv / "bin" / "python" + python.parent.mkdir(parents=True) + python.touch() + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) + + found = run_python_module._find_coverage_python() + + assert found is not None + assert found == python.resolve() + assert found.is_absolute() + + def test_ensure_coverage_venv_raises_when_created_venv_has_no_python( tmp_path: Path, run_python_module: ModuleType, @@ -1985,13 +2011,13 @@ def _assert_venv_rebuild_commands( "sync", "--inexact", "--python", - str(python_path), + str(python_path.resolve()), ] assert recorded[2][1:5] == [ "pip", "install", "--python", - str(python_path), + str(python_path.resolve()), ] @@ -2001,7 +2027,7 @@ def _assert_venv_default_python_rebuild( ) -> None: """Assert venv was rebuilt and Python resolved to the default POSIX path.""" expected = setup.coverage_venv / "bin" / "python" - assert python == str(expected) + assert python == str(expected.resolve()) _assert_venv_rebuild_commands(setup.recorded, setup.coverage_venv, expected) @@ -2045,7 +2071,7 @@ def test_ensure_coverage_venv_recreates_invalid_python_candidate( (setup.coverage_venv / "bin" / "python").mkdir(parents=True) # dir, not file assert run_python_module._ensure_coverage_venv() == str( - setup.coverage_venv / "Scripts" / "python.exe" + (setup.coverage_venv / "Scripts" / "python.exe").resolve() ) _assert_venv_rebuild_commands( setup.recorded, @@ -2067,13 +2093,18 @@ def test_ensure_coverage_venv_targets_venv_python_for_tooling( python.parent.mkdir(parents=True) python.touch() - assert run_python_module._ensure_coverage_venv() == str(python) + assert run_python_module._ensure_coverage_venv() == str(python.resolve()) assert len(setup.recorded) == 2 - assert setup.recorded[0][1:] == ["sync", "--inexact", "--python", str(python)] + assert setup.recorded[0][1:] == [ + "sync", + "--inexact", + "--python", + str(python.resolve()), + ] parts = setup.recorded[1] assert Path(parts[0]).stem == "uv" - assert parts[1:5] == ["pip", "install", "--python", str(python)] + assert parts[1:5] == ["pip", "install", "--python", str(python.resolve())] assert "--system" not in parts assert set(run_python_module.TOOLING_PACKAGES).issubset(parts) @@ -2101,7 +2132,7 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False) - assert run_python_module._ensure_coverage_venv() == str(python) + assert run_python_module._ensure_coverage_venv() == str(python.resolve()) assert sync_environment == [str(setup.coverage_venv.resolve())] assert "UV_PROJECT_ENVIRONMENT" not in os.environ @@ -2132,15 +2163,20 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: assert first is second parts = list(first.formulate()) - _assert_coverage_python_path(parts[0], str(python_path)) + _assert_coverage_python_path(parts[0], str(python_path.resolve())) assert len(recorded) == 3 assert recorded[0][1:] == ["venv", str(coverage_venv)] - assert recorded[1][1:] == ["sync", "--inexact", "--python", str(python_path)] + assert recorded[1][1:] == [ + "sync", + "--inexact", + "--python", + str(python_path.resolve()), + ] assert recorded[2][1:5] == [ "pip", "install", "--python", - str(python_path), + str(python_path.resolve()), ] From fcb0b4f4855ce1af5ebe8209d1b80eaed7f461f8 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 30 Apr 2026 12:25:18 +0200 Subject: [PATCH 50/51] Align test coverage python helper with platform layout Return the Windows Scripts/python.exe venv path from the test helper on Windows while preserving POSIX bin/python elsewhere. --- .github/actions/generate-coverage/tests/test_scripts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 552fc649..2d8a1a54 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1767,6 +1767,8 @@ def run_python_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType: def _coverage_python(run_python_module: ModuleType) -> str: """Return the expected throwaway venv Python path.""" + if sys.platform == "win32": + return str(run_python_module.COVERAGE_VENV / "Scripts" / "python.exe") return str(run_python_module.COVERAGE_VENV / "bin" / "python") From 33951ae03d53109226dc9eda10125c791744ac7e Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 30 Apr 2026 12:28:47 +0200 Subject: [PATCH 51/51] Extract coverage venv setup helpers Move project sync environment handling and coverage tooling installation out of `_ensure_coverage_venv` so the function remains a small orchestrator while preserving existing log messages and error propagation. --- .../generate-coverage/scripts/run_python.py | 72 ++++++++++++------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index b004cb9d..21ab3836 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -105,6 +105,49 @@ def _recreate_coverage_venv() -> Path: return python +@contextlib.contextmanager +def _project_env(venv: Path) -> cabc.Iterator[None]: + """Temporarily set UV_PROJECT_ENVIRONMENT to the given venv path.""" + previous = os.environ.get("UV_PROJECT_ENVIRONMENT") + os.environ["UV_PROJECT_ENVIRONMENT"] = str(venv.resolve()) + try: + yield + finally: + if previous is None: + os.environ.pop("UV_PROJECT_ENVIRONMENT", None) + else: + os.environ["UV_PROJECT_ENVIRONMENT"] = previous + + +def _sync_project_deps(python: Path) -> None: + """Run `uv sync` for the project into the coverage venv, with logs.""" + typer.echo(f"Installing project dependencies into {COVERAGE_VENV}") + try: + with _project_env(COVERAGE_VENV): + run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) + typer.echo(f"Project dependencies installed into {COVERAGE_VENV}") + except ProcessExecutionError as exc: + typer.echo( + f"uv sync failed with code {exc.retcode}: {exc.stderr}", + err=True, + ) + raise + + +def _install_coverage_tooling(python: Path) -> None: + """Install slipcover/pytest/coverage into the venv, with logs.""" + 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 + typer.echo(f"Coverage tooling installed into {COVERAGE_VENV}") + + def _ensure_coverage_venv() -> str: """Create or repair the coverage venv and install project/test tooling. @@ -133,33 +176,8 @@ def _ensure_coverage_venv() -> str: python = _recreate_coverage_venv() else: typer.echo(f"Reusing existing coverage venv at {COVERAGE_VENV}") - typer.echo(f"Installing project dependencies into {COVERAGE_VENV}") - previous_project_environment = os.environ.get("UV_PROJECT_ENVIRONMENT") - os.environ["UV_PROJECT_ENVIRONMENT"] = str(COVERAGE_VENV.resolve()) - try: - run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) - typer.echo(f"Project dependencies installed into {COVERAGE_VENV}") - except ProcessExecutionError as exc: - typer.echo( - f"uv sync failed with code {exc.retcode}: {exc.stderr}", - err=True, - ) - raise - finally: - if previous_project_environment is None: - os.environ.pop("UV_PROJECT_ENVIRONMENT", None) - else: - os.environ["UV_PROJECT_ENVIRONMENT"] = previous_project_environment - 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 - typer.echo(f"Coverage tooling installed into {COVERAGE_VENV}") + _sync_project_deps(python) + _install_coverage_tooling(python) return str(python)