diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 94d6f8e..2cd28f5 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -202,6 +202,6 @@ jobs:
git checkout --orphan badges
git rm -rf .
cp -r .badges/* .
- git add *.json
+ git add *.svg
git commit --no-verify -m "Update badges [skip ci]"
git push origin badges --force
diff --git a/README.md b/README.md
index 63937ee..9c75a9b 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
-[](https://www.python.org/downloads/)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
+[](https://www.python.org/downloads/)
## Quick Start
diff --git a/docs/index.md b/docs/index.md
index ec489d4..2142953 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,11 +5,11 @@ A lean command-line tool to automate the installation, configuration, and lifecy
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
-[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
+[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
## User Documentation
diff --git a/scripts/generate_badges.py b/scripts/generate_badges.py
index e5e89a1..91ad06f 100644
--- a/scripts/generate_badges.py
+++ b/scripts/generate_badges.py
@@ -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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
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'''
+'''
+
+
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)
diff --git a/tests/unit/test_generate_badges.py b/tests/unit/test_generate_badges.py
index 924dd63..4968c78 100644
--- a/tests/unit/test_generate_badges.py
+++ b/tests/unit/test_generate_badges.py
@@ -1,12 +1,12 @@
"""Unit tests for scripts/generate_badges.py."""
-import json
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from scripts.generate_badges import (
+ COLOR_HEX,
cli,
coverage_color,
doc_coverage_color,
@@ -16,6 +16,7 @@ from scripts.generate_badges import (
generate_badges,
make_badge,
read_version,
+ render_svg,
run_command,
)
@@ -42,6 +43,38 @@ class TestMakeBadge:
}
+class TestRenderSvg:
+ def test_generates_valid_svg(self) -> None:
+ svg = render_svg("coverage", "100%", "brightgreen")
+ assert svg.startswith("