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
« 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`.
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.
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.
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"""
24from __future__ import annotations
26import argparse
27import json
28import sys
29from typing import Any
31_SHIELDS = "https://img.shields.io"
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]]
38def _md(label: str, image: str, link: str) -> str:
39 """Render one markdown badge (an image wrapped in a link)."""
40 return f"[]({link})"
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 )
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 ], []
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 ], []
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 ], []
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)], []
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"]
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, []
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"]
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] = []
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")
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
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.
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 ]
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)}
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"
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()]
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)
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 )
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
299if __name__ == "__main__":
300 raise SystemExit(main())