DEVX-41: fix: badge generation REPO_ROOT, auto-detect package, error feedback
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 48s

This commit was merged in pull request #65.
This commit is contained in:
2026-06-24 20:33:12 +00:00
parent 7cf039ebbe
commit 95adf86895
4 changed files with 392 additions and 204 deletions
+12 -2
View File
@@ -18,6 +18,7 @@ Usage::
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
@@ -27,7 +28,16 @@ from typing import Any
import click import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
def _repo_root() -> Path:
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
workspace = os.environ.get("GITHUB_WORKSPACE")
if workspace:
path = Path(workspace)
if path.is_dir():
return path
return Path.cwd()
# Badge filenames that get pushed to the badges branch # Badge filenames that get pushed to the badges branch
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"] BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
@@ -116,7 +126,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
Switches back to master, replaces ``raw/branch/badges/`` URLs with Switches back to master, replaces ``raw/branch/badges/`` URLs with
``raw/commit/<sha>/`` URLs, commits and pushes. ``raw/commit/<sha>/`` URLs, commits and pushes.
""" """
root = repo_root or REPO_ROOT root = repo_root or _repo_root()
# Switch back to master # Switch back to master
_run(["git", "checkout", "master"]) # nosec B607 _run(["git", "checkout", "master"]) # nosec B607
+199 -60
View File
@@ -5,12 +5,21 @@ Runs pytest-cov, doc-coverage, lint checks, and version extraction,
then writes SVG badge files that can be served as static files from then writes SVG badge files that can be served as static files from
the Gitea raw file API. 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: Usage:
python3 -m devx.tools.generate_badges --output-dir .badges/ python3 -m devx.tools.generate_badges --output-dir .badges/
""" """
from __future__ import annotations from __future__ import annotations
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
@@ -18,8 +27,7 @@ from pathlib import Path
import click import click
REPO_ROOT = Path(__file__).resolve().parents[4] # Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%") _COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed") _PASSED_RE = re.compile(r"(\d+) passed")
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%") _DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
@@ -37,18 +45,59 @@ COLOR_HEX: dict[str, str] = {
} }
def _find_package_init() -> Path | None: def resolve_repo_root() -> Path:
"""Find the first package __init__.py under src/ that defines __version__.""" """Resolve the repository root directory.
src_dir = REPO_ROOT / "src"
if not src_dir.exists(): 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``,
``gitea_runner_manager``) or ``None`` if no package is found.
"""
src_dir = repo_root / "src"
if not src_dir.is_dir():
return None return None
for init_file in src_dir.rglob("__init__.py"): for entry in sorted(src_dir.iterdir()):
try: if not entry.is_dir():
content = init_file.read_text()
except OSError:
continue continue
if "__version__" in content: init_file = entry / "__init__.py"
return init_file 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 return None
@@ -57,14 +106,15 @@ def _xml_escape(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;") return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def run_command(cmd: list[str]) -> tuple[int, str, str]: def run_command(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr).""" """Run a command and return (returncode, stdout, stderr)."""
root = str(cwd or resolve_repo_root())
result = subprocess.run( # nosec B603 result = subprocess.run( # nosec B603
cmd, cmd,
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
cwd=str(REPO_ROOT), cwd=root,
) )
return result.returncode, result.stdout, result.stderr return result.returncode, result.stdout, result.stderr
@@ -139,15 +189,25 @@ def extract_doc_coverage(output: str) -> int | None:
return None return None
def read_version() -> str: def read_version(repo_root: Path) -> str:
"""Read __version__ from the package __init__.py.""" """Read __version__ from the package __init__.py under src/.
init_file = _find_package_init()
if init_file is None: 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(f" WARNING: {init_file} not found — version badge will show 'unknown'")
return "unknown" return "unknown"
content = init_file.read_text() content = init_file.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match: if match:
return match.group(1) return match.group(1)
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
return "unknown" return "unknown"
@@ -179,65 +239,138 @@ def doc_coverage_color(pct: int) -> str:
return "orange" return "orange"
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]: def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
"""Generate all badge SVG files and return badge data as a dict.""" """Run pytest-cov and collect coverage + test count badges.
badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run) Returns (coverage_badge, tests_badge). If pytest is not
rc, stdout, stderr = run_command( available or no tests are found, returns 'unknown' badges
[ with a clear warning explaining the failure.
sys.executable, """
"-m", cov_target = detect_coverage_target(repo_root)
"pytest", if cov_target is None:
"tests/", click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
"-v", return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
"--cov=src/devx",
"--cov-report=term-missing", tests_dir = repo_root / "tests"
"--cov-fail-under=0", testpaths: list[str] = [str(tests_dir)] if tests_dir.is_dir() else []
]
) 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 combined = stdout + "\n" + stderr
coverage = extract_coverage(combined) coverage = extract_coverage(combined)
if coverage is not None: if coverage is not None:
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage)) cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else: else:
badges["coverage"] = make_badge("coverage", "unknown", "red") click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
click.echo(f" pytest stderr: {stderr.strip()[:200]}")
cov_badge = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined) test_count = extract_test_count(combined)
if test_count is not None: if test_count is not None:
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red") tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else: else:
badges["tests"] = make_badge("tests", "unknown", "red") click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
click.echo(f" pytest stderr: {stderr.strip()[:200]}")
tests_badge = make_badge("tests", "unknown", "red")
# 2. Documentation coverage return cov_badge, tests_badge
rc, stdout, _ = run_command(
[
sys.executable, def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
"-m", """Run doc_coverage and collect the docs badge."""
"devx.ci.doc_coverage", rc, stdout, stderr = run_command(
] [sys.executable, "-m", "devx.ci.doc_coverage"],
cwd=repo_root,
) )
doc_pct = extract_doc_coverage(stdout) doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None: if doc_pct is not None:
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct)) return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
else: click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
badges["docs"] = make_badge("docs", "unknown", "red") click.echo(f" stderr: {stderr.strip()[:200]}")
return make_badge("docs", "unknown", "red")
# 3. Code quality (ruff + pyright + bandit all pass)
lint_rc, _, _ = run_command([sys.executable, "-m", "ruff", "check", "src/", "tests/"])
format_rc, _, _ = run_command([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"])
type_rc, _, _ = run_command([sys.executable, "-m", "pyright"])
bandit_rc, _, _ = run_command([sys.executable, "-m", "bandit", "-r", "src/"])
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc]) def collect_quality(repo_root: Path) -> dict[str, str | int]:
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red") """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, _, 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(f" WARNING: {name} not installed — skipping (counted as pass)")
results[-1] = True
tool_names.append(f"{name}: not installed (skipped)")
else:
tool_names.append(f"{name}: FAIL")
click.echo(f" WARNING: {name} failed (rc={rc})")
click.echo(f" stderr: {stderr.strip()[:200]}")
all_pass = all(results)
click.echo(f" Quality 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(f" Repo root: {root}")
pkg = detect_package_name(root)
click.echo(f" Package: {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 # 4. Version
version = read_version() click.echo(" Collecting version...")
version = read_version(root)
badges["version"] = make_badge("version", f"v{version}", "blue") badges["version"] = make_badge("version", f"v{version}", "blue")
# 5. Python version (static but nice) # 5. Python version (static)
badges["python"] = make_badge("python", "3.12", "blue") badges["python"] = make_badge("python", "3.12", "blue")
# Write SVG files # Write SVG files
@@ -254,14 +387,20 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
@click.command() @click.command()
@click.option( @click.option(
"--output-dir", "--output-dir",
default=str(REPO_ROOT / ".badges"), default=".badges",
help="Directory to write badge SVG files.", help="Directory to write badge SVG files.",
) )
def cli(output_dir: str) -> None: @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.""" """Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir) out = Path(output_dir)
root = Path(repo_root) if repo_root else None
click.echo(f"Generating badges in {out}...") click.echo(f"Generating badges in {out}...")
badges = generate_badges(out) badges = generate_badges(out, repo_root=root)
click.echo(f"\nGenerated {len(badges)} badges:") click.echo(f"\nGenerated {len(badges)} badges:")
for name, badge in badges.items(): for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})") click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
+165 -142
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/generate_badges.py.""" """Unit tests for devx/tools/generate_badges.py."""
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -8,7 +8,12 @@ from click.testing import CliRunner
from devx.tools.generate_badges import ( from devx.tools.generate_badges import (
COLOR_HEX, COLOR_HEX,
cli, cli,
collect_coverage_and_tests,
collect_doc_coverage,
collect_quality,
coverage_color, coverage_color,
detect_coverage_target,
detect_package_name,
doc_coverage_color, doc_coverage_color,
extract_coverage, extract_coverage,
extract_doc_coverage, extract_doc_coverage,
@@ -17,10 +22,81 @@ from devx.tools.generate_badges import (
make_badge, make_badge,
read_version, read_version,
render_svg, render_svg,
resolve_repo_root,
run_command, run_command,
) )
class TestResolveRepoRoot:
def test_uses_github_workspace_when_set(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
assert resolve_repo_root() == tmp_path
def test_falls_back_to_cwd_when_no_workspace(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
monkeypatch.chdir(tmp_path)
assert resolve_repo_root() == tmp_path
def test_falls_back_to_cwd_when_workspace_invalid(self, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent/path")
result = resolve_repo_root()
assert result == Path.cwd()
class TestDetectPackageName:
def test_detects_package_with_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0.0"\n')
assert detect_package_name(tmp_path) == "mypkg"
def test_returns_none_when_no_src(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_package_name(tmp_path) is None
def test_returns_none_when_no_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
# No __init__.py
assert detect_package_name(tmp_path) is None
def test_picks_first_package_alphabetically(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
for name in ["zpkg", "apkg"]:
d = src / name
d.mkdir(parents=True)
(d / "__init__.py").write_text("")
assert detect_package_name(tmp_path) == "apkg"
def test_skips_non_dir_entries(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
src.mkdir(parents=True)
(src / "README.md").write_text("not a package")
pkg = src / "mypkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
assert detect_package_name(tmp_path) == "mypkg"
class TestDetectCoverageTarget:
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text(
'[tool.pytest.ini_options]\naddopts = "--cov=src/devx --cov-report=term-missing"\n'
)
assert detect_coverage_target(tmp_path) == "src/devx"
def test_falls_back_to_src_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
assert detect_coverage_target(tmp_path) == "src/mypkg"
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_coverage_target(tmp_path) is None
class TestRunCommand: class TestRunCommand:
@patch("devx.tools.generate_badges.subprocess.run") @patch("devx.tools.generate_badges.subprocess.run")
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None: def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
@@ -157,100 +233,116 @@ class TestDocCoverageColor:
class TestReadVersion: class TestReadVersion:
@patch("devx.tools.generate_badges._find_package_init") def test_reads_version_from_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
def test_reads_version_from_init(self, mock_find: MagicMock) -> None: src = tmp_path / "src" / "mypkg"
mock_init = MagicMock() src.mkdir(parents=True)
mock_init.read_text.return_value = '__version__ = "0.5.0"\n' (src / "__init__.py").write_text('__version__ = "0.5.0"\n')
mock_find.return_value = mock_init assert read_version(tmp_path) == "0.5.0"
assert read_version() == "0.5.0"
@patch("devx.tools.generate_badges._find_package_init") def test_returns_unknown_when_no_version(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None: src = tmp_path / "src" / "mypkg"
mock_init = MagicMock() src.mkdir(parents=True)
mock_init.read_text.return_value = "no version here\n" (src / "__init__.py").write_text("no version here\n")
mock_find.return_value = mock_init assert read_version(tmp_path) == "unknown"
assert read_version() == "unknown"
@patch("devx.tools.generate_badges._find_package_init", return_value=None) def test_returns_unknown_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None: assert read_version(tmp_path) == "unknown"
assert read_version() == "unknown"
@patch("devx.tools.generate_badges.detect_package_name", return_value="mypkg")
def test_returns_unknown_when_init_missing(self, mock_pkg: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
# Package detected but __init__.py doesn't exist (edge case)
assert read_version(tmp_path) == "unknown"
class TestFindPackageInit: class TestCollectCoverageAndTests:
@patch("devx.tools.generate_badges.REPO_ROOT") @patch("devx.tools.generate_badges.run_command")
def test_no_src_dir(self, mock_root: MagicMock) -> None: @patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
"""Returns None when src/ directory doesn't exist.""" def test_extracts_coverage_and_tests(self, mock_target: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
from devx.tools.generate_badges import _find_package_init mock_run.return_value = (0, "1018 passed in 4.23s\nTOTAL 3546 0 100%", "")
cov, tests = collect_coverage_and_tests(tmp_path)
assert cov["message"] == "100%"
assert tests["message"] == "1018 passing"
mock_src = MagicMock() @patch("devx.tools.generate_badges.run_command")
mock_src.exists.return_value = False @patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
mock_root.__truediv__ = MagicMock(return_value=mock_src) def test_returns_unknown_when_no_match(self, mock_target: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert _find_package_init() is None mock_run.return_value = (1, "garbled output", "some error")
cov, tests = collect_coverage_and_tests(tmp_path)
assert cov["message"] == "unknown"
assert tests["message"] == "unknown"
@patch("devx.tools.generate_badges.REPO_ROOT") @patch("devx.tools.generate_badges.detect_coverage_target", return_value=None)
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None: def test_returns_lightgrey_when_no_target(self, mock_target: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
"""Returns None when no __init__.py has __version__.""" cov, tests = collect_coverage_and_tests(tmp_path)
from devx.tools.generate_badges import _find_package_init assert cov["message"] == "unknown"
assert cov["color"] == "lightgrey"
assert tests["message"] == "unknown"
assert tests["color"] == "lightgrey"
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "__init__.py").write_text("# no version here\n")
mock_root.__truediv__ = MagicMock(return_value=src_dir)
assert _find_package_init() is None
@patch("devx.tools.generate_badges.REPO_ROOT") class TestCollectDocCoverage:
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None: @patch("devx.tools.generate_badges.run_command")
"""Returns the __init__.py that has __version__.""" def test_extracts_doc_coverage(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
from devx.tools.generate_badges import _find_package_init mock_run.return_value = (0, "Doc coverage: 20/20 (100%)", "")
badge = collect_doc_coverage(tmp_path)
assert badge["message"] == "100%"
src_dir = tmp_path / "src" @patch("devx.tools.generate_badges.run_command")
pkg_dir = src_dir / "mypkg" def test_returns_unknown_when_no_match(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
pkg_dir.mkdir(parents=True) mock_run.return_value = (1, "no doc coverage", "error")
(src_dir / "__init__.py").write_text("# no version\n") badge = collect_doc_coverage(tmp_path)
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n') assert badge["message"] == "unknown"
mock_root.__truediv__ = MagicMock(return_value=src_dir)
result = _find_package_init()
assert result is not None
assert "__version__" in result.read_text()
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Handles OSError when reading init files."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src" class TestCollectQuality:
src_dir.mkdir() @patch("devx.tools.generate_badges.run_command")
init_file = src_dir / "__init__.py" def test_all_pass_returns_a(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
init_file.write_text('__version__ = "1.0.0"\n') mock_run.return_value = (0, "", "")
mock_root.__truediv__ = MagicMock(return_value=src_dir) badge = collect_quality(tmp_path)
# Patch Path.read_text to raise OSError assert badge["message"] == "A"
with patch.object(Path, "read_text", side_effect=OSError("permission denied")): assert badge["color"] == "brightgreen"
result = _find_package_init()
assert result is None @patch("devx.tools.generate_badges.run_command")
def test_lint_failure_returns_f(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "", "some error")
badge = collect_quality(tmp_path)
assert badge["message"] == "F"
assert badge["color"] == "red"
@patch("devx.tools.generate_badges.run_command")
def test_tool_not_installed_counts_as_pass(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "", "No module named ruff")
badge = collect_quality(tmp_path)
assert badge["message"] == "A"
class TestGenerateBadges: class TestGenerateBadges:
@patch("devx.tools.generate_badges.run_command") @patch("devx.tools.generate_badges.collect_quality")
@patch("devx.tools.generate_badges.collect_doc_coverage")
@patch("devx.tools.generate_badges.collect_coverage_and_tests")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0") @patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0) @patch("devx.tools.generate_badges.detect_package_name", return_value="devx")
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_generates_all_badge_files( def test_generates_all_badge_files(
self, self,
mock_doc_cov: MagicMock, mock_pkg: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock, mock_version: MagicMock,
mock_run: MagicMock, mock_cov_tests: MagicMock,
mock_doc: MagicMock,
mock_quality: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (0, "output", "") mock_cov_tests.return_value = (
badges = generate_badges(tmp_path) make_badge("coverage", "100%", "brightgreen"),
make_badge("tests", "573 passing", "brightgreen"),
)
mock_doc.return_value = make_badge("docs", "100%", "brightgreen")
mock_quality.return_value = make_badge("code quality", "A", "brightgreen")
badges = generate_badges(tmp_path, repo_root=tmp_path)
expected = {"coverage", "tests", "docs", "quality", "version", "python"} expected = {"coverage", "tests", "docs", "quality", "version", "python"}
assert set(badges.keys()) == expected assert set(badges.keys()) == expected
# Verify SVG files were written
for name in expected: for name in expected:
svg_file = tmp_path / f"{name}.svg" svg_file = tmp_path / f"{name}.svg"
assert svg_file.exists() assert svg_file.exists()
@@ -258,78 +350,10 @@ class TestGenerateBadges:
assert content.startswith("<svg") assert content.startswith("<svg")
assert "</svg>" in content assert "</svg>" in content
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_pass_when_all_lint_passes(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "A"
assert badges["quality"]["color"] == "brightgreen"
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_fails_when_lint_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.side_effect = [
(0, "output", ""),
(0, "output", ""),
(1, "error", ""),
(0, "output", ""),
(0, "output", ""),
(0, "output", ""),
]
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "F"
assert badges["quality"]["color"] == "red"
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=None)
def test_badges_show_unknown_when_extraction_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (1, "garbled output", "")
badges = generate_badges(tmp_path)
assert badges["coverage"]["message"] == "unknown"
assert badges["coverage"]["color"] == "red"
assert badges["tests"]["message"] == "unknown"
assert badges["tests"]["color"] == "red"
assert badges["docs"]["message"] == "unknown"
assert badges["docs"]["color"] == "red"
class TestCli: class TestCli:
@patch("devx.tools.generate_badges.generate_badges") @patch("devx.tools.generate_badges.generate_badges")
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None: def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_gen.return_value = { mock_gen.return_value = {
"coverage": make_badge("coverage", "100%", "brightgreen"), "coverage": make_badge("coverage", "100%", "brightgreen"),
"tests": make_badge("tests", "573 passing", "brightgreen"), "tests": make_badge("tests", "573 passing", "brightgreen"),
@@ -339,7 +363,6 @@ class TestCli:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Generating badges" in result.output assert "Generating badges" in result.output
assert "Generated 2 badges" in result.output assert "Generated 2 badges" in result.output
mock_gen.assert_called_once_with(tmp_path)
def test_main_module_block() -> None: def test_main_module_block() -> None:
+16
View File
@@ -287,3 +287,19 @@ class TestMain:
result = runner.invoke(push_badges.main, ["--no-readme-update"]) result = runner.invoke(push_badges.main, ["--no-readme-update"])
assert result.exit_code != 0 assert result.exit_code != 0
mock_sleep.assert_not_called() mock_sleep.assert_not_called()
class TestRepoRoot:
def test_uses_github_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
assert push_badges._repo_root() == tmp_path
def test_falls_back_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
monkeypatch.chdir(tmp_path)
assert push_badges._repo_root() == tmp_path
def test_falls_back_when_workspace_invalid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent")
monkeypatch.chdir(tmp_path)
assert push_badges._repo_root() == tmp_path