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
2 changes: 2 additions & 0 deletions docs/changelog/+cached-info-copies.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion src/python_discovery/_py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import copy
import json
import logging
import os
Expand Down Expand Up @@ -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]] = {}
Expand Down
12 changes: 12 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
33 changes: 33 additions & 0 deletions tests/test_py_info_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading