From 95adf868957be9d44d2eee700744caea8a5e157b Mon Sep 17 00:00:00 2001 From: emil Date: Wed, 24 Jun 2026 20:33:12 +0000 Subject: [PATCH] DEVX-41: fix: badge generation REPO_ROOT, auto-detect package, error feedback --- src/devx/ci/push_badges.py | 14 +- src/devx/tools/generate_badges.py | 259 ++++++++++++++++++------ tests/unit/test_generate_badges.py | 307 ++++++++++++++++------------- tests/unit/test_push_badges.py | 16 ++ 4 files changed, 392 insertions(+), 204 deletions(-) diff --git a/src/devx/ci/push_badges.py b/src/devx/ci/push_badges.py index 1cbdd4b..682b4f0 100644 --- a/src/devx/ci/push_badges.py +++ b/src/devx/ci/push_badges.py @@ -18,6 +18,7 @@ Usage:: from __future__ import annotations import contextlib +import os import re import subprocess # nosec B404 import sys @@ -27,7 +28,16 @@ from typing import Any 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_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 ``raw/commit//`` URLs, commits and pushes. """ - root = repo_root or REPO_ROOT + root = repo_root or _repo_root() # Switch back to master _run(["git", "checkout", "master"]) # nosec B607 diff --git a/src/devx/tools/generate_badges.py b/src/devx/tools/generate_badges.py index 83ed807..be9696a 100644 --- a/src/devx/tools/generate_badges.py +++ b/src/devx/tools/generate_badges.py @@ -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 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 @@ -18,8 +27,7 @@ from pathlib import Path 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+)?)%") _PASSED_RE = re.compile(r"(\d+) passed") _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: - """Find the first package __init__.py under src/ that defines __version__.""" - src_dir = REPO_ROOT / "src" - if not src_dir.exists(): +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``, + ``gitea_runner_manager``) or ``None`` if no package is found. + """ + src_dir = repo_root / "src" + if not src_dir.is_dir(): return None - for init_file in src_dir.rglob("__init__.py"): - try: - content = init_file.read_text() - except OSError: + for entry in sorted(src_dir.iterdir()): + if not entry.is_dir(): continue - if "__version__" in content: - return init_file + 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/``. Falls back to ``src/`` 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 @@ -57,14 +106,15 @@ def _xml_escape(text: str) -> str: return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) -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).""" + root = str(cwd or resolve_repo_root()) result = subprocess.run( # nosec B603 cmd, capture_output=True, text=True, check=False, - cwd=str(REPO_ROOT), + cwd=root, ) return result.returncode, result.stdout, result.stderr @@ -139,15 +189,25 @@ def extract_doc_coverage(output: str) -> int | None: return None -def read_version() -> str: - """Read __version__ from the package __init__.py.""" - init_file = _find_package_init() - if init_file is 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(f" WARNING: {init_file} not found — version badge will show 'unknown'") return "unknown" content = init_file.read_text() match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) if match: return match.group(1) + click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'") return "unknown" @@ -179,65 +239,138 @@ def doc_coverage_color(pct: int) -> str: 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]] = {} +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. - # 1. Code coverage + test count (single pytest-cov run) - rc, stdout, stderr = run_command( - [ - sys.executable, - "-m", - "pytest", - "tests/", - "-v", - "--cov=src/devx", - "--cov-report=term-missing", - "--cov-fail-under=0", - ] - ) + 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") + + tests_dir = repo_root / "tests" + 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 coverage = extract_coverage(combined) 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: - 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) 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: - 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 - rc, stdout, _ = run_command( - [ - sys.executable, - "-m", - "devx.ci.doc_coverage", - ] + 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: - badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct)) - else: - badges["docs"] = make_badge("docs", "unknown", "red") + return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct)) + click.echo(f" WARNING: Could not extract doc coverage (rc={rc})") + 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]) - badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "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, _, 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 - version = read_version() + click.echo(" Collecting version...") + version = read_version(root) 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") # Write SVG files @@ -254,14 +387,20 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]: @click.command() @click.option( "--output-dir", - default=str(REPO_ROOT / ".badges"), + default=".badges", 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.""" out = Path(output_dir) + root = Path(repo_root) if repo_root else None 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:") for name, badge in badges.items(): click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})") diff --git a/tests/unit/test_generate_badges.py b/tests/unit/test_generate_badges.py index 47a5ef4..4f634d8 100644 --- a/tests/unit/test_generate_badges.py +++ b/tests/unit/test_generate_badges.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/generate_badges.py.""" +"""Unit tests for devx/tools/generate_badges.py.""" from pathlib import Path from unittest.mock import MagicMock, patch @@ -8,7 +8,12 @@ from click.testing import CliRunner from devx.tools.generate_badges import ( COLOR_HEX, cli, + collect_coverage_and_tests, + collect_doc_coverage, + collect_quality, coverage_color, + detect_coverage_target, + detect_package_name, doc_coverage_color, extract_coverage, extract_doc_coverage, @@ -17,10 +22,81 @@ from devx.tools.generate_badges import ( make_badge, read_version, render_svg, + resolve_repo_root, 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: @patch("devx.tools.generate_badges.subprocess.run") def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None: @@ -157,100 +233,116 @@ class TestDocCoverageColor: class TestReadVersion: - @patch("devx.tools.generate_badges._find_package_init") - def test_reads_version_from_init(self, mock_find: MagicMock) -> None: - mock_init = MagicMock() - mock_init.read_text.return_value = '__version__ = "0.5.0"\n' - mock_find.return_value = mock_init - assert read_version() == "0.5.0" + def test_reads_version_from_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + src = tmp_path / "src" / "mypkg" + src.mkdir(parents=True) + (src / "__init__.py").write_text('__version__ = "0.5.0"\n') + assert read_version(tmp_path) == "0.5.0" - @patch("devx.tools.generate_badges._find_package_init") - def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None: - mock_init = MagicMock() - mock_init.read_text.return_value = "no version here\n" - mock_find.return_value = mock_init - assert read_version() == "unknown" + def test_returns_unknown_when_no_version(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + src = tmp_path / "src" / "mypkg" + src.mkdir(parents=True) + (src / "__init__.py").write_text("no version here\n") + assert read_version(tmp_path) == "unknown" - @patch("devx.tools.generate_badges._find_package_init", return_value=None) - def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None: - assert read_version() == "unknown" + def test_returns_unknown_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + assert read_version(tmp_path) == "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: - @patch("devx.tools.generate_badges.REPO_ROOT") - def test_no_src_dir(self, mock_root: MagicMock) -> None: - """Returns None when src/ directory doesn't exist.""" - from devx.tools.generate_badges import _find_package_init +class TestCollectCoverageAndTests: + @patch("devx.tools.generate_badges.run_command") + @patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx") + def test_extracts_coverage_and_tests(self, mock_target: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + 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() - mock_src.exists.return_value = False - mock_root.__truediv__ = MagicMock(return_value=mock_src) - assert _find_package_init() is None + @patch("devx.tools.generate_badges.run_command") + @patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx") + def test_returns_unknown_when_no_match(self, mock_target: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + 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") - def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None: - """Returns None when no __init__.py has __version__.""" - from devx.tools.generate_badges import _find_package_init + @patch("devx.tools.generate_badges.detect_coverage_target", return_value=None) + def test_returns_lightgrey_when_no_target(self, mock_target: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + cov, tests = collect_coverage_and_tests(tmp_path) + 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") - def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None: - """Returns the __init__.py that has __version__.""" - from devx.tools.generate_badges import _find_package_init +class TestCollectDocCoverage: + @patch("devx.tools.generate_badges.run_command") + def test_extracts_doc_coverage(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + 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" - pkg_dir = src_dir / "mypkg" - pkg_dir.mkdir(parents=True) - (src_dir / "__init__.py").write_text("# no version\n") - (pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n') - 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.run_command") + def test_returns_unknown_when_no_match(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + mock_run.return_value = (1, "no doc coverage", "error") + badge = collect_doc_coverage(tmp_path) + assert badge["message"] == "unknown" - @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" - src_dir.mkdir() - init_file = src_dir / "__init__.py" - init_file.write_text('__version__ = "1.0.0"\n') - mock_root.__truediv__ = MagicMock(return_value=src_dir) - # Patch Path.read_text to raise OSError - with patch.object(Path, "read_text", side_effect=OSError("permission denied")): - result = _find_package_init() - assert result is None +class TestCollectQuality: + @patch("devx.tools.generate_badges.run_command") + def test_all_pass_returns_a(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + mock_run.return_value = (0, "", "") + badge = collect_quality(tmp_path) + assert badge["message"] == "A" + assert badge["color"] == "brightgreen" + + @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: - @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.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) + @patch("devx.tools.generate_badges.detect_package_name", return_value="devx") def test_generates_all_badge_files( self, - mock_doc_cov: MagicMock, - mock_test_count: MagicMock, - mock_cov: MagicMock, + mock_pkg: MagicMock, mock_version: MagicMock, - mock_run: MagicMock, + mock_cov_tests: MagicMock, + mock_doc: MagicMock, + mock_quality: MagicMock, tmp_path: Path, - ) -> None: - mock_run.return_value = (0, "output", "") - badges = generate_badges(tmp_path) + ) -> None: # type: ignore[no-untyped-def] + mock_cov_tests.return_value = ( + 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"} assert set(badges.keys()) == expected - # Verify SVG files were written for name in expected: svg_file = tmp_path / f"{name}.svg" assert svg_file.exists() @@ -258,78 +350,10 @@ class TestGenerateBadges: assert content.startswith("" 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: @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 = { "coverage": make_badge("coverage", "100%", "brightgreen"), "tests": make_badge("tests", "573 passing", "brightgreen"), @@ -339,7 +363,6 @@ class TestCli: assert result.exit_code == 0 assert "Generating 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: diff --git a/tests/unit/test_push_badges.py b/tests/unit/test_push_badges.py index c38572c..62f6954 100644 --- a/tests/unit/test_push_badges.py +++ b/tests/unit/test_push_badges.py @@ -287,3 +287,19 @@ class TestMain: result = runner.invoke(push_badges.main, ["--no-readme-update"]) assert result.exit_code != 0 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