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 +33 -0
- structwire/catalog.py +84 -0
- structwire/codec.py +506 -0
- structwire/errors.py +17 -0
- structwire/formatting.py +38 -0
- structwire/loaders.py +234 -0
- structwire/model.py +330 -0
- structwire/py.typed +0 -0
- structwire-0.1.0.dist-info/METADATA +148 -0
- structwire-0.1.0.dist-info/RECORD +13 -0
- structwire-0.1.0.dist-info/WHEEL +5 -0
- structwire-0.1.0.dist-info/licenses/LICENSE +21 -0
- structwire-0.1.0.dist-info/top_level.txt +1 -0
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."""
|
structwire/formatting.py
ADDED
|
@@ -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()}
|