diff --git a/Doc/library/ctypes.rst b/Doc/library/ctypes.rst index 51f08fdc0544c9..80868a8b2418f3 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:`~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 diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index fad723ba3f4cb6..e5ec832fd98677 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 35ac5b6bfd6a37..c8eda52f3a63b0 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -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,83 @@ 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): + 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] + if get_origin(hint) is Annotated: + 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}") + + 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(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 + + +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_the_struct(klass) + + return inner + + return process_the_struct(class_or_none) + ################################################################ # test code diff --git a/Lib/test/test_ctypes/test_anon.py b/Lib/test/test_ctypes/test_anon.py index 2e16e708635989..1f36f3244e0bf2 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 518f838219eba0..f5f78bd4a25280 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)) diff --git a/Lib/test/test_ctypes/test_structures.py b/Lib/test/test_ctypes/test_structures.py index 65ccc12625f72b..4dc1ea867c0700 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() 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 00000000000000..adb08a8f79c109 --- /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.