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
3 changes: 3 additions & 0 deletions docs/changelog/86.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 16 additions & 2 deletions src/python_discovery/_py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 28 additions & 3 deletions tests/test_py_info_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down