Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions pact-python-cli/src/pact_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import os
import shutil
import sys
import sysconfig
import warnings
from pathlib import Path
from typing import TYPE_CHECKING
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions pact-python-cli/tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import shutil
import subprocess
import sys
import sysconfig
import time
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -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()
Loading