From b9f7703bddb540f996272a3843e75fa03ea2741f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 5 Jun 2026 18:49:19 -0700 Subject: [PATCH] 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. --- Lib/test/test_getpath.py | 111 ++++++++++++++++++ ...-06-05-18-39-35.gh-issue-128670.75Gm5f.rst | 5 + Modules/getpath.py | 32 ++++- 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-05-18-39-35.gh-issue-128670.75Gm5f.rst diff --git a/Lib/test/test_getpath.py b/Lib/test/test_getpath.py index 83f09f3495547ac..fbdc518773132bb 100644 --- a/Lib/test/test_getpath.py +++ b/Lib/test/test_getpath.py @@ -354,6 +354,117 @@ def test_venv_posix(self): actual = getpath(ns, expected) self.assertEqual(expected, actual) + def test_venv_posix_from_symlinked_base(self): + # gh-128670: the base interpreter is reached through a public symlink + # (e.g. Homebrew's stable opt path) that points to an internal, + # versioned location. base_executable must keep the public path so + # the internal path is not leaked into a child venv's pyvenv.cfg. + ns = MockPosixNamespace( + argv0="/venv/bin/python", + PREFIX="/real", + ) + ns.add_known_xfile("/venv/bin/python") + ns.add_known_xfile("/pub/bin/python") + ns.add_known_xfile("/real/bin/python") + ns.add_known_link("/venv/bin/python", "/pub/bin/python") + ns.add_known_link("/pub/bin/python", "/real/bin/python") + ns.add_known_file("/venv/pyvenv.cfg", [ + r"home = /pub/bin" + ]) + ns.add_known_file("/real/lib/python9.8/os.py") + ns.add_known_dir("/real/lib/python9.8/lib-dynload") + expected = dict( + executable="/venv/bin/python", + prefix="/venv", + exec_prefix="/venv", + base_executable="/pub/bin/python", + base_prefix="/real", + base_exec_prefix="/real", + module_search_paths_set=1, + module_search_paths=[ + "/real/lib/python98.zip", + "/real/lib/python9.8", + "/real/lib/python9.8/lib-dynload", + ], + ) + actual = getpath(ns, expected) + self.assertEqual(expected, actual) + + def test_venv_posix_from_symlinked_base_versioned(self): + # gh-128670: like the above, but the venv's primary executable is + # 'python' while 'home' only provides the versioned 'python3.8' name. + # base_executable must match on the resolved name, not 'python'. + ns = MockPosixNamespace( + argv0="/venv/bin/python", + PREFIX="/real", + ) + ns.add_known_xfile("/venv/bin/python") + ns.add_known_xfile("/pub/bin/python3.8") + ns.add_known_xfile("/real/bin/python3.8") + ns.add_known_link("/venv/bin/python", "/pub/bin/python3.8") + ns.add_known_link("/pub/bin/python3.8", "/real/bin/python3.8") + ns.add_known_file("/venv/pyvenv.cfg", [ + r"home = /pub/bin" + ]) + ns.add_known_file("/real/lib/python9.8/os.py") + ns.add_known_dir("/real/lib/python9.8/lib-dynload") + expected = dict( + executable="/venv/bin/python", + prefix="/venv", + exec_prefix="/venv", + base_executable="/pub/bin/python3.8", + base_prefix="/real", + base_exec_prefix="/real", + module_search_paths_set=1, + module_search_paths=[ + "/real/lib/python98.zip", + "/real/lib/python9.8", + "/real/lib/python9.8/lib-dynload", + ], + ) + actual = getpath(ns, expected) + self.assertEqual(expected, actual) + + def test_venv_posix_symlinked_base_mismatch_resolves(self): + # gh-128670 safety: if 'home' does not provide an executable that + # resolves to the running interpreter, base_executable resolves the + # symlink rather than trusting 'home'. + ns = MockPosixNamespace( + argv0="/venv/bin/python", + PREFIX="/real", + ) + ns.add_known_xfile("/venv/bin/python") + ns.add_known_xfile("/real/bin/python") + ns.add_known_xfile("/pub/bin/python") + ns.add_known_xfile("/other/bin/python") + ns.add_known_link("/venv/bin/python", "/real/bin/python") + ns.add_known_link("/pub/bin/python", "/other/bin/python") + ns.add_known_file("/venv/pyvenv.cfg", [ + r"home = /pub/bin" + ]) + ns.add_known_file("/real/lib/python9.8/os.py") + ns.add_known_dir("/real/lib/python9.8/lib-dynload") + actual = getpath(ns, {"base_executable": ""}) + self.assertEqual(actual["base_executable"], "/real/bin/python") + + def test_venv_posix_symlinked_base_no_home_exe(self): + # gh-128670 fallback: if 'home' has no matching executable, + # base_executable resolves the symlink. + ns = MockPosixNamespace( + argv0="/venv/bin/python", + PREFIX="/real", + ) + ns.add_known_xfile("/venv/bin/python") + ns.add_known_xfile("/real/bin/python") + ns.add_known_link("/venv/bin/python", "/real/bin/python") + ns.add_known_file("/venv/pyvenv.cfg", [ + r"home = /pub/bin" + ]) + ns.add_known_file("/real/lib/python9.8/os.py") + ns.add_known_dir("/real/lib/python9.8/lib-dynload") + actual = getpath(ns, {"base_executable": ""}) + self.assertEqual(actual["base_executable"], "/real/bin/python") + def test_venv_posix_without_home_key(self): ns = MockPosixNamespace( argv0="/venv/bin/python3", diff --git a/Misc/NEWS.d/next/Library/2026-06-05-18-39-35.gh-issue-128670.75Gm5f.rst b/Misc/NEWS.d/next/Library/2026-06-05-18-39-35.gh-issue-128670.75Gm5f.rst new file mode 100644 index 000000000000000..f5c8488ca6253a3 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-05-18-39-35.gh-issue-128670.75Gm5f.rst @@ -0,0 +1,5 @@ +Creating a virtual environment from another virtual environment no longer +resolves a symlinked base interpreter further than the original environment +did, so an internal, implementation-detail install path (such as a Homebrew +``Cellar`` directory) is no longer baked into the second environment's +``pyvenv.cfg``. diff --git a/Modules/getpath.py b/Modules/getpath.py index 4dceb5cdc8dfcf0..d5008f68923c771 100644 --- a/Modules/getpath.py +++ b/Modules/getpath.py @@ -382,15 +382,37 @@ def search_up(prefix, *landmarks, test=isfile): # the base installation — isn't set (eg. when embedded), try to find # it in 'home'. if not base_executable: - # First try to resolve symlinked executables, since that may be - # more accurate than assuming the executable in 'home'. + # Prefer the executable found in 'home' (the public, possibly + # symlinked, location the venv was created from) when it resolves + # to the same real file as the running executable. This avoids + # baking an internal, implementation-detail prefix (e.g. a + # Homebrew Cellar path) into pyvenv.cfg, which then breaks the + # venv when that internal path changes (gh-128670). Match on the + # *resolved* executable's name, since the venv's primary exe may + # be 'python' while 'home' only provides 'python3.X'. Fall back to + # the fully resolved path when 'home' has no matching executable. try: - base_executable = realpath(executable) + _executable_realpath = realpath(executable) + except OSError: + _executable_realpath = '' + _home_executable = '' + if _executable_realpath: + _home_executable = joinpath(executable_dir, + basename(_executable_realpath)) + try: + _home_realpath = realpath(_home_executable) if _home_executable else '' + except OSError: + _home_realpath = '' + if (_executable_realpath and _home_executable + and isfile(_home_executable) + and _home_realpath == _executable_realpath): + base_executable = _home_executable + else: + base_executable = _executable_realpath if base_executable == executable: # No change, so probably not a link. Clear it and fall back base_executable = '' - except OSError: - pass + if not base_executable: base_executable = joinpath(executable_dir, basename(executable)) # It's possible "python" is executed from within a posix venv but that