Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions cpython-unix/build-cpython.sh
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,12 @@ if [ -n "${PYTHON_MEETS_MINIMUM_VERSION_3_15}" ]; then
patch -p1 -i "${ROOT}/patch-testinternalcapi-interpreter-extern.patch"
fi

# Apply https://github.com/python/cpython/pull/149516 for a 3.15.0b1
# site startup regression.
if [ "${PYTHON_MAJMIN_VERSION}" = "3.15" ]; then
patch -p1 -i "${ROOT}/patch-site-reentrant-startup-files-3.15.patch"
fi

# Most bits look at CFLAGS. But setup.py only looks at CPPFLAGS.
# So we need to set both.
CFLAGS="${EXTRA_TARGET_CFLAGS} -fPIC -I${TOOLS_PATH}/deps/include -I${TOOLS_PATH}/deps/include/ncursesw"
Expand Down Expand Up @@ -415,10 +421,13 @@ CONFIGURE_FLAGS="

# Build a libpython3.x.so, but statically link the interpreter against
# libpython.
if [ -n "${PYTHON_MEETS_MINIMUM_VERSION_3_12}" ]; then
patch -p1 -i "${ROOT}/patch-python-configure-add-enable-static-libpython-for-interpreter.patch"
else
patch -p1 -i "${ROOT}/patch-python-configure-add-enable-static-libpython-for-interpreter-${PYTHON_MAJMIN_VERSION}.patch"
# Merged upstream in Python 3.15, https://github.com/python/cpython/pull/133313
if [ -n "${PYTHON_MEETS_MAXIMUM_VERSION_3_14}" ]; then
if [ -n "${PYTHON_MEETS_MINIMUM_VERSION_3_12}" ]; then
patch -p1 -i "${ROOT}/patch-python-configure-add-enable-static-libpython-for-interpreter.patch"
else
patch -p1 -i "${ROOT}/patch-python-configure-add-enable-static-libpython-for-interpreter-${PYTHON_MAJMIN_VERSION}.patch"
fi
fi
CONFIGURE_FLAGS="${CONFIGURE_FLAGS} --enable-static-libpython-for-interpreter"

Expand Down
4 changes: 4 additions & 0 deletions cpython-unix/extension-modules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,8 @@ _remote_debugging:
- _remote_debugging/code_objects.c
- _remote_debugging/frame_cache.c
- _remote_debugging/frames.c
- _remote_debugging/gc_stats.c
- _remote_debugging/interpreters.c
- _remote_debugging/module.c
- _remote_debugging/object_reading.c
- _remote_debugging/subprocess.c
Expand Down Expand Up @@ -786,6 +788,8 @@ _testlimitedcapi:
sources-conditional:
- source: _testlimitedcapi/codec.c
minimum-python-version: "3.14"
- source: _testlimitedcapi/slots.c
minimum-python-version: "3.15"
- source: _testlimitedcapi/threadstate.c
minimum-python-version: "3.15"
- source: _testlimitedcapi/version.c
Expand Down
158 changes: 158 additions & 0 deletions cpython-unix/patch-site-reentrant-startup-files-3.15.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
From 8e4ad95833c302dc3ef1edf0b58eb9ef21f56889 Mon Sep 17 00:00:00 2001
From: Zsolt Dollenstein <zsol.zsol@gmail.com>
Date: Thu, 7 May 2026 13:11:52 -0700
Subject: [PATCH] gh-149504: Fix reentrant site startup processing

Copy and clear pending startup file data before executing import lines or .start entry points so recursive site.addsitedir() calls process a separate batch.
---
Lib/site.py | 47 ++++++++++++++-----
Lib/test/test_site.py | 23 +++++++++
...6-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst | 2 +
3 files changed, 59 insertions(+), 13 deletions(-)
create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst

diff --git a/Lib/site.py b/Lib/site.py
index 52dd9648734c3e..766cbed460eac1 100644
--- a/Lib/site.py
+++ b/Lib/site.py
@@ -163,6 +163,13 @@ def _init_pathinfo():
_pending_importexecs = {}


+def _take_pending(mapping):
+ """Return the pending data and clear it before running startup code."""
+ pending = mapping.copy()
+ mapping.clear()
+ return pending
+
+
def _read_pthstart_file(sitedir, name, suffix):
"""Parse a .start or .pth file and return (lines, filename).

@@ -280,11 +287,14 @@ def _read_start_file(sitedir, name):
entrypoints.append(line)


-def _extend_syspath():
+def _extend_syspath(pending_syspaths=None):
# We've already filtered out duplicates, either in the existing sys.path
# or in all the .pth files we've seen. We've also abspath/normpath'd all
# the entries, so all that's left to do is to ensure that the path exists.
- for filename, dirs in _pending_syspaths.items():
+ if pending_syspaths is None:
+ pending_syspaths = _pending_syspaths
+
+ for filename, dirs in pending_syspaths.items():
for dir_ in dirs:
if os.path.exists(dir_):
_trace(f"Extending sys.path with {dir_} from {filename}")
@@ -295,16 +305,21 @@ def _extend_syspath():
f"skipping sys.path append")


-def _exec_imports():
+def _exec_imports(pending_importexecs=None, pending_entrypoints=None):
# For all the `import` lines we've seen in .pth files, exec() them in
# order. However, if they come from a file with a matching .start, then
# we ignore these import lines. For the ones we do process, print a
# warning but only when -v was given.
- for filename, imports in _pending_importexecs.items():
+ if pending_importexecs is None:
+ pending_importexecs = _pending_importexecs
+ if pending_entrypoints is None:
+ pending_entrypoints = _pending_entrypoints
+
+ for filename, imports in pending_importexecs.items():
name, dot, pth = filename.rpartition(".")
assert dot == "." and pth == "pth", f"Bad startup filename: {filename}"

- if f"{name}.start" in _pending_entrypoints:
+ if f"{name}.start" in pending_entrypoints:
# Skip import lines in favor of entry points.
continue

@@ -322,7 +337,7 @@ def _exec_imports():
f"Error in import line from {filename}: {line}", exc)


-def _execute_start_entrypoints():
+def _execute_start_entrypoints(pending_entrypoints=None):
"""Execute all accumulated .start file entry points.

Called after all site-packages directories have been processed so that
@@ -330,7 +345,10 @@ def _execute_start_entrypoints():
pkgutil.resolve_name(strict=True) which both validates the strict
pkg.mod:callable form and resolves the entry point in one step.
"""
- for filename, entrypoints in _pending_entrypoints.items():
+ if pending_entrypoints is None:
+ pending_entrypoints = _pending_entrypoints
+
+ for filename, entrypoints in pending_entrypoints.items():
for entrypoint in entrypoints:
try:
_trace(f"Executing entry point: {entrypoint} from {filename}")
@@ -355,12 +373,15 @@ def _execute_start_entrypoints():

def process_startup_files():
"""Flush all pending sys.path and entry points."""
- _extend_syspath()
- _exec_imports()
- _execute_start_entrypoints()
- _pending_syspaths.clear()
- _pending_importexecs.clear()
- _pending_entrypoints.clear()
+ # Startup code may call addsitedir(), so remove this batch from the
+ # globals before executing any import lines or entry points.
+ pending_syspaths = _take_pending(_pending_syspaths)
+ pending_importexecs = _take_pending(_pending_importexecs)
+ pending_entrypoints = _take_pending(_pending_entrypoints)
+
+ _extend_syspath(pending_syspaths)
+ _exec_imports(pending_importexecs, pending_entrypoints)
+ _execute_start_entrypoints(pending_entrypoints)


def addpackage(sitedir, name, known_paths):
diff --git a/Lib/test/test_site.py b/Lib/test/test_site.py
index ac69e2cbdbbe54..62fc8820f3b6f0 100644
--- a/Lib/test/test_site.py
+++ b/Lib/test/test_site.py
@@ -1297,6 +1297,29 @@ def startup():
import epmod
self.assertFalse(epmod.called)

+ def test_exec_imports_allows_reentrant_addsitedir(self):
+ nested = os.path.join(self.sitedir, 'nested')
+ nestedlib = os.path.join(nested, 'nestedlib')
+ os.mkdir(nested)
+ os.mkdir(nestedlib)
+ with open(os.path.join(nested, 'nested.pth'), 'w',
+ encoding='utf-8') as f:
+ f.write("nestedlib\n")
+ with open(os.path.join(nestedlib, 'nestedmod.py'), 'w',
+ encoding='utf-8') as f:
+ f.write("value = 42\n")
+ self.addCleanup(sys.modules.pop, 'nestedmod', None)
+
+ outer_pth = os.path.join(self.sitedir, 'outer.pth')
+ site._pending_importexecs[outer_pth] = [
+ f"import site; site.addsitedir({nested!r}); import nestedmod"
+ ]
+
+ site.process_startup_files()
+
+ self.assertIn(nestedlib, sys.path)
+ self.assertEqual(sys.modules['nestedmod'].value, 42)
+
# --- _extend_syspath tests ---

def test_extend_syspath_existing_dir(self):
diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst
new file mode 100644
index 00000000000000..77f72ea2247fe8
--- /dev/null
+++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst
@@ -0,0 +1,2 @@
+Fix reentrant processing of site startup files when a ``.pth`` import line
+calls :func:`site.addsitedir`.
62 changes: 55 additions & 7 deletions cpython-windows/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,30 @@ def static_replace_in_file(p: pathlib.Path, search, replace):
fh.write(data)


def apply_source_patch(cpython_source_path: pathlib.Path, patch_path: pathlib.Path):
with patch_path.open("rb") as fh:
patch = fh.read().replace(b"\r\n", b"\n")

with tempfile.NamedTemporaryFile("wb", delete=False) as fh:
fh.write(patch)
normalized_patch = pathlib.Path(fh.name)

try:
subprocess.run(
[
"git.exe",
"-C",
str(cpython_source_path),
"apply",
"--whitespace=nowarn",
str(normalized_patch),
],
check=True,
)
finally:
normalized_patch.unlink()


OPENSSL_PROPS_REMOVE_RULES_LEGACY = b"""
<ItemGroup>
<_SSLDLL Include="$(opensslOutDir)\libcrypto$(_DLLSuffix).dll" />
Expand Down Expand Up @@ -1032,20 +1056,23 @@ def collect_python_build_artifacts(
pcbuild_path: pathlib.Path,
out_dir: pathlib.Path,
python_majmin: str,
arch: str,
pcbuild_directory: str,
config: str,
openssl_entry: str,
zlib_entry: str,
freethreaded: bool,
):
"""Collect build artifacts from Python.

Copies them into an output directory and returns a data structure describing
the files.
"""
outputs_path = pcbuild_path / arch
arch = pcbuild_directory
# Python 3.15 suffixes the directory with 't' for free-threading
if arch.endswith("t"):
arch = arch.removesuffix("t")
outputs_path = pcbuild_path / pcbuild_directory
intermediates_path = (
pcbuild_path / "obj" / ("%s%s_%s" % (python_majmin, arch, config))
pcbuild_path / "obj" / ("%s%s_%s" % (python_majmin, pcbuild_directory, config))
)

if not outputs_path.exists():
Expand Down Expand Up @@ -1094,6 +1121,7 @@ def collect_python_build_artifacts(

other_projects = {"pythoncore"}
other_projects.add("python3dll")
other_projects.add("python3tdll")

# Projects providing dependencies.
depends_projects = set()
Expand Down Expand Up @@ -1395,6 +1423,11 @@ def build_cpython(
python_exe = "python.exe"
pythonw_exe = "pythonw.exe"

# Python 3.15 uses the default name for the executable in a suffixed directory
instrumented_python_exe = python_exe
if meets_python_minimum_version(python_version, "3.15") and freethreaded:
instrumented_python_exe = "python.exe"

if arch == "amd64":
build_platform = "x64"
build_directory = "amd64"
Expand All @@ -1407,6 +1440,12 @@ def build_cpython(
else:
raise Exception("unhandled architecture: %s" % arch)

pcbuild_directory = build_directory
# Starting with 3.15, free-threaded CPython outputs use a `t` suffix.
# The third-party dependency archives still use the base architecture name.
if freethreaded and meets_python_minimum_version(python_version, "3.15"):
pcbuild_directory = f"{build_directory}t"

tempdir_opts = (
{"ignore_cleanup_errors": True} if sys.version_info >= (3, 12) else {}
)
Expand Down Expand Up @@ -1477,6 +1516,12 @@ def build_cpython(
cpython_source_path = td / ("Python-%s" % python_version)
pcbuild_path = cpython_source_path / "PCbuild"

if python_version.startswith("3.15."):
apply_source_patch(
cpython_source_path,
SUPPORT / "patch-site-reentrant-startup-files-3.15.patch",
)

out_dir = td / "out"

build_dir = out_dir / "python" / "build"
Expand Down Expand Up @@ -1528,7 +1573,10 @@ def build_cpython(
# test execution. We work around this by invoking the test harness
# separately for each test.
instrumented_python = (
pcbuild_path / build_directory / "instrumented" / python_exe
pcbuild_path
/ pcbuild_directory
/ "instrumented"
/ instrumented_python_exe
)

tests = subprocess.run(
Expand Down Expand Up @@ -1608,7 +1656,7 @@ def build_cpython(
"--source",
str(cpython_source_path),
"--build",
str(pcbuild_path / build_directory),
str(pcbuild_path / pcbuild_directory),
"--copy",
str(install_dir),
"--temp",
Expand Down Expand Up @@ -1695,7 +1743,7 @@ def build_cpython(
pcbuild_path,
out_dir / "python",
"".join(entry["version"].split(".")[0:2]),
build_directory,
pcbuild_directory,
artifact_config,
openssl_entry=openssl_entry,
zlib_entry=zlib_entry,
Expand Down
Loading
Loading