Coverage for .github / scripts / render_coverage_badges.py: 100.00%
78 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Render the shields.io endpoint JSON for the README's three coverage badges.
3``pages.yml`` publishes one coverage report per test stack and, next to each,
4the badge JSON the README's ``img.shields.io/endpoint`` badges read. The three
5numbers come from three different tools, so they are read here, in one place,
6and written in one schema:
8``python-coverage-badge.json``
9 from ``coverage.json`` (coverage.py's JSON report, shipped in the
10 ``pytest-coverage`` artifact): ``totals.percent_covered``, which counts
11 statements and branches together because ``[tool.coverage.run]`` measures
12 branches.
14``bash-coverage-badge.json``
15 from ``summary.json`` written by ``check_bash_coverage.py --report``
16 (shipped in the ``bash-coverage-report`` artifact): the statement coverage
17 after the checker's lexer corrections — the number the gate enforces, not
18 SimpleCov's raw line count. A summary recording a failed floor is refused
19 rather than badged.
21``nodejs-coverage-badge.json``
22 from the ``lcov.info`` Node's test runner writes (``--test-reporter=lcov``,
23 shipped in the ``node-inference-streaming-proxy-coverage`` artifact): lines
24 and branches together (``LH+BRH`` over ``LF+BRF``), the same combination
25 coverage.py reports, so the three badges measure alike.
27Every badge is bright green at exactly 100% and red below it. That is the same
28floor the three test jobs enforce (``--cov-fail-under=100``, the shell checker,
29``--test-coverage-lines=100`` and friends), so the badge never introduces a
30second threshold: green means the gate would pass on this measurement.
32Usage::
34 python3 .github/scripts/render_coverage_badges.py \\
35 --python coverage-data/coverage.json \\
36 --bash bash-coverage-data/report/summary.json \\
37 --node node-coverage-data/lcov.info \\
38 --out site
40Exit codes::
42 0 the three badge files were written
43 2 an input is missing, unreadable or not the document it should be
44"""
46from __future__ import annotations
48import argparse
49import json
50import sys
51from pathlib import Path
53FLOOR = 100.0
55BADGES: tuple[tuple[str, str, str], ...] = (
56 # (command-line option, badge label, output file name)
57 ("python", "python coverage", "python-coverage-badge.json"),
58 ("bash", "bash coverage", "bash-coverage-badge.json"),
59 ("node", "node.js coverage", "nodejs-coverage-badge.json"),
60)
63class BadgeError(Exception):
64 """An input could not be read or does not carry the number it should."""
67def _read_json(path: Path, what: str) -> dict[str, object]:
68 try:
69 raw = json.loads(path.read_text(encoding="utf-8"))
70 except (OSError, json.JSONDecodeError) as exc:
71 raise BadgeError(f"could not read {what} from {path}: {exc}") from exc
72 if not isinstance(raw, dict):
73 raise BadgeError(f"{path} is not {what} (expected a JSON object)")
74 return raw
77def _number(value: object, where: str) -> float:
78 if isinstance(value, bool) or not isinstance(value, (int, float)):
79 raise BadgeError(f"{where} is not a number: {value!r}")
80 return float(value)
83def python_percent(path: Path) -> float:
84 """``totals.percent_covered`` from coverage.py's JSON report."""
85 data = _read_json(path, "coverage.py's JSON report")
86 totals = data.get("totals")
87 if not isinstance(totals, dict) or "percent_covered" not in totals:
88 raise BadgeError(
89 f"{path} has no totals.percent_covered; is it coverage.py's coverage.json?"
90 )
91 return _number(totals["percent_covered"], f"{path}: totals.percent_covered")
94def bash_percent(path: Path) -> float:
95 """``percent`` from the shell checker's summary, refusing a failed floor."""
96 data = _read_json(path, "the shell coverage summary")
97 if data.get("ok") is not True:
98 raise BadgeError(
99 f"{path} records a run that did not pass the shell coverage floor "
100 f"(ok={data.get('ok')!r}); a badge is not rendered for it"
101 )
102 if "percent" not in data:
103 raise BadgeError(f"{path} has no percent; is it check_bash_coverage.py's summary.json?")
104 return _number(data["percent"], f"{path}: percent")
107def node_percent(path: Path) -> float:
108 """Lines and branches together from an lcov tracefile.
110 ``LF``/``LH`` are the lines found and hit per source file, ``BRF``/``BRH``
111 the branches. The tracefile is summed across every ``SF:`` record, so a
112 second instrumented file would count too; today there is one.
113 """
114 try:
115 text = path.read_text(encoding="utf-8")
116 except OSError as exc:
117 raise BadgeError(f"could not read the lcov tracefile {path}: {exc}") from exc
118 totals = {"LF": 0, "LH": 0, "BRF": 0, "BRH": 0}
119 records = 0
120 for line in text.splitlines():
121 key, _sep, value = line.partition(":")
122 if key == "SF":
123 records += 1
124 elif key in totals:
125 try:
126 totals[key] += int(value)
127 except ValueError as exc:
128 raise BadgeError(f"{path}: malformed lcov line {line!r}") from exc
129 if records == 0:
130 raise BadgeError(f"{path} holds no SF: records; is it an lcov tracefile?")
131 found = totals["LF"] + totals["BRF"]
132 if found == 0:
133 raise BadgeError(f"{path} reports no lines or branches, so there is nothing to measure")
134 return 100.0 * (totals["LH"] + totals["BRH"]) / found
137def badge(label: str, percent: float) -> dict[str, object]:
138 """The shields.io endpoint document for one badge."""
139 return {
140 "schemaVersion": 1,
141 "label": label,
142 "message": f"{percent:.1f}%",
143 "color": "brightgreen" if percent >= FLOOR else "red",
144 }
147READERS = {"python": python_percent, "bash": bash_percent, "node": node_percent}
150def main(argv: list[str] | None = None) -> int:
151 parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
152 parser.add_argument("--python", type=Path, required=True, help="coverage.py's coverage.json")
153 parser.add_argument(
154 "--bash", type=Path, required=True, help="check_bash_coverage.py's summary.json"
155 )
156 parser.add_argument("--node", type=Path, required=True, help="Node's lcov.info tracefile")
157 parser.add_argument(
158 "--out", type=Path, required=True, help="directory the three badge files are written to"
159 )
160 args = parser.parse_args(argv)
162 rendered: list[tuple[Path, dict[str, object]]] = []
163 try:
164 for option, label, name in BADGES:
165 percent = READERS[option](getattr(args, option))
166 rendered.append((args.out / name, badge(label, percent)))
167 except BadgeError as exc:
168 print(f"ERROR: {exc}", file=sys.stderr)
169 return 2
171 # Nothing is written until every input has been read, so a broken input
172 # leaves no half-rendered set behind for the deploy to publish.
173 args.out.mkdir(parents=True, exist_ok=True)
174 for path, document in rendered:
175 path.write_text(json.dumps(document) + "\n", encoding="utf-8")
176 print(f"{path.name}: {document['label']} {document['message']} ({document['color']})")
177 return 0
180if __name__ == "__main__":
181 sys.exit(main())