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
« 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`.
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 [--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"""
25from __future__ import annotations
27import argparse
28import json
29import sys
30from typing import Any
32_SHIELDS = "https://img.shields.io"
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]]
39def _md(label: str, image: str, link: str) -> str:
40 """Render one markdown badge (an image wrapped in a link)."""
41 return f"[]({link})"
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 )
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 ], []
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 ], []
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}
95def _language_section(language: str | None, versions: list[str]) -> Section:
96 """The language-and-version badge, for whichever language the repo is.
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 ], []
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)], []
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"]
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, []
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"]
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] = []
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")
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
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.
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 ]
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)}
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"
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()]
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)
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 )
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
329if __name__ == "__main__":
330 raise SystemExit(main())