GRM-45: fix: generate self-contained SVG badges instead of shields.io JSON

shields.io can't fetch JSON from our self-hosted Gitea instance (not
publicly reachable), so badges showed "unknown". Switched to generating
self-contained SVG badge files that are served directly by Gitea's raw
file API — no external service needed.

Changes:
- generate_badges.py: Added render_svg() to produce shields.io-style
  SVG badges with gradient, rounded corners, and Verdana font
- Replaced xml.sax.saxutils.escape with a simple _xml_escape() to
  avoid bandit B406 warning (no defusedxml dependency needed)
- CI workflow: Push .svg files instead of .json to badges branch
- README.md and docs/index.md: Updated badge URLs to use raw SVG
  from the badges branch instead of shields.io endpoint

Also fixed:
- Coverage regex now handles 100% without decimal (was 100.00%)
- doc_coverage.py: Removed redundant % in pct variable that caused
  double-percent (100%%) in output

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Emil Simeonov
2026-06-22 00:50:22 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 28b4acf323
commit 749b4d025f
5 changed files with 129 additions and 53 deletions
+69 -24
View File
@@ -1,15 +1,9 @@
#!/usr/bin/env python3
"""Generate shields.io endpoint badge JSON files from project metrics.
"""Generate self-contained SVG badge files from project metrics.
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
then writes JSON files in the shields.io endpoint format:
{
"schemaVersion": 1,
"label": "coverage",
"message": "100%",
"color": "brightgreen"
}
then writes SVG badge files that can be served as static files from
the Gitea raw file API.
Usage:
python3 scripts/generate_badges.py --output-dir .badges/
@@ -17,7 +11,6 @@ Usage:
from __future__ import annotations
import json
import re
import subprocess # nosec B404
from pathlib import Path
@@ -31,15 +24,22 @@ _COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed")
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
# shields.io color names to hex values
COLOR_HEX: dict[str, str] = {
"brightgreen": "#4c1",
"green": "#97ca00",
"yellowgreen": "#a4a61d",
"yellow": "#dfb317",
"orange": "#fe7d37",
"red": "#e05d44",
"blue": "#007ec6",
"lightgrey": "#9f9f9f",
}
def make_badge(label: str, message: str, color: str) -> dict[str, str | int]:
"""Build a shields.io endpoint badge dict."""
return {
"schemaVersion": 1,
"label": label,
"message": message,
"color": color,
}
def _xml_escape(text: str) -> str:
"""Escape XML special characters."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def run_command(cmd: list[str]) -> tuple[int, str, str]:
@@ -54,6 +54,50 @@ def run_command(cmd: list[str]) -> tuple[int, str, str]:
return result.returncode, result.stdout, result.stderr
def make_badge(label: str, message: str, color: str) -> dict[str, str | int]:
"""Build a badge data dict."""
return {
"schemaVersion": 1,
"label": label,
"message": message,
"color": color,
}
def render_svg(label: str, message: str, color: str) -> str:
"""Render a shields.io-style SVG badge."""
color_hex = COLOR_HEX.get(color, color if color.startswith("#") else "#9f9f9f")
# Approximate text width: 7px per character + 10px padding
label_text = _xml_escape(label)
message_text = _xml_escape(message)
label_w = max(len(label) * 7 + 10, 30)
message_w = max(len(message) * 7 + 10, 30)
total_w = label_w + message_w
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" role="img"
aria-label="{label_text}: {message_text}">
<title>{label_text}: {message_text}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="{label_w}" height="20" fill="#555"/>
<rect x="{label_w}" width="{message_w}" height="20" fill="{color_hex}"/>
<rect width="{total_w}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="{label_w // 2}" y="14">{label_text}</text>
<text x="{label_w + message_w // 2}" y="14">{message_text}</text>
</g>
</svg>
'''
def extract_coverage(output: str) -> float | None:
"""Extract total coverage percentage from pytest-cov output."""
for line in output.splitlines():
@@ -118,7 +162,7 @@ def doc_coverage_color(pct: int) -> str:
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
"""Generate all badge JSON files and return them as a dict."""
"""Generate all badge SVG files and return badge data as a dict."""
badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run)
@@ -176,11 +220,12 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
# 5. Python version (static but nice)
badges["python"] = make_badge("python", "3.12", "blue")
# Write JSON files
# Write SVG files
output_dir.mkdir(parents=True, exist_ok=True)
for name, badge in badges.items():
path = output_dir / f"{name}.json"
path.write_text(json.dumps(badge, indent=2) + "\n")
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
path = output_dir / f"{name}.svg"
path.write_text(svg)
click.echo(f" Generated: {path}")
return badges
@@ -190,10 +235,10 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
@click.option(
"--output-dir",
default=str(REPO_ROOT / ".badges"),
help="Directory to write badge JSON files.",
help="Directory to write badge SVG files.",
)
def cli(output_dir: str) -> None:
"""Generate shields.io endpoint badge JSON files."""
"""Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir)
click.echo(f"Generating badges in {out}...")
badges = generate_badges(out)