Update hardcoded path and docstring examples from `gitea_runner_manager` to `grm` after the package rename in grm PR #203. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
455 lines
16 KiB
Python
455 lines
16 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.
|
|
|
|
The repo root is resolved from ``GITHUB_WORKSPACE`` or ``os.getcwd()``,
|
|
so this module works correctly both when run from a source checkout
|
|
and when devx is installed as a pip package in CI.
|
|
|
|
The package name and coverage target are auto-detected from the
|
|
``src/`` directory structure, making this module reusable across
|
|
all oblachno-oss repos without per-repo configuration.
|
|
|
|
Usage:
|
|
python3 -m devx.tools.generate_badges --output-dir .badges/
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess # nosec B404
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
from devx.i18n import _
|
|
|
|
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
|
|
_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 resolve_repo_root() -> Path:
|
|
"""Resolve the repository root directory.
|
|
|
|
Uses ``GITHUB_WORKSPACE`` env var (set by Gitea Actions) or
|
|
falls back to ``os.getcwd()``. This ensures the correct repo
|
|
root is used even when devx is installed as a pip package.
|
|
"""
|
|
workspace = os.environ.get("GITHUB_WORKSPACE")
|
|
if workspace:
|
|
path = Path(workspace)
|
|
if path.is_dir():
|
|
return path
|
|
return Path.cwd()
|
|
|
|
|
|
def detect_package_name(repo_root: Path) -> str | None:
|
|
"""Auto-detect the Python package name from ``src/`` directory.
|
|
|
|
Looks for the first subdirectory under ``src/`` that contains
|
|
an ``__init__.py`` file with ``__version__``.
|
|
|
|
Returns the package directory name (e.g., ``devx``,
|
|
``grm``) or ``None`` if no package is found.
|
|
"""
|
|
src_dir = repo_root / "src"
|
|
if not src_dir.is_dir():
|
|
return None
|
|
for entry in sorted(src_dir.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
init_file = entry / "__init__.py"
|
|
if init_file.exists():
|
|
return entry.name
|
|
return None
|
|
|
|
|
|
def detect_coverage_target(repo_root: Path) -> str | None:
|
|
"""Auto-detect the pytest-cov target from pyproject.toml.
|
|
|
|
Parses ``addopts`` in ``[tool.pytest.ini_options]`` for
|
|
``--cov=src/<package>``. Falls back to ``src/<package>`` if
|
|
the package is detected but no explicit cov target is found.
|
|
"""
|
|
pyproject = repo_root / "pyproject.toml"
|
|
if pyproject.exists():
|
|
content = pyproject.read_text()
|
|
match = re.search(r"--cov=(\S+)", content)
|
|
if match:
|
|
return match.group(1)
|
|
# Fallback: derive from package name
|
|
pkg = detect_package_name(repo_root)
|
|
if pkg:
|
|
return f"src/{pkg}"
|
|
return None
|
|
|
|
|
|
def _xml_escape(text: str) -> str:
|
|
"""Escape XML special characters."""
|
|
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
|
|
|
|
def run_command(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
|
"""Run a command and return (returncode, stdout, stderr)."""
|
|
root = str(cwd or resolve_repo_root())
|
|
result = subprocess.run( # nosec B603
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
cwd=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(repo_root: Path) -> str:
|
|
"""Read __version__ from the package __init__.py under src/.
|
|
|
|
Auto-detects the package directory and reads ``__version__``
|
|
from its ``__init__.py``.
|
|
"""
|
|
pkg = detect_package_name(repo_root)
|
|
if pkg is None:
|
|
click.echo(_(" WARNING: No Python package found under src/ — version badge will show 'unknown'"))
|
|
return "unknown"
|
|
init_file = repo_root / "src" / pkg / "__init__.py"
|
|
if not init_file.exists():
|
|
click.echo(_(" WARNING: {init_file} not found — version badge will show 'unknown'", init_file=init_file))
|
|
return "unknown"
|
|
content = init_file.read_text()
|
|
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
|
if match:
|
|
return match.group(1)
|
|
click.echo(
|
|
_(" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'", init_file=init_file)
|
|
)
|
|
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 detect_testpaths(repo_root: Path) -> list[str]:
|
|
"""Detect test paths from pyproject.toml or filesystem.
|
|
|
|
Parses ``testpaths`` in ``[tool.pytest.ini_options]`` from
|
|
pyproject.toml. Falls back to ``["tests"]`` if the tests/
|
|
directory exists. Returns an empty list if no test paths
|
|
are found (pytest will use its own defaults).
|
|
"""
|
|
pyproject = repo_root / "pyproject.toml"
|
|
if pyproject.exists():
|
|
content = pyproject.read_text()
|
|
# Match: testpaths = ["dir1", "dir2"]
|
|
match = re.search(r"testpaths\s*=\s*\[([^\]]+)\]", content)
|
|
if match:
|
|
paths = re.findall(r'["\']([^"\']+)["\']', match.group(1))
|
|
resolved = []
|
|
for p in paths:
|
|
p = p.strip()
|
|
if (repo_root / p).exists():
|
|
resolved.append(p)
|
|
if resolved:
|
|
return resolved
|
|
|
|
# Fallback: tests/ directory
|
|
tests_dir = repo_root / "tests"
|
|
if tests_dir.is_dir():
|
|
return ["tests"]
|
|
return []
|
|
|
|
|
|
def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
|
|
"""Run pytest-cov and collect coverage + test count badges.
|
|
|
|
Returns (coverage_badge, tests_badge). If pytest is not
|
|
available or no tests are found, returns 'unknown' badges
|
|
with a clear warning explaining the failure.
|
|
"""
|
|
cov_target = detect_coverage_target(repo_root)
|
|
if cov_target is None:
|
|
click.echo(_(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"))
|
|
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
|
|
|
testpaths = detect_testpaths(repo_root)
|
|
click.echo(_(" Test paths: {testpaths}", testpaths=testpaths or "(pytest defaults)"))
|
|
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"pytest",
|
|
*testpaths,
|
|
"--cov",
|
|
cov_target,
|
|
"--cov-report=term-missing",
|
|
"--cov-fail-under=0",
|
|
"-q",
|
|
]
|
|
rc, stdout, stderr = run_command(cmd, cwd=repo_root)
|
|
combined = stdout + "\n" + stderr
|
|
|
|
coverage = extract_coverage(combined)
|
|
if coverage is not None:
|
|
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
|
else:
|
|
click.echo(_(" WARNING: Could not extract coverage from pytest output (rc={rc})", rc=rc))
|
|
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
|
|
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
|
|
cov_badge = make_badge("coverage", "unknown", "red")
|
|
|
|
test_count = extract_test_count(combined)
|
|
if test_count is not None:
|
|
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
|
else:
|
|
click.echo(_(" WARNING: Could not extract test count from pytest output (rc={rc})", rc=rc))
|
|
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
|
|
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
|
|
tests_badge = make_badge("tests", "unknown", "red")
|
|
|
|
return cov_badge, tests_badge
|
|
|
|
|
|
def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
|
|
"""Run doc_coverage and collect the docs badge."""
|
|
rc, stdout, stderr = run_command(
|
|
[sys.executable, "-m", "devx.ci.doc_coverage"],
|
|
cwd=repo_root,
|
|
)
|
|
doc_pct = extract_doc_coverage(stdout)
|
|
if doc_pct is not None:
|
|
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
|
click.echo(_(" WARNING: Could not extract doc coverage (rc={rc})", rc=rc))
|
|
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
|
|
return make_badge("docs", "unknown", "red")
|
|
|
|
|
|
def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
|
"""Run lint checks and collect the quality badge.
|
|
|
|
Runs ruff check, ruff format --check, pyright, and bandit.
|
|
If any tool is not installed, it is skipped with a warning.
|
|
"""
|
|
results: list[bool] = []
|
|
tool_names: list[str] = []
|
|
|
|
for cmd, name in [
|
|
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
|
|
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
|
|
([sys.executable, "-m", "pyright"], "pyright"),
|
|
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
|
|
]:
|
|
rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
|
|
if rc == 0:
|
|
results.append(True)
|
|
tool_names.append(f"{name}: pass")
|
|
else:
|
|
results.append(False)
|
|
# Distinguish "tool not installed" from "tool found issues"
|
|
if "No module named" in stderr or "not found" in stderr.lower():
|
|
click.echo(_(" WARNING: {name} not installed — skipping (counted as pass)", name=name))
|
|
results[-1] = True
|
|
tool_names.append(f"{name}: not installed (skipped)")
|
|
else:
|
|
tool_names.append(f"{name}: FAIL")
|
|
click.echo(_(" WARNING: {name} failed (rc={rc})", name=name, rc=rc))
|
|
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
|
|
|
|
all_pass = all(results)
|
|
click.echo(_(" Quality checks: {checks}", checks=", ".join(tool_names)))
|
|
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
|
|
|
|
|
def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str, dict[str, str | int]]:
|
|
"""Generate all badge SVG files and return badge data as a dict.
|
|
|
|
Args:
|
|
output_dir: Directory to write SVG files.
|
|
repo_root: Repository root (auto-detected if None).
|
|
"""
|
|
root = repo_root or resolve_repo_root()
|
|
click.echo(_(" Repo root: {root}", root=root))
|
|
pkg = detect_package_name(root)
|
|
click.echo(_(" Package: {pkg}", pkg=pkg or "none"))
|
|
|
|
badges: dict[str, dict[str, str | int]] = {}
|
|
|
|
# 1. Code coverage + test count (single pytest-cov run)
|
|
click.echo(_(" Collecting coverage and tests..."))
|
|
cov_badge, tests_badge = collect_coverage_and_tests(root)
|
|
badges["coverage"] = cov_badge
|
|
badges["tests"] = tests_badge
|
|
|
|
# 2. Documentation coverage
|
|
click.echo(_(" Collecting doc coverage..."))
|
|
badges["docs"] = collect_doc_coverage(root)
|
|
|
|
# 3. Code quality (ruff + pyright + bandit)
|
|
click.echo(_(" Collecting code quality..."))
|
|
badges["quality"] = collect_quality(root)
|
|
|
|
# 4. Version
|
|
click.echo(_(" Collecting version..."))
|
|
version = read_version(root)
|
|
badges["version"] = make_badge("version", f"v{version}", "blue")
|
|
|
|
# 5. Python version (static)
|
|
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(_(" Generated: {path}", path=path))
|
|
|
|
return badges
|
|
|
|
|
|
@click.command()
|
|
@click.option(
|
|
"--output-dir",
|
|
default=".badges",
|
|
help="Directory to write badge SVG files.",
|
|
)
|
|
@click.option(
|
|
"--repo-root",
|
|
default=None,
|
|
help="Repository root (auto-detected if not specified).",
|
|
)
|
|
def cli(output_dir: str, repo_root: str | None) -> None:
|
|
"""Generate self-contained SVG badge files from project metrics."""
|
|
out = Path(output_dir)
|
|
root = Path(repo_root) if repo_root else None
|
|
click.echo(_("Generating badges in {out}...", out=out))
|
|
badges = generate_badges(out, repo_root=root)
|
|
click.echo(_("\nGenerated {count} badges:", count=len(badges)))
|
|
for name, badge in badges.items():
|
|
click.echo(
|
|
_(
|
|
" {name}: {label}={message} ({color})",
|
|
name=name,
|
|
label=badge["label"],
|
|
message=badge["message"],
|
|
color=badge["color"],
|
|
)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
cli() # pragma: no cover
|