]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-104533: Add a dataclass-like decorator for `ctypes` structures (GH-153781)
authorPeter Bierma <zintensitydev@gmail.com>
Sat, 18 Jul 2026 08:48:16 +0000 (04:48 -0400)
committerGitHub <noreply@github.com>
Sat, 18 Jul 2026 08:48:16 +0000 (04:48 -0400)
Co-authored-by: Bartosz Sławecki <bartosz@ilikepython.com>
Doc/library/ctypes.rst
Doc/whatsnew/3.16.rst
Lib/ctypes/util.py
Lib/test/test_ctypes/test_anon.py
Lib/test/test_ctypes/test_bitfields.py
Lib/test/test_ctypes/test_structures.py
Misc/NEWS.d/next/Library/2026-07-15-15-19-08.gh-issue-104533.pQJxCk.rst [new file with mode: 0644]

index 51f08fdc0544c96d1273eab515e5b7f51af2a75b..80868a8b2418f352f62aa116676a2daa55be0310 100644 (file)
@@ -3161,6 +3161,72 @@ fields, or any other data types containing pointer type fields.
       that should be merged into a containing structure or union.
 
 
+.. decorator:: struct(*, align=None, layout, endian='native', pack=None)
+   :module: ctypes.util
+
+   A :term:`decorator` that allows generating structure types using an
+   annotation-based syntax, similar to the :mod:`dataclasses` module.
+
+   For example:
+
+   .. code-block:: python
+
+      from ctypes.util import struct
+      from ctypes import c_int
+
+      @struct
+      class Point:
+          x: c_int
+          y: c_int
+
+      point = Point(1, 2)
+
+   *align*, *layout*, and *pack* supply the value for the :attr:`~ctypes.Structure._align_`,
+   :attr:`~ctypes.Structure._layout_`, and :attr:`~ctypes.Structure._pack_`
+   attributes, respectively.
+
+   *endian* controls which structure class will be used as the base.
+
+   - If *endian* is ``'native'``, :class:`~ctypes.Structure` will be used.
+   - If *endian* is ``'big'``, :class:`~ctypes.BigEndianStructure` will be used.
+   - If *endian* is ``'little'``, :class:`~ctypes.LittleEndianStructure` will be used.
+
+   Any other value will raise a :class:`ValueError`.
+
+   For controlling field-specific data, wrap the annotation in :class:`typing.Annotated`
+   with :class:`CFieldInfo` as the second argument, like so:
+
+   .. code-block:: python
+
+      @struct
+      class PyObject:
+         ob_refcnt: c_ssize_t
+         ob_type: c_void_p
+
+      @struct
+      class PyHovercraftObject:
+         ob_base: Annotated[PyObject, CFieldInfo(anonymous=True)]
+
+   .. versionadded:: next
+
+
+.. class:: CFieldInfo(anonymous=False, bit_width=None)
+   :module: ctypes.util
+
+   Information regarding a structure field defined by the :func:`struct`
+   decorator. This should be used in the second argument of a
+   :class:`typing.Annotated` wrapping a ctypes type.
+
+   *anonymous* specifies whether the field will be present in the
+   :attr:`~ctypes.Structure._anonymous_` attribute of the generated class.
+
+   If *bit_width* is non-``None``, the annotated field will be *bit_width*
+   number of bits in the generated structure. This is equivalent to passing
+   a third item in :attr:`~ctypes.Structure._fields_`.
+
+   .. versionadded:: next
+
+
 .. _ctypes-arrays-pointers:
 
 Arrays and pointers
index 0ef281dfe1e63b357fd1722c73509e2c3e08885e..5cb1a86dac4623393783e45714ecc5517b7c8993 100644 (file)
@@ -229,6 +229,16 @@ curses
   wide-character support.
   (Contributed by Serhiy Storchaka in :gh:`133031`.)
 
+
+ctypes
+------
+
+* Add :func:`ctypes.util.struct` for generating :class:`~ctypes.Structure` types
+  from an annotation-based syntax, similar to how the :mod:`dataclasses` module
+  is used.
+  (Contributed by Peter Bierma in :gh:`104533`.)
+
+
 encodings
 ---------
 
index 35ac5b6bfd6a37f761532c48f3a95aaadbda2976..51c0d441bc8df0ee7ea03f7891544b7b68a7d1a8 100644 (file)
@@ -1,9 +1,16 @@
 import os
 import sys
 
+from dataclasses import dataclass
+
+lazy import functools
 lazy import shutil
 lazy import subprocess
 
+lazy import annotationlib
+lazy from typing import Annotated, get_args, ClassVar, get_origin
+lazy from ctypes import Structure, BigEndianStructure, LittleEndianStructure
+
 # find_library(name) returns the pathname of a library, or None.
 if os.name == "nt":
 
@@ -491,6 +498,82 @@ if (os.name == "posix" and
                              ctypes.byref(ctypes.py_object(libraries)))
             return libraries
 
+
+@dataclass(slots=True, frozen=True)
+class CFieldInfo:
+    anonymous: bool = False
+    bit_width: int | None = None
+
+
+def _process_struct(decorated_class, /, *, align, layout, endian, pack):
+    fields = []
+    anonymous = []
+    if issubclass(decorated_class, Structure):
+        fields.extend(decorated_class._fields_)
+        anonymous.extend(decorated_class._anonymous_)
+
+    for name, hint in annotationlib.get_annotations(decorated_class).items():
+        if get_origin(hint) is ClassVar:
+            continue
+
+        field = [name]
+        if get_origin(hint) is Annotated:
+            cls, field_info = get_args(hint)
+            field.append(cls)
+            if not isinstance(field_info, CFieldInfo):
+                raise TypeError(f"expected CFieldInfo in Annotated, got {field_info!r}")
+
+            if field_info.bit_width is not None:
+                field.append(field_info.bit_width)
+
+            if field_info.anonymous is True:
+                anonymous.append(name)
+        else:
+            field.append(hint)
+
+        fields.append(field)
+
+    if endian == 'big':
+        endian_class = BigEndianStructure
+    elif endian == 'little':
+        endian_class = LittleEndianStructure
+    elif endian == 'native':
+        endian_class = Structure
+    else:
+        raise ValueError(f"expected 'big', 'little', or 'native', but got {endian!r}")
+
+    @functools.wraps(decorated_class, updated=())
+    class _Struct(endian_class):
+        vars().update(vars(decorated_class))
+        if align is not None:
+            _align_ = align
+        if layout is not None:
+            _layout_ = layout
+        if pack is not None:
+            _pack_ = pack
+        _fields_ = fields
+        _anonymous_ = anonymous
+
+    return _Struct
+
+
+def struct(class_or_none=None, /, *, align=None, layout=None, endian='native', pack=None):
+    process_the_struct = functools.partial(
+        _process_struct,
+        align=align,
+        layout=layout,
+        endian=endian,
+        pack=pack
+    )
+
+    if class_or_none is None:
+        def inner(decorated_class):
+            return process_the_struct(decorated_class)
+
+        return inner
+
+    return process_the_struct(class_or_none)
+
 ################################################################
 # test code
 
index 2e16e7086359890c827dfccd1be61e644b1a0108..1f36f3244e0bf20dad1a153c97196ea029ef0933 100644 (file)
@@ -1,22 +1,32 @@
 import unittest
 import test.support
 from ctypes import c_int, Union, Structure, sizeof
+from ctypes.util import CFieldInfo, struct
+from typing import Annotated
 from ._support import StructCheckMixin
 
 
 class AnonTest(unittest.TestCase, StructCheckMixin):
 
-    def test_anon(self):
+    @test.support.subTests("use_struct_util", [False, True])
+    def test_anon(self, use_struct_util):
         class ANON(Union):
             _fields_ = [("a", c_int),
                         ("b", c_int)]
         self.check_union(ANON)
 
-        class Y(Structure):
-            _fields_ = [("x", c_int),
-                        ("_", ANON),
-                        ("y", c_int)]
-            _anonymous_ = ["_"]
+        if use_struct_util:
+            @struct
+            class Y:
+                x: c_int
+                _: Annotated[ANON, CFieldInfo(anonymous=True)]
+                y: c_int
+        else:
+            class Y(Structure):
+                _fields_ = [("x", c_int),
+                            ("_", ANON),
+                            ("y", c_int)]
+                _anonymous_ = ["_"]
         self.check_struct(Y)
 
         self.assertEqual(Y.a.offset, sizeof(c_int))
index 518f838219eba002ccbcb83722f1189a712038d7..f5f78bd4a2528067f9218448d943b2573088992e 100644 (file)
@@ -1,5 +1,6 @@
 import os
 import sys
+from typing import Annotated
 import unittest
 from ctypes import (CDLL, Structure, sizeof, POINTER, byref, alignment,
                     LittleEndianStructure, BigEndianStructure,
@@ -8,8 +9,9 @@ from ctypes import (CDLL, Structure, sizeof, POINTER, byref, alignment,
                     c_short, c_ushort, c_int, c_uint, c_long, c_ulong,
                     c_longlong, c_ulonglong,
                     Union)
+from ctypes.util import struct, CFieldInfo
 from test import support
-from test.support import import_helper
+from test.support import import_helper, subTests
 from ._support import StructCheckMixin
 _ctypes_test = import_helper.import_module("_ctypes_test")
 
@@ -126,11 +128,19 @@ class BitFieldTest(unittest.TestCase, StructCheckMixin):
         self.check_struct(BITS_msvc)
         self.check_struct(BITS_gcc)
 
-    def test_longlong(self):
-        class X(Structure):
-            _fields_ = [("a", c_longlong, 1),
-                        ("b", c_longlong, 62),
-                        ("c", c_longlong, 1)]
+    @subTests("use_struct_util", [False, True])
+    def test_longlong(self, use_struct_util):
+        if use_struct_util:
+            @struct
+            class X:
+                a: Annotated[c_longlong, CFieldInfo(bit_width=1)]
+                b: Annotated[c_longlong, CFieldInfo(bit_width=62)]
+                c: Annotated[c_longlong, CFieldInfo(bit_width=1)]
+        else:
+            class X(Structure):
+                _fields_ = [("a", c_longlong, 1),
+                            ("b", c_longlong, 62),
+                            ("c", c_longlong, 1)]
         self.check_struct(X)
 
         self.assertEqual(sizeof(X), sizeof(c_longlong))
index 65ccc12625f72bee5fa7cf1e3ccefa4d0fd6333e..4dc1ea867c0700abd2faf3cc8720a486c00b2bef 100644 (file)
@@ -6,36 +6,50 @@ Features common with Union should go in test_structunion.py instead.
 from platform import architecture as _architecture
 import struct
 import sys
+from typing import Annotated, ClassVar
 import unittest
 from ctypes import (CDLL, Structure, Union, POINTER, sizeof, byref,
                     c_void_p, c_char, c_wchar, c_byte, c_ubyte,
                     c_uint8, c_uint16, c_uint32, c_int, c_uint,
                     c_long, c_ulong, c_longlong, c_float, c_double)
-from ctypes.util import find_library
+from ctypes.util import find_library, struct as struct_util
 from collections import namedtuple
 from test import support
-from test.support import import_helper
+from test.support import import_helper, subTests
 from ._support import StructCheckMixin
 _ctypes_test = import_helper.import_module("_ctypes_test")
 
 
 class StructureTestCase(unittest.TestCase, StructCheckMixin):
-    def test_packed(self):
-        class X(Structure):
-            _fields_ = [("a", c_byte),
-                        ("b", c_longlong)]
-            _pack_ = 1
-            _layout_ = 'ms'
+    @subTests("use_struct_util", [False, True])
+    def test_packed(self, use_struct_util):
+        if use_struct_util:
+            @struct_util(pack=1, layout='ms')
+            class X:
+                a: c_byte
+                b: c_longlong
+        else:
+            class X(Structure):
+                _fields_ = [("a", c_byte),
+                            ("b", c_longlong)]
+                _pack_ = 1
+                _layout_ = 'ms'
         self.check_struct(X)
 
         self.assertEqual(sizeof(X), 9)
         self.assertEqual(X.b.offset, 1)
 
-        class X(Structure):
-            _fields_ = [("a", c_byte),
-                        ("b", c_longlong)]
-            _pack_ = 2
-            _layout_ = 'ms'
+        if use_struct_util:
+            @struct_util(pack=2, layout='ms')
+            class X:
+                a: c_byte
+                b: c_longlong
+        else:
+            class X(Structure):
+                _fields_ = [("a", c_byte),
+                            ("b", c_longlong)]
+                _pack_ = 2
+                _layout_ = 'ms'
         self.check_struct(X)
         self.assertEqual(sizeof(X), 10)
         self.assertEqual(X.b.offset, 2)
@@ -43,51 +57,87 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         longlong_size = struct.calcsize("q")
         longlong_align = struct.calcsize("bq") - longlong_size
 
-        class X(Structure):
-            _fields_ = [("a", c_byte),
-                        ("b", c_longlong)]
-            _pack_ = 4
-            _layout_ = 'ms'
+        if use_struct_util:
+            @struct_util(pack=4, layout='ms')
+            class X:
+                a: c_byte
+                b: c_longlong
+        else:
+            class X(Structure):
+                _fields_ = [("a", c_byte),
+                            ("b", c_longlong)]
+                _pack_ = 4
+                _layout_ = 'ms'
         self.check_struct(X)
         self.assertEqual(sizeof(X), min(4, longlong_align) + longlong_size)
         self.assertEqual(X.b.offset, min(4, longlong_align))
 
-        class X(Structure):
-            _fields_ = [("a", c_byte),
-                        ("b", c_longlong)]
-            _pack_ = 8
-            _layout_ = 'ms'
+        if use_struct_util:
+            @struct_util(pack=8, layout='ms')
+            class X:
+                a: c_byte
+                b: c_longlong
+        else:
+            class X(Structure):
+                _fields_ = [("a", c_byte),
+                            ("b", c_longlong)]
+                _pack_ = 8
+                _layout_ = 'ms'
         self.check_struct(X)
 
         self.assertEqual(sizeof(X), min(8, longlong_align) + longlong_size)
         self.assertEqual(X.b.offset, min(8, longlong_align))
 
         with self.assertRaises(ValueError):
-            class X(Structure):
-                _fields_ = [("a", "b"), ("b", "q")]
-                _pack_ = -1
-                _layout_ = "ms"
+            if use_struct_util:
+                @struct_util(pack=-1, layout='ms')
+                class X:
+                    a: "b"
+                    b: "q"
+            else:
+                class X(Structure):
+                    _fields_ = [("a", "b"), ("b", "q")]
+                    _pack_ = -1
+                    _layout_ = "ms"
 
     @support.cpython_only
-    def test_packed_c_limits(self):
+    @subTests("use_struct_util", [False, True])
+    def test_packed_c_limits(self, use_struct_util):
         # Issue 15989
         import _testcapi
         with self.assertRaises(ValueError):
-            class X(Structure):
-                _fields_ = [("a", c_byte)]
-                _pack_ = _testcapi.INT_MAX + 1
-                _layout_ = "ms"
+            if use_struct_util:
+                @struct_util(pack=_testcapi.INT_MAX + 1, layout='ms')
+                class X:
+                    a: c_byte
+            else:
+                class X(Structure):
+                    _fields_ = [("a", c_byte)]
+                    _pack_ = _testcapi.INT_MAX + 1
+                    _layout_ = "ms"
 
         with self.assertRaises(ValueError):
-            class X(Structure):
-                _fields_ = [("a", c_byte)]
-                _pack_ = _testcapi.UINT_MAX + 2
-                _layout_ = "ms"
-
-    def test_initializers(self):
-        class Person(Structure):
-            _fields_ = [("name", c_char*6),
-                        ("age", c_int)]
+            if use_struct_util:
+                @struct_util(pack=_testcapi.UINT_MAX + 2, layout='ms')
+                class X:
+                    a: c_byte
+            else:
+                class X(Structure):
+                    _fields_ = [("a", c_byte)]
+                    _pack_ = _testcapi.UINT_MAX + 2
+                    _layout_ = "ms"
+
+    @subTests("use_struct_util", [False, True])
+    def test_initializers(self, use_struct_util):
+        if use_struct_util:
+            @struct_util
+            class Person:
+                name: c_char * 6
+                age: c_int
+        else:
+            class Person(Structure):
+                _fields_ = [("name", c_char*6),
+                            ("age", c_int)]
 
         self.assertRaises(TypeError, Person, 42)
         self.assertRaises(ValueError, Person, b"asldkjaslkdjaslkdj")
@@ -100,9 +150,16 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         # too long
         self.assertRaises(ValueError, Person, b"1234567", 5)
 
-    def test_conflicting_initializers(self):
-        class POINT(Structure):
-            _fields_ = [("phi", c_float), ("rho", c_float)]
+    @subTests("use_struct_util", [False, True])
+    def test_conflicting_initializers(self, use_struct_util):
+        if use_struct_util:
+            @struct_util
+            class POINT:
+                phi: c_float
+                rho: c_float
+        else:
+            class POINT(Structure):
+                _fields_ = [("phi", c_float), ("rho", c_float)]
         self.check_struct(POINT)
         # conflicting positional and keyword args
         self.assertRaisesRegex(TypeError, "phi", POINT, 2, 3, phi=4)
@@ -111,9 +168,16 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         # too many initializers
         self.assertRaises(TypeError, POINT, 2, 3, 4)
 
-    def test_keyword_initializers(self):
-        class POINT(Structure):
-            _fields_ = [("x", c_int), ("y", c_int)]
+    @subTests("use_struct_util", [False, True])
+    def test_keyword_initializers(self, use_struct_util):
+        if use_struct_util:
+            @struct_util
+            class POINT:
+                x: c_int
+                y: c_int
+        else:
+            class POINT(Structure):
+                _fields_ = [("x", c_int), ("y", c_int)]
         self.check_struct(POINT)
         pt = POINT(1, 2)
         self.assertEqual((pt.x, pt.y), (1, 2))
@@ -121,17 +185,31 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         pt = POINT(y=2, x=1)
         self.assertEqual((pt.x, pt.y), (1, 2))
 
-    def test_nested_initializers(self):
+    @subTests("use_struct_util", [False, True])
+    def test_nested_initializers(self, use_struct_util):
         # test initializing nested structures
-        class Phone(Structure):
-            _fields_ = [("areacode", c_char*6),
-                        ("number", c_char*12)]
+        if use_struct_util:
+            @struct_util
+            class Phone:
+                areacode: c_char * 6
+                number: c_char * 12
+        else:
+            class Phone(Structure):
+                _fields_ = [("areacode", c_char*6),
+                            ("number", c_char*12)]
         self.check_struct(Phone)
 
-        class Person(Structure):
-            _fields_ = [("name", c_char * 12),
-                        ("phone", Phone),
-                        ("age", c_int)]
+        if use_struct_util:
+            @struct_util
+            class Person:
+                name: c_char * 12
+                phone: Phone
+                age: c_int
+        else:
+            class Person(Structure):
+                _fields_ = [("name", c_char * 12),
+                            ("phone", Phone),
+                            ("age", c_int)]
         self.check_struct(Person)
 
         p = Person(b"Someone", (b"1234", b"5678"), 5)
@@ -141,10 +219,17 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         self.assertEqual(p.phone.number, b"5678")
         self.assertEqual(p.age, 5)
 
-    def test_structures_with_wchar(self):
-        class PersonW(Structure):
-            _fields_ = [("name", c_wchar * 12),
-                        ("age", c_int)]
+    @subTests("use_struct_util", [False, True])
+    def test_structures_with_wchar(self, use_struct_util):
+        if use_struct_util:
+            @struct_util
+            class PersonW:
+                name: c_wchar * 12
+                age: c_int
+        else:
+            class PersonW(Structure):
+                _fields_ = [("name", c_wchar * 12),
+                            ("age", c_int)]
         self.check_struct(PersonW)
 
         p = PersonW("Someone \xe9")
@@ -157,16 +242,30 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         #too long
         self.assertRaises(ValueError, PersonW, "1234567890123")
 
-    def test_init_errors(self):
-        class Phone(Structure):
-            _fields_ = [("areacode", c_char*6),
-                        ("number", c_char*12)]
+    @subTests("use_struct_util", [False, True])
+    def test_init_errors(self, use_struct_util):
+        if use_struct_util:
+            @struct_util
+            class Phone:
+                areacode: c_char * 6
+                number: c_char * 12
+        else:
+            class Phone(Structure):
+                _fields_ = [("areacode", c_char*6),
+                            ("number", c_char*12)]
         self.check_struct(Phone)
 
-        class Person(Structure):
-            _fields_ = [("name", c_char * 12),
-                        ("phone", Phone),
-                        ("age", c_int)]
+        if use_struct_util:
+            @struct_util
+            class Person:
+                name: c_char * 12
+                phone: Phone
+                age: c_int
+        else:
+            class Person(Structure):
+                _fields_ = [("name", c_char * 12),
+                            ("phone", Phone),
+                            ("age", c_int)]
         self.check_struct(Person)
 
         cls, msg = self.get_except(Person, b"Someone", (1, 2))
@@ -186,22 +285,46 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         except Exception as detail:
             return detail.__class__, str(detail)
 
-    def test_positional_args(self):
+    @subTests("use_struct_util", [False, True])
+    def test_positional_args(self, use_struct_util):
         # see also http://bugs.python.org/issue5042
-        class W(Structure):
-            _fields_ = [("a", c_int), ("b", c_int)]
+        if use_struct_util:
+            @struct_util
+            class W:
+                a: c_int
+                b: c_int
+        else:
+            class W(Structure):
+                _fields_ = [("a", c_int), ("b", c_int)]
         self.check_struct(W)
 
-        class X(W):
-            _fields_ = [("c", c_int)]
+        if use_struct_util:
+            @struct_util
+            class X(W):
+                c: c_int
+        else:
+            class X(W):
+                _fields_ = [("c", c_int)]
         self.check_struct(X)
 
-        class Y(X):
-            pass
+        if use_struct_util:
+            @struct_util
+            class Y(X):
+                pass
+        else:
+            class Y(X):
+                pass
         self.check_struct(Y)
 
-        class Z(Y):
-            _fields_ = [("d", c_int), ("e", c_int), ("f", c_int)]
+        if use_struct_util:
+            @struct_util
+            class Z(Y):
+                d: c_int
+                e: c_int
+                f: c_int
+        else:
+            class Z(Y):
+                _fields_ = [("d", c_int), ("e", c_int), ("f", c_int)]
         self.check_struct(Z)
 
         z = Z(1, 2, 3, 4, 5, 6)
@@ -212,15 +335,23 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
                          (1, 0, 0, 0, 0, 0))
         self.assertRaises(TypeError, lambda: Z(1, 2, 3, 4, 5, 6, 7))
 
-    def test_pass_by_value(self):
+    @subTests("use_struct_util", [False, True])
+    def test_pass_by_value(self, use_struct_util):
         # This should mirror the Test structure
         # in Modules/_ctypes/_ctypes_test.c
-        class Test(Structure):
-            _fields_ = [
-                ('first', c_ulong),
-                ('second', c_ulong),
-                ('third', c_ulong),
-            ]
+        if use_struct_util:
+            @struct_util
+            class Test:
+                first: c_ulong
+                second: c_ulong
+                third: c_ulong
+        else:
+            class Test(Structure):
+                _fields_ = [
+                    ('first', c_ulong),
+                    ('second', c_ulong),
+                    ('third', c_ulong),
+                ]
         self.check_struct(Test)
 
         s = Test()
@@ -236,21 +367,32 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         self.assertEqual(s.second, 0xcafebabe)
         self.assertEqual(s.third, 0x0bad1dea)
 
-    def test_pass_by_value_finalizer(self):
+    @subTests("use_struct_util", [False, True])
+    def test_pass_by_value_finalizer(self, use_struct_util):
         # bpo-37140: Similar to test_pass_by_value(), but the Python structure
         # has a finalizer (__del__() method): the finalizer must only be called
         # once.
 
         finalizer_calls = []
 
-        class Test(Structure):
-            _fields_ = [
-                ('first', c_ulong),
-                ('second', c_ulong),
-                ('third', c_ulong),
-            ]
-            def __del__(self):
-                finalizer_calls.append("called")
+        if use_struct_util:
+            @struct_util
+            class Test:
+                first: c_ulong
+                second: c_ulong
+                third: c_ulong
+
+                def __del__(self):
+                    finalizer_calls.append("called")
+        else:
+            class Test(Structure):
+                _fields_ = [
+                    ('first', c_ulong),
+                    ('second', c_ulong),
+                    ('third', c_ulong),
+                ]
+                def __del__(self):
+                    finalizer_calls.append("called")
         self.check_struct(Test)
 
         s = Test(1, 2, 3)
@@ -275,12 +417,19 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         support.gc_collect()
         self.assertEqual(finalizer_calls, ["called"])
 
-    def test_pass_by_value_in_register(self):
-        class X(Structure):
-            _fields_ = [
-                ('first', c_uint),
-                ('second', c_uint)
-            ]
+    @subTests("use_struct_util", [False, True])
+    def test_pass_by_value_in_register(self, use_struct_util):
+        if use_struct_util:
+            @struct_util
+            class X:
+                first: c_uint
+                second: c_uint
+        else:
+            class X(Structure):
+                _fields_ = [
+                    ('first', c_uint),
+                    ('second', c_uint)
+                ]
         self.check_struct(X)
 
         s = X()
@@ -325,47 +474,90 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
 
     @unittest.skipIf(_architecture() == ('64bit', 'WindowsPE'), "can't test Windows x64 build")
     @unittest.skipUnless(sys.byteorder == 'little', "can't test on this platform")
-    def test_issue18060_a(self):
+    @subTests("use_struct_util", [False, True])
+    def test_issue18060_a(self, use_struct_util):
         # This test case calls
         # PyCStructUnionType_update_stginfo() for each
         # _fields_ assignment, and PyCStgInfo_clone()
         # for the Mid and Vector class definitions.
-        class Base(Structure):
-            _fields_ = [('y', c_double),
-                        ('x', c_double)]
-        class Mid(Base):
-            pass
-        Mid._fields_ = []
-        class Vector(Mid): pass
+        if use_struct_util:
+            @struct_util
+            class Base:
+                y: c_double
+                x: c_double
+
+            @struct_util
+            class Mid(Base):
+                pass
+
+            @struct_util
+            class Vector(Mid): pass
+        else:
+            class Base(Structure):
+                _fields_ = [('y', c_double),
+                            ('x', c_double)]
+            class Mid(Base):
+                pass
+            Mid._fields_ = []
+            class Vector(Mid): pass
         self._test_issue18060(Vector)
 
     @unittest.skipIf(_architecture() == ('64bit', 'WindowsPE'), "can't test Windows x64 build")
     @unittest.skipUnless(sys.byteorder == 'little', "can't test on this platform")
-    def test_issue18060_b(self):
+    @subTests("use_struct_util", [False, True])
+    def test_issue18060_b(self, use_struct_util):
         # This test case calls
         # PyCStructUnionType_update_stginfo() for each
         # _fields_ assignment.
-        class Base(Structure):
-            _fields_ = [('y', c_double),
-                        ('x', c_double)]
-        class Mid(Base):
-            _fields_ = []
-        class Vector(Mid):
-            _fields_ = []
+        if use_struct_util:
+            @struct_util
+            class Base:
+                y: c_double
+                x: c_double
+
+            @struct_util
+            class Mid(Base):
+                pass
+
+            @struct_util
+            class Vector(Mid):
+                pass
+        else:
+            class Base(Structure):
+                _fields_ = [('y', c_double),
+                            ('x', c_double)]
+            class Mid(Base):
+                _fields_ = []
+            class Vector(Mid):
+                _fields_ = []
         self._test_issue18060(Vector)
 
     @unittest.skipIf(_architecture() == ('64bit', 'WindowsPE'), "can't test Windows x64 build")
     @unittest.skipUnless(sys.byteorder == 'little', "can't test on this platform")
-    def test_issue18060_c(self):
+    @subTests("use_struct_util", [False, True])
+    def test_issue18060_c(self, use_struct_util):
         # This test case calls
         # PyCStructUnionType_update_stginfo() for each
         # _fields_ assignment.
-        class Base(Structure):
-            _fields_ = [('y', c_double)]
-        class Mid(Base):
-            _fields_ = []
-        class Vector(Mid):
-            _fields_ = [('x', c_double)]
+        if use_struct_util:
+            @struct_util
+            class Base:
+                y: c_double
+
+            @struct_util
+            class Mid(Base):
+                pass
+
+            @struct_util
+            class Vector(Mid):
+                x: c_double
+        else:
+            class Base(Structure):
+                _fields_ = [('y', c_double)]
+            class Mid(Base):
+                _fields_ = []
+            class Vector(Mid):
+                _fields_ = [('x', c_double)]
         self._test_issue18060(Vector)
 
     def test_array_in_struct(self):
@@ -701,14 +893,21 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         self.assertEqual(ctx.exception.args[0], 'item 1 in _argtypes_ passes '
                          'a union by value, which is unsupported.')
 
-    def test_do_not_share_pointer_type_cache_via_stginfo_clone(self):
+    @subTests("use_struct_util", [False, True])
+    def test_do_not_share_pointer_type_cache_via_stginfo_clone(self, use_struct_util):
         # This test case calls PyCStgInfo_clone()
         # for the Mid and Vector class definitions
         # and checks that pointer_type cache not shared
         # between subclasses.
-        class Base(Structure):
-            _fields_ = [('y', c_double),
-                        ('x', c_double)]
+        if use_struct_util:
+            @struct_util
+            class Base:
+                y: c_double
+                x: c_double
+        else:
+            class Base(Structure):
+                _fields_ = [('y', c_double),
+                            ('x', c_double)]
         base_ptr = POINTER(Base)
 
         class Mid(Base):
@@ -725,6 +924,36 @@ class StructureTestCase(unittest.TestCase, StructCheckMixin):
         self.assertIsNot(base_ptr, vector_ptr)
         self.assertIsNot(mid_ptr, vector_ptr)
 
+    def test_struct_util_classvars(self):
+        @struct_util
+        class Foo:
+            x: c_int
+            not_a_field: ClassVar[int] = 42
+
+        self.assertEqual(Foo(1).x, 1)
+
+        with self.assertRaises(TypeError):
+            Foo(2, 3)
+
+        self.assertEqual(Foo.not_a_field, 42)
+
+    def test_struct_util_misc(self):
+        with self.assertRaises(TypeError):
+            @struct_util
+            class Foo:
+                x: Annotated[c_int, 42]
+
+        with self.assertRaises(ValueError):
+            @struct_util(endian='notvalid')
+            class Foo:
+                pass
+
+        @struct_util
+        class Foo:
+            pass
+
+        self.assertEqual(Foo.__name__, "Foo")
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/Misc/NEWS.d/next/Library/2026-07-15-15-19-08.gh-issue-104533.pQJxCk.rst b/Misc/NEWS.d/next/Library/2026-07-15-15-19-08.gh-issue-104533.pQJxCk.rst
new file mode 100644 (file)
index 0000000..adb08a8
--- /dev/null
@@ -0,0 +1,2 @@
+Add :func:`ctypes.util.struct` and :class:`ctypes.util.CFieldInfo` for
+generating structure types using annotations.