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. 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..17f0427 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: list[PythonInfo] = [] + 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