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

1"""Render the shields.io endpoint JSON for the README's three coverage badges. 

2 

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: 

7 

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. 

13 

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. 

20 

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. 

26 

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. 

31 

32Usage:: 

33 

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 

39 

40Exit codes:: 

41 

42 0 the three badge files were written 

43 2 an input is missing, unreadable or not the document it should be 

44""" 

45 

46from __future__ import annotations 

47 

48import argparse 

49import json 

50import sys 

51from pathlib import Path 

52 

53FLOOR = 100.0 

54 

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) 

61 

62 

63class BadgeError(Exception): 

64 """An input could not be read or does not carry the number it should.""" 

65 

66 

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 

75 

76 

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) 

81 

82 

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") 

92 

93 

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") 

105 

106 

107def node_percent(path: Path) -> float: 

108 """Lines and branches together from an lcov tracefile. 

109 

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 

135 

136 

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 } 

145 

146 

147READERS = {"python": python_percent, "bash": bash_percent, "node": node_percent} 

148 

149 

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) 

161 

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 

170 

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 

178 

179 

180if __name__ == "__main__": 

181 sys.exit(main())