]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
[3.14] gh-76595: Add tests for PyCapsule_Import() (GH-154588) (GH-154620)
authorSerhiy Storchaka <storchaka@gmail.com>
Fri, 24 Jul 2026 15:34:10 +0000 (18:34 +0300)
committerGitHub <noreply@github.com>
Fri, 24 Jul 2026 15:34:10 +0000 (15:34 +0000)
Add _testcapi helpers to create a capsule with an arbitrary name and to call PyCapsule_Import(), and Python tests covering the current behavior: only the first component of the dotted name is imported, the rest are attribute lookups.

(cherry picked from commit d24d9d0f1d1177cd6f130a4bd59c1c267a0f8d1f)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Lib/test/test_capi/test_capsule.py [new file with mode: 0644]
Misc/NEWS.d/next/Tests/2026-07-24-12-00-00.gh-issue-76595.KfF5xR.rst [new file with mode: 0644]
Modules/Setup.stdlib.in
Modules/_testlimitedcapi.c
Modules/_testlimitedcapi/capsule.c [new file with mode: 0644]
Modules/_testlimitedcapi/parts.h
PCbuild/_testlimitedcapi.vcxproj
PCbuild/_testlimitedcapi.vcxproj.filters

diff --git a/Lib/test/test_capi/test_capsule.py b/Lib/test/test_capi/test_capsule.py
new file mode 100644 (file)
index 0000000..981caf3
--- /dev/null
@@ -0,0 +1,181 @@
+import importlib
+import os
+import sys
+import textwrap
+import unittest
+from test.support import import_helper, os_helper
+
+_testlimitedcapi = import_helper.import_module('_testlimitedcapi')
+
+
+class CapsuleImportTests(unittest.TestCase):
+    """Tests for PyCapsule_Import()."""
+
+    @classmethod
+    def setUpClass(cls):
+        tmp = cls.tmp = cls.enterClassContext(os_helper.temp_dir())
+        cls.enterClassContext(import_helper.DirsOnSysPath(tmp))
+        cls.write_file(os.path.join(tmp, 'capsule_mod.py'), '''
+            import _testlimitedcapi
+
+            capsule = _testlimitedcapi.capsule_new('capsule_mod.capsule')
+            капсула = _testlimitedcapi.capsule_new('capsule_mod.капсула')
+            mismatched = _testlimitedcapi.capsule_new('other.name')
+            nonutf8 = _testlimitedcapi.capsule_new(b'capsule_mod.nonutf8\\xff')
+            nullname = _testlimitedcapi.capsule_new(None)
+            not_capsule = 42
+
+            class ns:
+                nested = _testlimitedcapi.capsule_new('capsule_mod.ns.nested')
+
+            def __getattr__(name):
+                if name == 'bad_attr':
+                    raise FloatingPointError('bad attribute')
+                raise AttributeError(name)
+        ''')
+        pkg = os.path.join(tmp, 'capsule_pkg')
+        os.mkdir(pkg)
+        cls.write_file(os.path.join(pkg, '__init__.py'), '')
+        cls.write_file(os.path.join(pkg, 'sub.py'), '''
+            import _testlimitedcapi
+
+            capsule = _testlimitedcapi.capsule_new('capsule_pkg.sub.capsule')
+        ''')
+        autopkg = os.path.join(tmp, 'capsule_autopkg')
+        os.mkdir(autopkg)
+        cls.write_file(os.path.join(autopkg, '__init__.py'), 'from . import sub\n')
+        cls.write_file(os.path.join(autopkg, 'sub.py'), '''
+            import _testlimitedcapi
+
+            capsule = _testlimitedcapi.capsule_new('capsule_autopkg.sub.capsule')
+        ''')
+        cls.write_file(os.path.join(tmp, 'capsule_broken.py'), '1/0\n')
+        importlib.invalidate_caches()
+
+    def setUp(self):
+        for name in ('capsule_mod', 'capsule_pkg.sub', 'capsule_pkg',
+                     'capsule_autopkg.sub', 'capsule_autopkg',
+                     'capsule_broken'):
+            self.addCleanup(import_helper.unload, name)
+
+    @staticmethod
+    def write_file(path, source):
+        with open(path, 'w', encoding='utf-8') as f:
+            f.write(textwrap.dedent(source))
+
+    def check_import(self, name, no_block=0):
+        # _testlimitedcapi.PyCapsule_Import() returns the name stored as the
+        # pointer by _testlimitedcapi.capsule_new().
+        self.assertEqual(_testlimitedcapi.PyCapsule_Import(name, no_block), name)
+
+    def test_import(self):
+        # The module is imported if not already imported.
+        self.assertNotIn('capsule_mod', sys.modules)
+        self.check_import('capsule_mod.capsule')
+        # Attributes after the first component are plain attribute lookups.
+        self.check_import('capsule_mod.ns.nested')
+        # Non-ASCII capsule and attribute name.
+        self.check_import('capsule_mod.капсула')
+        # The no_block argument is ignored.
+        self.check_import('capsule_mod.capsule', 1)
+
+    @unittest.skipUnless(os_helper.TESTFN_NONASCII,
+                         'requires non-ASCII file name support')
+    def test_non_ascii_module_name(self):
+        name = os_helper.TESTFN_NONASCII
+        self.write_file(os.path.join(self.tmp, name + '.py'), f'''
+            import _testlimitedcapi
+
+            capsule = _testlimitedcapi.capsule_new('{name}.capsule')
+        ''')
+        importlib.invalidate_caches()
+        self.addCleanup(import_helper.unload, name)
+        self.check_import(f'{name}.capsule')
+
+    def test_submodule(self):
+        # Only the first component is imported; a submodule not imported
+        # by its package is not found.
+        self.assertRaises(AttributeError,
+                          _testlimitedcapi.PyCapsule_Import, 'capsule_pkg.sub.capsule')
+        # It is found after explicit import.
+        importlib.import_module('capsule_pkg.sub')
+        self.check_import('capsule_pkg.sub.capsule')
+        # A submodule imported by its package is found.
+        self.check_import('capsule_autopkg.sub.capsule')
+
+    def test_invalid_name(self):
+        pycapsule_import = _testlimitedcapi.PyCapsule_Import
+        # Non-existing module.
+        self.assertRaisesRegex(ImportError,
+            'PyCapsule_Import could not import module "capsule_nonexistent"',
+            pycapsule_import, 'capsule_nonexistent.capsule')
+        # Non-UTF-8 module name.
+        self.assertRaisesRegex(ImportError,
+            'PyCapsule_Import could not import module',
+            pycapsule_import, b'\xff\xfe.capsule')
+        # Empty module name.
+        self.assertRaisesRegex(ImportError,
+            'PyCapsule_Import could not import module ""',
+            pycapsule_import, '.capsule_mod.capsule')
+        # Empty name.
+        self.assertRaisesRegex(ImportError,
+            'PyCapsule_Import could not import module ""',
+            pycapsule_import, '')
+        # Only a dot.
+        self.assertRaisesRegex(ImportError,
+            'PyCapsule_Import could not import module ""',
+            pycapsule_import, '.')
+        # Non-existing attribute.
+        self.assertRaises(AttributeError,
+                          pycapsule_import, 'capsule_mod.nonexistent')
+        # Empty attribute name.
+        self.assertRaises(AttributeError, pycapsule_import, 'capsule_mod.')
+        # Consecutive dots.
+        self.assertRaises(AttributeError,
+                          pycapsule_import, 'capsule_mod..capsule')
+        # Attribute of an object which is not a module.
+        self.assertRaises(AttributeError,
+                          pycapsule_import, 'capsule_mod.not_capsule.capsule')
+        # No attribute name.
+        self.assertRaisesRegex(AttributeError, 'is not valid',
+                               pycapsule_import, 'capsule_mod')
+
+        # CRASHES pycapsule_import(NULL)
+
+    def test_invalid_capsule(self):
+        pycapsule_import = _testlimitedcapi.PyCapsule_Import
+        # The attribute is not a capsule.
+        self.assertRaisesRegex(AttributeError, 'is not valid',
+                               pycapsule_import, 'capsule_mod.not_capsule')
+        # The capsule name does not match the requested name.
+        self.assertRaisesRegex(AttributeError, 'is not valid',
+                               pycapsule_import, 'capsule_mod.mismatched')
+        # The capsule name contains a byte not decodable from UTF-8.
+        self.assertRaisesRegex(AttributeError, 'is not valid',
+                               pycapsule_import, 'capsule_mod.nonutf8')
+        # Even the exactly matching name fails: the attribute lookup
+        # requires a name decodable from UTF-8.
+        self.assertRaises(UnicodeDecodeError,
+                          pycapsule_import, b'capsule_mod.nonutf8\xff')
+        # The capsule name is NULL.
+        self.assertRaisesRegex(AttributeError, 'is not valid',
+                               pycapsule_import, 'capsule_mod.nullname')
+
+    def test_error_from_import(self):
+        # The exception raised during importing the module is replaced
+        # with generic ImportError.
+        with self.assertRaises(ImportError) as cm:
+            _testlimitedcapi.PyCapsule_Import('capsule_broken.capsule')
+        self.assertEqual(str(cm.exception),
+                         'PyCapsule_Import could not import '
+                         'module "capsule_broken"')
+
+    def test_error_from_attribute_lookup(self):
+        self.assertRaises(FloatingPointError,
+                          _testlimitedcapi.PyCapsule_Import, 'capsule_mod.bad_attr')
+        self.assertRaises(FloatingPointError,
+                          _testlimitedcapi.PyCapsule_Import, 'capsule_mod.bad_attr.capsule')
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/Misc/NEWS.d/next/Tests/2026-07-24-12-00-00.gh-issue-76595.KfF5xR.rst b/Misc/NEWS.d/next/Tests/2026-07-24-12-00-00.gh-issue-76595.KfF5xR.rst
new file mode 100644 (file)
index 0000000..c344ee2
--- /dev/null
@@ -0,0 +1 @@
+Add C API tests for :c:func:`PyCapsule_Import`.
index 74e77f19f52ba062a90ee1c3a5d068d005d75b58..0f92ef49bbc3e442bb968f9a990200eff9b53cae 100644 (file)
 @MODULE__TESTBUFFER_TRUE@_testbuffer _testbuffer.c
 @MODULE__TESTINTERNALCAPI_TRUE@_testinternalcapi _testinternalcapi.c _testinternalcapi/test_lock.c _testinternalcapi/pytime.c _testinternalcapi/set.c _testinternalcapi/test_critical_sections.c _testinternalcapi/complex.c
 @MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c _testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c _testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c _testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c _testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c _testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c _testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c _testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c _testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c _testcapi/type.c _testcapi/function.c _testcapi/weakref.c
-@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c _testlimitedcapi/file.c _testlimitedcapi/weakref.c _testlimitedcapi/run.c
+@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/capsule.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c _testlimitedcapi/file.c _testlimitedcapi/weakref.c _testlimitedcapi/run.c
 @MODULE__TESTCLINIC_TRUE@_testclinic _testclinic.c
 @MODULE__TESTCLINIC_LIMITED_TRUE@_testclinic_limited _testclinic_limited.c
 
index 99e5d8ce341fbd00333490abbac1a318ab44edb2..00addafd5b982c0cbc11b9e2bdac5eb9c1485230 100644 (file)
@@ -38,6 +38,9 @@ PyInit__testlimitedcapi(void)
     if (_PyTestLimitedCAPI_Init_Bytes(mod) < 0) {
         return NULL;
     }
+    if (_PyTestLimitedCAPI_Init_Capsule(mod) < 0) {
+        return NULL;
+    }
     if (_PyTestLimitedCAPI_Init_Codec(mod) < 0) {
         return NULL;
     }
diff --git a/Modules/_testlimitedcapi/capsule.c b/Modules/_testlimitedcapi/capsule.c
new file mode 100644 (file)
index 0000000..07b8582
--- /dev/null
@@ -0,0 +1,63 @@
+#include "parts.h"
+
+static void
+capsule_destructor(PyObject *op)
+{
+    /* If non-NULL, the name and the pointer are the same allocation. */
+    free((char *)PyCapsule_GetName(op));
+}
+
+static PyObject *
+capsule_new(PyObject *self, PyObject *arg)
+{
+    const char *name;
+    Py_ssize_t size;
+    if (!PyArg_Parse(arg, "z#", &name, &size)) {
+        return NULL;
+    }
+    char *name_copy = NULL;
+    if (name != NULL) {
+        name_copy = strdup(name);
+        if (name_copy == NULL) {
+            return PyErr_NoMemory();
+        }
+    }
+    static const char dummy = 0;
+    void *pointer = name_copy != NULL ? (void *)name_copy : (void *)&dummy;
+    PyObject *capsule = PyCapsule_New(pointer, name_copy, capsule_destructor);
+    if (capsule == NULL) {
+        free(name_copy);
+    }
+    return capsule;
+}
+
+static PyObject *
+pycapsule_import(PyObject *self, PyObject *args)
+{
+    const char *name;
+    Py_ssize_t size;
+    int no_block = 0;
+    if (!PyArg_ParseTuple(args, "z#|i", &name, &size, &no_block)) {
+        return NULL;
+    }
+    void *pointer = PyCapsule_Import(name, no_block);
+    if (pointer == NULL) {
+        return NULL;
+    }
+    /* Capsules created by capsule_new() store a copy of their name as the
+       pointer, so a successful import round-trips the name.  Only use this
+       function with such capsules. */
+    return PyUnicode_FromString((const char *)pointer);
+}
+
+static PyMethodDef test_methods[] = {
+    {"capsule_new", capsule_new, METH_O},
+    {"PyCapsule_Import", pycapsule_import, METH_VARARGS},
+    {NULL},
+};
+
+int
+_PyTestLimitedCAPI_Init_Capsule(PyObject *m)
+{
+    return PyModule_AddFunctions(m, test_methods);
+}
index df023640ed55f841473ffd062f2274ccb14d877b..3d19e945bfdbc6cf23d93784b022edae9701365f 100644 (file)
@@ -25,6 +25,7 @@
 int _PyTestLimitedCAPI_Init_Abstract(PyObject *module);
 int _PyTestLimitedCAPI_Init_ByteArray(PyObject *module);
 int _PyTestLimitedCAPI_Init_Bytes(PyObject *module);
+int _PyTestLimitedCAPI_Init_Capsule(PyObject *module);
 int _PyTestLimitedCAPI_Init_Codec(PyObject *module);
 int _PyTestLimitedCAPI_Init_Complex(PyObject *module);
 int _PyTestLimitedCAPI_Init_Dict(PyObject *module);
index 3872ad0dc979ad9053c29ca367ea655d3c8074c2..f9be7b7e60c54986ccfefc0a31d8cdf566cfaafb 100644 (file)
@@ -97,6 +97,7 @@
     <ClCompile Include="..\Modules\_testlimitedcapi\abstract.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\bytearray.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\bytes.c" />
+    <ClCompile Include="..\Modules\_testlimitedcapi\capsule.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\codec.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\complex.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\dict.c" />
index ac16423163cf4d60120e785a6466da7f842e8f51..b4509df01a28da2097d5c2b441b211cae72bce85 100644 (file)
@@ -12,6 +12,7 @@
     <ClCompile Include="..\Modules\_testlimitedcapi\abstract.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\bytearray.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\bytes.c" />
+    <ClCompile Include="..\Modules\_testlimitedcapi\capsule.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\codec.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\complex.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\dict.c" />