Skip to content

Commit 70ed6e3

Browse files
committed
coverage: CI
1 parent 529fe22 commit 70ed6e3

File tree

7 files changed

+302
-22
lines changed

7 files changed

+302
-22
lines changed

.github/workflows/ci.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,76 @@ jobs:
3131
- name: Python tests
3232
run: nix develop --command bash -lc 'just py-test'
3333

34+
coverage:
35+
name: Coverage (Python 3.12)
36+
needs: nix-tests
37+
runs-on: ubuntu-latest
38+
continue-on-error: true
39+
permissions:
40+
contents: read
41+
issues: write
42+
pull-requests: write
43+
steps:
44+
- uses: actions/checkout@v4
45+
- uses: cachix/install-nix-action@v27
46+
with:
47+
nix_path: nixpkgs=channel:nixos-25.05
48+
extra_nix_config: |
49+
experimental-features = nix-command flakes
50+
51+
- name: Prepare dev environment (Python 3.12)
52+
run: nix develop --command bash -lc 'just venv 3.12 dev'
53+
54+
- name: Collect coverage
55+
id: coverage-run
56+
run: nix develop --command bash -lc 'just coverage'
57+
58+
- name: Generate coverage comment
59+
if: steps.coverage-run.outcome == 'success'
60+
run: |
61+
ROOT="$(pwd)"
62+
nix develop --command bash -lc "python3 codetracer-python-recorder/scripts/generate_coverage_comment.py \
63+
--rust-summary codetracer-python-recorder/target/coverage/rust/summary.json \
64+
--python-json codetracer-python-recorder/target/coverage/python/coverage.json \
65+
--output codetracer-python-recorder/target/coverage/coverage-comment.md \
66+
--repo-root \"${ROOT}\""
67+
68+
- name: Upload Rust coverage artefacts
69+
if: steps.coverage-run.outcome == 'success'
70+
uses: actions/upload-artifact@v4
71+
with:
72+
name: rust-coverage
73+
path: |
74+
codetracer-python-recorder/target/coverage/rust/lcov.info
75+
codetracer-python-recorder/target/coverage/rust/summary.json
76+
if-no-files-found: warn
77+
78+
- name: Upload Python coverage artefacts
79+
if: steps.coverage-run.outcome == 'success'
80+
uses: actions/upload-artifact@v4
81+
with:
82+
name: python-coverage
83+
path: |
84+
codetracer-python-recorder/target/coverage/python/coverage.xml
85+
codetracer-python-recorder/target/coverage/python/coverage.json
86+
if-no-files-found: warn
87+
88+
- name: Upload coverage comment
89+
if: steps.coverage-run.outcome == 'success'
90+
uses: actions/upload-artifact@v4
91+
with:
92+
name: coverage-comment
93+
path: codetracer-python-recorder/target/coverage/coverage-comment.md
94+
if-no-files-found: warn
95+
96+
- name: Comment coverage summary
97+
if: github.event_name == 'pull_request' && steps.coverage-run.outcome == 'success'
98+
uses: peter-evans/create-or-update-comment@v4
99+
with:
100+
issue-number: ${{ github.event.pull_request.number }}
101+
identifier: coverage-summary
102+
body-path: codetracer-python-recorder/target/coverage/coverage-comment.md
103+
34104
# rust-tests:
35105
# name: Rust module test on ${{ matrix.os }} (Python ${{ matrix.python-version }})
36106
# runs-on: ${{ matrix.os }}

Justfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ test-pure:
4848
uv run --group dev --group test pytest codetracer-pure-python-recorder
4949

5050
# Generate combined coverage artefacts for both crates
51-
alias cov := coverage
5251
coverage:
5352
just coverage-rust
5453
just coverage-python
@@ -64,7 +63,7 @@ coverage-rust:
6463

6564
coverage-python:
6665
mkdir -p codetracer-python-recorder/target/coverage/python
67-
uv run --group dev --group test pytest --cov=codetracer_python_recorder --cov-report=term --cov-report=xml:codetracer-python-recorder/target/coverage/python/coverage.xml codetracer-python-recorder/tests/python
66+
uv run --group dev --group test pytest --cov=codetracer_python_recorder --cov-report=term --cov-report=xml:codetracer-python-recorder/target/coverage/python/coverage.xml --cov-report=json:codetracer-python-recorder/target/coverage/python/coverage.json codetracer-python-recorder/tests/python
6867

6968
# Build the module in release mode
7069
build:
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
#!/usr/bin/env python3
2+
"""Generate a Markdown coverage summary comment for CI."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import pathlib
9+
from typing import Dict, Iterable, List, Tuple
10+
11+
from render_rust_coverage_summary import load_summary as load_rust_summary
12+
13+
Row = Tuple[str, int, int, float]
14+
15+
16+
def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
17+
parser = argparse.ArgumentParser(description=__doc__ or "")
18+
parser.add_argument(
19+
"--rust-summary",
20+
type=pathlib.Path,
21+
required=True,
22+
help="Path to cargo-llvm-cov JSON summary",
23+
)
24+
parser.add_argument(
25+
"--python-json",
26+
type=pathlib.Path,
27+
required=True,
28+
help="Path to coverage.py JSON report",
29+
)
30+
parser.add_argument(
31+
"--output",
32+
type=pathlib.Path,
33+
required=True,
34+
help="Output Markdown file for the PR comment",
35+
)
36+
parser.add_argument(
37+
"--repo-root",
38+
type=pathlib.Path,
39+
default=pathlib.Path.cwd(),
40+
help="Repository root used to relativise file paths (default: current working directory)",
41+
)
42+
parser.add_argument(
43+
"--max-rows",
44+
type=int,
45+
default=20,
46+
help="Maximum number of per-file rows to display for each language (default: 20).",
47+
)
48+
return parser.parse_args(argv)
49+
50+
51+
def _select_rows(rows: List[Row], max_rows: int) -> Tuple[List[Row], bool]:
52+
if max_rows <= 0 or len(rows) <= max_rows:
53+
return sorted(rows, key=lambda item: item[0]), False
54+
55+
priority_sorted = sorted(rows, key=lambda item: (-item[2], item[0]))
56+
trimmed = priority_sorted[:max_rows]
57+
return sorted(trimmed, key=lambda item: item[0]), True
58+
59+
60+
def _format_table(rows: List[Row], headers: Tuple[str, str, str, str]) -> List[str]:
61+
lines = [
62+
f"| {headers[0]} | {headers[1]} | {headers[2]} | {headers[3]} |",
63+
"| --- | ---: | ---: | ---: |",
64+
]
65+
for name, total, missed, percent in rows:
66+
lines.append(
67+
f"| `{name}` | {total:,} | {missed:,} | {percent:5.1f}% |"
68+
)
69+
return lines
70+
71+
72+
def _load_python_rows(
73+
report_path: pathlib.Path,
74+
repo_root: pathlib.Path,
75+
) -> Tuple[List[Row], Dict[str, float]]:
76+
try:
77+
payload = json.loads(report_path.read_text(encoding="utf-8"))
78+
except FileNotFoundError as exc:
79+
raise SystemExit(f"Python coverage JSON not found: {report_path}") from exc
80+
81+
repo_root = repo_root.resolve()
82+
rows: List[Row] = []
83+
84+
for path_str, details in payload.get("files", {}).items():
85+
summary = (details.get("summary") or {})
86+
total = int(summary.get("num_statements", 0))
87+
missed = int(summary.get("missing_lines", 0))
88+
percent = float(summary.get("percent_covered", 0.0))
89+
90+
if total == 0 and missed == 0:
91+
continue
92+
93+
file_path = pathlib.Path(path_str)
94+
if not file_path.is_absolute():
95+
file_path = (repo_root / file_path).resolve()
96+
else:
97+
file_path = file_path.resolve()
98+
99+
try:
100+
rel_path = file_path.relative_to(repo_root)
101+
except ValueError:
102+
continue
103+
104+
rows.append((rel_path.as_posix(), total, missed, percent))
105+
106+
rows.sort(key=lambda item: item[0])
107+
108+
totals = payload.get("totals", {})
109+
return rows, {
110+
"total": float(totals.get("num_statements", 0)),
111+
"covered": float(totals.get("covered_lines", 0)),
112+
"missed": float(totals.get("missing_lines", 0)),
113+
"percent": float(totals.get("percent_covered", 0.0)),
114+
}
115+
116+
117+
def _load_rust_rows(
118+
summary_path: pathlib.Path,
119+
repo_root: pathlib.Path,
120+
) -> Tuple[List[Row], Dict[str, float]]:
121+
rows, totals = load_rust_summary(summary_path, repo_root)
122+
# Normalise totals dict to expected keys
123+
total = float(totals.get("count", 0))
124+
covered = float(totals.get("covered", 0))
125+
missed = float(totals.get("notcovered", total - covered))
126+
return rows, {
127+
"total": total,
128+
"covered": covered,
129+
"missed": missed,
130+
"percent": float(totals.get("percent", 0.0)),
131+
}
132+
133+
134+
def _format_summary_block(
135+
heading: str,
136+
column_label: str,
137+
totals: Dict[str, float],
138+
rows: List[Row],
139+
max_rows: int,
140+
) -> List[str]:
141+
display_rows, truncated = _select_rows(rows, max_rows)
142+
lines = [heading]
143+
total = int(totals.get("total", 0))
144+
covered = int(totals.get("covered", 0))
145+
missed = int(totals.get("missed", total - covered))
146+
percent = totals.get("percent", 0.0)
147+
lines.append(
148+
f"**{percent:0.1f}%** covered ({covered:,} / {total:,} | {missed:,} missed)"
149+
)
150+
lines.extend(
151+
_format_table(display_rows, ("File", column_label, "Miss", "Cover"))
152+
)
153+
if truncated:
154+
lines.append(
155+
f"_Showing top {max_rows} entries by missed lines (of {len(rows)} total)._"
156+
)
157+
return lines
158+
159+
160+
def main(argv: Iterable[str] | None = None) -> int:
161+
args = parse_args(argv)
162+
repo_root = args.repo_root.resolve()
163+
164+
rust_rows, rust_totals = _load_rust_rows(args.rust_summary, repo_root)
165+
python_rows, python_totals = _load_python_rows(args.python_json, repo_root)
166+
167+
output_lines: List[str] = ["### Coverage Summary", ""]
168+
169+
output_lines.extend(
170+
_format_summary_block(
171+
"**Rust (lines)**", "Lines", rust_totals, rust_rows, args.max_rows
172+
)
173+
)
174+
output_lines.extend(["", ""])
175+
output_lines.extend(
176+
_format_summary_block(
177+
"**Python (statements)**", "Stmts", python_totals, python_rows, args.max_rows
178+
)
179+
)
180+
output_lines.append("")
181+
output_lines.append(
182+
"_Generated automatically via `generate_coverage_comment.py`._"
183+
)
184+
185+
args.output.parent.mkdir(parents=True, exist_ok=True)
186+
args.output.write_text("\n".join(output_lines) + "\n", encoding="utf-8")
187+
return 0
188+
189+
190+
if __name__ == "__main__":
191+
raise SystemExit(main())

codetracer-python-recorder/scripts/render_rust_coverage_summary.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
import json
77
import pathlib
88
import sys
9-
from typing import Iterable, List, Tuple
9+
from typing import Dict, Iterable, List, Tuple
10+
11+
12+
def _load_payload(summary_path: pathlib.Path) -> Dict:
13+
try:
14+
return json.loads(summary_path.read_text(encoding="utf-8"))
15+
except FileNotFoundError as exc:
16+
raise SystemExit(f"Rust coverage summary not found: {summary_path}") from exc
1017

1118

1219
def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
@@ -26,15 +33,28 @@ def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
2633

2734

2835
def load_rows(summary_path: pathlib.Path, repo_root: pathlib.Path) -> List[Tuple[str, int, int, float]]:
29-
try:
30-
payload = json.loads(summary_path.read_text(encoding="utf-8"))
31-
except FileNotFoundError as exc:
32-
raise SystemExit(f"Rust coverage summary not found: {summary_path}") from exc
36+
payload = _load_payload(summary_path)
37+
rows, _ = load_summary(summary_path, repo_root, payload)
38+
return rows
39+
40+
41+
def load_summary(
42+
summary_path: pathlib.Path,
43+
repo_root: pathlib.Path,
44+
payload: Dict | None = None,
45+
) -> Tuple[List[Tuple[str, int, int, float]], Dict[str, float]]:
46+
if payload is None:
47+
payload = _load_payload(summary_path)
3348

3449
repo_root = repo_root.resolve()
3550
rows: List[Tuple[str, int, int, float]] = []
3651

52+
totals: Dict[str, float] = {}
53+
3754
for dataset in payload.get("data", []):
55+
dataset_totals = dataset.get("totals", {})
56+
if dataset_totals:
57+
totals = dataset_totals.get("lines", dataset_totals)
3858
for entry in dataset.get("files", []):
3959
filename = entry.get("filename")
4060
if not filename:
@@ -54,7 +74,7 @@ def load_rows(summary_path: pathlib.Path, repo_root: pathlib.Path) -> List[Tuple
5474
rows.append((rel_path.as_posix(), total, missed, percent))
5575

5676
rows.sort(key=lambda item: item[0])
57-
return rows
77+
return rows, totals
5878

5979

6080
def render(rows: List[Tuple[str, int, int, float]]) -> str:
@@ -72,7 +92,7 @@ def render(rows: List[Tuple[str, int, int, float]]) -> str:
7292

7393
def main(argv: Iterable[str] | None = None) -> int:
7494
args = parse_args(argv)
75-
rows = load_rows(args.summary_path, args.root)
95+
rows, _ = load_summary(args.summary_path, args.root)
7696
output = render(rows)
7797
print(output)
7898
return 0

codetracer-python-recorder/tests/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ CI configuration:
3030
so the console mirrors the pytest coverage output. (Run
3131
`cargo llvm-cov nextest --html` manually if you need a browsable report.)
3232
- `just coverage-python` executes pytest with `pytest-cov`, limiting collection to
33-
`codetracer_python_recorder` and emitting `coverage.xml` in
34-
`codetracer-python-recorder/target/coverage/python/`.
33+
`codetracer_python_recorder` and emitting both `coverage.xml` and `coverage.json`
34+
in `codetracer-python-recorder/target/coverage/python/`.
3535
- `just coverage` is a convenience wrapper that invokes both commands in sequence.
3636

3737
All commands create their output directories on first run, so no manual setup is

0 commit comments

Comments
 (0)