Coverage for plugin/scripts/render_badges.py: 100%

106 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Render a repo's README badge block — the engine behind `/rhiza:docs`. 

3 

4Badges are *generated*, not hand-authored: every URL follows from facts about the 

5repo (platform, owner/repo, default branch, language, license, CI workflow, coverage 

6service). Keeping the templates here rather than in prose means the set is 

7deterministic, ordered, and testable — and that the **omit, don't fake** rule is 

8enforced by code: a badge whose backing fact is absent is never emitted, so a README 

9never advertises a workflow, license, or coverage service that isn't there. 

10 

11Detection is the caller's job (it has the repo in front of it); this script takes the 

12facts as flags and renders the block. Pass `--json` for the badge list plus the 

13reasons anything was skipped, so the command can report them. 

14 

15Usage: 

16 uv run --python 3.12 --no-project python \ 

17 scripts/render_badges.py --owner OWNER --repo REPO \ 

18 [--host github|gitlab] [--branch main] [--license MIT] \ 

19 [--language python|go|rust] [--language-versions 3.12,3.13] \ 

20 [--python-versions 3.12,3.13] [--ci-workflow rhiza_ci.yml] \ 

21 [--template-ref v1.1.3] [--coverage codecov|gitlab] \ 

22 [--uses-ruff] [--uses-uv] [--public] [--codespaces] [--json] 

23""" 

24 

25from __future__ import annotations 

26 

27import argparse 

28import json 

29import sys 

30from typing import Any 

31 

32_SHIELDS = "https://img.shields.io" 

33 

34# What one badge group contributes: the badges it emits, and the reasons it skipped 

35# any it couldn't back with a fact. Either list may be empty. 

36Section = tuple[list[str], list[str]] 

37 

38 

39def _md(label: str, image: str, link: str) -> str: 

40 """Render one markdown badge (an image wrapped in a link).""" 

41 return f"[![{label}]({image})]({link})" 

42 

43 

44def release_badge(host: str, owner: str, repo: str) -> str: 

45 """The repo's own latest-release badge.""" 

46 if host == "gitlab": 

47 return _md( 

48 "Release", 

49 f"{_SHIELDS}/gitlab/v/release/{owner}%2F{repo}", 

50 f"https://gitlab.com/{owner}/{repo}/-/releases", 

51 ) 

52 return _md( 

53 "Release", 

54 f"{_SHIELDS}/github/v/release/{owner}/{repo}?sort=semver", 

55 f"https://github.com/{owner}/{repo}/releases", 

56 ) 

57 

58 

59def _template_section(template_ref: str | None) -> Section: 

60 """The rhiza template-version badge.""" 

61 if not template_ref: 

62 return [], ["template version: no ref in .rhiza/template.yml"] 

63 return [ 

64 _md( 

65 f"rhiza {template_ref}", 

66 f"{_SHIELDS}/badge/rhiza-{template_ref}-blue", 

67 f"https://github.com/jebel-quant/rhiza/releases/tag/{template_ref}", 

68 ) 

69 ], [] 

70 

71 

72def _license_section(license_id: str | None) -> Section: 

73 """The license badge, pointing at the repo's own LICENSE file.""" 

74 if not license_id: 

75 return [], ["license: no LICENSE file detected"] 

76 return [ 

77 _md( 

78 f"License: {license_id}", 

79 f"{_SHIELDS}/badge/License-{license_id}-green.svg", 

80 "LICENSE", 

81 ) 

82 ], [] 

83 

84 

85# The language badge, per language: the shields label, colour, logo slug and the link. 

86# One table rather than one function each, because the badges differ only in these four 

87# values — and a table is what makes adding a language a one-line change. 

88_LANGUAGE_BADGES = { 

89 "python": ("Python", "blue", "python", "https://www.python.org/"), 

90 "go": ("Go", "00ADD8", "go", "https://go.dev/"), 

91 "rust": ("Rust", "000000", "rust", "https://www.rust-lang.org/"), 

92} 

93 

94 

95def _language_section(language: str | None, versions: list[str]) -> Section: 

96 """The language-and-version badge, for whichever language the repo is. 

97 

98 Was Python-only, which is why a Go repo's README came back with a 

99 "not a Python project" skip note and no language badge at all — technically 

100 truthful, and useless. 

101 """ 

102 if not language: 

103 return [], ["language badge: no language detected"] 

104 entry = _LANGUAGE_BADGES.get(language) 

105 if entry is None: 

106 return [], [f"language badge: no badge defined for {language}"] 

107 label, colour, logo, url = entry 

108 if not versions: 

109 return [], [f"{language} version: not detected"] 

110 joined = " • ".join(versions) 

111 return [ 

112 _md(f"{label} versions", f"{_SHIELDS}/badge/{label}-{joined}-{colour}?logo={logo}", url) 

113 ], [] 

114 

115 

116def _ci_section(*, gitlab: bool, slug: str, branch: str, ci_workflow: str | None) -> Section: 

117 """The CI badge — a GitLab pipeline, or a named GitHub Actions workflow.""" 

118 if gitlab: 

119 return [ 

120 _md( 

121 "pipeline", 

122 f"https://gitlab.com/{slug}/badges/{branch}/pipeline.svg", 

123 f"https://gitlab.com/{slug}/-/pipelines", 

124 ) 

125 ], [] 

126 if not ci_workflow: 

127 return [], ["CI: no workflow file found in .github/workflows"] 

128 base = f"https://github.com/{slug}/actions/workflows/{ci_workflow}" 

129 return [_md("CI", f"{base}/badge.svg?event=push", base)], [] 

130 

131 

132def _coverage_section(*, coverage: str | None, slug: str, branch: str) -> Section: 

133 """The coverage badge for whichever service was detected.""" 

134 if coverage == "codecov": 

135 return [ 

136 _md( 

137 "codecov", 

138 f"https://codecov.io/gh/{slug}/branch/{branch}/graph/badge.svg", 

139 f"https://codecov.io/gh/{slug}", 

140 ) 

141 ], [] 

142 if coverage == "gitlab": 

143 return [ 

144 _md( 

145 "coverage", 

146 f"https://gitlab.com/{slug}/badges/{branch}/coverage.svg", 

147 f"https://gitlab.com/{slug}/-/commits/{branch}", 

148 ) 

149 ], [] 

150 return [], ["coverage: no coverage service detected"] 

151 

152 

153def _tooling_section(*, uses_ruff: bool, uses_uv: bool) -> Section: 

154 """Badges for the tooling the repo uses. Optional extras — nothing is ever skipped.""" 

155 badges: list[str] = [] 

156 if uses_ruff: 

157 badges.append( 

158 _md( 

159 "Code style: ruff", 

160 f"{_SHIELDS}/badge/code%20style-ruff-000000.svg?logo=ruff", 

161 "https://github.com/astral-sh/ruff", 

162 ) 

163 ) 

164 if uses_uv: 

165 badges.append( 

166 _md( 

167 "uv", 

168 f"{_SHIELDS}/endpoint?url=https://raw.githubusercontent.com/" 

169 "astral-sh/uv/main/assets/badge/v0.json", 

170 "https://github.com/astral-sh/uv", 

171 ) 

172 ) 

173 return badges, [] 

174 

175 

176def _github_services_section(*, gitlab: bool, slug: str, public: bool, codespaces: bool) -> Section: 

177 """Badges for the GitHub-only services — all of them omitted on GitLab.""" 

178 if gitlab: 

179 return [], ["CodeFactor, OpenSSF Scorecard, Codespaces: GitHub-only"] 

180 

181 badges = [ 

182 _md( 

183 "CodeFactor", 

184 f"https://www.codefactor.io/repository/github/{slug}/badge", 

185 f"https://www.codefactor.io/repository/github/{slug}", 

186 ) 

187 ] 

188 skipped: list[str] = [] 

189 

190 if public: 

191 badges.append( 

192 _md( 

193 "OpenSSF Scorecard", 

194 f"https://api.scorecard.dev/projects/github.com/{slug}/badge", 

195 f"https://scorecard.dev/viewer/?uri=github.com/{slug}", 

196 ) 

197 ) 

198 else: 

199 skipped.append("OpenSSF Scorecard: only meaningful for a public repo") 

200 

201 if codespaces: 

202 badges.append( 

203 _md( 

204 "Open in GitHub Codespaces", 

205 "https://github.com/codespaces/badge.svg", 

206 f"https://codespaces.new/{slug}", 

207 ) 

208 ) 

209 return badges, skipped 

210 

211 

212def build_badges( 

213 *, 

214 host: str, 

215 owner: str, 

216 repo: str, 

217 branch: str, 

218 license_id: str | None, 

219 language: str | None, 

220 language_versions: list[str], 

221 ci_workflow: str | None, 

222 template_ref: str | None, 

223 coverage: str | None, 

224 uses_ruff: bool, 

225 uses_uv: bool, 

226 public: bool, 

227 codespaces: bool, 

228) -> dict[str, Any]: 

229 """Build the ordered badge list plus the reasons any standard badge was skipped. 

230 

231 Each section decides for itself whether its fact was detected, so the **omit, 

232 don't fake** rule lives next to the badge it governs; the order they appear in 

233 below is the order they appear in the README. 

234 """ 

235 gitlab = host == "gitlab" 

236 slug = f"{owner}/{repo}" 

237 sections: list[Section] = [ 

238 ([release_badge(host, owner, repo)], []), 

239 _template_section(template_ref), 

240 _license_section(license_id), 

241 _language_section(language, language_versions), 

242 _ci_section(gitlab=gitlab, slug=slug, branch=branch, ci_workflow=ci_workflow), 

243 _coverage_section(coverage=coverage, slug=slug, branch=branch), 

244 _tooling_section(uses_ruff=uses_ruff, uses_uv=uses_uv), 

245 _github_services_section(gitlab=gitlab, slug=slug, public=public, codespaces=codespaces), 

246 ] 

247 

248 badges = [badge for section, _ in sections for badge in section] 

249 skipped = [reason for _, reasons in sections for reason in reasons] 

250 return {"badges": badges, "skipped": skipped, "block": render_block(badges)} 

251 

252 

253def render_block(badges: list[str]) -> str: 

254 """Render the badge block: the release badge alone, then the rest together.""" 

255 if not badges: 

256 return "" 

257 head, *rest = badges 

258 if not rest: 

259 return head + "\n" 

260 return head + "\n" + "\n".join(rest) + "\n" 

261 

262 

263def _split_csv(raw: str | None) -> list[str]: 

264 """Split a comma-separated flag value into a clean list.""" 

265 return [part.strip() for part in (raw or "").split(",") if part.strip()] 

266 

267 

268def main(argv: list[str] | None = None) -> int: 

269 """Entry point: render the badge block and return an exit code.""" 

270 parser = argparse.ArgumentParser(description="Render a README badge block.") 

271 parser.add_argument("--owner", required=True, help="Repository owner / namespace.") 

272 parser.add_argument("--repo", required=True, help="Repository name.") 

273 parser.add_argument( 

274 "--host", choices=("github", "gitlab"), default="github", help="Hosting platform." 

275 ) 

276 parser.add_argument("--branch", default="main", help="Default branch (default: main).") 

277 parser.add_argument("--license", dest="license_id", help="SPDX id; omit if unlicensed.") 

278 parser.add_argument( 

279 "--language", 

280 choices=tuple(_LANGUAGE_BADGES), 

281 help="Repo language; implied by --python-versions for backwards compatibility.", 

282 ) 

283 parser.add_argument( 

284 "--language-versions", help="Comma-separated versions for --language, e.g. 1.22 or 2021." 

285 ) 

286 parser.add_argument( 

287 "--python-versions", help="Comma-separated, e.g. 3.12,3.13 (shorthand for Python)." 

288 ) 

289 parser.add_argument("--ci-workflow", help="CI workflow filename, e.g. rhiza_ci.yml.") 

290 parser.add_argument("--template-ref", help="Template ref from .rhiza/template.yml.") 

291 parser.add_argument( 

292 "--coverage", choices=("codecov", "gitlab"), help="Detected coverage service." 

293 ) 

294 parser.add_argument("--uses-ruff", action="store_true", help="Repo lints with ruff.") 

295 parser.add_argument("--uses-uv", action="store_true", help="Repo uses uv.") 

296 parser.add_argument("--public", action="store_true", help="Repo is public.") 

297 parser.add_argument("--codespaces", action="store_true", help="Offer a Codespaces badge.") 

298 parser.add_argument( 

299 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON." 

300 ) 

301 args = parser.parse_args(argv) 

302 

303 summary = build_badges( 

304 host=args.host, 

305 owner=args.owner, 

306 repo=args.repo, 

307 branch=args.branch, 

308 license_id=args.license_id, 

309 language=args.language or ("python" if args.python_versions else None), 

310 language_versions=_split_csv(args.language_versions or args.python_versions), 

311 ci_workflow=args.ci_workflow, 

312 template_ref=args.template_ref, 

313 coverage=args.coverage, 

314 uses_ruff=args.uses_ruff, 

315 uses_uv=args.uses_uv, 

316 public=args.public, 

317 codespaces=args.codespaces, 

318 ) 

319 

320 if args.json_output: 

321 print(json.dumps(summary, indent=2)) 

322 else: 

323 print(summary["block"], end="") 

324 for reason in summary["skipped"]: 

325 print(f"omitted {reason}", file=sys.stderr) 

326 return 0 

327 

328 

329if __name__ == "__main__": 

330 raise SystemExit(main())