Skip to content

Commit b9f7703

Browse files
committed
gh-128670: keep the public base executable for venv-from-venv
When a virtual environment is created from another virtual environment whose base interpreter is reached through a symlink (for example Homebrew's stable ``opt`` path pointing at a versioned ``Cellar`` path, or an NFS canonical path), getpath resolved ``sys._base_executable`` all the way to the internal target and recorded that internal, implementation -detail path in the child environment's ``pyvenv.cfg`` ``home`` key. When the internal path later changes (a patch upgrade removes the old versioned tree), the environment breaks. In the pyvenv.cfg ``home`` branch, prefer the executable found in ``home`` when it resolves to the same real file as the running executable, instead of the fully resolved path. Match on the resolved executable's name, since the environment's primary executable may be ``python`` while ``home`` only provides ``python3.X``. Fall back to the resolved path when ``home`` has no matching executable.
1 parent e28a2f4 commit b9f7703

3 files changed

Lines changed: 143 additions & 5 deletions

File tree

Lib/test/test_getpath.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,117 @@ def test_venv_posix(self):
354354
actual = getpath(ns, expected)
355355
self.assertEqual(expected, actual)
356356

357+
def test_venv_posix_from_symlinked_base(self):
358+
# gh-128670: the base interpreter is reached through a public symlink
359+
# (e.g. Homebrew's stable opt path) that points to an internal,
360+
# versioned location. base_executable must keep the public path so
361+
# the internal path is not leaked into a child venv's pyvenv.cfg.
362+
ns = MockPosixNamespace(
363+
argv0="/venv/bin/python",
364+
PREFIX="/real",
365+
)
366+
ns.add_known_xfile("/venv/bin/python")
367+
ns.add_known_xfile("/pub/bin/python")
368+
ns.add_known_xfile("/real/bin/python")
369+
ns.add_known_link("/venv/bin/python", "/pub/bin/python")
370+
ns.add_known_link("/pub/bin/python", "/real/bin/python")
371+
ns.add_known_file("/venv/pyvenv.cfg", [
372+
r"home = /pub/bin"
373+
])
374+
ns.add_known_file("/real/lib/python9.8/os.py")
375+
ns.add_known_dir("/real/lib/python9.8/lib-dynload")
376+
expected = dict(
377+
executable="/venv/bin/python",
378+
prefix="/venv",
379+
exec_prefix="/venv",
380+
base_executable="/pub/bin/python",
381+
base_prefix="/real",
382+
base_exec_prefix="/real",
383+
module_search_paths_set=1,
384+
module_search_paths=[
385+
"/real/lib/python98.zip",
386+
"/real/lib/python9.8",
387+
"/real/lib/python9.8/lib-dynload",
388+
],
389+
)
390+
actual = getpath(ns, expected)
391+
self.assertEqual(expected, actual)
392+
393+
def test_venv_posix_from_symlinked_base_versioned(self):
394+
# gh-128670: like the above, but the venv's primary executable is
395+
# 'python' while 'home' only provides the versioned 'python3.8' name.
396+
# base_executable must match on the resolved name, not 'python'.
397+
ns = MockPosixNamespace(
398+
argv0="/venv/bin/python",
399+
PREFIX="/real",
400+
)
401+
ns.add_known_xfile("/venv/bin/python")
402+
ns.add_known_xfile("/pub/bin/python3.8")
403+
ns.add_known_xfile("/real/bin/python3.8")
404+
ns.add_known_link("/venv/bin/python", "/pub/bin/python3.8")
405+
ns.add_known_link("/pub/bin/python3.8", "/real/bin/python3.8")
406+
ns.add_known_file("/venv/pyvenv.cfg", [
407+
r"home = /pub/bin"
408+
])
409+
ns.add_known_file("/real/lib/python9.8/os.py")
410+
ns.add_known_dir("/real/lib/python9.8/lib-dynload")
411+
expected = dict(
412+
executable="/venv/bin/python",
413+
prefix="/venv",
414+
exec_prefix="/venv",
415+
base_executable="/pub/bin/python3.8",
416+
base_prefix="/real",
417+
base_exec_prefix="/real",
418+
module_search_paths_set=1,
419+
module_search_paths=[
420+
"/real/lib/python98.zip",
421+
"/real/lib/python9.8",
422+
"/real/lib/python9.8/lib-dynload",
423+
],
424+
)
425+
actual = getpath(ns, expected)
426+
self.assertEqual(expected, actual)
427+
428+
def test_venv_posix_symlinked_base_mismatch_resolves(self):
429+
# gh-128670 safety: if 'home' does not provide an executable that
430+
# resolves to the running interpreter, base_executable resolves the
431+
# symlink rather than trusting 'home'.
432+
ns = MockPosixNamespace(
433+
argv0="/venv/bin/python",
434+
PREFIX="/real",
435+
)
436+
ns.add_known_xfile("/venv/bin/python")
437+
ns.add_known_xfile("/real/bin/python")
438+
ns.add_known_xfile("/pub/bin/python")
439+
ns.add_known_xfile("/other/bin/python")
440+
ns.add_known_link("/venv/bin/python", "/real/bin/python")
441+
ns.add_known_link("/pub/bin/python", "/other/bin/python")
442+
ns.add_known_file("/venv/pyvenv.cfg", [
443+
r"home = /pub/bin"
444+
])
445+
ns.add_known_file("/real/lib/python9.8/os.py")
446+
ns.add_known_dir("/real/lib/python9.8/lib-dynload")
447+
actual = getpath(ns, {"base_executable": ""})
448+
self.assertEqual(actual["base_executable"], "/real/bin/python")
449+
450+
def test_venv_posix_symlinked_base_no_home_exe(self):
451+
# gh-128670 fallback: if 'home' has no matching executable,
452+
# base_executable resolves the symlink.
453+
ns = MockPosixNamespace(
454+
argv0="/venv/bin/python",
455+
PREFIX="/real",
456+
)
457+
ns.add_known_xfile("/venv/bin/python")
458+
ns.add_known_xfile("/real/bin/python")
459+
ns.add_known_link("/venv/bin/python", "/real/bin/python")
460+
ns.add_known_file("/venv/pyvenv.cfg", [
461+
r"home = /pub/bin"
462+
])
463+
ns.add_known_file("/real/lib/python9.8/os.py")
464+
ns.add_known_dir("/real/lib/python9.8/lib-dynload")
465+
actual = getpath(ns, {"base_executable": ""})
466+
self.assertEqual(actual["base_executable"], "/real/bin/python")
467+
357468
def test_venv_posix_without_home_key(self):
358469
ns = MockPosixNamespace(
359470
argv0="/venv/bin/python3",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Creating a virtual environment from another virtual environment no longer
2+
resolves a symlinked base interpreter further than the original environment
3+
did, so an internal, implementation-detail install path (such as a Homebrew
4+
``Cellar`` directory) is no longer baked into the second environment's
5+
``pyvenv.cfg``.

Modules/getpath.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -382,15 +382,37 @@ def search_up(prefix, *landmarks, test=isfile):
382382
# the base installation — isn't set (eg. when embedded), try to find
383383
# it in 'home'.
384384
if not base_executable:
385-
# First try to resolve symlinked executables, since that may be
386-
# more accurate than assuming the executable in 'home'.
385+
# Prefer the executable found in 'home' (the public, possibly
386+
# symlinked, location the venv was created from) when it resolves
387+
# to the same real file as the running executable. This avoids
388+
# baking an internal, implementation-detail prefix (e.g. a
389+
# Homebrew Cellar path) into pyvenv.cfg, which then breaks the
390+
# venv when that internal path changes (gh-128670). Match on the
391+
# *resolved* executable's name, since the venv's primary exe may
392+
# be 'python' while 'home' only provides 'python3.X'. Fall back to
393+
# the fully resolved path when 'home' has no matching executable.
387394
try:
388-
base_executable = realpath(executable)
395+
_executable_realpath = realpath(executable)
396+
except OSError:
397+
_executable_realpath = ''
398+
_home_executable = ''
399+
if _executable_realpath:
400+
_home_executable = joinpath(executable_dir,
401+
basename(_executable_realpath))
402+
try:
403+
_home_realpath = realpath(_home_executable) if _home_executable else ''
404+
except OSError:
405+
_home_realpath = ''
406+
if (_executable_realpath and _home_executable
407+
and isfile(_home_executable)
408+
and _home_realpath == _executable_realpath):
409+
base_executable = _home_executable
410+
else:
411+
base_executable = _executable_realpath
389412
if base_executable == executable:
390413
# No change, so probably not a link. Clear it and fall back
391414
base_executable = ''
392-
except OSError:
393-
pass
415+
394416
if not base_executable:
395417
base_executable = joinpath(executable_dir, basename(executable))
396418
# It's possible "python" is executed from within a posix venv but that

0 commit comments

Comments
 (0)