https://github.com/python/cpython/commit/eb229a9b405f31a63493f7f78547578793b5df42
commit: eb229a9b405f31a63493f7f78547578793b5df42
branch: main
author: Russell Keith-Magee <[email protected]>
committer: ZeroIntensity <[email protected]>
date: 2026-10-22T17:52:55Z
summary:

gh-49960: Allow ctypes callbacks to return struct/union types (GH-157974)

files:
A Misc/NEWS.d/next/Library/2026-09-18-00-00-00.gh-issue-49960.MDT3jE.rst
M Doc/library/ctypes.rst
M Doc/whatsnew/3.16.rst
M Lib/test/test_ctypes/test_callbacks.py
M Modules/_ctypes/_ctypes_test.c
M Modules/_ctypes/callbacks.c

diff --git a/Doc/library/ctypes.rst b/Doc/library/ctypes.rst
index ded0a444992819..1acf5e85af48d7 100644
--- a/Doc/library/ctypes.rst
+++ b/Doc/library/ctypes.rst
@@ -1337,6 +1337,31 @@ write::
    :class:`threading.local` will *not* survive across different callbacks, 
even when
    those calls are made from the same C thread.
 
+Callback functions can return structures and unions by value, in addition to
+the simple types:
+
+.. code-block:: python
+
+   class Point(Structure):
+       _fields_ = [("x", c_int), ("y", c_int)]
+
+   @CFUNCTYPE(Point)
+   def get_origin():
+       return Point(0, 0)
+
+.. note::
+
+   A structure or union returned from a callback is copied *by value*, and
+   :mod:`!ctypes` does not keep the returned object alive after the callback
+   returns. If the structure contains a pointer field (such as
+   :class:`c_char_p` or a :func:`POINTER` type), you must ensure that the
+   memory it refers to stays valid for as long as the C code uses it -- the
+   same requirement C itself imposes. Returning a pointer to memory owned by
+   a temporary Python object will leave the C caller with a dangling pointer.
+
+.. versionchanged:: next
+   Callback functions can now return structures and unions.
+
 .. _ctypes-accessing-values-exported-from-dlls:
 
 Accessing values exported from dlls
diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index 925f425aba5869..7995da01285824 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -244,6 +244,10 @@ ctypes
   implicitly converted when passed for a :func:`~ctypes.POINTER` argument.
   (Contributed by Peter Bierma in :gh:`71990`.)
 
+* :ref:`Callback functions <ctypes-callback-functions>` can now return
+  :class:`~ctypes.Structure` and :class:`~ctypes.Union` instances by value.
+  (Contributed by Russell Keith-Magee in :gh:`49960`.)
+
 
 curses
 ------
diff --git a/Lib/test/test_ctypes/test_callbacks.py 
b/Lib/test/test_ctypes/test_callbacks.py
index c0171596a17a33..c7f287d9fb6293 100644
--- a/Lib/test/test_ctypes/test_callbacks.py
+++ b/Lib/test/test_ctypes/test_callbacks.py
@@ -4,14 +4,15 @@
 import math
 import sys
 import unittest
+import warnings
 from _ctypes import CTYPES_MAX_ARGCOUNT
-from ctypes import (CDLL, cdll, Structure, CFUNCTYPE,
+from ctypes import (CDLL, cdll, Structure, Union, CFUNCTYPE,
                     ArgumentError, POINTER, sizeof,
-                    c_byte, c_ubyte, c_char,
+                    c_byte, c_ubyte, c_char, c_char_p,
                     c_short, c_ushort, c_int, c_uint,
                     c_long, c_longlong, c_ulonglong, c_ulong,
                     c_float, c_double, c_longdouble, py_object)
-from ctypes.util import find_library, wrap_dll_function
+from ctypes.util import find_library, struct, wrap_dll_function
 from test import support
 from test.support import import_helper
 _ctypes_test = import_helper.import_module("_ctypes_test")
@@ -293,6 +294,222 @@ def callback(check, s):
         self.assertEqual(s.second, check.second)
         self.assertEqual(s.third, check.third)
 
+    # gh-49960: callbacks may return structures and unions by value.
+
+    def test_callback_return_small_struct(self):
+        @struct
+        class SmallRet:
+            a: c_int
+            b: c_int
+
+        CALLBACK = CFUNCTYPE(SmallRet)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_small_struct
+        func.argtypes = (CALLBACK,)
+        func.restype = SmallRet
+
+        result = func(CALLBACK(lambda: SmallRet(17, 42)))
+        self.assertEqual((result.a, result.b), (17, 42))
+
+    def test_callback_return_struct_reaches_c(self):
+        # The C helper sums the fields itself, proving the bytes actually
+        # reached the C caller rather than only round-tripping in Python.
+        @struct
+        class SmallRet:
+            a: c_int
+            b: c_int
+
+        CALLBACK = CFUNCTYPE(SmallRet)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_small_struct_sum
+        func.argtypes = (CALLBACK,)
+        func.restype = c_long
+
+        self.assertEqual(func(CALLBACK(lambda: SmallRet(300, 45))), 345)
+
+    def test_callback_return_large_struct(self):
+        # Mirrors `Test` in Modules/_ctypes/_ctypes_test.c: >8 bytes, so it
+        # is returned via a hidden pointer rather than in registers.
+        @struct
+        class X:
+            first: c_ulong
+            second: c_ulong
+            third: c_ulong
+
+        CALLBACK = CFUNCTYPE(X)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_large_struct
+        func.argtypes = (CALLBACK,)
+        func.restype = X
+
+        result = func(CALLBACK(lambda: X(0xdeadbeef, 0xcafebabe, 0x0bad1dea)))
+        self.assertEqual(result.first, 0xdeadbeef)
+        self.assertEqual(result.second, 0xcafebabe)
+        self.assertEqual(result.third, 0x0bad1dea)
+
+    def test_callback_return_float_struct(self):
+        # All-float struct: SSE class on x86-64, HFA on AArch64.
+        @struct
+        class FloatRet:
+            x: c_double
+            y: c_double
+
+        CALLBACK = CFUNCTYPE(FloatRet)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_float_struct
+        func.argtypes = (CALLBACK,)
+        func.restype = FloatRet
+
+        result = func(CALLBACK(lambda: FloatRet(1.5, -2.25)))
+        self.assertEqual((result.x, result.y), (1.5, -2.25))
+
+    def test_callback_return_union(self):
+        class UnionRet(Union):
+            _fields_ = [("i", c_int), ("f", c_float)]
+
+        CALLBACK = CFUNCTYPE(UnionRet)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_union
+        func.argtypes = (CALLBACK,)
+        func.restype = UnionRet
+
+        result = func(CALLBACK(lambda: UnionRet(i=0x41424344)))
+        self.assertEqual(result.i, 0x41424344)
+
+    def test_callback_return_struct_repeatedly(self):
+        # Guards against per-call leaks and against regressing into the
+        # "memory leak in callback function" RuntimeWarning path.
+        @struct
+        class SmallRet:
+            a: c_int
+            b: c_int
+
+        CALLBACK = CFUNCTYPE(SmallRet)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_small_struct_sum
+        func.argtypes = (CALLBACK,)
+        func.restype = c_long
+
+        cb = CALLBACK(lambda: SmallRet(1, 2))
+        # A RuntimeWarning raised inside the callback is reported as an
+        # unraisable exception rather than propagated, so check for those.
+        with warnings.catch_warnings():
+            warnings.simplefilter("error", RuntimeWarning)
+            with support.catch_unraisable_exception() as cm:
+                for _ in range(100):
+                    self.assertEqual(func(cb), 3)
+                    self.assertIsNone(cm.unraisable)
+
+    def test_callback_return_struct_subclass(self):
+        # isinstance semantics: a subclass instance is acceptable.
+        @struct
+        class SmallRet:
+            a: c_int
+            b: c_int
+
+        class SubRet(SmallRet):
+            pass
+
+        CALLBACK = CFUNCTYPE(SmallRet)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_small_struct_sum
+        func.argtypes = (CALLBACK,)
+        func.restype = c_long
+
+        self.assertEqual(func(CALLBACK(lambda: SubRet(4, 5))), 9)
+
+    def test_callback_return_struct_wrong_type(self):
+        @struct
+        class SmallRet:
+            a: c_int
+            b: c_int
+
+        @struct
+        class Other:
+            q: c_int
+
+        CALLBACK = CFUNCTYPE(SmallRet)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_small_struct_sum
+        func.argtypes = (CALLBACK,)
+        func.restype = c_long
+
+        for bad in (None, 42, Other(1)):
+            with self.subTest(bad=bad):
+                def cb(bad=bad):
+                    return bad
+                with support.catch_unraisable_exception() as cm:
+                    # As with other result types, the value returned to C
+                    # after a conversion failure is undefined; don't test it.
+                    func(CALLBACK(cb))
+                    self.assertIsInstance(cm.unraisable.exc_value, TypeError)
+                    self.assertEqual(
+                        cm.unraisable.err_msg,
+                        f"Exception ignored while converting result "
+                        f"of ctypes callback function {cb!r}")
+
+    def test_callback_return_struct_with_pointer(self):
+        # gh-49960 / bpo-5710 discussion: the struct is copied by value, so
+        # any pointer it contains must reference memory the caller keeps
+        # alive. Here `keepalive` does exactly that.
+        @struct
+        class WithPtr:
+            s: c_char_p
+
+        keepalive = b"hello"
+
+        CALLBACK = CFUNCTYPE(WithPtr)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_ptr_struct
+        func.argtypes = (CALLBACK,)
+        func.restype = WithPtr
+
+        result = func(CALLBACK(lambda: WithPtr(keepalive)))
+        self.assertEqual(result.s, b"hello")
+
+    def test_callback_return_struct_ignores_instancecheck(self):
+        # The result type is checked without running Python code, so a
+        # metaclass __instancecheck__ cannot cause a smaller structure to be
+        # accepted (which would read past the end of its buffer).
+        class Meta(type(Structure)):
+            def __instancecheck__(cls, inst):
+                return True
+
+        # Not @struct: it would rebuild the class and drop the metaclass.
+        class SmallRet(Structure, metaclass=Meta):
+            _fields_ = [("a", c_int), ("b", c_int)]
+
+        @struct
+        class Tiny:
+            x: c_byte
+
+        CALLBACK = CFUNCTYPE(SmallRet)
+        dll = CDLL(_ctypes_test.__file__)
+        func = dll._testfunc_cbk_ret_small_struct_sum
+        func.argtypes = (CALLBACK,)
+        func.restype = c_long
+
+        def cb():
+            return Tiny(5)
+        with support.catch_unraisable_exception() as cm:
+            func(CALLBACK(cb))
+            self.assertIsInstance(cm.unraisable.exc_value, TypeError)
+
+    def test_callback_return_struct_fields_final(self):
+        # Once a structure is used as a callback's result type, its layout
+        # is fixed: the callback's libffi signature depends on it.
+        @struct
+        class Base:
+            a: c_int
+            b: c_int
+
+        class Sub(Base):
+            pass
+
+        CFUNCTYPE(Sub)(lambda: Sub(1, 2))
+        with self.assertRaisesRegex(AttributeError, "_fields_ is final"):
+            Sub._fields_ = [("c", c_double)]
+
     def test_callback_too_many_args(self):
         def func(*args):
             return len(args)
diff --git 
a/Misc/NEWS.d/next/Library/2026-09-18-00-00-00.gh-issue-49960.MDT3jE.rst 
b/Misc/NEWS.d/next/Library/2026-09-18-00-00-00.gh-issue-49960.MDT3jE.rst
new file mode 100644
index 00000000000000..9793c1143c7d0d
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-09-18-00-00-00.gh-issue-49960.MDT3jE.rst
@@ -0,0 +1,3 @@
+:mod:`ctypes` callback functions can now return structures and unions by
+value. Previously only "simple" types could be returned, and any other result
+type raised :exc:`TypeError`.
diff --git a/Modules/_ctypes/_ctypes_test.c b/Modules/_ctypes/_ctypes_test.c
index 6a5b20143f5527..93c54af243242b 100644
--- a/Modules/_ctypes/_ctypes_test.c
+++ b/Modules/_ctypes/_ctypes_test.c
@@ -72,6 +72,79 @@ _testfunc_cbk_large_struct(Test in, void (*func)(Test))
     func(in);
 }
 
+/*
+ * gh-49960: callbacks returning structs and unions by value.
+ *
+ * Each of these exercises a different ABI return class, since struct
+ * return conventions are highly platform-specific:
+ *   - SmallRet:  small integer struct, returned in registers
+ *   - Test:      >8 bytes (reused from above), returned via hidden pointer
+ *   - FloatRet:  all-float struct, SSE class on x86-64 / HFA on AArch64
+ *   - UnionRet:  union
+ *   - PtrRet:    struct containing a pointer
+ */
+
+typedef struct {
+    int a;
+    int b;
+} SmallRet;
+
+EXPORT(SmallRet)
+_testfunc_cbk_ret_small_struct(SmallRet (*func)(void))
+{
+    return func();
+}
+
+/* Returns a scalar derived from the struct, so a test can prove the bytes
+   actually reached the C caller rather than only round-tripping in Python. */
+EXPORT(long)
+_testfunc_cbk_ret_small_struct_sum(SmallRet (*func)(void))
+{
+    SmallRet s = func();
+    return (long)s.a + (long)s.b;
+}
+
+EXPORT(Test)
+_testfunc_cbk_ret_large_struct(Test (*func)(void))
+{
+    return func();
+}
+
+typedef struct {
+    double x;
+    double y;
+} FloatRet;
+
+EXPORT(FloatRet)
+_testfunc_cbk_ret_float_struct(FloatRet (*func)(void))
+{
+    return func();
+}
+
+typedef union {
+    int i;
+    float f;
+} UnionRet;
+
+EXPORT(UnionRet)
+_testfunc_cbk_ret_union(UnionRet (*func)(void))
+{
+    return func();
+}
+
+/* Struct containing a pointer, for the documented pointer-lifetime contract:
+   the struct is copied by value, so the pointed-to memory must outlive the
+   callback. */
+typedef struct {
+    const char *s;
+} PtrRet;
+
+EXPORT(PtrRet)
+_testfunc_cbk_ret_ptr_struct(PtrRet (*func)(void))
+{
+    return func();
+}
+
 /*
  * See issue 29565. Update a structure passed by value;
  * the caller should not see any change.
diff --git a/Modules/_ctypes/callbacks.c b/Modules/_ctypes/callbacks.c
index e67817e8f6f4d9..8395e9eaf3505e 100644
--- a/Modules/_ctypes/callbacks.c
+++ b/Modules/_ctypes/callbacks.c
@@ -127,6 +127,7 @@ is_narrow_int_ffi_type(int type)
 static void _CallPythonObject(ctypes_state *st,
                               void *mem,
                               ffi_type *restype,
+                              PyObject *restype_obj,
                               SETFUNC setfunc,
                               PyObject *callable,
                               PyObject *converters,
@@ -236,11 +237,11 @@ static void _CallPythonObject(ctypes_state *st,
     Py_XDECREF(error_object);
 
     if (restype != &ffi_type_void && result) {
-        assert(setfunc);
 
         /* libffi's closure contract requires integral results narrower
            than ffi_arg to fill a whole register, sign-extended if signed;
-           setfunc() only writes restype->size bytes. */
+           setfunc() only writes restype->size bytes. Structs and unions
+           are never narrow integers, so they always write to mem directly. */
         union {
             ffi_arg arg;
             int8_t s8;
@@ -254,64 +255,95 @@ static void _CallPythonObject(ctypes_state *st,
                      is_narrow_int_ffi_type(restype->type);
         void *resmem = narrow ? (void *)&narrow_res : mem;
 
-        /* keep is an object we have to keep alive so that the result
-           stays valid.  If there is no such object, the setfunc will
-           have returned Py_None.
-
-           If there is such an object, we have no choice than to keep
-           it alive forever - but a refcount and/or memory leak will
-           be the result.  EXCEPT when restype is py_object - Python
-           itself knows how to manage the refcount of these objects.
-        */
-        PyObject *keep = setfunc(resmem, result, restype->size);
-
-        if (narrow && keep != NULL) {
-            ffi_arg widened;
-            switch (restype->type) {
-            case FFI_TYPE_SINT8:
-                widened = (ffi_arg)(ffi_sarg)narrow_res.s8;
-                break;
-            case FFI_TYPE_SINT16:
-                widened = (ffi_arg)(ffi_sarg)narrow_res.s16;
-                break;
-            case FFI_TYPE_SINT32:
-                widened = (ffi_arg)(ffi_sarg)narrow_res.s32;
-                break;
-            case FFI_TYPE_UINT8:
-                widened = narrow_res.u8;
-                break;
-            case FFI_TYPE_UINT16:
-                widened = narrow_res.u16;
-                break;
-            case FFI_TYPE_UINT32:
-                widened = narrow_res.u32;
-                break;
-            default:
-                Py_UNREACHABLE();
+        if (setfunc == NULL) {
+            /* gh-49960: struct/union return. There is no setfunc for these
+               types, so copy the bytes out of the CData object directly.
+               The struct is copied by value and no object is kept alive, so
+               any pointer it contains must reference memory the caller keeps
+               alive - the same contract C imposes. */
+            assert(!narrow);
+            /* Use a real subtype check rather than PyObject_IsInstance():
+               a metaclass __instancecheck__ could otherwise accept an object
+               whose buffer is smaller than restype->size. */
+            if (PyObject_TypeCheck(result, (PyTypeObject *)restype_obj)) {
+                /* restype_obj is a struct or union type (enforced in
+                   _ctypes_alloc_callback), so its instances are CData. */
+                assert(CDataObject_Check(st, result));
+                CDataObject *cd = (CDataObject *)result;
+                Py_BEGIN_CRITICAL_SECTION(cd);
+                memcpy(mem, cd->b_ptr, restype->size);
+                Py_END_CRITICAL_SECTION();
+            }
+            else {
+                PyErr_Format(PyExc_TypeError,
+                             "ctypes callback function returned unexpected "
+                             "type %T", result);
+                PyErr_FormatUnraisable(
+                        "Exception ignored while converting result "
+                        "of ctypes callback function %R",
+                        callable);
             }
-            memcpy(mem, &widened, sizeof(ffi_arg));
-        }
-
-        if (keep == NULL) {
-            /* Could not convert callback result. */
-            PyErr_FormatUnraisable(
-                    "Exception ignored while converting result "
-                    "of ctypes callback function %R",
-                    callable);
         }
-        else if (setfunc != _ctypes_get_fielddesc("O")->setfunc) {
-            if (keep == Py_None) {
-                /* Nothing to keep */
-                Py_DECREF(keep);
+        else {
+            /* keep is an object we have to keep alive so that the result
+               stays valid.  If there is no such object, the setfunc will
+               have returned Py_None.
+
+               If there is such an object, we have no choice than to keep
+               it alive forever - but a refcount and/or memory leak will
+               be the result.  EXCEPT when restype is py_object - Python
+               itself knows how to manage the refcount of these objects.
+            */
+            PyObject *keep = setfunc(resmem, result, restype->size);
+
+            if (narrow && keep != NULL) {
+                ffi_arg widened;
+                switch (restype->type) {
+                case FFI_TYPE_SINT8:
+                    widened = (ffi_arg)(ffi_sarg)narrow_res.s8;
+                    break;
+                case FFI_TYPE_SINT16:
+                    widened = (ffi_arg)(ffi_sarg)narrow_res.s16;
+                    break;
+                case FFI_TYPE_SINT32:
+                    widened = (ffi_arg)(ffi_sarg)narrow_res.s32;
+                    break;
+                case FFI_TYPE_UINT8:
+                    widened = narrow_res.u8;
+                    break;
+                case FFI_TYPE_UINT16:
+                    widened = narrow_res.u16;
+                    break;
+                case FFI_TYPE_UINT32:
+                    widened = narrow_res.u32;
+                    break;
+                default:
+                    Py_UNREACHABLE();
+                }
+                memcpy(mem, &widened, sizeof(ffi_arg));
             }
-            else if (PyErr_WarnEx(PyExc_RuntimeWarning,
-                                  "memory leak in callback function.",
-                                  1) == -1) {
+
+            if (keep == NULL) {
+                /* Could not convert callback result. */
                 PyErr_FormatUnraisable(
                         "Exception ignored while converting result "
                         "of ctypes callback function %R",
                         callable);
             }
+            else if (setfunc != _ctypes_get_fielddesc("O")->setfunc) {
+                if (keep == Py_None) {
+                    /* Nothing to keep */
+                    Py_DECREF(keep);
+                }
+                else if (PyErr_WarnEx(PyExc_RuntimeWarning,
+                                      "memory leak in callback function.",
+                                      1) == -1) {
+                    PyErr_FormatUnraisable(
+                            "Exception ignored while converting result "
+                            "of ctypes callback function %R",
+                            callable);
+                }
+            }
         }
     }
 
@@ -344,6 +376,7 @@ static void closure_fcn(ffi_cif *cif,
     _CallPythonObject(st,
                       resp,
                       p->ffi_restype,
+                      p->restype,
                       p->setfunc,
                       p->callable,
                       p->converters,
@@ -422,10 +455,25 @@ CThunkObject *_ctypes_alloc_callback(ctypes_state *st,
             goto error;
         }
 
-        if (info == NULL || info->setfunc == NULL) {
-          PyErr_SetString(PyExc_TypeError,
-                          "invalid result type for callback function");
-          goto error;
+        /* gh-49960: structs and unions have no setfunc (that is reserved for
+           "simple" types), but can still be returned by value. Leaving
+           p->setfunc as NULL signals the struct-return path in
+           _CallPythonObject. */
+        if (info == NULL
+            || (
+                info->setfunc == NULL
+                && !PyCStructTypeObject_Check(st, restype)
+                && !PyObject_TypeCheck(restype, st->UnionType_Type)
+            )
+        ) {
+            PyErr_SetString(PyExc_TypeError,
+                            "invalid result type for callback function");
+            goto error;
+        }
+        if (info->setfunc == NULL) {
+            /* The cif built below depends on the struct/union layout, so
+               _fields_ must not be (re)assigned after this point. */
+            stginfo_set_dict_final(info);
         }
         p->setfunc = info->setfunc;
         p->ffi_restype = &info->ffi_type_pointer;

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to