diff --git a/pact-python-cli/src/pact_cli/__init__.py b/pact-python-cli/src/pact_cli/__init__.py index 403ba09f5..1e65d5fb1 100644 --- a/pact-python-cli/src/pact_cli/__init__.py +++ b/pact-python-cli/src/pact_cli/__init__.py @@ -31,6 +31,7 @@ import os import shutil import sys +import sysconfig import warnings from pathlib import Path from typing import TYPE_CHECKING @@ -43,7 +44,7 @@ ) if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Iterator, Mapping _USE_SYSTEM_BINS = os.getenv("PACT_USE_SYSTEM_BINS", "").upper() in ("TRUE", "YES") _BIN_DIR = Path(__file__).parent.resolve() / "bin" @@ -148,6 +149,39 @@ def _exec() -> None: os.execve(executable, [executable, *args], _telemetry_env()) # noqa: S606 +def _bundled_bin_dirs() -> Iterator[Path]: + """ + Determine the directories that may contain the bundled Pact binaries. + + The binaries are normally bundled in a `bin` directory alongside this + module, which is the first location searched. Relying solely on the + module-relative path is fragile, however: for editable or relocated + installs, or when a virtual environment is reused across several checkouts, + `__file__` may resolve to a directory that no longer contains the binaries. + The installation's `site-packages` directories are therefore also derived + from `sysconfig` so the bundled `bin` directory can be located independently + of `__file__`. + + Returns: + The candidate directories to search, in order of preference and + deduplicated. + """ + seen: set[Path] = set() + resolved_bin_dir = _BIN_DIR.resolve() + if resolved_bin_dir.is_dir(): + yield resolved_bin_dir + seen.add(resolved_bin_dir) + for name in ("platlib", "purelib"): + if ( + (path := sysconfig.get_path(name)) + and (candidate := Path(path).resolve() / "pact_cli" / "bin") + and candidate.is_dir() + and candidate not in seen + ): + yield candidate + seen.add(candidate) + + def _find_executable(executable: str) -> str | None: """ Find the path to an executable. @@ -173,7 +207,8 @@ def _find_executable(executable: str) -> str | None: if _USE_SYSTEM_BINS: bin_path = shutil.which(executable) else: - bin_path = shutil.which(executable, path=str(_BIN_DIR)) + search_path = os.pathsep.join(str(d) for d in _bundled_bin_dirs()) + bin_path = shutil.which(executable, path=search_path) if search_path else None if bin_path is None: system_path = shutil.which(executable) if system_path is not None: diff --git a/pact-python-cli/tests/test_init.py b/pact-python-cli/tests/test_init.py index 904e2e62a..1fc560b51 100644 --- a/pact-python-cli/tests/test_init.py +++ b/pact-python-cli/tests/test_init.py @@ -6,6 +6,7 @@ import shutil import subprocess import sys +import sysconfig import time from pathlib import Path from unittest.mock import patch @@ -237,3 +238,46 @@ def test_pact_command_does_not_warn(capsys: pytest.CaptureFixture[str]) -> None: pact_cli._exec() # noqa: SLF001 captured = capsys.readouterr() assert "deprecated" not in captured.err + + +def test_find_executable_resolves_via_sysconfig( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Bundled binaries are found via the install location, not just `__file__`. + + This reproduces the cross-checkout failure: a virtual environment is reused + across multiple checkouts, so the module-relative `bin` directory (derived + from `__file__`) points at a location that no longer contains the binary. + The binary remains discoverable through the install's `site-packages` + location reported by `sysconfig`. + """ + # The module-relative bin directory exists but does not contain the binary. + empty_bin = tmp_path / "stale" / "pact_cli" / "bin" + empty_bin.mkdir(parents=True) + monkeypatch.setattr(pact_cli, "_BIN_DIR", empty_bin) + monkeypatch.setattr(pact_cli, "_USE_SYSTEM_BINS", False) + + # The binary is only present in the install's site-packages bin directory. + site_packages = tmp_path / "site-packages" + real_bin = site_packages / "pact_cli" / "bin" + real_bin.mkdir(parents=True) + name = "pact.exe" if os.name == "nt" else "pact" + binary = real_bin / name + binary.write_text("") + binary.chmod(0o755) + + monkeypatch.setattr( + sysconfig, + "get_path", + lambda key, *_args, **_kwargs: ( + str(site_packages) if key in ("platlib", "purelib") else None + ), + ) + # Ensure the binary is not discoverable on PATH. + monkeypatch.setenv("PATH", str(tmp_path / "nonexistent")) + + found = pact_cli._find_executable("pact") # noqa: SLF001 + assert found is not None + assert Path(found).resolve() == binary.resolve()