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/loaders.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Load layouts and catalogs from Python mappings or YAML files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .catalog import Catalog
|
|
10
|
+
from .errors import SchemaError
|
|
11
|
+
from .model import Field, Layout
|
|
12
|
+
|
|
13
|
+
_LAYOUT_KEYS = {"name", "description", "size", "fields"}
|
|
14
|
+
_FIELD_KEYS = {
|
|
15
|
+
"name",
|
|
16
|
+
"kind",
|
|
17
|
+
"type",
|
|
18
|
+
"length",
|
|
19
|
+
"offset",
|
|
20
|
+
"mask",
|
|
21
|
+
"bitmask",
|
|
22
|
+
"shift",
|
|
23
|
+
"bitshift",
|
|
24
|
+
"scale",
|
|
25
|
+
"bias",
|
|
26
|
+
"physical_offset",
|
|
27
|
+
"values",
|
|
28
|
+
"bits",
|
|
29
|
+
"byteorder",
|
|
30
|
+
"description",
|
|
31
|
+
"unit",
|
|
32
|
+
}
|
|
33
|
+
_CATALOG_KEYS = {"version", "codecs"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _intish(value: Any) -> int:
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
raise ValueError("boolean is not an integer value here")
|
|
39
|
+
if isinstance(value, int):
|
|
40
|
+
return value
|
|
41
|
+
return int(str(value), 0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _expect_keys(data: Mapping[str, Any], allowed: set[str], where: str) -> None:
|
|
45
|
+
if not isinstance(data, Mapping):
|
|
46
|
+
raise SchemaError(f"{where} must be a mapping")
|
|
47
|
+
unknown = sorted(str(key) for key in data if key not in allowed)
|
|
48
|
+
if unknown:
|
|
49
|
+
raise SchemaError(f"{where}: unknown key(s): {', '.join(unknown)}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _enum_values(data: Mapping[Any, Any]) -> dict[int, str]:
|
|
53
|
+
values: dict[int, str] = {}
|
|
54
|
+
for key, label in data.items():
|
|
55
|
+
raw = _intish(key)
|
|
56
|
+
if raw in values:
|
|
57
|
+
raise SchemaError(f"Duplicate enum value after normalization: {raw}")
|
|
58
|
+
values[raw] = label
|
|
59
|
+
return values
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _floatish(value: Any) -> float:
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
raise ValueError("boolean is not a scale or bias")
|
|
65
|
+
return float(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _alias_value(
|
|
69
|
+
data: Mapping[str, Any],
|
|
70
|
+
primary: str,
|
|
71
|
+
alias: str,
|
|
72
|
+
*,
|
|
73
|
+
default: Any = None,
|
|
74
|
+
where: str,
|
|
75
|
+
) -> Any:
|
|
76
|
+
has_primary = primary in data
|
|
77
|
+
has_alias = alias in data
|
|
78
|
+
if has_primary and has_alias and data[primary] != data[alias]:
|
|
79
|
+
raise SchemaError(f"{where}: {primary!r} and compatibility alias {alias!r} disagree")
|
|
80
|
+
if has_primary:
|
|
81
|
+
return data[primary]
|
|
82
|
+
if has_alias:
|
|
83
|
+
return data[alias]
|
|
84
|
+
return default
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def layout_from_mapping(data: Mapping[str, Any], *, default_name: str = "") -> Layout:
|
|
88
|
+
"""Build a :class:`Layout` from a plain mapping.
|
|
89
|
+
|
|
90
|
+
Accepts the aliases ``type``, ``bitmask``, ``bitshift`` and ``physical_offset``.
|
|
91
|
+
Unknown keys and conflicting aliases raise ``SchemaError``. Compile the
|
|
92
|
+
returned layout to validate overlaps and total size.
|
|
93
|
+
"""
|
|
94
|
+
_expect_keys(data, _LAYOUT_KEYS, "layout")
|
|
95
|
+
raw_fields = data.get("fields")
|
|
96
|
+
if not isinstance(raw_fields, list):
|
|
97
|
+
raise SchemaError("Layout mapping must contain a 'fields' list")
|
|
98
|
+
|
|
99
|
+
fields: list[Field] = []
|
|
100
|
+
for index, raw in enumerate(raw_fields):
|
|
101
|
+
where = f"fields[{index}]"
|
|
102
|
+
if not isinstance(raw, Mapping):
|
|
103
|
+
raise SchemaError(f"{where} must be a mapping")
|
|
104
|
+
_expect_keys(raw, _FIELD_KEYS, where)
|
|
105
|
+
|
|
106
|
+
name = raw.get("name")
|
|
107
|
+
kind = _alias_value(raw, "kind", "type", where=where)
|
|
108
|
+
if not isinstance(name, str) or not name.strip():
|
|
109
|
+
raise SchemaError(f"{where}.name must be a non-empty string")
|
|
110
|
+
if not isinstance(kind, str) or not kind.strip():
|
|
111
|
+
raise SchemaError(f"{where}.kind/type must be a non-empty string")
|
|
112
|
+
|
|
113
|
+
mask_raw = _alias_value(raw, "mask", "bitmask", where=where)
|
|
114
|
+
shift_raw = _alias_value(raw, "shift", "bitshift", default=0, where=where)
|
|
115
|
+
bias_raw = _alias_value(raw, "bias", "physical_offset", default=0.0, where=where)
|
|
116
|
+
values_raw = raw.get("values", {})
|
|
117
|
+
bits_raw = raw.get("bits", {})
|
|
118
|
+
if not isinstance(values_raw, Mapping) or not isinstance(bits_raw, Mapping):
|
|
119
|
+
raise SchemaError(f"{where}: values/bits must be mappings")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
field = Field(
|
|
123
|
+
name=name,
|
|
124
|
+
kind=kind,
|
|
125
|
+
length=_intish(raw["length"]) if raw.get("length") is not None else None,
|
|
126
|
+
offset=_intish(raw["offset"]) if raw.get("offset") is not None else None,
|
|
127
|
+
mask=_intish(mask_raw) if mask_raw is not None else None,
|
|
128
|
+
shift=_intish(shift_raw),
|
|
129
|
+
scale=_floatish(raw.get("scale", 1.0)),
|
|
130
|
+
bias=_floatish(bias_raw),
|
|
131
|
+
values=_enum_values(values_raw),
|
|
132
|
+
bits={k: _intish(v) for k, v in bits_raw.items()},
|
|
133
|
+
byteorder=raw.get("byteorder", "big"),
|
|
134
|
+
description=raw.get("description", ""),
|
|
135
|
+
unit=raw.get("unit", ""),
|
|
136
|
+
)
|
|
137
|
+
except SchemaError:
|
|
138
|
+
raise
|
|
139
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
140
|
+
raise SchemaError(f"{where}: invalid field value: {exc}") from exc
|
|
141
|
+
fields.append(field)
|
|
142
|
+
|
|
143
|
+
name = data.get("name", default_name)
|
|
144
|
+
if not isinstance(name, str):
|
|
145
|
+
raise SchemaError("Layout name must be a string")
|
|
146
|
+
name = name.strip()
|
|
147
|
+
if default_name and name != default_name:
|
|
148
|
+
raise SchemaError(f"Catalog key {default_name!r} conflicts with layout name {name!r}")
|
|
149
|
+
size_raw = data.get("size")
|
|
150
|
+
try:
|
|
151
|
+
size = _intish(size_raw) if size_raw is not None else None
|
|
152
|
+
except (TypeError, ValueError) as exc:
|
|
153
|
+
raise SchemaError(f"layout.size: invalid integer value {size_raw!r}") from exc
|
|
154
|
+
return Layout(
|
|
155
|
+
*fields,
|
|
156
|
+
name=name,
|
|
157
|
+
description=data.get("description", ""),
|
|
158
|
+
size=size,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def catalog_from_mapping(data: Mapping[str, Any]) -> Catalog:
|
|
163
|
+
"""Load and compile a catalog from a mapping.
|
|
164
|
+
|
|
165
|
+
``codecs`` maps names to layout definitions. ``version`` defaults to 1,
|
|
166
|
+
the only supported schema version.
|
|
167
|
+
"""
|
|
168
|
+
_expect_keys(data, _CATALOG_KEYS, "catalog")
|
|
169
|
+
version = data.get("version", 1)
|
|
170
|
+
try:
|
|
171
|
+
parsed_version = _intish(version)
|
|
172
|
+
except (TypeError, ValueError) as exc:
|
|
173
|
+
raise SchemaError("Catalog version must be an integer") from exc
|
|
174
|
+
if parsed_version != 1:
|
|
175
|
+
raise SchemaError(f"Unsupported catalog version: {parsed_version}")
|
|
176
|
+
|
|
177
|
+
raw_codecs = data.get("codecs")
|
|
178
|
+
if not isinstance(raw_codecs, Mapping) or not raw_codecs:
|
|
179
|
+
raise SchemaError("Catalog mapping must contain a non-empty 'codecs' mapping")
|
|
180
|
+
|
|
181
|
+
layouts: dict[str, Layout] = {}
|
|
182
|
+
for raw_name, raw_layout in raw_codecs.items():
|
|
183
|
+
if not isinstance(raw_name, str) or not raw_name.strip():
|
|
184
|
+
raise SchemaError("Catalog codec names must be non-empty strings")
|
|
185
|
+
name = raw_name.strip()
|
|
186
|
+
if name in layouts:
|
|
187
|
+
raise SchemaError(f"Duplicate codec name: {name!r}")
|
|
188
|
+
if not isinstance(raw_layout, Mapping):
|
|
189
|
+
raise SchemaError(f"codecs.{name} must be a mapping")
|
|
190
|
+
layouts[name] = layout_from_mapping(raw_layout, default_name=name)
|
|
191
|
+
return Catalog(layouts)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _yaml_load(path: str | Path) -> Mapping[str, Any]:
|
|
195
|
+
import yaml
|
|
196
|
+
|
|
197
|
+
class UniqueSafeLoader(yaml.SafeLoader):
|
|
198
|
+
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
|
|
199
|
+
if not isinstance(node, yaml.MappingNode):
|
|
200
|
+
raise SchemaError("YAML mapping expected")
|
|
201
|
+
self.flatten_mapping(node)
|
|
202
|
+
result: dict[Any, Any] = {}
|
|
203
|
+
for key_node, value_node in node.value:
|
|
204
|
+
key = self.construct_object(key_node, deep=deep)
|
|
205
|
+
try:
|
|
206
|
+
if key in result:
|
|
207
|
+
raise SchemaError(
|
|
208
|
+
f"Duplicate YAML key {key!r} at line {key_node.start_mark.line + 1}"
|
|
209
|
+
)
|
|
210
|
+
result[key] = self.construct_object(value_node, deep=deep)
|
|
211
|
+
except TypeError as exc:
|
|
212
|
+
raise SchemaError("YAML mapping keys must be hashable") from exc
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
with Path(path).open("r", encoding="utf-8") as handle:
|
|
216
|
+
try:
|
|
217
|
+
data = yaml.load(handle, Loader=UniqueSafeLoader)
|
|
218
|
+
except SchemaError:
|
|
219
|
+
raise
|
|
220
|
+
except (yaml.YAMLError, ValueError, KeyError) as exc:
|
|
221
|
+
raise SchemaError(f"Invalid YAML schema: {exc}") from exc
|
|
222
|
+
if not isinstance(data, Mapping):
|
|
223
|
+
raise SchemaError("YAML root must be a mapping")
|
|
224
|
+
return data
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def layout_from_yaml(path: str | Path) -> Layout:
|
|
228
|
+
"""Load one layout from YAML."""
|
|
229
|
+
return layout_from_mapping(_yaml_load(path))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def catalog_from_yaml(path: str | Path) -> Catalog:
|
|
233
|
+
"""Load a named codec catalog from YAML."""
|
|
234
|
+
return catalog_from_mapping(_yaml_load(path))
|
structwire/model.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Immutable public schema model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from math import isfinite
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
10
|
+
|
|
11
|
+
from .errors import SchemaError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .codec import Codec
|
|
15
|
+
|
|
16
|
+
_INTEGER_KINDS: dict[str, tuple[int, bool, Literal["big", "little"]]] = {
|
|
17
|
+
"u8": (1, False, "big"),
|
|
18
|
+
"i8": (1, True, "big"),
|
|
19
|
+
"u16be": (2, False, "big"),
|
|
20
|
+
"u16le": (2, False, "little"),
|
|
21
|
+
"i16be": (2, True, "big"),
|
|
22
|
+
"i16le": (2, True, "little"),
|
|
23
|
+
"u24be": (3, False, "big"),
|
|
24
|
+
"u24le": (3, False, "little"),
|
|
25
|
+
"i24be": (3, True, "big"),
|
|
26
|
+
"i24le": (3, True, "little"),
|
|
27
|
+
"u32be": (4, False, "big"),
|
|
28
|
+
"u32le": (4, False, "little"),
|
|
29
|
+
"i32be": (4, True, "big"),
|
|
30
|
+
"i32le": (4, True, "little"),
|
|
31
|
+
"u64be": (8, False, "big"),
|
|
32
|
+
"u64le": (8, False, "little"),
|
|
33
|
+
"i64be": (8, True, "big"),
|
|
34
|
+
"i64le": (8, True, "little"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_FLOAT_KINDS: dict[str, tuple[int, Literal["big", "little"]]] = {
|
|
38
|
+
"f32be": (4, "big"),
|
|
39
|
+
"f32le": (4, "little"),
|
|
40
|
+
"f64be": (8, "big"),
|
|
41
|
+
"f64le": (8, "little"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_ALIASES = {
|
|
45
|
+
"uint8": "u8",
|
|
46
|
+
"int8": "i8",
|
|
47
|
+
"uint16_be": "u16be",
|
|
48
|
+
"uint16_le": "u16le",
|
|
49
|
+
"int16_be": "i16be",
|
|
50
|
+
"int16_le": "i16le",
|
|
51
|
+
"uint24_be": "u24be",
|
|
52
|
+
"uint24_le": "u24le",
|
|
53
|
+
"int24_be": "i24be",
|
|
54
|
+
"int24_le": "i24le",
|
|
55
|
+
"uint32_be": "u32be",
|
|
56
|
+
"uint32_le": "u32le",
|
|
57
|
+
"int32_be": "i32be",
|
|
58
|
+
"int32_le": "i32le",
|
|
59
|
+
"uint64_be": "u64be",
|
|
60
|
+
"uint64_le": "u64le",
|
|
61
|
+
"int64_be": "i64be",
|
|
62
|
+
"int64_le": "i64le",
|
|
63
|
+
"float32_be": "f32be",
|
|
64
|
+
"float32_le": "f32le",
|
|
65
|
+
"float64_be": "f64be",
|
|
66
|
+
"float64_le": "f64le",
|
|
67
|
+
"raw": "bytes",
|
|
68
|
+
"mac_address": "mac",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_SPECIAL_KINDS = {"bcd", "ascii", "bytes", "mac", "enum", "flags", "bool"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _schema_integer(value: Any, where: str) -> int:
|
|
75
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
76
|
+
raise SchemaError(f"{where} must be an integer")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def canonical_kind(kind: str) -> str:
|
|
81
|
+
"""Return a stable kind name, accepting compatibility aliases."""
|
|
82
|
+
if not isinstance(kind, str):
|
|
83
|
+
raise SchemaError("Field kind must be a string")
|
|
84
|
+
key = kind.strip().lower().replace("-", "_")
|
|
85
|
+
canonical = _ALIASES.get(key, key)
|
|
86
|
+
if (
|
|
87
|
+
canonical not in _INTEGER_KINDS
|
|
88
|
+
and canonical not in _FLOAT_KINDS
|
|
89
|
+
and canonical not in _SPECIAL_KINDS
|
|
90
|
+
):
|
|
91
|
+
raise SchemaError(f"Unsupported field kind: {kind!r}")
|
|
92
|
+
return canonical
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def integer_spec(kind: str) -> tuple[int, bool, Literal["big", "little"]] | None:
|
|
96
|
+
"""Return ``(width, signed, byteorder)`` for an integer kind."""
|
|
97
|
+
return _INTEGER_KINDS.get(canonical_kind(kind))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def float_spec(kind: str) -> tuple[int, Literal["big", "little"]] | None:
|
|
101
|
+
"""Return ``(width, byteorder)`` for a floating-point kind."""
|
|
102
|
+
return _FLOAT_KINDS.get(canonical_kind(kind))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _logical_bits(width: int, mask: int | None, shift: int) -> int:
|
|
106
|
+
if mask is None:
|
|
107
|
+
return width * 8
|
|
108
|
+
return (mask >> shift).bit_length()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class Field:
|
|
113
|
+
"""One field in a binary layout.
|
|
114
|
+
|
|
115
|
+
``offset`` is a byte offset. When omitted, the field is placed after the
|
|
116
|
+
furthest byte occupied so far. ``mask`` and ``shift`` address sub-fields
|
|
117
|
+
within an integer-sized byte span.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
name: str
|
|
121
|
+
kind: str
|
|
122
|
+
length: int | None = None
|
|
123
|
+
offset: int | None = None
|
|
124
|
+
mask: int | None = None
|
|
125
|
+
shift: int = 0
|
|
126
|
+
scale: float = 1.0
|
|
127
|
+
bias: float = 0.0
|
|
128
|
+
values: Mapping[int, str] = field(default_factory=dict)
|
|
129
|
+
bits: Mapping[str, int] = field(default_factory=dict)
|
|
130
|
+
byteorder: str = "big"
|
|
131
|
+
description: str = ""
|
|
132
|
+
unit: str = ""
|
|
133
|
+
|
|
134
|
+
def __post_init__(self) -> None:
|
|
135
|
+
if not isinstance(self.name, str) or not self.name.strip():
|
|
136
|
+
raise SchemaError("Field name must be non-empty")
|
|
137
|
+
kind = canonical_kind(self.kind)
|
|
138
|
+
object.__setattr__(self, "kind", kind)
|
|
139
|
+
for attribute in ("offset", "length", "mask"):
|
|
140
|
+
value = getattr(self, attribute)
|
|
141
|
+
if value is not None:
|
|
142
|
+
_schema_integer(value, f"Field {self.name!r}: {attribute}")
|
|
143
|
+
_schema_integer(self.shift, f"Field {self.name!r}: shift")
|
|
144
|
+
if self.offset is not None and self.offset < 0:
|
|
145
|
+
raise SchemaError(f"Field {self.name!r}: offset must be non-negative")
|
|
146
|
+
if self.length is not None and self.length < 1:
|
|
147
|
+
raise SchemaError(f"Field {self.name!r}: length must be >= 1")
|
|
148
|
+
if self.shift < 0:
|
|
149
|
+
raise SchemaError(f"Field {self.name!r}: shift must be non-negative")
|
|
150
|
+
if self.shift and self.mask is None:
|
|
151
|
+
raise SchemaError(f"Field {self.name!r}: shift requires a mask")
|
|
152
|
+
for attribute in ("scale", "bias"):
|
|
153
|
+
value = getattr(self, attribute)
|
|
154
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
155
|
+
raise SchemaError(f"Field {self.name!r}: {attribute} must be a finite number")
|
|
156
|
+
try:
|
|
157
|
+
finite = isfinite(value)
|
|
158
|
+
except OverflowError:
|
|
159
|
+
finite = False
|
|
160
|
+
if not finite:
|
|
161
|
+
raise SchemaError(f"Field {self.name!r}: scale and bias must be finite")
|
|
162
|
+
if self.scale == 0:
|
|
163
|
+
raise SchemaError(f"Field {self.name!r}: scale cannot be zero")
|
|
164
|
+
if self.byteorder not in ("big", "little"):
|
|
165
|
+
raise SchemaError(f"Field {self.name!r}: byteorder must be 'big' or 'little'")
|
|
166
|
+
for attribute in ("description", "unit"):
|
|
167
|
+
if not isinstance(getattr(self, attribute), str):
|
|
168
|
+
raise SchemaError(f"Field {self.name!r}: {attribute} must be a string")
|
|
169
|
+
if not isinstance(self.values, Mapping) or not isinstance(self.bits, Mapping):
|
|
170
|
+
raise SchemaError(f"Field {self.name!r}: values/bits must be mappings")
|
|
171
|
+
if self.values and kind != "enum":
|
|
172
|
+
raise SchemaError(f"Field {self.name!r}: values is only valid for enum fields")
|
|
173
|
+
if self.bits and kind != "flags":
|
|
174
|
+
raise SchemaError(f"Field {self.name!r}: bits is only valid for flags fields")
|
|
175
|
+
if any(not isinstance(label, str) for label in self.values.values()):
|
|
176
|
+
raise SchemaError(f"Field {self.name!r}: enum labels must be strings")
|
|
177
|
+
if any(not isinstance(name, str) or not name.strip() for name in self.bits):
|
|
178
|
+
raise SchemaError(f"Field {self.name!r}: flag names must be non-empty strings")
|
|
179
|
+
|
|
180
|
+
values = {
|
|
181
|
+
_schema_integer(k, f"Field {self.name!r}: enum key"): v for k, v in self.values.items()
|
|
182
|
+
}
|
|
183
|
+
bits = {
|
|
184
|
+
k: _schema_integer(v, f"Field {self.name!r}: flag mask") for k, v in self.bits.items()
|
|
185
|
+
}
|
|
186
|
+
if len(set(values.values())) != len(values):
|
|
187
|
+
raise SchemaError(f"Field {self.name!r}: enum labels must be unique for encoding")
|
|
188
|
+
object.__setattr__(self, "values", MappingProxyType(values))
|
|
189
|
+
object.__setattr__(self, "bits", MappingProxyType(bits))
|
|
190
|
+
|
|
191
|
+
if kind in {"ascii", "bytes"} and self.length is None:
|
|
192
|
+
raise SchemaError(f"Field {self.name!r}: {kind} requires length")
|
|
193
|
+
width = self.width
|
|
194
|
+
if (kind in _INTEGER_KINDS or kind in _FLOAT_KINDS) and self.length not in (None, width):
|
|
195
|
+
raise SchemaError(f"Field {self.name!r}: {kind} is exactly {width} byte(s)")
|
|
196
|
+
if self.mask is not None:
|
|
197
|
+
if self.mask <= 0 or self.mask.bit_length() > width * 8:
|
|
198
|
+
raise SchemaError(
|
|
199
|
+
f"Field {self.name!r}: mask 0x{self.mask:X} does not fit {width} byte(s)"
|
|
200
|
+
)
|
|
201
|
+
shifted = self.mask >> self.shift
|
|
202
|
+
if shifted == 0 or shifted & (shifted + 1):
|
|
203
|
+
raise SchemaError(
|
|
204
|
+
f"Field {self.name!r}: mask must become contiguous low bits after shift"
|
|
205
|
+
)
|
|
206
|
+
if shifted << self.shift != self.mask:
|
|
207
|
+
raise SchemaError(f"Field {self.name!r}: shift does not match mask")
|
|
208
|
+
|
|
209
|
+
if kind == "mac" and self.length not in {None, 6}:
|
|
210
|
+
raise SchemaError(f"Field {self.name!r}: mac fields are exactly 6 bytes")
|
|
211
|
+
if self.mask is not None and (
|
|
212
|
+
kind in _FLOAT_KINDS or kind in {"bcd", "ascii", "bytes", "mac"}
|
|
213
|
+
):
|
|
214
|
+
raise SchemaError(f"Field {self.name!r}: mask is not valid for {kind}")
|
|
215
|
+
if kind in {"enum", "flags", "bool"} and not 1 <= width <= 8:
|
|
216
|
+
raise SchemaError(f"Field {self.name!r}: {kind} width must be between 1 and 8 bytes")
|
|
217
|
+
if kind in {"enum", "flags"}:
|
|
218
|
+
logical_bits = _logical_bits(width, self.mask, self.shift)
|
|
219
|
+
logical_max = (1 << logical_bits) - 1
|
|
220
|
+
if kind == "enum":
|
|
221
|
+
if not values:
|
|
222
|
+
raise SchemaError(f"Field {self.name!r}: enum requires values")
|
|
223
|
+
invalid = [raw for raw in values if raw < 0 or raw > logical_max]
|
|
224
|
+
if invalid:
|
|
225
|
+
raise SchemaError(
|
|
226
|
+
f"Field {self.name!r}: enum value does not fit {logical_bits} bit(s)"
|
|
227
|
+
)
|
|
228
|
+
if kind == "flags":
|
|
229
|
+
if not bits:
|
|
230
|
+
raise SchemaError(f"Field {self.name!r}: flags requires bits")
|
|
231
|
+
invalid = [mask for mask in bits.values() if mask <= 0 or mask > logical_max]
|
|
232
|
+
if invalid:
|
|
233
|
+
raise SchemaError(
|
|
234
|
+
f"Field {self.name!r}: flag mask 0x{invalid[0]:X} does not fit "
|
|
235
|
+
f"{logical_bits} bit(s)"
|
|
236
|
+
)
|
|
237
|
+
if kind in {"ascii", "bytes", "mac", "flags", "enum", "bool"} and (
|
|
238
|
+
self.scale != 1.0 or self.bias != 0.0
|
|
239
|
+
):
|
|
240
|
+
raise SchemaError(f"Field {self.name!r}: scale/bias is not valid for {kind}")
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def width(self) -> int:
|
|
244
|
+
"""Field width in bytes."""
|
|
245
|
+
int_spec = _INTEGER_KINDS.get(self.kind)
|
|
246
|
+
if int_spec is not None:
|
|
247
|
+
return int_spec[0]
|
|
248
|
+
flt_spec = _FLOAT_KINDS.get(self.kind)
|
|
249
|
+
if flt_spec is not None:
|
|
250
|
+
return flt_spec[0]
|
|
251
|
+
if self.kind == "mac":
|
|
252
|
+
return 6
|
|
253
|
+
if self.kind == "bcd":
|
|
254
|
+
return self.length or 1
|
|
255
|
+
if self.kind in {"enum", "flags", "bool"}:
|
|
256
|
+
return self.length or 1
|
|
257
|
+
assert self.length is not None
|
|
258
|
+
return self.length
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def effective_byteorder(self) -> Literal["big", "little"]:
|
|
262
|
+
"""Kind-defined numeric order, otherwise the supplied byteorder.
|
|
263
|
+
|
|
264
|
+
BCD, ASCII and bytes retain wire order regardless of this parameter.
|
|
265
|
+
"""
|
|
266
|
+
spec = _INTEGER_KINDS.get(self.kind)
|
|
267
|
+
if spec is not None:
|
|
268
|
+
return spec[2]
|
|
269
|
+
flt = _FLOAT_KINDS.get(self.kind)
|
|
270
|
+
if flt is not None:
|
|
271
|
+
return flt[1]
|
|
272
|
+
return cast(Literal["big", "little"], self.byteorder)
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def signed(self) -> bool:
|
|
276
|
+
spec = _INTEGER_KINDS.get(self.kind)
|
|
277
|
+
return bool(spec and spec[1])
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, slots=True)
|
|
281
|
+
class Layout:
|
|
282
|
+
"""Immutable declarative binary layout."""
|
|
283
|
+
|
|
284
|
+
fields: tuple[Field, ...]
|
|
285
|
+
name: str = ""
|
|
286
|
+
description: str = ""
|
|
287
|
+
size: int | None = None
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
*fields: Field,
|
|
292
|
+
name: str = "",
|
|
293
|
+
description: str = "",
|
|
294
|
+
size: int | None = None,
|
|
295
|
+
) -> None:
|
|
296
|
+
if size is not None:
|
|
297
|
+
_schema_integer(size, "Layout size")
|
|
298
|
+
if not isinstance(name, str):
|
|
299
|
+
raise SchemaError("Layout name must be a string")
|
|
300
|
+
if not isinstance(description, str):
|
|
301
|
+
raise SchemaError("Layout description must be a string")
|
|
302
|
+
if any(not isinstance(item, Field) for item in fields):
|
|
303
|
+
raise SchemaError("Layout fields must be Field objects")
|
|
304
|
+
object.__setattr__(self, "fields", tuple(fields))
|
|
305
|
+
object.__setattr__(self, "name", name)
|
|
306
|
+
object.__setattr__(self, "description", description)
|
|
307
|
+
object.__setattr__(self, "size", size)
|
|
308
|
+
if size is not None and size < 0:
|
|
309
|
+
raise SchemaError("Layout size must be non-negative")
|
|
310
|
+
if len({item.name for item in fields}) != len(fields):
|
|
311
|
+
raise SchemaError("Field names must be unique")
|
|
312
|
+
|
|
313
|
+
def compile(self) -> Codec:
|
|
314
|
+
"""Compile this layout into a reusable codec."""
|
|
315
|
+
from .codec import Codec
|
|
316
|
+
|
|
317
|
+
return Codec(self)
|
|
318
|
+
|
|
319
|
+
def encode(self, values: Mapping[str, Any], *, require_all: bool = True) -> bytes:
|
|
320
|
+
"""Compile and encode in one call."""
|
|
321
|
+
return self.compile().encode(values, require_all=require_all)
|
|
322
|
+
|
|
323
|
+
def decode(
|
|
324
|
+
self,
|
|
325
|
+
payload: bytes | bytearray | memoryview,
|
|
326
|
+
*,
|
|
327
|
+
strict: bool = True,
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
"""Compile and decode in one call."""
|
|
330
|
+
return self.compile().decode(payload, strict=strict)
|
structwire/py.typed
ADDED
|
File without changes
|