Files
grm/scripts/generate_badges.py
T
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 749b4d025f 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>
2026-06-22 00:50:22 +02:00

252 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Generate self-contained SVG badge files from project metrics.
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
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/
"""
from __future__ import annotations
import re
import subprocess # nosec B404
from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parent.parent
INIT_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "__init__.py"
_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 _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]:
"""Run a command and return (returncode, stdout, stderr)."""
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
cwd=str(REPO_ROOT),
)
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():
match = _COVERAGE_RE.search(line)
if match:
return float(match.group(1))
return None
def extract_test_count(output: str) -> int | None:
"""Extract number of passed tests from pytest output."""
for line in output.splitlines():
match = _PASSED_RE.search(line)
if match:
return int(match.group(1))
return None
def extract_doc_coverage(output: str) -> int | None:
"""Extract doc coverage percentage from doc_coverage.py output."""
match = _DOC_COVERAGE_RE.search(output)
if match:
return int(match.group(1))
return None
def read_version() -> str:
"""Read __version__ from the package __init__.py."""
content = INIT_FILE.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
return "unknown"
def coverage_color(pct: float) -> str:
"""Map coverage percentage to a color."""
if pct >= 100:
return "brightgreen"
if pct >= 90:
return "green"
if pct >= 80:
return "yellowgreen"
if pct >= 70:
return "yellow"
if pct >= 60:
return "orange"
return "red"
def doc_coverage_color(pct: int) -> str:
"""Map doc coverage percentage to a color."""
if pct >= 100:
return "brightgreen"
if pct >= 90:
return "green"
if pct >= 80:
return "yellowgreen"
if pct >= 70:
return "yellow"
return "orange"
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
"""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)
rc, stdout, stderr = run_command(
[
".venv/bin/pytest",
"tests/",
"-v",
"--cov=src/gitea_runner_manager",
"--cov=scripts",
"--cov-report=term-missing",
"--cov-fail-under=0",
]
)
combined = stdout + "\n" + stderr
coverage = extract_coverage(combined)
if coverage is not None:
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else:
badges["coverage"] = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined)
if test_count is not None:
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else:
badges["tests"] = make_badge("tests", "unknown", "red")
# 2. Documentation coverage
rc, stdout, _ = run_command(
[
".venv/bin/python3",
"scripts/ci/doc_coverage.py",
]
)
doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None:
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
else:
badges["docs"] = make_badge("docs", "unknown", "red")
# 3. Code quality (ruff + pyright + bandit all pass)
lint_rc, _, _ = run_command([".venv/bin/ruff", "check", "src/", "tests/", "scripts/"])
format_rc, _, _ = run_command([".venv/bin/ruff", "format", "--check", "src/", "tests/", "scripts/"])
type_rc, _, _ = run_command([".venv/bin/pyright"])
bandit_rc, _, _ = run_command([".venv/bin/bandit", "-r", "src/", "scripts/"])
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
# 4. Version
version = read_version()
badges["version"] = make_badge("version", f"v{version}", "blue")
# 5. Python version (static but nice)
badges["python"] = make_badge("python", "3.12", "blue")
# Write SVG files
output_dir.mkdir(parents=True, exist_ok=True)
for name, badge in badges.items():
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
@click.command()
@click.option(
"--output-dir",
default=str(REPO_ROOT / ".badges"),
help="Directory to write badge SVG files.",
)
def cli(output_dir: str) -> None:
"""Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir)
click.echo(f"Generating badges in {out}...")
badges = generate_badges(out)
click.echo(f"\nGenerated {len(badges)} badges:")
for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover