Skip to content

Commit 91c11ac

Browse files
geofftjjhelmus
andauthored
Use the symlink-resolved location of Python in getpath on 3.14+ (#896)
Mostly fixes #380, and also fixes #713 because we prefer the actual binary path to argv[0]. There are two changes here. The second one is taken from PR #903 (and indirectly from CPython upstream). The reason for that is the first change doesn't quite work on some aarch64 configs, but the second change doesn't cover the case of weird argv[0]. Co-authored-by: Jonathan Helmus <jjhelmus@gmail.com>
1 parent ef1b25e commit 91c11ac

File tree

5 files changed

+217
-0
lines changed

5 files changed

+217
-0
lines changed

cpython-unix/build-cpython.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,24 @@ if [ -n "${CROSS_COMPILING}" ]; then
629629
# TODO: There are probably more of these, see #599.
630630
fi
631631

632+
# Adjust the Python startup logic (getpath.py) to properly locate the installation, even when
633+
# invoked through a symlink or through an incorrect argv[0]. Because this Python is relocatable, we
634+
# don't get to rely on the fallback to the compiled-in installation prefix.
635+
if [[ -n "${PYTHON_MEETS_MINIMUM_VERSION_3_14}" ]]; then
636+
patch -p1 -i "${ROOT}/patch-python-getpath-3.14.patch"
637+
fi
638+
639+
# Another, similar change to getpath: When reading inside a venv use the base_executable path to
640+
# determine executable_dir when valid. This allows venv to be created from symlinks and covers some
641+
# cases the above patch doesn't. See:
642+
# https://github.com/python/cpython/issues/106045#issuecomment-2594628161
643+
# 3.10 does not use getpath.py only getpath.c, no patch is applied
644+
if [ -n "${PYTHON_MEETS_MINIMUM_VERSION_3_14}" ]; then
645+
patch -p1 -i "${ROOT}/patch-getpath-use-base_executable-for-executable_dir-314.patch"
646+
elif [ -n "${PYTHON_MEETS_MINIMUM_VERSION_3_11}" ]; then
647+
patch -p1 -i "${ROOT}/patch-getpath-use-base_executable-for-executable_dir.patch"
648+
fi
649+
632650
# We patched configure.ac above. Reflect those changes.
633651
autoconf
634652

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
diff --git a/Modules/getpath.py b/Modules/getpath.py
2+
index ceb605a75c8..164d708ffca 100644
3+
--- a/Modules/getpath.py
4+
+++ b/Modules/getpath.py
5+
@@ -411,6 +411,9 @@ def search_up(prefix, *landmarks, test=isfile):
6+
if isfile(candidate):
7+
base_executable = candidate
8+
break
9+
+ if base_executable and isfile(base_executable):
10+
+ # Update the executable directory to be based on the resolved base executable
11+
+ executable_dir = real_executable_dir = dirname(base_executable)
12+
# home key found; stop iterating over lines
13+
break
14+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
diff --git a/Modules/getpath.py b/Modules/getpath.py
2+
index 1f1bfcb4f64..ff5b18cc385 100644
3+
--- a/Modules/getpath.py
4+
+++ b/Modules/getpath.py
5+
@@ -398,6 +398,9 @@ def search_up(prefix, *landmarks, test=isfile):
6+
if isfile(candidate):
7+
base_executable = candidate
8+
break
9+
+ if base_executable and isfile(base_executable):
10+
+ # Update the executable directory to be based on the resolved base executable
11+
+ executable_dir = real_executable_dir = dirname(base_executable)
12+
break
13+
else:
14+
venv_prefix = None
15+
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
From 4fb328cb883504dde04dfdd0b4d182a0130a0909 Mon Sep 17 00:00:00 2001
2+
From: Geoffrey Thomas <geofft@ldpreload.com>
3+
Date: Mon, 1 Dec 2025 14:11:43 -0500
4+
Subject: [PATCH 1/1] getpath: Fix library detection and canonicalize paths on
5+
Linux
6+
Forwarded: no
7+
8+
The code in getpath.py to look for the stdlib relative to the Python
9+
library did not work in the common layout where libpython itself is in
10+
the lib/ directory; it added an extra lib/ segment. It is also equally
11+
applicable and useful when statically linking libpython into bin/python;
12+
in both cases, we want to go up a directory and then look into
13+
lib/python3.x/. Add an extra dirname() call in getpath.py, and
14+
unconditionally attempt to fill in the "library" variable in getpath.c,
15+
even on builds that are statically linking libpython.
16+
17+
Also, we want to use the realpath'd version of the library's path to
18+
locate the standard library, particularly in the case where the library
19+
is a symlink to an executable statically linking libpython. On macOS
20+
dyld, this is done automatically. On glibc and musl, we often get
21+
relative paths and they are not canonicalized, so instead, use
22+
/proc/self/maps to find the file where libpython is coming from.
23+
24+
(We could instead use the origin, which is canonicalized, but there is
25+
no safe API on glibc to read it and no API at all on musl. Note that and
26+
glibc also uses procfs to do so; see discussion at
27+
https://sourceware.org/bugzilla/show_bug.cgi?id=25263)
28+
---
29+
Modules/getpath.c | 52 ++++++++++++++++++++++++++++++++++++++++------
30+
Modules/getpath.py | 4 ++--
31+
2 files changed, 48 insertions(+), 8 deletions(-)
32+
33+
diff --git a/Modules/getpath.c b/Modules/getpath.c
34+
index 1e75993480a..72860807133 100644
35+
--- a/Modules/getpath.c
36+
+++ b/Modules/getpath.c
37+
@@ -802,14 +802,19 @@ progname_to_dict(PyObject *dict, const char *key)
38+
}
39+
40+
41+
+static void
42+
+fclose_cleanup(FILE **pf) {
43+
+ if (*pf) {
44+
+ fclose(*pf);
45+
+ *pf = NULL;
46+
+ }
47+
+}
48+
+
49+
+
50+
/* Add the runtime library's path to the dict */
51+
static int
52+
library_to_dict(PyObject *dict, const char *key)
53+
{
54+
-/* macOS framework builds do not link against a libpython dynamic library, but
55+
- instead link against a macOS Framework. */
56+
-#if defined(Py_ENABLE_SHARED) || defined(WITH_NEXT_FRAMEWORK)
57+
-
58+
#ifdef MS_WINDOWS
59+
extern HMODULE PyWin_DLLhModule;
60+
if (PyWin_DLLhModule) {
61+
@@ -817,12 +822,47 @@ library_to_dict(PyObject *dict, const char *key)
62+
}
63+
#endif
64+
65+
+ const void *target = (void *)Py_Initialize;
66+
+
67+
+#ifdef __linux__
68+
+ /* Linux libcs do not reliably report the realpath in dladdr dli_fname and
69+
+ * sometimes return relative paths, especially if the returned object is
70+
+ * the main program itself. However, /proc/self/maps will give absolute
71+
+ * realpaths (from the kernel, for the same reason that /proc/self/exe is
72+
+ * canonical), so try to parse and look it up there. (dyld seems to
73+
+ * reliably report the canonical path, so doing this matches the behavior
74+
+ * on macOS.) */
75+
+
76+
+ __attribute__((cleanup(fclose_cleanup)))
77+
+ FILE *maps = fopen("/proc/self/maps", "r");
78+
+ if (maps != NULL) {
79+
+ /* See implementation in fs/proc/task_mmu.c for spacing. The pathname
80+
+ * is the last field and has any \n characters escaped, so we can read
81+
+ * until \n. Note that the filename may have " (deleted)" appended;
82+
+ * we don't bother to handle that specially as the only user of this
83+
+ * value calls dirname() anyway.
84+
+ * TODO(geofft): Consider using PROCMAP_QUERY if supported.
85+
+ */
86+
+ uintptr_t low, high;
87+
+ char filename[PATH_MAX];
88+
+ while (fscanf(maps,
89+
+ "%lx-%lx %*s %*s %*s %*s %[^\n]",
90+
+ &low, &high, filename) == 3) {
91+
+ if (low <= (uintptr_t)target && (uintptr_t)target < high) {
92+
+ if (filename[0] == '/') {
93+
+ return decode_to_dict(dict, key, filename);
94+
+ }
95+
+ break;
96+
+ }
97+
+ }
98+
+ }
99+
+#endif
100+
+
101+
#if HAVE_DLADDR
102+
Dl_info libpython_info;
103+
- if (dladdr(&Py_Initialize, &libpython_info) && libpython_info.dli_fname) {
104+
+ if (dladdr(target, &libpython_info) && libpython_info.dli_fname) {
105+
return decode_to_dict(dict, key, libpython_info.dli_fname);
106+
}
107+
-#endif
108+
#endif
109+
110+
return PyDict_SetItemString(dict, key, Py_None) == 0;
111+
diff --git a/Modules/getpath.py b/Modules/getpath.py
112+
index b89d7427e3f..8c431e53be2 100644
113+
--- a/Modules/getpath.py
114+
+++ b/Modules/getpath.py
115+
@@ -436,7 +436,7 @@ def search_up(prefix, *landmarks, test=isfile):
116+
117+
if not executable_dir and os_name == 'darwin' and library:
118+
# QUIRK: macOS checks adjacent to its library early
119+
- library_dir = dirname(library)
120+
+ library_dir = dirname(dirname(library))
121+
if any(isfile(joinpath(library_dir, p)) for p in STDLIB_LANDMARKS):
122+
# Exceptions here should abort the whole process (to match
123+
# previous behavior)
124+
@@ -570,7 +570,7 @@ def search_up(prefix, *landmarks, test=isfile):
125+
126+
# First try to detect prefix by looking alongside our runtime library, if known
127+
if library and not prefix:
128+
- library_dir = dirname(library)
129+
+ library_dir = dirname(dirname(library))
130+
if ZIP_LANDMARK:
131+
if os_name == 'nt':
132+
# QUIRK: Windows does not search up for ZIP file
133+
--
134+
2.50.1 (Apple Git-155)
135+

src/verify_distribution.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
import importlib.machinery
66
import os
77
import struct
8+
import subprocess
89
import sys
10+
import tempfile
911
import unittest
12+
from pathlib import Path
1013

1114
TERMINFO_DIRS = [
1215
"/etc/terminfo",
@@ -269,6 +272,38 @@ def assertLibc(value):
269272

270273
assertLibc(importlib.machinery.EXTENSION_SUFFIXES[0])
271274

275+
@unittest.skipIf(
276+
sys.version_info[:2] < (3, 11),
277+
"not yet implemented",
278+
)
279+
@unittest.skipIf(os.name == "nt", "no symlinks or argv[0] on Windows")
280+
def test_getpath(self):
281+
def assertPythonWorks(path: Path, argv0: str = None):
282+
output = subprocess.check_output(
283+
[argv0 or path, "-c", "print(42)"], executable=path, text=True
284+
)
285+
self.assertEqual(output.strip(), "42")
286+
287+
with tempfile.TemporaryDirectory(prefix="verify-distribution-") as t:
288+
tmpdir = Path(t)
289+
symlink = tmpdir / "python"
290+
symlink.symlink_to(sys.executable)
291+
with self.subTest(msg="symlink without venv"):
292+
assertPythonWorks(symlink)
293+
294+
# TODO: --copies does not work right
295+
for flag in ("--symlinks",):
296+
with self.subTest(flag=flag):
297+
venv = tmpdir / f"venv_{flag}"
298+
subprocess.check_call(
299+
[symlink, "-m", "venv", flag, "--without-pip", venv]
300+
)
301+
assertPythonWorks(venv / "bin" / "python")
302+
303+
# TODO: does not yet work on ARM64
304+
# with self.subTest(msg="weird argv[0]"):
305+
# assertPythonWorks(sys.executable, argv0="/dev/null")
306+
272307

273308
if __name__ == "__main__":
274309
unittest.main()

0 commit comments

Comments
 (0)