From 6ecb06221c705aaa854647ff889bb99d926995cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Tue, 3 Mar 2026 21:45:20 +0000 Subject: [PATCH 1/5] GH-124241: try reading /proc/self/exe to determine sys.executable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- .../2026-03-03-21-45-16.gh-issue-124241._GLhVo.rst | 2 ++ Modules/getpath.py | 7 +++++++ 2 files changed, 9 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-03-03-21-45-16.gh-issue-124241._GLhVo.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-03-21-45-16.gh-issue-124241._GLhVo.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-03-21-45-16.gh-issue-124241._GLhVo.rst new file mode 100644 index 00000000000000..aa22546c677302 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-03-21-45-16.gh-issue-124241._GLhVo.rst @@ -0,0 +1,2 @@ +On POSIX, we now try to read the symlink target from ``/proc/self/exe`` to +determine :data:`sys.executable`. diff --git a/Modules/getpath.py b/Modules/getpath.py index 2f4d635a29585c..08655ed01ec505 100644 --- a/Modules/getpath.py +++ b/Modules/getpath.py @@ -265,6 +265,13 @@ def search_up(prefix, *landmarks, test=isfile): if not executable: executable = real_executable +if not executable and os_name == 'posix': + # On Linux, try resolving the executable path via procfs + try: + executable = realpath('/proc/self/exe') + except (OSError, MemoryError): + pass + if not executable and SEP in program_name: # Resolve partial path program_name against current directory executable = abspath(program_name) From 065bed62bee6a34822e9a16594d82d72fd0321c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 4 Mar 2026 18:24:56 +0000 Subject: [PATCH 2/5] Use readlink to read /proc/self/exe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- Modules/getpath.c | 5 +++++ Modules/getpath.py | 18 +++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Modules/getpath.c b/Modules/getpath.c index 1e75993480ae36..1e53aedc0713ba 100644 --- a/Modules/getpath.c +++ b/Modules/getpath.c @@ -797,6 +797,11 @@ progname_to_dict(PyObject *dict, const char *key) PyMem_RawFree(path); break; } +#elif defined(HAVE_READLINK) + wchar_t resolved[MAXPATHLEN + 1]; + if (_Py_wreadlink(L"/proc/self/exe", resolved, Py_ARRAY_LENGTH(resolved)) != -1) { + return wchar_to_dict(dict, key, resolved); + } #endif return PyDict_SetItemString(dict, key, Py_None) == 0; } diff --git a/Modules/getpath.py b/Modules/getpath.py index 08655ed01ec505..c3c115f5bf601f 100644 --- a/Modules/getpath.py +++ b/Modules/getpath.py @@ -265,13 +265,6 @@ def search_up(prefix, *landmarks, test=isfile): if not executable: executable = real_executable -if not executable and os_name == 'posix': - # On Linux, try resolving the executable path via procfs - try: - executable = realpath('/proc/self/exe') - except (OSError, MemoryError): - pass - if not executable and SEP in program_name: # Resolve partial path program_name against current directory executable = abspath(program_name) @@ -287,6 +280,17 @@ def search_up(prefix, *landmarks, test=isfile): # whether we are in a build tree. This is true even if the # executable path was provided in the config. real_executable = executable +elif os_name == 'posix': + # real_executable is more accurate than the value we have computed for + # executable, so use it instead if it resolves to a different path + # (eg. GH-124241). + # If real_executable and executable resolve to the same path, prefer + # executable, as that is much more likely to be the path the user is using. + try: + if realpath(executable) != real_executable: + executable = real_executable + except OSError: + pass if not executable and program_name and ENV_PATH: # Resolve names against PATH. From 74c565861133b9ecc50f4af53b9d2d36ac1f7be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 4 Mar 2026 18:31:51 +0000 Subject: [PATCH 3/5] Add missing real_executable check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- Modules/getpath.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/getpath.py b/Modules/getpath.py index c3c115f5bf601f..442cad4fb41b10 100644 --- a/Modules/getpath.py +++ b/Modules/getpath.py @@ -280,7 +280,7 @@ def search_up(prefix, *landmarks, test=isfile): # whether we are in a build tree. This is true even if the # executable path was provided in the config. real_executable = executable -elif os_name == 'posix': +elif os_name == 'posix' and real_executable: # real_executable is more accurate than the value we have computed for # executable, so use it instead if it resolves to a different path # (eg. GH-124241). From e9b41319326a46b1b16f17c6f51538640b6fa59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 4 Mar 2026 18:38:27 +0000 Subject: [PATCH 4/5] Add test_bad_argv0_posix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- Lib/test/test_getpath.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Lib/test/test_getpath.py b/Lib/test/test_getpath.py index 83f09f3495547a..6bfe9a1046ca84 100644 --- a/Lib/test/test_getpath.py +++ b/Lib/test/test_getpath.py @@ -497,6 +497,34 @@ def test_symlink_normal_posix(self): actual = getpath(ns, expected) self.assertEqual(expected, actual) + def test_bad_argv0_posix(self): + """Test that executable resolves correctly if argv0 is not Python and we + know real_executable (getpath.c computes it from readlink(/proc/self/exe)). + """ + ns = MockPosixNamespace( + PREFIX="/usr", + argv0="not-python", + real_executable="/usr/bin/python", + ) + ns.add_known_xfile("/usr/bin/python") + ns.add_known_file("/usr/lib/python9.8/os.py") + ns.add_known_dir("/usr/lib/python9.8/lib-dynload") + expected = dict( + executable="/usr/bin/python", + base_executable="/usr/bin/python", + prefix="/usr", + exec_prefix="/usr", + module_search_paths_set=1, + module_search_paths=[ + "/usr/lib/python98.zip", + "/usr/lib/python9.8", + "/usr/lib/python9.8/lib-dynload", + ], + ) + actual = getpath(ns, expected) + self.assertEqual(expected, actual) + + def test_symlink_buildpath_posix(self): """Test an in-build-tree layout on POSIX. From b70fd54cbf4f1e0236973bc61863efd65b929f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 4 Mar 2026 18:43:58 +0000 Subject: [PATCH 5/5] Don't set real_executable if readlink returns an empty string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- Modules/getpath.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/getpath.c b/Modules/getpath.c index 1e53aedc0713ba..0b424cfdfb4b3c 100644 --- a/Modules/getpath.c +++ b/Modules/getpath.c @@ -799,7 +799,7 @@ progname_to_dict(PyObject *dict, const char *key) } #elif defined(HAVE_READLINK) wchar_t resolved[MAXPATHLEN + 1]; - if (_Py_wreadlink(L"/proc/self/exe", resolved, Py_ARRAY_LENGTH(resolved)) != -1) { + if (_Py_wreadlink(L"/proc/self/exe", resolved, Py_ARRAY_LENGTH(resolved)) > 0) { return wchar_to_dict(dict, key, resolved); } #endif