firstclass 0.0.1__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.
@@ -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,97 @@
1
+ # firstclass
2
+
3
+ `@firstclass` works like `@dataclass` (same keyword args) and adds conversion to and from
4
+ packed bytes, `ctypes`, a C struct header, JSON and XML. Pure Python 3.10+, no dependencies.
5
+
6
+ ```python
7
+ from firstclass import firstclass, uint8_t, uint16_t, int32_t, float32_t, bool_t, char_array, bytes_t, array_t
8
+
9
+ @firstclass
10
+ class Point:
11
+ x: int32_t
12
+ y: int32_t
13
+
14
+ @firstclass
15
+ class Packet:
16
+ kind: uint8_t
17
+ ok: bool_t
18
+ temp: float32_t
19
+ name: char_array[8] # NUL-padded UTF-8
20
+ mac: bytes_t[6] # exactly 6 bytes
21
+ samples: array_t[uint16_t, 4]
22
+ origin: Point # nested
23
+
24
+ p = Packet(1, True, 21.5, "sensor", b"\x00\x11\x22\x33\x44\x55", [1, 2, 3, 4], Point(10, -20))
25
+
26
+ raw = p.to_bytes() # packed, little-endian
27
+ assert Packet.from_bytes(raw) == p
28
+ Packet.sizeof() # 36
29
+ ```
30
+
31
+ ## Types
32
+
33
+ | Marker | C | Bytes |
34
+ |---|---|---|
35
+ | `uint8_t` `uint16_t` `uint32_t` `uint64_t` | `uintN_t` | 1/2/4/8 |
36
+ | `int8_t` `int16_t` `int32_t` `int64_t` | `intN_t` | 1/2/4/8 |
37
+ | `float32_t` / `float64_t` | `float` / `double` | 4 / 8 |
38
+ | `bool_t` | `bool` | 1 |
39
+ | `char_t` | `char` | 1 |
40
+ | `char_array[N]` | `char x[N]` | N (UTF-8, NUL-padded) |
41
+ | `bytes_t[N]` | `uint8_t x[N]` | N |
42
+ | `array_t[T, N]` | `T x[N]` | N * size(T) |
43
+ | another `@firstclass` class | `struct Name` | its size |
44
+
45
+ Values are checked: out of range ints, over-long strings and wrong lengths raise
46
+ `ValueError`; wrong Python types raise `TypeError`. Nothing is truncated or coerced silently.
47
+
48
+ Raw `ctypes` types work as annotations too (packed, same layout as a `_pack_ = 1` Structure):
49
+ `ctypes.c_int`, `c_uint64`, `c_size_t`, `c_bool`, `c_float`, `c_double`, `c_char`, `c_uint16 * 4`,
50
+ `c_char * 8`. Pointers (`c_void_p`, `POINTER(T)`, `c_char_p`) are stored as the address (an `int`,
51
+ 0 = NULL) at pointer size; `to_ctypes()` gives a real pointer field you can dereference. The
52
+ address only means something inside the same process. Sizes follow the platform
53
+ (`c_long` is 4 bytes on Windows, 8 on Linux).
54
+
55
+ ## Methods added to every class
56
+
57
+ | Method | |
58
+ |---|---|
59
+ | `obj.to_bytes()` / `Cls.from_bytes(data)` | binary; `from_bytes` needs exactly `sizeof()` bytes |
60
+ | `Cls.sizeof()` | size in bytes |
61
+ | `obj.to_dict()` / `Cls.from_dict(d)` | JSON-compatible dict (bytes as hex) |
62
+ | `obj.to_json(**kw)` / `Cls.from_json(s)` | `kw` goes to `json.dumps` |
63
+ | `obj.to_xml(indent=None)` / `Cls.from_xml(s)` | `<Packet><kind>1</kind>...</Packet>`, arrays as `<item>` |
64
+ | `obj.to_ctypes()` / `Cls.from_ctypes(c)` / `Cls.ctypes_type()` | `ctypes.Structure` with matching layout |
65
+ | `Cls.c_header()` | C source for the struct(s), nested ones first |
66
+
67
+ ## Layout options
68
+
69
+ ```python
70
+ @firstclass(byteorder="big") # default "little"
71
+ @firstclass(align=True) # default False = packed, no padding (like __attribute__((packed)))
72
+ @firstclass(frozen=True, slots=True) # any dataclass option
73
+ ```
74
+
75
+ `align=True` inserts natural-alignment padding like a normal C struct (assumes the x86-64 style
76
+ rule: alignment equals size). Padding bytes are written as zero and ignored on read.
77
+ C headers describe the struct only; C compilers use native byte order, so `byteorder` is noted
78
+ in a comment.
79
+
80
+ ## Notes
81
+
82
+ - Fields with plain types (`str`, `int`) work for JSON/XML only; `to_bytes()` raises
83
+ `TypeError` naming the field.
84
+ - Use `from __future__ import annotations` freely; annotations are resolved lazily. Classes
85
+ defined inside functions that refer to local names are not resolvable.
86
+ - Don't parse untrusted XML with `from_xml` without extra care (stdlib `xml.etree`).
87
+ - Field names that collide with the generated methods (`to_bytes`, ...) raise `TypeError`.
88
+
89
+ ## Example
90
+
91
+ `python examples/example.py` shows bytes, JSON, XML, ctypes and the C header from one class.
92
+
93
+ ## Develop
94
+
95
+ ```
96
+ python -m unittest discover -s tests
97
+ ```
@@ -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
+ ]
@@ -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")