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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sltcodec
3
- Version: 0.2.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.3.0
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.2.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.3.0",
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, bytearray(), struct_def_dict)
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 FieldDef(name=f"{field_def.name}[{index}]",
174
- offset=resolved_offset,
175
- size=resolved_size,
176
- type=field_def.type,
177
- scale=field_def.scale,
178
- repeat=None,
179
- description=field_def.description)
180
-
181
-
182
- def encode_field(field_def: FieldDef,
183
- value: Any,
184
- buf: bytearray,
185
- env: dict[str, Any] | None = None,
186
- struct_def_dict: dict[str, StructDef] | None = None) -> None:
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(struct_instance: StructInstance,
204
- buf: bytearray,
205
- struct_def_dict: dict[str, StructDef] | None = None) -> bytearray:
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=field_def, value=info.to_bool)
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=field_def, value=info.to_signed_int)
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=field_def, value=info.to_unsigned_int)
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=field_def, value=info.to_float)
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=field_def, value=info.to_bytes)
306
- return FieldInstance(field_def=field_def, value=info.raw_value)
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
- struct_def: StructDef | list[FieldDef],
311
- data: bytearray | bytes,
312
- struct_def_dict: dict[str, StructDef] | None = None) -> StructInstance:
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
- next_boundary_bits = ((chunk_start_bits // 32) + 1) * 32
345
- chunk_end_bits = min(end_bits, next_boundary_bits)
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) -> "FieldDef":
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
- return FieldDef(name=f"{self.name}[{index}]",
34
- offset=self.offset if offset is None else offset,
35
- size=self.size,
36
- type=self.type,
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": self.name,
51
- "offset": self._serialize_value(self.offset),
52
- "size": self._serialize_value(self.size),
53
- "type": self._serialize_type(self.type),
54
- "scale": self.scale,
55
- "repeat": self.repeat,
56
- "description": self.description,
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=data.get("name", ""),
68
- offset=cls._deserialize_value(data.get("offset")),
69
- size=cls._deserialize_value(data.get("size")),
70
- type=cls._deserialize_type(data.get("type")),
71
- scale=data.get("scale", 1.0),
72
- repeat=data.get("repeat"),
73
- description=data.get("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
- return cls(name=data.get("name", ""),
207
- description=data.get("description", ""),
208
- fields=[
209
- FieldDef.from_dict(item)
210
- for item in data.get("fields", [])
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, StructInstance,
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.2.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.3.0" },
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.3.0"
100
+ version = "1.5.0"
101
101
  source = { registry = "https://pypi.org/simple" }
102
- sdist = { url = "https://files.pythonhosted.org/packages/9a/fa/856dea8a444493506f1c73aa434d30c895fb793ef73f47ff777e938fc639/sltcore-1.3.0.tar.gz", hash = "sha256:01b77f2932a028a1a1d7239ac15aa8c7fa88973f4db71a851a4bcc399649fd15", size = 16916, upload-time = "2026-08-08T13:10:11.545Z" }
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/7f/5f/15c2d6d78df481a62c60762c3824a9574f9bf610bc9d0463d3c8e5197e04/sltcore-1.3.0-py3-none-any.whl", hash = "sha256:85368bd7014879f4e67f98cdd6d489c29b3dc12f04e8964fcb701d75efa0c953", size = 9729, upload-time = "2026-08-08T13:10:10.5Z" },
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