firstclass 0.0.3__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.0.3 → firstclass-0.2.0}/PKG-INFO +61 -1
- {firstclass-0.0.3 → firstclass-0.2.0}/README.md +60 -0
- {firstclass-0.0.3 → firstclass-0.2.0}/firstclass/__init__.py +6 -2
- {firstclass-0.0.3 → firstclass-0.2.0}/firstclass/_core.py +55 -4
- {firstclass-0.0.3 → 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.0.3 → firstclass-0.2.0}/firstclass/_specs.py +108 -13
- {firstclass-0.0.3 → firstclass-0.2.0}/firstclass.egg-info/PKG-INFO +61 -1
- {firstclass-0.0.3 → firstclass-0.2.0}/firstclass.egg-info/SOURCES.txt +14 -1
- {firstclass-0.0.3 → firstclass-0.2.0}/pyproject.toml +1 -1
- firstclass-0.2.0/tests/test_compare.py +221 -0
- firstclass-0.2.0/tests/test_compilers.py +353 -0
- firstclass-0.2.0/tests/test_docs.py +113 -0
- {firstclass-0.0.3 → firstclass-0.2.0}/tests/test_edge_cases.py +20 -3
- {firstclass-0.0.3 → firstclass-0.2.0}/tests/test_enum_bitfield.py +29 -2
- {firstclass-0.0.3 → 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.0.3 → 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.0.3 → firstclass-0.2.0}/firstclass.egg-info/dependency_links.txt +0 -0
- {firstclass-0.0.3 → firstclass-0.2.0}/firstclass.egg-info/top_level.txt +0 -0
- {firstclass-0.0.3 → firstclass-0.2.0}/setup.cfg +0 -0
- {firstclass-0.0.3 → firstclass-0.2.0}/tests/test_ctypes_types.py +0 -0
- {firstclass-0.0.3 → firstclass-0.2.0}/tests/test_example.py +0 -0
- {firstclass-0.0.3 → 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.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,60 @@ 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
|
+
|
|
135
|
+
## Comparison
|
|
136
|
+
|
|
137
|
+
Like a dataclass, `==` / `!=` compare field by field. Unlike a plain dataclass, ordering is on by
|
|
138
|
+
default: `<`, `<=`, `>`, `>=` compare fields in declaration order, like tuples, so you can `sorted()`,
|
|
139
|
+
`min()` and `max()` instances. Comparing with another class raises `TypeError` (`==` is just `False`).
|
|
140
|
+
Switch it off with `@firstclass(order=False)`; `eq=False` turns off both.
|
|
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
|
+
|
|
115
171
|
## Layout options
|
|
116
172
|
|
|
117
173
|
```python
|
|
@@ -144,3 +200,7 @@ bitfields, `#pragma pack` structs) and uses the generated classes.
|
|
|
144
200
|
```
|
|
145
201
|
python -m unittest discover -s tests
|
|
146
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,60 @@ 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
|
+
|
|
128
|
+
## Comparison
|
|
129
|
+
|
|
130
|
+
Like a dataclass, `==` / `!=` compare field by field. Unlike a plain dataclass, ordering is on by
|
|
131
|
+
default: `<`, `<=`, `>`, `>=` compare fields in declaration order, like tuples, so you can `sorted()`,
|
|
132
|
+
`min()` and `max()` instances. Comparing with another class raises `TypeError` (`==` is just `False`).
|
|
133
|
+
Switch it off with `@firstclass(order=False)`; `eq=False` turns off both.
|
|
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
|
+
|
|
108
164
|
## Layout options
|
|
109
165
|
|
|
110
166
|
```python
|
|
@@ -137,3 +193,7 @@ bitfields, `#pragma pack` structs) and uses the generated classes.
|
|
|
137
193
|
```
|
|
138
194
|
python -m unittest discover -s tests
|
|
139
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
|
|
@@ -279,6 +306,11 @@ class Schema:
|
|
|
279
306
|
def firstclass(cls=None, /, *, byteorder: str = "little", align: bool = False, **dataclass_kwargs):
|
|
280
307
|
"""Like `@dataclass` (same keyword args), plus binary/JSON/XML/ctypes/C codecs.
|
|
281
308
|
|
|
309
|
+
Unlike a plain dataclass, `order=True` is the default (compare field by field, like tuples);
|
|
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()`.
|
|
313
|
+
|
|
282
314
|
byteorder: 'little' (default) or 'big'.
|
|
283
315
|
align: False (default) = packed layout, no padding; True = natural C alignment padding.
|
|
284
316
|
"""
|
|
@@ -286,12 +318,30 @@ def firstclass(cls=None, /, *, byteorder: str = "little", align: bool = False, *
|
|
|
286
318
|
def wrap(cls):
|
|
287
319
|
if not isinstance(cls, type):
|
|
288
320
|
raise TypeError(f"@firstclass needs a class, got {type(cls).__name__}")
|
|
289
|
-
|
|
321
|
+
kwargs = dict(dataclass_kwargs)
|
|
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__
|
|
336
|
+
dc = dataclasses.dataclass(cls, **kwargs)
|
|
290
337
|
clash = [f.name for f in dataclasses.fields(dc) if f.name in _METHODS or f.name == "__firstclass__"]
|
|
291
338
|
if clash:
|
|
292
339
|
raise TypeError(f"{dc.__name__}: field names clash with firstclass methods: {clash}")
|
|
293
340
|
s = Schema(dc, byteorder, align)
|
|
294
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)
|
|
295
345
|
dc.to_bytes = lambda self: s.pack(self)
|
|
296
346
|
dc.from_bytes = staticmethod(s.from_bytes)
|
|
297
347
|
dc.unpack_from = staticmethod(s.unpack_from)
|
|
@@ -305,6 +355,7 @@ def firstclass(cls=None, /, *, byteorder: str = "little", align: bool = False, *
|
|
|
305
355
|
dc.from_ctypes = staticmethod(s.from_ctypes)
|
|
306
356
|
dc.ctypes_type = staticmethod(s.ctypes_type)
|
|
307
357
|
dc.c_header = staticmethod(s.c_header)
|
|
358
|
+
dc.rust_source = staticmethod(lambda: rust_source(s))
|
|
308
359
|
dc.sizeof = staticmethod(s.size)
|
|
309
360
|
return dc
|
|
310
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)
|