#!/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("&", "&").replace("<", "<").replace(">", ">").replace('"', """) 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''' {label_text}: {message_text} {label_text} {message_text} ''' 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