diff --git a/tests/test_multiple_interpreters.py b/tests/test_multiple_interpreters.py index a02a27547b..627ccc591d 100644 --- a/tests/test_multiple_interpreters.py +++ b/tests/test_multiple_interpreters.py @@ -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" @@ -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") @@ -37,36 +98,24 @@ 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" ) @@ -74,12 +123,50 @@ def test_independent_subinterpreters(): 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( @@ -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") @@ -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" - )