sltcodec 0.3.0__tar.gz → 0.5.0__tar.gz
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.
- {sltcodec-0.3.0 → sltcodec-0.5.0}/PKG-INFO +2 -2
- {sltcodec-0.3.0 → sltcodec-0.5.0}/pyproject.toml +2 -2
- {sltcodec-0.3.0 → sltcodec-0.5.0}/src/sltcodec/__init__.py +2 -1
- {sltcodec-0.3.0 → sltcodec-0.5.0}/src/sltcodec/codec.py +103 -30
- {sltcodec-0.3.0 → sltcodec-0.5.0}/src/sltcodec/types.py +291 -128
- {sltcodec-0.3.0 → sltcodec-0.5.0}/tests/test_codec.py +238 -13
- {sltcodec-0.3.0 → sltcodec-0.5.0}/uv.lock +5 -5
- {sltcodec-0.3.0 → sltcodec-0.5.0}/.github/workflows/publish_to_pypi.yml +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/.github/workflows/publish_to_testpypi.yml +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/.gitignore +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/.python-version +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/.vscode/launch.json +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/.vscode/settings.json +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/LICENSE +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/README.md +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/bump_major.ps1 +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/bump_minor.ps1 +0 -0
- {sltcodec-0.3.0 → sltcodec-0.5.0}/bump_patch.ps1 +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sltcodec
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Decode and encode bytearrays according to struct layout definitions using sltcore.
|
|
5
5
|
Project-URL: Homepage, https://github.com/fangface-hub/StructLayoutToolkitCodec
|
|
6
6
|
Project-URL: Documentation, https://readthedocs.org
|
|
@@ -12,7 +12,7 @@ License-File: LICENSE
|
|
|
12
12
|
Keywords: byte,deserialization,layout,serialization,struct
|
|
13
13
|
Requires-Python: >=3.12
|
|
14
14
|
Requires-Dist: sltcalc>=0.1.0
|
|
15
|
-
Requires-Dist: sltcore>=1.
|
|
15
|
+
Requires-Dist: sltcore>=1.5.0
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
|
|
18
18
|
# StructLayoutToolkitCodec
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sltcodec"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Decode and encode bytearrays according to struct layout definitions using sltcore."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
dependencies = [
|
|
8
8
|
"sltcalc>=0.1.0",
|
|
9
|
-
"sltcore>=1.
|
|
9
|
+
"sltcore>=1.5.0",
|
|
10
10
|
]
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from .codec import (decode, encode, encode_field, load_struct_def_dict,
|
|
2
2
|
save_struct_def_dict)
|
|
3
|
-
from .types import FieldDef, FieldInstance, StructDef, StructInstance
|
|
3
|
+
from .types import EnumDef, FieldDef, FieldInstance, StructDef, StructInstance
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
|
+
"EnumDef",
|
|
6
7
|
"FieldDef",
|
|
7
8
|
"FieldInstance",
|
|
8
9
|
"StructDef",
|
|
@@ -8,7 +8,7 @@ from typing import Any
|
|
|
8
8
|
from sltcalc import SltEval
|
|
9
9
|
from sltcore import Info, InfoSize, bits_get, bits_set
|
|
10
10
|
|
|
11
|
-
from .types import FieldDef, FieldInstance, StructDef, StructInstance
|
|
11
|
+
from .types import EnumDef, FieldDef, FieldInstance, StructDef, StructInstance
|
|
12
12
|
|
|
13
13
|
_PRIMITIVE_TYPES = {
|
|
14
14
|
"bool",
|
|
@@ -20,6 +20,8 @@ _PRIMITIVE_TYPES = {
|
|
|
20
20
|
"bytes",
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
_DEFAULT_PADDING_ALIGNMENT_BITS = 32
|
|
24
|
+
|
|
23
25
|
|
|
24
26
|
def _as_field_defs(struct_def: StructDef | list[FieldDef]) -> list[FieldDef]:
|
|
25
27
|
"""Normalize StructDef/list inputs to a list of field definitions."""
|
|
@@ -96,6 +98,16 @@ def _info_size_from_bits(total_bits: int) -> InfoSize:
|
|
|
96
98
|
return InfoSize(total_bits >> 3, total_bits & 0x7)
|
|
97
99
|
|
|
98
100
|
|
|
101
|
+
def _validate_padding_alignment_bits(padding_alignment_bits: int) -> None:
|
|
102
|
+
"""Validate padding alignment as a positive power-of-two bit size."""
|
|
103
|
+
if (isinstance(padding_alignment_bits, bool)
|
|
104
|
+
or not isinstance(padding_alignment_bits, int)
|
|
105
|
+
or padding_alignment_bits <= 0
|
|
106
|
+
or (padding_alignment_bits & (padding_alignment_bits - 1)) != 0):
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"padding_alignment_bits must be a positive power of two")
|
|
109
|
+
|
|
110
|
+
|
|
99
111
|
def _resolve_field_type(field_type: str | StructDef,
|
|
100
112
|
struct_def_dict: dict[str, StructDef] | None = None,
|
|
101
113
|
env: dict[str, Any] | None = None) -> str | StructDef:
|
|
@@ -141,7 +153,9 @@ def _prepare_field_info(
|
|
|
141
153
|
field_def: FieldDef,
|
|
142
154
|
value: Any,
|
|
143
155
|
env: dict[str, Any],
|
|
144
|
-
struct_def_dict: dict[str, StructDef] | None = None
|
|
156
|
+
struct_def_dict: dict[str, StructDef] | None = None,
|
|
157
|
+
enum_def_dict: dict[str, EnumDef] | None = None,
|
|
158
|
+
padding_alignment_bits: int = _DEFAULT_PADDING_ALIGNMENT_BITS,
|
|
145
159
|
) -> tuple[InfoSize, InfoSize, Info] | None:
|
|
146
160
|
"""Resolve a field definition into an offset, size, and info payload."""
|
|
147
161
|
offset = _resolve_offset(field_def, env)
|
|
@@ -155,7 +169,11 @@ def _prepare_field_info(
|
|
|
155
169
|
raise TypeError(
|
|
156
170
|
"Nested StructDef fields must be encoded with StructInstance "
|
|
157
171
|
"values")
|
|
158
|
-
nested_bytes = encode(value,
|
|
172
|
+
nested_bytes = encode(value,
|
|
173
|
+
bytearray(),
|
|
174
|
+
struct_def_dict,
|
|
175
|
+
enum_def_dict,
|
|
176
|
+
padding_alignment_bits=padding_alignment_bits)
|
|
159
177
|
info = Info.from_bytes(bytes(nested_bytes), size, scale=field_def.scale)
|
|
160
178
|
else:
|
|
161
179
|
info = _encode_primitive(resolved_type, value, size, field_def.scale)
|
|
@@ -175,16 +193,21 @@ def _split_repeated_field(field_def: FieldDef,
|
|
|
175
193
|
size=resolved_size)
|
|
176
194
|
|
|
177
195
|
|
|
178
|
-
def encode_field(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
196
|
+
def encode_field(
|
|
197
|
+
field_def: FieldDef,
|
|
198
|
+
value: Any,
|
|
199
|
+
buf: bytearray,
|
|
200
|
+
env: dict[str, Any] | None = None,
|
|
201
|
+
struct_def_dict: dict[str, StructDef] | None = None,
|
|
202
|
+
enum_def_dict: dict[str, EnumDef] | None = None,
|
|
203
|
+
padding_alignment_bits: int = _DEFAULT_PADDING_ALIGNMENT_BITS,
|
|
204
|
+
) -> None:
|
|
183
205
|
"""Encode a single field into a bytearray."""
|
|
184
206
|
if env is None:
|
|
185
207
|
env = {}
|
|
186
208
|
|
|
187
|
-
prepared = _prepare_field_info(field_def, value, env, struct_def_dict
|
|
209
|
+
prepared = _prepare_field_info(field_def, value, env, struct_def_dict,
|
|
210
|
+
enum_def_dict, padding_alignment_bits)
|
|
188
211
|
if prepared is None:
|
|
189
212
|
return
|
|
190
213
|
|
|
@@ -196,9 +219,13 @@ def encode_field(field_def: FieldDef,
|
|
|
196
219
|
bits_set(buf, offset, info)
|
|
197
220
|
|
|
198
221
|
|
|
199
|
-
def encode(
|
|
200
|
-
|
|
201
|
-
|
|
222
|
+
def encode(
|
|
223
|
+
struct_instance: StructInstance,
|
|
224
|
+
buf: bytearray,
|
|
225
|
+
struct_def_dict: dict[str, StructDef] | None = None,
|
|
226
|
+
enum_def_dict: dict[str, EnumDef] | None = None,
|
|
227
|
+
padding_alignment_bits: int = _DEFAULT_PADDING_ALIGNMENT_BITS,
|
|
228
|
+
) -> bytearray:
|
|
202
229
|
"""Encode decode() result into a bytearray.
|
|
203
230
|
|
|
204
231
|
Parameters
|
|
@@ -209,6 +236,10 @@ def encode(struct_instance: StructInstance,
|
|
|
209
236
|
The base bytearray instance to write into.
|
|
210
237
|
struct_def_dict : dict[str, StructDef] | None, optional
|
|
211
238
|
A dictionary of structure definitions, by default None.
|
|
239
|
+
enum_def_dict : dict[str, EnumDef] | None, optional
|
|
240
|
+
A dictionary of enum definitions, by default None.
|
|
241
|
+
padding_alignment_bits : int, optional
|
|
242
|
+
The padding alignment boundary in bits, by default 32.
|
|
212
243
|
|
|
213
244
|
Returns
|
|
214
245
|
-------
|
|
@@ -216,6 +247,7 @@ def encode(struct_instance: StructInstance,
|
|
|
216
247
|
The encoded bytearray.
|
|
217
248
|
"""
|
|
218
249
|
_validate_struct_instance(struct_instance)
|
|
250
|
+
_validate_padding_alignment_bits(padding_alignment_bits)
|
|
219
251
|
env: dict[str, Any] = {}
|
|
220
252
|
has_padding = False
|
|
221
253
|
|
|
@@ -236,14 +268,16 @@ def encode(struct_instance: StructInstance,
|
|
|
236
268
|
field_def_repeat = _split_repeated_field(
|
|
237
269
|
field_def, i, env, current_offset)
|
|
238
270
|
encode_field(field_def_repeat, values[i], buf, env,
|
|
239
|
-
struct_def_dict
|
|
271
|
+
struct_def_dict, enum_def_dict,
|
|
272
|
+
padding_alignment_bits)
|
|
240
273
|
env[field_def_repeat.name] = values[i]
|
|
241
274
|
if isinstance(current_offset, InfoSize) and isinstance(
|
|
242
275
|
resolved_size, InfoSize):
|
|
243
276
|
current_offset += resolved_size
|
|
244
277
|
continue
|
|
245
278
|
|
|
246
|
-
encode_field(field_def, value, buf, env, struct_def_dict
|
|
279
|
+
encode_field(field_def, value, buf, env, struct_def_dict, enum_def_dict,
|
|
280
|
+
padding_alignment_bits)
|
|
247
281
|
env[field_def.name] = value
|
|
248
282
|
|
|
249
283
|
if has_padding and struct_instance.size.bytes > len(buf):
|
|
@@ -256,7 +290,9 @@ def decode_field(
|
|
|
256
290
|
field_def: FieldDef,
|
|
257
291
|
data: bytearray | bytes,
|
|
258
292
|
env: dict[str, Any] | None = None,
|
|
259
|
-
struct_def_dict: dict[str, StructDef] | None = None
|
|
293
|
+
struct_def_dict: dict[str, StructDef] | None = None,
|
|
294
|
+
enum_def_dict: dict[str, EnumDef] | None = None,
|
|
295
|
+
padding_alignment_bits: int = _DEFAULT_PADDING_ALIGNMENT_BITS,
|
|
260
296
|
) -> FieldInstance | None:
|
|
261
297
|
"""Decode a single field from a bytearray according to a field definition.
|
|
262
298
|
|
|
@@ -283,29 +319,56 @@ def decode_field(
|
|
|
283
319
|
return None
|
|
284
320
|
info = bits_get(data, offset, size, scale=field_def.scale)
|
|
285
321
|
resolved_type = _resolve_field_type(field_def.type, struct_def_dict, env)
|
|
322
|
+
resolved_field_def = FieldDef(
|
|
323
|
+
name=field_def.name,
|
|
324
|
+
offset=offset,
|
|
325
|
+
size=size,
|
|
326
|
+
type=resolved_type,
|
|
327
|
+
scale=field_def.scale,
|
|
328
|
+
repeat=field_def.repeat,
|
|
329
|
+
description=field_def.description,
|
|
330
|
+
range_expression=field_def.range_expression,
|
|
331
|
+
enum_def=field_def.enum_def,
|
|
332
|
+
)
|
|
286
333
|
if isinstance(resolved_type, StructDef):
|
|
287
334
|
return FieldInstance(
|
|
288
|
-
field_def=
|
|
335
|
+
field_def=resolved_field_def,
|
|
289
336
|
value=decode(resolved_type, bytearray(info.to_bytes),
|
|
290
|
-
struct_def_dict
|
|
337
|
+
struct_def_dict, enum_def_dict,
|
|
338
|
+
padding_alignment_bits),
|
|
291
339
|
)
|
|
292
340
|
if resolved_type == "bool":
|
|
293
|
-
return FieldInstance(
|
|
341
|
+
return FieldInstance.from_value(resolved_field_def,
|
|
342
|
+
info.to_bool,
|
|
343
|
+
enum_def_dict=enum_def_dict)
|
|
294
344
|
if resolved_type == "signed int":
|
|
295
|
-
return FieldInstance(
|
|
345
|
+
return FieldInstance.from_value(resolved_field_def,
|
|
346
|
+
info.to_signed_int,
|
|
347
|
+
enum_def_dict=enum_def_dict)
|
|
296
348
|
if resolved_type in ["int", "unsigned int"]:
|
|
297
|
-
return FieldInstance(
|
|
349
|
+
return FieldInstance.from_value(resolved_field_def,
|
|
350
|
+
info.to_unsigned_int,
|
|
351
|
+
enum_def_dict=enum_def_dict)
|
|
298
352
|
if resolved_type == "float":
|
|
299
|
-
return FieldInstance(
|
|
353
|
+
return FieldInstance.from_value(resolved_field_def,
|
|
354
|
+
info.to_float,
|
|
355
|
+
enum_def_dict=enum_def_dict)
|
|
300
356
|
if resolved_type in ["bytearray", "bytes"]:
|
|
301
|
-
return FieldInstance(
|
|
302
|
-
|
|
357
|
+
return FieldInstance.from_value(resolved_field_def,
|
|
358
|
+
info.to_bytes,
|
|
359
|
+
enum_def_dict=enum_def_dict)
|
|
360
|
+
return FieldInstance.from_value(resolved_field_def,
|
|
361
|
+
info.raw_value,
|
|
362
|
+
enum_def_dict=enum_def_dict)
|
|
303
363
|
|
|
304
364
|
|
|
305
365
|
def decode(
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
366
|
+
struct_def: StructDef | list[FieldDef],
|
|
367
|
+
data: bytearray | bytes,
|
|
368
|
+
struct_def_dict: dict[str, StructDef] | None = None,
|
|
369
|
+
enum_def_dict: dict[str, EnumDef] | None = None,
|
|
370
|
+
padding_alignment_bits: int = _DEFAULT_PADDING_ALIGNMENT_BITS,
|
|
371
|
+
) -> StructInstance:
|
|
309
372
|
"""Decode a bytearray into field values according to a layout.
|
|
310
373
|
|
|
311
374
|
Parameters
|
|
@@ -316,11 +379,16 @@ def decode(
|
|
|
316
379
|
The data to decode.
|
|
317
380
|
struct_def_dict : dict[str, StructDef] | None, optional
|
|
318
381
|
A dictionary of structure definitions, by default None.
|
|
382
|
+
enum_def_dict : dict[str, EnumDef] | None, optional
|
|
383
|
+
A dictionary of enum definitions, by default None.
|
|
384
|
+
padding_alignment_bits : int, optional
|
|
385
|
+
The padding alignment boundary in bits, by default 32.
|
|
319
386
|
Returns
|
|
320
387
|
-------
|
|
321
388
|
StructInstance
|
|
322
389
|
The decoded structure instance.
|
|
323
390
|
"""
|
|
391
|
+
_validate_padding_alignment_bits(padding_alignment_bits)
|
|
324
392
|
env = {}
|
|
325
393
|
struct_def_obj = (struct_def if isinstance(struct_def, StructDef) else
|
|
326
394
|
StructDef(fields=struct_def))
|
|
@@ -337,8 +405,11 @@ def decode(
|
|
|
337
405
|
|
|
338
406
|
chunk_start_bits = start_bits
|
|
339
407
|
while chunk_start_bits < end_bits:
|
|
340
|
-
|
|
341
|
-
|
|
408
|
+
chunk_start = _info_size_from_bits(chunk_start_bits)
|
|
409
|
+
bits_to_boundary = _info_size_to_bits(
|
|
410
|
+
chunk_start.align_to(padding_alignment_bits))
|
|
411
|
+
chunk_size_bits = bits_to_boundary or padding_alignment_bits
|
|
412
|
+
chunk_end_bits = min(end_bits, chunk_start_bits + chunk_size_bits)
|
|
342
413
|
padding_field_def = FieldDef(
|
|
343
414
|
name=f"padding[{padding_index}]",
|
|
344
415
|
offset=_info_size_from_bits(chunk_start_bits),
|
|
@@ -359,7 +430,8 @@ def decode(
|
|
|
359
430
|
if field_def.repeat is None or field_def.repeat <= 1:
|
|
360
431
|
resolved_offset = _resolve_offset(field_def, env)
|
|
361
432
|
append_padding_until(resolved_offset)
|
|
362
|
-
field_instance = decode_field(field_def, data, env, struct_def_dict
|
|
433
|
+
field_instance = decode_field(field_def, data, env, struct_def_dict,
|
|
434
|
+
enum_def_dict, padding_alignment_bits)
|
|
363
435
|
if field_instance is not None:
|
|
364
436
|
env[field_instance.field_def.name] = field_instance.value
|
|
365
437
|
result.append_field_instance(field_instance)
|
|
@@ -374,7 +446,8 @@ def decode(
|
|
|
374
446
|
field_def_repeat = _split_repeated_field(field_def, i, env,
|
|
375
447
|
current_offset)
|
|
376
448
|
field_instance = decode_field(field_def_repeat, data, env,
|
|
377
|
-
struct_def_dict
|
|
449
|
+
struct_def_dict, enum_def_dict,
|
|
450
|
+
padding_alignment_bits)
|
|
378
451
|
if field_instance is not None:
|
|
379
452
|
env[field_instance.field_def.name] = field_instance.value
|
|
380
453
|
result.append_field_instance(field_instance)
|
|
@@ -1,16 +1,90 @@
|
|
|
1
1
|
"""Type definitions for structured layout metadata."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
import importlib
|
|
5
4
|
import json
|
|
6
5
|
import re
|
|
7
6
|
from dataclasses import dataclass, field
|
|
8
|
-
from
|
|
7
|
+
from functools import total_ordering
|
|
9
8
|
from typing import Any
|
|
10
9
|
|
|
10
|
+
from sltcalc import SltEval
|
|
11
11
|
from sltcore import Info, InfoSize
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
@total_ordering
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class EnumDef:
|
|
17
|
+
"""An enumeration definition."""
|
|
18
|
+
name: str = field(default_factory=str,
|
|
19
|
+
metadata={"desc": "The name of the enum"})
|
|
20
|
+
description: str | None = field(
|
|
21
|
+
default=None, metadata={"desc": "The description of the enum"})
|
|
22
|
+
values: dict[str, int] = field(
|
|
23
|
+
default_factory=dict,
|
|
24
|
+
metadata={"desc": "The mapping of enum names to integer values"},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
"""Convert this enum definition to a JSON-serializable dictionary."""
|
|
29
|
+
return {
|
|
30
|
+
"name": self.name,
|
|
31
|
+
"description": self.description,
|
|
32
|
+
"values": self.values,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def to_json(self) -> str:
|
|
36
|
+
"""Convert this enum definition to a JSON string."""
|
|
37
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, data: dict[str, Any]) -> "EnumDef":
|
|
41
|
+
"""Create an enum definition from a JSON-serializable dictionary."""
|
|
42
|
+
name = data.get("name", "")
|
|
43
|
+
description = data.get("description")
|
|
44
|
+
values = data.get("values", {})
|
|
45
|
+
return cls(name=name, description=description, values=values)
|
|
46
|
+
|
|
47
|
+
def serialize(self) -> dict[str, Any]:
|
|
48
|
+
"""Serialize this enum definition with an explicit type tag."""
|
|
49
|
+
return {
|
|
50
|
+
"__type__": "EnumDef",
|
|
51
|
+
"name": self.name,
|
|
52
|
+
"description": self.description,
|
|
53
|
+
"values": self.values,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def deserialize(cls, data: dict[str, Any]) -> "EnumDef":
|
|
58
|
+
"""Deserialize an enum definition from a typed dictionary."""
|
|
59
|
+
if data.get("__type__") != "EnumDef":
|
|
60
|
+
raise ValueError("Invalid EnumDef payload")
|
|
61
|
+
return cls(
|
|
62
|
+
name=data.get("name", ""),
|
|
63
|
+
description=data.get("description"),
|
|
64
|
+
values=data.get("values", {}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_json(cls, data: str) -> "EnumDef":
|
|
69
|
+
"""Create an enum definition from a JSON string."""
|
|
70
|
+
return cls.from_dict(json.loads(data))
|
|
71
|
+
|
|
72
|
+
def __lt__(self, other: object) -> bool:
|
|
73
|
+
"""Compare enum definitions using a stable serialized sort key."""
|
|
74
|
+
if not isinstance(other, EnumDef):
|
|
75
|
+
return NotImplemented
|
|
76
|
+
return self._sort_key() < other._sort_key()
|
|
77
|
+
|
|
78
|
+
def _sort_key(self) -> tuple[Any, ...]:
|
|
79
|
+
"""Build a stable comparison key for ordering enum definitions."""
|
|
80
|
+
return (
|
|
81
|
+
self.name,
|
|
82
|
+
"" if self.description is None else self.description,
|
|
83
|
+
json.dumps(self.values, sort_keys=True),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@total_ordering
|
|
14
88
|
@dataclass(frozen=True)
|
|
15
89
|
class FieldDef:
|
|
16
90
|
"""A field in a structured layout."""
|
|
@@ -30,8 +104,8 @@ class FieldDef:
|
|
|
30
104
|
default=None, metadata={"desc": "The description of the field"})
|
|
31
105
|
range_expression: str | None = field(
|
|
32
106
|
default=None, metadata={"desc": "The value range expression"})
|
|
33
|
-
|
|
34
|
-
|
|
107
|
+
enum_def: EnumDef | None = field(
|
|
108
|
+
default=None, metadata={"desc": "The enum definition for the field"})
|
|
35
109
|
|
|
36
110
|
def split_repeat(self,
|
|
37
111
|
index: int,
|
|
@@ -58,7 +132,7 @@ class FieldDef:
|
|
|
58
132
|
repeat=None,
|
|
59
133
|
description=self.description,
|
|
60
134
|
range_expression=split_range_expression,
|
|
61
|
-
|
|
135
|
+
enum_def=self.enum_def)
|
|
62
136
|
|
|
63
137
|
@staticmethod
|
|
64
138
|
def _replace_name_in_expression(value: Any, old_name: str,
|
|
@@ -79,15 +153,33 @@ class FieldDef:
|
|
|
79
153
|
def to_dict(self) -> dict[str, Any]:
|
|
80
154
|
"""Convert this field definition to a JSON-serializable dictionary."""
|
|
81
155
|
return {
|
|
82
|
-
"name":
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
|
|
156
|
+
"name":
|
|
157
|
+
self.name,
|
|
158
|
+
"offset":
|
|
159
|
+
self.offset.serialize() if isinstance(self.offset,
|
|
160
|
+
(Info,
|
|
161
|
+
InfoSize)) else self.offset,
|
|
162
|
+
"size":
|
|
163
|
+
self.size.serialize() if isinstance(self.size,
|
|
164
|
+
(Info,
|
|
165
|
+
InfoSize)) else self.size,
|
|
166
|
+
"type": {
|
|
167
|
+
"__type__": "StructDef",
|
|
168
|
+
"fields": self.type.to_dict(),
|
|
169
|
+
} if isinstance(self.type, StructDef) else {
|
|
170
|
+
"__type__": "StructDef",
|
|
171
|
+
"fields": [field.to_dict() for field in self.type],
|
|
172
|
+
} if isinstance(self.type, list) else self.type,
|
|
173
|
+
"scale":
|
|
174
|
+
self.scale,
|
|
175
|
+
"repeat":
|
|
176
|
+
self.repeat,
|
|
177
|
+
"description":
|
|
178
|
+
self.description,
|
|
179
|
+
"range_expression":
|
|
180
|
+
self.range_expression,
|
|
181
|
+
"enum_def":
|
|
182
|
+
None if self.enum_def is None else self.enum_def.serialize(),
|
|
91
183
|
}
|
|
92
184
|
|
|
93
185
|
def to_json(self) -> str:
|
|
@@ -97,16 +189,68 @@ class FieldDef:
|
|
|
97
189
|
@classmethod
|
|
98
190
|
def from_dict(cls, data: dict[str, Any]) -> "FieldDef":
|
|
99
191
|
"""Create a field definition from a JSON-serializable dictionary."""
|
|
192
|
+
name = data.get("name", "")
|
|
193
|
+
offset_data = data.get("offset")
|
|
194
|
+
size_data = data.get("size")
|
|
195
|
+
type_data = data.get("type")
|
|
196
|
+
scale = data.get("scale", 1.0)
|
|
197
|
+
repeat = data.get("repeat")
|
|
198
|
+
description = data.get("description")
|
|
199
|
+
range_expression = data.get("range_expression")
|
|
200
|
+
enum_def_data = data.get("enum_def")
|
|
201
|
+
|
|
202
|
+
def _deserialize_info_like(value: Any) -> Any:
|
|
203
|
+
if isinstance(value, dict):
|
|
204
|
+
value_type = value.get("__type__")
|
|
205
|
+
if value_type == "Info":
|
|
206
|
+
return Info.deserialize(json.dumps(value))
|
|
207
|
+
if value_type == "InfoSize":
|
|
208
|
+
return InfoSize.deserialize(json.dumps(value))
|
|
209
|
+
return value
|
|
210
|
+
if not isinstance(value, str):
|
|
211
|
+
return value
|
|
212
|
+
try:
|
|
213
|
+
parsed_value = json.loads(value)
|
|
214
|
+
except json.JSONDecodeError:
|
|
215
|
+
return value
|
|
216
|
+
if (isinstance(parsed_value, dict)
|
|
217
|
+
and parsed_value.get("__type__") == "Info"):
|
|
218
|
+
return Info.deserialize(value)
|
|
219
|
+
if (isinstance(parsed_value, dict)
|
|
220
|
+
and parsed_value.get("__type__") == "InfoSize"):
|
|
221
|
+
return InfoSize.deserialize(value)
|
|
222
|
+
return value
|
|
223
|
+
|
|
224
|
+
offset = _deserialize_info_like(offset_data)
|
|
225
|
+
size = _deserialize_info_like(size_data)
|
|
226
|
+
|
|
227
|
+
if (isinstance(type_data, dict)
|
|
228
|
+
and type_data.get("__type__") == "StructDef"):
|
|
229
|
+
type_value = StructDef.from_dict(type_data["fields"])
|
|
230
|
+
elif isinstance(type_data, list):
|
|
231
|
+
# Backward-compatibility for legacy list-based nested types.
|
|
232
|
+
type_value = StructDef.from_dict(type_data)
|
|
233
|
+
else:
|
|
234
|
+
type_value = type_data
|
|
235
|
+
|
|
236
|
+
if enum_def_data is None:
|
|
237
|
+
enum_def = None
|
|
238
|
+
elif (isinstance(enum_def_data, dict)
|
|
239
|
+
and enum_def_data.get("__type__") == "EnumDef"):
|
|
240
|
+
enum_def = EnumDef.deserialize(enum_def_data)
|
|
241
|
+
else:
|
|
242
|
+
enum_def = None
|
|
243
|
+
|
|
100
244
|
return cls(
|
|
101
|
-
name=
|
|
102
|
-
offset=
|
|
103
|
-
size=
|
|
104
|
-
type=
|
|
105
|
-
scale=
|
|
106
|
-
repeat=
|
|
107
|
-
description=
|
|
108
|
-
range_expression=
|
|
109
|
-
|
|
245
|
+
name=name,
|
|
246
|
+
offset=offset,
|
|
247
|
+
size=size,
|
|
248
|
+
type=type_value,
|
|
249
|
+
scale=scale,
|
|
250
|
+
repeat=repeat,
|
|
251
|
+
description=description,
|
|
252
|
+
range_expression=range_expression,
|
|
253
|
+
enum_def=enum_def,
|
|
110
254
|
)
|
|
111
255
|
|
|
112
256
|
@classmethod
|
|
@@ -114,83 +258,6 @@ class FieldDef:
|
|
|
114
258
|
"""Create a field definition from a JSON string."""
|
|
115
259
|
return cls.from_dict(json.loads(data))
|
|
116
260
|
|
|
117
|
-
@staticmethod
|
|
118
|
-
def _serialize_value(value: Any) -> Any:
|
|
119
|
-
if isinstance(value, Info):
|
|
120
|
-
return {
|
|
121
|
-
"__type__": "Info",
|
|
122
|
-
"value": value.to_json(),
|
|
123
|
-
}
|
|
124
|
-
if isinstance(value, InfoSize):
|
|
125
|
-
return {
|
|
126
|
-
"__type__": "InfoSize",
|
|
127
|
-
"value": value.to_json(),
|
|
128
|
-
}
|
|
129
|
-
return value
|
|
130
|
-
|
|
131
|
-
@classmethod
|
|
132
|
-
def _deserialize_value(cls, value: Any) -> Any:
|
|
133
|
-
if isinstance(value, dict) and value.get("__type__") == "Info":
|
|
134
|
-
return Info.from_json(value["value"])
|
|
135
|
-
if isinstance(value, dict) and value.get("__type__") == "InfoSize":
|
|
136
|
-
return InfoSize.from_json(value["value"])
|
|
137
|
-
return value
|
|
138
|
-
|
|
139
|
-
@staticmethod
|
|
140
|
-
def _serialize_type(value: Any) -> Any:
|
|
141
|
-
if isinstance(value, StructDef):
|
|
142
|
-
return {
|
|
143
|
-
"__type__": "StructDef",
|
|
144
|
-
"fields": value.to_dict(),
|
|
145
|
-
}
|
|
146
|
-
if isinstance(value, list):
|
|
147
|
-
# Backward-compatibility for legacy list-based nested types.
|
|
148
|
-
return {
|
|
149
|
-
"__type__": "StructDef",
|
|
150
|
-
"fields": [field.to_dict() for field in value],
|
|
151
|
-
}
|
|
152
|
-
return value
|
|
153
|
-
|
|
154
|
-
@classmethod
|
|
155
|
-
def _deserialize_type(cls, value: Any) -> Any:
|
|
156
|
-
if isinstance(value, dict) and value.get("__type__") == "StructDef":
|
|
157
|
-
return StructDef.from_dict(value["fields"])
|
|
158
|
-
if isinstance(value, list):
|
|
159
|
-
# Backward-compatibility for legacy list-based nested types.
|
|
160
|
-
return StructDef.from_dict(value)
|
|
161
|
-
return value
|
|
162
|
-
|
|
163
|
-
@staticmethod
|
|
164
|
-
def _serialize_enum_type(value: type[Enum] | None) -> Any:
|
|
165
|
-
if value is None:
|
|
166
|
-
return None
|
|
167
|
-
return {
|
|
168
|
-
"__type__": "EnumType",
|
|
169
|
-
"module": value.__module__,
|
|
170
|
-
"qualname": value.__qualname__,
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
@classmethod
|
|
174
|
-
def _deserialize_enum_type(cls, value: Any) -> type[Enum] | None:
|
|
175
|
-
if value is None:
|
|
176
|
-
return None
|
|
177
|
-
if not isinstance(value, dict) or value.get("__type__") != "EnumType":
|
|
178
|
-
return None
|
|
179
|
-
module_name = value.get("module")
|
|
180
|
-
qualname = value.get("qualname")
|
|
181
|
-
if not isinstance(module_name, str) or not isinstance(qualname, str):
|
|
182
|
-
return None
|
|
183
|
-
try:
|
|
184
|
-
module = importlib.import_module(module_name)
|
|
185
|
-
enum_type: Any = module
|
|
186
|
-
for attr in qualname.split("."):
|
|
187
|
-
enum_type = getattr(enum_type, attr)
|
|
188
|
-
if isinstance(enum_type, type) and issubclass(enum_type, Enum):
|
|
189
|
-
return enum_type
|
|
190
|
-
except (ImportError, AttributeError, TypeError):
|
|
191
|
-
return None
|
|
192
|
-
return None
|
|
193
|
-
|
|
194
261
|
def _sort_key(self) -> tuple[Any, ...]:
|
|
195
262
|
"""Build a stable comparison key for ordering field definitions."""
|
|
196
263
|
return (
|
|
@@ -202,7 +269,7 @@ class FieldDef:
|
|
|
202
269
|
-1 if self.repeat is None else self.repeat,
|
|
203
270
|
"" if self.description is None else self.description,
|
|
204
271
|
"" if self.range_expression is None else self.range_expression,
|
|
205
|
-
self._sortable_enum_type(self.
|
|
272
|
+
self._sortable_enum_type(self.enum_def),
|
|
206
273
|
)
|
|
207
274
|
|
|
208
275
|
@staticmethod
|
|
@@ -220,18 +287,29 @@ class FieldDef:
|
|
|
220
287
|
return (1, value)
|
|
221
288
|
|
|
222
289
|
@staticmethod
|
|
223
|
-
def _sortable_enum_type(value:
|
|
224
|
-
"""Build a comparable key for enum
|
|
290
|
+
def _sortable_enum_type(value: EnumDef | None) -> str:
|
|
291
|
+
"""Build a comparable key for enum definition metadata."""
|
|
225
292
|
if value is None:
|
|
226
293
|
return ""
|
|
227
|
-
return
|
|
294
|
+
return json.dumps(
|
|
295
|
+
{
|
|
296
|
+
"name": value.name,
|
|
297
|
+
"description": value.description,
|
|
298
|
+
"values": value.values,
|
|
299
|
+
},
|
|
300
|
+
sort_keys=True)
|
|
228
301
|
|
|
229
302
|
|
|
303
|
+
@total_ordering
|
|
230
304
|
@dataclass(frozen=True)
|
|
231
305
|
class FieldInstance:
|
|
232
306
|
"""A decoded/encodable field value with its field definition."""
|
|
233
307
|
field_def: FieldDef = field(metadata={"desc": "The field definition"})
|
|
234
308
|
value: Any = field(metadata={"desc": "The decoded/encodable value"})
|
|
309
|
+
enum_item: tuple[str, int] | None = field(
|
|
310
|
+
default=None,
|
|
311
|
+
metadata={"desc": "Matched enum item for this value"},
|
|
312
|
+
)
|
|
235
313
|
is_padding: bool = field(
|
|
236
314
|
default=False,
|
|
237
315
|
metadata={"desc": "Whether this field instance represents padding"},
|
|
@@ -243,7 +321,59 @@ class FieldInstance:
|
|
|
243
321
|
return NotImplemented
|
|
244
322
|
return self.field_def < other.field_def
|
|
245
323
|
|
|
324
|
+
def range_check(self, env: dict[str, Any] | None = None) -> Any | None:
|
|
325
|
+
"""Evaluate range_expression and return its evaluated result."""
|
|
326
|
+
range_expression = self.field_def.range_expression
|
|
327
|
+
if range_expression is None:
|
|
328
|
+
return None
|
|
246
329
|
|
|
330
|
+
eval_env = {} if env is None else dict(env)
|
|
331
|
+
eval_env[self.field_def.name] = self.value
|
|
332
|
+
stleval = SltEval(eval_env)
|
|
333
|
+
return stleval.eval(range_expression)
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def from_value(cls,
|
|
337
|
+
field_def: FieldDef,
|
|
338
|
+
value: Any,
|
|
339
|
+
enum_def_dict: dict[str, EnumDef] | None = None,
|
|
340
|
+
is_padding: bool = False) -> "FieldInstance":
|
|
341
|
+
"""Create a FieldInstance and attach a matched enum item if any."""
|
|
342
|
+
enum_def = cls._resolve_enum_def(field_def, enum_def_dict)
|
|
343
|
+
enum_item = cls._resolve_enum_item(enum_def, value)
|
|
344
|
+
return cls(field_def=field_def,
|
|
345
|
+
value=value,
|
|
346
|
+
enum_item=enum_item,
|
|
347
|
+
is_padding=is_padding)
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _resolve_enum_def(
|
|
351
|
+
field_def: FieldDef,
|
|
352
|
+
enum_def_dict: dict[str, EnumDef] | None) -> EnumDef | None:
|
|
353
|
+
"""Resolve enum definition from field metadata or lookup dictionary."""
|
|
354
|
+
if field_def.enum_def is not None:
|
|
355
|
+
return field_def.enum_def
|
|
356
|
+
if not enum_def_dict:
|
|
357
|
+
return None
|
|
358
|
+
if isinstance(field_def.type, str) and field_def.type in enum_def_dict:
|
|
359
|
+
return enum_def_dict[field_def.type]
|
|
360
|
+
return enum_def_dict.get(field_def.name)
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def _resolve_enum_item(enum_def: EnumDef | None,
|
|
364
|
+
value: Any) -> tuple[str, int] | None:
|
|
365
|
+
"""Resolve one enum values item that matches the given value."""
|
|
366
|
+
if enum_def is None:
|
|
367
|
+
return None
|
|
368
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
369
|
+
return None
|
|
370
|
+
for enum_name, enum_value in enum_def.values.items():
|
|
371
|
+
if enum_value == value:
|
|
372
|
+
return enum_name, enum_value
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@total_ordering
|
|
247
377
|
@dataclass(frozen=True)
|
|
248
378
|
class StructDef:
|
|
249
379
|
"""A structured layout definition that groups multiple fields."""
|
|
@@ -258,6 +388,21 @@ class StructDef:
|
|
|
258
388
|
metadata={"desc": "The fields of the structure"},
|
|
259
389
|
)
|
|
260
390
|
|
|
391
|
+
def __lt__(self, other: object) -> bool:
|
|
392
|
+
"""Compare structure definitions using a stable serialized sort key."""
|
|
393
|
+
if not isinstance(other, StructDef):
|
|
394
|
+
return NotImplemented
|
|
395
|
+
return self._sort_key() < other._sort_key()
|
|
396
|
+
|
|
397
|
+
def _sort_key(self) -> tuple[Any, ...]:
|
|
398
|
+
"""Build a stable comparison key for ordering structure definitions."""
|
|
399
|
+
return (
|
|
400
|
+
self.name,
|
|
401
|
+
"" if self.description is None else self.description,
|
|
402
|
+
json.dumps([field.to_dict() for field in self.fields],
|
|
403
|
+
sort_keys=True),
|
|
404
|
+
)
|
|
405
|
+
|
|
261
406
|
def to_dict(self) -> dict[str, Any]:
|
|
262
407
|
"""Convert this structure definition to JSON-serializable data."""
|
|
263
408
|
return {
|
|
@@ -279,12 +424,11 @@ class StructDef:
|
|
|
279
424
|
if isinstance(data, list):
|
|
280
425
|
# Backward-compatibility for legacy list-only StructDef payloads.
|
|
281
426
|
return cls(fields=[FieldDef.from_dict(item) for item in data])
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
])
|
|
427
|
+
name = data.get("name", "")
|
|
428
|
+
description = data.get("description", "")
|
|
429
|
+
fields_data = data.get("fields", [])
|
|
430
|
+
fields = [FieldDef.from_dict(item) for item in fields_data]
|
|
431
|
+
return cls(name=name, description=description, fields=fields)
|
|
288
432
|
|
|
289
433
|
@classmethod
|
|
290
434
|
def from_json(cls, data: str) -> "StructDef":
|
|
@@ -292,6 +436,7 @@ class StructDef:
|
|
|
292
436
|
return cls.from_dict(json.loads(data))
|
|
293
437
|
|
|
294
438
|
|
|
439
|
+
@total_ordering
|
|
295
440
|
@dataclass
|
|
296
441
|
class StructInstance:
|
|
297
442
|
"""A decoded/encodable structure instance."""
|
|
@@ -344,6 +489,23 @@ class StructInstance:
|
|
|
344
489
|
"""Return one field instance by index."""
|
|
345
490
|
return self.field_instances[index]
|
|
346
491
|
|
|
492
|
+
def __lt__(self, other: object) -> bool:
|
|
493
|
+
"""Compare structure instances using a stable serialized sort key."""
|
|
494
|
+
if not isinstance(other, StructInstance):
|
|
495
|
+
return NotImplemented
|
|
496
|
+
return self._sort_key() < other._sort_key()
|
|
497
|
+
|
|
498
|
+
def _sort_key(self) -> tuple[Any, ...]:
|
|
499
|
+
"""Build a stable comparison key for ordering structure instances."""
|
|
500
|
+
return (
|
|
501
|
+
self.struct_def._sort_key(),
|
|
502
|
+
json.dumps([
|
|
503
|
+
field_instance.to_dict()
|
|
504
|
+
for field_instance in self.field_instances
|
|
505
|
+
],
|
|
506
|
+
sort_keys=True),
|
|
507
|
+
)
|
|
508
|
+
|
|
347
509
|
def _sort_field_instances(self) -> None:
|
|
348
510
|
"""Keep field instances sorted by FieldDef order."""
|
|
349
511
|
self.field_instances.sort()
|
|
@@ -356,14 +518,25 @@ class StructInstance:
|
|
|
356
518
|
]
|
|
357
519
|
non_padding_instances.sort()
|
|
358
520
|
|
|
521
|
+
has_unresolved_layout = any(
|
|
522
|
+
not isinstance(field_instance.field_def.offset, InfoSize)
|
|
523
|
+
or not isinstance(field_instance.field_def.size, InfoSize)
|
|
524
|
+
for field_instance in non_padding_instances)
|
|
525
|
+
if has_unresolved_layout:
|
|
526
|
+
# Layout expressions are resolved later in codec paths.
|
|
527
|
+
self.field_instances = non_padding_instances
|
|
528
|
+
self._sort_field_instances()
|
|
529
|
+
self._update_size()
|
|
530
|
+
return
|
|
531
|
+
|
|
359
532
|
rebuilt_instances: list[FieldInstance] = []
|
|
360
533
|
current_offset = InfoSize(0, 0)
|
|
361
534
|
padding_index = 0
|
|
362
535
|
|
|
363
536
|
for field_instance in non_padding_instances:
|
|
364
537
|
field_def = field_instance.field_def
|
|
365
|
-
field_offset =
|
|
366
|
-
field_size =
|
|
538
|
+
field_offset = field_def.offset
|
|
539
|
+
field_size = field_def.size
|
|
367
540
|
if field_offset > current_offset:
|
|
368
541
|
gap_size = field_offset - current_offset
|
|
369
542
|
if gap_size.byte > 0 or gap_size.bit > 0:
|
|
@@ -411,25 +584,15 @@ class StructInstance:
|
|
|
411
584
|
max_end_offset = InfoSize(0, 0)
|
|
412
585
|
for field_instance in self.field_instances:
|
|
413
586
|
field_def = field_instance.field_def
|
|
414
|
-
|
|
415
|
-
|
|
587
|
+
if not isinstance(field_def.offset, InfoSize):
|
|
588
|
+
continue
|
|
589
|
+
if not isinstance(field_def.size, InfoSize):
|
|
590
|
+
continue
|
|
591
|
+
field_offset = field_def.offset
|
|
592
|
+
field_size = field_def.size
|
|
416
593
|
field_end = field_offset + field_size
|
|
417
594
|
if field_end > max_end_offset:
|
|
418
595
|
max_end_offset = field_end
|
|
419
596
|
|
|
420
597
|
if max_end_offset > self.size:
|
|
421
598
|
self.size = max_end_offset
|
|
422
|
-
|
|
423
|
-
@staticmethod
|
|
424
|
-
def _resolve_field_offset(field_def: FieldDef) -> InfoSize:
|
|
425
|
-
"""Resolve field offsets to InfoSize values when possible."""
|
|
426
|
-
if isinstance(field_def.offset, InfoSize):
|
|
427
|
-
return field_def.offset
|
|
428
|
-
return InfoSize(0, 0)
|
|
429
|
-
|
|
430
|
-
@staticmethod
|
|
431
|
-
def _resolve_field_size(field_def: FieldDef) -> InfoSize:
|
|
432
|
-
"""Resolve field sizes to InfoSize values when possible."""
|
|
433
|
-
if isinstance(field_def.size, InfoSize):
|
|
434
|
-
return field_def.size
|
|
435
|
-
return InfoSize(0, 0)
|
|
@@ -5,14 +5,14 @@ from pathlib import Path
|
|
|
5
5
|
import pytest
|
|
6
6
|
from sltcore import InfoSize
|
|
7
7
|
|
|
8
|
-
from sltcodec import (FieldDef, FieldInstance, StructDef,
|
|
9
|
-
decode, encode, load_struct_def_dict,
|
|
8
|
+
from sltcodec import (EnumDef, FieldDef, FieldInstance, StructDef,
|
|
9
|
+
StructInstance, decode, encode, load_struct_def_dict,
|
|
10
10
|
save_struct_def_dict)
|
|
11
11
|
from sltcodec.codec import decode_field
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class ValueKind(Enum):
|
|
15
|
-
"""Enum used for
|
|
15
|
+
"""Enum used for EnumDef metadata tests."""
|
|
16
16
|
A = 1
|
|
17
17
|
B = 2
|
|
18
18
|
|
|
@@ -155,13 +155,17 @@ def test_repeated_field_preserves_description():
|
|
|
155
155
|
|
|
156
156
|
def test_repeated_field_preserves_range_and_enum_metadata():
|
|
157
157
|
"""Test repeated fields preserve range/enum metadata on decoded entries."""
|
|
158
|
+
enum_def = EnumDef(
|
|
159
|
+
name="ValueKind",
|
|
160
|
+
values={member.name: member.value
|
|
161
|
+
for member in ValueKind})
|
|
158
162
|
field_def = FieldDef(name="value",
|
|
159
163
|
offset=InfoSize(0, 0),
|
|
160
164
|
size=InfoSize(1, 0),
|
|
161
165
|
type="unsigned int",
|
|
162
166
|
repeat=2,
|
|
163
167
|
range_expression="0 <= value <= 255",
|
|
164
|
-
|
|
168
|
+
enum_def=enum_def)
|
|
165
169
|
|
|
166
170
|
encoded = encode(
|
|
167
171
|
StructInstance(struct_def=StructDef(fields=[field_def]),
|
|
@@ -174,8 +178,8 @@ def test_repeated_field_preserves_range_and_enum_metadata():
|
|
|
174
178
|
"0 <= value[0] <= 255")
|
|
175
179
|
assert decoded.field_instances[1].field_def.range_expression == (
|
|
176
180
|
"0 <= value[1] <= 255")
|
|
177
|
-
assert decoded.field_instances[0].field_def.
|
|
178
|
-
assert decoded.field_instances[1].field_def.
|
|
181
|
+
assert decoded.field_instances[0].field_def.enum_def == enum_def
|
|
182
|
+
assert decoded.field_instances[1].field_def.enum_def == enum_def
|
|
179
183
|
|
|
180
184
|
|
|
181
185
|
def test_split_repeat_replaces_name_in_range_expression():
|
|
@@ -212,6 +216,10 @@ def test_field_def_json_round_trip():
|
|
|
212
216
|
|
|
213
217
|
def test_field_def_to_json_from_json_round_trip():
|
|
214
218
|
"""Test FieldDef.to_json/from_json round-trip conversion."""
|
|
219
|
+
enum_def = EnumDef(
|
|
220
|
+
name="ValueKind",
|
|
221
|
+
values={member.name: member.value
|
|
222
|
+
for member in ValueKind})
|
|
215
223
|
field_def = FieldDef(name="value",
|
|
216
224
|
offset=InfoSize(0, 0),
|
|
217
225
|
size=InfoSize(1, 0),
|
|
@@ -220,14 +228,123 @@ def test_field_def_to_json_from_json_round_trip():
|
|
|
220
228
|
repeat=3,
|
|
221
229
|
description="A repeated value",
|
|
222
230
|
range_expression="0 <= value <= 255",
|
|
223
|
-
|
|
231
|
+
enum_def=enum_def)
|
|
224
232
|
|
|
225
233
|
payload = field_def.to_json()
|
|
226
234
|
restored = FieldDef.from_json(payload)
|
|
227
235
|
|
|
228
236
|
assert restored == field_def
|
|
229
237
|
assert restored.range_expression == "0 <= value <= 255"
|
|
230
|
-
assert restored.
|
|
238
|
+
assert restored.enum_def == enum_def
|
|
239
|
+
assert restored.to_dict()["enum_def"] == {
|
|
240
|
+
"__type__": "EnumDef",
|
|
241
|
+
"name": "ValueKind",
|
|
242
|
+
"description": None,
|
|
243
|
+
"values": {
|
|
244
|
+
member.name: member.value
|
|
245
|
+
for member in ValueKind
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_enum_def_to_json_from_json_round_trip():
|
|
251
|
+
"""Test EnumDef.to_json/from_json round-trip conversion."""
|
|
252
|
+
enum_def = EnumDef(name="Status",
|
|
253
|
+
description="Status enum",
|
|
254
|
+
values={
|
|
255
|
+
"OK": 1,
|
|
256
|
+
"NG": 2
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
payload = enum_def.to_json()
|
|
260
|
+
restored = EnumDef.from_json(payload)
|
|
261
|
+
|
|
262
|
+
assert restored == enum_def
|
|
263
|
+
assert restored.to_dict() == {
|
|
264
|
+
"name": "Status",
|
|
265
|
+
"description": "Status enum",
|
|
266
|
+
"values": {
|
|
267
|
+
"OK": 1,
|
|
268
|
+
"NG": 2,
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_enum_def_to_dict_from_dict_round_trip():
|
|
274
|
+
"""Test EnumDef.to_dict/from_dict round-trip conversion."""
|
|
275
|
+
enum_def = EnumDef(name="Mode",
|
|
276
|
+
description="Mode enum",
|
|
277
|
+
values={
|
|
278
|
+
"AUTO": 0,
|
|
279
|
+
"MANUAL": 1,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
payload = enum_def.to_dict()
|
|
283
|
+
restored = EnumDef.from_dict(payload)
|
|
284
|
+
|
|
285
|
+
assert restored == enum_def
|
|
286
|
+
assert payload == {
|
|
287
|
+
"name": "Mode",
|
|
288
|
+
"description": "Mode enum",
|
|
289
|
+
"values": {
|
|
290
|
+
"AUTO": 0,
|
|
291
|
+
"MANUAL": 1,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_enum_def_serialize_deserialize_round_trip():
|
|
297
|
+
"""Test EnumDef.serialize/deserialize round-trip conversion."""
|
|
298
|
+
enum_def = EnumDef(name="State",
|
|
299
|
+
description="State enum",
|
|
300
|
+
values={
|
|
301
|
+
"ON": 1,
|
|
302
|
+
"OFF": 0,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
payload = enum_def.serialize()
|
|
306
|
+
restored = EnumDef.deserialize(payload)
|
|
307
|
+
|
|
308
|
+
assert restored == enum_def
|
|
309
|
+
assert payload == {
|
|
310
|
+
"__type__": "EnumDef",
|
|
311
|
+
"name": "State",
|
|
312
|
+
"description": "State enum",
|
|
313
|
+
"values": {
|
|
314
|
+
"ON": 1,
|
|
315
|
+
"OFF": 0,
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_decode_uses_enum_def_dict_to_set_enum_item():
|
|
321
|
+
"""Test decode uses enum_def_dict to resolve FieldInstance enum_item."""
|
|
322
|
+
enum_def = EnumDef(name="Status", values={"OK": 1, "NG": 2})
|
|
323
|
+
field_def = FieldDef(name="status",
|
|
324
|
+
offset=InfoSize(0, 0),
|
|
325
|
+
size=InfoSize(1, 0),
|
|
326
|
+
type="unsigned int")
|
|
327
|
+
|
|
328
|
+
decoded = decode([field_def],
|
|
329
|
+
bytearray(b"\x02"),
|
|
330
|
+
enum_def_dict={"status": enum_def})
|
|
331
|
+
|
|
332
|
+
assert decoded.field_instances[0].value == 2
|
|
333
|
+
assert decoded.field_instances[0].enum_item == ("NG", 2)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_field_instance_from_value_sets_enum_item_from_field_enum_def():
|
|
337
|
+
"""Test FieldInstance.from_value sets enum_item from field enum_def."""
|
|
338
|
+
enum_def = EnumDef(name="Mode", values={"AUTO": 0, "MANUAL": 1})
|
|
339
|
+
field_def = FieldDef(name="mode",
|
|
340
|
+
offset=InfoSize(0, 0),
|
|
341
|
+
size=InfoSize(1, 0),
|
|
342
|
+
type="unsigned int",
|
|
343
|
+
enum_def=enum_def)
|
|
344
|
+
|
|
345
|
+
field_instance = FieldInstance.from_value(field_def, 1)
|
|
346
|
+
|
|
347
|
+
assert field_instance.enum_item == ("MANUAL", 1)
|
|
231
348
|
|
|
232
349
|
|
|
233
350
|
def test_struct_def_metadata_json_round_trip():
|
|
@@ -358,10 +475,11 @@ def test_type_expression_uses_previous_field_value():
|
|
|
358
475
|
bytearray(),
|
|
359
476
|
)
|
|
360
477
|
decoded_int = decode(struct_def, encoded_int)
|
|
361
|
-
assert decoded_int.field_instances == [
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
]
|
|
478
|
+
assert decoded_int.field_instances[0] == FieldInstance(struct_def[0], 1)
|
|
479
|
+
assert decoded_int.field_instances[1].field_def.offset == InfoSize(1, 0)
|
|
480
|
+
assert decoded_int.field_instances[1].field_def.size == InfoSize(1, 0)
|
|
481
|
+
assert decoded_int.field_instances[1].field_def.type == "int"
|
|
482
|
+
assert decoded_int.field_instances[1].value == 7
|
|
365
483
|
|
|
366
484
|
encoded_float = encode(
|
|
367
485
|
StructInstance(struct_def=StructDef(fields=struct_def),
|
|
@@ -373,7 +491,9 @@ def test_type_expression_uses_previous_field_value():
|
|
|
373
491
|
)
|
|
374
492
|
decoded_float = decode(struct_def, encoded_float)
|
|
375
493
|
assert decoded_float.field_instances[0] == FieldInstance(struct_def[0], 2)
|
|
376
|
-
assert decoded_float.field_instances[1].field_def ==
|
|
494
|
+
assert decoded_float.field_instances[1].field_def.offset == InfoSize(1, 0)
|
|
495
|
+
assert decoded_float.field_instances[1].field_def.size == InfoSize(4, 0)
|
|
496
|
+
assert decoded_float.field_instances[1].field_def.type == "float"
|
|
377
497
|
assert decoded_float.field_instances[1].value == 1.5
|
|
378
498
|
|
|
379
499
|
|
|
@@ -410,6 +530,84 @@ def test_decode_inserts_padding_fields_for_gaps_and_trailing_space():
|
|
|
410
530
|
assert decoded.field_instances[2].value == b"\xdd\xee"
|
|
411
531
|
|
|
412
532
|
|
|
533
|
+
def test_decode_splits_padding_at_32_bit_boundaries():
|
|
534
|
+
"""Test split into 32-bit chunks when a gap crosses a boundary."""
|
|
535
|
+
struct_def = StructDef(fields=[
|
|
536
|
+
FieldDef(name="head",
|
|
537
|
+
offset=InfoSize(0, 0),
|
|
538
|
+
size=InfoSize(1, 0),
|
|
539
|
+
type="unsigned int"),
|
|
540
|
+
FieldDef(name="tail",
|
|
541
|
+
offset=InfoSize(5, 0),
|
|
542
|
+
size=InfoSize(1, 0),
|
|
543
|
+
type="unsigned int"),
|
|
544
|
+
])
|
|
545
|
+
decoded = decode(struct_def, bytearray(8))
|
|
546
|
+
|
|
547
|
+
assert [
|
|
548
|
+
field_instance.field_def.name
|
|
549
|
+
for field_instance in decoded.field_instances
|
|
550
|
+
] == [
|
|
551
|
+
"head",
|
|
552
|
+
"padding[0]",
|
|
553
|
+
"padding[1]",
|
|
554
|
+
"tail",
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_decode_splits_padding_with_custom_alignment_bits():
|
|
559
|
+
"""Test that decode can split padding by a custom bit alignment."""
|
|
560
|
+
struct_def = StructDef(fields=[
|
|
561
|
+
FieldDef(name="head",
|
|
562
|
+
offset=InfoSize(0, 0),
|
|
563
|
+
size=InfoSize(1, 0),
|
|
564
|
+
type="unsigned int"),
|
|
565
|
+
FieldDef(name="tail",
|
|
566
|
+
offset=InfoSize(4, 0),
|
|
567
|
+
size=InfoSize(1, 0),
|
|
568
|
+
type="unsigned int"),
|
|
569
|
+
])
|
|
570
|
+
data = bytearray(b"\x11\xaa\xbb\xcc\x22")
|
|
571
|
+
|
|
572
|
+
decoded = decode(struct_def, data, padding_alignment_bits=16)
|
|
573
|
+
|
|
574
|
+
assert [
|
|
575
|
+
field_instance.field_def.name
|
|
576
|
+
for field_instance in decoded.field_instances
|
|
577
|
+
] == [
|
|
578
|
+
"head",
|
|
579
|
+
"padding[0]",
|
|
580
|
+
"padding[1]",
|
|
581
|
+
"tail",
|
|
582
|
+
]
|
|
583
|
+
assert decoded.field_instances[1].field_def.offset == InfoSize(1, 0)
|
|
584
|
+
assert decoded.field_instances[1].field_def.size == InfoSize(1, 0)
|
|
585
|
+
assert decoded.field_instances[1].value == b"\xaa"
|
|
586
|
+
assert decoded.field_instances[2].field_def.offset == InfoSize(2, 0)
|
|
587
|
+
assert decoded.field_instances[2].field_def.size == InfoSize(2, 0)
|
|
588
|
+
assert decoded.field_instances[2].value == b"\xbb\xcc"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def test_decode_rejects_invalid_padding_alignment_bits():
|
|
592
|
+
"""Test that decode validates padding alignment bit size."""
|
|
593
|
+
struct_def = StructDef(fields=[])
|
|
594
|
+
|
|
595
|
+
with pytest.raises(
|
|
596
|
+
ValueError,
|
|
597
|
+
match=r"padding_alignment_bits must be a positive power of two"):
|
|
598
|
+
decode(struct_def, bytearray(), padding_alignment_bits=24)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def test_encode_rejects_invalid_padding_alignment_bits():
|
|
602
|
+
"""Test that encode validates padding alignment bit size."""
|
|
603
|
+
struct_instance = StructInstance(struct_def=StructDef(fields=[]))
|
|
604
|
+
|
|
605
|
+
with pytest.raises(
|
|
606
|
+
ValueError,
|
|
607
|
+
match=r"padding_alignment_bits must be a positive power of two"):
|
|
608
|
+
encode(struct_instance, bytearray(), padding_alignment_bits=0)
|
|
609
|
+
|
|
610
|
+
|
|
413
611
|
def test_encode_rejects_tuple_input():
|
|
414
612
|
"""Test that encode only accepts StructInstance input."""
|
|
415
613
|
field_def = FieldDef(name="value",
|
|
@@ -535,6 +733,33 @@ def test_field_instance_sort_uses_field_def_order():
|
|
|
535
733
|
assert sorted_instances == [earlier, later]
|
|
536
734
|
|
|
537
735
|
|
|
736
|
+
def test_field_instance_range_check_evaluates_expression_with_field_env():
|
|
737
|
+
"""Test that range_check evaluates expression with field name bound."""
|
|
738
|
+
field_def = FieldDef(name="value",
|
|
739
|
+
offset=InfoSize(0, 0),
|
|
740
|
+
size=InfoSize(1, 0),
|
|
741
|
+
type="unsigned int",
|
|
742
|
+
range_expression="0 <= value <= limit")
|
|
743
|
+
field_instance = FieldInstance(field_def=field_def, value=7)
|
|
744
|
+
|
|
745
|
+
assert field_instance.range_check({"limit": 10}) is True
|
|
746
|
+
assert field_instance.range_check({"limit": 5}) is False
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def test_field_instance_range_check_returns_none_when_expression_is_none():
|
|
750
|
+
"""Test that range_check returns None when range_expression is None."""
|
|
751
|
+
field_def = FieldDef(name="value",
|
|
752
|
+
offset=InfoSize(0, 0),
|
|
753
|
+
size=InfoSize(1, 0),
|
|
754
|
+
type="unsigned int",
|
|
755
|
+
range_expression=None)
|
|
756
|
+
field_instance = FieldInstance(field_def=field_def, value=7)
|
|
757
|
+
|
|
758
|
+
result = field_instance.range_check()
|
|
759
|
+
|
|
760
|
+
assert result is None
|
|
761
|
+
|
|
762
|
+
|
|
538
763
|
def test_struct_instance_size_property_returns_initial_size():
|
|
539
764
|
"""Test that StructInstance size is initialized from the provided value."""
|
|
540
765
|
struct_instance = StructInstance(size=InfoSize(5, 0),
|
|
@@ -74,7 +74,7 @@ wheels = [
|
|
|
74
74
|
|
|
75
75
|
[[package]]
|
|
76
76
|
name = "sltcodec"
|
|
77
|
-
version = "0.
|
|
77
|
+
version = "0.4.0"
|
|
78
78
|
source = { editable = "." }
|
|
79
79
|
dependencies = [
|
|
80
80
|
{ name = "sltcalc" },
|
|
@@ -89,7 +89,7 @@ dev = [
|
|
|
89
89
|
[package.metadata]
|
|
90
90
|
requires-dist = [
|
|
91
91
|
{ name = "sltcalc", specifier = ">=0.1.0" },
|
|
92
|
-
{ name = "sltcore", specifier = ">=1.
|
|
92
|
+
{ name = "sltcore", specifier = ">=1.5.0" },
|
|
93
93
|
]
|
|
94
94
|
|
|
95
95
|
[package.metadata.requires-dev]
|
|
@@ -97,9 +97,9 @@ dev = [{ name = "pytest", specifier = ">=9.1.1" }]
|
|
|
97
97
|
|
|
98
98
|
[[package]]
|
|
99
99
|
name = "sltcore"
|
|
100
|
-
version = "1.
|
|
100
|
+
version = "1.5.0"
|
|
101
101
|
source = { registry = "https://pypi.org/simple" }
|
|
102
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
102
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/692c45c8d007b71b627fb4c53d90ecb5ea1168d892dc14e5ad3ecae0b8d3/sltcore-1.5.0.tar.gz", hash = "sha256:26280f743947f22e31e334fd793679f3aad19367bbf9bfddb6215123d7bd2d18", size = 18050, upload-time = "2026-08-11T13:14:51.241Z" }
|
|
103
103
|
wheels = [
|
|
104
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
104
|
+
{ url = "https://files.pythonhosted.org/packages/0f/ca/38d1ab93a871ba9e60e500b93f8c48fdb2be3308a8ab08e6ea0a73e516c2/sltcore-1.5.0-py3-none-any.whl", hash = "sha256:148611c58329fcadc793b38a973412144c073a036516baf9430b3170e71180c4", size = 10458, upload-time = "2026-08-11T13:14:50.295Z" },
|
|
105
105
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|