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.
- {firstclass-0.1.0 → firstclass-0.2.0}/PKG-INFO +54 -1
- {firstclass-0.1.0 → firstclass-0.2.0}/README.md +53 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/firstclass/__init__.py +6 -2
- {firstclass-0.1.0 → firstclass-0.2.0}/firstclass/_core.py +50 -4
- {firstclass-0.1.0 → firstclass-0.2.0}/firstclass/_header.py +55 -32
- firstclass-0.2.0/firstclass/_rust.py +471 -0
- firstclass-0.2.0/firstclass/_rustgen.py +96 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/firstclass/_specs.py +108 -13
- {firstclass-0.1.0 → firstclass-0.2.0}/firstclass.egg-info/PKG-INFO +54 -1
- {firstclass-0.1.0 → firstclass-0.2.0}/firstclass.egg-info/SOURCES.txt +13 -1
- {firstclass-0.1.0 → firstclass-0.2.0}/pyproject.toml +1 -1
- firstclass-0.2.0/tests/test_compilers.py +353 -0
- firstclass-0.2.0/tests/test_docs.py +113 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_edge_cases.py +19 -2
- {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_enum_bitfield.py +29 -2
- {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_firstclass.py +3 -4
- firstclass-0.2.0/tests/test_fuzz.py +201 -0
- firstclass-0.2.0/tests/test_golden.py +187 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_header.py +10 -0
- firstclass-0.2.0/tests/test_parser_fuzz.py +221 -0
- firstclass-0.2.0/tests/test_real_world.py +200 -0
- firstclass-0.2.0/tests/test_rust.py +527 -0
- firstclass-0.2.0/tests/test_scale.py +100 -0
- firstclass-0.2.0/tests/test_validation.py +389 -0
- firstclass-0.2.0/tests/test_wchar.py +220 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/firstclass.egg-info/dependency_links.txt +0 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/firstclass.egg-info/top_level.txt +0 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/setup.cfg +0 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_compare.py +0 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_ctypes_types.py +0 -0
- {firstclass-0.1.0 → firstclass-0.2.0}/tests/test_example.py +0 -0
- {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.
|
|
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].
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1020
|
-
|
|
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)
|