#!/usr/bin/env python3 """Whole-project coverage summary from a coverage.py JSON report. Batch helper for `make coverage-summary`. After `make coverage` writes a coverage.py JSON report (`coverage json`), this prints a file-weighted project number and the source files present on disk but absent from the report. The value here is the missing-file detection: a module no test imports never appears in coverage.py's output, so a line-weighted total silently skips it and the suite looks healthier than it is. This counts such a file as 0% and weights the project number by file rather than by line, so untested modules stay visible. coverage.py's own `coverage report` prints the per-file table, so this deliberately doesn't reimplement that — it adds the accounting `report` lacks. Self-contained on the standard library (json only); it parses the report, it does not import coverage. The coverage.py JSON shape it reads is: { "files": { "": { "summary": { "covered_lines": N, "num_statements": M, ... } } } } Paths are relative to the directory coverage ran in (the project root). CLI: coverage-summary.py REPORT SOURCE_DIR [PROJECT_ROOT] REPORT path to the coverage.py JSON report SOURCE_DIR the package/source directory to account for PROJECT_ROOT defaults to the current directory """ from __future__ import annotations import json import sys from pathlib import Path from typing import NoReturn def _die(msg: str) -> NoReturn: print(f"coverage-summary: {msg}", file=sys.stderr) raise SystemExit(1) def parse_report(report_path) -> dict: """Parse a coverage.py JSON report into {abspath: (covered, total)}. COVERED is the file's covered_lines, TOTAL its num_statements. coverage.py records paths relative to the directory it ran in, which is where the report is written, so relative paths resolve against the report's parent. Exits with a clear message when the report is missing or malformed. """ p = Path(report_path) if not p.is_file(): _die(f"coverage report not found: {p}") base = p.resolve().parent try: data = json.loads(p.read_text()) except (ValueError, OSError) as e: _die(f"malformed coverage JSON in {p}: {e}") result: dict = {} for path, entry in (data.get("files") or {}).items(): summary = entry.get("summary", {}) if isinstance(entry, dict) else {} covered = int(summary.get("covered_lines", 0)) total = int(summary.get("num_statements", 0)) fp = Path(path) abspath = fp.resolve() if fp.is_absolute() else (base / fp).resolve() result[str(abspath)] = (covered, total) return result def under_dir(table: dict, source_dir, project_root) -> dict: """Filter TABLE to files under SOURCE_DIR, re-keyed relative to PROJECT_ROOT.""" source_dir = Path(source_dir).resolve() project_root = Path(project_root).resolve() result: dict = {} for abspath, counts in table.items(): ap = Path(abspath) if source_dir == ap or source_dir in ap.parents: result[str(ap.relative_to(project_root))] = counts return result def source_files(source_dir, project_root) -> list: """Return *.py files under SOURCE_DIR (recursive), relative to PROJECT_ROOT. Packages nest, so this recurses; __pycache__ is skipped. The caller scopes coverage by choosing SOURCE_DIR. """ source_dir = Path(source_dir).resolve() project_root = Path(project_root).resolve() out = [] for p in source_dir.rglob("*.py"): if "__pycache__" in p.parts: continue out.append(str(p.resolve().relative_to(project_root))) return sorted(out) def missing(tracked, source_dir, project_root) -> list: """Source files on disk but absent from TRACKED (the report's keys).""" tracked_set = set(tracked) return [f for f in source_files(source_dir, project_root) if f not in tracked_set] def file_pct(covered: int, total: int) -> float: """COVERED/TOTAL as a percentage. A file with no statements is 100%.""" return (100.0 * covered / total) if total > 0 else 100.0 def project_pct(report_path, source_dir, project_root) -> float: """File-weighted project coverage: every tracked file contributes its own percentage, every source file missing from the report contributes 0%, and the result is the mean over all files under SOURCE_DIR.""" tracked = under_dir(parse_report(report_path), source_dir, project_root) miss = missing(list(tracked), source_dir, project_root) total_count = len(tracked) + len(miss) if total_count == 0: return 0.0 score = sum(file_pct(c, t) for c, t in tracked.values()) return score / total_count def summary_text(report_path, source_dir, project_root) -> str: """Render the project number and the missing-file list (not the per-file table — `coverage report` already prints that).""" tracked = under_dir(parse_report(report_path), source_dir, project_root) miss = missing(list(tracked), source_dir, project_root) pct = project_pct(report_path, source_dir, project_root) rel_src = Path(source_dir).resolve().relative_to(Path(project_root).resolve()) total = len(tracked) + len(miss) lines = [ f"Coverage summary for {rel_src}", "", f"Project coverage: {pct:.1f}% ({len(tracked)} tracked, {len(miss)} " f"missing, {total} total; missing files count as 0%)", "", f"Not in coverage report: {len(miss)} file{'' if len(miss) == 1 else 's'}", ] if miss: lines.append("These files had no coverage entry; they count as 0% in project coverage.") lines += [f" {m}" for m in sorted(miss)] else: lines.append("Every source file appears in the coverage report.") lines.append("") lines.append("(Per-file table: run `coverage report`.)") return "\n".join(lines) + "\n" def main(argv) -> int: if not 2 <= len(argv) <= 3: print("usage: coverage-summary.py REPORT SOURCE_DIR [PROJECT_ROOT]", file=sys.stderr) return 2 report, source_dir = argv[0], argv[1] project_root = argv[2] if len(argv) == 3 else "." print() print(summary_text(report, source_dir, project_root), end="") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))