Skip to content

Commit 4f6132b

Browse files
authored
šŸ› fix: stop symlink resolution at stdlib landmark and framework builds (#87)
The resolution added in #84 follows every symlink hop of the executable. pypa/virtualenv CI caught two unwanted consequences on its `brew@3.12`/`brew@3.13` jobs: Homebrew's `/opt/homebrew/bin/python3.12` resolves through the version-pinned `Cellar/python@3.12/<version>` directory, so the recorded `home` breaks on `brew upgrade` and the base site-packages shows up in created environments under a `Cellar` alias that no longer matches the interpreter's own reported paths. šŸ› Stable distro aliases such as Debian's `/usr/bin/python3` were also rewritten to the versioned binary for no benefit. Real `getpath` only follows the executable symlink while the stdlib landmark is missing beside it, so the walk now stops as soon as `<dir>/../lib(64)/pythonX.Y/os.py` is reachable, and macOS framework builds skip resolution entirely since they self-locate through the real binary via `dyld` at runtime. The pypa/virtualenv#3157 scenario, a symlink in a directory with no installation around it, keeps resolving to the real interpreter exactly as before, verified against both a python-build-standalone interpreter and a Homebrew one where created environments now match the pre-#84 layout byte for byte. Fixes #86.
1 parent 95e6470 commit 4f6132b

3 files changed

Lines changed: 47 additions & 5 deletions

File tree

ā€Ždocs/changelog/86.bugfix.rstā€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Stop executable symlink resolution once the stdlib landmark is reachable and keep macOS framework builds untouched,
2+
matching ``getpath`` - Homebrew interpreters no longer get version-pinned ``Cellar`` paths recorded and stable
3+
aliases such as Debian's ``/usr/bin/python3`` are preserved - by :user:`gaborbernat`.

ā€Žsrc/python_discovery/_py_info.pyā€Ž

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,21 +225,28 @@ def _fast_get_system_executable(self) -> str | None:
225225
# Try fallback for POSIX virtual environments
226226
return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
227227

228-
def _resolve_executable_symlink(self, path: str) -> str:
228+
def _resolve_executable_symlink(self, path: str, *, framework: bool | None = None) -> str:
229229
"""
230230
Resolve symlinks of the executable itself, but never of its parent directories.
231231
232232
Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink
233233
resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is
234-
kept as-is.
234+
kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current
235+
directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched.
235236
"""
236237
result = os.path.abspath(path)
237238
if self.os != "posix": # CPython only does this where HAVE_READLINK
238239
return result
240+
if framework is None:
241+
framework = bool(sysconfig.get_config_var("PYTHONFRAMEWORK"))
242+
if framework: # macOS framework builds self-locate via dyld from the real binary; e.g. for Homebrew
243+
return result # resolving would pin the versioned Cellar path into the recorded home
239244
real_path = os.path.realpath(result)
240245
if not os.path.exists(real_path): # symlink loop or broken symlink
241246
return result
242247
while os.path.islink(result):
248+
if self._stdlib_landmark_exists(os.path.dirname(result)):
249+
return result
243250
link = os.readlink(result)
244251
candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link))
245252
# 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:
248255
result = candidate
249256
return result
250257

258+
@staticmethod
259+
def _stdlib_landmark_exists(dir_path: str) -> bool:
260+
lib_name = os.path.basename(os.path.dirname(os.__file__))
261+
return any(
262+
os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64")
263+
)
264+
251265
def _try_posix_fallback_executable(self, base_executable: str) -> str | None:
252266
"""Find a versioned Python binary as fallback for POSIX virtual environments."""
253267
major, minor = self.version_info.major, self.version_info.minor

ā€Žtests/test_py_info_extra.pyā€Ž

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,36 @@ def test_resolve_executable_symlink(
183183
layout: Callable[[Path], tuple[Path, Path]],
184184
) -> None:
185185
path, expected = layout(tmp_path)
186-
assert posix_info._resolve_executable_symlink(str(path)) == str(expected)
186+
assert posix_info._resolve_executable_symlink(str(path), framework=False) == str(expected)
187187

188188

189189
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
190-
def test_from_exe_resolves_executable_only_symlink(tmp_path: Path, session_cache: DiskCache) -> None:
190+
def test_resolve_executable_symlink_framework_kept(tmp_path: Path, posix_info: PythonInfo) -> None:
191+
link, _exe = _layout_absolute_symlink(tmp_path)
192+
assert posix_info._resolve_executable_symlink(str(link), framework=True) == str(link)
193+
194+
195+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
196+
def test_resolve_executable_symlink_stdlib_landmark_kept(tmp_path: Path, posix_info: PythonInfo) -> None:
197+
exe = tmp_path / "install" / "bin" / "python3.12"
198+
exe.parent.mkdir(parents=True)
199+
exe.touch()
200+
alias_bin = tmp_path / "alias" / "bin"
201+
alias_bin.mkdir(parents=True)
202+
landmark = tmp_path / "alias" / "lib" / Path(os.__file__).parent.name / "os.py"
203+
landmark.parent.mkdir(parents=True)
204+
landmark.touch()
205+
link = alias_bin / "python3"
206+
link.symlink_to(exe)
207+
assert posix_info._resolve_executable_symlink(str(link), framework=False) == str(link)
208+
209+
210+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
211+
@pytest.mark.skipif(bool(CURRENT.sysconfig_vars.get("PYTHONFRAMEWORK")), reason="framework builds keep recorded path")
212+
def test_from_exe_resolves_executable_only_symlink( # pragma: no cover # skipped on framework interpreter hosts
213+
tmp_path: Path,
214+
session_cache: DiskCache,
215+
) -> None:
191216
system_exe = CURRENT.system_executable
192217
assert system_exe is not None
193218
link = tmp_path / "python3"
@@ -196,7 +221,7 @@ def test_from_exe_resolves_executable_only_symlink(tmp_path: Path, session_cache
196221
assert info is not None
197222
assert info.system_executable is not None
198223
assert Path(info.system_executable).samefile(system_exe)
199-
assert not Path(info.system_executable).is_symlink()
224+
assert Path(info.system_executable).parent != tmp_path
200225

201226

202227
def test_try_posix_fallback_not_posix() -> None:

0 commit comments

Comments
Ā (0)