-
Notifications
You must be signed in to change notification settings - Fork 0
Run Python coverage tooling in a venv #257
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
leynos
wants to merge
48
commits into
main
Choose a base branch
from
fix/coverage-venv-fix
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 41 commits
Commits
Show all changes
48 commits
Select commit
Hold shift + click to select a range
2b71fb7
Run Python coverage tooling in a venv
leynos 009141b
Use uv tool run for Ruff make targets
leynos c6669c4
Resolve tool paths in make checks
leynos fb502bc
Refine coverage venv command handling
leynos a93f3b7
Recreate broken coverage venvs
leynos cb49e90
Bundle coverage format test parameters
leynos 6a8c09b
Mark coverage XML stub parameters unused
leynos 2ab45d6
Document coverage venv lifecycle
leynos 600e710
Stabilise markdownlint for branch docs
leynos 23b373e
Share coverage venv test scaffolding
leynos ba7e729
Remove duplicate coverage venv cache test
leynos 006b165
Document coverage venv setup in action README
leynos 1a302b8
Fix generate coverage README markdown lint
leynos 89cfd3a
Decompose coverage venv command predicate
leynos 3827e03
Simplify coverage venv setup
leynos 7c9e72f
Sync project dependencies into coverage venv
leynos c759a4e
Harden coverage venv cache repair
leynos f4fbdce
Clarify markdownlint fallback warning
leynos 2b60470
Extract coverage venv recreation assertions
leynos 3d8f177
Rename coverage venv rebuild assertion helper
leynos a9f3b44
Cover coverage venv repair edge cases
leynos 145b90b
Guard coverage command test venv detection
leynos 533fa20
Run action-validator per action metadata file
leynos ee24905
Extract coverage venv recreation helpers
leynos 3c20bbd
Move markdownlint logic into helper script
leynos 3588178
Prefer Bun action-validator and quote markdownlint command
leynos ab88554
Extract default coverage venv rebuild assertion
leynos e7b1ce7
Use executable stems in coverage tests
leynos aaf1276
Merge non-Cobertura coverage command tests
leynos 13da80f
Document coverage venv migration and add integration tests
leynos d712151
Fix AGENTS markdown table alignment
leynos d002a85
Handle invalid coverage venv paths
leynos 394def3
Fix AGENTS documentation links and tool lookup
leynos d617af4
Fix coverage design wording
leynos ee1226c
Refactor run_python integration test runner
leynos a8aa693
Remove unused run_python test helper
leynos 92d0b34
Handle dangling coverage venv symlinks
leynos dc432bb
Reduce run_python complexity
leynos bcbc0f1
Parametrize coverage venv failure tests
leynos e26e9de
Target uv sync at coverage venv
leynos 8fe680e
Validate run_python integration env contract
leynos 217dc00
Bundle uv failure test parameters
leynos 6086cec
Document coverage venv internals
leynos dad0cf6
Fix developer guide markdownlint
leynos 6865952
Handle coverage venv bootstrap failures
leynos 3db43ba
Extract run_python main helpers
leynos 77ca00c
Combine run_python coverage execution helper
leynos c811865
Improve run_python observability and baseline test
leynos File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,9 @@ | |
| FMT_OPT = typer.Option(..., envvar="DETECTED_FMT") | ||
| GITHUB_OUTPUT_OPT = typer.Option(..., envvar="GITHUB_OUTPUT") | ||
| BASELINE_OPT = typer.Option(None, envvar="BASELINE_PYTHON_FILE") | ||
| COVERAGE_VENV = Path(".venv-coverage") | ||
| TOOLING_PACKAGES: tuple[str, ...] = ("slipcover", "pytest", "coverage") | ||
| PROJECT_SYNC_ARGS: tuple[str, ...] = ("sync", "--inexact", "--python") | ||
|
|
||
| SLIPCOVER_ARGS: tuple[str, ...] = ( | ||
| "-m", | ||
|
|
@@ -40,18 +47,95 @@ | |
| ) | ||
|
|
||
|
|
||
| def _uv_python_cmd() -> BoundCommand: | ||
| """Return ``uv run`` configured with the required coverage tools.""" | ||
| return uv[ | ||
| "run", | ||
| "--with", | ||
| "slipcover", | ||
| "--with", | ||
| "pytest", | ||
| "--with", | ||
| "coverage", | ||
| "python", | ||
| ] | ||
| def _find_coverage_python() -> Path | None: | ||
| """Return the coverage venv Python executable path when it exists.""" | ||
| if COVERAGE_VENV.is_symlink() or not COVERAGE_VENV.is_dir(): | ||
| return None | ||
| candidates = ( | ||
| COVERAGE_VENV / "bin" / "python", | ||
| COVERAGE_VENV / "Scripts" / "python.exe", | ||
| COVERAGE_VENV / "Scripts" / "python", | ||
| ) | ||
| return next((c for c in candidates if c.is_file()), None) | ||
|
|
||
|
|
||
| def _remove_coverage_venv() -> None: | ||
| """Remove the coverage venv directory or non-directory placeholder. | ||
|
|
||
| Uses ``shutil.rmtree`` for directories and ``Path.unlink`` for any | ||
| other filesystem object (e.g. a symlink or a stale file). | ||
| """ | ||
| if COVERAGE_VENV.is_dir() and not COVERAGE_VENV.is_symlink(): | ||
| shutil.rmtree(COVERAGE_VENV) | ||
| else: | ||
| COVERAGE_VENV.unlink(missing_ok=True) | ||
|
|
||
|
|
||
| def _recreate_coverage_venv() -> Path: | ||
| """Remove any existing broken venv, create a fresh one, and return its Python. | ||
|
|
||
| Returns | ||
| ------- | ||
| Path | ||
| Absolute path to the Python executable inside the newly created venv. | ||
|
|
||
| Raises | ||
| ------ | ||
| RuntimeError | ||
| If the Python executable cannot be located after creation. | ||
| """ | ||
| if COVERAGE_VENV.exists() or COVERAGE_VENV.is_symlink(): | ||
| typer.echo( | ||
| f"Coverage venv at {COVERAGE_VENV} is missing its Python " | ||
| "executable; recreating.", | ||
| err=True, | ||
| ) | ||
| _remove_coverage_venv() | ||
| else: | ||
| typer.echo(f"Creating coverage venv at {COVERAGE_VENV}") | ||
| run_cmd(uv["venv", str(COVERAGE_VENV)]) | ||
| python = _find_coverage_python() | ||
| if python is None: | ||
| msg = f"Coverage venv Python executable not found in {COVERAGE_VENV}" | ||
| raise RuntimeError(msg) | ||
| return python | ||
|
|
||
|
|
||
| def _ensure_coverage_venv() -> str: | ||
| """Create or repair the coverage venv and install project/test tooling. | ||
|
|
||
| Returns the Python executable path inside the isolated coverage venv. | ||
| """ | ||
| python = _find_coverage_python() | ||
| if python is None: | ||
| python = _recreate_coverage_venv() | ||
| typer.echo(f"Installing project dependencies into {COVERAGE_VENV}") | ||
| os.environ["UV_PROJECT_ENVIRONMENT"] = str(COVERAGE_VENV.resolve()) | ||
| try: | ||
| run_cmd(uv[*PROJECT_SYNC_ARGS, str(python)]) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| except ProcessExecutionError as exc: | ||
| typer.echo( | ||
| f"uv sync failed with code {exc.retcode}: {exc.stderr}", | ||
| err=True, | ||
| ) | ||
| raise | ||
| typer.echo(f"Installing coverage tooling {TOOLING_PACKAGES} into {COVERAGE_VENV}") | ||
| try: | ||
| run_cmd(uv["pip", "install", "--python", str(python), *TOOLING_PACKAGES]) | ||
| except ProcessExecutionError as exc: | ||
| typer.echo( | ||
| f"uv pip install failed with code {exc.retcode}: {exc.stderr}", | ||
| err=True, | ||
| ) | ||
| raise | ||
| return str(python) | ||
|
Comment on lines
+108
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❌ New issue: Complex Method |
||
|
|
||
|
|
||
| @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 +149,55 @@ def _coverage_args(fmt: str, out: Path) -> list[str]: | |
|
|
||
|
|
||
| def coverage_cmd_for_fmt(fmt: str, out: Path) -> BoundCommand: | ||
| """Return the slipcover command for the requested format.""" | ||
| python_cmd = _uv_python_cmd() | ||
| """Return the slipcover command for the requested coverage format. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| fmt : str | ||
| Coverage format identifier. ``"cobertura"`` adds slipcover's | ||
| ``--xml`` and ``--out`` flags; all other values produce a bare | ||
| slipcover/pytest invocation. | ||
| out : Path | ||
| Destination path for the coverage output file; passed to slipcover's | ||
| ``--out`` argument when ``fmt == "cobertura"``. | ||
|
|
||
| Returns | ||
| ------- | ||
| plumbum.commands.base.BoundCommand | ||
| A plumbum command that runs slipcover via the coverage venv Python. | ||
| """ | ||
| python_cmd = _coverage_python_cmd() | ||
| return python_cmd[_coverage_args(fmt, out)] | ||
|
|
||
|
|
||
| @contextlib.contextmanager | ||
| def tmp_coveragepy_xml(out: Path) -> cabc.Generator[Path]: | ||
| """Generate a cobertura XML from coverage.py and clean up afterwards.""" | ||
| """Generate a Cobertura XML from coverage.py and clean it up afterwards. | ||
|
|
||
| Invokes ``python -m coverage xml -o <xml_tmp>`` using the coverage venv | ||
| Python, yields the temporary XML path for the caller to consume, and | ||
| removes the file on exit - whether the body raised or returned normally. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| out : Path | ||
| Path to the ``.dat`` (coverage.py data) file. The temporary XML is | ||
| written to ``out.with_suffix(".xml")``. | ||
|
|
||
| Yields | ||
| ------ | ||
| Path | ||
| Absolute path to the freshly generated temporary Cobertura XML file. | ||
|
|
||
| Raises | ||
| ------ | ||
| typer.Exit | ||
| If ``coverage xml`` exits with a non-zero return code. | ||
| """ | ||
| xml_tmp = out.with_suffix(".xml") | ||
| python_cmd = _coverage_python_cmd() | ||
| try: | ||
| cmd = _uv_python_cmd()["-m", "coverage", "xml", "-o", str(xml_tmp)] | ||
| cmd = python_cmd["-m", "coverage", "xml", "-o", str(xml_tmp)] | ||
| run_cmd(cmd) | ||
| except ProcessExecutionError as exc: | ||
| typer.echo( | ||
|
|
@@ -89,17 +211,54 @@ def tmp_coveragepy_xml(out: Path) -> cabc.Generator[Path]: | |
| xml_tmp.unlink(missing_ok=True) | ||
|
|
||
|
|
||
| def _resolve_output_path(output_path: Path, lang: str) -> Path: | ||
| """Return the effective output path, accounting for mixed-language projects. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| output_path : Path | ||
| Base output path supplied by the caller. | ||
| lang : str | ||
| Detected project language. When ``"mixed"``, a ``.python`` infix | ||
| is inserted between the stem and the suffix. | ||
|
|
||
| Returns | ||
| ------- | ||
| Path | ||
| Adjusted output path. | ||
| """ | ||
| if lang == "mixed": | ||
| return output_path.with_name(f"{output_path.stem}.python{output_path.suffix}") | ||
| return output_path | ||
|
|
||
|
|
||
| def main( | ||
| output_path: Path = OUTPUT_PATH_OPT, | ||
| lang: str = LANG_OPT, | ||
| fmt: str = FMT_OPT, | ||
| github_output: Path = GITHUB_OUTPUT_OPT, | ||
| baseline_file: Path | None = BASELINE_OPT, | ||
| ) -> None: | ||
| """Run slipcover coverage and write the output path to ``GITHUB_OUTPUT``.""" | ||
| out = output_path | ||
| if lang == "mixed": | ||
| out = output_path.with_name(f"{output_path.stem}.python{output_path.suffix}") | ||
| """Run slipcover coverage and write the result to ``GITHUB_OUTPUT``. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| output_path : Path | ||
| Destination path for the coverage output file. | ||
| lang : str | ||
| Detected project language (``"rust"``, ``"python"``, or | ||
| ``"mixed"``). When ``"mixed"``, the output file is renamed to | ||
| include a ``.python`` infix. | ||
| fmt : str | ||
| Coverage format identifier passed to :func:`coverage_cmd_for_fmt`. | ||
| github_output : Path | ||
| Path to the ``GITHUB_OUTPUT`` append file where ``file=`` and | ||
| ``percent=`` are written. | ||
| baseline_file : Path or None | ||
| Optional path to a previous coverage baseline file. When present, | ||
| the previous percentage is echoed to the log. | ||
| """ | ||
| out = _resolve_output_path(output_path, lang) | ||
| out.parent.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| cmd = coverage_cmd_for_fmt(fmt, out) | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Return a resolved interpreter path to satisfy the absolute-path contract.
Align implementation with the documented return contract. Line 101 and Line 160 currently propagate a relative interpreter path, while the docstrings state an absolute path. Resolve once before returning and cache the resolved value.
🔧 Proposed fix
Also applies to: 117-121, 160-160
🤖 Prompt for AI Agents