Skip to content

Commit d8e19e8

Browse files
scripts: Add check_class_function_coverage.py for class-specific coverage checks
- Introduced a Python script to analyze function coverage for specified classes using `coverage.py` JSON output. - Validates minimum coverage thresholds for each function and identifies uncovered classes or insufficiently covered functions. - Supports generating a Markdown report for detailed coverage insights. - Handles invalid or missing JSON inputs gracefully with meaningful error messages. Assisted-by: Codex
1 parent 739dd21 commit d8e19e8

1 file changed

Lines changed: 223 additions & 0 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import sys
7+
from collections.abc import Iterable
8+
from dataclasses import dataclass
9+
from pathlib import Path
10+
11+
12+
@dataclass(frozen=True)
13+
class FunctionCoverage:
14+
file: str
15+
function: str
16+
percent_covered: float
17+
covered_lines: int
18+
num_statements: int
19+
20+
21+
def _parse_classes(values: Iterable[str]) -> list[str]:
22+
classes: list[str] = []
23+
for value in values:
24+
if not value:
25+
continue
26+
for item in value.split(","):
27+
item = item.strip()
28+
if item:
29+
classes.append(item)
30+
return classes
31+
32+
33+
def _load_coverage(path: Path) -> dict:
34+
try:
35+
data = json.loads(path.read_text(encoding="utf-8"))
36+
except FileNotFoundError:
37+
message = f"Coverage JSON not found: {path}"
38+
raise SystemExit(message) from None
39+
except json.JSONDecodeError as exc:
40+
message = f"Invalid coverage JSON: {exc}"
41+
raise SystemExit(message) from exc
42+
43+
if not isinstance(data, dict) or "files" not in data:
44+
message = "Coverage JSON missing 'files' data."
45+
raise SystemExit(message)
46+
return data
47+
48+
49+
def _match_class(function_name: str, classes: Iterable[str]) -> str | None:
50+
for class_name in classes:
51+
prefix = f"{class_name}."
52+
if function_name.startswith(prefix):
53+
return class_name
54+
return None
55+
56+
57+
def _collect_function_coverage(
58+
data: dict, classes: list[str]
59+
) -> tuple[list[FunctionCoverage], set[str]]:
60+
results: list[FunctionCoverage] = []
61+
matched_classes: set[str] = set()
62+
63+
for file_name, file_info in data.get("files", {}).items():
64+
functions = file_info.get("functions") or {}
65+
for function_name, function_info in functions.items():
66+
if not function_name or "." not in function_name:
67+
continue
68+
class_name = _match_class(function_name, classes)
69+
if not class_name:
70+
continue
71+
72+
summary = function_info.get("summary") or {}
73+
percent = summary.get("percent_covered")
74+
covered_lines = summary.get("covered_lines")
75+
num_statements = summary.get("num_statements")
76+
if percent is None or covered_lines is None or num_statements is None:
77+
continue
78+
79+
matched_classes.add(class_name)
80+
results.append(
81+
FunctionCoverage(
82+
file=file_name,
83+
function=function_name,
84+
percent_covered=float(percent),
85+
covered_lines=int(covered_lines),
86+
num_statements=int(num_statements),
87+
)
88+
)
89+
90+
return results, matched_classes
91+
92+
93+
def _parse_args() -> argparse.Namespace:
94+
parser = argparse.ArgumentParser(
95+
description=(
96+
"Check function coverage for methods in specified classes using coverage.py JSON data."
97+
)
98+
)
99+
parser.add_argument("coverage_json", type=Path, help="Path to coverage.json")
100+
parser.add_argument(
101+
"--class",
102+
dest="classes",
103+
action="append",
104+
default=[],
105+
help="Class name to check (repeatable).",
106+
)
107+
parser.add_argument(
108+
"--classes",
109+
dest="classes_csv",
110+
default="",
111+
help="Comma-separated class names to check.",
112+
)
113+
parser.add_argument(
114+
"--fail-under",
115+
"--min-coverage",
116+
dest="fail_under",
117+
type=float,
118+
required=True,
119+
help="Minimum coverage percentage required for each function.",
120+
)
121+
parser.add_argument(
122+
"--markdown-report",
123+
dest="markdown_report",
124+
type=Path,
125+
default=None,
126+
help="Write a markdown report to the given path.",
127+
)
128+
return parser.parse_args()
129+
130+
131+
def _print_summary(
132+
classes: list[str],
133+
threshold: float,
134+
functions: list[FunctionCoverage],
135+
missing_classes: list[str],
136+
failures: list[FunctionCoverage],
137+
) -> None:
138+
print(f"Minimum required coverage: {threshold:.2f}%")
139+
print(f"Classes checked: {', '.join(classes)}")
140+
print(f"Functions checked: {len(functions)}")
141+
142+
if missing_classes:
143+
print("Classes with no discovered functions:")
144+
for cls in missing_classes:
145+
print(f"- {cls}")
146+
147+
if failures:
148+
print("Functions with insufficient coverage:")
149+
for fn in sorted(failures, key=lambda item: (item.file, item.function)):
150+
print(
151+
f"- {fn.file} :: {fn.function} -> {fn.percent_covered:.2f}% "
152+
f"({fn.covered_lines}/{fn.num_statements} lines)"
153+
)
154+
155+
156+
def _build_report_lines(
157+
classes: list[str],
158+
threshold: float,
159+
functions: list[FunctionCoverage],
160+
missing_classes: list[str],
161+
failures: list[FunctionCoverage],
162+
) -> list[str]:
163+
report_lines = [
164+
"# Function Coverage Report",
165+
"",
166+
f"- Minimum required coverage: {threshold:.2f}%",
167+
f"- Classes checked: {', '.join(classes)}",
168+
f"- Functions checked: {len(functions)}",
169+
]
170+
if missing_classes:
171+
report_lines.append("- Classes with no discovered functions:")
172+
report_lines.extend(f" - {cls}" for cls in missing_classes)
173+
if failures:
174+
report_lines.append("- Functions with insufficient coverage:")
175+
report_lines.extend(
176+
f" - {fn.file} :: {fn.function} -> {fn.percent_covered:.2f}% "
177+
f"({fn.covered_lines}/{fn.num_statements} lines)"
178+
for fn in sorted(failures, key=lambda item: (item.file, item.function))
179+
)
180+
else:
181+
report_lines.append("- All checked functions meet the coverage threshold.")
182+
return report_lines
183+
184+
185+
def _write_markdown_report(path: Path, report_lines: list[str]) -> None:
186+
path.write_text("\n".join(report_lines) + "\n", encoding="utf-8")
187+
188+
189+
def main() -> int:
190+
args = _parse_args()
191+
192+
classes = _parse_classes([*args.classes, args.classes_csv])
193+
if not classes:
194+
print("No classes specified. Use --class or --classes.", file=sys.stderr)
195+
return 2
196+
197+
data = _load_coverage(args.coverage_json)
198+
functions, matched_classes = _collect_function_coverage(data, classes)
199+
200+
missing_classes = [cls for cls in classes if cls not in matched_classes]
201+
if not functions:
202+
print("No functions found for the requested classes.")
203+
204+
threshold = args.fail_under
205+
failures: list[FunctionCoverage] = [
206+
fn for fn in functions if fn.percent_covered + 1e-9 < threshold
207+
]
208+
209+
_print_summary(classes, threshold, functions, missing_classes, failures)
210+
211+
if args.markdown_report:
212+
report_lines = _build_report_lines(
213+
classes, threshold, functions, missing_classes, failures
214+
)
215+
_write_markdown_report(args.markdown_report, report_lines)
216+
217+
if failures or missing_classes:
218+
return 1
219+
return 0
220+
221+
222+
if __name__ == "__main__":
223+
raise SystemExit(main())

0 commit comments

Comments
 (0)