diff --git a/Lib/test/test_ctypes/test_callbacks.py b/Lib/test/test_ctypes/test_callbacks.py index c7f287d9fb6293..a2ef81f9fed462 100644 --- a/Lib/test/test_ctypes/test_callbacks.py +++ b/Lib/test/test_ctypes/test_callbacks.py @@ -8,8 +8,8 @@ from _ctypes import CTYPES_MAX_ARGCOUNT from ctypes import (CDLL, cdll, Structure, Union, CFUNCTYPE, ArgumentError, POINTER, sizeof, - c_byte, c_ubyte, c_char, c_char_p, - c_short, c_ushort, c_int, c_uint, + c_byte, c_ubyte, c_char, c_char_p, c_void_p, + c_short, c_ushort, c_int, c_uint, c_size_t, c_long, c_longlong, c_ulonglong, c_ulong, c_float, c_double, c_longdouble, py_object) from ctypes.util import find_library, struct, wrap_dll_function @@ -197,6 +197,81 @@ def cmp_func(a, b): libc.qsort(array, len(array), sizeof(c_int), cmp_func) self.assertEqual(array[:], [1, 5, 7, 33, 99]) + def test_mismatched_callback_prototype(self): + # gh-125788: CFUNCTYPE and WINFUNCTYPE prototypes are distinct + # classes that share one type name, so a mismatch used to raise + # "expected CFunctionType instance instead of CFunctionType". + # Reproducer from the issue: qsort's comparator prototype does + # not match the callback that is passed. Where c_int is an alias + # of c_long, those two prototypes are the same object; a shorter + # argument list keeps them distinct. + def signature(proto): + return (f"(restype={proto._restype_!r}, " + f"argtypes={proto._argtypes_!r})") + + def message(expected, actual): + return ( + f"expected {expected.__name__} instance with signature " + f"{signature(expected)} instead of " + f"{actual.__name__} instance with signature " + f"{signature(actual)}" + ) + + def cmp(*args): + return 0 + + cmp_int = CFUNCTYPE(c_int, c_void_p, c_void_p) + cmp_long = CFUNCTYPE(c_long, c_void_p, c_void_p) + if cmp_int is cmp_long: + cmp_long = CFUNCTYPE(c_long, c_void_p) + + long_cb = cmp_long(cmp) + mismatch = message(cmp_int, cmp_long) + with self.assertRaises(TypeError) as cm: + cmp_int.from_param(long_cb) + self.assertEqual(str(cm.exception), mismatch) + + # A matching callback is still accepted unchanged. + good = cmp_int(cmp) + self.assertIs(cmp_int.from_param(good), good) + + # Values that are not callbacks keep the generic message. + with self.assertRaises(TypeError) as cm: + cmp_int.from_param(object()) + self.assertEqual(str(cm.exception), + "expected CFunctionType instance instead of object") + + # Argument types differ, so each side of the message is checked + # independently of the result type. + cmp_chars = CFUNCTYPE(c_int, c_char_p) + with self.assertRaises(TypeError) as cm: + cmp_int.from_param(cmp_chars(cmp)) + self.assertEqual(str(cm.exception), message(cmp_int, cmp_chars)) + + if hasattr(ctypes, 'WINFUNCTYPE'): + win_int = ctypes.WINFUNCTYPE(c_int, c_void_p, c_void_p) + win_long = ctypes.WINFUNCTYPE(c_long, c_void_p, c_void_p) + if win_int is win_long: + win_long = ctypes.WINFUNCTYPE(c_long, c_void_p) + with self.assertRaises(TypeError) as cm: + win_int.from_param(win_long(cmp)) + self.assertEqual(str(cm.exception), message(win_int, win_long)) + + libc_path = find_library("c") + if not libc_path: + return + libc = CDLL(libc_path) + try: + qsort = libc.qsort + except (AttributeError, OSError, ValueError): + return + qsort.restype = None + qsort.argtypes = [c_void_p, c_size_t, c_size_t, cmp_int] + with self.assertRaises(ArgumentError) as cm: + qsort(None, 0, sizeof(c_int), long_cb) + self.assertEqual(str(cm.exception), + f"argument 4: TypeError: {mismatch}") + @unittest.skipUnless(hasattr(ctypes, 'WINFUNCTYPE'), 'ctypes.WINFUNCTYPE is required') def test_issue_8959_b(self): diff --git a/Misc/NEWS.d/next/Library/2026-11-12-14-20-00.gh-issue-125788.k7Qm2p.rst b/Misc/NEWS.d/next/Library/2026-11-12-14-20-00.gh-issue-125788.k7Qm2p.rst new file mode 100644 index 00000000000000..cdbf4594eb85fb --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-12-14-20-00.gh-issue-125788.k7Qm2p.rst @@ -0,0 +1,3 @@ +:mod:`ctypes` now includes the expected and actual callback signatures in +the :exc:`TypeError` raised when a :func:`~ctypes.CFUNCTYPE` or +:func:`~ctypes.WINFUNCTYPE` argument does not match the prototype. diff --git a/Modules/_ctypes/_ctypes.c b/Modules/_ctypes/_ctypes.c index 56d39e29f140dd..85da91a2c6f426 100644 --- a/Modules/_ctypes/_ctypes.c +++ b/Modules/_ctypes/_ctypes.c @@ -1123,6 +1123,44 @@ CDataType_from_param_impl(PyObject *type, PyTypeObject *cls, PyObject *value) _Py_LeaveRecursiveCall(); return value; } + /* + CFUNCTYPE and WINFUNCTYPE prototypes are distinct classes that + share one type name (CFunctionType or WinFunctionType). Include + both signatures so a mismatch can be told apart. Other mismatches + keep the generic message. (gh-125788) + */ + if (PyCFuncPtrTypeObject_Check(st, type) && + PyCFuncPtrObject_Check(st, value)) { + StgInfo *expected_info; + StgInfo *actual_info; + + if (PyStgInfo_FromType(st, type, &expected_info) < 0) { + return NULL; + } + if (PyStgInfo_FromType(st, (PyObject *)Py_TYPE(value), + &actual_info) < 0) { + return NULL; + } + if (expected_info != NULL && actual_info != NULL && + expected_info->argtypes != NULL && + actual_info->argtypes != NULL) { + PyObject *expected_restype = expected_info->restype ? + expected_info->restype : Py_None; + PyObject *actual_restype = actual_info->restype ? + actual_info->restype : Py_None; + PyErr_Format(PyExc_TypeError, + "expected %s instance with signature " + "(restype=%R, argtypes=%R) instead of %s instance " + "with signature (restype=%R, argtypes=%R)", + ((PyTypeObject *)type)->tp_name, + expected_restype, + expected_info->argtypes, + Py_TYPE(value)->tp_name, + actual_restype, + actual_info->argtypes); + return NULL; + } + } PyErr_Format(PyExc_TypeError, "expected %s instance instead of %s", ((PyTypeObject *)type)->tp_name,