sltcodec 0.2.0__tar.gz → 0.4.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.2.0 → sltcodec-0.4.0}/PKG-INFO +3 -3
- {sltcodec-0.2.0 → sltcodec-0.4.0}/pyproject.toml +2 -2
- {sltcodec-0.2.0 → sltcodec-0.4.0}/src/sltcodec/__init__.py +2 -1
- {sltcodec-0.2.0 → sltcodec-0.4.0}/src/sltcodec/codec.py +96 -38
- {sltcodec-0.2.0 → sltcodec-0.4.0}/src/sltcodec/types.py +258 -72
- {sltcodec-0.2.0 → sltcodec-0.4.0}/tests/test_codec.py +278 -3
- {sltcodec-0.2.0 → sltcodec-0.4.0}/uv.lock +5 -5
- {sltcodec-0.2.0 → sltcodec-0.4.0}/.github/workflows/publish_to_pypi.yml +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/.github/workflows/publish_to_testpypi.yml +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/.gitignore +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/.python-version +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/.vscode/launch.json +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/.vscode/settings.json +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/LICENSE +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/README.md +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/bump_major.ps1 +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/bump_minor.ps1 +0 -0
- {sltcodec-0.2.0 → sltcodec-0.4.0}/bump_patch.ps1 +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sltcodec
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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.4.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)
|
|
@@ -170,25 +188,26 @@ def _split_repeated_field(field_def: FieldDef,
|
|
|
170
188
|
resolved_offset = (_resolve_offset(field_def, env)
|
|
171
189
|
if offset is None else offset)
|
|
172
190
|
resolved_size = _resolve_size(field_def, env)
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
return field_def.split_repeat(index,
|
|
192
|
+
offset=resolved_offset,
|
|
193
|
+
size=resolved_size)
|
|
194
|
+
|
|
195
|
+
|
|
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:
|
|
187
205
|
"""Encode a single field into a bytearray."""
|
|
188
206
|
if env is None:
|
|
189
207
|
env = {}
|
|
190
208
|
|
|
191
|
-
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)
|
|
192
211
|
if prepared is None:
|
|
193
212
|
return
|
|
194
213
|
|
|
@@ -200,9 +219,13 @@ def encode_field(field_def: FieldDef,
|
|
|
200
219
|
bits_set(buf, offset, info)
|
|
201
220
|
|
|
202
221
|
|
|
203
|
-
def encode(
|
|
204
|
-
|
|
205
|
-
|
|
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:
|
|
206
229
|
"""Encode decode() result into a bytearray.
|
|
207
230
|
|
|
208
231
|
Parameters
|
|
@@ -213,6 +236,10 @@ def encode(struct_instance: StructInstance,
|
|
|
213
236
|
The base bytearray instance to write into.
|
|
214
237
|
struct_def_dict : dict[str, StructDef] | None, optional
|
|
215
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.
|
|
216
243
|
|
|
217
244
|
Returns
|
|
218
245
|
-------
|
|
@@ -220,6 +247,7 @@ def encode(struct_instance: StructInstance,
|
|
|
220
247
|
The encoded bytearray.
|
|
221
248
|
"""
|
|
222
249
|
_validate_struct_instance(struct_instance)
|
|
250
|
+
_validate_padding_alignment_bits(padding_alignment_bits)
|
|
223
251
|
env: dict[str, Any] = {}
|
|
224
252
|
has_padding = False
|
|
225
253
|
|
|
@@ -240,14 +268,16 @@ def encode(struct_instance: StructInstance,
|
|
|
240
268
|
field_def_repeat = _split_repeated_field(
|
|
241
269
|
field_def, i, env, current_offset)
|
|
242
270
|
encode_field(field_def_repeat, values[i], buf, env,
|
|
243
|
-
struct_def_dict
|
|
271
|
+
struct_def_dict, enum_def_dict,
|
|
272
|
+
padding_alignment_bits)
|
|
244
273
|
env[field_def_repeat.name] = values[i]
|
|
245
274
|
if isinstance(current_offset, InfoSize) and isinstance(
|
|
246
275
|
resolved_size, InfoSize):
|
|
247
276
|
current_offset += resolved_size
|
|
248
277
|
continue
|
|
249
278
|
|
|
250
|
-
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)
|
|
251
281
|
env[field_def.name] = value
|
|
252
282
|
|
|
253
283
|
if has_padding and struct_instance.size.bytes > len(buf):
|
|
@@ -260,7 +290,9 @@ def decode_field(
|
|
|
260
290
|
field_def: FieldDef,
|
|
261
291
|
data: bytearray | bytes,
|
|
262
292
|
env: dict[str, Any] | None = None,
|
|
263
|
-
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,
|
|
264
296
|
) -> FieldInstance | None:
|
|
265
297
|
"""Decode a single field from a bytearray according to a field definition.
|
|
266
298
|
|
|
@@ -291,25 +323,41 @@ def decode_field(
|
|
|
291
323
|
return FieldInstance(
|
|
292
324
|
field_def=field_def,
|
|
293
325
|
value=decode(resolved_type, bytearray(info.to_bytes),
|
|
294
|
-
struct_def_dict
|
|
326
|
+
struct_def_dict, enum_def_dict,
|
|
327
|
+
padding_alignment_bits),
|
|
295
328
|
)
|
|
296
329
|
if resolved_type == "bool":
|
|
297
|
-
return FieldInstance(field_def
|
|
330
|
+
return FieldInstance.from_value(field_def,
|
|
331
|
+
info.to_bool,
|
|
332
|
+
enum_def_dict=enum_def_dict)
|
|
298
333
|
if resolved_type == "signed int":
|
|
299
|
-
return FieldInstance(field_def
|
|
334
|
+
return FieldInstance.from_value(field_def,
|
|
335
|
+
info.to_signed_int,
|
|
336
|
+
enum_def_dict=enum_def_dict)
|
|
300
337
|
if resolved_type in ["int", "unsigned int"]:
|
|
301
|
-
return FieldInstance(field_def
|
|
338
|
+
return FieldInstance.from_value(field_def,
|
|
339
|
+
info.to_unsigned_int,
|
|
340
|
+
enum_def_dict=enum_def_dict)
|
|
302
341
|
if resolved_type == "float":
|
|
303
|
-
return FieldInstance(field_def
|
|
342
|
+
return FieldInstance.from_value(field_def,
|
|
343
|
+
info.to_float,
|
|
344
|
+
enum_def_dict=enum_def_dict)
|
|
304
345
|
if resolved_type in ["bytearray", "bytes"]:
|
|
305
|
-
return FieldInstance(field_def
|
|
306
|
-
|
|
346
|
+
return FieldInstance.from_value(field_def,
|
|
347
|
+
info.to_bytes,
|
|
348
|
+
enum_def_dict=enum_def_dict)
|
|
349
|
+
return FieldInstance.from_value(field_def,
|
|
350
|
+
info.raw_value,
|
|
351
|
+
enum_def_dict=enum_def_dict)
|
|
307
352
|
|
|
308
353
|
|
|
309
354
|
def decode(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
355
|
+
struct_def: StructDef | list[FieldDef],
|
|
356
|
+
data: bytearray | bytes,
|
|
357
|
+
struct_def_dict: dict[str, StructDef] | None = None,
|
|
358
|
+
enum_def_dict: dict[str, EnumDef] | None = None,
|
|
359
|
+
padding_alignment_bits: int = _DEFAULT_PADDING_ALIGNMENT_BITS,
|
|
360
|
+
) -> StructInstance:
|
|
313
361
|
"""Decode a bytearray into field values according to a layout.
|
|
314
362
|
|
|
315
363
|
Parameters
|
|
@@ -320,11 +368,16 @@ def decode(
|
|
|
320
368
|
The data to decode.
|
|
321
369
|
struct_def_dict : dict[str, StructDef] | None, optional
|
|
322
370
|
A dictionary of structure definitions, by default None.
|
|
371
|
+
enum_def_dict : dict[str, EnumDef] | None, optional
|
|
372
|
+
A dictionary of enum definitions, by default None.
|
|
373
|
+
padding_alignment_bits : int, optional
|
|
374
|
+
The padding alignment boundary in bits, by default 32.
|
|
323
375
|
Returns
|
|
324
376
|
-------
|
|
325
377
|
StructInstance
|
|
326
378
|
The decoded structure instance.
|
|
327
379
|
"""
|
|
380
|
+
_validate_padding_alignment_bits(padding_alignment_bits)
|
|
328
381
|
env = {}
|
|
329
382
|
struct_def_obj = (struct_def if isinstance(struct_def, StructDef) else
|
|
330
383
|
StructDef(fields=struct_def))
|
|
@@ -341,8 +394,11 @@ def decode(
|
|
|
341
394
|
|
|
342
395
|
chunk_start_bits = start_bits
|
|
343
396
|
while chunk_start_bits < end_bits:
|
|
344
|
-
|
|
345
|
-
|
|
397
|
+
chunk_start = _info_size_from_bits(chunk_start_bits)
|
|
398
|
+
bits_to_boundary = _info_size_to_bits(
|
|
399
|
+
chunk_start.align_to(padding_alignment_bits))
|
|
400
|
+
chunk_size_bits = bits_to_boundary or padding_alignment_bits
|
|
401
|
+
chunk_end_bits = min(end_bits, chunk_start_bits + chunk_size_bits)
|
|
346
402
|
padding_field_def = FieldDef(
|
|
347
403
|
name=f"padding[{padding_index}]",
|
|
348
404
|
offset=_info_size_from_bits(chunk_start_bits),
|
|
@@ -363,7 +419,8 @@ def decode(
|
|
|
363
419
|
if field_def.repeat is None or field_def.repeat <= 1:
|
|
364
420
|
resolved_offset = _resolve_offset(field_def, env)
|
|
365
421
|
append_padding_until(resolved_offset)
|
|
366
|
-
field_instance = decode_field(field_def, data, env, struct_def_dict
|
|
422
|
+
field_instance = decode_field(field_def, data, env, struct_def_dict,
|
|
423
|
+
enum_def_dict, padding_alignment_bits)
|
|
367
424
|
if field_instance is not None:
|
|
368
425
|
env[field_instance.field_def.name] = field_instance.value
|
|
369
426
|
result.append_field_instance(field_instance)
|
|
@@ -378,7 +435,8 @@ def decode(
|
|
|
378
435
|
field_def_repeat = _split_repeated_field(field_def, i, env,
|
|
379
436
|
current_offset)
|
|
380
437
|
field_instance = decode_field(field_def_repeat, data, env,
|
|
381
|
-
struct_def_dict
|
|
438
|
+
struct_def_dict, enum_def_dict,
|
|
439
|
+
padding_alignment_bits)
|
|
382
440
|
if field_instance is not None:
|
|
383
441
|
env[field_instance.field_def.name] = field_instance.value
|
|
384
442
|
result.append_field_instance(field_instance)
|
|
@@ -2,12 +2,72 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
9
|
+
from sltcalc import SltEval
|
|
8
10
|
from sltcore import Info, InfoSize
|
|
9
11
|
|
|
10
12
|
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class EnumDef:
|
|
15
|
+
"""An enumeration definition."""
|
|
16
|
+
name: str = field(default_factory=str,
|
|
17
|
+
metadata={"desc": "The name of the enum"})
|
|
18
|
+
description: str | None = field(
|
|
19
|
+
default=None, metadata={"desc": "The description of the enum"})
|
|
20
|
+
values: dict[str, int] = field(
|
|
21
|
+
default_factory=dict,
|
|
22
|
+
metadata={"desc": "The mapping of enum names to integer values"},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
"""Convert this enum definition to a JSON-serializable dictionary."""
|
|
27
|
+
return {
|
|
28
|
+
"name": self.name,
|
|
29
|
+
"description": self.description,
|
|
30
|
+
"values": self.values,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def to_json(self) -> str:
|
|
34
|
+
"""Convert this enum definition to a JSON string."""
|
|
35
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, data: dict[str, Any]) -> "EnumDef":
|
|
39
|
+
"""Create an enum definition from a JSON-serializable dictionary."""
|
|
40
|
+
name = data.get("name", "")
|
|
41
|
+
description = data.get("description")
|
|
42
|
+
values = data.get("values", {})
|
|
43
|
+
return cls(name=name, description=description, values=values)
|
|
44
|
+
|
|
45
|
+
def serialize(self) -> dict[str, Any]:
|
|
46
|
+
"""Serialize this enum definition with an explicit type tag."""
|
|
47
|
+
return {
|
|
48
|
+
"__type__": "EnumDef",
|
|
49
|
+
"name": self.name,
|
|
50
|
+
"description": self.description,
|
|
51
|
+
"values": self.values,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def deserialize(cls, data: dict[str, Any]) -> "EnumDef":
|
|
56
|
+
"""Deserialize an enum definition from a typed dictionary."""
|
|
57
|
+
if data.get("__type__") != "EnumDef":
|
|
58
|
+
raise ValueError("Invalid EnumDef payload")
|
|
59
|
+
return cls(
|
|
60
|
+
name=data.get("name", ""),
|
|
61
|
+
description=data.get("description"),
|
|
62
|
+
values=data.get("values", {}),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_json(cls, data: str) -> "EnumDef":
|
|
67
|
+
"""Create an enum definition from a JSON string."""
|
|
68
|
+
return cls.from_dict(json.loads(data))
|
|
69
|
+
|
|
70
|
+
|
|
11
71
|
@dataclass(frozen=True)
|
|
12
72
|
class FieldDef:
|
|
13
73
|
"""A field in a structured layout."""
|
|
@@ -25,18 +85,47 @@ class FieldDef:
|
|
|
25
85
|
default=None, metadata={"desc": "The repeat count of the field"})
|
|
26
86
|
description: str | None = field(
|
|
27
87
|
default=None, metadata={"desc": "The description of the field"})
|
|
88
|
+
range_expression: str | None = field(
|
|
89
|
+
default=None, metadata={"desc": "The value range expression"})
|
|
90
|
+
enum_def: EnumDef | None = field(
|
|
91
|
+
default=None, metadata={"desc": "The enum definition for the field"})
|
|
28
92
|
|
|
29
93
|
def split_repeat(self,
|
|
30
94
|
index: int,
|
|
31
|
-
offset: InfoSize | str | None = None
|
|
95
|
+
offset: InfoSize | str | None = None,
|
|
96
|
+
size: InfoSize | str | None = None) -> "FieldDef":
|
|
32
97
|
"""Create a single repeated-field definition for the given index."""
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
98
|
+
split_name = f"{self.name}[{index}]"
|
|
99
|
+
split_offset_source = self.offset if offset is None else offset
|
|
100
|
+
split_size_source = self.size if size is None else size
|
|
101
|
+
split_offset = self._replace_name_in_expression(split_offset_source,
|
|
102
|
+
self.name, split_name)
|
|
103
|
+
split_size = self._replace_name_in_expression(split_size_source,
|
|
104
|
+
self.name, split_name)
|
|
105
|
+
split_type = self._replace_name_in_expression(self.type, self.name,
|
|
106
|
+
split_name)
|
|
107
|
+
split_range_expression = self._replace_name_in_expression(
|
|
108
|
+
self.range_expression, self.name, split_name)
|
|
109
|
+
|
|
110
|
+
return FieldDef(name=split_name,
|
|
111
|
+
offset=split_offset,
|
|
112
|
+
size=split_size,
|
|
113
|
+
type=split_type,
|
|
37
114
|
scale=self.scale,
|
|
38
115
|
repeat=None,
|
|
39
|
-
description=self.description
|
|
116
|
+
description=self.description,
|
|
117
|
+
range_expression=split_range_expression,
|
|
118
|
+
enum_def=self.enum_def)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _replace_name_in_expression(value: Any, old_name: str,
|
|
122
|
+
new_name: str) -> Any:
|
|
123
|
+
"""Replace standalone old_name tokens in expression-like strings."""
|
|
124
|
+
if not isinstance(value, str):
|
|
125
|
+
return value
|
|
126
|
+
pattern = (rf"(?<![0-9A-Za-z_]){re.escape(old_name)}"
|
|
127
|
+
rf"(?![0-9A-Za-z_])")
|
|
128
|
+
return re.sub(pattern, lambda _: new_name, value)
|
|
40
129
|
|
|
41
130
|
def __lt__(self, other: object) -> bool:
|
|
42
131
|
"""Compare field definitions using a stable serialized sort key."""
|
|
@@ -47,13 +136,33 @@ class FieldDef:
|
|
|
47
136
|
def to_dict(self) -> dict[str, Any]:
|
|
48
137
|
"""Convert this field definition to a JSON-serializable dictionary."""
|
|
49
138
|
return {
|
|
50
|
-
"name":
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
139
|
+
"name":
|
|
140
|
+
self.name,
|
|
141
|
+
"offset":
|
|
142
|
+
self.offset.serialize() if isinstance(self.offset,
|
|
143
|
+
(Info,
|
|
144
|
+
InfoSize)) else self.offset,
|
|
145
|
+
"size":
|
|
146
|
+
self.size.serialize() if isinstance(self.size,
|
|
147
|
+
(Info,
|
|
148
|
+
InfoSize)) else self.size,
|
|
149
|
+
"type": {
|
|
150
|
+
"__type__": "StructDef",
|
|
151
|
+
"fields": self.type.to_dict(),
|
|
152
|
+
} if isinstance(self.type, StructDef) else {
|
|
153
|
+
"__type__": "StructDef",
|
|
154
|
+
"fields": [field.to_dict() for field in self.type],
|
|
155
|
+
} if isinstance(self.type, list) else self.type,
|
|
156
|
+
"scale":
|
|
157
|
+
self.scale,
|
|
158
|
+
"repeat":
|
|
159
|
+
self.repeat,
|
|
160
|
+
"description":
|
|
161
|
+
self.description,
|
|
162
|
+
"range_expression":
|
|
163
|
+
self.range_expression,
|
|
164
|
+
"enum_def":
|
|
165
|
+
None if self.enum_def is None else self.enum_def.serialize(),
|
|
57
166
|
}
|
|
58
167
|
|
|
59
168
|
def to_json(self) -> str:
|
|
@@ -63,14 +172,68 @@ class FieldDef:
|
|
|
63
172
|
@classmethod
|
|
64
173
|
def from_dict(cls, data: dict[str, Any]) -> "FieldDef":
|
|
65
174
|
"""Create a field definition from a JSON-serializable dictionary."""
|
|
175
|
+
name = data.get("name", "")
|
|
176
|
+
offset_data = data.get("offset")
|
|
177
|
+
size_data = data.get("size")
|
|
178
|
+
type_data = data.get("type")
|
|
179
|
+
scale = data.get("scale", 1.0)
|
|
180
|
+
repeat = data.get("repeat")
|
|
181
|
+
description = data.get("description")
|
|
182
|
+
range_expression = data.get("range_expression")
|
|
183
|
+
enum_def_data = data.get("enum_def")
|
|
184
|
+
|
|
185
|
+
def _deserialize_info_like(value: Any) -> Any:
|
|
186
|
+
if isinstance(value, dict):
|
|
187
|
+
value_type = value.get("__type__")
|
|
188
|
+
if value_type == "Info":
|
|
189
|
+
return Info.deserialize(json.dumps(value))
|
|
190
|
+
if value_type == "InfoSize":
|
|
191
|
+
return InfoSize.deserialize(json.dumps(value))
|
|
192
|
+
return value
|
|
193
|
+
if not isinstance(value, str):
|
|
194
|
+
return value
|
|
195
|
+
try:
|
|
196
|
+
parsed_value = json.loads(value)
|
|
197
|
+
except json.JSONDecodeError:
|
|
198
|
+
return value
|
|
199
|
+
if (isinstance(parsed_value, dict)
|
|
200
|
+
and parsed_value.get("__type__") == "Info"):
|
|
201
|
+
return Info.deserialize(value)
|
|
202
|
+
if (isinstance(parsed_value, dict)
|
|
203
|
+
and parsed_value.get("__type__") == "InfoSize"):
|
|
204
|
+
return InfoSize.deserialize(value)
|
|
205
|
+
return value
|
|
206
|
+
|
|
207
|
+
offset = _deserialize_info_like(offset_data)
|
|
208
|
+
size = _deserialize_info_like(size_data)
|
|
209
|
+
|
|
210
|
+
if (isinstance(type_data, dict)
|
|
211
|
+
and type_data.get("__type__") == "StructDef"):
|
|
212
|
+
type_value = StructDef.from_dict(type_data["fields"])
|
|
213
|
+
elif isinstance(type_data, list):
|
|
214
|
+
# Backward-compatibility for legacy list-based nested types.
|
|
215
|
+
type_value = StructDef.from_dict(type_data)
|
|
216
|
+
else:
|
|
217
|
+
type_value = type_data
|
|
218
|
+
|
|
219
|
+
if enum_def_data is None:
|
|
220
|
+
enum_def = None
|
|
221
|
+
elif (isinstance(enum_def_data, dict)
|
|
222
|
+
and enum_def_data.get("__type__") == "EnumDef"):
|
|
223
|
+
enum_def = EnumDef.deserialize(enum_def_data)
|
|
224
|
+
else:
|
|
225
|
+
enum_def = None
|
|
226
|
+
|
|
66
227
|
return cls(
|
|
67
|
-
name=
|
|
68
|
-
offset=
|
|
69
|
-
size=
|
|
70
|
-
type=
|
|
71
|
-
scale=
|
|
72
|
-
repeat=
|
|
73
|
-
description=
|
|
228
|
+
name=name,
|
|
229
|
+
offset=offset,
|
|
230
|
+
size=size,
|
|
231
|
+
type=type_value,
|
|
232
|
+
scale=scale,
|
|
233
|
+
repeat=repeat,
|
|
234
|
+
description=description,
|
|
235
|
+
range_expression=range_expression,
|
|
236
|
+
enum_def=enum_def,
|
|
74
237
|
)
|
|
75
238
|
|
|
76
239
|
@classmethod
|
|
@@ -78,52 +241,6 @@ class FieldDef:
|
|
|
78
241
|
"""Create a field definition from a JSON string."""
|
|
79
242
|
return cls.from_dict(json.loads(data))
|
|
80
243
|
|
|
81
|
-
@staticmethod
|
|
82
|
-
def _serialize_value(value: Any) -> Any:
|
|
83
|
-
if isinstance(value, Info):
|
|
84
|
-
return {
|
|
85
|
-
"__type__": "Info",
|
|
86
|
-
"value": value.to_json(),
|
|
87
|
-
}
|
|
88
|
-
if isinstance(value, InfoSize):
|
|
89
|
-
return {
|
|
90
|
-
"__type__": "InfoSize",
|
|
91
|
-
"value": value.to_json(),
|
|
92
|
-
}
|
|
93
|
-
return value
|
|
94
|
-
|
|
95
|
-
@classmethod
|
|
96
|
-
def _deserialize_value(cls, value: Any) -> Any:
|
|
97
|
-
if isinstance(value, dict) and value.get("__type__") == "Info":
|
|
98
|
-
return Info.from_json(value["value"])
|
|
99
|
-
if isinstance(value, dict) and value.get("__type__") == "InfoSize":
|
|
100
|
-
return InfoSize.from_json(value["value"])
|
|
101
|
-
return value
|
|
102
|
-
|
|
103
|
-
@staticmethod
|
|
104
|
-
def _serialize_type(value: Any) -> Any:
|
|
105
|
-
if isinstance(value, StructDef):
|
|
106
|
-
return {
|
|
107
|
-
"__type__": "StructDef",
|
|
108
|
-
"fields": value.to_dict(),
|
|
109
|
-
}
|
|
110
|
-
if isinstance(value, list):
|
|
111
|
-
# Backward-compatibility for legacy list-based nested types.
|
|
112
|
-
return {
|
|
113
|
-
"__type__": "StructDef",
|
|
114
|
-
"fields": [field.to_dict() for field in value],
|
|
115
|
-
}
|
|
116
|
-
return value
|
|
117
|
-
|
|
118
|
-
@classmethod
|
|
119
|
-
def _deserialize_type(cls, value: Any) -> Any:
|
|
120
|
-
if isinstance(value, dict) and value.get("__type__") == "StructDef":
|
|
121
|
-
return StructDef.from_dict(value["fields"])
|
|
122
|
-
if isinstance(value, list):
|
|
123
|
-
# Backward-compatibility for legacy list-based nested types.
|
|
124
|
-
return StructDef.from_dict(value)
|
|
125
|
-
return value
|
|
126
|
-
|
|
127
244
|
def _sort_key(self) -> tuple[Any, ...]:
|
|
128
245
|
"""Build a stable comparison key for ordering field definitions."""
|
|
129
246
|
return (
|
|
@@ -134,6 +251,8 @@ class FieldDef:
|
|
|
134
251
|
self.scale,
|
|
135
252
|
-1 if self.repeat is None else self.repeat,
|
|
136
253
|
"" if self.description is None else self.description,
|
|
254
|
+
"" if self.range_expression is None else self.range_expression,
|
|
255
|
+
self._sortable_enum_type(self.enum_def),
|
|
137
256
|
)
|
|
138
257
|
|
|
139
258
|
@staticmethod
|
|
@@ -150,12 +269,29 @@ class FieldDef:
|
|
|
150
269
|
return (0, json.dumps(value.to_dict(), sort_keys=True))
|
|
151
270
|
return (1, value)
|
|
152
271
|
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _sortable_enum_type(value: EnumDef | None) -> str:
|
|
274
|
+
"""Build a comparable key for enum definition metadata."""
|
|
275
|
+
if value is None:
|
|
276
|
+
return ""
|
|
277
|
+
return json.dumps(
|
|
278
|
+
{
|
|
279
|
+
"name": value.name,
|
|
280
|
+
"description": value.description,
|
|
281
|
+
"values": value.values,
|
|
282
|
+
},
|
|
283
|
+
sort_keys=True)
|
|
284
|
+
|
|
153
285
|
|
|
154
286
|
@dataclass(frozen=True)
|
|
155
287
|
class FieldInstance:
|
|
156
288
|
"""A decoded/encodable field value with its field definition."""
|
|
157
289
|
field_def: FieldDef = field(metadata={"desc": "The field definition"})
|
|
158
290
|
value: Any = field(metadata={"desc": "The decoded/encodable value"})
|
|
291
|
+
enum_item: tuple[str, int] | None = field(
|
|
292
|
+
default=None,
|
|
293
|
+
metadata={"desc": "Matched enum item for this value"},
|
|
294
|
+
)
|
|
159
295
|
is_padding: bool = field(
|
|
160
296
|
default=False,
|
|
161
297
|
metadata={"desc": "Whether this field instance represents padding"},
|
|
@@ -167,6 +303,57 @@ class FieldInstance:
|
|
|
167
303
|
return NotImplemented
|
|
168
304
|
return self.field_def < other.field_def
|
|
169
305
|
|
|
306
|
+
def range_check(self, env: dict[str, Any] | None = None) -> Any | None:
|
|
307
|
+
"""Evaluate range_expression and return its evaluated result."""
|
|
308
|
+
range_expression = self.field_def.range_expression
|
|
309
|
+
if range_expression is None:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
eval_env = {} if env is None else dict(env)
|
|
313
|
+
eval_env[self.field_def.name] = self.value
|
|
314
|
+
stleval = SltEval(eval_env)
|
|
315
|
+
return stleval.eval(range_expression)
|
|
316
|
+
|
|
317
|
+
@classmethod
|
|
318
|
+
def from_value(cls,
|
|
319
|
+
field_def: FieldDef,
|
|
320
|
+
value: Any,
|
|
321
|
+
enum_def_dict: dict[str, EnumDef] | None = None,
|
|
322
|
+
is_padding: bool = False) -> "FieldInstance":
|
|
323
|
+
"""Create a FieldInstance and attach a matched enum item if any."""
|
|
324
|
+
enum_def = cls._resolve_enum_def(field_def, enum_def_dict)
|
|
325
|
+
enum_item = cls._resolve_enum_item(enum_def, value)
|
|
326
|
+
return cls(field_def=field_def,
|
|
327
|
+
value=value,
|
|
328
|
+
enum_item=enum_item,
|
|
329
|
+
is_padding=is_padding)
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _resolve_enum_def(
|
|
333
|
+
field_def: FieldDef,
|
|
334
|
+
enum_def_dict: dict[str, EnumDef] | None) -> EnumDef | None:
|
|
335
|
+
"""Resolve enum definition from field metadata or lookup dictionary."""
|
|
336
|
+
if field_def.enum_def is not None:
|
|
337
|
+
return field_def.enum_def
|
|
338
|
+
if not enum_def_dict:
|
|
339
|
+
return None
|
|
340
|
+
if isinstance(field_def.type, str) and field_def.type in enum_def_dict:
|
|
341
|
+
return enum_def_dict[field_def.type]
|
|
342
|
+
return enum_def_dict.get(field_def.name)
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _resolve_enum_item(enum_def: EnumDef | None,
|
|
346
|
+
value: Any) -> tuple[str, int] | None:
|
|
347
|
+
"""Resolve one enum values item that matches the given value."""
|
|
348
|
+
if enum_def is None:
|
|
349
|
+
return None
|
|
350
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
351
|
+
return None
|
|
352
|
+
for enum_name, enum_value in enum_def.values.items():
|
|
353
|
+
if enum_value == value:
|
|
354
|
+
return enum_name, enum_value
|
|
355
|
+
return None
|
|
356
|
+
|
|
170
357
|
|
|
171
358
|
@dataclass(frozen=True)
|
|
172
359
|
class StructDef:
|
|
@@ -203,12 +390,11 @@ class StructDef:
|
|
|
203
390
|
if isinstance(data, list):
|
|
204
391
|
# Backward-compatibility for legacy list-only StructDef payloads.
|
|
205
392
|
return cls(fields=[FieldDef.from_dict(item) for item in data])
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
])
|
|
393
|
+
name = data.get("name", "")
|
|
394
|
+
description = data.get("description", "")
|
|
395
|
+
fields_data = data.get("fields", [])
|
|
396
|
+
fields = [FieldDef.from_dict(item) for item in fields_data]
|
|
397
|
+
return cls(name=name, description=description, fields=fields)
|
|
212
398
|
|
|
213
399
|
@classmethod
|
|
214
400
|
def from_json(cls, data: str) -> "StructDef":
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
"""Tests for the sltcodec module."""
|
|
2
|
+
from enum import Enum
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
import pytest
|
|
5
6
|
from sltcore import InfoSize
|
|
6
7
|
|
|
7
|
-
from sltcodec import (FieldDef, FieldInstance, StructDef,
|
|
8
|
-
decode, encode, load_struct_def_dict,
|
|
8
|
+
from sltcodec import (EnumDef, FieldDef, FieldInstance, StructDef,
|
|
9
|
+
StructInstance, decode, encode, load_struct_def_dict,
|
|
9
10
|
save_struct_def_dict)
|
|
10
11
|
from sltcodec.codec import decode_field
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
class ValueKind(Enum):
|
|
15
|
+
"""Enum used for EnumDef metadata tests."""
|
|
16
|
+
A = 1
|
|
17
|
+
B = 2
|
|
18
|
+
|
|
19
|
+
|
|
13
20
|
def test_encode_and_decode_round_trip():
|
|
14
21
|
"""Test that encoding and then decoding returns the original data."""
|
|
15
22
|
struct_def = [
|
|
@@ -146,6 +153,52 @@ def test_repeated_field_preserves_description():
|
|
|
146
153
|
decoded.field_instances[1].field_def.description == "A repeated value")
|
|
147
154
|
|
|
148
155
|
|
|
156
|
+
def test_repeated_field_preserves_range_and_enum_metadata():
|
|
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})
|
|
162
|
+
field_def = FieldDef(name="value",
|
|
163
|
+
offset=InfoSize(0, 0),
|
|
164
|
+
size=InfoSize(1, 0),
|
|
165
|
+
type="unsigned int",
|
|
166
|
+
repeat=2,
|
|
167
|
+
range_expression="0 <= value <= 255",
|
|
168
|
+
enum_def=enum_def)
|
|
169
|
+
|
|
170
|
+
encoded = encode(
|
|
171
|
+
StructInstance(struct_def=StructDef(fields=[field_def]),
|
|
172
|
+
field_instances=[FieldInstance(field_def, [1, 2])]),
|
|
173
|
+
bytearray(),
|
|
174
|
+
)
|
|
175
|
+
decoded = decode([field_def], encoded)
|
|
176
|
+
|
|
177
|
+
assert decoded.field_instances[0].field_def.range_expression == (
|
|
178
|
+
"0 <= value[0] <= 255")
|
|
179
|
+
assert decoded.field_instances[1].field_def.range_expression == (
|
|
180
|
+
"0 <= value[1] <= 255")
|
|
181
|
+
assert decoded.field_instances[0].field_def.enum_def == enum_def
|
|
182
|
+
assert decoded.field_instances[1].field_def.enum_def == enum_def
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_split_repeat_replaces_name_in_range_expression():
|
|
186
|
+
"""Test split_repeat replaces field-name tokens in range expressions."""
|
|
187
|
+
field_def = FieldDef(
|
|
188
|
+
name="value",
|
|
189
|
+
offset=InfoSize(0, 0),
|
|
190
|
+
size=InfoSize(1, 0),
|
|
191
|
+
type="unsigned int",
|
|
192
|
+
repeat=2,
|
|
193
|
+
range_expression=("0 <= value and value <= limit and value_count"))
|
|
194
|
+
|
|
195
|
+
split = field_def.split_repeat(1)
|
|
196
|
+
|
|
197
|
+
assert split.name == "value[1]"
|
|
198
|
+
assert split.range_expression == (
|
|
199
|
+
"0 <= value[1] and value[1] <= limit and value_count")
|
|
200
|
+
|
|
201
|
+
|
|
149
202
|
def test_field_def_json_round_trip():
|
|
150
203
|
"""Test that FieldDef can be serialized and deserialized from JSON."""
|
|
151
204
|
field_def = FieldDef(name="value",
|
|
@@ -163,18 +216,135 @@ def test_field_def_json_round_trip():
|
|
|
163
216
|
|
|
164
217
|
def test_field_def_to_json_from_json_round_trip():
|
|
165
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})
|
|
166
223
|
field_def = FieldDef(name="value",
|
|
167
224
|
offset=InfoSize(0, 0),
|
|
168
225
|
size=InfoSize(1, 0),
|
|
169
226
|
type="unsigned int",
|
|
170
227
|
scale=2.0,
|
|
171
228
|
repeat=3,
|
|
172
|
-
description="A repeated value"
|
|
229
|
+
description="A repeated value",
|
|
230
|
+
range_expression="0 <= value <= 255",
|
|
231
|
+
enum_def=enum_def)
|
|
173
232
|
|
|
174
233
|
payload = field_def.to_json()
|
|
175
234
|
restored = FieldDef.from_json(payload)
|
|
176
235
|
|
|
177
236
|
assert restored == field_def
|
|
237
|
+
assert restored.range_expression == "0 <= value <= 255"
|
|
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)
|
|
178
348
|
|
|
179
349
|
|
|
180
350
|
def test_struct_def_metadata_json_round_trip():
|
|
@@ -357,6 +527,84 @@ def test_decode_inserts_padding_fields_for_gaps_and_trailing_space():
|
|
|
357
527
|
assert decoded.field_instances[2].value == b"\xdd\xee"
|
|
358
528
|
|
|
359
529
|
|
|
530
|
+
def test_decode_splits_padding_at_32_bit_boundaries():
|
|
531
|
+
"""Test split into 32-bit chunks when a gap crosses a boundary."""
|
|
532
|
+
struct_def = StructDef(fields=[
|
|
533
|
+
FieldDef(name="head",
|
|
534
|
+
offset=InfoSize(0, 0),
|
|
535
|
+
size=InfoSize(1, 0),
|
|
536
|
+
type="unsigned int"),
|
|
537
|
+
FieldDef(name="tail",
|
|
538
|
+
offset=InfoSize(5, 0),
|
|
539
|
+
size=InfoSize(1, 0),
|
|
540
|
+
type="unsigned int"),
|
|
541
|
+
])
|
|
542
|
+
decoded = decode(struct_def, bytearray(8))
|
|
543
|
+
|
|
544
|
+
assert [
|
|
545
|
+
field_instance.field_def.name
|
|
546
|
+
for field_instance in decoded.field_instances
|
|
547
|
+
] == [
|
|
548
|
+
"head",
|
|
549
|
+
"padding[0]",
|
|
550
|
+
"padding[1]",
|
|
551
|
+
"tail",
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_decode_splits_padding_with_custom_alignment_bits():
|
|
556
|
+
"""Test that decode can split padding by a custom bit alignment."""
|
|
557
|
+
struct_def = StructDef(fields=[
|
|
558
|
+
FieldDef(name="head",
|
|
559
|
+
offset=InfoSize(0, 0),
|
|
560
|
+
size=InfoSize(1, 0),
|
|
561
|
+
type="unsigned int"),
|
|
562
|
+
FieldDef(name="tail",
|
|
563
|
+
offset=InfoSize(4, 0),
|
|
564
|
+
size=InfoSize(1, 0),
|
|
565
|
+
type="unsigned int"),
|
|
566
|
+
])
|
|
567
|
+
data = bytearray(b"\x11\xaa\xbb\xcc\x22")
|
|
568
|
+
|
|
569
|
+
decoded = decode(struct_def, data, padding_alignment_bits=16)
|
|
570
|
+
|
|
571
|
+
assert [
|
|
572
|
+
field_instance.field_def.name
|
|
573
|
+
for field_instance in decoded.field_instances
|
|
574
|
+
] == [
|
|
575
|
+
"head",
|
|
576
|
+
"padding[0]",
|
|
577
|
+
"padding[1]",
|
|
578
|
+
"tail",
|
|
579
|
+
]
|
|
580
|
+
assert decoded.field_instances[1].field_def.offset == InfoSize(1, 0)
|
|
581
|
+
assert decoded.field_instances[1].field_def.size == InfoSize(1, 0)
|
|
582
|
+
assert decoded.field_instances[1].value == b"\xaa"
|
|
583
|
+
assert decoded.field_instances[2].field_def.offset == InfoSize(2, 0)
|
|
584
|
+
assert decoded.field_instances[2].field_def.size == InfoSize(2, 0)
|
|
585
|
+
assert decoded.field_instances[2].value == b"\xbb\xcc"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def test_decode_rejects_invalid_padding_alignment_bits():
|
|
589
|
+
"""Test that decode validates padding alignment bit size."""
|
|
590
|
+
struct_def = StructDef(fields=[])
|
|
591
|
+
|
|
592
|
+
with pytest.raises(
|
|
593
|
+
ValueError,
|
|
594
|
+
match=r"padding_alignment_bits must be a positive power of two"):
|
|
595
|
+
decode(struct_def, bytearray(), padding_alignment_bits=24)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_encode_rejects_invalid_padding_alignment_bits():
|
|
599
|
+
"""Test that encode validates padding alignment bit size."""
|
|
600
|
+
struct_instance = StructInstance(struct_def=StructDef(fields=[]))
|
|
601
|
+
|
|
602
|
+
with pytest.raises(
|
|
603
|
+
ValueError,
|
|
604
|
+
match=r"padding_alignment_bits must be a positive power of two"):
|
|
605
|
+
encode(struct_instance, bytearray(), padding_alignment_bits=0)
|
|
606
|
+
|
|
607
|
+
|
|
360
608
|
def test_encode_rejects_tuple_input():
|
|
361
609
|
"""Test that encode only accepts StructInstance input."""
|
|
362
610
|
field_def = FieldDef(name="value",
|
|
@@ -482,6 +730,33 @@ def test_field_instance_sort_uses_field_def_order():
|
|
|
482
730
|
assert sorted_instances == [earlier, later]
|
|
483
731
|
|
|
484
732
|
|
|
733
|
+
def test_field_instance_range_check_evaluates_expression_with_field_env():
|
|
734
|
+
"""Test that range_check evaluates expression with field name bound."""
|
|
735
|
+
field_def = FieldDef(name="value",
|
|
736
|
+
offset=InfoSize(0, 0),
|
|
737
|
+
size=InfoSize(1, 0),
|
|
738
|
+
type="unsigned int",
|
|
739
|
+
range_expression="0 <= value <= limit")
|
|
740
|
+
field_instance = FieldInstance(field_def=field_def, value=7)
|
|
741
|
+
|
|
742
|
+
assert field_instance.range_check({"limit": 10}) is True
|
|
743
|
+
assert field_instance.range_check({"limit": 5}) is False
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def test_field_instance_range_check_returns_none_when_expression_is_none():
|
|
747
|
+
"""Test that range_check returns None when range_expression is None."""
|
|
748
|
+
field_def = FieldDef(name="value",
|
|
749
|
+
offset=InfoSize(0, 0),
|
|
750
|
+
size=InfoSize(1, 0),
|
|
751
|
+
type="unsigned int",
|
|
752
|
+
range_expression=None)
|
|
753
|
+
field_instance = FieldInstance(field_def=field_def, value=7)
|
|
754
|
+
|
|
755
|
+
result = field_instance.range_check()
|
|
756
|
+
|
|
757
|
+
assert result is None
|
|
758
|
+
|
|
759
|
+
|
|
485
760
|
def test_struct_instance_size_property_returns_initial_size():
|
|
486
761
|
"""Test that StructInstance size is initialized from the provided value."""
|
|
487
762
|
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.3.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
|