From a51ae6c2a6f9c1a9a77451d21c73d2b64c13d64d Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:37:51 -0700 Subject: [PATCH 1/3] fix(py_info): stop venv lookups rewriting each other's executable resolve_to_system swaps a venv's info for the base interpreter's info, which comes straight out of the in-process cache, and then writes the venv's executable onto it. Every venv built on the same base, and the base itself, get that one object back, so each lookup rewrites the executable of every earlier result. A process running from a venv that looks up a second venv on the same base then gets the second venv from get_interpreter("python") and from PythonInfo.current_system(), and running that executable lands in the wrong environment. Results yielded by iter_interpreters change after the fact in the same way, and when the process runs from the base interpreter the object is the one PythonInfo.current() returns. resolve_to_system now copies the info before pointing it at the start executable, whether the info came from from_exe or from the discover_exe walk, since both hand back cached objects. When the executable already matches, the same object comes back as before, so virtualenv's check that the interpreter is PythonInfo.current() keeps working. --- src/python_discovery/_py_info.py | 6 +++++- tests/test_discovery.py | 12 ++++++++++++ tests/test_py_info_extra.py | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 3dc6161..879764f 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import json import logging import os @@ -475,7 +476,10 @@ def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> Pyt resolved = cls.from_exe(target.system_executable, cache) if resolved is not None: target = resolved - target.executable = start_executable + if target.executable != start_executable: + # the cache hands every lookup of this base the same object, so point a copy at the start executable + target = copy.copy(target) + target.executable = start_executable return target _cache_exe_discovery: ClassVar[dict[tuple[str, bool], PythonInfo]] = {} diff --git a/tests/test_discovery.py b/tests/test_discovery.py index b0cbb4d..5d464ef 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -167,6 +167,18 @@ def test_discovery_absolute_path_with_try_first( assert Path(interpreter.executable) == good_exe +def test_discovery_venvs_on_one_base_keep_their_executable(tmp_path: Path, session_cache: DiskCache) -> None: + scripts_dir = "Scripts" if IS_WIN else "bin" + exe_name = "python.exe" if IS_WIN else "python" + exes = [tmp_path / name / scripts_dir / exe_name for name in ("a", "b")] + for exe in exes: + subprocess.check_call([sys.executable, "-m", "venv", "--without-pip", str(exe.parents[1])]) + + found = [get_interpreter(str(exe), cache=session_cache) for exe in exes] + + assert [Path(info.executable) for info in found if info is not None] == exes + + def test_discovery_via_path_with_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: a_file = tmp_path / "a_file" a_file.touch() diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index a7488ea..bf4672e 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -439,3 +439,36 @@ def test_resolve_to_system_from_exe_returns_none(mocker: MockerFixture) -> None: mocker.patch.object(PythonInfo, "from_exe", return_value=None) result = PythonInfo.resolve_to_system(None, target) assert result.executable == "/some/venv/python" + + +@pytest.mark.parametrize( + "system_executable", + [ + pytest.param("/some/system/python", id="resolved"), + pytest.param(None, id="discovered"), + ], +) +def test_resolve_to_system_leaves_shared_info_untouched(mocker: MockerFixture, system_executable: str | None) -> None: + shared = copy.deepcopy(CURRENT) + shared.system_executable = shared.executable = "/some/system/python" + mocker.patch.object(PythonInfo, "from_exe", return_value=shared) + mocker.patch.object(PythonInfo, "discover_exe", return_value=shared) + + results = [] + for venv in ("/some/venv-a", "/some/venv-b"): + target = copy.deepcopy(CURRENT) + target.system_executable = system_executable + target.real_prefix, target.base_prefix, target.executable = None, venv, f"{venv}/python" + results.append(PythonInfo.resolve_to_system(None, target)) + + assert [(info.executable, info.system_executable) for info in results] == [ + ("/some/venv-a/python", "/some/system/python"), + ("/some/venv-b/python", "/some/system/python"), + ] + assert shared.executable == "/some/system/python" + + +def test_resolve_to_system_returns_target_when_executable_unchanged() -> None: + target = copy.deepcopy(CURRENT) + target.system_executable = target.executable = "/some/system/python" + assert PythonInfo.resolve_to_system(None, target) is target From a80b6f33b2b0f6644a024c4f1bee4737470fc25e Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:58:19 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20docs(changelog):=20add=20the?= =?UTF-8?q?=20news=20fragment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/+cached-info-copies.bugfix.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/changelog/+cached-info-copies.bugfix.rst diff --git a/docs/changelog/+cached-info-copies.bugfix.rst b/docs/changelog/+cached-info-copies.bugfix.rst new file mode 100644 index 0000000..06b1f28 --- /dev/null +++ b/docs/changelog/+cached-info-copies.bugfix.rst @@ -0,0 +1,2 @@ +Stop a venv lookup from rewriting the ``executable`` of earlier results, ``PythonInfo.current()`` and +``PythonInfo.current_system()`` when they share its base interpreter. From ae97f9416a8f3fed6e0d30070e760545042c9050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 27 Oct 2026 23:14:56 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A7=AA=20test(py=5Finfo):=20annotate?= =?UTF-8?q?=20the=20resolved=20results=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_py_info_extra.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index bf4672e..17f0427 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -454,7 +454,7 @@ def test_resolve_to_system_leaves_shared_info_untouched(mocker: MockerFixture, s mocker.patch.object(PythonInfo, "from_exe", return_value=shared) mocker.patch.object(PythonInfo, "discover_exe", return_value=shared) - results = [] + results: list[PythonInfo] = [] for venv in ("/some/venv-a", "/some/venv-b"): target = copy.deepcopy(CURRENT) target.system_executable = system_executable