summaryrefslogtreecommitdiffstats
path: root/Lib/test
diff options
context:
space:
mode:
authorEric Snow <ericsnowcurrently@gmail.com>2023-02-16 01:16:00 (GMT)
committerGitHub <noreply@github.com>2023-02-16 01:16:00 (GMT)
commit89ac665891dec1988bedec2ce9b2c4d016502a49 (patch)
tree246997ab21e8b587b8a3f58ac93d7b278c9d0938 /Lib/test
parent3dea4ba6c1b9237893d23574f931f33c940b74e8 (diff)
downloadcpython-89ac665891dec1988bedec2ce9b2c4d016502a49.zip
cpython-89ac665891dec1988bedec2ce9b2c4d016502a49.tar.gz
cpython-89ac665891dec1988bedec2ce9b2c4d016502a49.tar.bz2
gh-98627: Add an Optional Check for Extension Module Subinterpreter Compatibility (gh-99040)
Enforcing (optionally) the restriction set by PEP 489 makes sense. Furthermore, this sets the stage for a potential restriction related to a per-interpreter GIL. This change includes the following: * add tests for extension module subinterpreter compatibility * add _PyInterpreterConfig.check_multi_interp_extensions * add Py_RTFLAGS_MULTI_INTERP_EXTENSIONS * add _PyImport_CheckSubinterpIncompatibleExtensionAllowed() * fail iff the module does not implement multi-phase init and the current interpreter is configured to check https://github.com/python/cpython/issues/98627
Diffstat (limited to 'Lib/test')
-rw-r--r--Lib/test/support/import_helper.py18
-rw-r--r--Lib/test/test_capi/check_config.py77
-rw-r--r--Lib/test/test_capi/test_misc.py98
-rw-r--r--Lib/test/test_embed.py4
-rw-r--r--Lib/test/test_import/__init__.py220
-rw-r--r--Lib/test/test_threading.py1
6 files changed, 411 insertions, 7 deletions
diff --git a/Lib/test/support/import_helper.py b/Lib/test/support/import_helper.py
index 63a8a79..772c098 100644
--- a/Lib/test/support/import_helper.py
+++ b/Lib/test/support/import_helper.py
@@ -105,6 +105,24 @@ def frozen_modules(enabled=True):
_imp._override_frozen_modules_for_tests(0)
+@contextlib.contextmanager
+def multi_interp_extensions_check(enabled=True):
+ """Force legacy modules to be allowed in subinterpreters (or not).
+
+ ("legacy" == single-phase init)
+
+ This only applies to modules that haven't been imported yet.
+ It overrides the PyInterpreterConfig.check_multi_interp_extensions
+ setting (see support.run_in_subinterp_with_config() and
+ _xxsubinterpreters.create()).
+ """
+ old = _imp._override_multi_interp_extensions_check(1 if enabled else -1)
+ try:
+ yield
+ finally:
+ _imp._override_multi_interp_extensions_check(old)
+
+
def import_fresh_module(name, fresh=(), blocked=(), *,
deprecated=False,
usefrozen=False,
diff --git a/Lib/test/test_capi/check_config.py b/Lib/test/test_capi/check_config.py
new file mode 100644
index 0000000..aaedd82
--- /dev/null
+++ b/Lib/test/test_capi/check_config.py
@@ -0,0 +1,77 @@
+# This script is used by test_misc.
+
+import _imp
+import _testinternalcapi
+import json
+import os
+import sys
+
+
+def import_singlephase():
+ assert '_testsinglephase' not in sys.modules
+ try:
+ import _testsinglephase
+ except ImportError:
+ sys.modules.pop('_testsinglephase')
+ return False
+ else:
+ del sys.modules['_testsinglephase']
+ return True
+
+
+def check_singlephase(override):
+ # Check using the default setting.
+ settings_initial = _testinternalcapi.get_interp_settings()
+ allowed_initial = import_singlephase()
+ assert(_testinternalcapi.get_interp_settings() == settings_initial)
+
+ # Apply the override and check.
+ override_initial = _imp._override_multi_interp_extensions_check(override)
+ settings_after = _testinternalcapi.get_interp_settings()
+ allowed_after = import_singlephase()
+
+ # Apply the override again and check.
+ noop = {}
+ override_after = _imp._override_multi_interp_extensions_check(override)
+ settings_noop = _testinternalcapi.get_interp_settings()
+ if settings_noop != settings_after:
+ noop['settings_noop'] = settings_noop
+ allowed_noop = import_singlephase()
+ if allowed_noop != allowed_after:
+ noop['allowed_noop'] = allowed_noop
+
+ # Restore the original setting and check.
+ override_noop = _imp._override_multi_interp_extensions_check(override_initial)
+ if override_noop != override_after:
+ noop['override_noop'] = override_noop
+ settings_restored = _testinternalcapi.get_interp_settings()
+ allowed_restored = import_singlephase()
+
+ # Restore the original setting again.
+ override_restored = _imp._override_multi_interp_extensions_check(override_initial)
+ assert(_testinternalcapi.get_interp_settings() == settings_restored)
+
+ return dict({
+ 'requested': override,
+ 'override__initial': override_initial,
+ 'override_after': override_after,
+ 'override_restored': override_restored,
+ 'settings__initial': settings_initial,
+ 'settings_after': settings_after,
+ 'settings_restored': settings_restored,
+ 'allowed__initial': allowed_initial,
+ 'allowed_after': allowed_after,
+ 'allowed_restored': allowed_restored,
+ }, **noop)
+
+
+def run_singlephase_check(override, outfd):
+ with os.fdopen(outfd, 'w') as outfile:
+ sys.stdout = outfile
+ sys.stderr = outfile
+ try:
+ results = check_singlephase(override)
+ json.dump(results, outfile)
+ finally:
+ sys.stdout = sys.__stdout__
+ sys.stderr = sys.__stderr__
diff --git a/Lib/test/test_capi/test_misc.py b/Lib/test/test_capi/test_misc.py
index 7612cdd..f26b472 100644
--- a/Lib/test/test_capi/test_misc.py
+++ b/Lib/test/test_capi/test_misc.py
@@ -31,6 +31,10 @@ try:
import _testmultiphase
except ImportError:
_testmultiphase = None
+try:
+ import _testsinglephase
+except ImportError:
+ _testsinglephase = None
# Skip this test if the _testcapi module isn't available.
_testcapi = import_helper.import_module('_testcapi')
@@ -1297,17 +1301,20 @@ class SubinterpreterTest(unittest.TestCase):
"""
import json
+ EXTENSIONS = 1<<8
THREADS = 1<<10
DAEMON_THREADS = 1<<11
FORK = 1<<15
EXEC = 1<<16
- features = ['fork', 'exec', 'threads', 'daemon_threads']
+ features = ['fork', 'exec', 'threads', 'daemon_threads', 'extensions']
kwlist = [f'allow_{n}' for n in features]
+ kwlist[-1] = 'check_multi_interp_extensions'
for config, expected in {
- (True, True, True, True): FORK | EXEC | THREADS | DAEMON_THREADS,
- (False, False, False, False): 0,
- (False, False, True, False): THREADS,
+ (True, True, True, True, True):
+ FORK | EXEC | THREADS | DAEMON_THREADS | EXTENSIONS,
+ (False, False, False, False, False): 0,
+ (False, False, True, False, True): THREADS | EXTENSIONS,
}.items():
kwargs = dict(zip(kwlist, config))
expected = {
@@ -1322,12 +1329,93 @@ class SubinterpreterTest(unittest.TestCase):
json.dump(settings, stdin)
''')
with os.fdopen(r) as stdout:
- support.run_in_subinterp_with_config(script, **kwargs)
+ ret = support.run_in_subinterp_with_config(script, **kwargs)
+ self.assertEqual(ret, 0)
out = stdout.read()
settings = json.loads(out)
self.assertEqual(settings, expected)
+ @unittest.skipIf(_testsinglephase is None, "test requires _testsinglephase module")
+ @unittest.skipUnless(hasattr(os, "pipe"), "requires os.pipe()")
+ def test_overridden_setting_extensions_subinterp_check(self):
+ """
+ PyInterpreterConfig.check_multi_interp_extensions can be overridden
+ with PyInterpreterState.override_multi_interp_extensions_check.
+ This verifies that the override works but does not modify
+ the underlying setting.
+ """
+ import json
+
+ EXTENSIONS = 1<<8
+ THREADS = 1<<10
+ DAEMON_THREADS = 1<<11
+ FORK = 1<<15
+ EXEC = 1<<16
+ BASE_FLAGS = FORK | EXEC | THREADS | DAEMON_THREADS
+ base_kwargs = {
+ 'allow_fork': True,
+ 'allow_exec': True,
+ 'allow_threads': True,
+ 'allow_daemon_threads': True,
+ }
+
+ def check(enabled, override):
+ kwargs = dict(
+ base_kwargs,
+ check_multi_interp_extensions=enabled,
+ )
+ flags = BASE_FLAGS | EXTENSIONS if enabled else BASE_FLAGS
+ settings = {
+ 'feature_flags': flags,
+ }
+
+ expected = {
+ 'requested': override,
+ 'override__initial': 0,
+ 'override_after': override,
+ 'override_restored': 0,
+ # The override should not affect the config or settings.
+ 'settings__initial': settings,
+ 'settings_after': settings,
+ 'settings_restored': settings,
+ # These are the most likely values to be wrong.
+ 'allowed__initial': not enabled,
+ 'allowed_after': not ((override > 0) if override else enabled),
+ 'allowed_restored': not enabled,
+ }
+
+ r, w = os.pipe()
+ script = textwrap.dedent(f'''
+ from test.test_capi.check_config import run_singlephase_check
+ run_singlephase_check({override}, {w})
+ ''')
+ with os.fdopen(r) as stdout:
+ ret = support.run_in_subinterp_with_config(script, **kwargs)
+ self.assertEqual(ret, 0)
+ out = stdout.read()
+ results = json.loads(out)
+
+ self.assertEqual(results, expected)
+
+ self.maxDiff = None
+
+ # setting: check disabled
+ with self.subTest('config: check disabled; override: disabled'):
+ check(False, -1)
+ with self.subTest('config: check disabled; override: use config'):
+ check(False, 0)
+ with self.subTest('config: check disabled; override: enabled'):
+ check(False, 1)
+
+ # setting: check enabled
+ with self.subTest('config: check enabled; override: disabled'):
+ check(True, -1)
+ with self.subTest('config: check enabled; override: use config'):
+ check(True, 0)
+ with self.subTest('config: check enabled; override: enabled'):
+ check(True, 1)
+
def test_mutate_exception(self):
"""
Exceptions saved in global module state get shared between
diff --git a/Lib/test/test_embed.py b/Lib/test/test_embed.py
index 4d422da..e56d0db 100644
--- a/Lib/test/test_embed.py
+++ b/Lib/test/test_embed.py
@@ -1656,13 +1656,15 @@ class InitConfigTests(EmbeddingTestsMixin, unittest.TestCase):
api=API_PYTHON, env=env)
def test_init_main_interpreter_settings(self):
+ EXTENSIONS = 1<<8
THREADS = 1<<10
DAEMON_THREADS = 1<<11
FORK = 1<<15
EXEC = 1<<16
expected = {
# All optional features should be enabled.
- 'feature_flags': FORK | EXEC | THREADS | DAEMON_THREADS,
+ 'feature_flags':
+ FORK | EXEC | THREADS | DAEMON_THREADS,
}
out, err = self.run_embedded_interpreter(
'test_init_main_interpreter_settings',
diff --git a/Lib/test/test_import/__init__.py b/Lib/test/test_import/__init__.py
index 1e4429e..96815b3 100644
--- a/Lib/test/test_import/__init__.py
+++ b/Lib/test/test_import/__init__.py
@@ -21,7 +21,7 @@ from unittest import mock
from test.support import os_helper
from test.support import (
STDLIB_DIR, swap_attr, swap_item, cpython_only, is_emscripten,
- is_wasi)
+ is_wasi, run_in_subinterp_with_config)
from test.support.import_helper import (
forget, make_legacy_pyc, unlink, unload, DirsOnSysPath, CleanImport)
from test.support.os_helper import (
@@ -30,6 +30,14 @@ from test.support import script_helper
from test.support import threading_helper
from test.test_importlib.util import uncache
from types import ModuleType
+try:
+ import _testsinglephase
+except ImportError:
+ _testsinglephase = None
+try:
+ import _testmultiphase
+except ImportError:
+ _testmultiphase = None
skip_if_dont_write_bytecode = unittest.skipIf(
@@ -1392,6 +1400,216 @@ class CircularImportTests(unittest.TestCase):
unwritable.x = 42
+class SubinterpImportTests(unittest.TestCase):
+
+ RUN_KWARGS = dict(
+ allow_fork=False,
+ allow_exec=False,
+ allow_threads=True,
+ allow_daemon_threads=False,
+ )
+
+ @unittest.skipUnless(hasattr(os, "pipe"), "requires os.pipe()")
+ def pipe(self):
+ r, w = os.pipe()
+ self.addCleanup(os.close, r)
+ self.addCleanup(os.close, w)
+ if hasattr(os, 'set_blocking'):
+ os.set_blocking(r, False)
+ return (r, w)
+
+ def import_script(self, name, fd, check_override=None):
+ override_text = ''
+ if check_override is not None:
+ override_text = f'''
+ import _imp
+ _imp._override_multi_interp_extensions_check({check_override})
+ '''
+ return textwrap.dedent(f'''
+ import os, sys
+ {override_text}
+ try:
+ import {name}
+ except ImportError as exc:
+ text = 'ImportError: ' + str(exc)
+ else:
+ text = 'okay'
+ os.write({fd}, text.encode('utf-8'))
+ ''')
+
+ def run_shared(self, name, *,
+ check_singlephase_setting=False,
+ check_singlephase_override=None,
+ ):
+ """
+ Try importing the named module in a subinterpreter.
+
+ The subinterpreter will be in the current process.
+ The module will have already been imported in the main interpreter.
+ Thus, for extension/builtin modules, the module definition will
+ have been loaded already and cached globally.
+
+ "check_singlephase_setting" determines whether or not
+ the interpreter will be configured to check for modules
+ that are not compatible with use in multiple interpreters.
+
+ This should always return "okay" for all modules if the
+ setting is False (with no override).
+ """
+ __import__(name)
+
+ kwargs = dict(
+ **self.RUN_KWARGS,
+ check_multi_interp_extensions=check_singlephase_setting,
+ )
+
+ r, w = self.pipe()
+ script = self.import_script(name, w, check_singlephase_override)
+
+ ret = run_in_subinterp_with_config(script, **kwargs)
+ self.assertEqual(ret, 0)
+ return os.read(r, 100)
+
+ def check_compatible_shared(self, name, *, strict=False):
+ # Verify that the named module may be imported in a subinterpreter.
+ # (See run_shared() for more info.)
+ out = self.run_shared(name, check_singlephase_setting=strict)
+ self.assertEqual(out, b'okay')
+
+ def check_incompatible_shared(self, name):
+ # Differences from check_compatible_shared():
+ # * verify that import fails
+ # * "strict" is always True
+ out = self.run_shared(name, check_singlephase_setting=True)
+ self.assertEqual(
+ out.decode('utf-8'),
+ f'ImportError: module {name} does not support loading in subinterpreters',
+ )
+
+ def check_compatible_isolated(self, name, *, strict=False):
+ # Differences from check_compatible_shared():
+ # * subinterpreter in a new process
+ # * module has never been imported before in that process
+ # * this tests importing the module for the first time
+ _, out, err = script_helper.assert_python_ok('-c', textwrap.dedent(f'''
+ import _testcapi, sys
+ assert (
+ {name!r} in sys.builtin_module_names or
+ {name!r} not in sys.modules
+ ), repr({name!r})
+ ret = _testcapi.run_in_subinterp_with_config(
+ {self.import_script(name, "sys.stdout.fileno()")!r},
+ **{self.RUN_KWARGS},
+ check_multi_interp_extensions={strict},
+ )
+ assert ret == 0, ret
+ '''))
+ self.assertEqual(err, b'')
+ self.assertEqual(out, b'okay')
+
+ def check_incompatible_isolated(self, name):
+ # Differences from check_compatible_isolated():
+ # * verify that import fails
+ # * "strict" is always True
+ _, out, err = script_helper.assert_python_ok('-c', textwrap.dedent(f'''
+ import _testcapi, sys
+ assert {name!r} not in sys.modules, {name!r}
+ ret = _testcapi.run_in_subinterp_with_config(
+ {self.import_script(name, "sys.stdout.fileno()")!r},
+ **{self.RUN_KWARGS},
+ check_multi_interp_extensions=True,
+ )
+ assert ret == 0, ret
+ '''))
+ self.assertEqual(err, b'')
+ self.assertEqual(
+ out.decode('utf-8'),
+ f'ImportError: module {name} does not support loading in subinterpreters',
+ )
+
+ def test_builtin_compat(self):
+ module = 'sys'
+ with self.subTest(f'{module}: not strict'):
+ self.check_compatible_shared(module, strict=False)
+ with self.subTest(f'{module}: strict, shared'):
+ self.check_compatible_shared(module, strict=True)
+
+ @cpython_only
+ def test_frozen_compat(self):
+ module = '_frozen_importlib'
+ if __import__(module).__spec__.origin != 'frozen':
+ raise unittest.SkipTest(f'{module} is unexpectedly not frozen')
+ with self.subTest(f'{module}: not strict'):
+ self.check_compatible_shared(module, strict=False)
+ with self.subTest(f'{module}: strict, shared'):
+ self.check_compatible_shared(module, strict=True)
+
+ @unittest.skipIf(_testsinglephase is None, "test requires _testsinglephase module")
+ def test_single_init_extension_compat(self):
+ module = '_testsinglephase'
+ with self.subTest(f'{module}: not strict'):
+ self.check_compatible_shared(module, strict=False)
+ with self.subTest(f'{module}: strict, shared'):
+ self.check_incompatible_shared(module)
+ with self.subTest(f'{module}: strict, isolated'):
+ self.check_incompatible_isolated(module)
+
+ @unittest.skipIf(_testmultiphase is None, "test requires _testmultiphase module")
+ def test_multi_init_extension_compat(self):
+ module = '_testmultiphase'
+ with self.subTest(f'{module}: not strict'):
+ self.check_compatible_shared(module, strict=False)
+ with self.subTest(f'{module}: strict, shared'):
+ self.check_compatible_shared(module, strict=True)
+ with self.subTest(f'{module}: strict, isolated'):
+ self.check_compatible_isolated(module, strict=True)
+
+ def test_python_compat(self):
+ module = 'threading'
+ if __import__(module).__spec__.origin == 'frozen':
+ raise unittest.SkipTest(f'{module} is unexpectedly frozen')
+ with self.subTest(f'{module}: not strict'):
+ self.check_compatible_shared(module, strict=False)
+ with self.subTest(f'{module}: strict, shared'):
+ self.check_compatible_shared(module, strict=True)
+ with self.subTest(f'{module}: strict, isolated'):
+ self.check_compatible_isolated(module, strict=True)
+
+ @unittest.skipIf(_testsinglephase is None, "test requires _testsinglephase module")
+ def test_singlephase_check_with_setting_and_override(self):
+ module = '_testsinglephase'
+
+ def check_compatible(setting, override):
+ out = self.run_shared(
+ module,
+ check_singlephase_setting=setting,
+ check_singlephase_override=override,
+ )
+ self.assertEqual(out, b'okay')
+
+ def check_incompatible(setting, override):
+ out = self.run_shared(
+ module,
+ check_singlephase_setting=setting,
+ check_singlephase_override=override,
+ )
+ self.assertNotEqual(out, b'okay')
+
+ with self.subTest('config: check enabled; override: enabled'):
+ check_incompatible(True, 1)
+ with self.subTest('config: check enabled; override: use config'):
+ check_incompatible(True, 0)
+ with self.subTest('config: check enabled; override: disabled'):
+ check_compatible(True, -1)
+
+ with self.subTest('config: check disabled; override: enabled'):
+ check_incompatible(False, 1)
+ with self.subTest('config: check disabled; override: use config'):
+ check_compatible(False, 0)
+ with self.subTest('config: check disabled; override: disabled'):
+ check_compatible(False, -1)
+
+
if __name__ == '__main__':
# Test needs to be a package, so we can do relative imports.
unittest.main()
diff --git a/Lib/test/test_threading.py b/Lib/test/test_threading.py
index 31bf463..7fea2d3 100644
--- a/Lib/test/test_threading.py
+++ b/Lib/test/test_threading.py
@@ -1347,6 +1347,7 @@ class SubinterpThreadingTests(BaseTestCase):
allow_exec=True,
allow_threads={allowed},
allow_daemon_threads={daemon_allowed},
+ check_multi_interp_extensions=False,
)
""")
with test.support.SuppressCrashReport():