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: