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 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'
@@ -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
+ [![Python application](https://github.com/doronz88/bytestruct/workflows/Python%20application/badge.svg)](https://github.com/doronz88/bytestruct/actions/workflows/python-app.yml "Python application action")
40
+ [![Pypi version](https://img.shields.io/pypi/v/bytestruct.svg)](https://pypi.org/project/bytestruct/ "PyPi package")
41
+ [![Docs](https://github.com/doronz88/bytestruct/actions/workflows/docs.yml/badge.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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