sltcodec 0.3.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
1
  Metadata-Version: 2.5
2
2
  Name: sltcodec
3
- Version: 0.3.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.3.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)
@@ -175,16 +193,21 @@ def _split_repeated_field(field_def: FieldDef,
175
193
  size=resolved_size)
176
194
 
177
195
 
178
- def encode_field(field_def: FieldDef,
179
- value: Any,
180
- buf: bytearray,
181
- env: dict[str, Any] | None = None,
182
- struct_def_dict: dict[str, StructDef] | None = None) -> None:
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(struct_instance: StructInstance,
200
- buf: bytearray,
201
- 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:
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
 
@@ -287,25 +323,41 @@ def decode_field(
287
323
  return FieldInstance(
288
324
  field_def=field_def,
289
325
  value=decode(resolved_type, bytearray(info.to_bytes),
290
- struct_def_dict),
326
+ struct_def_dict, enum_def_dict,
327
+ padding_alignment_bits),
291
328
  )
292
329
  if resolved_type == "bool":
293
- 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)
294
333
  if resolved_type == "signed int":
295
- 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)
296
337
  if resolved_type in ["int", "unsigned int"]:
297
- 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)
298
341
  if resolved_type == "float":
299
- 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)
300
345
  if resolved_type in ["bytearray", "bytes"]:
301
- return FieldInstance(field_def=field_def, value=info.to_bytes)
302
- 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)
303
352
 
304
353
 
305
354
  def decode(
306
- struct_def: StructDef | list[FieldDef],
307
- data: bytearray | bytes,
308
- 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:
309
361
  """Decode a bytearray into field values according to a layout.
310
362
 
311
363
  Parameters
@@ -316,11 +368,16 @@ def decode(
316
368
  The data to decode.
317
369
  struct_def_dict : dict[str, StructDef] | None, optional
318
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.
319
375
  Returns
320
376
  -------
321
377
  StructInstance
322
378
  The decoded structure instance.
323
379
  """
380
+ _validate_padding_alignment_bits(padding_alignment_bits)
324
381
  env = {}
325
382
  struct_def_obj = (struct_def if isinstance(struct_def, StructDef) else
326
383
  StructDef(fields=struct_def))
@@ -337,8 +394,11 @@ def decode(
337
394
 
338
395
  chunk_start_bits = start_bits
339
396
  while chunk_start_bits < end_bits:
340
- next_boundary_bits = ((chunk_start_bits // 32) + 1) * 32
341
- 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)
342
402
  padding_field_def = FieldDef(
343
403
  name=f"padding[{padding_index}]",
344
404
  offset=_info_size_from_bits(chunk_start_bits),
@@ -359,7 +419,8 @@ def decode(
359
419
  if field_def.repeat is None or field_def.repeat <= 1:
360
420
  resolved_offset = _resolve_offset(field_def, env)
361
421
  append_padding_until(resolved_offset)
362
- 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)
363
424
  if field_instance is not None:
364
425
  env[field_instance.field_def.name] = field_instance.value
365
426
  result.append_field_instance(field_instance)
@@ -374,7 +435,8 @@ def decode(
374
435
  field_def_repeat = _split_repeated_field(field_def, i, env,
375
436
  current_offset)
376
437
  field_instance = decode_field(field_def_repeat, data, env,
377
- struct_def_dict)
438
+ struct_def_dict, enum_def_dict,
439
+ padding_alignment_bits)
378
440
  if field_instance is not None:
379
441
  env[field_instance.field_def.name] = field_instance.value
380
442
  result.append_field_instance(field_instance)
@@ -1,16 +1,73 @@
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 enum import Enum
9
7
  from typing import Any
10
8
 
9
+ from sltcalc import SltEval
11
10
  from sltcore import Info, InfoSize
12
11
 
13
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
+
14
71
  @dataclass(frozen=True)
15
72
  class FieldDef:
16
73
  """A field in a structured layout."""
@@ -30,8 +87,8 @@ class FieldDef:
30
87
  default=None, metadata={"desc": "The description of the field"})
31
88
  range_expression: str | None = field(
32
89
  default=None, metadata={"desc": "The value range expression"})
33
- enum_type: type[Enum] | None = field(default=None,
34
- metadata={"desc": "The enum type"})
90
+ enum_def: EnumDef | None = field(
91
+ default=None, metadata={"desc": "The enum definition for the field"})
35
92
 
36
93
  def split_repeat(self,
37
94
  index: int,
@@ -58,7 +115,7 @@ class FieldDef:
58
115
  repeat=None,
59
116
  description=self.description,
60
117
  range_expression=split_range_expression,
61
- enum_type=self.enum_type)
118
+ enum_def=self.enum_def)
62
119
 
63
120
  @staticmethod
64
121
  def _replace_name_in_expression(value: Any, old_name: str,
@@ -79,15 +136,33 @@ class FieldDef:
79
136
  def to_dict(self) -> dict[str, Any]:
80
137
  """Convert this field definition to a JSON-serializable dictionary."""
81
138
  return {
82
- "name": self.name,
83
- "offset": self._serialize_value(self.offset),
84
- "size": self._serialize_value(self.size),
85
- "type": self._serialize_type(self.type),
86
- "scale": self.scale,
87
- "repeat": self.repeat,
88
- "description": self.description,
89
- "range_expression": self.range_expression,
90
- "enum_type": self._serialize_enum_type(self.enum_type),
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(),
91
166
  }
92
167
 
93
168
  def to_json(self) -> str:
@@ -97,16 +172,68 @@ class FieldDef:
97
172
  @classmethod
98
173
  def from_dict(cls, data: dict[str, Any]) -> "FieldDef":
99
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
+
100
227
  return cls(
101
- name=data.get("name", ""),
102
- offset=cls._deserialize_value(data.get("offset")),
103
- size=cls._deserialize_value(data.get("size")),
104
- type=cls._deserialize_type(data.get("type")),
105
- scale=data.get("scale", 1.0),
106
- repeat=data.get("repeat"),
107
- description=data.get("description"),
108
- range_expression=data.get("range_expression"),
109
- enum_type=cls._deserialize_enum_type(data.get("enum_type")),
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,
110
237
  )
111
238
 
112
239
  @classmethod
@@ -114,83 +241,6 @@ class FieldDef:
114
241
  """Create a field definition from a JSON string."""
115
242
  return cls.from_dict(json.loads(data))
116
243
 
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
244
  def _sort_key(self) -> tuple[Any, ...]:
195
245
  """Build a stable comparison key for ordering field definitions."""
196
246
  return (
@@ -202,7 +252,7 @@ class FieldDef:
202
252
  -1 if self.repeat is None else self.repeat,
203
253
  "" if self.description is None else self.description,
204
254
  "" if self.range_expression is None else self.range_expression,
205
- self._sortable_enum_type(self.enum_type),
255
+ self._sortable_enum_type(self.enum_def),
206
256
  )
207
257
 
208
258
  @staticmethod
@@ -220,11 +270,17 @@ class FieldDef:
220
270
  return (1, value)
221
271
 
222
272
  @staticmethod
223
- def _sortable_enum_type(value: type[Enum] | None) -> str:
224
- """Build a comparable key for enum type metadata."""
273
+ def _sortable_enum_type(value: EnumDef | None) -> str:
274
+ """Build a comparable key for enum definition metadata."""
225
275
  if value is None:
226
276
  return ""
227
- return f"{value.__module__}.{value.__qualname__}"
277
+ return json.dumps(
278
+ {
279
+ "name": value.name,
280
+ "description": value.description,
281
+ "values": value.values,
282
+ },
283
+ sort_keys=True)
228
284
 
229
285
 
230
286
  @dataclass(frozen=True)
@@ -232,6 +288,10 @@ class FieldInstance:
232
288
  """A decoded/encodable field value with its field definition."""
233
289
  field_def: FieldDef = field(metadata={"desc": "The field definition"})
234
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
+ )
235
295
  is_padding: bool = field(
236
296
  default=False,
237
297
  metadata={"desc": "Whether this field instance represents padding"},
@@ -243,6 +303,57 @@ class FieldInstance:
243
303
  return NotImplemented
244
304
  return self.field_def < other.field_def
245
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
+
246
357
 
247
358
  @dataclass(frozen=True)
248
359
  class StructDef:
@@ -279,12 +390,11 @@ class StructDef:
279
390
  if isinstance(data, list):
280
391
  # Backward-compatibility for legacy list-only StructDef payloads.
281
392
  return cls(fields=[FieldDef.from_dict(item) for item in data])
282
- return cls(name=data.get("name", ""),
283
- description=data.get("description", ""),
284
- fields=[
285
- FieldDef.from_dict(item)
286
- for item in data.get("fields", [])
287
- ])
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)
288
398
 
289
399
  @classmethod
290
400
  def from_json(cls, data: str) -> "StructDef":
@@ -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, StructInstance,
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 FieldDef enum_type metadata tests."""
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
- enum_type=ValueKind)
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.enum_type is ValueKind
178
- assert decoded.field_instances[1].field_def.enum_type is ValueKind
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
- enum_type=ValueKind)
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.enum_type is ValueKind
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():
@@ -410,6 +527,84 @@ def test_decode_inserts_padding_fields_for_gaps_and_trailing_space():
410
527
  assert decoded.field_instances[2].value == b"\xdd\xee"
411
528
 
412
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
+
413
608
  def test_encode_rejects_tuple_input():
414
609
  """Test that encode only accepts StructInstance input."""
415
610
  field_def = FieldDef(name="value",
@@ -535,6 +730,33 @@ def test_field_instance_sort_uses_field_def_order():
535
730
  assert sorted_instances == [earlier, later]
536
731
 
537
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
+
538
760
  def test_struct_instance_size_property_returns_initial_size():
539
761
  """Test that StructInstance size is initialized from the provided value."""
540
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