diff --git a/Lib/test/test_getpath.py b/Lib/test/test_getpath.py index 2907abe4b4cee6..abf8913cc950e8 100644 --- a/Lib/test/test_getpath.py +++ b/Lib/test/test_getpath.py @@ -475,6 +475,109 @@ def test_venv_symlink_with_misleading_landmarks_posix(self): actual = getpath(ns, expected) self.assertEqual(expected, actual) + def test_venv_symlink_to_launcher_outside_runtime_posix(self): + "Test a venv whose base executable resolves away from its stdlib." + ns = MockPosixNamespace( + argv0="/venv/bin/python", + PREFIX="/usr", + ) + ns.add_known_xfile("/out/launcher") + ns.add_known_xfile("/runtime/bin/python") + ns.add_known_link("/runtime/bin/python", "/out/launcher") + ns.add_known_xfile("/venv/bin/python") + ns.add_known_link("/venv/bin/python", "/runtime/bin/python") + ns.add_known_file("/runtime/lib/python9.8/os.py") + ns.add_known_dir("/runtime/lib/python9.8/lib-dynload") + ns.add_known_file("/venv/pyvenv.cfg", [ + "home = /runtime/bin", + ]) + expected = dict( + executable="/venv/bin/python", + prefix="/venv", + exec_prefix="/venv", + base_executable="/out/launcher", + base_prefix="/runtime", + base_exec_prefix="/runtime", + module_search_paths_set=1, + module_search_paths=[ + "/runtime/lib/python98.zip", + "/runtime/lib/python9.8", + "/runtime/lib/python9.8/lib-dynload", + ], + ) + actual = getpath(ns, expected) + self.assertEqual(expected, actual) + + def test_venv_symlink_to_separate_exec_prefix_posix(self): + "Test a venv whose base executable lives under a separate exec_prefix." + ns = MockPosixNamespace( + argv0="/venv/bin/python", + PREFIX="/usr", + EXEC_PREFIX="/usr", + ) + ns.add_known_xfile("/exec/bin/python") + ns.add_known_xfile("/runtime/bin/python") + ns.add_known_link("/runtime/bin/python", "/exec/bin/python") + ns.add_known_xfile("/venv/bin/python") + ns.add_known_link("/venv/bin/python", "/runtime/bin/python") + ns.add_known_file("/runtime/lib/python9.8/os.py") + ns.add_known_dir("/exec/lib/python9.8/lib-dynload") + ns.add_known_file("/venv/pyvenv.cfg", [ + "home = /runtime/bin", + ]) + expected = dict( + executable="/venv/bin/python", + prefix="/venv", + exec_prefix="/venv", + base_executable="/exec/bin/python", + base_prefix="/runtime", + base_exec_prefix="/exec", + module_search_paths_set=1, + module_search_paths=[ + "/runtime/lib/python98.zip", + "/runtime/lib/python9.8", + "/exec/lib/python9.8/lib-dynload", + ], + ) + actual = getpath(ns, expected) + self.assertEqual(expected, actual) + + def test_venv_separate_exec_prefix_with_misleading_home_posix(self): + "Test that a stale lib-dynload near home does not override the base executable." + ns = MockPosixNamespace( + argv0="/venv/bin/python", + PREFIX="/stdlib", + EXEC_PREFIX="/build/exec", + ) + ns.add_known_xfile("/exec/bin/python") + ns.add_known_xfile("/public/bin/python") + ns.add_known_link("/public/bin/python", "/exec/bin/python") + ns.add_known_xfile("/venv/bin/python") + ns.add_known_link("/venv/bin/python", "/public/bin/python") + ns.add_known_file("/stdlib/lib/python9.8/os.py") + ns.add_known_dir("/exec/lib/python9.8/lib-dynload") + # An empty directory near the symlink must not win. + ns.add_known_dir("/public/lib/python9.8/lib-dynload") + ns.add_known_file("/venv/pyvenv.cfg", [ + "home = /public/bin", + ]) + expected = dict( + executable="/venv/bin/python", + prefix="/venv", + exec_prefix="/venv", + base_executable="/exec/bin/python", + base_prefix="/stdlib", + base_exec_prefix="/exec", + module_search_paths_set=1, + module_search_paths=[ + "/stdlib/lib/python98.zip", + "/stdlib/lib/python9.8", + "/exec/lib/python9.8/lib-dynload", + ], + ) + actual = getpath(ns, expected) + self.assertEqual(expected, actual) + def test_venv_non_installed_zip_path_posix(self): "Test a venv created from non-installed python has correct zip path.""" ns = MockPosixNamespace( diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-22-12-30-11.gh-issue-106045.fkMW34.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-22-12-30-11.gh-issue-106045.fkMW34.rst new file mode 100644 index 00000000000000..98abf1debe08d6 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-22-12-30-11.gh-issue-106045.fkMW34.rst @@ -0,0 +1,5 @@ +Fix :mod:`venv` path discovery when the base executable resolves to a +location outside the installation pointed to by the ``home`` key in +``pyvenv.cfg``. The standard library is now found from ``home``, and +extension modules from the resolved executable's installation, instead of +falling back to the compiled-in prefix. diff --git a/Modules/getpath.py b/Modules/getpath.py index f5f928f1795de5..2ffb1d964c0fb6 100644 --- a/Modules/getpath.py +++ b/Modules/getpath.py @@ -232,6 +232,7 @@ def search_up(prefix, *landmarks, test=isfile): stdlib_dir_was_set_in_config = bool(stdlib_dir) real_executable_dir = None +base_executable_dir = None platstdlib_dir = None stdlib_zip = None @@ -412,7 +413,17 @@ def search_up(prefix, *landmarks, test=isfile): if base_executable and isfile(base_executable): # Search relative to the resolved base executable rather # than the potentially external symlink in pyvenv.cfg. - executable_dir = real_executable_dir = dirname(base_executable) + # If nothing there locates a standard library (e.g. a + # launcher binary stored outside the installation that + # 'home' points into), keep searching from 'home'. + # base_executable_dir is still used to find platform + # dependent libraries (exec_prefix) in that case. + base_executable_dir = dirname(base_executable) + if ( + isfile(joinpath(base_executable_dir, BUILDDIR_TXT)) + or search_up(base_executable_dir, ZIP_LANDMARK, *STDLIB_LANDMARKS) + ): + executable_dir = real_executable_dir = base_executable_dir # home key found; stop iterating over lines break @@ -622,6 +633,11 @@ def search_up(prefix, *landmarks, test=isfile): exec_prefix = prefix if not exec_prefix and prefix and isdir(joinpath(prefix, PLATSTDLIB_LANDMARK)): exec_prefix = prefix + if not exec_prefix and base_executable_dir and base_executable_dir != executable_dir: + # The venv's resolved base executable may live under a separate + # exec_prefix (e.g. ./configure --exec-prefix) even when the + # stdlib was found from 'home'. + exec_prefix = search_up(base_executable_dir, PLATSTDLIB_LANDMARK, test=isdir) if not exec_prefix and executable_dir: exec_prefix = search_up(executable_dir, PLATSTDLIB_LANDMARK, test=isdir) if not exec_prefix and EXEC_PREFIX: