From 0dca4289920a827aba2cbc0c84163200d270b80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 11 Jun 2026 09:07:00 -0700 Subject: [PATCH] Stop symlink resolution at stdlib landmark and framework builds Following every hop pinned Homebrew's versioned Cellar path into the recorded home (breaks on brew upgrade, changes the alias under which base site-packages appear) and rewrote stable aliases like Debian's /usr/bin/python3. Mirror getpath: resolve only while lib(64)/pythonX.Y/os.py is not reachable next to the executable, and never for macOS framework builds, which self-locate through the real binary via dyld. Fixes #86. --- docs/changelog/86.bugfix.rst | 3 +++ src/python_discovery/_py_info.py | 18 ++++++++++++++++-- tests/test_py_info_extra.py | 31 ++++++++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 docs/changelog/86.bugfix.rst diff --git a/docs/changelog/86.bugfix.rst b/docs/changelog/86.bugfix.rst new file mode 100644 index 0000000..60992d6 --- /dev/null +++ b/docs/changelog/86.bugfix.rst @@ -0,0 +1,3 @@ +Stop executable symlink resolution once the stdlib landmark is reachable and keep macOS framework builds untouched, +matching ``getpath`` - Homebrew interpreters no longer get version-pinned ``Cellar`` paths recorded and stable +aliases such as Debian's ``/usr/bin/python3`` are preserved - by :user:`gaborbernat`. diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 41a7f98..a98a87a 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -225,21 +225,28 @@ def _fast_get_system_executable(self) -> str | None: # Try fallback for POSIX virtual environments return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover - def _resolve_executable_symlink(self, path: str) -> str: + def _resolve_executable_symlink(self, path: str, *, framework: bool | None = None) -> str: """ Resolve symlinks of the executable itself, but never of its parent directories. Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is - kept as-is. + kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current + directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched. """ result = os.path.abspath(path) if self.os != "posix": # CPython only does this where HAVE_READLINK return result + if framework is None: + framework = bool(sysconfig.get_config_var("PYTHONFRAMEWORK")) + if framework: # macOS framework builds self-locate via dyld from the real binary; e.g. for Homebrew + return result # resolving would pin the versioned Cellar path into the recorded home real_path = os.path.realpath(result) if not os.path.exists(real_path): # symlink loop or broken symlink return result while os.path.islink(result): + if self._stdlib_landmark_exists(os.path.dirname(result)): + return result link = os.readlink(result) candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link)) # normpath through a symlinked directory may point at a different file - stop resolving there @@ -248,6 +255,13 @@ def _resolve_executable_symlink(self, path: str) -> str: result = candidate return result + @staticmethod + def _stdlib_landmark_exists(dir_path: str) -> bool: + lib_name = os.path.basename(os.path.dirname(os.__file__)) + return any( + os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64") + ) + def _try_posix_fallback_executable(self, base_executable: str) -> str | None: """Find a versioned Python binary as fallback for POSIX virtual environments.""" major, minor = self.version_info.major, self.version_info.minor diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 7d4e95d..19b5685 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -183,11 +183,36 @@ def test_resolve_executable_symlink( layout: Callable[[Path], tuple[Path, Path]], ) -> None: path, expected = layout(tmp_path) - assert posix_info._resolve_executable_symlink(str(path)) == str(expected) + assert posix_info._resolve_executable_symlink(str(path), framework=False) == str(expected) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") -def test_from_exe_resolves_executable_only_symlink(tmp_path: Path, session_cache: DiskCache) -> None: +def test_resolve_executable_symlink_framework_kept(tmp_path: Path, posix_info: PythonInfo) -> None: + link, _exe = _layout_absolute_symlink(tmp_path) + assert posix_info._resolve_executable_symlink(str(link), framework=True) == str(link) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") +def test_resolve_executable_symlink_stdlib_landmark_kept(tmp_path: Path, posix_info: PythonInfo) -> None: + exe = tmp_path / "install" / "bin" / "python3.12" + exe.parent.mkdir(parents=True) + exe.touch() + alias_bin = tmp_path / "alias" / "bin" + alias_bin.mkdir(parents=True) + landmark = tmp_path / "alias" / "lib" / Path(os.__file__).parent.name / "os.py" + landmark.parent.mkdir(parents=True) + landmark.touch() + link = alias_bin / "python3" + link.symlink_to(exe) + assert posix_info._resolve_executable_symlink(str(link), framework=False) == str(link) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") +@pytest.mark.skipif(bool(CURRENT.sysconfig_vars.get("PYTHONFRAMEWORK")), reason="framework builds keep recorded path") +def test_from_exe_resolves_executable_only_symlink( # pragma: no cover # skipped on framework interpreter hosts + tmp_path: Path, + session_cache: DiskCache, +) -> None: system_exe = CURRENT.system_executable assert system_exe is not None link = tmp_path / "python3" @@ -196,7 +221,7 @@ def test_from_exe_resolves_executable_only_symlink(tmp_path: Path, session_cache assert info is not None assert info.system_executable is not None assert Path(info.system_executable).samefile(system_exe) - assert not Path(info.system_executable).is_symlink() + assert Path(info.system_executable).parent != tmp_path def test_try_posix_fallback_not_posix() -> None: