firstclass 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {firstclass-0.1.0 → firstclass-0.2.0}/PKG-INFO +54 -1
  2. {firstclass-0.1.0 → firstclass-0.2.0}/README.md +53 -0
  3. {firstclass-0.1.0 → firstclass-0.2.0}/firstclass/__init__.py +6 -2
  4. {firstclass-0.1.0 → firstclass-0.2.0}/firstclass/_core.py +50 -4
  5. {firstclass-0.1.0 → firstclass-0.2.0}/firstclass/_header.py +55 -32
  6. firstclass-0.2.0/firstclass/_rust.py +471 -0
  7. firstclass-0.2.0/firstclass/_rustgen.py +96 -0
  8. {firstclass-0.1.0 → firstclass-0.2.0}/firstclass/_specs.py +108 -13
  9. {firstclass-0.1.0 → firstclass-0.2.0}/firstclass.egg-info/PKG-INFO +54 -1
  10. {firstclass-0.1.0 → firstclass-0.2.0}/firstclass.egg-info/SOURCES.txt +13 -1
  11. {firstclass-0.1.0 → firstclass-0.2.0}/pyproject.toml +1 -1
  12. firstclass-0.2.0/tests/test_compilers.py +353 -0
  13. firstclass-0.2.0/tests/test_docs.py +113 -0
  14. {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_edge_cases.py +19 -2
  15. {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_enum_bitfield.py +29 -2
  16. {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_firstclass.py +3 -4
  17. firstclass-0.2.0/tests/test_fuzz.py +201 -0
  18. firstclass-0.2.0/tests/test_golden.py +187 -0
  19. {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_header.py +10 -0
  20. firstclass-0.2.0/tests/test_parser_fuzz.py +221 -0
  21. firstclass-0.2.0/tests/test_real_world.py +200 -0
  22. firstclass-0.2.0/tests/test_rust.py +527 -0
  23. firstclass-0.2.0/tests/test_scale.py +100 -0
  24. firstclass-0.2.0/tests/test_validation.py +389 -0
  25. firstclass-0.2.0/tests/test_wchar.py +220 -0
  26. {firstclass-0.1.0 → firstclass-0.2.0}/firstclass.egg-info/dependency_links.txt +0 -0
  27. {firstclass-0.1.0 → firstclass-0.2.0}/firstclass.egg-info/top_level.txt +0 -0
  28. {firstclass-0.1.0 → firstclass-0.2.0}/setup.cfg +0 -0
  29. {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_compare.py +0 -0
  30. {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_ctypes_types.py +0 -0
  31. {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_example.py +0 -0
  32. {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_header_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firstclass
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: dataclass with bytes, ctypes, C struct, JSON and XML serialization
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -45,6 +45,7 @@ Packet.sizeof() # 36
45
45
  | `bool_t` | `bool` | 1 |
46
46
  | `char_t` | `char` | 1 |
47
47
  | `char_array[N]` | `char x[N]` | N (UTF-8, NUL-padded) |
48
+ | `wchar_t` / `wchar_array[N]` | `uint16_t x[N]` | 2 (2*N), UTF-16, NUL-padded |
48
49
  | `bytes_t[N]` | `uint8_t x[N]` | N |
49
50
  | `array_t[T, N]` | `T x[N]` | N * size(T) |
50
51
  | `enum_t[Color, uint8_t]` | `uint8_t x` (+ `enum Color {..}` in header) | storage size |
@@ -84,6 +85,7 @@ address only means something inside the same process. Sizes follow the platform
84
85
  | `obj.to_xml(indent=None)` / `Cls.from_xml(s)` | `<Packet><kind>1</kind>...</Packet>`, arrays as `<item>` |
85
86
  | `obj.to_ctypes()` / `Cls.from_ctypes(c)` / `Cls.ctypes_type()` | `ctypes.Structure` with matching layout |
86
87
  | `Cls.c_header()` | C source for the struct(s), nested ones first |
88
+ | `Cls.rust_source()` | Rust `#[repr(C)]` source for the struct(s) and enums |
87
89
 
88
90
  ## Import C/C++ headers
89
91
 
@@ -112,6 +114,24 @@ bitfields, inheritance, virtual members, unknown types (`#include`d headers are
112
114
  Plain C enums are 32-bit like C unless `: type` is given. C field names that are Python keywords
113
115
  (`class`, `from`, ...) get a trailing underscore (`class_`).
114
116
 
117
+ ## Validation
118
+
119
+ A value that cannot be encoded never becomes an instance. Fields are checked when the object is
120
+ created and on every attribute assignment, so the mistake surfaces where it was made, not later in
121
+ `to_bytes()`:
122
+
123
+ ```python
124
+ Header(kind=300, tag="x") # ValueError: Header.kind: 300 out of range for uint8_t [0, 255]
125
+ Header(kind=1, tag="ab") # ValueError: Header.tag: 'ab' is 2 bytes, char_array[1] holds 1
126
+ h.name = "far too long" # ValueError, and h.name keeps its old value
127
+ ```
128
+
129
+ Wrong type -> `TypeError`, wrong value or size -> `ValueError`. Sequences and bytes-likes are
130
+ normalised (`(1, 2)` becomes `[1, 2]`, `bytearray` becomes `bytes`) and copied. The one gap is
131
+ mutating a container in place (`obj.nums[0] = 300`), which is not an assignment to the field;
132
+ `to_bytes()` still refuses it. Applies to hand-written classes and to classes made by
133
+ `from_header` / `from_rust`.
134
+
115
135
  ## Comparison
116
136
 
117
137
  Like a dataclass, `==` / `!=` compare field by field. Unlike a plain dataclass, ordering is on by
@@ -119,6 +139,35 @@ default: `<`, `<=`, `>`, `>=` compare fields in declaration order, like tuples,
119
139
  `min()` and `max()` instances. Comparing with another class raises `TypeError` (`==` is just `False`).
120
140
  Switch it off with `@firstclass(order=False)`; `eq=False` turns off both.
121
141
 
142
+ ## Rust
143
+
144
+ ```python
145
+ from firstclass import from_rust
146
+
147
+ t = from_rust("src/protocol.rs") # or from_rust(text="...")
148
+ t.Header, t.Status, t.constants, t.skipped # same result object as from_header
149
+ print(t.Header.rust_source()) # and back: any firstclass class -> Rust definitions
150
+ ```
151
+
152
+ `from_rust` reads simple `#[repr(C)]` structs (C layout with padding) and `#[repr(C, packed)]`
153
+ structs (no padding), plus C-like enums with `#[repr(u8)]`, `#[repr(i32)]` or `#[repr(C)]` and
154
+ plain discriminants (constant expressions, `as` casts, `const`s allowed). Types: `u8`..`u64`,
155
+ `i8`..`i64`, `usize`, `isize`, `f32`, `f64`, `bool`, `c_int` and the other `c_*` names, `[T; N]`
156
+ (`N` may be a `const` expression), nested structs, type aliases, tuple structs, raw pointers,
157
+ references, `Box`, `Option<&T>` and `fn` pointers (stored as addresses). `[u8; N]` becomes
158
+ `bytes_t[N]`, `[c_char; N]` becomes `char_array[N]`, `wchar_t` the 2-byte UTF-16 types. Modules are
159
+ flattened and `#[cfg(test)]` items are ignored.
160
+
161
+ Skipped with a reason in `.skipped`: structs without `#[repr(C)]` (Rust may reorder their fields;
162
+ pass `assume_repr_c=True` to read them anyway), `repr(transparent)`, `repr(align(N))`,
163
+ `packed(N)` for N > 1, generics, unions, `char`, `u128`, `String`/`Vec`/slices/tuples, enums with
164
+ data. Options: `byteorder`, `align`, `assume_repr_c`, `long_size`.
165
+
166
+ `Cls.rust_source()` writes `#[repr(C)]` / `#[repr(C, packed)]` structs, `#[repr(u8)]` enums,
167
+ `[u8; N]` for strings and bytes, `[u16; N]` for wide strings, `*mut c_void` for pointers and
168
+ `r#` for keyword names. Rust has no bitfields, so each group of bitfields becomes one integer
169
+ field with the member names in a comment.
170
+
122
171
  ## Layout options
123
172
 
124
173
  ```python
@@ -151,3 +200,7 @@ bitfields, `#pragma pack` structs) and uses the generated classes.
151
200
  ```
152
201
  python -m unittest discover -s tests
153
202
  ```
203
+
204
+ The suite (about 450 tests, 40 s) includes random-class fuzzing of every conversion path, parser
205
+ fuzzing, byte-for-byte checks against real packets, and layout checks against the system C compiler
206
+ and `rustc` (skipped when they are not installed). CI runs it on Ubuntu for Python 3.10 to 3.14.
@@ -38,6 +38,7 @@ Packet.sizeof() # 36
38
38
  | `bool_t` | `bool` | 1 |
39
39
  | `char_t` | `char` | 1 |
40
40
  | `char_array[N]` | `char x[N]` | N (UTF-8, NUL-padded) |
41
+ | `wchar_t` / `wchar_array[N]` | `uint16_t x[N]` | 2 (2*N), UTF-16, NUL-padded |
41
42
  | `bytes_t[N]` | `uint8_t x[N]` | N |
42
43
  | `array_t[T, N]` | `T x[N]` | N * size(T) |
43
44
  | `enum_t[Color, uint8_t]` | `uint8_t x` (+ `enum Color {..}` in header) | storage size |
@@ -77,6 +78,7 @@ address only means something inside the same process. Sizes follow the platform
77
78
  | `obj.to_xml(indent=None)` / `Cls.from_xml(s)` | `<Packet><kind>1</kind>...</Packet>`, arrays as `<item>` |
78
79
  | `obj.to_ctypes()` / `Cls.from_ctypes(c)` / `Cls.ctypes_type()` | `ctypes.Structure` with matching layout |
79
80
  | `Cls.c_header()` | C source for the struct(s), nested ones first |
81
+ | `Cls.rust_source()` | Rust `#[repr(C)]` source for the struct(s) and enums |
80
82
 
81
83
  ## Import C/C++ headers
82
84
 
@@ -105,6 +107,24 @@ bitfields, inheritance, virtual members, unknown types (`#include`d headers are
105
107
  Plain C enums are 32-bit like C unless `: type` is given. C field names that are Python keywords
106
108
  (`class`, `from`, ...) get a trailing underscore (`class_`).
107
109
 
110
+ ## Validation
111
+
112
+ A value that cannot be encoded never becomes an instance. Fields are checked when the object is
113
+ created and on every attribute assignment, so the mistake surfaces where it was made, not later in
114
+ `to_bytes()`:
115
+
116
+ ```python
117
+ Header(kind=300, tag="x") # ValueError: Header.kind: 300 out of range for uint8_t [0, 255]
118
+ Header(kind=1, tag="ab") # ValueError: Header.tag: 'ab' is 2 bytes, char_array[1] holds 1
119
+ h.name = "far too long" # ValueError, and h.name keeps its old value
120
+ ```
121
+
122
+ Wrong type -> `TypeError`, wrong value or size -> `ValueError`. Sequences and bytes-likes are
123
+ normalised (`(1, 2)` becomes `[1, 2]`, `bytearray` becomes `bytes`) and copied. The one gap is
124
+ mutating a container in place (`obj.nums[0] = 300`), which is not an assignment to the field;
125
+ `to_bytes()` still refuses it. Applies to hand-written classes and to classes made by
126
+ `from_header` / `from_rust`.
127
+
108
128
  ## Comparison
109
129
 
110
130
  Like a dataclass, `==` / `!=` compare field by field. Unlike a plain dataclass, ordering is on by
@@ -112,6 +132,35 @@ default: `<`, `<=`, `>`, `>=` compare fields in declaration order, like tuples,
112
132
  `min()` and `max()` instances. Comparing with another class raises `TypeError` (`==` is just `False`).
113
133
  Switch it off with `@firstclass(order=False)`; `eq=False` turns off both.
114
134
 
135
+ ## Rust
136
+
137
+ ```python
138
+ from firstclass import from_rust
139
+
140
+ t = from_rust("src/protocol.rs") # or from_rust(text="...")
141
+ t.Header, t.Status, t.constants, t.skipped # same result object as from_header
142
+ print(t.Header.rust_source()) # and back: any firstclass class -> Rust definitions
143
+ ```
144
+
145
+ `from_rust` reads simple `#[repr(C)]` structs (C layout with padding) and `#[repr(C, packed)]`
146
+ structs (no padding), plus C-like enums with `#[repr(u8)]`, `#[repr(i32)]` or `#[repr(C)]` and
147
+ plain discriminants (constant expressions, `as` casts, `const`s allowed). Types: `u8`..`u64`,
148
+ `i8`..`i64`, `usize`, `isize`, `f32`, `f64`, `bool`, `c_int` and the other `c_*` names, `[T; N]`
149
+ (`N` may be a `const` expression), nested structs, type aliases, tuple structs, raw pointers,
150
+ references, `Box`, `Option<&T>` and `fn` pointers (stored as addresses). `[u8; N]` becomes
151
+ `bytes_t[N]`, `[c_char; N]` becomes `char_array[N]`, `wchar_t` the 2-byte UTF-16 types. Modules are
152
+ flattened and `#[cfg(test)]` items are ignored.
153
+
154
+ Skipped with a reason in `.skipped`: structs without `#[repr(C)]` (Rust may reorder their fields;
155
+ pass `assume_repr_c=True` to read them anyway), `repr(transparent)`, `repr(align(N))`,
156
+ `packed(N)` for N > 1, generics, unions, `char`, `u128`, `String`/`Vec`/slices/tuples, enums with
157
+ data. Options: `byteorder`, `align`, `assume_repr_c`, `long_size`.
158
+
159
+ `Cls.rust_source()` writes `#[repr(C)]` / `#[repr(C, packed)]` structs, `#[repr(u8)]` enums,
160
+ `[u8; N]` for strings and bytes, `[u16; N]` for wide strings, `*mut c_void` for pointers and
161
+ `r#` for keyword names. Rust has no bitfields, so each group of bitfields becomes one integer
162
+ field with the member names in a comment.
163
+
115
164
  ## Layout options
116
165
 
117
166
  ```python
@@ -144,3 +193,7 @@ bitfields, `#pragma pack` structs) and uses the generated classes.
144
193
  ```
145
194
  python -m unittest discover -s tests
146
195
  ```
196
+
197
+ The suite (about 450 tests, 40 s) includes random-class fuzzing of every conversion path, parser
198
+ fuzzing, byte-for-byte checks against real packets, and layout checks against the system C compiler
199
+ and `rustc` (skipped when they are not installed). CI runs it on Ubuntu for Python 3.10 to 3.14.
@@ -1,5 +1,6 @@
1
1
  from ._core import firstclass
2
2
  from ._header import from_header
3
+ from ._rust import from_rust
3
4
  from ._specs import (
4
5
  array_t,
5
6
  bitfield,
@@ -18,14 +19,17 @@ from ._specs import (
18
19
  uint16_t,
19
20
  uint32_t,
20
21
  uint64_t,
22
+ wchar_array,
23
+ wchar_t,
21
24
  )
22
25
 
23
26
  __all__ = [
24
- "firstclass", "from_header",
27
+ "firstclass", "from_header", "from_rust",
25
28
  "uint8_t", "uint16_t", "uint32_t", "uint64_t",
26
29
  "int8_t", "int16_t", "int32_t", "int64_t",
27
30
  "float32_t", "float64_t", "bool_t", "char_t",
28
- "char_array", "bytes_t", "array_t", "bitfield", "enum_t",
31
+ "char_array", "wchar_t", "wchar_array", "bytes_t", "array_t", "bitfield", "enum_t",
29
32
  ]
30
33
 
31
34
  firstclass.from_header = from_header # also reachable as the decorator's attribute
35
+ firstclass.from_rust = from_rust
@@ -8,11 +8,12 @@ import sys
8
8
  import typing
9
9
  import xml.etree.ElementTree as ET
10
10
 
11
+ from ._rustgen import rust_source
11
12
  from ._specs import ArrayOf, BitField, EnumSpec, Nested, Spec, resolve_spec
12
13
 
13
14
  _METHODS = (
14
15
  "to_bytes", "from_bytes", "unpack_from", "to_dict", "from_dict", "to_json", "from_json",
15
- "to_xml", "from_xml", "to_ctypes", "from_ctypes", "ctypes_type", "c_header", "sizeof",
16
+ "to_xml", "from_xml", "to_ctypes", "from_ctypes", "ctypes_type", "c_header", "rust_source", "sizeof",
16
17
  )
17
18
 
18
19
 
@@ -31,6 +32,7 @@ class Schema:
31
32
  self._fields: list[tuple[dataclasses.Field, Spec]] | None = None
32
33
  self._layout = None
33
34
  self._ctype = None
35
+ self._spec_map = None
34
36
 
35
37
  # -- structure -----------------------------------------------------------
36
38
 
@@ -61,7 +63,7 @@ class Schema:
61
63
  for i, (_, spec) in enumerate(self.fields):
62
64
  if isinstance(spec, BitField):
63
65
  st, unit = spec.storage, spec.storage.size() * 8
64
- if group is None or group[0].name != st.name or group[1] + spec.bits > unit:
66
+ if group is None or group[0].size() != st.size() or group[1] + spec.bits > unit:
65
67
  a = st.align() if self.aligned else 1
66
68
  off = _round_up(off, a)
67
69
  widest = max(widest, a)
@@ -99,6 +101,31 @@ class Schema:
99
101
  def _by_name(self):
100
102
  return {f.name: f for f, _ in self.fields} # small; fields are cached
101
103
 
104
+ @property
105
+ def spec_map(self):
106
+ if self._spec_map is None:
107
+ self._spec_map = {f.name: (f, spec) for f, spec in self.fields}
108
+ return self._spec_map
109
+
110
+ def validate_instance(self, obj):
111
+ """Check (and normalise) every field of a freshly built instance."""
112
+ for f, spec in self.fields:
113
+ if hasattr(obj, f.name):
114
+ try:
115
+ value = spec.check(getattr(obj, f.name))
116
+ except (TypeError, ValueError) as e:
117
+ raise self._ctx(f, e) from e
118
+ object.__setattr__(obj, f.name, value)
119
+
120
+ def checked_setattr(self, obj, name, value):
121
+ entry = self.spec_map.get(name)
122
+ if entry is not None:
123
+ try:
124
+ value = entry[1].check(value)
125
+ except (TypeError, ValueError) as e:
126
+ raise self._ctx(entry[0], e) from e
127
+ object.__setattr__(obj, name, value)
128
+
102
129
  def _ctx(self, f, exc):
103
130
  kind = TypeError if isinstance(exc, TypeError) else ValueError # e.g. UnicodeDecodeError -> ValueError
104
131
  return kind(f"{self.cls.__name__}.{f.name}: {exc}")
@@ -231,7 +258,7 @@ class Schema:
231
258
  c = self.ctypes_type()()
232
259
  for f, spec in self.fields:
233
260
  try:
234
- setattr(c, f.name, spec.to_c(getattr(obj, f.name)))
261
+ setattr(c, f.name, spec.to_c(getattr(obj, f.name), self.bo))
235
262
  except (TypeError, ValueError) as e:
236
263
  raise self._ctx(f, e) from e
237
264
  return c
@@ -280,7 +307,9 @@ def firstclass(cls=None, /, *, byteorder: str = "little", align: bool = False, *
280
307
  """Like `@dataclass` (same keyword args), plus binary/JSON/XML/ctypes/C codecs.
281
308
 
282
309
  Unlike a plain dataclass, `order=True` is the default (compare field by field, like tuples);
283
- pass `order=False` to turn it off.
310
+ pass `order=False` to turn it off. Values are validated when an instance is created and on
311
+ every attribute assignment: a field that cannot be encoded (out of range, too long, wrong type)
312
+ raises `ValueError` / `TypeError` immediately, never later in `to_bytes()`.
284
313
 
285
314
  byteorder: 'little' (default) or 'big'.
286
315
  align: False (default) = packed layout, no padding; True = natural C alignment padding.
@@ -291,12 +320,28 @@ def firstclass(cls=None, /, *, byteorder: str = "little", align: bool = False, *
291
320
  raise TypeError(f"@firstclass needs a class, got {type(cls).__name__}")
292
321
  kwargs = dict(dataclass_kwargs)
293
322
  kwargs.setdefault("order", kwargs.get("eq", True)) # ==, <, <=, >, >= by field order
323
+ holder = []
324
+ frozen = bool(kwargs.get("frozen"))
325
+ if frozen: # frozen __init__ bypasses __setattr__, so validate once the instance is built
326
+ user_post = getattr(cls, "__post_init__", None)
327
+ if getattr(user_post, "_firstclass", False):
328
+ user_post = None # inherited from a firstclass parent: already covered
329
+
330
+ def __post_init__(self, *args):
331
+ holder[0].validate_instance(self)
332
+ if user_post is not None:
333
+ user_post(self, *args)
334
+ __post_init__._firstclass = True
335
+ cls.__post_init__ = __post_init__
294
336
  dc = dataclasses.dataclass(cls, **kwargs)
295
337
  clash = [f.name for f in dataclasses.fields(dc) if f.name in _METHODS or f.name == "__firstclass__"]
296
338
  if clash:
297
339
  raise TypeError(f"{dc.__name__}: field names clash with firstclass methods: {clash}")
298
340
  s = Schema(dc, byteorder, align)
299
341
  dc.__firstclass__ = s
342
+ holder.append(s)
343
+ if not frozen: # every assignment (including those in __init__) is checked
344
+ dc.__setattr__ = lambda self, name, value: s.checked_setattr(self, name, value)
300
345
  dc.to_bytes = lambda self: s.pack(self)
301
346
  dc.from_bytes = staticmethod(s.from_bytes)
302
347
  dc.unpack_from = staticmethod(s.unpack_from)
@@ -310,6 +355,7 @@ def firstclass(cls=None, /, *, byteorder: str = "little", align: bool = False, *
310
355
  dc.from_ctypes = staticmethod(s.from_ctypes)
311
356
  dc.ctypes_type = staticmethod(s.ctypes_type)
312
357
  dc.c_header = staticmethod(s.c_header)
358
+ dc.rust_source = staticmethod(lambda: rust_source(s))
313
359
  dc.sizeof = staticmethod(s.size)
314
360
  return dc
315
361
 
@@ -73,7 +73,10 @@ def _int_value(text: str) -> int:
73
73
 
74
74
  def _match(toks: list[Tok], i: int) -> int:
75
75
  """Index of the bracket closing toks[i] ('(' '[' '{'), or the last index if unbalanced."""
76
- open_, close = {"(": ("(", ")"), "[": ("[", "]"), "{": ("{", "}")}[toks[i].text]
76
+ pair = {"(": ("(", ")"), "[": ("[", "]"), "{": ("{", "}")}.get(toks[i].text)
77
+ if pair is None: # not an opening bracket: nothing to match
78
+ return i
79
+ open_, close = pair
77
80
  depth = 0
78
81
  for j in range(i, len(toks)):
79
82
  if toks[j].kind == "op":
@@ -99,7 +102,10 @@ class _Expr:
99
102
  def value(self) -> int:
100
103
  if not self.t:
101
104
  raise _ExprError("empty expression")
102
- v = self.ternary()
105
+ try:
106
+ v = self.ternary()
107
+ except RecursionError:
108
+ raise _ExprError("expression nested too deeply") from None
103
109
  if self.i != len(self.t):
104
110
  raise _ExprError(f"unexpected token {self.t[self.i].text!r}")
105
111
  return v
@@ -286,6 +292,7 @@ class _Ctx:
286
292
  self.typedefs: dict[str, TypedefDecl] = {}
287
293
  self.constants: dict[str, int] = {}
288
294
  self.pack = None
295
+ self.parse_error = None
289
296
  self.long_size = long_size
290
297
  self._anon = 0
291
298
 
@@ -570,7 +577,7 @@ class _Parser:
570
577
  continue
571
578
  break
572
579
  self.i = self.statement_end(self.i)
573
- if words and words[0] in ("struct", "enum", "union") and len(words) == 2 and words[1].startswith("_anon"):
580
+ if words and words[0] in ("struct", "enum", "union") and len(words) == 2 and (words[1] or "").startswith("_anon"):
574
581
  plain = next((d for d in decls if d[0] and not d[1] and not d[2]), None)
575
582
  if plain:
576
583
  self.ctx.rename(words[1], plain[0])
@@ -752,7 +759,7 @@ class _Parser:
752
759
  def parse_field_stmt(self, decl):
753
760
  words = self.parse_specifiers()
754
761
  inline_anon = bool(words) and words[0] in ("struct", "union", "enum") and len(words) == 2 \
755
- and words[1].startswith("_anon")
762
+ and (words[1] or "").startswith("_anon")
756
763
  if not words:
757
764
  decl.reason = decl.reason or "unsupported member declaration"
758
765
  return
@@ -891,6 +898,8 @@ class _Builder:
891
898
  sc = _named_scalar(w)
892
899
  if sc:
893
900
  return sc, False, []
901
+ if w in ("wchar_t", "char16_t"): # always the 2-byte UTF-16 unit
902
+ return ("wchar",), False, []
894
903
  if words[0] in ("struct", "union", "enum") and len(words) == 2:
895
904
  if words[0] == "union":
896
905
  raise _Skip("union unsupported")
@@ -982,6 +991,8 @@ class _Builder:
982
991
  return S.bool_t
983
992
  if kind == "char":
984
993
  return S.char_t
994
+ if kind == "wchar":
995
+ return S.wchar_t
985
996
  if kind == "struct":
986
997
  return self.struct(base[1])
987
998
  if kind == "enum":
@@ -1007,6 +1018,8 @@ class _Builder:
1007
1018
  last = dims.pop()
1008
1019
  if not ptr and base == ("char",):
1009
1020
  elem = S.char_array[last]
1021
+ elif not ptr and base == ("wchar",):
1022
+ elem = S.wchar_array[last]
1010
1023
  elif not ptr and base == ("int", 8, False):
1011
1024
  elem = S.bytes_t[last]
1012
1025
  else:
@@ -1016,37 +1029,13 @@ class _Builder:
1016
1029
  return elem
1017
1030
 
1018
1031
 
1019
- def from_header(path=None, *, text=None, byteorder: str = "little", align: bool | None = None,
1020
- defines: dict | None = None, long_size: int | None = None) -> HeaderTypes:
1021
- """Parse a C/C++ header (`path`, or source `text=`) into firstclass classes.
1022
-
1023
- structs/classes become `@firstclass` dataclasses, enums/enum classes become `IntEnum`s.
1024
- Layout follows the C rules: natural alignment padding, unless the struct is
1025
- `#pragma pack(1)` / `__attribute__((packed))`. Override with `align=True/False`.
1026
- `defines` presets macros; `long_size` is the size of C `long` (default: this machine's).
1027
- Things that cannot be represented are listed in the result's `.skipped`.
1028
- """
1029
- if (path is None) == (text is None):
1030
- raise TypeError("pass exactly one of path or text=")
1031
- if path is not None:
1032
- text = Path(os.fspath(path)).read_text(encoding="utf-8", errors="replace")
1033
- if byteorder not in ("little", "big"):
1034
- raise ValueError(f"byteorder must be 'little' or 'big', got {byteorder!r}")
1035
- long_size = long_size or ctypes.sizeof(ctypes.c_long)
1036
- ctx = _Ctx(long_size)
1037
- pre = _Preprocessor(defines)
1038
- toks = _fold_attributes(pre.run(text, ctx))
1039
- _Parser(toks, ctx).parse_all()
1040
- for name, body in pre.macros.items(): # integer #defines are constants too
1041
- if name not in ctx.constants:
1042
- try:
1043
- ctx.constants[name] = ctx.eval(pre.expand(body, (name,)))
1044
- except (_ExprError, ValueError, KeyError):
1045
- pass
1046
-
1032
+ def _assemble(ctx, byteorder, align) -> HeaderTypes:
1033
+ """Build classes for everything the parser collected."""
1047
1034
  builder = _Builder(ctx, byteorder, align)
1048
1035
  result = HeaderTypes()
1049
1036
  result.constants = dict(ctx.constants)
1037
+ if ctx.parse_error:
1038
+ result.skipped["<input>"] = ctx.parse_error
1050
1039
  for name in list(ctx.structs):
1051
1040
  if name.startswith("_anon"):
1052
1041
  continue
@@ -1076,3 +1065,37 @@ def from_header(path=None, *, text=None, byteorder: str = "little", align: bool
1076
1065
  elif base[1] in result.skipped:
1077
1066
  result.skipped[name] = result.skipped[base[1]]
1078
1067
  return result
1068
+
1069
+
1070
+ def from_header(path=None, *, text=None, byteorder: str = "little", align: bool | None = None,
1071
+ defines: dict | None = None, long_size: int | None = None) -> HeaderTypes:
1072
+ """Parse a C/C++ header (`path`, or source `text=`) into firstclass classes.
1073
+
1074
+ structs/classes become `@firstclass` dataclasses, enums/enum classes become `IntEnum`s.
1075
+ Layout follows the C rules: natural alignment padding, unless the struct is
1076
+ `#pragma pack(1)` / `__attribute__((packed))`. Override with `align=True/False`.
1077
+ `defines` presets macros; `long_size` is the size of C `long` (default: this machine's).
1078
+ Things that cannot be represented are listed in the result's `.skipped`.
1079
+ """
1080
+ if (path is None) == (text is None):
1081
+ raise TypeError("pass exactly one of path or text=")
1082
+ if path is not None:
1083
+ text = Path(os.fspath(path)).read_text(encoding="utf-8", errors="replace")
1084
+ if byteorder not in ("little", "big"):
1085
+ raise ValueError(f"byteorder must be 'little' or 'big', got {byteorder!r}")
1086
+ long_size = long_size or ctypes.sizeof(ctypes.c_long)
1087
+ ctx = _Ctx(long_size)
1088
+ pre = _Preprocessor(defines)
1089
+ toks = _fold_attributes(pre.run(text, ctx))
1090
+ try:
1091
+ _Parser(toks, ctx).parse_all()
1092
+ except RecursionError: # absurdly nested input: keep what was parsed before it
1093
+ ctx.parse_error = "input nested too deeply to parse completely"
1094
+ for name, body in pre.macros.items(): # integer #defines are constants too
1095
+ if name not in ctx.constants:
1096
+ try:
1097
+ ctx.constants[name] = ctx.eval(pre.expand(body, (name,)))
1098
+ except (_ExprError, ValueError, KeyError):
1099
+ pass
1100
+
1101
+ return _assemble(ctx, byteorder, align)