Coverage for scripts/test_report.py: 56%
91 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-14 12:31 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-14 12:31 +0000
1#!/usr/bin/env python3
2"""Run a test command and write an auditable Markdown report."""
4import argparse
5import datetime as dt
6import hashlib
7import json
8import os
9import subprocess
10from pathlib import Path
13def digest(path):
14 return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
17def compact_lines(lines):
18 numbers = sorted(set(int(line) for line in lines))
19 if not numbers: 19 ↛ 20line 19 didn't jump to line 20 because the condition on line 19 was never true
20 return "none"
21 ranges = []
22 start = previous = numbers[0]
23 for number in numbers[1:]:
24 if number == previous + 1:
25 previous = number
26 continue
27 ranges.append(str(start) if start == previous else f"{start}-{previous}")
28 start = previous = number
29 ranges.append(str(start) if start == previous else f"{start}-{previous}")
30 return ", ".join(ranges)
33def coverage_details(path, source):
34 unavailable = {
35 "summary": "not measured",
36 "covered_lines": "not measured",
37 "missing_lines": "not measured",
38 "missing_branches": "not measured",
39 }
40 if not path.is_file(): 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 return unavailable
42 try:
43 files = json.loads(path.read_text(encoding="utf-8"))["files"]
44 except (KeyError, TypeError, ValueError, json.JSONDecodeError):
45 return {key: "invalid coverage data" for key in unavailable}
47 source_resolved = source.resolve()
48 match = next(
49 (
50 details
51 for filename, details in files.items()
52 if Path(filename).resolve() == source_resolved
53 ),
54 None,
55 )
56 if match is None:
57 return unavailable
58 try:
59 summary = match["summary"]
60 percent = float(summary["percent_covered"])
61 covered = int(summary["covered_lines"])
62 statements = int(summary["num_statements"])
63 covered_lines = compact_lines(match["executed_lines"])
64 missing_lines = compact_lines(match["missing_lines"])
65 missing_branches = ", ".join(
66 f"{source_line} -> {'exit' if destination < 0 else destination}"
67 for source_line, destination in match["missing_branches"]
68 )
69 except (KeyError, TypeError, ValueError):
70 return {key: "invalid coverage data" for key in unavailable}
71 return {
72 "summary": f"{percent:.1f}% ({covered}/{statements} lines)",
73 "covered_lines": covered_lines,
74 "missing_lines": missing_lines,
75 "missing_branches": missing_branches or "none",
76 }
79def repository_context(environment=None):
80 environment = os.environ if environment is None else environment
81 server = environment.get("GITHUB_SERVER_URL")
82 repository = environment.get("GITHUB_REPOSITORY")
83 sha = environment.get("GITHUB_SHA")
84 run_id = environment.get("GITHUB_RUN_ID")
85 if server and repository: 85 ↛ 95line 85 didn't jump to line 95 because the condition on line 85 was always true
86 commit = (
87 f"[{sha[:12]}]({server}/{repository}/commit/{sha})" if sha else "unknown"
88 )
89 ci_run = (
90 f"[GitHub Actions run]({server}/{repository}/actions/runs/{run_id})"
91 if run_id
92 else "not available"
93 )
94 return commit, ci_run
95 try:
96 local_sha = subprocess.run(
97 ["git", "rev-parse", "--short=12", "HEAD"],
98 capture_output=True,
99 text=True,
100 check=True,
101 ).stdout.strip()
102 except (OSError, subprocess.CalledProcessError):
103 local_sha = "unknown"
104 return f"`{local_sha}`", "local run"
107def main():
108 parser = argparse.ArgumentParser()
109 parser.add_argument("--source", type=Path, required=True)
110 parser.add_argument("--test", type=Path, required=True)
111 parser.add_argument("--report", type=Path, required=True)
112 parser.add_argument("--coverage-json", type=Path, default=Path("coverage.json"))
113 parser.add_argument("--html-coverage-url")
114 parser.add_argument("command", nargs=argparse.REMAINDER)
115 args = parser.parse_args()
116 command = args.command[1:] if args.command[:1] == ["--"] else args.command
117 if not command:
118 parser.error("a test command is required after --")
119 for path in (args.source, args.test):
120 if not path.is_file():
121 parser.error(f"file not found: {path}")
123 result = subprocess.run(command, capture_output=True, text=True)
124 output = (result.stdout + result.stderr).strip()
125 status = "PASS" if result.returncode == 0 else "FAIL"
126 timestamp = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
127 rendered_command = " ".join(command)
128 commit, ci_run = repository_context()
129 coverage = coverage_details(args.coverage_json, args.source)
130 html_coverage = (
131 f"[Open annotated source]({args.html_coverage_url})"
132 if args.html_coverage_url
133 else "not published"
134 )
135 content = f"""# Test Report: `{args.source.name}`
137## Result
139| Field | Value |
140|---|---|
141| Status | **{status}** |
142| Exit code | `{result.returncode}` |
143| Run at | `{timestamp}` |
144| Source SHA-256 | `{digest(args.source)}` |
145| Test SHA-256 | `{digest(args.test)}` |
146| Target source coverage | {coverage['summary']} |
147| HTML coverage | {html_coverage} |
148| Commit | {commit} |
149| CI | {ci_run} |
151## Target Source Coverage Details
153- **Covered lines:** {coverage['covered_lines']}
154- **Missing lines:** {coverage['missing_lines']}
155- **Missing branches:** {coverage['missing_branches']}
157## Command
159```text
160{rendered_command}
161```
163## Test output
165```text
166{output or '(no output)'}
167```
168"""
169 args.report.parent.mkdir(parents=True, exist_ok=True)
170 args.report.write_text(content, encoding="utf-8")
171 print(f"Wrote {args.report} ({status})")
172 raise SystemExit(result.returncode)
175if __name__ == "__main__": 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 main()