diff --git a/docs/changelog/219.bugfix.rst b/docs/changelog/219.bugfix.rst new file mode 100644 index 0000000..1297457 --- /dev/null +++ b/docs/changelog/219.bugfix.rst @@ -0,0 +1 @@ +Stop repeated interpreter queries when a virtual environment reports the same base prefix with different path spellings. diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 09733de..f4d26b1 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -10,6 +10,7 @@ import sys from collections import OrderedDict from itertools import product +from os.path import normcase, realpath from string import digits from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple, TypedDict @@ -462,7 +463,8 @@ def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> Pyt prefixes = OrderedDict() while target.system_executable is None: prefix = target.real_prefix or target.base_prefix or target.prefix - if prefix in prefixes: + prefix_key = normcase(realpath(prefix)) + if prefix_key in prefixes: if len(prefixes) == 1: _LOGGER.info("%r links back to itself via prefixes", target) target.system_executable = target.executable @@ -472,7 +474,7 @@ def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> Pyt _LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target) msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys())) raise RuntimeError(msg) - prefixes[prefix] = target + prefixes[prefix_key] = target target = target.discover_exe(cache, prefix=prefix, exact=False) if target.executable != target.system_executable: resolved = cls.from_exe(target.system_executable, cache) diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 016636a..deedb5a 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -168,6 +168,21 @@ def test_resolve_to_system_single_prefix_self_link(mocker: MockerFixture, caplog assert any("links back to itself" in r.message for r in caplog.records) +def test_resolve_to_system_repeated_prefix_with_parent_segments(tmp_path: Path, mocker: MockerFixture) -> None: + (tmp_path / "child").mkdir() + target = PythonInfo() + target.system_executable = target.real_prefix = None + target.base_prefix = str(tmp_path) + repeated = PythonInfo() + repeated.system_executable = repeated.real_prefix = None + repeated.base_prefix = str(tmp_path / "child" / "..") + mocker.patch.object(PythonInfo, "discover_exe", return_value=repeated) + + result = PythonInfo.resolve_to_system(None, target) + + assert result.system_executable == repeated.executable + + def test_discover_exe_cache_hit() -> None: info = copy.deepcopy(CURRENT) cached = copy.deepcopy(CURRENT)