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
1 change: 1 addition & 0 deletions docs/changelog/+base-probe-env.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use the supplied environment for base-interpreter probes when resolving a virtual environment.
11 changes: 7 additions & 4 deletions src/python_discovery/_py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ def _from_exe_with_args( # ruff:ignore[too-many-arguments]

if isinstance(proposed, PythonInfo) and resolve_to_host:
try:
proposed = proposed.resolve_to_system(cache, proposed)
proposed = proposed.resolve_to_system(cache, proposed, env=env)
except Exception as exception:
if raise_on_error:
raise
Expand Down Expand Up @@ -452,12 +452,15 @@ def from_dict(cls, data: dict[str, InfoValue]) -> PythonInfo:
return result

@classmethod
def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> PythonInfo:
def resolve_to_system(
cls, cache: PyInfoCache | None, target: PythonInfo, *, env: Mapping[str, str] | None = None
) -> PythonInfo:
"""
Walk virtualenv/venv prefix chains to find the underlying system interpreter.

:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param target: the interpreter to resolve.
:param env: environment mapping; defaults to :data:`os.environ`.
"""
start_executable = target.executable
prefixes = OrderedDict()
Expand All @@ -475,9 +478,9 @@ def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> Pyt
msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys()))
raise RuntimeError(msg)
prefixes[prefix_key] = target
target = target.discover_exe(cache, prefix=prefix, exact=False)
target = target.discover_exe(cache, prefix=prefix, exact=False, env=env)
if target.executable != target.system_executable:
resolved = cls.from_exe(target.system_executable, cache)
resolved = cls.from_exe(target.system_executable, cache, env=env)
if resolved is not None:
target = resolved
if target.executable != start_executable:
Expand Down
49 changes: 49 additions & 0 deletions tests/test_py_info_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,55 @@ def test_resolve_to_system_resolved_from_exe(mocker: MockerFixture, caplog: pyte
assert result.executable == "/some/venv/python"


def test_from_exe_uses_supplied_environment_for_base_probe(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("PYTHONPATH", "process-only")
target = copy.deepcopy(CURRENT)
target.executable = "venv"
target.system_executable = "system"
mapped = copy.deepcopy(CURRENT)
mapped.executable = mapped.system_executable = "system"
mapped.path = ["mapped"]
process_only = copy.deepcopy(mapped)
process_only.path = ["process-only"]
mocker.patch(
"python_discovery._cached_py_info.from_exe",
side_effect=lambda _cls, _cache, exe, **kwargs: {
"venv": target,
"system": mapped if kwargs["env"] == {"PYTHONPATH": "mapped"} else process_only,
}[exe],
)

info = PythonInfo.from_exe("venv", env={"PYTHONPATH": "mapped"})

assert info is not None
assert info.path == ["mapped"]


def test_resolve_to_system_uses_supplied_environment_for_prefix_probe(mocker: MockerFixture) -> None:
target = copy.deepcopy(CURRENT)
target.system_executable = None
target.real_prefix = None
target.base_prefix = "/prefix"

mapped = copy.deepcopy(CURRENT)
mapped.path = ["mapped"]
mapped.system_executable = mapped.executable
fallback = copy.deepcopy(CURRENT)
fallback.path = ["process"]
fallback.system_executable = fallback.executable
mocker.patch.object(
PythonInfo,
"discover_exe",
side_effect=lambda _cache, **kwargs: mapped if kwargs.get("env") == {"PYTHONPATH": "mapped"} else fallback,
)

resolved = PythonInfo.resolve_to_system(None, target, env={"PYTHONPATH": "mapped"})

assert resolved.path == ["mapped"]


def test_resolve_to_system_from_exe_returns_none(mocker: MockerFixture) -> None:
target = copy.deepcopy(CURRENT)
target.system_executable = "/some/system/python"
Expand Down