diff --git a/README.md b/README.md index c255eca..fad81a7 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ The baseline command has to pass before any mutant runs. A mutant's timeout is t | Go | `go test -count=1` of the file's package | nearest `go.mod` | | TypeScript | `npm test` | nearest `package.json` | | Rust | `cargo test` | nearest `Cargo.toml` | -| Python | `python -m pytest`, or `unittest discover` | nearest project file | +| Python | the project's `.venv` or `venv` Python running `pytest`, or `unittest discover` | nearest project file | Coverage is generated with crapper's commands unless `--use-existing-coverage` or `--no-coverage` is set. A file missing from the report is treated as uncovered, and the run says so. diff --git a/src/mutator/runner.py b/src/mutator/runner.py index 1c3a90d..86f829a 100644 --- a/src/mutator/runner.py +++ b/src/mutator/runner.py @@ -128,7 +128,22 @@ def _clojure_command(directory: Path) -> str: return "clj -M:test" +def _project_interpreter(directory: Path) -> str: + """The project's virtualenv, or this process when the project has none. + + The path stays absolute and the symlink is left in place. A worker does + not link ``.venv``, and resolving ``bin/python`` would leave the virtualenv. + """ + + for name in (".venv", "venv"): + candidate = (directory / name / "bin" / "python").absolute() + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + return sys.executable + + def _python_command(directory: Path) -> str: + interpreter = _project_interpreter(directory) pyproject = _read(directory / "pyproject.toml") if ( (directory / "pytest.ini").is_file() @@ -136,8 +151,8 @@ def _python_command(directory: Path) -> str: or "pytest" in pyproject or (directory / "setup.cfg").is_file() and "pytest" in _read(directory / "setup.cfg") ): - return f"{sys.executable} -m pytest" - return f"{sys.executable} -m unittest discover" + return f"{interpreter} -m pytest" + return f"{interpreter} -m unittest discover" def test_plan(root: Path, source: Path, language: str, override: str | None) -> tuple[str, Path]: diff --git a/tests/test_runner.py b/tests/test_runner.py index 083019e..6f40b03 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -44,6 +44,51 @@ def test_clojure_commands(tmp_path): assert _clojure_command(plain) == "clj -M:test" +def _executable(path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\n", encoding="utf-8") + path.chmod(0o755) + + +def test_python_command_uses_an_absolute_project_interpreter(tmp_path): + project = tmp_path / "proj" + dot = project / ".venv" / "bin" / "python" + _executable(dot) + (project / "pytest.ini").write_text("[pytest]\n", encoding="utf-8") + assert _python_command(project) == f"{dot.absolute()} -m pytest" + + other = tmp_path / "other" + plain = other / "venv" / "bin" / "python" + _executable(plain) + assert _python_command(other) == f"{plain.absolute()} -m unittest discover" + + _executable(other / ".venv" / "bin" / "python") + assert _python_command(other) == f"{(other / '.venv' / 'bin' / 'python').absolute()} -m unittest discover" + + +def test_python_command_keeps_the_virtualenv_symlink(tmp_path): + project = tmp_path / "proj" + target = project / "real-python" + _executable(target) + link = project / ".venv" / "bin" / "python" + link.parent.mkdir(parents=True) + link.symlink_to(target) + (project / "pytest.ini").write_text("[pytest]\n", encoding="utf-8") + assert _python_command(project) == f"{link.absolute()} -m pytest" + + +def test_python_command_falls_back_when_the_virtualenv_cannot_run(tmp_path): + project = tmp_path / "proj" + binary = project / ".venv" / "bin" / "python" + binary.parent.mkdir(parents=True) + binary.write_text("", encoding="utf-8") + assert _python_command(project) == f"{sys.executable} -m unittest discover" + + missing = tmp_path / "missing" + missing.mkdir() + assert _python_command(missing) == f"{sys.executable} -m unittest discover" + + def test_python_commands(tmp_path): pytest_dir = tmp_path / "py" pytest_dir.mkdir()