Coverage for scripts/render_badges.py: 100%

97 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 06:18 +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 [--python-versions 3.12,3.13] [--ci-workflow rhiza_ci.yml] \ 

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

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

22""" 

23 

24from __future__ import annotations 

25 

26import argparse 

27import json 

28import sys 

29from typing import Any 

30 

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

32 

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

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

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

36 

37 

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

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

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

41 

42 

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

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

45 if host == "gitlab": 

46 return _md( 

47 "Release", 

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

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

50 ) 

51 return _md( 

52 "Release", 

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

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

55 ) 

56 

57 

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

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

60 if not template_ref: 

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

62 return [ 

63 _md( 

64 f"rhiza {template_ref}", 

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

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

67 ) 

68 ], [] 

69 

70 

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

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

73 if not license_id: 

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

75 return [ 

76 _md( 

77 f"License: {license_id}", 

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

79 "LICENSE", 

80 ) 

81 ], [] 

82 

83 

84def _python_section(python_versions: list[str]) -> Section: 

85 """The supported-Python-versions badge.""" 

86 if not python_versions: 

87 return [], ["python versions: not a Python project"] 

88 joined = " • ".join(python_versions) 

89 return [ 

90 _md( 

91 "Python versions", 

92 f"{_SHIELDS}/badge/Python-{joined}-blue?logo=python", 

93 "https://www.python.org/", 

94 ) 

95 ], [] 

96 

97 

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

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

100 if gitlab: 

101 return [ 

102 _md( 

103 "pipeline", 

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

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

106 ) 

107 ], [] 

108 if not ci_workflow: 

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

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

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

112 

113 

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

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

116 if coverage == "codecov": 

117 return [ 

118 _md( 

119 "codecov", 

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

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

122 ) 

123 ], [] 

124 if coverage == "gitlab": 

125 return [ 

126 _md( 

127 "coverage", 

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

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

130 ) 

131 ], [] 

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

133 

134 

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

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

137 badges: list[str] = [] 

138 if uses_ruff: 

139 badges.append( 

140 _md( 

141 "Code style: ruff", 

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

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

144 ) 

145 ) 

146 if uses_uv: 

147 badges.append( 

148 _md( 

149 "uv", 

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

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

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

153 ) 

154 ) 

155 return badges, [] 

156 

157 

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

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

160 if gitlab: 

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

162 

163 badges = [ 

164 _md( 

165 "CodeFactor", 

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

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

168 ) 

169 ] 

170 skipped: list[str] = [] 

171 

172 if public: 

173 badges.append( 

174 _md( 

175 "OpenSSF Scorecard", 

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

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

178 ) 

179 ) 

180 else: 

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

182 

183 if codespaces: 

184 badges.append( 

185 _md( 

186 "Open in GitHub Codespaces", 

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

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

189 ) 

190 ) 

191 return badges, skipped 

192 

193 

194def build_badges( 

195 *, 

196 host: str, 

197 owner: str, 

198 repo: str, 

199 branch: str, 

200 license_id: str | None, 

201 python_versions: list[str], 

202 ci_workflow: str | None, 

203 template_ref: str | None, 

204 coverage: str | None, 

205 uses_ruff: bool, 

206 uses_uv: bool, 

207 public: bool, 

208 codespaces: bool, 

209) -> dict[str, Any]: 

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

211 

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

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

214 below is the order they appear in the README. 

215 """ 

216 gitlab = host == "gitlab" 

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

218 sections: list[Section] = [ 

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

220 _template_section(template_ref), 

221 _license_section(license_id), 

222 _python_section(python_versions), 

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

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

225 _tooling_section(uses_ruff=uses_ruff, uses_uv=uses_uv), 

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

227 ] 

228 

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

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

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

232 

233 

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

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

236 if not badges: 

237 return "" 

238 head, *rest = badges 

239 if not rest: 

240 return head + "\n" 

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

242 

243 

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

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

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

247 

248 

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

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

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

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

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

254 parser.add_argument( 

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

256 ) 

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

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

259 parser.add_argument("--python-versions", help="Comma-separated, e.g. 3.12,3.13.") 

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

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

262 parser.add_argument( 

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

264 ) 

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

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

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

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

269 parser.add_argument( 

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

271 ) 

272 args = parser.parse_args(argv) 

273 

274 summary = build_badges( 

275 host=args.host, 

276 owner=args.owner, 

277 repo=args.repo, 

278 branch=args.branch, 

279 license_id=args.license_id, 

280 python_versions=_split_csv(args.python_versions), 

281 ci_workflow=args.ci_workflow, 

282 template_ref=args.template_ref, 

283 coverage=args.coverage, 

284 uses_ruff=args.uses_ruff, 

285 uses_uv=args.uses_uv, 

286 public=args.public, 

287 codespaces=args.codespaces, 

288 ) 

289 

290 if args.json_output: 

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

292 else: 

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

294 for reason in summary["skipped"]: 

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

296 return 0 

297 

298 

299if __name__ == "__main__": 

300 raise SystemExit(main())