Skip to content

Commit 5b85756

Browse files
gh-104533: Add a dataclass-like decorator for ctypes structures (GH-153781)
Co-authored-by: Bartosz Sławecki <bartosz@ilikepython.com>
1 parent cffcee4 commit 5b85756

7 files changed

Lines changed: 547 additions & 137 deletions

File tree

Doc/library/ctypes.rst

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3161,6 +3161,72 @@ fields, or any other data types containing pointer type fields.
31613161
that should be merged into a containing structure or union.
31623162

31633163

3164+
.. decorator:: struct(*, align=None, layout, endian='native', pack=None)
3165+
:module: ctypes.util
3166+
3167+
A :term:`decorator` that allows generating structure types using an
3168+
annotation-based syntax, similar to the :mod:`dataclasses` module.
3169+
3170+
For example:
3171+
3172+
.. code-block:: python
3173+
3174+
from ctypes.util import struct
3175+
from ctypes import c_int
3176+
3177+
@struct
3178+
class Point:
3179+
x: c_int
3180+
y: c_int
3181+
3182+
point = Point(1, 2)
3183+
3184+
*align*, *layout*, and *pack* supply the value for the :attr:`~ctypes.Structure._align_`,
3185+
:attr:`~ctypes.Structure._layout_`, and :attr:`~ctypes.Structure._pack_`
3186+
attributes, respectively.
3187+
3188+
*endian* controls which structure class will be used as the base.
3189+
3190+
- If *endian* is ``'native'``, :class:`~ctypes.Structure` will be used.
3191+
- If *endian* is ``'big'``, :class:`~ctypes.BigEndianStructure` will be used.
3192+
- If *endian* is ``'little'``, :class:`~ctypes.LittleEndianStructure` will be used.
3193+
3194+
Any other value will raise a :class:`ValueError`.
3195+
3196+
For controlling field-specific data, wrap the annotation in :class:`typing.Annotated`
3197+
with :class:`CFieldInfo` as the second argument, like so:
3198+
3199+
.. code-block:: python
3200+
3201+
@struct
3202+
class PyObject:
3203+
ob_refcnt: c_ssize_t
3204+
ob_type: c_void_p
3205+
3206+
@struct
3207+
class PyHovercraftObject:
3208+
ob_base: Annotated[PyObject, CFieldInfo(anonymous=True)]
3209+
3210+
.. versionadded:: next
3211+
3212+
3213+
.. class:: CFieldInfo(anonymous=False, bit_width=None)
3214+
:module: ctypes.util
3215+
3216+
Information regarding a structure field defined by the :func:`struct`
3217+
decorator. This should be used in the second argument of a
3218+
:class:`typing.Annotated` wrapping a ctypes type.
3219+
3220+
*anonymous* specifies whether the field will be present in the
3221+
:attr:`~ctypes.Structure._anonymous_` attribute of the generated class.
3222+
3223+
If *bit_width* is non-``None``, the annotated field will be *bit_width*
3224+
number of bits in the generated structure. This is equivalent to passing
3225+
a third item in :attr:`~ctypes.Structure._fields_`.
3226+
3227+
.. versionadded:: next
3228+
3229+
31643230
.. _ctypes-arrays-pointers:
31653231

31663232
Arrays and pointers

Doc/whatsnew/3.16.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,16 @@ curses
229229
wide-character support.
230230
(Contributed by Serhiy Storchaka in :gh:`133031`.)
231231

232+
233+
ctypes
234+
------
235+
236+
* Add :func:`ctypes.util.struct` for generating :class:`~ctypes.Structure` types
237+
from an annotation-based syntax, similar to how the :mod:`dataclasses` module
238+
is used.
239+
(Contributed by Peter Bierma in :gh:`104533`.)
240+
241+
232242
encodings
233243
---------
234244

Lib/ctypes/util.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import os
22
import sys
33

4+
from dataclasses import dataclass
5+
6+
lazy import functools
47
lazy import shutil
58
lazy import subprocess
69

10+
lazy import annotationlib
11+
lazy from typing import Annotated, get_args, ClassVar, get_origin
12+
lazy from ctypes import Structure, BigEndianStructure, LittleEndianStructure
13+
714
# find_library(name) returns the pathname of a library, or None.
815
if os.name == "nt":
916

@@ -491,6 +498,82 @@ def dllist():
491498
ctypes.byref(ctypes.py_object(libraries)))
492499
return libraries
493500

501+
502+
@dataclass(slots=True, frozen=True)
503+
class CFieldInfo:
504+
anonymous: bool = False
505+
bit_width: int | None = None
506+
507+
508+
def _process_struct(decorated_class, /, *, align, layout, endian, pack):
509+
fields = []
510+
anonymous = []
511+
if issubclass(decorated_class, Structure):
512+
fields.extend(decorated_class._fields_)
513+
anonymous.extend(decorated_class._anonymous_)
514+
515+
for name, hint in annotationlib.get_annotations(decorated_class).items():
516+
if get_origin(hint) is ClassVar:
517+
continue
518+
519+
field = [name]
520+
if get_origin(hint) is Annotated:
521+
cls, field_info = get_args(hint)
522+
field.append(cls)
523+
if not isinstance(field_info, CFieldInfo):
524+
raise TypeError(f"expected CFieldInfo in Annotated, got {field_info!r}")
525+
526+
if field_info.bit_width is not None:
527+
field.append(field_info.bit_width)
528+
529+
if field_info.anonymous is True:
530+
anonymous.append(name)
531+
else:
532+
field.append(hint)
533+
534+
fields.append(field)
535+
536+
if endian == 'big':
537+
endian_class = BigEndianStructure
538+
elif endian == 'little':
539+
endian_class = LittleEndianStructure
540+
elif endian == 'native':
541+
endian_class = Structure
542+
else:
543+
raise ValueError(f"expected 'big', 'little', or 'native', but got {endian!r}")
544+
545+
@functools.wraps(decorated_class, updated=())
546+
class _Struct(endian_class):
547+
vars().update(vars(decorated_class))
548+
if align is not None:
549+
_align_ = align
550+
if layout is not None:
551+
_layout_ = layout
552+
if pack is not None:
553+
_pack_ = pack
554+
_fields_ = fields
555+
_anonymous_ = anonymous
556+
557+
return _Struct
558+
559+
560+
def struct(class_or_none=None, /, *, align=None, layout=None, endian='native', pack=None):
561+
process_the_struct = functools.partial(
562+
_process_struct,
563+
align=align,
564+
layout=layout,
565+
endian=endian,
566+
pack=pack
567+
)
568+
569+
if class_or_none is None:
570+
def inner(decorated_class):
571+
return process_the_struct(decorated_class)
572+
573+
return inner
574+
575+
return process_the_struct(class_or_none)
576+
494577
################################################################
495578
# test code
496579

Lib/test/test_ctypes/test_anon.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
import unittest
22
import test.support
33
from ctypes import c_int, Union, Structure, sizeof
4+
from ctypes.util import CFieldInfo, struct
5+
from typing import Annotated
46
from ._support import StructCheckMixin
57

68

79
class AnonTest(unittest.TestCase, StructCheckMixin):
810

9-
def test_anon(self):
11+
@test.support.subTests("use_struct_util", [False, True])
12+
def test_anon(self, use_struct_util):
1013
class ANON(Union):
1114
_fields_ = [("a", c_int),
1215
("b", c_int)]
1316
self.check_union(ANON)
1417

15-
class Y(Structure):
16-
_fields_ = [("x", c_int),
17-
("_", ANON),
18-
("y", c_int)]
19-
_anonymous_ = ["_"]
18+
if use_struct_util:
19+
@struct
20+
class Y:
21+
x: c_int
22+
_: Annotated[ANON, CFieldInfo(anonymous=True)]
23+
y: c_int
24+
else:
25+
class Y(Structure):
26+
_fields_ = [("x", c_int),
27+
("_", ANON),
28+
("y", c_int)]
29+
_anonymous_ = ["_"]
2030
self.check_struct(Y)
2131

2232
self.assertEqual(Y.a.offset, sizeof(c_int))

Lib/test/test_ctypes/test_bitfields.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import sys
3+
from typing import Annotated
34
import unittest
45
from ctypes import (CDLL, Structure, sizeof, POINTER, byref, alignment,
56
LittleEndianStructure, BigEndianStructure,
@@ -8,8 +9,9 @@
89
c_short, c_ushort, c_int, c_uint, c_long, c_ulong,
910
c_longlong, c_ulonglong,
1011
Union)
12+
from ctypes.util import struct, CFieldInfo
1113
from test import support
12-
from test.support import import_helper
14+
from test.support import import_helper, subTests
1315
from ._support import StructCheckMixin
1416
_ctypes_test = import_helper.import_module("_ctypes_test")
1517

@@ -126,11 +128,19 @@ def test_generic_checks(self):
126128
self.check_struct(BITS_msvc)
127129
self.check_struct(BITS_gcc)
128130

129-
def test_longlong(self):
130-
class X(Structure):
131-
_fields_ = [("a", c_longlong, 1),
132-
("b", c_longlong, 62),
133-
("c", c_longlong, 1)]
131+
@subTests("use_struct_util", [False, True])
132+
def test_longlong(self, use_struct_util):
133+
if use_struct_util:
134+
@struct
135+
class X:
136+
a: Annotated[c_longlong, CFieldInfo(bit_width=1)]
137+
b: Annotated[c_longlong, CFieldInfo(bit_width=62)]
138+
c: Annotated[c_longlong, CFieldInfo(bit_width=1)]
139+
else:
140+
class X(Structure):
141+
_fields_ = [("a", c_longlong, 1),
142+
("b", c_longlong, 62),
143+
("c", c_longlong, 1)]
134144
self.check_struct(X)
135145

136146
self.assertEqual(sizeof(X), sizeof(c_longlong))

0 commit comments

Comments
 (0)