Skip to content

fix: handle implementations without subinterpreter support #5732

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 18, 2025
Merged
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
183 changes: 121 additions & 62 deletions tests/test_multiple_interpreters.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,80 @@
from __future__ import annotations

import contextlib
import os
import pickle
import sys

import pytest

# 3.14.0b3+, though sys.implementation.supports_isolated_interpreters is being added in b4
# Can be simplified when we drop support for the first three betas
CONCURRENT_INTERPRETERS_SUPPORT = (
sys.version_info >= (3, 14)
and (
sys.version_info != (3, 14, 0, "beta", 1)
and sys.version_info != (3, 14, 0, "beta", 2)
)
and (
sys.version_info == (3, 14, 0, "beta", 3)
or sys.implementation.supports_isolated_interpreters
)
)


def get_interpreters(*, modern: bool):
if modern and CONCURRENT_INTERPRETERS_SUPPORT:
from concurrent import interpreters

def create():
return contextlib.closing(interpreters.create())

def run_string(
interp: interpreters.Interpreter,
code: str,
*,
shared: dict[str, object] | None = None,
) -> Exception | None:
if shared:
interp.prepare_main(**shared)
try:
interp.exec(code)
return None
except interpreters.ExecutionFailed as err:
return err

return run_string, create

if sys.version_info >= (3, 12):
interpreters = pytest.importorskip(
"_interpreters" if sys.version_info >= (3, 13) else "_xxsubinterpreters"
)

@contextlib.contextmanager
def create(config: str = ""):
try:
if config:
interp = interpreters.create(config)
else:
interp = interpreters.create()
except TypeError:
pytest.skip(f"interpreters module needs to support {config} config")

try:
yield interp
finally:
interpreters.destroy(interp)

def run_string(
interp: int, code: str, shared: dict[str, object] | None = None
) -> Exception | None:
kwargs = {"shared": shared} if shared else {}
return interpreters.run_string(interp, code, **kwargs)

return run_string, create

pytest.skip("Test requires the interpreters stdlib module")


@pytest.mark.skipif(
sys.platform.startswith("emscripten"), reason="Requires loadable modules"
Expand All @@ -15,15 +84,7 @@ def test_independent_subinterpreters():

sys.path.append(".")

# This is supposed to be added in 3.14.0b3
if sys.version_info >= (3, 15):
import interpreters
elif sys.version_info >= (3, 13):
import _interpreters as interpreters
elif sys.version_info >= (3, 12):
import _xxsubinterpreters as interpreters
else:
pytest.skip("Test requires the interpreters stdlib module")
run_string, create = get_interpreters(modern=True)

m = pytest.importorskip("mod_per_interpreter_gil")

Expand All @@ -37,49 +98,75 @@ def test_independent_subinterpreters():
pickle.dump(m.internals_at(), f)
"""

interp1 = interpreters.create()
interp2 = interpreters.create()
try:
with create() as interp1, create() as interp2:
try:
res0 = interpreters.run_string(interp1, "import mod_shared_interpreter_gil")
res0 = run_string(interp1, "import mod_shared_interpreter_gil")
if res0 is not None:
res0 = res0.msg
res0 = str(res0)
except Exception as e:
res0 = str(e)

pipei, pipeo = os.pipe()
interpreters.run_string(interp1, code, shared={"pipeo": pipeo})
run_string(interp1, code, shared={"pipeo": pipeo})
with open(pipei, "rb") as f:
res1 = pickle.load(f)

pipei, pipeo = os.pipe()
interpreters.run_string(interp2, code, shared={"pipeo": pipeo})
run_string(interp2, code, shared={"pipeo": pipeo})
with open(pipei, "rb") as f:
res2 = pickle.load(f)

# do this while the two interpreters are active
import mod_per_interpreter_gil as m2

assert m.internals_at() == m2.internals_at(), (
"internals should be the same within the main interpreter"
)
finally:
interpreters.destroy(interp1)
interpreters.destroy(interp2)

assert "does not support loading in subinterpreters" in res0, (
"cannot use shared_gil in a default subinterpreter"
)
assert res1 != m.internals_at(), "internals should differ from main interpreter"
assert res2 != m.internals_at(), "internals should differ from main interpreter"
assert res1 != res2, "internals should differ between interpreters"

# do this after the two interpreters are destroyed and only one remains
import mod_per_interpreter_gil as m3

assert m.internals_at() == m3.internals_at(), (
"internals should be the same within the main interpreter"
)
@pytest.mark.skipif(
sys.platform.startswith("emscripten"), reason="Requires loadable modules"
)
@pytest.mark.skipif(not CONCURRENT_INTERPRETERS_SUPPORT, reason="Requires 3.14.0b3+")
def test_independent_subinterpreters_modern():
"""Makes sure the internals object differs across independent subinterpreters. Modern (3.14+) syntax."""

sys.path.append(".")

m = pytest.importorskip("mod_per_interpreter_gil")

if not m.defined_PYBIND11_HAS_SUBINTERPRETER_SUPPORT:
pytest.skip("Does not have subinterpreter support compiled in")

from concurrent import interpreters

code = """
import mod_per_interpreter_gil as m

values.put_nowait(m.internals_at())
"""

with contextlib.closing(interpreters.create()) as interp1, contextlib.closing(
interpreters.create()
) as interp2:
with pytest.raises(
interpreters.ExecutionFailed,
match="does not support loading in subinterpreters",
):
interp1.exec("import mod_shared_interpreter_gil")

values = interpreters.create_queue()
interp1.prepare_main(values=values)
interp1.exec(code)
res1 = values.get_nowait()

interp2.prepare_main(values=values)
interp2.exec(code)
res2 = values.get_nowait()

assert res1 != m.internals_at(), "internals should differ from main interpreter"
assert res2 != m.internals_at(), "internals should differ from main interpreter"
assert res1 != res2, "internals should differ between interpreters"


@pytest.mark.skipif(
Expand All @@ -90,14 +177,7 @@ def test_dependent_subinterpreters():

sys.path.append(".")

if sys.version_info >= (3, 15):
import interpreters
elif sys.version_info >= (3, 13):
import _interpreters as interpreters
elif sys.version_info >= (3, 12):
import _xxsubinterpreters as interpreters
else:
pytest.skip("Test requires the interpreters stdlib module")
run_string, create = get_interpreters(modern=False)

m = pytest.importorskip("mod_shared_interpreter_gil")

Expand All @@ -111,31 +191,10 @@ def test_dependent_subinterpreters():
pickle.dump(m.internals_at(), f)
"""

try:
interp1 = interpreters.create("legacy")
except TypeError:
pytest.skip("interpreters module needs to support legacy config")

try:
with create("legacy") as interp1:
pipei, pipeo = os.pipe()
interpreters.run_string(interp1, code, shared={"pipeo": pipeo})
run_string(interp1, code, shared={"pipeo": pipeo})
with open(pipei, "rb") as f:
res1 = pickle.load(f)

# do this while the other interpreter is active
import mod_shared_interpreter_gil as m2

assert m.internals_at() == m2.internals_at(), (
"internals should be the same within the main interpreter"
)
finally:
interpreters.destroy(interp1)

assert res1 != m.internals_at(), "internals should differ from main interpreter"

# do this after the other interpreters are destroyed and only one remains
import mod_shared_interpreter_gil as m3

assert m.internals_at() == m3.internals_at(), (
"internals should be the same within the main interpreter"
)
Loading