structwire 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.
structwire/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """Encode and decode fixed-layout binary payloads."""
2
+
3
+ from .catalog import Catalog
4
+ from .codec import Codec
5
+ from .errors import DecodeError, EncodeError, SchemaError, StructWireError
6
+ from .formatting import format_mac_address, format_record, format_value
7
+ from .loaders import (
8
+ catalog_from_mapping,
9
+ catalog_from_yaml,
10
+ layout_from_mapping,
11
+ layout_from_yaml,
12
+ )
13
+ from .model import Field, Layout
14
+
15
+ __all__ = [
16
+ "Catalog",
17
+ "Codec",
18
+ "DecodeError",
19
+ "EncodeError",
20
+ "Field",
21
+ "Layout",
22
+ "SchemaError",
23
+ "StructWireError",
24
+ "catalog_from_mapping",
25
+ "catalog_from_yaml",
26
+ "format_mac_address",
27
+ "format_record",
28
+ "format_value",
29
+ "layout_from_mapping",
30
+ "layout_from_yaml",
31
+ ]
32
+
33
+ __version__ = "0.1.0"
structwire/catalog.py ADDED
@@ -0,0 +1,84 @@
1
+ """Named collections of compiled codecs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Mapping
6
+ from types import MappingProxyType
7
+ from typing import Any
8
+
9
+ from .codec import Codec
10
+ from .errors import SchemaError
11
+ from .model import Layout
12
+
13
+
14
+ class Catalog(Mapping[str, Codec]):
15
+ """Immutable mapping of codec names to compiled :class:`Codec` objects."""
16
+
17
+ __slots__ = ("_codecs",)
18
+
19
+ def __init__(self, layouts: Mapping[str, Layout | Codec]) -> None:
20
+ if not isinstance(layouts, Mapping):
21
+ raise SchemaError(
22
+ "Catalog layouts must be a mapping of names to Layout or Codec objects"
23
+ )
24
+ if not layouts:
25
+ raise SchemaError("Catalog must contain at least one codec")
26
+ compiled: dict[str, Codec] = {}
27
+ for raw_name, layout_or_codec in layouts.items():
28
+ if not isinstance(raw_name, str) or not raw_name.strip():
29
+ raise SchemaError("Catalog codec names must be non-empty strings")
30
+ name = raw_name.strip()
31
+ if name in compiled:
32
+ raise SchemaError(f"Duplicate codec name: {name!r}")
33
+ if not isinstance(layout_or_codec, (Layout, Codec)):
34
+ raise SchemaError(f"Catalog {name!r}: expected a Layout or Codec object")
35
+ codec = (
36
+ layout_or_codec if isinstance(layout_or_codec, Codec) else layout_or_codec.compile()
37
+ )
38
+ if codec.layout.name and codec.layout.name != name:
39
+ raise SchemaError(
40
+ f"Catalog key {name!r} conflicts with layout name {codec.layout.name!r}"
41
+ )
42
+ compiled[name] = codec
43
+ self._codecs: Mapping[str, Codec] = MappingProxyType(compiled)
44
+
45
+ def __getitem__(self, name: str) -> Codec:
46
+ try:
47
+ return self._codecs[name]
48
+ except KeyError as exc:
49
+ raise KeyError(f"Unknown codec {name!r}") from exc
50
+
51
+ def __iter__(self) -> Iterator[str]:
52
+ return iter(self._codecs)
53
+
54
+ def __len__(self) -> int:
55
+ return len(self._codecs)
56
+
57
+ @property
58
+ def names(self) -> tuple[str, ...]:
59
+ """Codec names in declaration order."""
60
+ return tuple(self._codecs)
61
+
62
+ def encode(
63
+ self,
64
+ name: str,
65
+ values: Mapping[str, Any],
66
+ *,
67
+ require_all: bool = True,
68
+ ) -> bytes:
69
+ """Encode values with a named codec."""
70
+ return self[name].encode(values, require_all=require_all)
71
+
72
+ def decode(
73
+ self,
74
+ name: str,
75
+ payload: bytes | bytearray | memoryview,
76
+ *,
77
+ strict: bool = True,
78
+ ) -> dict[str, Any]:
79
+ """Decode bytes with a named codec."""
80
+ return self[name].decode(payload, strict=strict)
81
+
82
+ def describe(self) -> dict[str, dict[str, Any]]:
83
+ """Return a serializable description of each named codec."""
84
+ return {name: codec.describe() for name, codec in self._codecs.items()}
structwire/codec.py ADDED
@@ -0,0 +1,506 @@
1
+ """Compiled encoder/decoder implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import struct
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from .errors import DecodeError, EncodeError, SchemaError
13
+ from .model import Field, Layout, float_spec, integer_spec
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class _CompiledField:
18
+ field: Field
19
+ offset: int
20
+
21
+ @property
22
+ def end(self) -> int:
23
+ return self.offset + self.field.width
24
+
25
+
26
+ class Codec:
27
+ """Compiled, reusable binary codec.
28
+
29
+ Layout validation and offsets are resolved once in the constructor. A
30
+ ``Codec`` holds no per-call mutable state and can therefore be reused by
31
+ independent callers.
32
+ """
33
+
34
+ __slots__ = ("_fields", "_layout", "_size")
35
+
36
+ def __init__(self, layout: Layout) -> None:
37
+ self._layout = layout
38
+ cursor = 0
39
+ compiled: list[_CompiledField] = []
40
+ for field in layout.fields:
41
+ offset = cursor if field.offset is None else field.offset
42
+ item = _CompiledField(field, offset)
43
+ compiled.append(item)
44
+ cursor = max(cursor, item.end)
45
+ if layout.size is not None and layout.size < cursor:
46
+ raise SchemaError(
47
+ f"Layout {layout.name!r} declares size {layout.size}, "
48
+ f"but fields require at least {cursor} byte(s)"
49
+ )
50
+ _check_overlaps(compiled)
51
+ self._fields = tuple(compiled)
52
+ self._size = max(cursor, layout.size or 0)
53
+
54
+ @property
55
+ def layout(self) -> Layout:
56
+ return self._layout
57
+
58
+ @property
59
+ def size(self) -> int:
60
+ """Exact encoded payload size in bytes."""
61
+ return self._size
62
+
63
+ @property
64
+ def fields(self) -> tuple[Field, ...]:
65
+ return tuple(item.field for item in self._fields)
66
+
67
+ def describe(self) -> dict[str, Any]:
68
+ """Return a serialization-friendly description of the compiled layout."""
69
+ return {
70
+ "name": self._layout.name,
71
+ "description": self._layout.description,
72
+ "size": self._size,
73
+ "fields": [
74
+ {
75
+ "name": item.field.name,
76
+ "kind": item.field.kind,
77
+ "offset": item.offset,
78
+ "length": item.field.width,
79
+ "mask": item.field.mask,
80
+ "shift": item.field.shift,
81
+ "scale": item.field.scale,
82
+ "bias": item.field.bias,
83
+ "byteorder": item.field.effective_byteorder,
84
+ "values": dict(item.field.values),
85
+ "bits": dict(item.field.bits),
86
+ "unit": item.field.unit,
87
+ "description": item.field.description,
88
+ }
89
+ for item in self._fields
90
+ ],
91
+ }
92
+
93
+ def decode(
94
+ self,
95
+ payload: bytes | bytearray | memoryview,
96
+ *,
97
+ strict: bool = True,
98
+ ) -> dict[str, Any]:
99
+ """Decode a bytes-like payload into a new mapping of named field values.
100
+
101
+ ``strict=True`` requires exactly ``size`` bytes. With ``strict=False``,
102
+ trailing bytes are ignored; short payloads still raise ``DecodeError``.
103
+ Malformed field data and scaled values that lose raw integer precision
104
+ also raise ``DecodeError``. Mutable input is copied; inputs are never modified.
105
+ """
106
+ if not isinstance(payload, (bytes, bytearray, memoryview)):
107
+ raise DecodeError("Payload must be bytes, bytearray or memoryview")
108
+ try:
109
+ raw = bytes(payload)
110
+ except ValueError as exc:
111
+ raise DecodeError("Payload memoryview is no longer accessible") from exc
112
+ if len(raw) < self._size:
113
+ raise DecodeError(f"Payload has {len(raw)} byte(s); layout requires {self._size}")
114
+ if strict and len(raw) != self._size:
115
+ raise DecodeError(f"Payload has {len(raw)} byte(s); expected exactly {self._size}")
116
+ result: dict[str, Any] = {}
117
+ for item in self._fields:
118
+ fragment = raw[item.offset : item.end]
119
+ result[item.field.name] = _decode_field(item.field, fragment)
120
+ return result
121
+
122
+ def encode(self, values: Mapping[str, Any], *, require_all: bool = True) -> bytes:
123
+ """Encode named values as canonical bytes without modifying the mapping.
124
+
125
+ ``require_all=True`` requires every field. With ``require_all=False``,
126
+ omitted fields leave their storage bits zero. Unused bytes and bits are
127
+ always zero, so unrepresented input bits cannot survive a round trip.
128
+ Non-mapping inputs, unknown names, missing required fields, invalid values,
129
+ out-of-range numbers and off-grid scaled integers raise ``EncodeError``.
130
+ """
131
+ if not isinstance(values, Mapping):
132
+ raise EncodeError("Values must be a mapping of field names to values")
133
+ unknown = set(values) - {item.field.name for item in self._fields}
134
+ if unknown:
135
+ raise EncodeError(f"Unknown field(s): {', '.join(sorted(map(str, unknown)))}")
136
+ if require_all:
137
+ missing = [item.field.name for item in self._fields if item.field.name not in values]
138
+ if missing:
139
+ raise EncodeError(f"Missing field(s): {', '.join(missing)}")
140
+
141
+ output = bytearray(self._size)
142
+ for item in self._fields:
143
+ if item.field.name not in values:
144
+ continue
145
+ encoded, write_mask = _encode_field(item.field, values[item.field.name])
146
+ for index, byte in enumerate(encoded):
147
+ absolute = item.offset + index
148
+ mask = write_mask[index]
149
+ output[absolute] = (output[absolute] & ~mask) | (byte & mask)
150
+ return bytes(output)
151
+
152
+
153
+ def _check_overlaps(fields: list[_CompiledField]) -> None:
154
+ # Full-width writers need one interval, regardless of their width or offset.
155
+ # Masked kinds have at most eight bytes, so each needs at most eight claims.
156
+ claims: list[tuple[int, int, int, str]] = []
157
+ for item in fields:
158
+ if item.field.mask is None:
159
+ claims.append((item.offset, item.end, 0xFF, item.field.name))
160
+ else:
161
+ for index, mask in enumerate(_write_masks(item.field)):
162
+ if mask:
163
+ start = item.offset + index
164
+ claims.append((start, start + 1, mask, item.field.name))
165
+
166
+ claimed_end = 0
167
+ claimed_mask = 0
168
+ for start, end, mask, name in sorted(claims, key=lambda claim: claim[0]):
169
+ if start >= claimed_end:
170
+ claimed_mask = 0
171
+ overlap = claimed_mask & mask
172
+ if overlap:
173
+ raise SchemaError(
174
+ f"Fields overlap ambiguously at byte {start}: field {name!r} "
175
+ f"reuses bit mask 0x{overlap:02X}"
176
+ )
177
+ # Only full-byte claims span multiple bytes. Any accepted overlap must
178
+ # therefore consist of disjoint masks at the same single byte.
179
+ claimed_mask |= mask
180
+ claimed_end = end
181
+
182
+
183
+ def _write_masks(field: Field) -> bytes:
184
+ if field.mask is None:
185
+ return b"\xff" * field.width
186
+ return field.mask.to_bytes(field.width, field.effective_byteorder, signed=False)
187
+
188
+
189
+ def _decode_field(field: Field, fragment: bytes) -> Any:
190
+ kind = field.kind
191
+ int_spec = integer_spec(kind)
192
+ if int_spec is not None:
193
+ _, signed, byteorder = int_spec
194
+ if field.mask is None:
195
+ value = int.from_bytes(fragment, byteorder, signed=signed)
196
+ else:
197
+ container = int.from_bytes(fragment, byteorder, signed=False)
198
+ masked = (container & field.mask) >> field.shift
199
+ value = _sign_extend(masked, _mask_width(field.mask, field.shift)) if signed else masked
200
+ return _physical(field, value)
201
+
202
+ flt = float_spec(kind)
203
+ if flt is not None:
204
+ if field.mask is not None:
205
+ raise SchemaError(f"Field {field.name!r}: masks are not supported for floats")
206
+ prefix = ">" if flt[1] == "big" else "<"
207
+ fmt = "f" if flt[0] == 4 else "d"
208
+ value = struct.unpack(prefix + fmt, fragment)[0]
209
+ return _physical(field, value)
210
+
211
+ if kind == "bcd":
212
+ digits: list[str] = []
213
+ for byte in fragment:
214
+ high, low = byte >> 4, byte & 0x0F
215
+ if high > 9 or low > 9:
216
+ raise DecodeError(f"Field {field.name!r}: invalid BCD byte 0x{byte:02X}")
217
+ digits.extend((str(high), str(low)))
218
+ try:
219
+ value = int("".join(digits))
220
+ except ValueError as exc:
221
+ raise DecodeError(
222
+ f"Field {field.name!r}: BCD exceeds integer conversion limit"
223
+ ) from exc
224
+ return _physical(field, value)
225
+
226
+ if kind == "ascii":
227
+ try:
228
+ return fragment.decode("ascii").rstrip("\x00")
229
+ except UnicodeDecodeError as exc:
230
+ raise DecodeError(f"Field {field.name!r}: invalid ASCII") from exc
231
+
232
+ if kind == "bytes":
233
+ return fragment
234
+
235
+ if kind == "mac":
236
+ raw_mac = fragment[::-1] if field.byteorder == "little" else fragment
237
+ return ":".join(f"{byte:02X}" for byte in raw_mac)
238
+
239
+ raw = int.from_bytes(fragment, field.effective_byteorder, signed=False)
240
+ if field.mask is not None:
241
+ raw = (raw & field.mask) >> field.shift
242
+
243
+ if kind == "enum":
244
+ return field.values.get(raw, raw)
245
+ if kind == "flags":
246
+ return {name: bool(raw & mask) for name, mask in field.bits.items()}
247
+ if kind == "bool":
248
+ return bool(raw)
249
+ raise AssertionError(kind)
250
+
251
+
252
+ def _encode_field(field: Field, value: Any) -> tuple[bytes, bytes]:
253
+ kind = field.kind
254
+ masks = _write_masks(field)
255
+ raw_value: int
256
+ int_spec = integer_spec(kind)
257
+ if int_spec is not None:
258
+ width, signed, byteorder = int_spec
259
+ numeric = _raw_numeric(field, value, integral=True)
260
+ assert isinstance(numeric, int)
261
+ if field.mask is None:
262
+ try:
263
+ return numeric.to_bytes(width, byteorder, signed=signed), masks
264
+ except OverflowError as exc:
265
+ raise EncodeError(f"Field {field.name!r}: value does not fit {kind}") from exc
266
+ bits = _mask_width(field.mask, field.shift)
267
+ encoded_numeric = _fit_masked_integer(field, numeric, bits, signed)
268
+ container = (encoded_numeric << field.shift) & field.mask
269
+ return container.to_bytes(width, byteorder, signed=False), masks
270
+
271
+ flt = float_spec(kind)
272
+ if flt is not None:
273
+ if field.mask is not None:
274
+ raise SchemaError(f"Field {field.name!r}: masks are not supported for floats")
275
+ numeric = float(_raw_numeric(field, value, integral=False))
276
+ prefix = ">" if flt[1] == "big" else "<"
277
+ fmt = "f" if flt[0] == 4 else "d"
278
+ try:
279
+ return struct.pack(prefix + fmt, numeric), masks
280
+ except (OverflowError, struct.error) as exc:
281
+ raise EncodeError(f"Field {field.name!r}: {value!r} does not fit {kind}") from exc
282
+
283
+ if kind == "bcd":
284
+ numeric = _raw_numeric(field, value, integral=True)
285
+ assert isinstance(numeric, int)
286
+ try:
287
+ digits = str(numeric)
288
+ except ValueError as exc:
289
+ raise EncodeError(
290
+ f"Field {field.name!r}: BCD exceeds integer conversion limit"
291
+ ) from exc
292
+ max_digits = field.width * 2
293
+ if numeric < 0 or len(digits) > max_digits:
294
+ raise EncodeError(
295
+ f"Field {field.name!r}: {numeric} does not fit {field.width}-byte BCD"
296
+ )
297
+ digits = digits.zfill(max_digits)
298
+ encoded = bytes((int(digits[i]) << 4) | int(digits[i + 1]) for i in range(0, max_digits, 2))
299
+ return encoded, masks
300
+
301
+ if kind == "ascii":
302
+ if not isinstance(value, str):
303
+ raise EncodeError(f"Field {field.name!r}: ASCII value must be str")
304
+ try:
305
+ raw = value.encode("ascii")
306
+ except UnicodeEncodeError as exc:
307
+ raise EncodeError(f"Field {field.name!r}: value is not ASCII") from exc
308
+ if len(raw) > field.width:
309
+ raise EncodeError(f"Field {field.name!r}: string is longer than {field.width} byte(s)")
310
+ return raw.ljust(field.width, b"\x00"), masks
311
+
312
+ if kind == "bytes":
313
+ if isinstance(value, int):
314
+ raise EncodeError(f"Field {field.name!r}: bytes value required, not integer length")
315
+ try:
316
+ raw = bytes(value)
317
+ except (TypeError, ValueError) as exc:
318
+ raise EncodeError(f"Field {field.name!r}: bytes value required") from exc
319
+ if len(raw) != field.width:
320
+ raise EncodeError(
321
+ f"Field {field.name!r}: expected {field.width} byte(s), got {len(raw)}"
322
+ )
323
+ return raw, masks
324
+
325
+ if kind == "mac":
326
+ return _encode_mac(field, value), masks
327
+
328
+ if kind == "enum":
329
+ raw_value = _enum_raw(field, value)
330
+ elif kind == "flags":
331
+ raw_value = _flags_raw(field, value)
332
+ elif kind == "bool":
333
+ raw_value = _bool_raw(field, value)
334
+ else:
335
+ raise AssertionError(kind)
336
+
337
+ if field.mask is not None:
338
+ width = _mask_width(field.mask, field.shift)
339
+ if raw_value < 0 or raw_value >= 1 << width:
340
+ raise EncodeError(f"Field {field.name!r}: value does not fit mask 0x{field.mask:X}")
341
+ raw_value = (raw_value << field.shift) & field.mask
342
+ try:
343
+ return raw_value.to_bytes(field.width, field.effective_byteorder, signed=False), masks
344
+ except OverflowError as exc:
345
+ raise EncodeError(
346
+ f"Field {field.name!r}: value does not fit {field.width} byte(s)"
347
+ ) from exc
348
+
349
+
350
+ def _raw_numeric(field: Field, value: Any, *, integral: bool) -> int | float:
351
+ numeric: int | float
352
+ if isinstance(value, bool):
353
+ numeric = int(value)
354
+ elif isinstance(value, (int, float)):
355
+ numeric = value
356
+ else:
357
+ raise EncodeError(f"Field {field.name!r}: numeric value required")
358
+ if integral and field.scale == 1.0 and field.bias == 0.0 and isinstance(numeric, int):
359
+ return int(numeric)
360
+ try:
361
+ floating = float(numeric)
362
+ raw = (floating - field.bias) / field.scale
363
+ except OverflowError as exc:
364
+ raise EncodeError(f"Field {field.name!r}: numeric value is too large") from exc
365
+ if integral:
366
+ if isinstance(numeric, int) and floating != numeric:
367
+ raise EncodeError(
368
+ f"Field {field.name!r}: scaling cannot preserve integer input precision"
369
+ )
370
+ if not math.isfinite(raw):
371
+ raise EncodeError(f"Field {field.name!r}: finite numeric value required")
372
+ rounded = round(raw)
373
+ tolerance = 1e-12
374
+ if field.scale != 1.0 or field.bias != 0.0:
375
+ # Inverting a decoded physical value can introduce one floating-point
376
+ # step of arithmetic error, even when the raw integer is exact.
377
+ tolerance = max(tolerance, math.ulp(raw))
378
+ on_grid = math.isclose(raw, rounded, rel_tol=0.0, abs_tol=tolerance)
379
+ if not on_grid and (field.scale != 1.0 or field.bias != 0.0):
380
+ # Subtracting a large bias can amplify cancellation noise. Accept
381
+ # a candidate only if its forward conversion reproduces the input.
382
+ on_grid = rounded * field.scale + field.bias == numeric
383
+ # Reject half-count values even when the tolerance reaches half a count.
384
+ if abs(raw - rounded) >= 0.5 or not on_grid:
385
+ raise EncodeError(
386
+ f"Field {field.name!r}: {value!r} is not exactly representable with "
387
+ f"scale={field.scale} bias={field.bias}"
388
+ )
389
+ if field.scale != 1.0 or field.bias != 0.0:
390
+ forward = rounded * field.scale + field.bias
391
+ if not math.isfinite(forward) or abs(forward - floating) / abs(field.scale) >= 0.5:
392
+ raise EncodeError(
393
+ f"Field {field.name!r}: scaling cannot preserve integer input precision"
394
+ )
395
+ return int(rounded)
396
+ if math.isfinite(floating) and not math.isfinite(raw):
397
+ raise EncodeError(f"Field {field.name!r}: inverse scaling overflows floating-point range")
398
+ return raw
399
+
400
+
401
+ def _physical(field: Field, raw: int | float) -> int | float:
402
+ if field.scale == 1.0 and field.bias == 0.0:
403
+ return raw
404
+ try:
405
+ physical = raw * field.scale + field.bias
406
+ except OverflowError as exc:
407
+ raise DecodeError(
408
+ f"Field {field.name!r}: scaled value overflows floating-point range"
409
+ ) from exc
410
+ if isinstance(raw, int):
411
+ try:
412
+ recovered = _raw_numeric(field, physical, integral=True)
413
+ except EncodeError as exc:
414
+ raise DecodeError(
415
+ f"Field {field.name!r}: scaling cannot preserve integer precision; "
416
+ "use an unscaled field to obtain the raw integer"
417
+ ) from exc
418
+ if recovered != raw:
419
+ raise DecodeError(
420
+ f"Field {field.name!r}: scaling cannot preserve integer precision; "
421
+ "use an unscaled field to obtain the raw integer"
422
+ )
423
+ elif math.isfinite(raw) and not math.isfinite(physical):
424
+ raise DecodeError(f"Field {field.name!r}: scaled value overflows floating-point range")
425
+ return physical
426
+
427
+
428
+ def _mask_width(mask: int | None, shift: int) -> int:
429
+ assert mask is not None
430
+ return (mask >> shift).bit_length()
431
+
432
+
433
+ def _sign_extend(value: int, width: int) -> int:
434
+ sign = 1 << (width - 1)
435
+ return value - (1 << width) if value & sign else value
436
+
437
+
438
+ def _fit_masked_integer(field: Field, value: int, width: int, signed: bool) -> int:
439
+ if signed:
440
+ minimum = -(1 << (width - 1))
441
+ maximum = (1 << (width - 1)) - 1
442
+ if not minimum <= value <= maximum:
443
+ raise EncodeError(f"Field {field.name!r}: value does not fit signed {width}-bit mask")
444
+ return value & ((1 << width) - 1)
445
+ if not 0 <= value < 1 << width:
446
+ raise EncodeError(f"Field {field.name!r}: value does not fit unsigned {width}-bit mask")
447
+ return value
448
+
449
+
450
+ def _bool_raw(field: Field, value: Any) -> int:
451
+ if isinstance(value, bool):
452
+ return int(value)
453
+ if isinstance(value, int) and value in {0, 1}:
454
+ return value
455
+ raise EncodeError(f"Field {field.name!r}: bool value must be True/False or 0/1")
456
+
457
+
458
+ def _enum_raw(field: Field, value: Any) -> int:
459
+ if isinstance(value, str):
460
+ for raw, label in field.values.items():
461
+ if label == value:
462
+ return raw
463
+ raise EncodeError(f"Field {field.name!r}: unknown enum label {value!r}")
464
+ if isinstance(value, int):
465
+ return value
466
+ raise EncodeError(f"Field {field.name!r}: enum value must be label or integer")
467
+
468
+
469
+ def _flags_raw(field: Field, value: Any) -> int:
470
+ if isinstance(value, Mapping):
471
+ unknown = set(value) - set(field.bits)
472
+ if unknown:
473
+ raise EncodeError(
474
+ f"Field {field.name!r}: unknown flag(s): {', '.join(sorted(map(str, unknown)))}"
475
+ )
476
+ raw = 0
477
+ for name, mask in field.bits.items():
478
+ state = value.get(name, False)
479
+ if not isinstance(state, bool) and not (isinstance(state, int) and state in {0, 1}):
480
+ raise EncodeError(f"Field {field.name!r}: flag {name!r} must be True/False or 0/1")
481
+ if bool(state):
482
+ raw |= mask
483
+ return raw
484
+ if isinstance(value, int):
485
+ return value
486
+ raise EncodeError(f"Field {field.name!r}: flags value must be mapping or integer")
487
+
488
+
489
+ def _encode_mac(field: Field, value: Any) -> bytes:
490
+ if isinstance(value, str):
491
+ if (
492
+ re.fullmatch(r"[0-9a-fA-F]{2}([:-])(?:[0-9a-fA-F]{2}\1){4}[0-9a-fA-F]{2}", value)
493
+ is None
494
+ ):
495
+ raise EncodeError(f"Field {field.name!r}: invalid MAC address {value!r}")
496
+ raw = bytes.fromhex(value.replace(":", "").replace("-", ""))
497
+ else:
498
+ if isinstance(value, int):
499
+ raise EncodeError(f"Field {field.name!r}: MAC must be text or six bytes")
500
+ try:
501
+ raw = bytes(value)
502
+ except (TypeError, ValueError) as exc:
503
+ raise EncodeError(f"Field {field.name!r}: MAC must be text or six bytes") from exc
504
+ if len(raw) != 6:
505
+ raise EncodeError(f"Field {field.name!r}: MAC address must contain six bytes")
506
+ return raw[::-1] if field.byteorder == "little" else raw
structwire/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """StructWire exception hierarchy."""
2
+
3
+
4
+ class StructWireError(Exception):
5
+ """Base class for all StructWire errors."""
6
+
7
+
8
+ class SchemaError(StructWireError, ValueError):
9
+ """Raised when a layout is invalid or ambiguous."""
10
+
11
+
12
+ class EncodeError(StructWireError, ValueError):
13
+ """Raised when a value cannot be represented by a layout."""
14
+
15
+
16
+ class DecodeError(StructWireError, ValueError):
17
+ """Raised when bytes do not satisfy a layout."""
@@ -0,0 +1,38 @@
1
+ """Optional human-readable formatting helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def format_mac_address(data: bytes | bytearray | memoryview, *, byteorder: str = "big") -> str:
9
+ """Format six address bytes as uppercase, colon-separated hexadecimal."""
10
+ if not isinstance(data, (bytes, bytearray, memoryview)):
11
+ raise ValueError("MAC address must be bytes, bytearray or memoryview")
12
+ raw = bytes(data)
13
+ if len(raw) != 6:
14
+ raise ValueError("MAC address requires exactly six bytes")
15
+ if byteorder == "little":
16
+ raw = raw[::-1]
17
+ elif byteorder != "big":
18
+ raise ValueError("byteorder must be 'big' or 'little'")
19
+ return ":".join(f"{value:02X}" for value in raw)
20
+
21
+
22
+ def format_value(value: Any) -> Any:
23
+ """Recursively format byte strings as uppercase, space-separated hex."""
24
+ if isinstance(value, (bytes, bytearray, memoryview)):
25
+ raw = bytes(value)
26
+ return "<empty>" if not raw else raw.hex(" ").upper()
27
+ if isinstance(value, dict):
28
+ return {key: format_value(item) for key, item in value.items()}
29
+ if isinstance(value, list):
30
+ return [format_value(item) for item in value]
31
+ if isinstance(value, tuple):
32
+ return tuple(format_value(item) for item in value)
33
+ return value
34
+
35
+
36
+ def format_record(record: dict[str, Any]) -> dict[str, Any]:
37
+ """Return a new record with bytes formatted recursively by :func:`format_value`."""
38
+ return {key: format_value(value) for key, value in record.items()}