From 7dcb9c03c01a05c8032614c5fb65848d1f59c7bf Mon Sep 17 00:00:00 2001 From: emil Date: Wed, 24 Jun 2026 20:55:35 +0000 Subject: [PATCH] DEVX-42: fix: badge generation respects pyproject.toml testpaths, shows stdout in warnings --- src/devx/tools/generate_badges.py | 40 +++++++++++++++++++++++++++--- tests/unit/test_generate_badges.py | 37 +++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/devx/tools/generate_badges.py b/src/devx/tools/generate_badges.py index be9696a..a4ef4dc 100644 --- a/src/devx/tools/generate_badges.py +++ b/src/devx/tools/generate_badges.py @@ -239,6 +239,36 @@ def doc_coverage_color(pct: int) -> str: 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. @@ -251,8 +281,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d 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 [] + testpaths = detect_testpaths(repo_root) + click.echo(f" Test paths: {testpaths or '(pytest defaults)'}") cmd = [ sys.executable, @@ -273,7 +303,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage)) else: click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})") - click.echo(f" pytest stderr: {stderr.strip()[:200]}") + click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}") + click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}") cov_badge = make_badge("coverage", "unknown", "red") test_count = extract_test_count(combined) @@ -281,7 +312,8 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red") else: click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})") - click.echo(f" pytest stderr: {stderr.strip()[:200]}") + click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}") + click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}") tests_badge = make_badge("tests", "unknown", "red") return cov_badge, tests_badge diff --git a/tests/unit/test_generate_badges.py b/tests/unit/test_generate_badges.py index 4f634d8..add5a45 100644 --- a/tests/unit/test_generate_badges.py +++ b/tests/unit/test_generate_badges.py @@ -14,6 +14,7 @@ from devx.tools.generate_badges import ( coverage_color, detect_coverage_target, detect_package_name, + detect_testpaths, doc_coverage_color, extract_coverage, extract_doc_coverage, @@ -97,6 +98,32 @@ class TestDetectCoverageTarget: assert detect_coverage_target(tmp_path) is None +class TestDetectTestpaths: + def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + (tmp_path / "scripts" / "tests").mkdir(parents=True) + (tmp_path / "tests" / "unit").mkdir(parents=True) + (tmp_path / "pyproject.toml").write_text( + '[tool.pytest.ini_options]\ntestpaths = ["scripts/tests", "tests/unit"]\n' + ) + assert detect_testpaths(tmp_path) == ["scripts/tests", "tests/unit"] + + def test_filters_nonexistent_paths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + (tmp_path / "tests").mkdir() + (tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n') + assert detect_testpaths(tmp_path) == ["tests"] + + def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + (tmp_path / "tests").mkdir() + assert detect_testpaths(tmp_path) == ["tests"] + + def test_returns_empty_when_no_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + assert detect_testpaths(tmp_path) == [] + + def test_returns_empty_when_pyproject_has_no_testpaths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def] + (tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\naddopts = '-ra'\n") + assert detect_testpaths(tmp_path) == [] + + class TestRunCommand: @patch("devx.tools.generate_badges.subprocess.run") def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None: @@ -256,16 +283,22 @@ class TestReadVersion: class TestCollectCoverageAndTests: @patch("devx.tools.generate_badges.run_command") + @patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"]) @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] + def test_extracts_coverage_and_tests( + self, mock_target: MagicMock, mock_testpaths: 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" @patch("devx.tools.generate_badges.run_command") + @patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"]) @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] + def test_returns_unknown_when_no_match( + self, mock_target: MagicMock, mock_testpaths: 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"