diff --git a/Lib/test/test_getpath.py b/Lib/test/test_getpath.py index 83f09f3495547a..fbdc518773132b 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 00000000000000..f5c8488ca6253a --- /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 4dceb5cdc8dfcf..d5008f68923c77 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