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/README.md b/.github/actions/generate-coverage/README.md index 20c77ca8..f78c5d06 100644 --- a/.github/actions/generate-coverage/README.md +++ b/.github/actions/generate-coverage/README.md @@ -7,8 +7,10 @@ projects. The action uses `cargo llvm-cov` (with `cargo nextest` by default) when a `Cargo.toml` is present and `slipcover` with `pytest` when a `pyproject.toml` is present. If the repository root does not contain a Cargo manifest, set `cargo-manifest` to point to a nested `Cargo.toml`. It installs -`slipcover` and `pytest` automatically via `uv` before running the tests, -leveraging ``uv run --with`` so no system-level Python installs are required. +the project dependencies plus `slipcover`, `pytest`, and `coverage` +automatically via `uv` into an isolated throwaway virtual environment +(`.venv-coverage`) before running the tests, so no system-level Python installs +are required. When Rust coverage is required, `cargo-llvm-cov` and `cargo-nextest` are installed automatically. If both configuration files are present, coverage is run for each language and the Cobertura reports are merged using @@ -45,6 +47,7 @@ codegen-backend-related variables first, and then merges `get_cargo_coverage_env(manifest_path)` on top so workflow-level Cranelift exports are not treated as the default coverage behaviour. + ```mermaid sequenceDiagram actor GitHubActions @@ -78,6 +81,7 @@ sequenceDiagram _run_cargo-->>run_rust_py: stdout end ``` + ## Cranelift codegen backend support @@ -123,6 +127,7 @@ Known limitations: ## Inputs + | Name | Description | Required | Default | | --- | --- | --- | --- | | features | Enable Cargo (Rust) features; space- or comma-separated. | no | | @@ -134,11 +139,11 @@ Known limitations: | with-ratchet | Fail if coverage drops below baseline | no | `false` | | artefact-name-suffix | Additional suffix appended to the uploaded coverage artefact | no | | | baseline-rust-file | Rust baseline path | no | `.coverage-baseline.rust` | - | 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`. diff --git a/.github/actions/generate-coverage/scripts/run_python.py b/.github/actions/generate-coverage/scripts/run_python.py index 6e976a54..21ab3836 100644 --- a/.github/actions/generate-coverage/scripts/run_python.py +++ b/.github/actions/generate-coverage/scripts/run_python.py @@ -9,12 +9,16 @@ import collections.abc as cabc # noqa: TC003 - used at runtime import contextlib +import os +import shutil import typing as typ +from functools import lru_cache from pathlib import Path import typer from cmd_utils_loader import run_cmd from coverage_parsers import get_line_coverage_percent_from_cobertura +from plumbum import local from plumbum.cmd import uv from plumbum.commands.processes import ProcessExecutionError from shared_utils import read_previous_coverage @@ -27,6 +31,12 @@ 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") SLIPCOVER_ARGS: tuple[str, ...] = ( "-m", @@ -40,18 +50,146 @@ ) -def _uv_python_cmd() -> BoundCommand: - """Return ``uv run`` configured with the required coverage tools.""" - return uv[ - "run", - "--with", - "slipcover", - "--with", - "pytest", - "--with", - "coverage", - "python", - ] +def _find_coverage_python() -> Path | None: + """Return the coverage venv Python executable path when it exists.""" + if COVERAGE_VENV.is_symlink() or not COVERAGE_VENV.is_dir(): + return None + candidates = ( + COVERAGE_VENV / "bin" / "python", + COVERAGE_VENV / "Scripts" / "python.exe", + COVERAGE_VENV / "Scripts" / "python", + ) + return next((c.resolve() for c in candidates if c.is_file()), None) + + +def _remove_coverage_venv() -> None: + """Remove the coverage venv directory or non-directory placeholder. + + Uses ``shutil.rmtree`` for directories and ``Path.unlink`` for any + other filesystem object (e.g. a symlink or a stale file). + """ + if COVERAGE_VENV.is_dir() and not COVERAGE_VENV.is_symlink(): + shutil.rmtree(COVERAGE_VENV) + else: + COVERAGE_VENV.unlink(missing_ok=True) + + +def _recreate_coverage_venv() -> Path: + """Remove any existing broken venv, create a fresh one, and return its Python. + + Returns + ------- + Path + Absolute path to the Python executable inside the newly created venv. + + Raises + ------ + RuntimeError + If the Python executable cannot be located after creation. + """ + if COVERAGE_VENV.exists() or COVERAGE_VENV.is_symlink(): + typer.echo( + f"Coverage venv at {COVERAGE_VENV} is missing its Python " + "executable; recreating.", + err=True, + ) + _remove_coverage_venv() + else: + typer.echo(f"Creating coverage venv at {COVERAGE_VENV}") + run_cmd(uv["venv", str(COVERAGE_VENV)]) + 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}" + raise RuntimeError(msg) + 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. + + 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: + python = _recreate_coverage_venv() + else: + typer.echo(f"Reusing existing coverage venv at {COVERAGE_VENV}") + _sync_project_deps(python) + _install_coverage_tooling(python) + 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.""" + python = _ensure_coverage_venv() + return local[python] def _coverage_args(fmt: str, out: Path) -> list[str]: @@ -65,17 +203,55 @@ def _coverage_args(fmt: str, out: Path) -> list[str]: def coverage_cmd_for_fmt(fmt: str, out: Path) -> BoundCommand: - """Return the slipcover command for the requested format.""" - python_cmd = _uv_python_cmd() + """Return the slipcover command for the requested coverage format. + + Parameters + ---------- + fmt : str + Coverage format identifier. ``"cobertura"`` adds slipcover's + ``--xml`` and ``--out`` flags; all other values produce a bare + slipcover/pytest invocation. + out : Path + Destination path for the coverage output file; passed to slipcover's + ``--out`` argument when ``fmt == "cobertura"``. + + Returns + ------- + plumbum.commands.base.BoundCommand + A plumbum command that runs slipcover via the coverage venv Python. + """ + python_cmd = _coverage_python_cmd() return python_cmd[_coverage_args(fmt, out)] @contextlib.contextmanager def tmp_coveragepy_xml(out: Path) -> cabc.Generator[Path]: - """Generate a cobertura XML from coverage.py and clean up afterwards.""" + """Generate a Cobertura XML from coverage.py and clean it up afterwards. + + Invokes ``python -m coverage xml -o `` using the coverage venv + Python, yields the temporary XML path for the caller to consume, and + removes the file on exit - whether the body raised or returned normally. + + Parameters + ---------- + out : Path + Path to the ``.dat`` (coverage.py data) file. The temporary XML is + written to ``out.with_suffix(".xml")``. + + Yields + ------ + Path + Absolute path to the freshly generated temporary Cobertura XML file. + + Raises + ------ + typer.Exit + If ``coverage xml`` exits with a non-zero return code. + """ xml_tmp = out.with_suffix(".xml") + python_cmd = _coverage_python_cmd() try: - cmd = _uv_python_cmd()["-m", "coverage", "xml", "-o", str(xml_tmp)] + cmd = python_cmd["-m", "coverage", "xml", "-o", str(xml_tmp)] run_cmd(cmd) except ProcessExecutionError as exc: typer.echo( @@ -89,37 +265,106 @@ def tmp_coveragepy_xml(out: Path) -> cabc.Generator[Path]: xml_tmp.unlink(missing_ok=True) -def main( - output_path: Path = OUTPUT_PATH_OPT, - lang: str = LANG_OPT, - fmt: str = FMT_OPT, - github_output: Path = GITHUB_OUTPUT_OPT, - baseline_file: Path | None = BASELINE_OPT, -) -> None: - """Run slipcover coverage and write the output path to ``GITHUB_OUTPUT``.""" - out = output_path +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": - out = output_path.with_name(f"{output_path.stem}.python{output_path.suffix}") - out.parent.mkdir(parents=True, exist_ok=True) + return output_path.with_name(f"{output_path.stem}.python{output_path.suffix}") + return output_path + + +def _run_coverage(fmt: str, out: Path) -> str: + """Run slipcover and return the line coverage percentage. + + Parameters + ---------- + fmt : str + 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. - cmd = coverage_cmd_for_fmt(fmt, out) + 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. + """ 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) + return percent + return get_line_coverage_percent_from_cobertura(out) + + +def main( + output_path: Path = OUTPUT_PATH_OPT, + lang: str = LANG_OPT, + fmt: str = FMT_OPT, + github_output: Path = GITHUB_OUTPUT_OPT, + baseline_file: Path | None = BASELINE_OPT, +) -> None: + """Run slipcover coverage and write the 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. + 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) + 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") diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index e9ab890b..2d8a1a54 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 @@ -38,7 +39,28 @@ 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)``. + + 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: command = command[list(args)] @@ -1743,21 +1765,43 @@ 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.""" + if sys.platform == "win32": + return str(run_python_module.COVERAGE_VENV / "Scripts" / "python.exe") + 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 _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]).stem == "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.""" + 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: """Assert that ``tokens`` appear in ``parts`` while preserving order.""" iterator = iter(parts) @@ -1779,64 +1823,434 @@ 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 _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_coverage_venv_test for venv tests.""" + + coverage_venv: Path + recorded: list[list[str]] = dataclasses.field(default_factory=list) + + +def _setup_coverage_venv_test( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + 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: + parts = list(cmd.formulate()) # type: ignore[attr-defined] + setup.recorded.append(parts) + 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() + + monkeypatch.setattr(run_python_module, "COVERAGE_VENV", coverage_venv) + monkeypatch.setattr(run_python_module, "run_cmd", fake_run_cmd) + return setup + + +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_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(expected_python) + assert len(setup.recorded) == 3 + venv_parts = setup.recorded[0] + 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] + install_parts = setup.recorded[2] + assert install_parts[1:5] == [ + "pip", + "install", + "--python", + str(expected_python), + ] + + +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_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._ensure_coverage_venv() == str(python_path.resolve()) + assert len(setup.recorded) == 2 + assert setup.recorded[0][1:] == [ + "sync", + "--inexact", + "--python", + str(python_path.resolve()), + ] + assert setup.recorded[1][1:5] == [ + "pip", + "install", + "--python", + str(python_path.resolve()), + ] + + +def test_ensure_coverage_venv_recovers_from_broken_cache( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """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._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").resolve()) + assert setup.recorded[-2][1:] == ["sync", "--inexact", "--python", python] + assert setup.recorded[-1][1:5] == [ + "pip", + "install", + "--python", + python, + ] + + +def test_find_coverage_python_returns_none_when_no_executable( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_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) + + 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, + 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]).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 [ + 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, + python_path: Path, +) -> None: + """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.resolve()), + ] + assert recorded[2][1:5] == [ + "pip", + "install", + "--python", + str(python_path.resolve()), + ] + + +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.resolve()) + _assert_venv_rebuild_commands(setup.recorded, setup.coverage_venv, expected) + + +@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 removes file and symlink placeholders before recreating the venv.""" + setup = _setup_coverage_venv_test(tmp_path, run_python_module, monkeypatch) + 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) + + +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_coverage_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._ensure_coverage_venv() == str( + (setup.coverage_venv / "Scripts" / "python.exe").resolve() + ) + _assert_venv_rebuild_commands( + setup.recorded, + setup.coverage_venv, + setup.coverage_venv / "Scripts" / "python.exe", + ) + + +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.""" + 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() + + 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.resolve()), + ] + parts = setup.recorded[1] + assert Path(parts[0]).stem == "uv" + assert parts[1:5] == ["pip", "install", "--python", str(python.resolve())] + assert "--system" not in parts + 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.resolve()) + + assert sync_environment == [str(setup.coverage_venv.resolve())] + assert "UV_PROJECT_ENVIRONMENT" not in os.environ + + +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: + 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() + + 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.resolve())) + assert len(recorded) == 3 + assert recorded[0][1:] == ["venv", str(coverage_venv)] + assert recorded[1][1:] == [ + "sync", + "--inexact", + "--python", + str(python_path.resolve()), + ] + assert recorded[2][1:5] == [ + "pip", + "install", + "--python", + str(python_path.resolve()), + ] + + +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 = _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_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) + + +@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, - fmt: str, - suffix: str, + monkeypatch: pytest.MonkeyPatch, + spec: CoverageFmtSpec, ) -> 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) + out = tmp_path / f"cov.{spec.suffix}" + _set_fake_coverage_python_cmd(monkeypatch, run_python_module) + cmd = run_python_module.coverage_cmd_for_fmt(spec.fmt, out) parts = list(cmd.formulate()) - _assert_uv_command_structure(parts) + _assert_python_command_structure(parts) return parts, out -def test_coverage_cmd_cobertura_uses_uv( - tmp_path: Path, run_python_module: ModuleType +def test_coverage_cmd_cobertura_uses_venv_python( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, ) -> 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" + tmp_path, run_python_module, monkeypatch, CoverageFmtSpec("cobertura", "xml") ) assert parts.count("--xml") == 1 _assert_tokens_in_order(parts, "--xml", "--out") _assert_flag_value_pair(parts, "--out", str(out)) -def test_coverage_cmd_default_branch_has_shared_args( - tmp_path: Path, run_python_module: ModuleType +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, "coveragepy", "dat" + 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 -) -> None: - """Ensure slipcover output targeting stays isolated to Cobertura runs.""" - parts, _ = _get_coverage_cmd_parts(tmp_path, run_python_module, "coveragepy", "dat") assert "--out" not in parts -def test_tmp_coveragepy_xml_invokes_uv( - tmp_path: Path, run_python_module: ModuleType +def test_tmp_coveragepy_xml_invokes_venv_python( + tmp_path: Path, + run_python_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, ) -> 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 +2261,15 @@ def fake_run_cmd(cmd: object, *_args: object, **_kwargs: object) -> None: run_python_module.run_cmd = fake_run_cmd # type: ignore[assignment] + 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() 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 +2284,22 @@ 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) + _set_fake_coverage_python_cmd(monkeypatch, run_python_module) run_python_module.main(output, "python", "cobertura", github_output, None) - parts = recorded["cmd"] - _assert_uv_command_structure(parts) + 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)) data = github_output.read_text().splitlines() @@ -1955,9 +2374,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) -> 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( "", @@ -1974,6 +2398,9 @@ def fake_tmp_coveragepy_xml(out: Path) -> 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 @@ -1985,6 +2412,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, @@ -2001,9 +2479,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) -> 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: @@ -2018,6 +2501,9 @@ def fake_tmp_coveragepy_xml(out: Path) -> 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() @@ -2052,3 +2538,229 @@ 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 _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, + *, + 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.""" + 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") + env = { + **shell_stubs.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']}" + 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) + + +@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, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """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( + 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 ")] + 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 + # 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) +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( + "spec", + [ + 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"], +) +def test_run_python_integration_uv_failure_modes( + tmp_path: Path, + shell_stubs: StubManager, + monkeypatch: pytest.MonkeyPatch, + spec: UvFailureSpec, +) -> None: + """run_python.py exits non-zero when uv venv or uv sync fails.""" + 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 spec.expected_in_stderr is not None: + assert spec.expected_in_stderr in stderr diff --git a/AGENTS.md b/AGENTS.md index 3ea78e85..e8b2c50f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,29 @@ 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`, 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: + +```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`). @@ -122,7 +145,7 @@ schedule. ## 6  Documentation Standards -- Each action [**README.md**](http://README.md) must contain: +- Each action `README.md` must contain: - **One‑liner summary** diff --git a/Makefile b/Makefile index 7949bb03..2033372f 100644 --- a/Makefile +++ b/Makefile @@ -14,25 +14,28 @@ clean: ## Remove transient artefacts rm -rf .venv .pytest_cache .ruff_cache workspace/.ruff_cache BUILD_JOBS ?= -MDLINT ?= markdownlint +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 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 - uvx ruff check - find .github/actions -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ - | xargs -r -0 -n1 action-validator + $(UV) tool run ruff check + 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 \ @@ -58,15 +61,15 @@ 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) + 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/docs/developers-guide.md b/docs/developers-guide.md new file mode 100644 index 00000000..2c1ed3e9 --- /dev/null +++ b/docs/developers-guide.md @@ -0,0 +1,101 @@ +# 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. | +| 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 for `` thereafter. + +### Public API + +| Symbol | Signature | Role | +| --- | --- | --- | +| `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_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. +`main` runs slipcover, parses coverage, and writes `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 synchronization; 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 install, then Cargo install, then `PATH` | +| `MDLINT` | `~/.bun/bin/markdownlint` if present, otherwise `markdownlint` | +| `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: + +```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 +``` diff --git a/docs/generate-coverage-design.md b/docs/generate-coverage-design.md index 8a2b9362..8fa0f150 100644 --- a/docs/generate-coverage-design.md +++ b/docs/generate-coverage-design.md @@ -16,6 +16,13 @@ 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. `_ensure_coverage_venv()` creates + 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. ## Rust Coverage Environment Overrides @@ -169,3 +176,50 @@ 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 + +### 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. `_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 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. +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 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. + +### Concurrency Model + +GitHub Actions executes action steps sequentially in a single thread. The +`functools.lru_cache` memoized `_coverage_python_cmd()` accessor therefore +requires no explicit synchronization. + +### Public API + + +| Symbol | Role | +|---|---| +| `_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. | + diff --git a/workflow_scripts/markdownlint-check.sh b/workflow_scripts/markdownlint-check.sh new file mode 100755 index 00000000..381f2fb5 --- /dev/null +++ b/workflow_scripts/markdownlint-check.sh @@ -0,0 +1,35 @@ +#!/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}" +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