From b971064fd0bb2bf27b525001621df403f2570d22 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sun, 27 Sep 2026 12:26:50 +0200 Subject: [PATCH 1/2] feat(scan): add CLI scan arguments and auto-venv detection - Add positional test_paths, --source/-s, and --python options to testless scan CLI - Auto-detect target repository virtualenv in resolve_python_executable - Ignore vendor/virtualenv directories in EndpointInventory and FixtureIndex - Add test coverage for vendor filtering, python resolution, and CLI options - Closes PLF-006, PLF-007, PLF-008 Co-authored-by: Koru Agent --- src/testless/cli.py | 18 ++++++-- src/testless/collect/endpoint_inventory.py | 11 +++++ src/testless/collect/fixture_index.py | 10 ++++ src/testless/collect/pytest_runner.py | 28 ++++++++++- tests/test_cli.py | 3 ++ tests/test_collectors.py | 54 ++++++++++++++++++++++ 6 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/testless/cli.py b/src/testless/cli.py index 041441f..4901974 100644 --- a/src/testless/cli.py +++ b/src/testless/cli.py @@ -24,21 +24,33 @@ def main(ctx: click.Context, config: str | None) -> None: # --------------------------------------------------------------------------- @main.command() +@click.argument("test_paths", nargs=-1, type=click.Path()) +@click.option("--source", "-s", multiple=True, help="Source package or directory to measure coverage for.") +@click.option("--python", default=None, help="Python executable to run pytest with.") @click.option("--out", default=None, help="Override coverage output directory.") @click.pass_context -def scan(ctx: click.Context, out: str | None) -> None: +def scan( + ctx: click.Context, + test_paths: tuple[str, ...], + source: tuple[str, ...], + python: str | None, + out: str | None, +) -> None: """Run pytest with coverage contexts and collect test metadata.""" from testless.collect.pytest_runner import run_pytest cfg = ctx.obj["config"] coverage_dir = out or cfg.coverage_dir + target_dirs = list(test_paths) if test_paths else cfg.test_dirs + target_packages = list(source) if source else cfg.packages click.echo(f"Running pytest with coverage contexts → {coverage_dir}") tests, cov_json = run_pytest( - packages=cfg.packages, - test_dirs=cfg.test_dirs, + packages=target_packages, + test_dirs=target_dirs, coverage_dir=coverage_dir, extra_args=cfg.pytest_args, + python_executable=python, ) click.echo(f"Collected {len(tests)} tests. Coverage JSON: {cov_json}") diff --git a/src/testless/collect/endpoint_inventory.py b/src/testless/collect/endpoint_inventory.py index 3a02230..9dc9746 100644 --- a/src/testless/collect/endpoint_inventory.py +++ b/src/testless/collect/endpoint_inventory.py @@ -39,6 +39,11 @@ class EndpointInventory: _HTTP_CLIENT_INDICATORS = {"requests", "httpx", "aiohttp", "urllib", "ClientSession"} _RETRY_INDICATORS = {"retry", "backoff", "tenacity", "Retry"} + _IGNORED_PARTS = { + ".venv", "venv", ".git", ".worktrees", "node_modules", + "build", "dist", ".tox", "__pycache__", ".subactor", ".planfile" + } + def __init__(self) -> None: self.endpoints: list[EndpointInfo] = [] self.services: list[ServiceInfo] = [] @@ -47,6 +52,12 @@ def scan_directory(self, directory: str | Path) -> None: """Recursively scan Python source files.""" root = Path(directory) for py_file in root.rglob("*.py"): + # Skip ignored directories and vendor packages + if any( + part in self._IGNORED_PARTS or (part.startswith(".") and part not in (".", "..")) + for part in py_file.parts[:-1] + ): + continue # Skip test files if py_file.name.startswith("test_") or py_file.name.endswith("_test.py"): continue diff --git a/src/testless/collect/fixture_index.py b/src/testless/collect/fixture_index.py index d8e31f2..c75fb2e 100644 --- a/src/testless/collect/fixture_index.py +++ b/src/testless/collect/fixture_index.py @@ -9,6 +9,11 @@ class FixtureIndex: """Map test node IDs to their declared fixture parameters.""" + _IGNORED_PARTS = { + ".venv", "venv", ".git", ".worktrees", "node_modules", + "build", "dist", ".tox", "__pycache__", ".subactor", ".planfile" + } + def __init__(self) -> None: self._test_fixtures: dict[str, list[str]] = {} self._fixture_defs: dict[str, str] = {} # fixture name -> file @@ -17,6 +22,11 @@ def scan_directory(self, directory: str | Path) -> None: """Recursively scan a directory for test files and conftest.py.""" root = Path(directory) for py_file in root.rglob("*.py"): + if any( + part in self._IGNORED_PARTS or (part.startswith(".") and part not in (".", "..")) + for part in py_file.parts[:-1] + ): + continue self._scan_file(py_file) def _scan_file(self, path: Path) -> None: diff --git a/src/testless/collect/pytest_runner.py b/src/testless/collect/pytest_runner.py index ba8867c..b863b27 100644 --- a/src/testless/collect/pytest_runner.py +++ b/src/testless/collect/pytest_runner.py @@ -10,11 +10,33 @@ from testless.models.findings import TestMeta +def resolve_python_executable(custom_python: str | None = None) -> str: + """Resolve the appropriate Python executable (custom, venv, or current).""" + if custom_python and Path(custom_python).is_file(): + return str(Path(custom_python).resolve()) + import os + if "VIRTUAL_ENV" in os.environ: + candidate = Path(os.environ["VIRTUAL_ENV"]) / "bin" / "python" + if candidate.is_file(): + return str(candidate.resolve()) + for rel in ( + ".venv/bin/python", + "venv/bin/python", + ".venv/Scripts/python.exe", + "venv/Scripts/python.exe", + ): + candidate = Path(rel) + if candidate.is_file(): + return str(candidate.resolve()) + return sys.executable + + def run_pytest( packages: list[str], test_dirs: list[str], coverage_dir: str = ".coverage_data", extra_args: list[str] | None = None, + python_executable: str | None = None, ) -> tuple[list[TestMeta], Path]: """ Run pytest with coverage contexts enabled and collect test metadata. @@ -34,8 +56,10 @@ def run_pytest( else: cov_source = "." + python_bin = resolve_python_executable(python_executable) + cmd = [ - sys.executable, + python_bin, "-m", "pytest", "--tb=no", @@ -68,7 +92,7 @@ def run_pytest( try: subprocess.run( [ - sys.executable, + python_bin, "-m", "coverage", "json", diff --git a/tests/test_cli.py b/tests/test_cli.py index a0f3cfa..279146d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,9 @@ def test_scan_help(): runner = CliRunner() result = runner.invoke(main, ["scan", "--help"]) assert result.exit_code == 0 + assert "--source" in result.output + assert "--python" in result.output + def test_duplicates_help(): diff --git a/tests/test_collectors.py b/tests/test_collectors.py index 33cda15..4eedd77 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -130,3 +130,57 @@ def test_run_pytest_resilient(tmp_path: Path): assert json_path.exists() assert isinstance(tests, list) + +def test_endpoint_inventory_ignores_vendor_dirs(tmp_path: Path): + venv_dir = tmp_path / ".venv" / "lib" + venv_dir.mkdir(parents=True) + vendor_file = venv_dir / "routes.py" + vendor_file.write_text( + textwrap.dedent("""\ + @app.route('/vendor_endpoint') + def vendor(): + pass + """) + ) + inv = EndpointInventory() + inv.scan_directory(tmp_path) + paths = {e.path for e in inv.endpoints} + assert "/vendor_endpoint" not in paths + + +def test_fixture_index_ignores_vendor_dirs(tmp_path: Path): + node_dir = tmp_path / "node_modules" / "sub" + node_dir.mkdir(parents=True) + vendor_file = node_dir / "conftest.py" + vendor_file.write_text( + textwrap.dedent("""\ + import pytest + + @pytest.fixture + def vendor_fixture(): + return 42 + """) + ) + index = FixtureIndex() + index.scan_directory(tmp_path) + assert "vendor_fixture" not in index.all_defined_fixtures() + + +def test_resolve_python_executable(tmp_path: Path, monkeypatch): + from testless.collect.pytest_runner import resolve_python_executable + + # Custom python provided and exists + dummy_py = tmp_path / "custom_python" + dummy_py.write_text("#!/bin/sh\n") + assert resolve_python_executable(str(dummy_py)) == str(dummy_py.resolve()) + + # VIRTUAL_ENV set + fake_venv = tmp_path / "fake_env" + fake_bin = fake_venv / "bin" + fake_bin.mkdir(parents=True) + fake_py = fake_bin / "python" + fake_py.write_text("#!/bin/sh\n") + monkeypatch.setenv("VIRTUAL_ENV", str(fake_venv)) + assert resolve_python_executable() == str(fake_py.resolve()) + + From 2b417f6217593611629ad8d65c4f482b29011307 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sun, 27 Sep 2026 12:29:23 +0200 Subject: [PATCH 2/2] fix(pytest_runner): preserve venv symlinks by using absolute path instead of resolve Co-authored-by: Koru Agent --- src/testless/collect/pytest_runner.py | 26 +++++++++++++++++++++----- tests/test_collectors.py | 5 +++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/testless/collect/pytest_runner.py b/src/testless/collect/pytest_runner.py index b863b27..810cf88 100644 --- a/src/testless/collect/pytest_runner.py +++ b/src/testless/collect/pytest_runner.py @@ -13,21 +13,37 @@ def resolve_python_executable(custom_python: str | None = None) -> str: """Resolve the appropriate Python executable (custom, venv, or current).""" if custom_python and Path(custom_python).is_file(): - return str(Path(custom_python).resolve()) + return str(Path(custom_python).absolute()) import os if "VIRTUAL_ENV" in os.environ: - candidate = Path(os.environ["VIRTUAL_ENV"]) / "bin" / "python" - if candidate.is_file(): - return str(candidate.resolve()) + venv_root = Path(os.environ["VIRTUAL_ENV"]) + for candidate in ( + venv_root / "bin" / "python", + venv_root / "bin" / "python3", + venv_root / "Scripts" / "python.exe", + ): + if candidate.is_file(): + return str(candidate.absolute()) for rel in ( ".venv/bin/python", + ".venv/bin/python3", "venv/bin/python", + "venv/bin/python3", ".venv/Scripts/python.exe", "venv/Scripts/python.exe", ): candidate = Path(rel) if candidate.is_file(): - return str(candidate.resolve()) + return str(candidate.absolute()) + if sys.prefix != getattr(sys, "base_prefix", sys.prefix): + prefix_root = Path(sys.prefix) + for candidate in ( + prefix_root / "bin" / "python", + prefix_root / "bin" / "python3", + prefix_root / "Scripts" / "python.exe", + ): + if candidate.is_file(): + return str(candidate.absolute()) return sys.executable diff --git a/tests/test_collectors.py b/tests/test_collectors.py index 4eedd77..9c33f2f 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -172,7 +172,7 @@ def test_resolve_python_executable(tmp_path: Path, monkeypatch): # Custom python provided and exists dummy_py = tmp_path / "custom_python" dummy_py.write_text("#!/bin/sh\n") - assert resolve_python_executable(str(dummy_py)) == str(dummy_py.resolve()) + assert resolve_python_executable(str(dummy_py)) == str(dummy_py.absolute()) # VIRTUAL_ENV set fake_venv = tmp_path / "fake_env" @@ -181,6 +181,7 @@ def test_resolve_python_executable(tmp_path: Path, monkeypatch): fake_py = fake_bin / "python" fake_py.write_text("#!/bin/sh\n") monkeypatch.setenv("VIRTUAL_ENV", str(fake_venv)) - assert resolve_python_executable() == str(fake_py.resolve()) + assert resolve_python_executable() == str(fake_py.absolute()) +