Skip to content

Commit 2a36128

Browse files
authored
🐛 fix(discovery): resolve base interpreter executable-only symlinks (#3166)
Creating an environment with `-p` pointing at a symlink to just the interpreter binary recorded the symlink in `pyvenv.cfg`: `home` ended up as the symlink's directory, which contains no `lib/`, and `base-executable` kept the unresolved link. 🐛 Standard CPython survives because `getpath` re-resolves the link when reading `home`, but layouts where `home` must directly locate the base stdlib, such as python-build-standalone, produce a broken environment (#3157). The resolution lives in python-discovery (tox-dev/python-discovery#85, refined by tox-dev/python-discovery#87): `system_executable` follows the symlink chain of the executable's final path component only, stopping as soon as the stdlib landmark is reachable and never touching macOS framework builds, the same semantics CPython's `getpath` uses and its `venv` adopts in python/cpython#115237. A barren-directory symlink resolves to the real interpreter while stable aliases like Homebrew's `opt` paths, Debian's `/usr/bin/python3`, or a fully symlinked interpreter tree keep their recorded form. This PR raises the dependency floor to `python-discovery>=1.4.2` and bumps the app-data `py_info` cache key from `4` to `5` so interpreter records probed by older virtualenv versions are not shared with the corrected ones. CI needs python-discovery `1.4.2` on PyPI (tox-dev/python-discovery#87 merge + release) to go green. Fixes #3157.
1 parent 5389c25 commit 2a36128

4 files changed

Lines changed: 28 additions & 2 deletions

File tree

docs/changelog/3157.bugfix.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Resolve executable-only symlinks when recording ``home`` and ``base-executable`` in ``pyvenv.cfg``, mirroring CPython's
2+
``getpath.realpath`` (python/cpython#115237), so environments created from a symlink to the interpreter binary locate
3+
the base stdlib (for example python-build-standalone); a fully symlinked interpreter tree is kept as-is - by
4+
:user:`gaborbernat`.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ dependencies = [
5050
"filelock>=3.24.2,<4; python_version>='3.10'",
5151
"importlib-metadata>=6.6; python_version<'3.8'",
5252
"platformdirs>=3.9.1,<5",
53-
"python-discovery>=1.4",
53+
"python-discovery>=1.4.2",
5454
"typing-extensions>=4.13.2; python_version<'3.11'",
5555
]
5656
urls.Documentation = "https://virtualenv.pypa.io"

src/virtualenv/app_data/via_disk_folder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def extract(self, path: Path, to_folder: Path | None) -> Generator[Path]:
8686

8787
@property
8888
def py_info_at(self) -> ReentrantFileLock:
89-
return self.lock / "py_info" / "4" # ty: ignore[invalid-return-type]
89+
return self.lock / "py_info" / "5" # ty: ignore[invalid-return-type]
9090

9191
def py_info(self, path: Path) -> PyInfoStoreDisk:
9292
return PyInfoStoreDisk(self.py_info_at, path)

tests/unit/create/test_creator.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,28 @@ def test_home_path_is_exe_parent(tmp_path, creator) -> None:
385385
assert any(os.path.exists(os.path.join(cfg["home"], exe)) for exe in exes)
386386

387387

388+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
389+
@pytest.mark.skipif(
390+
bool(CURRENT.sysconfig_vars.get("PYTHONFRAMEWORK")),
391+
reason="framework builds self-locate via dyld and keep the recorded path",
392+
)
393+
@pytest.mark.usefixtures("temp_app_data")
394+
def test_home_resolves_executable_only_symlink(tmp_path: Path) -> None:
395+
"""An executable-only symlink must not be recorded as home / base-executable (issue #3157)."""
396+
system_exe = CURRENT.system_executable
397+
assert system_exe is not None
398+
link = tmp_path / "symdir" / "python3"
399+
link.parent.mkdir()
400+
link.symlink_to(system_exe)
401+
402+
result = cli_run(["-p", str(link), str(tmp_path / "env"), "--seeder", "app-data", "--without-pip"])
403+
cfg = PyEnvCfg.from_file(result.creator.pyenv_cfg.path)
404+
405+
assert Path(cfg["home"]) != link.parent
406+
assert os.path.samefile(cfg["base-executable"], system_exe)
407+
assert Path(cfg["home"]) == Path(cfg["base-executable"]).parent
408+
409+
388410
@pytest.mark.usefixtures("temp_app_data")
389411
def test_create_parallel(tmp_path) -> None:
390412
def create(count) -> None:

0 commit comments

Comments
 (0)