-
Notifications
You must be signed in to change notification settings - Fork 83
Expand file tree
/
Copy pathcoverage_check.py
More file actions
314 lines (270 loc) · 11.1 KB
/
Copy pathcoverage_check.py
File metadata and controls
314 lines (270 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
"""Enforce a minimum line-coverage threshold on a Bazel LCOV report.
Usage:
coverage_check.py [--threshold N] [--include PREFIX,...] [LCOV_FILE]
Defaults:
LCOV_FILE bazel-out/_coverage/_coverage_report.dat
--threshold 90 (or $COVERAGE_THRESHOLD)
--include cli/src/main/,tools/coverage_check.py
Only production-source files (whose path matches one of the include prefixes)
contribute to the numerator and denominator. Bazel's Kotlin and Python
instrumentation report test sources too; including them would let thin
production-code coverage hide behind well-covered tests, so they are stripped.
Exit codes:
0 overall coverage meets or exceeds the threshold
1 overall coverage is below the threshold
2 the LCOV report is missing, unreadable, or has no production-source lines
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import Iterable, List
@dataclass(frozen=True)
class FileCoverage:
"""Per-file line coverage extracted from one LCOV `SF` record."""
path: str
lines_found: int
lines_hit: int
@property
def pct(self) -> float:
return (self.lines_hit / self.lines_found * 100.0) if self.lines_found else 0.0
def parse_lcov(text: str) -> List[FileCoverage]:
"""Parse an LCOV `.info`/`.dat` blob into per-file coverage records.
Only `SF`, `LF`, `LH`, and `end_of_record` lines are read; everything else
(`FN`, `FNDA`, `DA`, `BRF`, etc.) is ignored because the threshold is
line-coverage-based.
Malformed integer payloads on `LF`/`LH` fall back to 0 rather than raising,
so a single bad record can't take down the whole check.
"""
records: List[FileCoverage] = []
sf = None
lf = 0
lh = 0
for raw in text.splitlines():
if raw.startswith("SF:"):
sf = raw[3:]
lf = 0
lh = 0
elif raw.startswith("LF:") and sf is not None:
try:
lf = int(raw[3:])
except ValueError:
lf = 0
elif raw.startswith("LH:") and sf is not None:
try:
lh = int(raw[3:])
except ValueError:
lh = 0
elif raw == "end_of_record" and sf is not None:
records.append(FileCoverage(sf, lf, lh))
sf = None
return records
def filter_main_source(
records: Iterable[FileCoverage], include_prefixes: List[str]
) -> List[FileCoverage]:
"""Keep records whose path begins with one of `include_prefixes` and that
have at least one instrumented line.
Files with `LF:0` are dropped because LCOV has no measurable content for
them. This happens in two ways: (a) the file genuinely has no
instrumentable lines (rare), and (b) the language's instrumentation tool
didn't run for that file. Bazel's Python coverage in particular only
gathers data when the toolchain is configured with `coverage_tool` set,
so without that step a `py_test` produces an `LF:0`/`LH:0` placeholder
record for its sources. Treating that as "0% covered" would be misleading
and tank the threshold for files that are actually well-tested.
"""
if not include_prefixes:
return []
return [
r
for r in records
if r.lines_found > 0
and any(r.path.startswith(p) for p in include_prefixes)
]
def format_report(
records: List[FileCoverage], total_lh: int, total_lf: int, threshold: float
) -> str:
"""Render the per-file table + overall summary as a multi-line string."""
lines = []
lines.append(f"{'COV%':>8} {'LINES (hit/total)':<17} FILE")
lines.append(f"{'-' * 8:>8} {'-' * 17:<17} ----")
for r in sorted(records, key=lambda r: (r.pct, r.path)):
lines.append(
f"{r.pct:>7.2f}% {r.lines_hit:>5} / {r.lines_found:<7} {r.path}"
)
overall = (total_lh / total_lf * 100.0) if total_lf else 0.0
lines.append("")
lines.append(
f"Overall main-source line coverage: {overall:.2f}% ({total_lh} / {total_lf})"
)
lines.append(f"Threshold: {threshold:.2f}%")
return "\n".join(lines)
def badge_color(pct: float) -> str:
"""Pick a shields.io color band that visually tracks the project's 90% gate.
Anything at-or-above the gate is brightgreen; below the gate degrades through
yellow/orange/red so the badge becomes a quick visual signal of how far the
main-source coverage has drifted.
"""
if pct >= 90.0:
return "brightgreen"
if pct >= 75.0:
return "yellow"
if pct >= 60.0:
return "orange"
return "red"
def write_badge_json(path: str, overall_pct: float) -> None:
"""Write a shields.io endpoint badge JSON describing the overall coverage.
See https://shields.io/endpoint — the schema is `{schemaVersion, label,
message, color}`. Consumed by a README badge URL of the form
`https://img.shields.io/endpoint?url=…/coverage.json`.
"""
payload = {
"schemaVersion": 1,
"label": "coverage",
"message": f"{overall_pct:.1f}%",
"color": badge_color(overall_pct),
}
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f)
f.write("\n")
def generate_html_report(lcov_path: str, output_dir: str) -> str:
"""Render an annotated HTML coverage report from `lcov_path` into `output_dir`.
Returns the absolute path to the generated `index.html`. Raises FileNotFoundError
if `genhtml` is not on PATH (the LCOV `lcov` package; install via
`brew install lcov` on macOS or `apt-get install lcov` on Debian/Ubuntu) and
`subprocess.CalledProcessError` if genhtml itself fails.
The directory is created if it doesn't exist; existing files are overwritten
by genhtml's normal behaviour (it always re-emits the full report).
"""
if shutil.which("genhtml") is None:
raise FileNotFoundError(
"genhtml not found on PATH. Install lcov (brew install lcov / "
"apt-get install lcov) and retry."
)
os.makedirs(output_dir, exist_ok=True)
subprocess.run(
["genhtml", lcov_path, "--output-directory", output_dir, "--quiet"],
check=True,
)
return os.path.abspath(os.path.join(output_dir, "index.html"))
def _chdir_to_workspace_if_invoked_via_bazel_run() -> None:
"""`bazel run //tools:coverage-check -- bazel-out/_coverage/...` would otherwise
leave us in the runfiles directory and fail to find the LCOV file. When Bazel
sets `BUILD_WORKING_DIRECTORY` (the user's cwd at the time they ran the binary)
chdir to it so relative paths resolve the way the user expects.
"""
cwd = os.environ.get("BUILD_WORKING_DIRECTORY")
if cwd and os.path.isdir(cwd):
os.chdir(cwd)
def main(argv: List[str] | None = None) -> int:
_chdir_to_workspace_if_invoked_via_bazel_run()
parser = argparse.ArgumentParser(
description="Enforce a minimum line-coverage threshold on a Bazel LCOV report.",
)
parser.add_argument(
"lcov",
nargs="?",
default="bazel-out/_coverage/_coverage_report.dat",
help="Path to the LCOV report (default: %(default)s).",
)
parser.add_argument(
"--threshold",
"-t",
type=float,
default=float(os.environ.get("COVERAGE_THRESHOLD", "90")),
help=(
"Minimum overall line coverage percentage. "
"Default 90 (override with --threshold or $COVERAGE_THRESHOLD)."
),
)
parser.add_argument(
"--include",
default=os.environ.get(
"COVERAGE_INCLUDE", "cli/src/main/,tools/coverage_check.py"
),
help=(
"Comma-separated path prefixes counted as production code "
"(default: cli/src/main/,tools/coverage_check.py)."
),
)
parser.add_argument(
"--html",
metavar="DIR",
help=(
"Also write an annotated HTML coverage report to DIR (requires "
"`genhtml`: brew install lcov / apt-get install lcov). The "
"threshold check still runs and still gates the exit code; --html "
"only adds an additional artifact for interactive inspection."
),
)
parser.add_argument(
"--badge-json",
metavar="PATH",
help=(
"Also write a shields.io endpoint JSON describing the overall "
"main-source coverage to PATH. Consumed by the README badge "
"(https://img.shields.io/endpoint?url=…). Written regardless of "
"whether the threshold passes so a regression is visible on the "
"badge instead of being silently skipped."
),
)
args = parser.parse_args(argv)
if not os.path.isfile(args.lcov):
print(f"error: LCOV report not found at '{args.lcov}'.", file=sys.stderr)
print(
"Hint: run 'bazel coverage --combined_report=lcov //cli/... //tools/...' first,",
file=sys.stderr,
)
print(" or pass an explicit path as the first argument.", file=sys.stderr)
return 2
with open(args.lcov, "r", encoding="utf-8", errors="replace") as f:
all_records = parse_lcov(f.read())
include_prefixes = [p.strip() for p in args.include.split(",") if p.strip()]
records = filter_main_source(all_records, include_prefixes)
total_lf = sum(r.lines_found for r in records)
total_lh = sum(r.lines_hit for r in records)
if total_lf == 0:
print(
f"error: no instrumented production-source lines found in {args.lcov}.",
file=sys.stderr,
)
print(f" include prefixes: {include_prefixes!r}", file=sys.stderr)
return 2
print(format_report(records, total_lh, total_lf, args.threshold))
# Generate the HTML report before the threshold gate so an interactive
# investigation has the report to look at even when the gate fails -- the
# below-threshold case is exactly when the report is most useful.
if args.html:
try:
index_path = generate_html_report(args.lcov, args.html)
print(f"HTML report: {index_path}")
except FileNotFoundError as e:
# Fail loudly: the user explicitly asked for HTML and we couldn't
# produce it. Better to bail than to silently skip and confuse them.
print(f"error: {e}", file=sys.stderr)
return 2
except subprocess.CalledProcessError as e:
print(f"error: genhtml exited with code {e.returncode}.", file=sys.stderr)
return 2
overall = total_lh / total_lf * 100.0
if args.badge_json:
write_badge_json(args.badge_json, overall)
# Allow a tiny epsilon so 89.99999... that displays as 90.00 still passes a 90
# threshold. Use the raw value, not the rounded one, otherwise 89.99 would slip
# through.
if overall + 1e-9 < args.threshold:
print(
f"FAIL: coverage {overall:.2f}% is below threshold {args.threshold:.2f}%.",
file=sys.stderr,
)
return 1
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())