diff --git a/docs/changelog/+base-probe-env.bugfix.rst b/docs/changelog/+base-probe-env.bugfix.rst new file mode 100644 index 0000000..9f3c086 --- /dev/null +++ b/docs/changelog/+base-probe-env.bugfix.rst @@ -0,0 +1 @@ +Use the supplied environment for base-interpreter probes when resolving a virtual environment. diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index ecf6cc6..a85b4b9 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -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 @@ -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() @@ -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: diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index b7642bd..d93fb90 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -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"