From f0db59d9f5f25507538db75e4dcb2e58f45b60c7 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 15 Jul 2026 14:53:47 -0400 Subject: [PATCH 01/10] Implement a dataclass-like struct decorator for ctypes. --- Lib/ctypes/util.py | 86 +++++ Lib/test/test_ctypes/test_structures.py | 479 +++++++++++++++++------- 2 files changed, 440 insertions(+), 125 deletions(-) diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 35ac5b6bfd6a37f..369cfe1fa860003 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -1,9 +1,15 @@ import os import sys +from dataclasses import dataclass + 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 +497,86 @@ def dllist(): 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(klass, /, *, align, layout, endian, pack): + fields = [] + anonymous = [] + if issubclass(klass, (Structure, LittleEndianStructure, BigEndianStructure)): + fields.extend(klass._fields_) + anonymous.extend(klass._anonymous_) + + for name, hint in annotationlib.get_annotations(klass).items(): + if get_origin(hint) is ClassVar: + continue + + field = [name, hint] + if get_origin(hint) is Annotated: + field_info = get_args(hint)[1] + 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) + + continue + + 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}") + + # These fields don't apply correctly when set later. + # As a workaround, we have this weird _StructData thing to set the attributes + # in advance. + class _StructData: + pass + + if align is not None: + _StructData._align_ = align + + if layout is not None: + _StructData._layout_ = layout + + if pack is not None: + _StructData._pack_ = pack + + for attr, value in klass.__dict__.items(): + if attr != "__dict__": + setattr(_StructData, attr, value) + + class _Struct(_StructData, endian_class): + _fields_ = fields + _anonymous_ = anonymous + + _Struct.__name__ = klass.__name__ + _Struct.__qualname__ = klass.__qualname__ + return _Struct + + +def struct(class_or_none=None, /, *, align=None, layout=None, endian='native', pack=None): + if class_or_none is None: + def inner(klass): + return _process_struct(klass, align=align, layout=layout, endian=endian, pack=pack) + + return inner + + return _process_struct(class_or_none, align=align, layout=layout, endian=endian, pack=pack) + ################################################################ # test code diff --git a/Lib/test/test_ctypes/test_structures.py b/Lib/test/test_ctypes/test_structures.py index 65ccc12625f72be..4dc1ea867c0700a 100644 --- a/Lib/test/test_ctypes/test_structures.py +++ b/Lib/test/test_ctypes/test_structures.py @@ -6,36 +6,50 @@ 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 X(Structure): 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 Person(Structure): # 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 POINT(Structure): # 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 POINT(Structure): 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 Person(Structure): 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 PersonW(Structure): #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 @@ def get_except(self, func, *args): 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 Z(Y): (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 Test(Structure): 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 @@ def __del__(self): 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 @@ def _test_issue18060(self, 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_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 Test8(Union): 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 Vector(Mid): 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() From b73c912dc7eb923badfcf6160f72134d36e0cb3b Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 15 Jul 2026 15:18:23 -0400 Subject: [PATCH 02/10] Add docs. --- Doc/library/ctypes.rst | 66 ++++++++++++++++++++++++++++++++++++++++++ Doc/whatsnew/3.16.rst | 10 +++++++ Lib/ctypes/util.py | 1 + 3 files changed, 77 insertions(+) diff --git a/Doc/library/ctypes.rst b/Doc/library/ctypes.rst index 51f08fdc0544c96..848bf5aeee11664 100644 --- a/Doc/library/ctypes.rst +++ b/Doc/library/ctypes.rst @@ -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:`~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 diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index fad723ba3f4cb63..e5ec832fd98677f 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -224,6 +224,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 --------- diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 369cfe1fa860003..68e1bf00312c149 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -565,6 +565,7 @@ class _Struct(_StructData, endian_class): _Struct.__name__ = klass.__name__ _Struct.__qualname__ = klass.__qualname__ + _Struct.__module__ = klass.__module__ return _Struct From 94eb25acbe496f1034b86b199f21e048ece31cff Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 15 Jul 2026 15:19:10 -0400 Subject: [PATCH 03/10] Add blurb. --- .../next/Library/2026-07-15-15-19-08.gh-issue-104533.pQJxCk.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-15-15-19-08.gh-issue-104533.pQJxCk.rst 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 index 000000000000000..adb08a8f79c1092 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-15-15-19-08.gh-issue-104533.pQJxCk.rst @@ -0,0 +1,2 @@ +Add :func:`ctypes.util.struct` and :class:`ctypes.util.CFieldInfo` for +generating structure types using annotations. From bba76828e2fca2a69f2062e0aebb49233761d044 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 15 Jul 2026 15:27:41 -0400 Subject: [PATCH 04/10] Fix Sphinx warnings. --- Doc/library/ctypes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/ctypes.rst b/Doc/library/ctypes.rst index 848bf5aeee11664..80868a8b2418f35 100644 --- a/Doc/library/ctypes.rst +++ b/Doc/library/ctypes.rst @@ -3218,7 +3218,7 @@ fields, or any other data types containing pointer type fields. :class:`typing.Annotated` wrapping a ctypes type. *anonymous* specifies whether the field will be present in the - :attr:`~Structure._anonymous_` attribute of the generated class. + :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 From 91eb76b784b7630dd5659d0a607fe6ebaa3a325e Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 16 Jul 2026 03:31:45 -0400 Subject: [PATCH 05/10] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartosz Sławecki --- Lib/ctypes/util.py | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 68e1bf00312c149..68af847707b8bab 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -507,7 +507,7 @@ class CFieldInfo: def _process_struct(klass, /, *, align, layout, endian, pack): fields = [] anonymous = [] - if issubclass(klass, (Structure, LittleEndianStructure, BigEndianStructure)): + if issubclass(klass, Structure): fields.extend(klass._fields_) anonymous.extend(klass._anonymous_) @@ -540,28 +540,16 @@ def _process_struct(klass, /, *, align, layout, endian, pack): else: raise ValueError(f"expected 'big', 'little', or 'native', but got {endian!r}") - # These fields don't apply correctly when set later. - # As a workaround, we have this weird _StructData thing to set the attributes - # in advance. - class _StructData: - pass - - if align is not None: - _StructData._align_ = align - - if layout is not None: - _StructData._layout_ = layout - - if pack is not None: - _StructData._pack_ = pack - - for attr, value in klass.__dict__.items(): - if attr != "__dict__": - setattr(_StructData, attr, value) - - class _Struct(_StructData, endian_class): - _fields_ = fields - _anonymous_ = anonymous + class _Struct(endian_class): + vars().update(vars(klass)) + 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 _Struct.__name__ = klass.__name__ _Struct.__qualname__ = klass.__qualname__ From 103a74045af3eebce0f254072117e62bb8f4a7a5 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 16 Jul 2026 03:35:20 -0400 Subject: [PATCH 06/10] Use functools.wraps. --- Lib/ctypes/util.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 68af847707b8bab..50ff318d7496e92 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -3,6 +3,7 @@ from dataclasses import dataclass +lazy import functools lazy import shutil lazy import subprocess @@ -540,20 +541,18 @@ def _process_struct(klass, /, *, align, layout, endian, pack): else: raise ValueError(f"expected 'big', 'little', or 'native', but got {endian!r}") - class _Struct(endian_class): - vars().update(vars(klass)) - 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 - - _Struct.__name__ = klass.__name__ - _Struct.__qualname__ = klass.__qualname__ - _Struct.__module__ = klass.__module__ + @functools.wraps(klass, updated=()) + class _Struct(endian_class): + vars().update(vars(klass)) + 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 From b14dd5145cef8f50bc72602b865f601966c17809 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 16 Jul 2026 03:37:14 -0400 Subject: [PATCH 07/10] Use functools.partial. --- Lib/ctypes/util.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 50ff318d7496e92..34b33133a66139a 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -557,13 +557,21 @@ class _Struct(endian_class): 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(klass): - return _process_struct(klass, align=align, layout=layout, endian=endian, pack=pack) + return process_the_struct(klass) return inner - return _process_struct(class_or_none, align=align, layout=layout, endian=endian, pack=pack) + return process_the_struct(class_or_none) ################################################################ # test code From 864b8114dfda5c24ac695831f5d76c38104b7df0 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 16 Jul 2026 03:58:13 -0400 Subject: [PATCH 08/10] Add tests for CFieldInfo. --- Lib/ctypes/util.py | 10 ++++++---- Lib/test/test_ctypes/test_anon.py | 22 ++++++++++++++++------ Lib/test/test_ctypes/test_bitfields.py | 22 ++++++++++++++++------ 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 34b33133a66139a..c8eda52f3a63b09 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -516,9 +516,11 @@ def _process_struct(klass, /, *, align, layout, endian, pack): if get_origin(hint) is ClassVar: continue - field = [name, hint] + field = [name] if get_origin(hint) is Annotated: - field_info = get_args(hint)[1] + args = get_args(hint) + field.append(args[0]) + field_info = args[1] if not isinstance(field_info, CFieldInfo): raise TypeError(f"expected CFieldInfo in Annotated, got {field_info!r}") @@ -527,8 +529,8 @@ def _process_struct(klass, /, *, align, layout, endian, pack): if field_info.anonymous is True: anonymous.append(name) - - continue + else: + field.append(hint) fields.append(field) diff --git a/Lib/test/test_ctypes/test_anon.py b/Lib/test/test_ctypes/test_anon.py index 2e16e7086359890..1f36f3244e0bf20 100644 --- a/Lib/test/test_ctypes/test_anon.py +++ b/Lib/test/test_ctypes/test_anon.py @@ -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)) diff --git a/Lib/test/test_ctypes/test_bitfields.py b/Lib/test/test_ctypes/test_bitfields.py index 518f838219eba00..f5f78bd4a252806 100644 --- a/Lib/test/test_ctypes/test_bitfields.py +++ b/Lib/test/test_ctypes/test_bitfields.py @@ -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 @@ 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 @@ def test_generic_checks(self): 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)) From c0afd38676a105c8c497c7f299bef29a93df0b76 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 16 Jul 2026 12:01:53 -0400 Subject: [PATCH 09/10] Update Lib/ctypes/util.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartosz Sławecki --- Lib/ctypes/util.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index c8eda52f3a63b09..325a4ea697403ee 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -518,9 +518,8 @@ def _process_struct(klass, /, *, align, layout, endian, pack): field = [name] if get_origin(hint) is Annotated: - args = get_args(hint) - field.append(args[0]) - field_info = args[1] + 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}") From 2e469b608828bc792df362e78e6d825f0fbd9f16 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 16 Jul 2026 12:05:13 -0400 Subject: [PATCH 10/10] Use 'decorated_class' instead of 'klass'. --- Lib/ctypes/util.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index 325a4ea697403ee..51c0d441bc8df0e 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -505,14 +505,14 @@ class CFieldInfo: bit_width: int | None = None -def _process_struct(klass, /, *, align, layout, endian, pack): +def _process_struct(decorated_class, /, *, align, layout, endian, pack): fields = [] anonymous = [] - if issubclass(klass, Structure): - fields.extend(klass._fields_) - anonymous.extend(klass._anonymous_) + if issubclass(decorated_class, Structure): + fields.extend(decorated_class._fields_) + anonymous.extend(decorated_class._anonymous_) - for name, hint in annotationlib.get_annotations(klass).items(): + for name, hint in annotationlib.get_annotations(decorated_class).items(): if get_origin(hint) is ClassVar: continue @@ -542,9 +542,9 @@ def _process_struct(klass, /, *, align, layout, endian, pack): else: raise ValueError(f"expected 'big', 'little', or 'native', but got {endian!r}") - @functools.wraps(klass, updated=()) + @functools.wraps(decorated_class, updated=()) class _Struct(endian_class): - vars().update(vars(klass)) + vars().update(vars(decorated_class)) if align is not None: _align_ = align if layout is not None: @@ -567,8 +567,8 @@ def struct(class_or_none=None, /, *, align=None, layout=None, endian='native', p ) if class_or_none is None: - def inner(klass): - return process_the_struct(klass) + def inner(decorated_class): + return process_the_struct(decorated_class) return inner