From: Serhiy Storchaka Date: Fri, 24 Jul 2026 15:01:56 +0000 (+0300) Subject: gh-76595: Add tests for PyCapsule_Import() (GH-154588) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=d24d9d0f1d1177cd6f130a4bd59c1c267a0f8d1f;p=thirdparty%2FPython%2Fcpython.git gh-76595: Add tests for PyCapsule_Import() (GH-154588) 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. Co-Authored-By: Claude Fable 5 --- diff --git a/Lib/test/test_capi/test_capsule.py b/Lib/test/test_capi/test_capsule.py new file mode 100644 index 000000000000..981caf3fad42 --- /dev/null +++ b/Lib/test/test_capi/test_capsule.py @@ -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 index 000000000000..c344ee2f2cb3 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2026-07-24-12-00-00.gh-issue-76595.KfF5xR.rst @@ -0,0 +1 @@ +Add C API tests for :c:func:`PyCapsule_Import`. diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in index 0f88dddf03b9..eaf8777a56c5 100644 --- a/Modules/Setup.stdlib.in +++ b/Modules/Setup.stdlib.in @@ -174,7 +174,7 @@ @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 _testinternalcapi/interpreter.c _testinternalcapi/tuple.c _testinternalcapi/typecache.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/modsupport.c _testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c _testcapi/type.c _testcapi/function.c _testcapi/module.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/slots.c _testlimitedcapi/sys.c _testlimitedcapi/threadstate.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/slots.c _testlimitedcapi/sys.c _testlimitedcapi/threadstate.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 diff --git a/Modules/_testlimitedcapi.c b/Modules/_testlimitedcapi.c index 8ce704502af0..0a562ea9c031 100644 --- a/Modules/_testlimitedcapi.c +++ b/Modules/_testlimitedcapi.c @@ -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 index 000000000000..07b85823f399 --- /dev/null +++ b/Modules/_testlimitedcapi/capsule.c @@ -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); +} diff --git a/Modules/_testlimitedcapi/parts.h b/Modules/_testlimitedcapi/parts.h index a11e0edb8311..32c1bbc1b71c 100644 --- a/Modules/_testlimitedcapi/parts.h +++ b/Modules/_testlimitedcapi/parts.h @@ -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); diff --git a/PCbuild/_testlimitedcapi.vcxproj b/PCbuild/_testlimitedcapi.vcxproj index 7ddcee6d9735..785bb151e081 100644 --- a/PCbuild/_testlimitedcapi.vcxproj +++ b/PCbuild/_testlimitedcapi.vcxproj @@ -97,6 +97,7 @@ + diff --git a/PCbuild/_testlimitedcapi.vcxproj.filters b/PCbuild/_testlimitedcapi.vcxproj.filters index 66a0a47d8e55..51dc9950a103 100644 --- a/PCbuild/_testlimitedcapi.vcxproj.filters +++ b/PCbuild/_testlimitedcapi.vcxproj.filters @@ -12,6 +12,7 @@ +