firstclass 0.0.1__py3-none-any.whl
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/__init__.py +26 -0
- firstclass/_core.py +251 -0
- firstclass/_specs.py +526 -0
- firstclass-0.0.1.dist-info/METADATA +104 -0
- firstclass-0.0.1.dist-info/RECORD +7 -0
- firstclass-0.0.1.dist-info/WHEEL +5 -0
- firstclass-0.0.1.dist-info/top_level.txt +1 -0
firstclass/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from ._core import firstclass
|
|
2
|
+
from ._specs import (
|
|
3
|
+
array_t,
|
|
4
|
+
bool_t,
|
|
5
|
+
bytes_t,
|
|
6
|
+
char_array,
|
|
7
|
+
char_t,
|
|
8
|
+
float32_t,
|
|
9
|
+
float64_t,
|
|
10
|
+
int8_t,
|
|
11
|
+
int16_t,
|
|
12
|
+
int32_t,
|
|
13
|
+
int64_t,
|
|
14
|
+
uint8_t,
|
|
15
|
+
uint16_t,
|
|
16
|
+
uint32_t,
|
|
17
|
+
uint64_t,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"firstclass",
|
|
22
|
+
"uint8_t", "uint16_t", "uint32_t", "uint64_t",
|
|
23
|
+
"int8_t", "int16_t", "int32_t", "int64_t",
|
|
24
|
+
"float32_t", "float64_t", "bool_t", "char_t",
|
|
25
|
+
"char_array", "bytes_t", "array_t",
|
|
26
|
+
]
|
firstclass/_core.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""The @firstclass decorator and the per-class Schema it attaches."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ctypes
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import typing
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
|
|
11
|
+
from ._specs import ArrayOf, Nested, Spec, resolve_spec
|
|
12
|
+
|
|
13
|
+
_METHODS = (
|
|
14
|
+
"to_bytes", "from_bytes", "to_dict", "from_dict", "to_json", "from_json",
|
|
15
|
+
"to_xml", "from_xml", "to_ctypes", "from_ctypes", "ctypes_type", "c_header", "sizeof",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _round_up(n: int, a: int) -> int:
|
|
20
|
+
return -(-n // a) * a
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Schema:
|
|
24
|
+
def __init__(self, cls, byteorder: str, align: bool):
|
|
25
|
+
if byteorder not in ("little", "big"):
|
|
26
|
+
raise ValueError(f"byteorder must be 'little' or 'big', got {byteorder!r}")
|
|
27
|
+
self.cls = cls
|
|
28
|
+
self.bo = "<" if byteorder == "little" else ">"
|
|
29
|
+
self.byteorder = byteorder
|
|
30
|
+
self.aligned = align
|
|
31
|
+
self._fields: list[tuple[dataclasses.Field, Spec]] | None = None
|
|
32
|
+
self._layout = None
|
|
33
|
+
self._ctype = None
|
|
34
|
+
|
|
35
|
+
# -- structure -----------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def fields(self):
|
|
39
|
+
if self._fields is None:
|
|
40
|
+
hints = typing.get_type_hints(self.cls, include_extras=True)
|
|
41
|
+
self._fields = [(f, resolve_spec(hints[f.name])) for f in dataclasses.fields(self.cls)]
|
|
42
|
+
return self._fields
|
|
43
|
+
|
|
44
|
+
def require_binary(self):
|
|
45
|
+
for f, spec in self.fields:
|
|
46
|
+
if not spec.binary:
|
|
47
|
+
raise TypeError(
|
|
48
|
+
f"{self.cls.__name__}.{f.name}: type {spec.tp!r} has no binary form; "
|
|
49
|
+
"annotate it with uint8_t, char_array[N], ... or a firstclass class"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def layout(self):
|
|
53
|
+
if self._layout is None:
|
|
54
|
+
self.require_binary()
|
|
55
|
+
off, widest, offsets = 0, 1, []
|
|
56
|
+
for _, spec in self.fields:
|
|
57
|
+
a = spec.align() if self.aligned else 1
|
|
58
|
+
off = _round_up(off, a)
|
|
59
|
+
offsets.append(off)
|
|
60
|
+
off += spec.size()
|
|
61
|
+
widest = max(widest, a)
|
|
62
|
+
self._layout = (offsets, _round_up(off, widest), widest)
|
|
63
|
+
return self._layout
|
|
64
|
+
|
|
65
|
+
def size(self) -> int:
|
|
66
|
+
return self.layout()[1]
|
|
67
|
+
|
|
68
|
+
def align(self) -> int:
|
|
69
|
+
return self.layout()[2]
|
|
70
|
+
|
|
71
|
+
def _build(self, kw: dict):
|
|
72
|
+
init = {k: v for k, v in kw.items() if self._by_name[k].init}
|
|
73
|
+
obj = self.cls(**init)
|
|
74
|
+
for k, v in kw.items():
|
|
75
|
+
if not self._by_name[k].init:
|
|
76
|
+
object.__setattr__(obj, k, v)
|
|
77
|
+
return obj
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def _by_name(self):
|
|
81
|
+
return {f.name: f for f, _ in self.fields} # small; fields are cached
|
|
82
|
+
|
|
83
|
+
def _ctx(self, f, exc):
|
|
84
|
+
return type(exc)(f"{self.cls.__name__}.{f.name}: {exc}")
|
|
85
|
+
|
|
86
|
+
# -- bytes ---------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def pack(self, obj) -> bytes:
|
|
89
|
+
offsets, total, _ = self.layout()
|
|
90
|
+
buf = bytearray(total)
|
|
91
|
+
for (f, spec), off in zip(self.fields, offsets):
|
|
92
|
+
try:
|
|
93
|
+
raw = spec.pack(getattr(obj, f.name), self.bo)
|
|
94
|
+
except (TypeError, ValueError) as e:
|
|
95
|
+
raise self._ctx(f, e) from e
|
|
96
|
+
buf[off:off + len(raw)] = raw
|
|
97
|
+
return bytes(buf)
|
|
98
|
+
|
|
99
|
+
def unpack(self, buf, off: int = 0):
|
|
100
|
+
offsets, total, _ = self.layout()
|
|
101
|
+
if len(buf) - off < total:
|
|
102
|
+
raise ValueError(f"{self.cls.__name__} needs {total} bytes, got {len(buf) - off}")
|
|
103
|
+
kw = {}
|
|
104
|
+
for (f, spec), o in zip(self.fields, offsets):
|
|
105
|
+
try:
|
|
106
|
+
kw[f.name] = spec.unpack(buf, off + o, self.bo)
|
|
107
|
+
except (TypeError, ValueError) as e:
|
|
108
|
+
raise self._ctx(f, e) from e
|
|
109
|
+
return self._build(kw)
|
|
110
|
+
|
|
111
|
+
def from_bytes(self, data):
|
|
112
|
+
data = memoryview(data).cast("B") if not isinstance(data, (bytes, bytearray)) else data
|
|
113
|
+
if len(data) != self.size():
|
|
114
|
+
raise ValueError(f"{self.cls.__name__} needs exactly {self.size()} bytes, got {len(data)}")
|
|
115
|
+
return self.unpack(data, 0)
|
|
116
|
+
|
|
117
|
+
# -- dict / json / xml ---------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def to_dict(self, obj) -> dict:
|
|
120
|
+
return {f.name: spec.to_json(getattr(obj, f.name)) for f, spec in self.fields}
|
|
121
|
+
|
|
122
|
+
def from_dict(self, d):
|
|
123
|
+
if not isinstance(d, dict):
|
|
124
|
+
raise TypeError(f"{self.cls.__name__}.from_dict needs a dict, got {type(d).__name__}")
|
|
125
|
+
unknown = set(d) - set(self._by_name)
|
|
126
|
+
if unknown:
|
|
127
|
+
raise ValueError(f"{self.cls.__name__}: unknown keys {sorted(unknown)}")
|
|
128
|
+
kw = {}
|
|
129
|
+
for f, spec in self.fields:
|
|
130
|
+
if f.name in d:
|
|
131
|
+
try:
|
|
132
|
+
kw[f.name] = spec.from_json(d[f.name])
|
|
133
|
+
except (TypeError, ValueError) as e:
|
|
134
|
+
raise self._ctx(f, e) from e
|
|
135
|
+
return self._build(kw)
|
|
136
|
+
|
|
137
|
+
def fill_xml(self, obj, elem: ET.Element) -> None:
|
|
138
|
+
for f, spec in self.fields:
|
|
139
|
+
spec.to_xml(getattr(obj, f.name), ET.SubElement(elem, f.name))
|
|
140
|
+
|
|
141
|
+
def read_xml(self, elem: ET.Element):
|
|
142
|
+
known = self._by_name
|
|
143
|
+
specs = {f.name: s for f, s in self.fields}
|
|
144
|
+
kw = {}
|
|
145
|
+
for child in elem:
|
|
146
|
+
if child.tag not in known:
|
|
147
|
+
raise ValueError(f"{self.cls.__name__}: unknown element <{child.tag}>")
|
|
148
|
+
if child.tag in kw:
|
|
149
|
+
raise ValueError(f"{self.cls.__name__}: duplicate element <{child.tag}>")
|
|
150
|
+
spec = specs[child.tag]
|
|
151
|
+
try:
|
|
152
|
+
kw[child.tag] = spec.from_xml(child)
|
|
153
|
+
except (TypeError, ValueError) as e:
|
|
154
|
+
raise self._ctx(known[child.tag], e) from e
|
|
155
|
+
return self._build(kw)
|
|
156
|
+
|
|
157
|
+
# -- ctypes / C ----------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def ctypes_type(self):
|
|
160
|
+
if self._ctype is None:
|
|
161
|
+
self.require_binary()
|
|
162
|
+
base = ctypes.LittleEndianStructure if self.bo == "<" else ctypes.BigEndianStructure
|
|
163
|
+
ns = {"_fields_": [(f.name, spec.ctype(self.bo)) for f, spec in self.fields]}
|
|
164
|
+
if not self.aligned:
|
|
165
|
+
ns["_pack_"] = 1
|
|
166
|
+
if sys.version_info >= (3, 14):
|
|
167
|
+
ns["_layout_"] = "ms"
|
|
168
|
+
self._ctype = type(self.cls.__name__ + "_c", (base,), ns)
|
|
169
|
+
return self._ctype
|
|
170
|
+
|
|
171
|
+
def to_ctypes(self, obj):
|
|
172
|
+
c = self.ctypes_type()()
|
|
173
|
+
for f, spec in self.fields:
|
|
174
|
+
try:
|
|
175
|
+
setattr(c, f.name, spec.to_c(getattr(obj, f.name)))
|
|
176
|
+
except (TypeError, ValueError) as e:
|
|
177
|
+
raise self._ctx(f, e) from e
|
|
178
|
+
return c
|
|
179
|
+
|
|
180
|
+
def from_ctypes(self, c):
|
|
181
|
+
return self._build({f.name: spec.from_c(getattr(c, f.name)) for f, spec in self.fields})
|
|
182
|
+
|
|
183
|
+
def _nested_schemas(self):
|
|
184
|
+
for _, spec in self.fields:
|
|
185
|
+
while isinstance(spec, ArrayOf):
|
|
186
|
+
spec = spec.elem
|
|
187
|
+
if isinstance(spec, Nested):
|
|
188
|
+
yield spec.schema
|
|
189
|
+
|
|
190
|
+
def c_header(self) -> str:
|
|
191
|
+
self.require_binary()
|
|
192
|
+
out, seen = [], set()
|
|
193
|
+
|
|
194
|
+
def emit(schema):
|
|
195
|
+
if schema.cls in seen:
|
|
196
|
+
return
|
|
197
|
+
seen.add(schema.cls)
|
|
198
|
+
for dep in schema._nested_schemas():
|
|
199
|
+
emit(dep)
|
|
200
|
+
lines = [f"/* byteorder: {schema.byteorder} */"]
|
|
201
|
+
if not schema.aligned:
|
|
202
|
+
lines.append("#pragma pack(push, 1)")
|
|
203
|
+
lines.append(f"struct {schema.cls.__name__} {{")
|
|
204
|
+
lines += [f" {spec.c_decl(f.name)};" for f, spec in schema.fields]
|
|
205
|
+
lines.append("};")
|
|
206
|
+
if not schema.aligned:
|
|
207
|
+
lines.append("#pragma pack(pop)")
|
|
208
|
+
out.append("\n".join(lines))
|
|
209
|
+
|
|
210
|
+
emit(self)
|
|
211
|
+
return "#include <stdint.h>\n#include <stdbool.h>\n\n" + "\n\n".join(out) + "\n"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def firstclass(cls=None, /, *, byteorder: str = "little", align: bool = False, **dataclass_kwargs):
|
|
215
|
+
"""Like `@dataclass` (same keyword args), plus binary/JSON/XML/ctypes/C codecs.
|
|
216
|
+
|
|
217
|
+
byteorder: 'little' (default) or 'big'.
|
|
218
|
+
align: False (default) = packed layout, no padding; True = natural C alignment padding.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def wrap(cls):
|
|
222
|
+
dc = dataclasses.dataclass(cls, **dataclass_kwargs)
|
|
223
|
+
clash = [f.name for f in dataclasses.fields(dc) if f.name in _METHODS or f.name == "__firstclass__"]
|
|
224
|
+
if clash:
|
|
225
|
+
raise TypeError(f"{dc.__name__}: field names clash with firstclass methods: {clash}")
|
|
226
|
+
s = Schema(dc, byteorder, align)
|
|
227
|
+
dc.__firstclass__ = s
|
|
228
|
+
dc.to_bytes = lambda self: s.pack(self)
|
|
229
|
+
dc.from_bytes = staticmethod(s.from_bytes)
|
|
230
|
+
dc.to_dict = lambda self: s.to_dict(self)
|
|
231
|
+
dc.from_dict = staticmethod(s.from_dict)
|
|
232
|
+
dc.to_json = lambda self, **kw: json.dumps(s.to_dict(self), **kw)
|
|
233
|
+
dc.from_json = staticmethod(lambda text: s.from_dict(json.loads(text)))
|
|
234
|
+
dc.to_xml = lambda self, indent=None: _to_xml(s, self, indent)
|
|
235
|
+
dc.from_xml = staticmethod(lambda text: s.read_xml(ET.fromstring(text)))
|
|
236
|
+
dc.to_ctypes = lambda self: s.to_ctypes(self)
|
|
237
|
+
dc.from_ctypes = staticmethod(s.from_ctypes)
|
|
238
|
+
dc.ctypes_type = staticmethod(s.ctypes_type)
|
|
239
|
+
dc.c_header = staticmethod(s.c_header)
|
|
240
|
+
dc.sizeof = staticmethod(s.size)
|
|
241
|
+
return dc
|
|
242
|
+
|
|
243
|
+
return wrap if cls is None else wrap(cls)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _to_xml(schema: Schema, obj, indent) -> str:
|
|
247
|
+
root = ET.Element(schema.cls.__name__)
|
|
248
|
+
schema.fill_xml(obj, root)
|
|
249
|
+
if indent is not None:
|
|
250
|
+
ET.indent(root, space=" " * indent if isinstance(indent, int) else indent)
|
|
251
|
+
return ET.tostring(root, encoding="unicode")
|
firstclass/_specs.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Field specs: how one annotated field maps to bytes, JSON, XML and ctypes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import binascii
|
|
5
|
+
import ctypes
|
|
6
|
+
import struct
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Annotated, Any, get_origin
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Spec:
|
|
13
|
+
"""Base class. `bo` is a struct byte-order prefix: '<' or '>'."""
|
|
14
|
+
|
|
15
|
+
binary = True
|
|
16
|
+
|
|
17
|
+
def align(self) -> int:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
def size(self) -> int:
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
def check(self, value: Any) -> Any:
|
|
24
|
+
"""Validate (and normalise) a python value; raises TypeError/ValueError."""
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
def pack(self, value: Any, bo: str) -> bytes:
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def unpack(self, buf, off: int, bo: str) -> Any:
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def to_json(self, value: Any) -> Any:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def from_json(self, obj: Any) -> Any:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def to_xml(self, value: Any, elem: ET.Element) -> None:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def from_xml(self, elem: ET.Element) -> Any:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def ctype(self, bo: str):
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
def to_c(self, value: Any) -> Any:
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def from_c(self, cvalue: Any) -> Any:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def c_decl(self, name: str) -> str:
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _xml_text(elem: ET.Element) -> str:
|
|
59
|
+
return elem.text or ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Scalar(Spec):
|
|
63
|
+
def __init__(self, name: str, fmt: str, ctype, c_name: str, kind: str):
|
|
64
|
+
self.name, self.fmt, self._ctype, self.c_name, self.kind = name, fmt, ctype, c_name, kind
|
|
65
|
+
self._size = struct.calcsize("<" + fmt)
|
|
66
|
+
if kind == "int":
|
|
67
|
+
bits = self._size * 8
|
|
68
|
+
signed = fmt.islower()
|
|
69
|
+
self.lo, self.hi = (-(1 << (bits - 1)), (1 << (bits - 1)) - 1) if signed else (0, (1 << bits) - 1)
|
|
70
|
+
|
|
71
|
+
def align(self):
|
|
72
|
+
return self._size
|
|
73
|
+
|
|
74
|
+
def size(self):
|
|
75
|
+
return self._size
|
|
76
|
+
|
|
77
|
+
def check(self, value):
|
|
78
|
+
if self.kind == "bool":
|
|
79
|
+
if not isinstance(value, bool):
|
|
80
|
+
raise TypeError(f"{self.name} needs bool, got {type(value).__name__}")
|
|
81
|
+
elif self.kind == "int":
|
|
82
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
83
|
+
raise TypeError(f"{self.name} needs int, got {type(value).__name__}")
|
|
84
|
+
if not self.lo <= value <= self.hi:
|
|
85
|
+
raise ValueError(f"{value} out of range for {self.name} [{self.lo}, {self.hi}]")
|
|
86
|
+
else:
|
|
87
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
88
|
+
raise TypeError(f"{self.name} needs float, got {type(value).__name__}")
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
def pack(self, value, bo):
|
|
92
|
+
value = self.check(value)
|
|
93
|
+
try:
|
|
94
|
+
return struct.pack(bo + self.fmt, value)
|
|
95
|
+
except (OverflowError, struct.error) as e:
|
|
96
|
+
raise ValueError(f"{value!r} does not fit {self.name}: {e}") from e
|
|
97
|
+
|
|
98
|
+
def unpack(self, buf, off, bo):
|
|
99
|
+
return struct.unpack_from(bo + self.fmt, buf, off)[0]
|
|
100
|
+
|
|
101
|
+
def to_json(self, value):
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
def from_json(self, obj):
|
|
105
|
+
return self.check(obj)
|
|
106
|
+
|
|
107
|
+
def to_xml(self, value, elem):
|
|
108
|
+
elem.text = ("true" if value else "false") if self.kind == "bool" else repr(value)
|
|
109
|
+
|
|
110
|
+
def from_xml(self, elem):
|
|
111
|
+
text = _xml_text(elem).strip()
|
|
112
|
+
if self.kind == "bool":
|
|
113
|
+
if text not in ("true", "false"):
|
|
114
|
+
raise ValueError(f"bad bool text {text!r}")
|
|
115
|
+
return text == "true"
|
|
116
|
+
return self.check(int(text) if self.kind == "int" else float(text))
|
|
117
|
+
|
|
118
|
+
def ctype(self, bo):
|
|
119
|
+
return self._ctype
|
|
120
|
+
|
|
121
|
+
def to_c(self, value):
|
|
122
|
+
return self.check(value)
|
|
123
|
+
|
|
124
|
+
def from_c(self, cvalue):
|
|
125
|
+
return cvalue
|
|
126
|
+
|
|
127
|
+
def c_decl(self, name):
|
|
128
|
+
return f"{self.c_name} {name}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CharArray(Spec):
|
|
132
|
+
"""Fixed-size, NUL-padded UTF-8 string, like `char name[N]`."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, n: int):
|
|
135
|
+
self.n = n
|
|
136
|
+
|
|
137
|
+
def align(self):
|
|
138
|
+
return 1
|
|
139
|
+
|
|
140
|
+
def size(self):
|
|
141
|
+
return self.n
|
|
142
|
+
|
|
143
|
+
def _encode(self, value) -> bytes:
|
|
144
|
+
if not isinstance(value, str):
|
|
145
|
+
raise TypeError(f"char_array needs str, got {type(value).__name__}")
|
|
146
|
+
if "\0" in value:
|
|
147
|
+
raise ValueError("char_array value must not contain NUL")
|
|
148
|
+
raw = value.encode("utf-8")
|
|
149
|
+
if len(raw) > self.n:
|
|
150
|
+
raise ValueError(f"{value!r} is {len(raw)} bytes, char_array[{self.n}] holds {self.n}")
|
|
151
|
+
return raw
|
|
152
|
+
|
|
153
|
+
def check(self, value):
|
|
154
|
+
self._encode(value)
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
def pack(self, value, bo):
|
|
158
|
+
return self._encode(value).ljust(self.n, b"\0")
|
|
159
|
+
|
|
160
|
+
def unpack(self, buf, off, bo):
|
|
161
|
+
return bytes(buf[off:off + self.n]).split(b"\0", 1)[0].decode("utf-8")
|
|
162
|
+
|
|
163
|
+
def to_json(self, value):
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
def from_json(self, obj):
|
|
167
|
+
return self.check(obj)
|
|
168
|
+
|
|
169
|
+
def to_xml(self, value, elem):
|
|
170
|
+
elem.text = value
|
|
171
|
+
|
|
172
|
+
def from_xml(self, elem):
|
|
173
|
+
return self.check(_xml_text(elem))
|
|
174
|
+
|
|
175
|
+
def ctype(self, bo):
|
|
176
|
+
return ctypes.c_char * self.n
|
|
177
|
+
|
|
178
|
+
def to_c(self, value):
|
|
179
|
+
return self._encode(value)
|
|
180
|
+
|
|
181
|
+
def from_c(self, cvalue):
|
|
182
|
+
return bytes(cvalue).split(b"\0", 1)[0].decode("utf-8")
|
|
183
|
+
|
|
184
|
+
def c_decl(self, name):
|
|
185
|
+
return f"char {name}[{self.n}]"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ByteArray(Spec):
|
|
189
|
+
"""Fixed-size raw bytes, exactly N long."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, n: int):
|
|
192
|
+
self.n = n
|
|
193
|
+
|
|
194
|
+
def align(self):
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
def size(self):
|
|
198
|
+
return self.n
|
|
199
|
+
|
|
200
|
+
def check(self, value):
|
|
201
|
+
if not isinstance(value, (bytes, bytearray, memoryview)):
|
|
202
|
+
raise TypeError(f"bytes_t needs bytes, got {type(value).__name__}")
|
|
203
|
+
value = bytes(value)
|
|
204
|
+
if len(value) != self.n:
|
|
205
|
+
raise ValueError(f"bytes_t[{self.n}] needs exactly {self.n} bytes, got {len(value)}")
|
|
206
|
+
return value
|
|
207
|
+
|
|
208
|
+
def pack(self, value, bo):
|
|
209
|
+
return self.check(value)
|
|
210
|
+
|
|
211
|
+
def unpack(self, buf, off, bo):
|
|
212
|
+
return bytes(buf[off:off + self.n])
|
|
213
|
+
|
|
214
|
+
def to_json(self, value):
|
|
215
|
+
return value.hex()
|
|
216
|
+
|
|
217
|
+
def from_json(self, obj):
|
|
218
|
+
if not isinstance(obj, str):
|
|
219
|
+
raise TypeError("bytes_t JSON value must be a hex string")
|
|
220
|
+
try:
|
|
221
|
+
return self.check(binascii.unhexlify(obj))
|
|
222
|
+
except binascii.Error as e:
|
|
223
|
+
raise ValueError(f"bad hex string: {e}") from e
|
|
224
|
+
|
|
225
|
+
def to_xml(self, value, elem):
|
|
226
|
+
elem.text = value.hex()
|
|
227
|
+
|
|
228
|
+
def from_xml(self, elem):
|
|
229
|
+
return self.from_json(_xml_text(elem).strip())
|
|
230
|
+
|
|
231
|
+
def ctype(self, bo):
|
|
232
|
+
return ctypes.c_ubyte * self.n
|
|
233
|
+
|
|
234
|
+
def to_c(self, value):
|
|
235
|
+
return (ctypes.c_ubyte * self.n).from_buffer_copy(self.check(value))
|
|
236
|
+
|
|
237
|
+
def from_c(self, cvalue):
|
|
238
|
+
return bytes(cvalue)
|
|
239
|
+
|
|
240
|
+
def c_decl(self, name):
|
|
241
|
+
return f"uint8_t {name}[{self.n}]"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class ArrayOf(Spec):
|
|
245
|
+
"""Fixed-length array of any other spec, exactly N elements."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, elem: Spec, n: int):
|
|
248
|
+
self.elem, self.n = elem, n
|
|
249
|
+
self.binary = elem.binary
|
|
250
|
+
|
|
251
|
+
def align(self):
|
|
252
|
+
return self.elem.align()
|
|
253
|
+
|
|
254
|
+
def size(self):
|
|
255
|
+
return self.elem.size() * self.n
|
|
256
|
+
|
|
257
|
+
def check(self, value):
|
|
258
|
+
if not isinstance(value, (list, tuple)):
|
|
259
|
+
raise TypeError(f"array_t needs list, got {type(value).__name__}")
|
|
260
|
+
if len(value) != self.n:
|
|
261
|
+
raise ValueError(f"array_t[..., {self.n}] needs {self.n} items, got {len(value)}")
|
|
262
|
+
return [self.elem.check(v) for v in value]
|
|
263
|
+
|
|
264
|
+
def pack(self, value, bo):
|
|
265
|
+
return b"".join(self.elem.pack(v, bo) for v in self.check(value))
|
|
266
|
+
|
|
267
|
+
def unpack(self, buf, off, bo):
|
|
268
|
+
step = self.elem.size()
|
|
269
|
+
return [self.elem.unpack(buf, off + i * step, bo) for i in range(self.n)]
|
|
270
|
+
|
|
271
|
+
def to_json(self, value):
|
|
272
|
+
return [self.elem.to_json(v) for v in value]
|
|
273
|
+
|
|
274
|
+
def from_json(self, obj):
|
|
275
|
+
if not isinstance(obj, (list, tuple)):
|
|
276
|
+
raise TypeError("array_t JSON value must be a list")
|
|
277
|
+
return self.check([self.elem.from_json(v) for v in obj])
|
|
278
|
+
|
|
279
|
+
def to_xml(self, value, elem):
|
|
280
|
+
for v in value:
|
|
281
|
+
self.elem.to_xml(v, ET.SubElement(elem, "item"))
|
|
282
|
+
|
|
283
|
+
def from_xml(self, elem):
|
|
284
|
+
kids = list(elem)
|
|
285
|
+
if any(k.tag != "item" for k in kids):
|
|
286
|
+
raise ValueError("array children must be <item>")
|
|
287
|
+
return self.check([self.elem.from_xml(k) for k in kids])
|
|
288
|
+
|
|
289
|
+
def ctype(self, bo):
|
|
290
|
+
return self.elem.ctype(bo) * self.n
|
|
291
|
+
|
|
292
|
+
def to_c(self, value):
|
|
293
|
+
arr = self.ctype(None)()
|
|
294
|
+
for i, v in enumerate(self.check(value)):
|
|
295
|
+
c = self.elem.to_c(v)
|
|
296
|
+
if isinstance(self.elem, CharArray): # array items need an instance, not bytes
|
|
297
|
+
c = self.elem.ctype(None).from_buffer_copy(c.ljust(self.elem.n, b"\0"))
|
|
298
|
+
arr[i] = c
|
|
299
|
+
return arr
|
|
300
|
+
|
|
301
|
+
def from_c(self, cvalue):
|
|
302
|
+
return [self.elem.from_c(cvalue[i]) for i in range(self.n)]
|
|
303
|
+
|
|
304
|
+
def c_decl(self, name):
|
|
305
|
+
return self.elem.c_decl(f"{name}[{self.n}]")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class Nested(Spec):
|
|
309
|
+
"""A field whose type is itself a firstclass class."""
|
|
310
|
+
|
|
311
|
+
def __init__(self, schema):
|
|
312
|
+
self.schema = schema
|
|
313
|
+
|
|
314
|
+
def align(self):
|
|
315
|
+
return self.schema.align()
|
|
316
|
+
|
|
317
|
+
def size(self):
|
|
318
|
+
return self.schema.size()
|
|
319
|
+
|
|
320
|
+
def check(self, value):
|
|
321
|
+
if not isinstance(value, self.schema.cls):
|
|
322
|
+
raise TypeError(f"expected {self.schema.cls.__name__}, got {type(value).__name__}")
|
|
323
|
+
return value
|
|
324
|
+
|
|
325
|
+
def pack(self, value, bo):
|
|
326
|
+
return self.schema.pack(self.check(value))
|
|
327
|
+
|
|
328
|
+
def unpack(self, buf, off, bo):
|
|
329
|
+
return self.schema.unpack(buf, off)
|
|
330
|
+
|
|
331
|
+
def to_json(self, value):
|
|
332
|
+
return self.schema.to_dict(value)
|
|
333
|
+
|
|
334
|
+
def from_json(self, obj):
|
|
335
|
+
return self.schema.from_dict(obj)
|
|
336
|
+
|
|
337
|
+
def to_xml(self, value, elem):
|
|
338
|
+
self.schema.fill_xml(value, elem)
|
|
339
|
+
|
|
340
|
+
def from_xml(self, elem):
|
|
341
|
+
return self.schema.read_xml(elem)
|
|
342
|
+
|
|
343
|
+
def ctype(self, bo):
|
|
344
|
+
return self.schema.ctypes_type()
|
|
345
|
+
|
|
346
|
+
def to_c(self, value):
|
|
347
|
+
return self.schema.to_ctypes(self.check(value))
|
|
348
|
+
|
|
349
|
+
def from_c(self, cvalue):
|
|
350
|
+
return self.schema.from_ctypes(cvalue)
|
|
351
|
+
|
|
352
|
+
def c_decl(self, name):
|
|
353
|
+
return f"struct {self.schema.cls.__name__} {name}"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class Plain(Spec):
|
|
357
|
+
"""Un-annotated field: JSON/XML only, no binary form."""
|
|
358
|
+
|
|
359
|
+
binary = False
|
|
360
|
+
|
|
361
|
+
def __init__(self, tp):
|
|
362
|
+
self.tp = tp
|
|
363
|
+
|
|
364
|
+
def check(self, value):
|
|
365
|
+
return value
|
|
366
|
+
|
|
367
|
+
def to_json(self, value):
|
|
368
|
+
return value
|
|
369
|
+
|
|
370
|
+
def from_json(self, obj):
|
|
371
|
+
return obj
|
|
372
|
+
|
|
373
|
+
def to_xml(self, value, elem):
|
|
374
|
+
if self.tp is bool:
|
|
375
|
+
elem.text = "true" if value else "false"
|
|
376
|
+
elif self.tp in (int, float, str):
|
|
377
|
+
elem.text = str(value) if self.tp is not float else repr(value)
|
|
378
|
+
else:
|
|
379
|
+
raise TypeError(f"XML needs a firstclass type for {self.tp!r}")
|
|
380
|
+
|
|
381
|
+
def from_xml(self, elem):
|
|
382
|
+
text = _xml_text(elem)
|
|
383
|
+
if self.tp is bool:
|
|
384
|
+
if text.strip() not in ("true", "false"):
|
|
385
|
+
raise ValueError(f"bad bool text {text!r}")
|
|
386
|
+
return text.strip() == "true"
|
|
387
|
+
if self.tp in (int, float):
|
|
388
|
+
return self.tp(text.strip())
|
|
389
|
+
if self.tp is str:
|
|
390
|
+
return text
|
|
391
|
+
raise TypeError(f"XML needs a firstclass type for {self.tp!r}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
_NATIVE_BO = "<" if sys.byteorder == "little" else ">"
|
|
395
|
+
_PTR_SIZE = ctypes.sizeof(ctypes.c_void_p)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class Pointer(Scalar):
|
|
399
|
+
"""A ctypes pointer field. The python value is the address as an int (0 = NULL).
|
|
400
|
+
|
|
401
|
+
The bytes are the raw address, so it only round-trips inside one process/ABI.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
def __init__(self, tp):
|
|
405
|
+
code = "Q" if _PTR_SIZE == 8 else "I"
|
|
406
|
+
super().__init__("pointer", code, ctypes.c_void_p, "void *", "int")
|
|
407
|
+
self.tp = tp
|
|
408
|
+
|
|
409
|
+
def ctype(self, bo):
|
|
410
|
+
if bo != _NATIVE_BO:
|
|
411
|
+
raise TypeError("ctypes pointers need the platform's native byteorder")
|
|
412
|
+
return self.tp
|
|
413
|
+
|
|
414
|
+
def to_c(self, value):
|
|
415
|
+
value = self.check(value)
|
|
416
|
+
if self.tp is ctypes.c_void_p:
|
|
417
|
+
return value
|
|
418
|
+
return ctypes.cast(ctypes.c_void_p(value), self.tp)
|
|
419
|
+
|
|
420
|
+
def from_c(self, cvalue):
|
|
421
|
+
if isinstance(cvalue, int) or cvalue is None:
|
|
422
|
+
return cvalue or 0
|
|
423
|
+
return ctypes.cast(cvalue, ctypes.c_void_p).value or 0
|
|
424
|
+
|
|
425
|
+
def c_decl(self, name):
|
|
426
|
+
return f"void *{name}"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
_SIGNED = {1: ctypes.c_int8, 2: ctypes.c_int16, 4: ctypes.c_int32, 8: ctypes.c_int64}
|
|
430
|
+
_UNSIGNED = {1: ctypes.c_uint8, 2: ctypes.c_uint16, 4: ctypes.c_uint32, 8: ctypes.c_uint64}
|
|
431
|
+
_INT_CODES = "bBhHiIlLqQ"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _ctypes_spec(tp):
|
|
435
|
+
"""Spec for a raw ctypes type used as an annotation, or None."""
|
|
436
|
+
if not isinstance(tp, type):
|
|
437
|
+
return None
|
|
438
|
+
if issubclass(tp, ctypes.Array):
|
|
439
|
+
elem = tp._type_
|
|
440
|
+
if elem is ctypes.c_char:
|
|
441
|
+
return CharArray(tp._length_)
|
|
442
|
+
sub = resolve_spec(elem)
|
|
443
|
+
return None if not sub.binary else ArrayOf(sub, tp._length_)
|
|
444
|
+
if issubclass(tp, ctypes._Pointer):
|
|
445
|
+
return Pointer(tp)
|
|
446
|
+
if issubclass(tp, ctypes.Structure) or issubclass(tp, ctypes.Union):
|
|
447
|
+
return None
|
|
448
|
+
if not issubclass(tp, ctypes._SimpleCData):
|
|
449
|
+
return None
|
|
450
|
+
code = tp._type_
|
|
451
|
+
if code in "PzZ":
|
|
452
|
+
return Pointer(ctypes.c_void_p)
|
|
453
|
+
if code == "?":
|
|
454
|
+
return Scalar("c_bool", "?", ctypes.c_bool, "bool", "bool")
|
|
455
|
+
if code == "c":
|
|
456
|
+
return CharArray(1)
|
|
457
|
+
if code == "f":
|
|
458
|
+
return Scalar("c_float", "f", ctypes.c_float, "float", "float")
|
|
459
|
+
if code == "d":
|
|
460
|
+
return Scalar("c_double", "d", ctypes.c_double, "double", "float")
|
|
461
|
+
if code in _INT_CODES:
|
|
462
|
+
size = ctypes.sizeof(tp)
|
|
463
|
+
unsigned = code.isupper()
|
|
464
|
+
canon = (_UNSIGNED if unsigned else _SIGNED)[size]
|
|
465
|
+
fmt = {1: "b", 2: "h", 4: "i", 8: "q"}[size]
|
|
466
|
+
name = f"{'u' if unsigned else ''}int{size * 8}_t"
|
|
467
|
+
return Scalar(tp.__name__, fmt.upper() if unsigned else fmt, canon, name, "int")
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def resolve_spec(tp) -> Spec:
|
|
472
|
+
if get_origin(tp) is Annotated:
|
|
473
|
+
for meta in tp.__metadata__:
|
|
474
|
+
if isinstance(meta, Spec):
|
|
475
|
+
return meta
|
|
476
|
+
schema = getattr(tp, "__firstclass__", None) if isinstance(tp, type) else None
|
|
477
|
+
if schema is not None:
|
|
478
|
+
return Nested(schema)
|
|
479
|
+
return _ctypes_spec(tp) or Plain(tp)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _positive(n) -> int:
|
|
483
|
+
if isinstance(n, bool) or not isinstance(n, int) or n <= 0:
|
|
484
|
+
raise TypeError(f"size must be a positive int, got {n!r}")
|
|
485
|
+
return n
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# --- public type markers -----------------------------------------------------
|
|
489
|
+
|
|
490
|
+
uint8_t = Annotated[int, Scalar("uint8_t", "B", ctypes.c_uint8, "uint8_t", "int")]
|
|
491
|
+
uint16_t = Annotated[int, Scalar("uint16_t", "H", ctypes.c_uint16, "uint16_t", "int")]
|
|
492
|
+
uint32_t = Annotated[int, Scalar("uint32_t", "I", ctypes.c_uint32, "uint32_t", "int")]
|
|
493
|
+
uint64_t = Annotated[int, Scalar("uint64_t", "Q", ctypes.c_uint64, "uint64_t", "int")]
|
|
494
|
+
int8_t = Annotated[int, Scalar("int8_t", "b", ctypes.c_int8, "int8_t", "int")]
|
|
495
|
+
int16_t = Annotated[int, Scalar("int16_t", "h", ctypes.c_int16, "int16_t", "int")]
|
|
496
|
+
int32_t = Annotated[int, Scalar("int32_t", "i", ctypes.c_int32, "int32_t", "int")]
|
|
497
|
+
int64_t = Annotated[int, Scalar("int64_t", "q", ctypes.c_int64, "int64_t", "int")]
|
|
498
|
+
float32_t = Annotated[float, Scalar("float32_t", "f", ctypes.c_float, "float", "float")]
|
|
499
|
+
float64_t = Annotated[float, Scalar("float64_t", "d", ctypes.c_double, "double", "float")]
|
|
500
|
+
bool_t = Annotated[bool, Scalar("bool_t", "?", ctypes.c_bool, "bool", "bool")]
|
|
501
|
+
char_t = Annotated[str, CharArray(1)]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class char_array:
|
|
505
|
+
"""`char_array[N]`: NUL-padded UTF-8 string in N bytes."""
|
|
506
|
+
|
|
507
|
+
def __class_getitem__(cls, n):
|
|
508
|
+
return Annotated[str, CharArray(_positive(n))]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class bytes_t:
|
|
512
|
+
"""`bytes_t[N]`: exactly N raw bytes."""
|
|
513
|
+
|
|
514
|
+
def __class_getitem__(cls, n):
|
|
515
|
+
return Annotated[bytes, ByteArray(_positive(n))]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class array_t:
|
|
519
|
+
"""`array_t[T, N]`: exactly N items of T (scalar, char_array, or firstclass class)."""
|
|
520
|
+
|
|
521
|
+
def __class_getitem__(cls, params):
|
|
522
|
+
try:
|
|
523
|
+
elem, n = params
|
|
524
|
+
except (TypeError, ValueError):
|
|
525
|
+
raise TypeError("array_t needs two params: array_t[element_type, length]") from None
|
|
526
|
+
return Annotated[list, ArrayOf(resolve_spec(elem), _positive(n))]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: firstclass
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: dataclass with bytes, ctypes, C struct, JSON and XML serialization
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# firstclass
|
|
9
|
+
|
|
10
|
+
`@firstclass` works like `@dataclass` (same keyword args) and adds conversion to and from
|
|
11
|
+
packed bytes, `ctypes`, a C struct header, JSON and XML. Pure Python 3.10+, no dependencies.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from firstclass import firstclass, uint8_t, uint16_t, int32_t, float32_t, bool_t, char_array, bytes_t, array_t
|
|
15
|
+
|
|
16
|
+
@firstclass
|
|
17
|
+
class Point:
|
|
18
|
+
x: int32_t
|
|
19
|
+
y: int32_t
|
|
20
|
+
|
|
21
|
+
@firstclass
|
|
22
|
+
class Packet:
|
|
23
|
+
kind: uint8_t
|
|
24
|
+
ok: bool_t
|
|
25
|
+
temp: float32_t
|
|
26
|
+
name: char_array[8] # NUL-padded UTF-8
|
|
27
|
+
mac: bytes_t[6] # exactly 6 bytes
|
|
28
|
+
samples: array_t[uint16_t, 4]
|
|
29
|
+
origin: Point # nested
|
|
30
|
+
|
|
31
|
+
p = Packet(1, True, 21.5, "sensor", b"\x00\x11\x22\x33\x44\x55", [1, 2, 3, 4], Point(10, -20))
|
|
32
|
+
|
|
33
|
+
raw = p.to_bytes() # packed, little-endian
|
|
34
|
+
assert Packet.from_bytes(raw) == p
|
|
35
|
+
Packet.sizeof() # 36
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Types
|
|
39
|
+
|
|
40
|
+
| Marker | C | Bytes |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `uint8_t` `uint16_t` `uint32_t` `uint64_t` | `uintN_t` | 1/2/4/8 |
|
|
43
|
+
| `int8_t` `int16_t` `int32_t` `int64_t` | `intN_t` | 1/2/4/8 |
|
|
44
|
+
| `float32_t` / `float64_t` | `float` / `double` | 4 / 8 |
|
|
45
|
+
| `bool_t` | `bool` | 1 |
|
|
46
|
+
| `char_t` | `char` | 1 |
|
|
47
|
+
| `char_array[N]` | `char x[N]` | N (UTF-8, NUL-padded) |
|
|
48
|
+
| `bytes_t[N]` | `uint8_t x[N]` | N |
|
|
49
|
+
| `array_t[T, N]` | `T x[N]` | N * size(T) |
|
|
50
|
+
| another `@firstclass` class | `struct Name` | its size |
|
|
51
|
+
|
|
52
|
+
Values are checked: out of range ints, over-long strings and wrong lengths raise
|
|
53
|
+
`ValueError`; wrong Python types raise `TypeError`. Nothing is truncated or coerced silently.
|
|
54
|
+
|
|
55
|
+
Raw `ctypes` types work as annotations too (packed, same layout as a `_pack_ = 1` Structure):
|
|
56
|
+
`ctypes.c_int`, `c_uint64`, `c_size_t`, `c_bool`, `c_float`, `c_double`, `c_char`, `c_uint16 * 4`,
|
|
57
|
+
`c_char * 8`. Pointers (`c_void_p`, `POINTER(T)`, `c_char_p`) are stored as the address (an `int`,
|
|
58
|
+
0 = NULL) at pointer size; `to_ctypes()` gives a real pointer field you can dereference. The
|
|
59
|
+
address only means something inside the same process. Sizes follow the platform
|
|
60
|
+
(`c_long` is 4 bytes on Windows, 8 on Linux).
|
|
61
|
+
|
|
62
|
+
## Methods added to every class
|
|
63
|
+
|
|
64
|
+
| Method | |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `obj.to_bytes()` / `Cls.from_bytes(data)` | binary; `from_bytes` needs exactly `sizeof()` bytes |
|
|
67
|
+
| `Cls.sizeof()` | size in bytes |
|
|
68
|
+
| `obj.to_dict()` / `Cls.from_dict(d)` | JSON-compatible dict (bytes as hex) |
|
|
69
|
+
| `obj.to_json(**kw)` / `Cls.from_json(s)` | `kw` goes to `json.dumps` |
|
|
70
|
+
| `obj.to_xml(indent=None)` / `Cls.from_xml(s)` | `<Packet><kind>1</kind>...</Packet>`, arrays as `<item>` |
|
|
71
|
+
| `obj.to_ctypes()` / `Cls.from_ctypes(c)` / `Cls.ctypes_type()` | `ctypes.Structure` with matching layout |
|
|
72
|
+
| `Cls.c_header()` | C source for the struct(s), nested ones first |
|
|
73
|
+
|
|
74
|
+
## Layout options
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
@firstclass(byteorder="big") # default "little"
|
|
78
|
+
@firstclass(align=True) # default False = packed, no padding (like __attribute__((packed)))
|
|
79
|
+
@firstclass(frozen=True, slots=True) # any dataclass option
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`align=True` inserts natural-alignment padding like a normal C struct (assumes the x86-64 style
|
|
83
|
+
rule: alignment equals size). Padding bytes are written as zero and ignored on read.
|
|
84
|
+
C headers describe the struct only; C compilers use native byte order, so `byteorder` is noted
|
|
85
|
+
in a comment.
|
|
86
|
+
|
|
87
|
+
## Notes
|
|
88
|
+
|
|
89
|
+
- Fields with plain types (`str`, `int`) work for JSON/XML only; `to_bytes()` raises
|
|
90
|
+
`TypeError` naming the field.
|
|
91
|
+
- Use `from __future__ import annotations` freely; annotations are resolved lazily. Classes
|
|
92
|
+
defined inside functions that refer to local names are not resolvable.
|
|
93
|
+
- Don't parse untrusted XML with `from_xml` without extra care (stdlib `xml.etree`).
|
|
94
|
+
- Field names that collide with the generated methods (`to_bytes`, ...) raise `TypeError`.
|
|
95
|
+
|
|
96
|
+
## Example
|
|
97
|
+
|
|
98
|
+
`python examples/example.py` shows bytes, JSON, XML, ctypes and the C header from one class.
|
|
99
|
+
|
|
100
|
+
## Develop
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
python -m unittest discover -s tests
|
|
104
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
firstclass/__init__.py,sha256=ZpxYPlIPoVFGN1ouH9a-Zqnx68s1x6U2tQxTeVI3u0o,477
|
|
2
|
+
firstclass/_core.py,sha256=25F5VoaJI6rPUcCQKMr7wK9Jdb8Edqg4TdU5WN__1_o,9852
|
|
3
|
+
firstclass/_specs.py,sha256=naIM81qiAoyKY5GJVEiHA2zduJjtA5Y0IE0jWN2saQA,16398
|
|
4
|
+
firstclass-0.0.1.dist-info/METADATA,sha256=KJ0uORJ26VH5mzyb2P9ACrwg-dpg8jS6KXv1HG2EnG8,4142
|
|
5
|
+
firstclass-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
firstclass-0.0.1.dist-info/top_level.txt,sha256=97bGTourIpqo_FxCQEDC1ZrU6r220PBcE2XqKahU4nM,11
|
|
7
|
+
firstclass-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
firstclass
|