bytestruct 0.1.0__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.
- bytestruct/__init__.py +1088 -0
- bytestruct/_version.py +24 -0
- bytestruct/exceptions.py +76 -0
- bytestruct/py.typed +0 -0
- bytestruct-0.1.0.dist-info/METADATA +127 -0
- bytestruct-0.1.0.dist-info/RECORD +9 -0
- bytestruct-0.1.0.dist-info/WHEEL +5 -0
- bytestruct-0.1.0.dist-info/licenses/LICENSE +21 -0
- bytestruct-0.1.0.dist-info/top_level.txt +1 -0
bytestruct/__init__.py
ADDED
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
"""Declarative binary records and composable codecs, on the standard library alone.
|
|
2
|
+
|
|
3
|
+
Fixed-size records are declared as dataclasses deriving from :class:`BinaryStruct`, with each field's wire
|
|
4
|
+
type given by its annotation::
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Header(BinaryStruct, byte_order="<"):
|
|
8
|
+
magic: Annotated[u32, Const(0x1F3D5B79)] = field(default=0x1F3D5B79, init=False)
|
|
9
|
+
size: u32
|
|
10
|
+
name: Annotated[str, PaddedString(16)]
|
|
11
|
+
kind: Annotated[SomeIntEnum, u16] # parsed into the enum type
|
|
12
|
+
|
|
13
|
+
header = Header.parse(data) # short input -> TruncatedDataError, wrong magic -> UnexpectedConstantError
|
|
14
|
+
data = header.build() # an unencodable value or a wrong constant -> BinaryBuildError
|
|
15
|
+
|
|
16
|
+
The layout is compiled into ``struct.Struct`` objects once, when the class is defined, so a parse is one
|
|
17
|
+
``unpack_from`` per byte-order run. Wire fields are positional ``__init__`` parameters in wire order; a field
|
|
18
|
+
declared ``= field(default=..., init=False)`` is left out of ``__init__``: parse skips its bytes (padding) and
|
|
19
|
+
build packs its default (on a ``Const`` field, the constant). Fields without a wire type may follow the wire
|
|
20
|
+
fields and are ignored by parse/build. Records cannot be subclassed.
|
|
21
|
+
|
|
22
|
+
Variable-length values are described by codecs, which compose like construct's::
|
|
23
|
+
|
|
24
|
+
STRING = Aligned(4, Prefixed(U32_LE, CSTRING)) # u32 length, NUL-terminated UTF-8, padded to 4 bytes
|
|
25
|
+
OBJECT = Switch(U32_LE, {1: STRING, 2: U64_LE}) # parses to (tag, value)
|
|
26
|
+
|
|
27
|
+
A codec is anything with ``read(reader)`` and ``build(value)``: the :class:`Codec` combinators, the scalars
|
|
28
|
+
(``U32_LE``, ...), ``Bytes(n)``, and record classes themselves. A record field annotated with a codec
|
|
29
|
+
(``name: Annotated[str, CSTRING]``) makes the record variable-length; such fields follow the fixed-size ones.
|
|
30
|
+
Formats with no declarative shape are read by hand with :class:`ByteReader`.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import dataclasses
|
|
34
|
+
import enum
|
|
35
|
+
import ipaddress
|
|
36
|
+
import itertools
|
|
37
|
+
import operator
|
|
38
|
+
import struct
|
|
39
|
+
import sys
|
|
40
|
+
import uuid
|
|
41
|
+
from collections.abc import Mapping
|
|
42
|
+
from typing import (
|
|
43
|
+
Annotated,
|
|
44
|
+
Any,
|
|
45
|
+
Callable,
|
|
46
|
+
ClassVar,
|
|
47
|
+
Generic,
|
|
48
|
+
NoReturn,
|
|
49
|
+
Optional,
|
|
50
|
+
Protocol,
|
|
51
|
+
TypeVar,
|
|
52
|
+
Union,
|
|
53
|
+
cast,
|
|
54
|
+
get_args,
|
|
55
|
+
get_origin,
|
|
56
|
+
get_type_hints,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if sys.version_info >= (3, 11):
|
|
60
|
+
from typing import Self
|
|
61
|
+
else: # only installed below 3.11, where pyright still finds its bundled stub
|
|
62
|
+
from typing_extensions import Self # pyright: ignore[reportMissingModuleSource]
|
|
63
|
+
|
|
64
|
+
from bytestruct.exceptions import (
|
|
65
|
+
BinaryBuildError,
|
|
66
|
+
BinaryDataError,
|
|
67
|
+
BinaryParseError,
|
|
68
|
+
TruncatedDataError,
|
|
69
|
+
UnexpectedConstantError,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
BytesLike = Union[bytes, bytearray, memoryview]
|
|
73
|
+
"""Any buffer the parsers accept: ``bytes``, ``bytearray`` or ``memoryview``."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ByteReader:
|
|
77
|
+
"""Sequential reader over the window ``data[offset:end]`` of an in-memory buffer; every ``read*``/``unpack*``
|
|
78
|
+
returns exactly what was asked for or raises :class:`TruncatedDataError`.
|
|
79
|
+
|
|
80
|
+
Readers made by :meth:`sub` share ``data`` (nothing is copied) and have a narrower ``end``. ``base`` is the
|
|
81
|
+
absolute offset of ``data[0]`` in the outermost buffer, so errors report absolute offsets.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
__slots__ = ("base", "data", "end", "offset")
|
|
85
|
+
|
|
86
|
+
def __init__(self, data: BytesLike, offset: int = 0, base: int = 0) -> None:
|
|
87
|
+
self.data = data if type(data) is bytes else bytes(data)
|
|
88
|
+
self.base = base
|
|
89
|
+
self.end = len(self.data)
|
|
90
|
+
if not 0 <= offset <= self.end: # offsets often come from the wire
|
|
91
|
+
raise BinaryParseError(f"invalid offset {offset} in {self.end} bytes", offset=base)
|
|
92
|
+
self.offset = offset
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def remaining(self) -> int:
|
|
96
|
+
"""The number of bytes left before ``end``."""
|
|
97
|
+
return self.end - self.offset
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def absolute(self) -> int:
|
|
101
|
+
"""The current position as an absolute offset in the outermost buffer."""
|
|
102
|
+
return self.base + self.offset
|
|
103
|
+
|
|
104
|
+
def _invalid_size(self, size: int) -> BinaryParseError:
|
|
105
|
+
if size < 0: # a length computed from wire data
|
|
106
|
+
return BinaryParseError(f"invalid size {size}", offset=self.absolute)
|
|
107
|
+
return TruncatedDataError(f"expected {size} bytes, found {self.remaining}", offset=self.absolute)
|
|
108
|
+
|
|
109
|
+
def read(self, size: int) -> bytes:
|
|
110
|
+
"""Consume and return the next ``size`` bytes."""
|
|
111
|
+
offset = self.offset
|
|
112
|
+
if not 0 <= size <= self.end - offset:
|
|
113
|
+
raise self._invalid_size(size)
|
|
114
|
+
self.offset = offset + size
|
|
115
|
+
return self.data[offset : offset + size]
|
|
116
|
+
|
|
117
|
+
def skip(self, size: int) -> None:
|
|
118
|
+
"""Consume the next ``size`` bytes without returning them."""
|
|
119
|
+
if not 0 <= size <= self.end - self.offset:
|
|
120
|
+
raise self._invalid_size(size)
|
|
121
|
+
self.offset += size
|
|
122
|
+
|
|
123
|
+
def sub(self, size: int) -> "ByteReader":
|
|
124
|
+
"""Consume the next ``size`` bytes and return a reader confined to them."""
|
|
125
|
+
start = self.offset
|
|
126
|
+
self.skip(size)
|
|
127
|
+
reader = ByteReader.__new__(ByteReader)
|
|
128
|
+
reader.data, reader.base, reader.offset, reader.end = self.data, self.base, start, start + size
|
|
129
|
+
return reader
|
|
130
|
+
|
|
131
|
+
def read_all(self) -> bytes:
|
|
132
|
+
"""Consume and return every remaining byte, up to ``end``."""
|
|
133
|
+
return self.read(self.end - self.offset)
|
|
134
|
+
|
|
135
|
+
def unpack(self, fmt: struct.Struct) -> tuple[Any, ...]:
|
|
136
|
+
"""Consume ``fmt.size`` bytes and return all the values ``fmt`` unpacks from them."""
|
|
137
|
+
offset = self.offset
|
|
138
|
+
if fmt.size > self.end - offset:
|
|
139
|
+
raise self._invalid_size(fmt.size)
|
|
140
|
+
self.offset = offset + fmt.size
|
|
141
|
+
return fmt.unpack_from(self.data, offset)
|
|
142
|
+
|
|
143
|
+
def unpack_one(self, fmt: struct.Struct) -> Any:
|
|
144
|
+
"""Consume ``fmt.size`` bytes and return the single value ``fmt`` unpacks from them."""
|
|
145
|
+
offset = self.offset
|
|
146
|
+
if fmt.size > self.end - offset:
|
|
147
|
+
raise self._invalid_size(fmt.size)
|
|
148
|
+
self.offset = offset + fmt.size
|
|
149
|
+
return fmt.unpack_from(self.data, offset)[0]
|
|
150
|
+
|
|
151
|
+
def read_cstring(self) -> bytes:
|
|
152
|
+
"""Read up to (and consume) a NUL terminator; the terminator is not returned."""
|
|
153
|
+
end = self.data.find(b"\0", self.offset, self.end)
|
|
154
|
+
if end == -1:
|
|
155
|
+
raise TruncatedDataError("unterminated C string", offset=self.absolute)
|
|
156
|
+
result = self.data[self.offset : end]
|
|
157
|
+
self.offset = end + 1
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
T = TypeVar("T")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Codec(Protocol[T]):
|
|
165
|
+
"""Parses one wire value from a :class:`ByteReader` (``read``) and encodes one (``build``).
|
|
166
|
+
|
|
167
|
+
Anything with these two methods is a codec: the combinators below, the scalars (``U32_LE``, ...), ``Bytes(n)``,
|
|
168
|
+
and :class:`BinaryStruct` record classes. Errors raised inside a nested value record the enclosing cases,
|
|
169
|
+
elements and fields in :attr:`BinaryDataError.path`.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
# Attributes rather than methods, so that a record class (whose ``build`` is an instance method, called as
|
|
173
|
+
# ``Record.build(record)``) is a codec too.
|
|
174
|
+
@property
|
|
175
|
+
def read(self) -> Callable[[ByteReader], T]: ...
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def build(self) -> Callable[[T], bytes]: ...
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CodecBase(Generic[T]):
|
|
182
|
+
"""Base of the combinators: a codec with a ``parse`` shortcut."""
|
|
183
|
+
|
|
184
|
+
def read(self, reader: ByteReader) -> T:
|
|
185
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
186
|
+
raise NotImplementedError
|
|
187
|
+
|
|
188
|
+
def build(self, value: T) -> bytes:
|
|
189
|
+
"""Encode ``value`` (raising :class:`BinaryBuildError` when it cannot be encoded)."""
|
|
190
|
+
raise NotImplementedError
|
|
191
|
+
|
|
192
|
+
def parse(self, data: BytesLike) -> T:
|
|
193
|
+
"""Parse the value from the start of ``data``; trailing bytes are ignored."""
|
|
194
|
+
return self.read(ByteReader(data))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Scalar(struct.Struct):
|
|
198
|
+
"""A ``struct.Struct`` holding a single value, usable as a codec and with ``ByteReader.unpack_one``."""
|
|
199
|
+
|
|
200
|
+
def read(self, reader: ByteReader) -> Any:
|
|
201
|
+
"""Parse the value at the reader's offset and advance past it."""
|
|
202
|
+
return reader.unpack_one(self)
|
|
203
|
+
|
|
204
|
+
def parse(self, data: BytesLike) -> Any:
|
|
205
|
+
"""Parse the value from the start of ``data``; trailing bytes are ignored."""
|
|
206
|
+
return self.read(ByteReader(data))
|
|
207
|
+
|
|
208
|
+
def build(self, value: Any) -> bytes:
|
|
209
|
+
"""Pack ``value``; out-of-range or wrongly-typed values raise :class:`BinaryBuildError`."""
|
|
210
|
+
try:
|
|
211
|
+
return self.pack(value)
|
|
212
|
+
except (struct.error, OverflowError) as e:
|
|
213
|
+
raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
U8 = Scalar("B")
|
|
217
|
+
"""An unsigned 8-bit integer."""
|
|
218
|
+
U16_LE = Scalar("<H")
|
|
219
|
+
"""A little-endian unsigned 16-bit integer."""
|
|
220
|
+
U16_BE = Scalar(">H")
|
|
221
|
+
"""A big-endian unsigned 16-bit integer."""
|
|
222
|
+
U32_LE = Scalar("<I")
|
|
223
|
+
"""A little-endian unsigned 32-bit integer."""
|
|
224
|
+
U32_BE = Scalar(">I")
|
|
225
|
+
"""A big-endian unsigned 32-bit integer."""
|
|
226
|
+
U64_LE = Scalar("<Q")
|
|
227
|
+
"""A little-endian unsigned 64-bit integer."""
|
|
228
|
+
U64_BE = Scalar(">Q")
|
|
229
|
+
"""A big-endian unsigned 64-bit integer."""
|
|
230
|
+
I16_LE = Scalar("<h")
|
|
231
|
+
"""A little-endian signed 16-bit integer."""
|
|
232
|
+
I16_BE = Scalar(">h")
|
|
233
|
+
"""A big-endian signed 16-bit integer."""
|
|
234
|
+
I32_LE = Scalar("<i")
|
|
235
|
+
"""A little-endian signed 32-bit integer."""
|
|
236
|
+
I32_BE = Scalar(">i")
|
|
237
|
+
"""A big-endian signed 32-bit integer."""
|
|
238
|
+
I64_LE = Scalar("<q")
|
|
239
|
+
"""A little-endian signed 64-bit integer."""
|
|
240
|
+
I64_BE = Scalar(">q")
|
|
241
|
+
"""A big-endian signed 64-bit integer."""
|
|
242
|
+
F32_LE = Scalar("<f")
|
|
243
|
+
"""A little-endian IEEE 754 single."""
|
|
244
|
+
F32_BE = Scalar(">f")
|
|
245
|
+
"""A big-endian IEEE 754 single."""
|
|
246
|
+
F64_LE = Scalar("<d")
|
|
247
|
+
"""A little-endian IEEE 754 double."""
|
|
248
|
+
F64_BE = Scalar(">d")
|
|
249
|
+
"""A big-endian IEEE 754 double."""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class Pass(CodecBase[None]):
|
|
253
|
+
"""No bytes; parses to ``None``."""
|
|
254
|
+
|
|
255
|
+
def read(self, reader: ByteReader) -> None:
|
|
256
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
def build(self, value: None) -> bytes:
|
|
260
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
261
|
+
return b""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class GreedyBytes(CodecBase[bytes]):
|
|
265
|
+
"""Every remaining byte (of the enclosing :class:`Prefixed`, or of the buffer)."""
|
|
266
|
+
|
|
267
|
+
def read(self, reader: ByteReader) -> bytes:
|
|
268
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
269
|
+
return reader.read_all()
|
|
270
|
+
|
|
271
|
+
def build(self, value: BytesLike) -> bytes:
|
|
272
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
273
|
+
return bytes(value)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class CString(CodecBase[str]):
|
|
277
|
+
"""A NUL-terminated UTF-8 string. Building one with an embedded NUL (the peer would stop there) is an error.
|
|
278
|
+
|
|
279
|
+
``errors`` is the UTF-8 decoding error handler (e.g. ``"replace"`` for names cut mid-character).
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(self, errors: str = "strict") -> None:
|
|
283
|
+
self.errors = errors
|
|
284
|
+
|
|
285
|
+
def read(self, reader: ByteReader) -> str:
|
|
286
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
287
|
+
start = reader.absolute
|
|
288
|
+
raw = reader.read_cstring()
|
|
289
|
+
try:
|
|
290
|
+
return raw.decode("utf-8", self.errors)
|
|
291
|
+
except UnicodeDecodeError as e:
|
|
292
|
+
raise BinaryParseError(f"invalid UTF-8 in C string: {e}", offset=start) from e
|
|
293
|
+
|
|
294
|
+
def build(self, value: str) -> bytes:
|
|
295
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
296
|
+
if "\0" in value:
|
|
297
|
+
raise BinaryBuildError(f"embedded NUL in {value!r}")
|
|
298
|
+
try:
|
|
299
|
+
return value.encode("utf-8") + b"\0"
|
|
300
|
+
except UnicodeEncodeError as e:
|
|
301
|
+
raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class GreedyString(CodecBase[str]):
|
|
305
|
+
"""Every remaining byte (of the enclosing :class:`Prefixed`, or of the buffer), as a UTF-8 string.
|
|
306
|
+
|
|
307
|
+
``Prefixed(U16_BE, GREEDY_STRING)`` is a length-prefixed string (construct's ``PascalString``). ``errors``
|
|
308
|
+
is the UTF-8 decoding error handler (e.g. ``"replace"``).
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def __init__(self, errors: str = "strict") -> None:
|
|
312
|
+
self.errors = errors
|
|
313
|
+
|
|
314
|
+
def read(self, reader: ByteReader) -> str:
|
|
315
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
316
|
+
start = reader.absolute
|
|
317
|
+
try:
|
|
318
|
+
return reader.read_all().decode("utf-8", self.errors)
|
|
319
|
+
except UnicodeDecodeError as e:
|
|
320
|
+
raise BinaryParseError(f"invalid UTF-8 in string: {e}", offset=start) from e
|
|
321
|
+
|
|
322
|
+
def build(self, value: str) -> bytes:
|
|
323
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
324
|
+
try:
|
|
325
|
+
return value.encode("utf-8")
|
|
326
|
+
except UnicodeEncodeError as e:
|
|
327
|
+
raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
PASS = Pass()
|
|
331
|
+
"""The :class:`Pass` codec."""
|
|
332
|
+
GREEDY_BYTES = GreedyBytes()
|
|
333
|
+
"""The :class:`GreedyBytes` codec."""
|
|
334
|
+
GREEDY_STRING = GreedyString()
|
|
335
|
+
"""A strict :class:`GreedyString` codec."""
|
|
336
|
+
CSTRING = CString()
|
|
337
|
+
"""A strict :class:`CString` codec."""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class Prefixed(CodecBase[T]):
|
|
341
|
+
"""``inner``, confined to the byte count that ``length`` precedes it with."""
|
|
342
|
+
|
|
343
|
+
def __init__(self, length: Scalar, inner: Codec[T]) -> None:
|
|
344
|
+
self.length = length
|
|
345
|
+
self.inner = inner
|
|
346
|
+
|
|
347
|
+
def read(self, reader: ByteReader) -> T:
|
|
348
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
349
|
+
size = reader.unpack_one(self.length)
|
|
350
|
+
end = reader.end
|
|
351
|
+
stop = reader.offset + size
|
|
352
|
+
if not reader.offset <= stop <= end:
|
|
353
|
+
raise reader._invalid_size(size)
|
|
354
|
+
reader.end = stop # cheaper than a sub-reader
|
|
355
|
+
try:
|
|
356
|
+
return self.inner.read(reader)
|
|
357
|
+
finally:
|
|
358
|
+
reader.end = end
|
|
359
|
+
reader.offset = stop
|
|
360
|
+
|
|
361
|
+
def build(self, value: T) -> bytes:
|
|
362
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
363
|
+
body = self.inner.build(value)
|
|
364
|
+
try:
|
|
365
|
+
return self.length.pack(len(body)) + body
|
|
366
|
+
except struct.error as e:
|
|
367
|
+
raise BinaryBuildError(f"cannot encode a length of {len(body)}: {e}") from e
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class Aligned(CodecBase[T]):
|
|
371
|
+
"""``inner``, followed by NUL padding to a multiple of ``modulus`` bytes counted from where it starts."""
|
|
372
|
+
|
|
373
|
+
def __init__(self, modulus: int, inner: Codec[T]) -> None:
|
|
374
|
+
self.modulus = modulus
|
|
375
|
+
self.inner = inner
|
|
376
|
+
self._padding = [bytes(size) for size in range(modulus)]
|
|
377
|
+
|
|
378
|
+
def read(self, reader: ByteReader) -> T:
|
|
379
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
380
|
+
start = reader.offset
|
|
381
|
+
value = self.inner.read(reader)
|
|
382
|
+
reader.skip(-(reader.offset - start) % self.modulus)
|
|
383
|
+
return value
|
|
384
|
+
|
|
385
|
+
def build(self, value: T) -> bytes:
|
|
386
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
387
|
+
data = self.inner.build(value)
|
|
388
|
+
return data + self._padding[-len(data) % self.modulus]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class PrefixedArray(CodecBase[list[T]]):
|
|
392
|
+
"""An element count in ``count``, followed by that many ``element``s."""
|
|
393
|
+
|
|
394
|
+
def __init__(self, count: Scalar, element: Codec[T]) -> None:
|
|
395
|
+
self.count = count
|
|
396
|
+
self.element = element
|
|
397
|
+
|
|
398
|
+
def read(self, reader: ByteReader) -> list[T]:
|
|
399
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
400
|
+
count = reader.unpack_one(self.count)
|
|
401
|
+
read = self.element.read
|
|
402
|
+
result: list[T] = []
|
|
403
|
+
try:
|
|
404
|
+
for _ in range(count):
|
|
405
|
+
result.append(read(reader))
|
|
406
|
+
except BinaryDataError as e:
|
|
407
|
+
e.within(f"[{len(result)}]")
|
|
408
|
+
raise
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
def build(self, value: list[T]) -> bytes:
|
|
412
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
413
|
+
build = self.element.build
|
|
414
|
+
parts = [self.count.build(len(value))]
|
|
415
|
+
for index, element in enumerate(value):
|
|
416
|
+
try:
|
|
417
|
+
parts.append(build(element))
|
|
418
|
+
except BinaryDataError as e:
|
|
419
|
+
e.within(f"[{index}]")
|
|
420
|
+
raise
|
|
421
|
+
return b"".join(parts)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class Tuple(CodecBase[tuple[Any, ...]]):
|
|
425
|
+
"""Consecutive values of different codecs, parsed into a tuple."""
|
|
426
|
+
|
|
427
|
+
def __init__(self, *codecs: Codec[Any]) -> None:
|
|
428
|
+
self.codecs = codecs
|
|
429
|
+
|
|
430
|
+
def read(self, reader: ByteReader) -> tuple[Any, ...]:
|
|
431
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
432
|
+
return tuple([codec.read(reader) for codec in self.codecs])
|
|
433
|
+
|
|
434
|
+
def build(self, value: tuple[Any, ...]) -> bytes:
|
|
435
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
436
|
+
codecs = self.codecs
|
|
437
|
+
if len(value) != len(codecs):
|
|
438
|
+
raise BinaryBuildError(f"expected {len(codecs)} values, got {len(value)}")
|
|
439
|
+
if len(codecs) == 2: # the common (key, value) pair, without a join
|
|
440
|
+
return codecs[0].build(value[0]) + codecs[1].build(value[1])
|
|
441
|
+
return b"".join([codec.build(item) for codec, item in zip(codecs, value)])
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class Switch(CodecBase[tuple[Any, Any]]):
|
|
445
|
+
"""A ``tag``, followed by a value in the codec it selects from ``cases``; parses to ``(tag, value)``.
|
|
446
|
+
|
|
447
|
+
A tag without a case uses ``default``, or is an error when there is none.
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
def __init__(self, tag: Scalar, cases: Mapping[Any, Codec[Any]], *, default: Optional[Codec[Any]] = None) -> None:
|
|
451
|
+
self.tag = tag
|
|
452
|
+
self.cases = dict(cases)
|
|
453
|
+
self.default = default
|
|
454
|
+
self._names = {key: getattr(key, "name", None) or _show(key) for key in self.cases}
|
|
455
|
+
|
|
456
|
+
def read(self, reader: ByteReader) -> tuple[Any, Any]:
|
|
457
|
+
"""Parse one value at the reader's offset and advance past it."""
|
|
458
|
+
start = reader.absolute
|
|
459
|
+
tag = reader.unpack_one(self.tag)
|
|
460
|
+
case = self.cases.get(tag, self.default)
|
|
461
|
+
if case is None:
|
|
462
|
+
raise BinaryParseError(f"unknown tag {_show(tag)}", offset=start)
|
|
463
|
+
try:
|
|
464
|
+
return tag, case.read(reader)
|
|
465
|
+
except BinaryDataError as e:
|
|
466
|
+
e.within(self._names.get(tag) or _show(tag))
|
|
467
|
+
raise
|
|
468
|
+
|
|
469
|
+
def build(self, value: tuple[Any, Any]) -> bytes:
|
|
470
|
+
"""Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
|
|
471
|
+
tag, item = value
|
|
472
|
+
case = self.cases.get(tag, self.default)
|
|
473
|
+
if case is None:
|
|
474
|
+
raise BinaryBuildError(f"unknown tag {_show(tag)}")
|
|
475
|
+
head = self.tag.build(tag)
|
|
476
|
+
try:
|
|
477
|
+
return head + case.build(item)
|
|
478
|
+
except BinaryDataError as e:
|
|
479
|
+
e.within(self._names.get(tag) or _show(tag))
|
|
480
|
+
raise
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class OpenIntEnum(enum.IntEnum):
|
|
484
|
+
"""``IntEnum`` that maps unknown values to cached pseudo-members instead of raising, so a value from a newer
|
|
485
|
+
peer (an unknown opcode, status, level, ...) still parses. Declare at least one member."""
|
|
486
|
+
|
|
487
|
+
@classmethod
|
|
488
|
+
def _missing_(cls, value: object) -> Optional["OpenIntEnum"]:
|
|
489
|
+
if not isinstance(value, int):
|
|
490
|
+
return None
|
|
491
|
+
member = int.__new__(cls, value)
|
|
492
|
+
member._name_ = str(value)
|
|
493
|
+
member._value_ = value
|
|
494
|
+
# Registered so the next ``cls(value)`` returns the same object.
|
|
495
|
+
return cast(OpenIntEnum, cls._value2member_map_.setdefault(value, member))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class Format:
|
|
499
|
+
"""Field annotation metadata: a single-value ``struct`` code, optionally pinned to a byte order.
|
|
500
|
+
|
|
501
|
+
``Annotated[int, Format("H")]`` is how ``u16`` is defined. Subclasses define custom fixed-size field types by
|
|
502
|
+
overriding ``decode`` (unpacked value -> field value, raising :class:`BinaryParseError`) and/or ``encode``
|
|
503
|
+
(the reverse, raising :class:`BinaryBuildError`); only overridden ones are called.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
def __init__(self, code: str, byte_order: Optional[str] = None) -> None:
|
|
507
|
+
self.code = code
|
|
508
|
+
self.byte_order = byte_order
|
|
509
|
+
|
|
510
|
+
def decode(self, value: Any) -> Any:
|
|
511
|
+
"""Convert the unpacked wire value to the field value."""
|
|
512
|
+
return value
|
|
513
|
+
|
|
514
|
+
def encode(self, value: Any) -> Any:
|
|
515
|
+
"""Convert the field value to the value to pack."""
|
|
516
|
+
return value
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class Bytes(Format):
|
|
520
|
+
"""A fixed-size raw byte field; building it from a value of any other length is an error."""
|
|
521
|
+
|
|
522
|
+
def __init__(self, size: int) -> None:
|
|
523
|
+
super().__init__(f"{size}s")
|
|
524
|
+
self.size = size
|
|
525
|
+
|
|
526
|
+
def encode(self, value: Any) -> Any:
|
|
527
|
+
"""Convert the field value to the wire bytes, raising :class:`BinaryBuildError` when it does not fit."""
|
|
528
|
+
if len(value) != self.size:
|
|
529
|
+
raise BinaryBuildError(f"expected {self.size} bytes, got {len(value)}")
|
|
530
|
+
return value
|
|
531
|
+
|
|
532
|
+
# As a codec, outside of fixed-size records.
|
|
533
|
+
def read(self, reader: ByteReader) -> bytes:
|
|
534
|
+
"""Read the next ``size`` bytes (as a codec, outside of fixed-size records)."""
|
|
535
|
+
return reader.read(self.size)
|
|
536
|
+
|
|
537
|
+
def build(self, value: BytesLike) -> bytes:
|
|
538
|
+
"""Encode ``value``, which must be exactly ``size`` bytes long (as a codec)."""
|
|
539
|
+
return bytes(self.encode(value))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class PaddedString(Format):
|
|
543
|
+
"""A fixed-size field holding a NUL-padded UTF-8 string of at most ``size`` bytes.
|
|
544
|
+
|
|
545
|
+
A string that fills the field has no terminator; a shorter one ends at its first NUL, and the bytes after it
|
|
546
|
+
are ignored. ``errors`` is the UTF-8 decoding error handler (e.g. ``"replace"`` for names cut mid-character).
|
|
547
|
+
Building NUL-pads the encoded string to the field size.
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
def __init__(self, size: int, errors: str = "strict") -> None:
|
|
551
|
+
super().__init__(f"{size}s")
|
|
552
|
+
self.size = size
|
|
553
|
+
self.errors = errors
|
|
554
|
+
|
|
555
|
+
def decode(self, value: Any) -> str:
|
|
556
|
+
"""Decode the bytes before the first NUL (the whole field when there is none)."""
|
|
557
|
+
end = value.find(b"\0")
|
|
558
|
+
try:
|
|
559
|
+
return (value if end == -1 else value[:end]).decode("utf-8", self.errors)
|
|
560
|
+
except UnicodeDecodeError as e:
|
|
561
|
+
raise BinaryParseError(f"invalid UTF-8 in string field: {e}") from e
|
|
562
|
+
|
|
563
|
+
def encode(self, value: Any) -> bytes:
|
|
564
|
+
"""Encode the string, raising :class:`BinaryBuildError` when it cannot be encoded, does not fit or contains a
|
|
565
|
+
NUL byte."""
|
|
566
|
+
try:
|
|
567
|
+
encoded = value.encode("utf-8")
|
|
568
|
+
except UnicodeEncodeError as e:
|
|
569
|
+
raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e
|
|
570
|
+
if b"\0" in encoded: # parse would stop there
|
|
571
|
+
raise BinaryBuildError("string contains a NUL byte")
|
|
572
|
+
if len(encoded) > self.size:
|
|
573
|
+
raise BinaryBuildError(f"string of {len(encoded)} bytes does not fit its {self.size}-byte field")
|
|
574
|
+
return encoded
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class Uuid(Format):
|
|
578
|
+
"""A 16-byte UUID field, parsed into a :class:`uuid.UUID`."""
|
|
579
|
+
|
|
580
|
+
def __init__(self) -> None:
|
|
581
|
+
super().__init__("16s")
|
|
582
|
+
|
|
583
|
+
def decode(self, value: Any) -> uuid.UUID:
|
|
584
|
+
"""Parse the 16 wire bytes into a :class:`uuid.UUID`."""
|
|
585
|
+
return uuid.UUID(bytes=value)
|
|
586
|
+
|
|
587
|
+
def encode(self, value: Any) -> bytes:
|
|
588
|
+
"""Return the UUID's 16 bytes."""
|
|
589
|
+
return value.bytes
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
class IPAddress(Format):
|
|
593
|
+
"""A packed IPv4 (4-byte) or IPv6 (16-byte) address field, parsed into an :mod:`ipaddress` object."""
|
|
594
|
+
|
|
595
|
+
def __init__(self, version: int) -> None:
|
|
596
|
+
self.address_type = ipaddress.IPv4Address if version == 4 else ipaddress.IPv6Address
|
|
597
|
+
super().__init__("4s" if version == 4 else "16s")
|
|
598
|
+
|
|
599
|
+
def decode(self, value: Any) -> Union[ipaddress.IPv4Address, ipaddress.IPv6Address]:
|
|
600
|
+
"""Parse the packed wire bytes into an :mod:`ipaddress` address."""
|
|
601
|
+
return self.address_type(value)
|
|
602
|
+
|
|
603
|
+
def encode(self, value: Any) -> bytes:
|
|
604
|
+
"""Return the address's packed bytes."""
|
|
605
|
+
return value.packed
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class Array(Format):
|
|
609
|
+
"""A fixed-length array of a plain number type, parsed into a list:
|
|
610
|
+
``Annotated[list[int], Array(u64, 6)]``. Elements use the record's byte order unless their alias pins one."""
|
|
611
|
+
|
|
612
|
+
def __init__(self, element: Any, count: int, byte_order: str = "<") -> None:
|
|
613
|
+
fmt: Format = get_args(element)[1]
|
|
614
|
+
self.element = element
|
|
615
|
+
self.count = count
|
|
616
|
+
self.pinned = fmt.byte_order is not None
|
|
617
|
+
self.struct = struct.Struct(f"{fmt.byte_order or byte_order}{count}{fmt.code}")
|
|
618
|
+
super().__init__(f"{self.struct.size}s")
|
|
619
|
+
|
|
620
|
+
def in_record(self, byte_order: str) -> "Array":
|
|
621
|
+
"""This array with unpinned elements in the enclosing record's ``byte_order``."""
|
|
622
|
+
return self if self.pinned else Array(self.element, self.count, byte_order)
|
|
623
|
+
|
|
624
|
+
def decode(self, value: Any) -> list[Any]:
|
|
625
|
+
"""Unpack the wire bytes into a list of ``count`` numbers."""
|
|
626
|
+
return list(self.struct.unpack(value))
|
|
627
|
+
|
|
628
|
+
def encode(self, value: Any) -> bytes:
|
|
629
|
+
"""Convert the field value to the wire bytes, raising :class:`BinaryBuildError` when it does not fit."""
|
|
630
|
+
if len(value) != self.count:
|
|
631
|
+
raise BinaryBuildError(f"expected {self.count} elements, got {len(value)}")
|
|
632
|
+
try:
|
|
633
|
+
return self.struct.pack(*value)
|
|
634
|
+
except (struct.error, OverflowError) as e:
|
|
635
|
+
raise BinaryBuildError(f"cannot encode: {e}") from e
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class Const:
|
|
639
|
+
"""Field annotation metadata: the field must always hold ``value``.
|
|
640
|
+
|
|
641
|
+
A wrong value raises :class:`UnexpectedConstantError` on parse and :class:`BinaryBuildError` on build. Only for
|
|
642
|
+
fields whose value is not converted (numbers, ``Bytes``, int enums): parse compares the raw unpacked value.
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
def __init__(self, value: Any) -> None:
|
|
646
|
+
self.value = value
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# Field types: ``Annotated[<what type checkers see>, <wire format>]``. The byte order comes from the record unless
|
|
650
|
+
# the alias pins one.
|
|
651
|
+
u8 = Annotated[int, Format("B")]
|
|
652
|
+
"""An unsigned 8-bit integer field."""
|
|
653
|
+
u16 = Annotated[int, Format("H")]
|
|
654
|
+
"""An unsigned 16-bit integer field, in the record's byte order."""
|
|
655
|
+
u32 = Annotated[int, Format("I")]
|
|
656
|
+
"""An unsigned 32-bit integer field, in the record's byte order."""
|
|
657
|
+
u64 = Annotated[int, Format("Q")]
|
|
658
|
+
"""An unsigned 64-bit integer field, in the record's byte order."""
|
|
659
|
+
i16 = Annotated[int, Format("h")]
|
|
660
|
+
"""A signed 16-bit integer field, in the record's byte order."""
|
|
661
|
+
i32 = Annotated[int, Format("i")]
|
|
662
|
+
"""A signed 32-bit integer field, in the record's byte order."""
|
|
663
|
+
i64 = Annotated[int, Format("q")]
|
|
664
|
+
"""A signed 64-bit integer field, in the record's byte order."""
|
|
665
|
+
u32le = Annotated[int, Format("I", "<")]
|
|
666
|
+
"""An unsigned 32-bit integer field, always little-endian."""
|
|
667
|
+
u32be = Annotated[int, Format("I", ">")]
|
|
668
|
+
"""An unsigned 32-bit integer field, always big-endian."""
|
|
669
|
+
i8 = Annotated[int, Format("b")]
|
|
670
|
+
"""A signed 8-bit integer field."""
|
|
671
|
+
f32 = Annotated[float, Format("f")]
|
|
672
|
+
"""An IEEE 754 single-precision float field, in the record's byte order."""
|
|
673
|
+
f64 = Annotated[float, Format("d")]
|
|
674
|
+
"""An IEEE 754 double-precision float field, in the record's byte order."""
|
|
675
|
+
u16le = Annotated[int, Format("H", "<")]
|
|
676
|
+
"""An unsigned 16-bit integer field, always little-endian."""
|
|
677
|
+
u16be = Annotated[int, Format("H", ">")]
|
|
678
|
+
"""An unsigned 16-bit integer field, always big-endian."""
|
|
679
|
+
u64le = Annotated[int, Format("Q", "<")]
|
|
680
|
+
"""An unsigned 64-bit integer field, always little-endian."""
|
|
681
|
+
u64be = Annotated[int, Format("Q", ">")]
|
|
682
|
+
"""An unsigned 64-bit integer field, always big-endian."""
|
|
683
|
+
i16le = Annotated[int, Format("h", "<")]
|
|
684
|
+
"""A signed 16-bit integer field, always little-endian."""
|
|
685
|
+
i16be = Annotated[int, Format("h", ">")]
|
|
686
|
+
"""A signed 16-bit integer field, always big-endian."""
|
|
687
|
+
i32le = Annotated[int, Format("i", "<")]
|
|
688
|
+
"""A signed 32-bit integer field, always little-endian."""
|
|
689
|
+
i32be = Annotated[int, Format("i", ">")]
|
|
690
|
+
"""A signed 32-bit integer field, always big-endian."""
|
|
691
|
+
i64le = Annotated[int, Format("q", "<")]
|
|
692
|
+
"""A signed 64-bit integer field, always little-endian."""
|
|
693
|
+
i64be = Annotated[int, Format("q", ">")]
|
|
694
|
+
"""A signed 64-bit integer field, always big-endian."""
|
|
695
|
+
f32le = Annotated[float, Format("f", "<")]
|
|
696
|
+
"""An IEEE 754 single-precision float field, always little-endian."""
|
|
697
|
+
f32be = Annotated[float, Format("f", ">")]
|
|
698
|
+
"""An IEEE 754 single-precision float field, always big-endian."""
|
|
699
|
+
f64le = Annotated[float, Format("d", "<")]
|
|
700
|
+
"""An IEEE 754 double-precision float field, always little-endian."""
|
|
701
|
+
f64be = Annotated[float, Format("d", ">")]
|
|
702
|
+
"""An IEEE 754 double-precision float field, always big-endian."""
|
|
703
|
+
uuid_t = Annotated[uuid.UUID, Uuid()]
|
|
704
|
+
"""A 16-byte UUID field, parsed into a :class:`uuid.UUID`."""
|
|
705
|
+
ipv4 = Annotated[ipaddress.IPv4Address, IPAddress(4)]
|
|
706
|
+
"""A 4-byte IPv4 address field, parsed into an :class:`ipaddress.IPv4Address`."""
|
|
707
|
+
ipv6 = Annotated[ipaddress.IPv6Address, IPAddress(6)]
|
|
708
|
+
"""A 16-byte IPv6 address field, parsed into an :class:`ipaddress.IPv6Address`."""
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
class _EnumFormat(Format):
|
|
712
|
+
"""Converts a parsed integer to the field's int-valued enum (``Annotated[SomeEnum, u32]``)."""
|
|
713
|
+
|
|
714
|
+
def __init__(self, base: Format, enum_type: type[enum.Enum]) -> None:
|
|
715
|
+
super().__init__(base.code, base.byte_order)
|
|
716
|
+
self.enum_type = enum_type
|
|
717
|
+
|
|
718
|
+
def decode(self, value: Any) -> Any:
|
|
719
|
+
"""Convert the unpacked integer to the enum, raising :class:`BinaryParseError` for an unknown value."""
|
|
720
|
+
try:
|
|
721
|
+
return self.enum_type(value)
|
|
722
|
+
except ValueError as e:
|
|
723
|
+
raise BinaryParseError(str(e)) from e
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
_NO_CONST = object() # sentinel: None (or 0) can be a legitimate constant
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@dataclasses.dataclass(frozen=True)
|
|
730
|
+
class _Field:
|
|
731
|
+
index: int # position among the fixed-size fields
|
|
732
|
+
name: str
|
|
733
|
+
fmt: Format
|
|
734
|
+
struct: struct.Struct # this field alone: its size, and for locating errors
|
|
735
|
+
offset: int
|
|
736
|
+
const: Any
|
|
737
|
+
init: bool # an ``__init__`` parameter
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@dataclasses.dataclass(frozen=True)
|
|
741
|
+
class _CodecField:
|
|
742
|
+
"""A variable-length field, read and written by its codec after the fixed-size fields."""
|
|
743
|
+
|
|
744
|
+
name: str
|
|
745
|
+
codec: Codec[Any]
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@dataclasses.dataclass(frozen=True)
|
|
749
|
+
class _Run:
|
|
750
|
+
"""Consecutive fields sharing one byte order, (un)packed by a single ``struct.Struct``."""
|
|
751
|
+
|
|
752
|
+
struct: struct.Struct
|
|
753
|
+
offset: int
|
|
754
|
+
first: int # index of the run's first field
|
|
755
|
+
count: int
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _is_codec(item: Any) -> bool:
|
|
759
|
+
return hasattr(item, "read") and hasattr(item, "build")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _wire_format(hint: Any) -> tuple[Optional[Format], Any, Optional[Codec[Any]]]:
|
|
763
|
+
"""The fixed-size format, constant and codec of a field annotation (all absent for a field without a wire type).
|
|
764
|
+
|
|
765
|
+
``Annotated[u32, Const(1)]`` is flattened by Python; ``Annotated[SomeEnum, u16]`` nests the alias.
|
|
766
|
+
"""
|
|
767
|
+
if get_origin(hint) is not Annotated:
|
|
768
|
+
return None, _NO_CONST, None
|
|
769
|
+
base, *metadata = get_args(hint)
|
|
770
|
+
for item in list(metadata):
|
|
771
|
+
if get_origin(item) is Annotated:
|
|
772
|
+
metadata.extend(get_args(item)[1:])
|
|
773
|
+
fmt = next((m for m in reversed(metadata) if isinstance(m, Format)), None)
|
|
774
|
+
if fmt is not None and isinstance(base, type) and issubclass(base, enum.Enum):
|
|
775
|
+
fmt = _EnumFormat(fmt, base)
|
|
776
|
+
const = next((m.value for m in metadata if isinstance(m, Const)), _NO_CONST)
|
|
777
|
+
codec = None if fmt is not None else next((m for m in metadata if _is_codec(m)), None)
|
|
778
|
+
return fmt, const, codec
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _layout(cls: type, byte_order: str) -> tuple[list[_Field], list[_CodecField]]:
|
|
782
|
+
# 3.10+ ``__annotations__`` is per class (lazily computed on 3.14); 3.9's falls back to a base class's.
|
|
783
|
+
names = cls.__annotations__ if sys.version_info >= (3, 10) else cls.__dict__.get("__annotations__", {})
|
|
784
|
+
hints = get_type_hints(cls, include_extras=True) # keeps the Annotated metadata
|
|
785
|
+
fields: list[_Field] = []
|
|
786
|
+
codec_fields: list[_CodecField] = []
|
|
787
|
+
offset = 0
|
|
788
|
+
for name in names:
|
|
789
|
+
fmt, const, codec = _wire_format(hints[name])
|
|
790
|
+
if codec is not None:
|
|
791
|
+
codec_fields.append(_CodecField(name, codec))
|
|
792
|
+
continue
|
|
793
|
+
if fmt is None: # a trailing field without a wire type
|
|
794
|
+
continue
|
|
795
|
+
if codec_fields:
|
|
796
|
+
raise TypeError(f"{cls.__name__}.{name}: fixed-size fields must precede the codec fields")
|
|
797
|
+
if isinstance(fmt, Array):
|
|
798
|
+
fmt = fmt.in_record(byte_order)
|
|
799
|
+
field_struct = struct.Struct((fmt.byte_order or byte_order) + fmt.code)
|
|
800
|
+
spec = cls.__dict__.get(name)
|
|
801
|
+
init = not isinstance(spec, dataclasses.Field) or spec.init
|
|
802
|
+
fields.append(_Field(len(fields), name, fmt, field_struct, offset, const, init))
|
|
803
|
+
offset += field_struct.size
|
|
804
|
+
return fields, codec_fields
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _runs(fields: list[_Field]) -> list[_Run]:
|
|
808
|
+
"""Group consecutive same-byte-order fields into one ``struct.Struct`` each (usually one per record)."""
|
|
809
|
+
runs: list[_Run] = []
|
|
810
|
+
for order, group in itertools.groupby(fields, key=lambda f: f.struct.format[0]):
|
|
811
|
+
members = list(group)
|
|
812
|
+
fmt = struct.Struct(order + "".join(f.struct.format[1:] for f in members))
|
|
813
|
+
runs.append(_Run(fmt, members[0].offset, members[0].index, len(members)))
|
|
814
|
+
return runs
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _overrides(fmt: Format, method: str) -> bool:
|
|
818
|
+
return getattr(type(fmt), method) is not getattr(Format, method)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _tuple_getter(names: list[str]) -> Callable[[Any], tuple[Any, ...]]:
|
|
822
|
+
"""``operator.attrgetter`` that always returns a tuple (with one name it returns the bare value)."""
|
|
823
|
+
if not names:
|
|
824
|
+
return lambda obj: ()
|
|
825
|
+
if len(names) == 1:
|
|
826
|
+
single = operator.attrgetter(names[0])
|
|
827
|
+
return lambda obj: (single(obj),)
|
|
828
|
+
return operator.attrgetter(*names)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _show(value: Any) -> str:
|
|
832
|
+
"""Integers (magics, versions, tags) read best in hex."""
|
|
833
|
+
return hex(value) if isinstance(value, int) and not isinstance(value, bool) else repr(value)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
class BinaryStruct:
|
|
837
|
+
"""Base for dataclasses describing a binary record; see the module docstring.
|
|
838
|
+
|
|
839
|
+
Record classes are codecs themselves (``read``/``build``), so they nest inside other records and combinators.
|
|
840
|
+
"""
|
|
841
|
+
|
|
842
|
+
# Computed once per record class by ``__init_subclass__``.
|
|
843
|
+
SIZE: ClassVar[int] # of the fixed-size fields (all of a record without codec fields)
|
|
844
|
+
_fields: ClassVar[tuple[_Field, ...]] # in wire order
|
|
845
|
+
_codec_fields: ClassVar[tuple[_CodecField, ...]]
|
|
846
|
+
_runs: ClassVar[tuple[_Run, ...]]
|
|
847
|
+
_consts: ClassVar[tuple[_Field, ...]]
|
|
848
|
+
_const_getter: ClassVar[Callable[[Any], Any]] # instance -> its constant fields' values
|
|
849
|
+
_const_values: ClassVar[Any] # what ``_const_getter`` must return
|
|
850
|
+
_decoders: ClassVar[tuple[_Field, ...]]
|
|
851
|
+
_encoders: ClassVar[tuple[_Field, ...]] # non-constant fields whose format converts values before packing
|
|
852
|
+
_getter: ClassVar[Callable[[Any], tuple[Any, ...]]] # instance -> its fixed-size field values
|
|
853
|
+
_init_indices: ClassVar[Optional[list[int]]] # the fixed-size fields passed to ``__init__``, if not all of them
|
|
854
|
+
_plain: ClassVar[bool] # parse is a single unpack straight into ``__init__``
|
|
855
|
+
_plain_build: ClassVar[bool] # build is a single pack of the field values
|
|
856
|
+
|
|
857
|
+
def __init_subclass__(cls, byte_order: str = "<", **kwargs: Any) -> None:
|
|
858
|
+
super().__init_subclass__(**kwargs)
|
|
859
|
+
if cls.__bases__ != (BinaryStruct,):
|
|
860
|
+
raise TypeError(f"{cls.__name__}: records cannot be subclassed or mixed with other bases")
|
|
861
|
+
fields, codec_fields = _layout(cls, byte_order)
|
|
862
|
+
cls._fields = tuple(fields)
|
|
863
|
+
cls._codec_fields = tuple(codec_fields)
|
|
864
|
+
cls._runs = tuple(_runs(fields))
|
|
865
|
+
cls._consts = tuple(f for f in fields if f.const is not _NO_CONST)
|
|
866
|
+
cls._decoders = tuple(f for f in fields if _overrides(f.fmt, "decode"))
|
|
867
|
+
# A constant was checked before packing, so it never needs encoding.
|
|
868
|
+
cls._encoders = tuple(f for f in fields if _overrides(f.fmt, "encode") and f.const is _NO_CONST)
|
|
869
|
+
cls.SIZE = sum(f.struct.size for f in fields)
|
|
870
|
+
cls._getter = _tuple_getter([f.name for f in fields]) # pyright: ignore[reportAttributeAccessIssue]
|
|
871
|
+
cls._init_indices = None if all(f.init for f in fields) else [f.index for f in fields if f.init]
|
|
872
|
+
cls._plain = (
|
|
873
|
+
len(cls._runs) == 1
|
|
874
|
+
and not cls._consts
|
|
875
|
+
and not cls._decoders
|
|
876
|
+
and cls._init_indices is None
|
|
877
|
+
and not cls._codec_fields
|
|
878
|
+
)
|
|
879
|
+
cls._plain_build = len(cls._runs) == 1 and not cls._consts and not cls._encoders
|
|
880
|
+
if cls._consts:
|
|
881
|
+
# ``attrgetter`` returns the bare value for one name and a tuple for several; compare likewise.
|
|
882
|
+
cls._const_getter = operator.attrgetter(*(f.name for f in cls._consts))
|
|
883
|
+
consts = [f.const for f in cls._consts]
|
|
884
|
+
cls._const_values = consts[0] if len(consts) == 1 else tuple(consts)
|
|
885
|
+
|
|
886
|
+
@classmethod
|
|
887
|
+
def parse(cls, data: BytesLike) -> Self:
|
|
888
|
+
"""Parse the record from the start of ``data``; trailing bytes are ignored."""
|
|
889
|
+
if cls._codec_fields:
|
|
890
|
+
return cls.read(ByteReader(data))
|
|
891
|
+
return cls._parse(data, 0, len(data), 0)
|
|
892
|
+
|
|
893
|
+
@classmethod
|
|
894
|
+
def read(cls, reader: ByteReader) -> Self:
|
|
895
|
+
"""Parse the record at the reader's offset and advance past it."""
|
|
896
|
+
start = reader.offset
|
|
897
|
+
if not cls._codec_fields:
|
|
898
|
+
record = cls._parse(reader.data, start, reader.end, reader.base)
|
|
899
|
+
reader.offset = start + cls.SIZE
|
|
900
|
+
return record
|
|
901
|
+
values = cls._unpack(reader.data, start, reader.end, reader.base)
|
|
902
|
+
reader.offset = start + cls.SIZE
|
|
903
|
+
for field in cls._codec_fields:
|
|
904
|
+
try:
|
|
905
|
+
values.append(field.codec.read(reader))
|
|
906
|
+
except BinaryDataError as e:
|
|
907
|
+
e.within(f"{cls.__name__}.{field.name}")
|
|
908
|
+
raise
|
|
909
|
+
return cls(*values)
|
|
910
|
+
|
|
911
|
+
@classmethod
|
|
912
|
+
def _parse(cls, data: BytesLike, start: int, end: int, base: int) -> Self:
|
|
913
|
+
"""Parse at ``data[start:end]``; ``base + start`` is that position's absolute offset (for errors)."""
|
|
914
|
+
if cls._plain:
|
|
915
|
+
if end - start < cls.SIZE:
|
|
916
|
+
cls._raise_short(data, start, end, base)
|
|
917
|
+
return cls(*cls._runs[0].struct.unpack_from(data, start))
|
|
918
|
+
return cls(*cls._unpack(data, start, end, base))
|
|
919
|
+
|
|
920
|
+
@classmethod
|
|
921
|
+
def _unpack(cls, data: BytesLike, start: int, end: int, base: int) -> list[Any]:
|
|
922
|
+
"""The ``__init__`` arguments of the fixed-size fields at ``data[start:end]``."""
|
|
923
|
+
if end - start < cls.SIZE:
|
|
924
|
+
cls._raise_short(data, start, end, base)
|
|
925
|
+
values: list[Any] = []
|
|
926
|
+
for run in cls._runs:
|
|
927
|
+
values.extend(run.struct.unpack_from(data, start + run.offset))
|
|
928
|
+
# Constants first, so a wrong magic is reported as such.
|
|
929
|
+
for field in cls._consts:
|
|
930
|
+
if values[field.index] != field.const:
|
|
931
|
+
raise cls._const_error(field, values[field.index], base + start)
|
|
932
|
+
for field in cls._decoders:
|
|
933
|
+
try:
|
|
934
|
+
values[field.index] = field.fmt.decode(values[field.index])
|
|
935
|
+
except BinaryParseError as e:
|
|
936
|
+
raise e.located(record=cls.__name__, field=field.name, offset=base + start + field.offset) from e
|
|
937
|
+
if cls._init_indices is not None:
|
|
938
|
+
return [values[i] for i in cls._init_indices]
|
|
939
|
+
return values
|
|
940
|
+
|
|
941
|
+
@classmethod
|
|
942
|
+
def _raise_short(cls, data: BytesLike, start: int, end: int, base: int) -> NoReturn:
|
|
943
|
+
"""A wrong constant that is already readable wins (``b"HTTP/1.1..."`` is not a short frame, it is not
|
|
944
|
+
a frame of this record at all); otherwise the error names the first field that does not fit."""
|
|
945
|
+
available = end - start
|
|
946
|
+
for field in cls._consts:
|
|
947
|
+
if field.offset + field.struct.size <= available:
|
|
948
|
+
actual = field.struct.unpack_from(data, start + field.offset)[0]
|
|
949
|
+
if actual != field.const:
|
|
950
|
+
raise cls._const_error(field, actual, base + start)
|
|
951
|
+
field = next(f for f in cls._fields if f.offset + f.struct.size > available)
|
|
952
|
+
raise TruncatedDataError(
|
|
953
|
+
f"expected {field.struct.size} bytes, found {max(available - field.offset, 0)}",
|
|
954
|
+
record=cls.__name__,
|
|
955
|
+
field=field.name,
|
|
956
|
+
offset=base + start + field.offset,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
@classmethod
|
|
960
|
+
def _const_error(cls, field: _Field, actual: Any, record_offset: int) -> UnexpectedConstantError:
|
|
961
|
+
return UnexpectedConstantError(
|
|
962
|
+
f"expected {_show(field.const)}, got {_show(actual)}",
|
|
963
|
+
record=cls.__name__,
|
|
964
|
+
field=field.name,
|
|
965
|
+
offset=record_offset + field.offset,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
def build(self) -> bytes:
|
|
969
|
+
"""Encode the record (as a codec, ``Record.build(record)``)."""
|
|
970
|
+
if not self._codec_fields:
|
|
971
|
+
return self._build_fixed()
|
|
972
|
+
parts = [self._build_fixed()]
|
|
973
|
+
for field in self._codec_fields:
|
|
974
|
+
try:
|
|
975
|
+
parts.append(field.codec.build(getattr(self, field.name)))
|
|
976
|
+
except BinaryDataError as e:
|
|
977
|
+
e.within(f"{type(self).__name__}.{field.name}")
|
|
978
|
+
raise
|
|
979
|
+
return b"".join(parts)
|
|
980
|
+
|
|
981
|
+
def _build_fixed(self) -> bytes:
|
|
982
|
+
cls = type(self)
|
|
983
|
+
if cls._plain_build:
|
|
984
|
+
try:
|
|
985
|
+
return cls._runs[0].struct.pack(*cls._getter(self))
|
|
986
|
+
except (struct.error, OverflowError) as e:
|
|
987
|
+
raise BinaryBuildError(f"cannot encode: {e}", record=cls.__name__) from e
|
|
988
|
+
if cls._consts and cls._const_getter(self) != cls._const_values:
|
|
989
|
+
field = next(f for f in cls._consts if getattr(self, f.name) != f.const)
|
|
990
|
+
raise BinaryBuildError(
|
|
991
|
+
f"expected {_show(field.const)}, got {_show(getattr(self, field.name))}",
|
|
992
|
+
record=cls.__name__,
|
|
993
|
+
field=field.name,
|
|
994
|
+
)
|
|
995
|
+
values = cls._getter(self)
|
|
996
|
+
if cls._encoders:
|
|
997
|
+
values = list(values)
|
|
998
|
+
for field in cls._encoders:
|
|
999
|
+
try:
|
|
1000
|
+
values[field.index] = field.fmt.encode(values[field.index])
|
|
1001
|
+
except BinaryDataError as e:
|
|
1002
|
+
raise e.located(record=cls.__name__, field=field.name) from e
|
|
1003
|
+
try:
|
|
1004
|
+
if len(cls._runs) == 1:
|
|
1005
|
+
return cls._runs[0].struct.pack(*values)
|
|
1006
|
+
return b"".join([run.struct.pack(*values[run.first : run.first + run.count]) for run in cls._runs])
|
|
1007
|
+
except (struct.error, OverflowError) as e:
|
|
1008
|
+
raise BinaryBuildError(f"cannot encode: {e}", record=cls.__name__) from e
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
__all__ = [
|
|
1012
|
+
"CSTRING",
|
|
1013
|
+
"F32_BE",
|
|
1014
|
+
"F32_LE",
|
|
1015
|
+
"F64_BE",
|
|
1016
|
+
"F64_LE",
|
|
1017
|
+
"GREEDY_BYTES",
|
|
1018
|
+
"GREEDY_STRING",
|
|
1019
|
+
"I16_BE",
|
|
1020
|
+
"I16_LE",
|
|
1021
|
+
"I32_BE",
|
|
1022
|
+
"I32_LE",
|
|
1023
|
+
"I64_BE",
|
|
1024
|
+
"I64_LE",
|
|
1025
|
+
"PASS",
|
|
1026
|
+
"U8",
|
|
1027
|
+
"U16_BE",
|
|
1028
|
+
"U16_LE",
|
|
1029
|
+
"U32_BE",
|
|
1030
|
+
"U32_LE",
|
|
1031
|
+
"U64_BE",
|
|
1032
|
+
"U64_LE",
|
|
1033
|
+
"Aligned",
|
|
1034
|
+
"Array",
|
|
1035
|
+
"BinaryBuildError",
|
|
1036
|
+
"BinaryDataError",
|
|
1037
|
+
"BinaryParseError",
|
|
1038
|
+
"BinaryStruct",
|
|
1039
|
+
"ByteReader",
|
|
1040
|
+
"Bytes",
|
|
1041
|
+
"BytesLike",
|
|
1042
|
+
"CString",
|
|
1043
|
+
"Codec",
|
|
1044
|
+
"CodecBase",
|
|
1045
|
+
"Const",
|
|
1046
|
+
"Format",
|
|
1047
|
+
"GreedyBytes",
|
|
1048
|
+
"GreedyString",
|
|
1049
|
+
"OpenIntEnum",
|
|
1050
|
+
"PaddedString",
|
|
1051
|
+
"Pass",
|
|
1052
|
+
"Prefixed",
|
|
1053
|
+
"PrefixedArray",
|
|
1054
|
+
"Scalar",
|
|
1055
|
+
"Switch",
|
|
1056
|
+
"TruncatedDataError",
|
|
1057
|
+
"Tuple",
|
|
1058
|
+
"UnexpectedConstantError",
|
|
1059
|
+
"f32",
|
|
1060
|
+
"f32be",
|
|
1061
|
+
"f32le",
|
|
1062
|
+
"f64",
|
|
1063
|
+
"f64be",
|
|
1064
|
+
"f64le",
|
|
1065
|
+
"i8",
|
|
1066
|
+
"i16",
|
|
1067
|
+
"i16be",
|
|
1068
|
+
"i16le",
|
|
1069
|
+
"i32",
|
|
1070
|
+
"i32be",
|
|
1071
|
+
"i32le",
|
|
1072
|
+
"i64",
|
|
1073
|
+
"i64be",
|
|
1074
|
+
"i64le",
|
|
1075
|
+
"ipv4",
|
|
1076
|
+
"ipv6",
|
|
1077
|
+
"u8",
|
|
1078
|
+
"u16",
|
|
1079
|
+
"u16be",
|
|
1080
|
+
"u16le",
|
|
1081
|
+
"u32",
|
|
1082
|
+
"u32be",
|
|
1083
|
+
"u32le",
|
|
1084
|
+
"u64",
|
|
1085
|
+
"u64be",
|
|
1086
|
+
"u64le",
|
|
1087
|
+
"uuid_t",
|
|
1088
|
+
]
|
bytestruct/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g56aed9dbc'
|
bytestruct/exceptions.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Errors raised while parsing or building binary data."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"BinaryBuildError",
|
|
7
|
+
"BinaryDataError",
|
|
8
|
+
"BinaryParseError",
|
|
9
|
+
"TruncatedDataError",
|
|
10
|
+
"UnexpectedConstantError",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BinaryDataError(ValueError):
|
|
15
|
+
"""Base for binary wire-format errors: data that could not be parsed, or a value that could not be encoded.
|
|
16
|
+
|
|
17
|
+
``record``, ``field`` and ``offset`` (absolute, in the outermost buffer) locate the failure when known, and
|
|
18
|
+
``path`` lists the enclosing cases, elements and fields, outermost first. They prefix the message, e.g.
|
|
19
|
+
``Header.magic at offset 0: expected 0x29b00b92, got 0x0`` or
|
|
20
|
+
``DICTIONARY -> [2] -> STRING at offset 104: expected 200 bytes, found 16``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
record: Optional[str] = None,
|
|
28
|
+
field: Optional[str] = None,
|
|
29
|
+
offset: Optional[int] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.message = message
|
|
33
|
+
self.record = record
|
|
34
|
+
self.field = field
|
|
35
|
+
self.offset = offset
|
|
36
|
+
self.path: list[str] = []
|
|
37
|
+
|
|
38
|
+
def __str__(self) -> str:
|
|
39
|
+
name = ".".join(part for part in (self.record, self.field) if part)
|
|
40
|
+
location = " -> ".join([*self.path, name] if name else self.path)
|
|
41
|
+
if self.offset is not None:
|
|
42
|
+
location = f"{location} at offset {self.offset}" if location else f"at offset {self.offset}"
|
|
43
|
+
return f"{location}: {self.message}" if location else self.message
|
|
44
|
+
|
|
45
|
+
def located(
|
|
46
|
+
self, *, record: Optional[str] = None, field: Optional[str] = None, offset: Optional[int] = None
|
|
47
|
+
) -> "BinaryDataError":
|
|
48
|
+
"""Return a copy of this error (same type) with any missing location filled in."""
|
|
49
|
+
error = type(self)(
|
|
50
|
+
self.message,
|
|
51
|
+
record=self.record or record,
|
|
52
|
+
field=self.field or field,
|
|
53
|
+
offset=self.offset if self.offset is not None else offset,
|
|
54
|
+
)
|
|
55
|
+
error.path = list(self.path)
|
|
56
|
+
return error
|
|
57
|
+
|
|
58
|
+
def within(self, step: str) -> None:
|
|
59
|
+
"""Record that the failure happened inside ``step`` (a case, element or field), as the error propagates."""
|
|
60
|
+
self.path.insert(0, step)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BinaryParseError(BinaryDataError):
|
|
64
|
+
"""Binary wire data could not be parsed."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BinaryBuildError(BinaryDataError):
|
|
68
|
+
"""A value could not be encoded into its binary wire format."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TruncatedDataError(BinaryParseError):
|
|
72
|
+
"""Binary wire data ended before the structure being parsed was complete."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class UnexpectedConstantError(BinaryParseError):
|
|
76
|
+
"""A constant field (magic, version, ...) in binary wire data did not have its expected value."""
|
bytestruct/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bytestruct
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declarative binary records and composable codecs, using only the standard library
|
|
5
|
+
Author-email: doronz88 <doron88@gmail.com>
|
|
6
|
+
Maintainer-email: doronz88 <doron88@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/doronz88/bytestruct
|
|
9
|
+
Project-URL: Documentation, https://doronz88.github.io/bytestruct/
|
|
10
|
+
Project-URL: Bug Reports, https://github.com/doronz88/bytestruct/issues
|
|
11
|
+
Keywords: binary,struct,parsing,serialization,dataclasses,construct
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: typing_extensions>=4.1.0; python_version < "3.11"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest; extra == "test"
|
|
31
|
+
Provides-Extra: docs
|
|
32
|
+
Requires-Dist: mkdocs-material>=9.5; python_version >= "3.10" and extra == "docs"
|
|
33
|
+
Requires-Dist: mkdocstrings[python]>=0.26; python_version >= "3.10" and extra == "docs"
|
|
34
|
+
Requires-Dist: mkdocs-llmstxt>=0.5; python_version >= "3.10" and extra == "docs"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# bytestruct
|
|
38
|
+
|
|
39
|
+
[](https://github.com/doronz88/bytestruct/actions/workflows/python-app.yml "Python application action")
|
|
40
|
+
[](https://pypi.org/project/bytestruct/ "PyPi package")
|
|
41
|
+
[](https://doronz88.github.io/bytestruct/ "Documentation")
|
|
42
|
+
|
|
43
|
+
`bytestruct` parses and builds binary wire formats with the standard library alone. Fixed-size records are
|
|
44
|
+
dataclasses whose field annotations give each field's wire type; variable-length data is described by small
|
|
45
|
+
codecs that compose like [construct](https://github.com/construct/construct)'s. Everything is fully typed.
|
|
46
|
+
|
|
47
|
+
The layout of a record is compiled into `struct.Struct` objects once, when the class is defined, so parsing a
|
|
48
|
+
record is a single `unpack_from` per byte-order run.
|
|
49
|
+
|
|
50
|
+
**Documentation: <https://doronz88.github.io/bytestruct/>**, including a
|
|
51
|
+
[migration guide from construct](https://doronz88.github.io/bytestruct/migrating-from-construct/).
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```shell
|
|
56
|
+
python3 -m pip install -U bytestruct
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Fixed-size records
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from dataclasses import dataclass, field
|
|
63
|
+
from enum import IntEnum
|
|
64
|
+
from typing import Annotated
|
|
65
|
+
|
|
66
|
+
from bytestruct import BinaryStruct, Const, PaddedString, u16, u32
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Kind(IntEnum):
|
|
70
|
+
FILE = 1
|
|
71
|
+
DIRECTORY = 2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Header(BinaryStruct, byte_order="<"):
|
|
76
|
+
magic: Annotated[u32, Const(0x1F3D5B79)] = field(default=0x1F3D5B79, init=False)
|
|
77
|
+
size: u32
|
|
78
|
+
name: Annotated[str, PaddedString(16)]
|
|
79
|
+
kind: Annotated[Kind, u16] # parsed into the enum type
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
data = Header(size=1024, name="notes.txt", kind=Kind.FILE).build() # a bad value -> BinaryBuildError
|
|
83
|
+
header = Header.parse(data) # short input -> TruncatedDataError, wrong magic -> UnexpectedConstantError
|
|
84
|
+
assert header.kind is Kind.FILE
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Wire fields are positional `__init__` parameters in wire order. A field declared
|
|
88
|
+
`= field(default=..., init=False)` is left out of `__init__`: parse skips its bytes (padding) and build packs
|
|
89
|
+
its default (on a `Const` field, the constant). Fields without a wire type may follow the wire fields and are
|
|
90
|
+
ignored by parse/build. Records cannot be subclassed.
|
|
91
|
+
|
|
92
|
+
Available field types include `u8`..`u64`, `i16`..`i64`, byte-order pinned `u32le`/`u32be`, `Bytes(n)`,
|
|
93
|
+
`PaddedString(n)`, `uuid_t`, `ipv4`/`ipv6`, `Array(element, n)`, int-enum fields and `Const` magics; custom
|
|
94
|
+
ones subclass `Format`.
|
|
95
|
+
|
|
96
|
+
## Codecs
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from bytestruct import CSTRING, U32_LE, U64_LE, Aligned, Prefixed, Switch
|
|
100
|
+
|
|
101
|
+
STRING = Aligned(4, Prefixed(U32_LE, CSTRING)) # u32 length, NUL-terminated UTF-8, padded to 4 bytes
|
|
102
|
+
OBJECT = Switch(U32_LE, {1: STRING, 2: U64_LE}) # parses to (tag, value)
|
|
103
|
+
|
|
104
|
+
assert OBJECT.parse(OBJECT.build((1, "hi"))) == (1, "hi")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A codec is anything with `read(reader)` and `build(value)`: the combinators (`Prefixed`, `Aligned`,
|
|
108
|
+
`PrefixedArray`, `Tuple`, `Switch`), the leaves (`CSTRING`, `GREEDY_STRING`, `GREEDY_BYTES`, `PASS`), the
|
|
109
|
+
scalars (`U8`, `U16_LE`/`U16_BE`, ..., `F64_BE`), `Bytes(n)`, and record classes themselves. A record field annotated with a codec
|
|
110
|
+
(`name: Annotated[str, CSTRING]`) makes the record variable-length; such fields follow the fixed-size ones.
|
|
111
|
+
Formats with no declarative shape are read by hand with `ByteReader`, whose sub-readers are windows on the same
|
|
112
|
+
buffer (nothing is copied).
|
|
113
|
+
|
|
114
|
+
## Errors
|
|
115
|
+
|
|
116
|
+
Every failure raises a `BinaryDataError` (a `ValueError`): `BinaryParseError` (with `TruncatedDataError` and
|
|
117
|
+
`UnexpectedConstantError`) or `BinaryBuildError`. Errors carry the record, field, absolute offset and the path
|
|
118
|
+
through nested codecs, and prefix their message with them:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
Header.magic at offset 0: expected 0x1f3d5b79, got 0x0
|
|
122
|
+
DICTIONARY -> [2] -> STRING at offset 104: expected 200 bytes, found 16
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
bytestruct/__init__.py,sha256=tjqx8tf3MnVKn9440TBTaK0LM0q1kuv_k-JaCMyPdNI,43317
|
|
2
|
+
bytestruct/_version.py,sha256=DqNwOX8fUbtZkGEfEThJS32dSY4Zu3PBkOFtqqT0FFo,528
|
|
3
|
+
bytestruct/exceptions.py,sha256=ApIs9_wlt938tf5VsQoooB1hqF5H-wXpRuZfV_LETps,2721
|
|
4
|
+
bytestruct/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
bytestruct-0.1.0.dist-info/licenses/LICENSE,sha256=yLCk8rOuuGsdobWBq_7cZBbX5LgGqK7cwF081kL9AzU,1065
|
|
6
|
+
bytestruct-0.1.0.dist-info/METADATA,sha256=RJixlWogvupLgVM8O8NkOm_m_1l74ft0FgxYIRghgG8,5584
|
|
7
|
+
bytestruct-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
bytestruct-0.1.0.dist-info/top_level.txt,sha256=gL5C-47ufgKufV9GErsJQXU56mozZOK64PBoCFjxHeQ,11
|
|
9
|
+
bytestruct-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 doronz88
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bytestruct
|