From 4d069c84a51a0e58e26db7d56dcb315eff50e74d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 17:03:47 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(probe):=20preserve=20sup?= =?UTF-8?q?plied=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/+base-probe-env.bugfix.rst | 1 + src/python_discovery/_py_info.py | 11 ++++--- tests/test_py_info_extra.py | 37 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/+base-probe-env.bugfix.rst 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..1cb3c8a 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import MagicMock +from venv import create import pytest @@ -490,6 +491,42 @@ 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(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + create(tmp_path / "venv", with_pip=False) + executable = tmp_path / "venv" / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + process_only = tmp_path / "process-only" + mapped = tmp_path / "mapped" + monkeypatch.setenv("PYTHONPATH", str(process_only)) + + info = PythonInfo.from_exe(str(executable), env={**os.environ, "PYTHONPATH": str(mapped)}) + + assert info is not None + assert (str(mapped) in info.path, str(process_only) in info.path) == (True, False) + + +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" From 08a7d5551f5d4ae78ea7fae52aa0d65f14b475f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 17:56:22 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20test(probe):=20avoid=20reloc?= =?UTF-8?q?atable=20venvs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_py_info_extra.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 1cb3c8a..d93fb90 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import MagicMock -from venv import create import pytest @@ -491,17 +490,30 @@ 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(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - create(tmp_path / "venv", with_pip=False) - executable = tmp_path / "venv" / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") - process_only = tmp_path / "process-only" - mapped = tmp_path / "mapped" - monkeypatch.setenv("PYTHONPATH", str(process_only)) +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(str(executable), env={**os.environ, "PYTHONPATH": str(mapped)}) + info = PythonInfo.from_exe("venv", env={"PYTHONPATH": "mapped"}) assert info is not None - assert (str(mapped) in info.path, str(process_only) in info.path) == (True, False) + assert info.path == ["mapped"] def test_resolve_to_system_uses_supplied_environment_for_prefix_probe(mocker: MockerFixture) -> None: