From 33b5541b454ddb38450493727d62ab964d50f9ef Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Tue, 30 Jun 2026 09:31:22 +1000 Subject: [PATCH] fix(cli): improve bin dirs search As discovered in #1623, the path resolution is not as rigorous as originally anticipated, with some situations (e.g., worktrees, or displaced venvs) resulting in Pact Python CLI being unable to discover the bundled binaries. Signed-off-by: JP-Ellis --- pact-python-cli/src/pact_cli/__init__.py | 39 +++++++++++++++++++-- pact-python-cli/tests/test_init.py | 44 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) 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()