sltcodec 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sltcodec/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .codec import (decode, encode, encode_field, load_struct_def_dict,
2
+ save_struct_def_dict)
3
+ from .types import FieldDef, FieldInstance, StructDef, StructInstance
4
+
5
+ __all__ = [
6
+ "FieldDef",
7
+ "FieldInstance",
8
+ "StructDef",
9
+ "StructInstance",
10
+ "decode",
11
+ "encode",
12
+ "encode_field",
13
+ "load_struct_def_dict",
14
+ "save_struct_def_dict",
15
+ ]
sltcodec/codec.py ADDED
@@ -0,0 +1,428 @@
1
+ """Encoding and decoding of structured layouts into bytearrays."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from sltcalc import SltEval
9
+ from sltcore import Info, InfoSize, bits_get, bits_set
10
+
11
+ from .types import FieldDef, FieldInstance, StructDef, StructInstance
12
+
13
+ _PRIMITIVE_TYPES = {
14
+ "bool",
15
+ "signed int",
16
+ "int",
17
+ "unsigned int",
18
+ "float",
19
+ "bytearray",
20
+ "bytes",
21
+ }
22
+
23
+
24
+ def _as_field_defs(struct_def: StructDef | list[FieldDef]) -> list[FieldDef]:
25
+ """Normalize StructDef/list inputs to a list of field definitions."""
26
+ if isinstance(struct_def, StructDef):
27
+ return struct_def.fields
28
+ return struct_def
29
+
30
+
31
+ def _validate_struct_instance(struct_instance: StructInstance) -> None:
32
+ """Validate that encode input is a well-typed StructInstance."""
33
+ if not isinstance(struct_instance, StructInstance):
34
+ raise TypeError("encode() expects StructInstance for struct_instance")
35
+ for index, field_instance in enumerate(struct_instance.field_instances):
36
+ if not isinstance(field_instance, FieldInstance):
37
+ raise TypeError(
38
+ "encode() expects StructInstance.field_instances to be "
39
+ "list[FieldInstance]. "
40
+ f"Invalid item at index {index}: "
41
+ f"{type(field_instance).__name__}")
42
+
43
+
44
+ def save_struct_def_dict(path: str | Path,
45
+ struct_def_dict: dict[str, StructDef]) -> None:
46
+ """Save a structure definition dictionary to a JSON file."""
47
+ payload = {
48
+ name: struct_def.to_dict()
49
+ for name, struct_def in struct_def_dict.items()
50
+ }
51
+ Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2),
52
+ encoding="utf-8")
53
+
54
+
55
+ def load_struct_def_dict(path: str | Path) -> dict[str, StructDef]:
56
+ """Load a structure definition dictionary from a JSON file."""
57
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
58
+ return {
59
+ name: StructDef.from_dict(field_def_data)
60
+ for name, field_def_data in data.items()
61
+ }
62
+
63
+
64
+ def _resolve_info_size(value: InfoSize | str, env: dict[str, Any]) -> InfoSize:
65
+ """Resolve an InfoSize value that can be static or expression-based."""
66
+ if isinstance(value, str):
67
+ stleval = SltEval(env)
68
+ resolved_byte = stleval.eval(value)
69
+ return InfoSize(resolved_byte, 0)
70
+ return value
71
+
72
+
73
+ def _resolve_offset(field_def: FieldDef, env: dict[str, Any]) -> InfoSize:
74
+ """Resolve a field offset that can be static or expression-based."""
75
+ return _resolve_info_size(field_def.offset, env)
76
+
77
+
78
+ def _resolve_size(field_def: FieldDef, env: dict[str, Any]) -> InfoSize:
79
+ """Resolve a field size that can be static or expression-based."""
80
+ return _resolve_info_size(field_def.size, env)
81
+
82
+
83
+ def _is_padding_field_def(field_def: FieldDef) -> bool:
84
+ """Check whether a field definition represents padding."""
85
+ return (field_def.name.startswith("padding[")
86
+ and field_def.type in ["bytes", "bytearray"])
87
+
88
+
89
+ def _info_size_to_bits(info_size: InfoSize) -> int:
90
+ """Convert InfoSize to an absolute bit count."""
91
+ return info_size.byte * 8 + info_size.bit
92
+
93
+
94
+ def _info_size_from_bits(total_bits: int) -> InfoSize:
95
+ """Create InfoSize from an absolute bit count."""
96
+ return InfoSize(total_bits >> 3, total_bits & 0x7)
97
+
98
+
99
+ def _resolve_field_type(field_type: str | StructDef,
100
+ struct_def_dict: dict[str, StructDef] | None = None,
101
+ env: dict[str, Any] | None = None) -> str | StructDef:
102
+ """Resolve a field type that can be primitive, named, or nested."""
103
+ if isinstance(field_type, StructDef):
104
+ return field_type
105
+ if field_type in _PRIMITIVE_TYPES:
106
+ return field_type
107
+
108
+ resolved_struct_def = _get_struct_def(field_type, struct_def_dict, env)
109
+ if isinstance(resolved_struct_def, StructDef):
110
+ return resolved_struct_def
111
+ if (isinstance(resolved_struct_def, str)
112
+ and resolved_struct_def in _PRIMITIVE_TYPES):
113
+ return resolved_struct_def
114
+ return field_type
115
+
116
+
117
+ def _encode_primitive(field_type: str, value: Any, size: InfoSize,
118
+ scale: float) -> Info:
119
+ """Convert a primitive typed value into an Info object for bits_set."""
120
+ if field_type == "bool":
121
+ return Info.from_bool(bool(value), size, scale=scale)
122
+ if field_type == "signed int":
123
+ return Info.from_signed_int(int(value), size, scale=scale)
124
+ if field_type in ["int", "unsigned int"]:
125
+ return Info.from_unsigned_int(int(value), size, scale=scale)
126
+ if field_type == "float":
127
+ return Info.from_float(float(value), size, scale=scale)
128
+ if field_type in ["bytearray", "bytes"]:
129
+ return Info.from_bytes(bytes(value), size, scale=scale)
130
+ if isinstance(value, (bytes, bytearray)):
131
+ return Info.from_bytes(bytes(value), size, scale=scale)
132
+ if isinstance(value, bool):
133
+ return Info.from_bool(value, size, scale=scale)
134
+ if isinstance(value, int):
135
+ return Info.from_unsigned_int(value, size, scale=scale)
136
+
137
+ return Info(raw_value=value, info_size=size, scale=scale)
138
+
139
+
140
+ def _prepare_field_info(
141
+ field_def: FieldDef,
142
+ value: Any,
143
+ env: dict[str, Any],
144
+ struct_def_dict: dict[str, StructDef] | None = None
145
+ ) -> tuple[InfoSize, InfoSize, Info] | None:
146
+ """Resolve a field definition into an offset, size, and info payload."""
147
+ offset = _resolve_offset(field_def, env)
148
+ size = _resolve_size(field_def, env)
149
+ if size.byte == 0 and size.bit == 0:
150
+ return None
151
+
152
+ resolved_type = _resolve_field_type(field_def.type, struct_def_dict, env)
153
+ if isinstance(resolved_type, StructDef):
154
+ if not isinstance(value, StructInstance):
155
+ raise TypeError(
156
+ "Nested StructDef fields must be encoded with StructInstance "
157
+ "values")
158
+ nested_bytes = encode(value, bytearray(), struct_def_dict)
159
+ info = Info.from_bytes(bytes(nested_bytes), size, scale=field_def.scale)
160
+ else:
161
+ info = _encode_primitive(resolved_type, value, size, field_def.scale)
162
+ return offset, size, info
163
+
164
+
165
+ def _split_repeated_field(field_def: FieldDef,
166
+ index: int,
167
+ env: dict[str, Any],
168
+ offset: InfoSize | str | None = None) -> FieldDef:
169
+ """Create a repeated-field definition with resolved offset and size."""
170
+ resolved_offset = (_resolve_offset(field_def, env)
171
+ if offset is None else offset)
172
+ 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:
187
+ """Encode a single field into a bytearray."""
188
+ if env is None:
189
+ env = {}
190
+
191
+ prepared = _prepare_field_info(field_def, value, env, struct_def_dict)
192
+ if prepared is None:
193
+ return
194
+
195
+ offset, size, info = prepared
196
+ required_bytes = (offset + size).bytes
197
+ if len(buf) < required_bytes:
198
+ buf.extend(b"\x00" * (required_bytes - len(buf)))
199
+
200
+ bits_set(buf, offset, info)
201
+
202
+
203
+ def encode(struct_instance: StructInstance,
204
+ buf: bytearray,
205
+ struct_def_dict: dict[str, StructDef] | None = None) -> bytearray:
206
+ """Encode decode() result into a bytearray.
207
+
208
+ Parameters
209
+ ----------
210
+ struct_instance : StructInstance
211
+ The structure instance to encode.
212
+ buf : bytearray
213
+ The base bytearray instance to write into.
214
+ struct_def_dict : dict[str, StructDef] | None, optional
215
+ A dictionary of structure definitions, by default None.
216
+
217
+ Returns
218
+ -------
219
+ bytearray
220
+ The encoded bytearray.
221
+ """
222
+ _validate_struct_instance(struct_instance)
223
+ env: dict[str, Any] = {}
224
+ has_padding = False
225
+
226
+ for field_value in struct_instance.field_instances:
227
+ field_def = field_value.field_def
228
+ if _is_padding_field_def(field_def):
229
+ has_padding = True
230
+ value = field_value.value
231
+ if _is_padding_field_def(field_def):
232
+ padding_size = _resolve_size(field_def, env)
233
+ value = bytearray(padding_size.bytes)
234
+ if field_def.repeat is not None and field_def.repeat > 1:
235
+ current_offset = _resolve_offset(field_def, env)
236
+ resolved_size = _resolve_size(field_def, env)
237
+ values = list(value)
238
+
239
+ for i in range(field_def.repeat):
240
+ field_def_repeat = _split_repeated_field(
241
+ field_def, i, env, current_offset)
242
+ encode_field(field_def_repeat, values[i], buf, env,
243
+ struct_def_dict)
244
+ env[field_def_repeat.name] = values[i]
245
+ if isinstance(current_offset, InfoSize) and isinstance(
246
+ resolved_size, InfoSize):
247
+ current_offset += resolved_size
248
+ continue
249
+
250
+ encode_field(field_def, value, buf, env, struct_def_dict)
251
+ env[field_def.name] = value
252
+
253
+ if has_padding and struct_instance.size.bytes > len(buf):
254
+ buf.extend(b"\x00" * (struct_instance.size.bytes - len(buf)))
255
+
256
+ return buf
257
+
258
+
259
+ def decode_field(
260
+ field_def: FieldDef,
261
+ data: bytearray | bytes,
262
+ env: dict[str, Any] | None = None,
263
+ struct_def_dict: dict[str, StructDef] | None = None
264
+ ) -> FieldInstance | None:
265
+ """Decode a single field from a bytearray according to a field definition.
266
+
267
+ Parameters
268
+ ----------
269
+ field_def : FieldDef
270
+ The definition of the field to decode.
271
+ data : bytearray | bytes
272
+ The data to decode.
273
+ env : dict[str, Any] | None, optional
274
+ The environment for evaluating expressions, by default None.
275
+ struct_def_dict : dict[str, StructDef] | None, optional
276
+ A dictionary of structure definitions, by default None.
277
+ Returns
278
+ -------
279
+ FieldInstance | None
280
+ The decoded field instance, or None when size is zero.
281
+ """
282
+ if env is None:
283
+ env = {}
284
+ offset = _resolve_offset(field_def, env)
285
+ size = _resolve_size(field_def, env)
286
+ if size.byte == 0 and size.bit == 0:
287
+ return None
288
+ info = bits_get(data, offset, size, scale=field_def.scale)
289
+ resolved_type = _resolve_field_type(field_def.type, struct_def_dict, env)
290
+ if isinstance(resolved_type, StructDef):
291
+ return FieldInstance(
292
+ field_def=field_def,
293
+ value=decode(resolved_type, bytearray(info.to_bytes),
294
+ struct_def_dict),
295
+ )
296
+ if resolved_type == "bool":
297
+ return FieldInstance(field_def=field_def, value=info.to_bool)
298
+ if resolved_type == "signed int":
299
+ return FieldInstance(field_def=field_def, value=info.to_signed_int)
300
+ if resolved_type in ["int", "unsigned int"]:
301
+ return FieldInstance(field_def=field_def, value=info.to_unsigned_int)
302
+ if resolved_type == "float":
303
+ return FieldInstance(field_def=field_def, value=info.to_float)
304
+ 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)
307
+
308
+
309
+ def decode(
310
+ struct_def: StructDef | list[FieldDef],
311
+ data: bytearray | bytes,
312
+ struct_def_dict: dict[str, StructDef] | None = None) -> StructInstance:
313
+ """Decode a bytearray into field values according to a layout.
314
+
315
+ Parameters
316
+ ----------
317
+ struct_def : StructDef | list[FieldDef]
318
+ The definitions of the fields to decode.
319
+ data : bytearray | bytes
320
+ The data to decode.
321
+ struct_def_dict : dict[str, StructDef] | None, optional
322
+ A dictionary of structure definitions, by default None.
323
+ Returns
324
+ -------
325
+ StructInstance
326
+ The decoded structure instance.
327
+ """
328
+ env = {}
329
+ struct_def_obj = (struct_def if isinstance(struct_def, StructDef) else
330
+ StructDef(fields=struct_def))
331
+ result = StructInstance(struct_def=struct_def_obj)
332
+ current_position = InfoSize(0, 0)
333
+ padding_index = 0
334
+
335
+ def append_padding_until(target_offset: InfoSize) -> None:
336
+ nonlocal current_position, padding_index
337
+ start_bits = _info_size_to_bits(current_position)
338
+ end_bits = _info_size_to_bits(target_offset)
339
+ if end_bits <= start_bits:
340
+ return
341
+
342
+ chunk_start_bits = start_bits
343
+ 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)
346
+ padding_field_def = FieldDef(
347
+ name=f"padding[{padding_index}]",
348
+ offset=_info_size_from_bits(chunk_start_bits),
349
+ size=_info_size_from_bits(chunk_end_bits - chunk_start_bits),
350
+ type="bytes",
351
+ description="Auto-generated padding",
352
+ )
353
+ padding_field_instance = decode_field(padding_field_def, data)
354
+ if padding_field_instance is not None:
355
+ result.append_field_instance(padding_field_instance)
356
+ padding_index += 1
357
+ chunk_start_bits = chunk_end_bits
358
+
359
+ current_position = target_offset
360
+
361
+ for field_def in _as_field_defs(struct_def_obj):
362
+ # Handle non-repeated fields
363
+ if field_def.repeat is None or field_def.repeat <= 1:
364
+ resolved_offset = _resolve_offset(field_def, env)
365
+ append_padding_until(resolved_offset)
366
+ field_instance = decode_field(field_def, data, env, struct_def_dict)
367
+ if field_instance is not None:
368
+ env[field_instance.field_def.name] = field_instance.value
369
+ result.append_field_instance(field_instance)
370
+ current_position = (resolved_offset +
371
+ _resolve_size(field_def, env))
372
+ continue
373
+ # Handle repeated fields
374
+ current_offset = _resolve_offset(field_def, env)
375
+ resolved_size = _resolve_size(field_def, env)
376
+ append_padding_until(current_offset)
377
+ for i in range(field_def.repeat):
378
+ field_def_repeat = _split_repeated_field(field_def, i, env,
379
+ current_offset)
380
+ field_instance = decode_field(field_def_repeat, data, env,
381
+ struct_def_dict)
382
+ if field_instance is not None:
383
+ env[field_instance.field_def.name] = field_instance.value
384
+ result.append_field_instance(field_instance)
385
+ current_position = current_offset + resolved_size
386
+ current_offset += resolved_size
387
+
388
+ append_padding_until(result.size)
389
+
390
+ return result
391
+
392
+
393
+ def _get_struct_def(
394
+ struct_def_name: str,
395
+ struct_def_dict: dict[str, StructDef] | None = None,
396
+ env: dict[str, Any] | None = None) -> StructDef | str | None:
397
+ """Get a structure definition from a dictionary or evaluate it.
398
+
399
+ Parameters
400
+ ----------
401
+ struct_def_name : str
402
+ The name of the structure definition to get.
403
+ struct_def_dict : dict[str, StructDef] | None, optional
404
+ A dictionary of structure definitions, by default None.
405
+ env : dict[str, Any] | None, optional
406
+ The environment for evaluating the structure definition,
407
+ by default None.
408
+
409
+ Returns
410
+ -------
411
+ StructDef | str | None
412
+ The structure definition, primitive type, or None if not found.
413
+ """
414
+ if struct_def_dict and struct_def_name in struct_def_dict:
415
+ return struct_def_dict[struct_def_name]
416
+
417
+ stleval = SltEval(env)
418
+ try:
419
+ eval_result = stleval.eval(struct_def_name)
420
+ except (SyntaxError, NameError, TypeError, ValueError):
421
+ return None
422
+
423
+ if isinstance(eval_result, str) and eval_result in _PRIMITIVE_TYPES:
424
+ return eval_result
425
+ if (isinstance(eval_result, str) and struct_def_dict
426
+ and eval_result in struct_def_dict):
427
+ return struct_def_dict[eval_result]
428
+ return None
sltcodec/types.py ADDED
@@ -0,0 +1,359 @@
1
+ """Type definitions for structured layout metadata."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from sltcore import Info, InfoSize
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class FieldDef:
13
+ """A field in a structured layout."""
14
+ name: str = field(default_factory=str,
15
+ metadata={"desc": "The name of the field"})
16
+ offset: InfoSize | str = field(default_factory=InfoSize,
17
+ metadata={"desc": "The offset of the field"})
18
+ size: InfoSize | str = field(default_factory=InfoSize,
19
+ metadata={"desc": "The size of the field"})
20
+ type: str | "StructDef" = field(default_factory=str,
21
+ metadata={"desc": "The type of the field"})
22
+ scale: float = field(default=1.0,
23
+ metadata={"desc": "The scale of the field"})
24
+ repeat: int | None = field(
25
+ default=None, metadata={"desc": "The repeat count of the field"})
26
+ description: str | None = field(
27
+ default=None, metadata={"desc": "The description of the field"})
28
+
29
+ def split_repeat(self,
30
+ index: int,
31
+ offset: InfoSize | str | None = None) -> "FieldDef":
32
+ """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,
37
+ scale=self.scale,
38
+ repeat=None,
39
+ description=self.description)
40
+
41
+ def __lt__(self, other: object) -> bool:
42
+ """Compare field definitions using a stable serialized sort key."""
43
+ if not isinstance(other, FieldDef):
44
+ return NotImplemented
45
+ return self._sort_key() < other._sort_key()
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ """Convert this field definition to a JSON-serializable dictionary."""
49
+ 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,
57
+ }
58
+
59
+ def to_json(self) -> str:
60
+ """Convert this field definition to a JSON string."""
61
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict[str, Any]) -> "FieldDef":
65
+ """Create a field definition from a JSON-serializable dictionary."""
66
+ 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"),
74
+ )
75
+
76
+ @classmethod
77
+ def from_json(cls, data: str) -> "FieldDef":
78
+ """Create a field definition from a JSON string."""
79
+ return cls.from_dict(json.loads(data))
80
+
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
+ def _sort_key(self) -> tuple[Any, ...]:
128
+ """Build a stable comparison key for ordering field definitions."""
129
+ return (
130
+ self._sortable_info_size_or_expr(self.offset),
131
+ self._sortable_info_size_or_expr(self.size),
132
+ self.name,
133
+ self._sortable_type(self.type),
134
+ self.scale,
135
+ -1 if self.repeat is None else self.repeat,
136
+ "" if self.description is None else self.description,
137
+ )
138
+
139
+ @staticmethod
140
+ def _sortable_info_size_or_expr(value: InfoSize | str) -> tuple[Any, ...]:
141
+ """Build a comparable key for InfoSize-or-expression values."""
142
+ if isinstance(value, InfoSize):
143
+ return (0, value.byte, value.bit)
144
+ return (1, value)
145
+
146
+ @staticmethod
147
+ def _sortable_type(value: str | "StructDef") -> tuple[Any, ...]:
148
+ """Build a comparable key for primitive or nested field types."""
149
+ if isinstance(value, StructDef):
150
+ return (0, json.dumps(value.to_dict(), sort_keys=True))
151
+ return (1, value)
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class FieldInstance:
156
+ """A decoded/encodable field value with its field definition."""
157
+ field_def: FieldDef = field(metadata={"desc": "The field definition"})
158
+ value: Any = field(metadata={"desc": "The decoded/encodable value"})
159
+ is_padding: bool = field(
160
+ default=False,
161
+ metadata={"desc": "Whether this field instance represents padding"},
162
+ )
163
+
164
+ def __lt__(self, other: object) -> bool:
165
+ """Compare field instances using their field definition order."""
166
+ if not isinstance(other, FieldInstance):
167
+ return NotImplemented
168
+ return self.field_def < other.field_def
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class StructDef:
173
+ """A structured layout definition that groups multiple fields."""
174
+ name: str = field(default_factory=str,
175
+ metadata={"desc": "The name of the structure"})
176
+ description: str = field(
177
+ default_factory=str,
178
+ metadata={"desc": "The description of the structure"},
179
+ )
180
+ fields: list[FieldDef] = field(
181
+ default_factory=list,
182
+ metadata={"desc": "The fields of the structure"},
183
+ )
184
+
185
+ def to_dict(self) -> dict[str, Any]:
186
+ """Convert this structure definition to JSON-serializable data."""
187
+ return {
188
+ "name": self.name,
189
+ "description": self.description,
190
+ "fields": [field_def.to_dict() for field_def in self.fields],
191
+ }
192
+
193
+ def to_json(self) -> str:
194
+ """Convert this structure definition to a JSON string."""
195
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
196
+
197
+ @classmethod
198
+ def from_dict(
199
+ cls,
200
+ data: dict[str, Any] | list[dict[str, Any]],
201
+ ) -> "StructDef":
202
+ """Create a structure definition from JSON-serializable data."""
203
+ if isinstance(data, list):
204
+ # Backward-compatibility for legacy list-only StructDef payloads.
205
+ 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
+ ])
212
+
213
+ @classmethod
214
+ def from_json(cls, data: str) -> "StructDef":
215
+ """Create a structure definition from a JSON string."""
216
+ return cls.from_dict(json.loads(data))
217
+
218
+
219
+ @dataclass
220
+ class StructInstance:
221
+ """A decoded/encodable structure instance."""
222
+ struct_def: StructDef = field(
223
+ default_factory=StructDef,
224
+ metadata={"desc": "The structure definition"},
225
+ )
226
+ field_instances: list[FieldInstance] = field(
227
+ default_factory=list,
228
+ metadata={"desc": "The decoded/encodable field instances"},
229
+ )
230
+ size: InfoSize = field(
231
+ default_factory=InfoSize,
232
+ metadata={"desc": "The total size of the structure instance"},
233
+ )
234
+
235
+ def __post_init__(self) -> None:
236
+ """Normalize stored field instances to sorted order."""
237
+ self._sort_field_instances()
238
+ self._update_size()
239
+ self._rebuild_padding_field_instances()
240
+
241
+ def append_field_instance(self, field_instance: FieldInstance) -> None:
242
+ """Append one field instance to this structure instance."""
243
+ if not isinstance(field_instance, FieldInstance):
244
+ raise TypeError("field_instance must be FieldInstance")
245
+ self.field_instances.append(field_instance)
246
+ self._update_size()
247
+ self._rebuild_padding_field_instances()
248
+
249
+ def extend_field_instances(self,
250
+ field_instances: list[FieldInstance]) -> None:
251
+ """Append multiple field instances to this structure instance."""
252
+ for field_instance in field_instances:
253
+ if not isinstance(field_instance, FieldInstance):
254
+ raise TypeError("field_instance must be FieldInstance")
255
+ self.field_instances.extend(field_instances)
256
+ self._update_size()
257
+ self._rebuild_padding_field_instances()
258
+
259
+ def __iter__(self):
260
+ """Iterate over stored field instances."""
261
+ return iter(self.field_instances)
262
+
263
+ def __len__(self) -> int:
264
+ """Return the number of stored field instances."""
265
+ return len(self.field_instances)
266
+
267
+ def __getitem__(self, index: int) -> FieldInstance:
268
+ """Return one field instance by index."""
269
+ return self.field_instances[index]
270
+
271
+ def _sort_field_instances(self) -> None:
272
+ """Keep field instances sorted by FieldDef order."""
273
+ self.field_instances.sort()
274
+
275
+ def _rebuild_padding_field_instances(self) -> None:
276
+ """Rebuild padding field instances for gaps between stored values."""
277
+ non_padding_instances = [
278
+ field_instance for field_instance in self.field_instances
279
+ if not field_instance.is_padding
280
+ ]
281
+ non_padding_instances.sort()
282
+
283
+ rebuilt_instances: list[FieldInstance] = []
284
+ current_offset = InfoSize(0, 0)
285
+ padding_index = 0
286
+
287
+ for field_instance in non_padding_instances:
288
+ field_def = field_instance.field_def
289
+ field_offset = self._resolve_field_offset(field_def)
290
+ field_size = self._resolve_field_size(field_def)
291
+ if field_offset > current_offset:
292
+ gap_size = field_offset - current_offset
293
+ if gap_size.byte > 0 or gap_size.bit > 0:
294
+ rebuilt_instances.append(
295
+ FieldInstance(
296
+ field_def=FieldDef(
297
+ name=f"padding[{padding_index}]",
298
+ offset=current_offset,
299
+ size=gap_size,
300
+ type="bytes",
301
+ description="Auto-generated padding",
302
+ ),
303
+ value=bytearray(gap_size.byte),
304
+ is_padding=True,
305
+ ))
306
+ padding_index += 1
307
+ rebuilt_instances.append(field_instance)
308
+ current_offset = field_offset + field_size
309
+
310
+ if self.size > current_offset:
311
+ gap_size = self.size - current_offset
312
+ if gap_size.byte > 0 or gap_size.bit > 0:
313
+ rebuilt_instances.append(
314
+ FieldInstance(
315
+ field_def=FieldDef(
316
+ name=f"padding[{padding_index}]",
317
+ offset=current_offset,
318
+ size=gap_size,
319
+ type="bytes",
320
+ description="Auto-generated padding",
321
+ ),
322
+ value=bytearray(gap_size.byte),
323
+ is_padding=True,
324
+ ))
325
+
326
+ self.field_instances = rebuilt_instances
327
+ self._sort_field_instances()
328
+ self._update_size()
329
+
330
+ def _update_size(self) -> None:
331
+ """Update the instance size from the current field layout."""
332
+ if not self.field_instances:
333
+ return
334
+
335
+ max_end_offset = InfoSize(0, 0)
336
+ for field_instance in self.field_instances:
337
+ field_def = field_instance.field_def
338
+ field_offset = self._resolve_field_offset(field_def)
339
+ field_size = self._resolve_field_size(field_def)
340
+ field_end = field_offset + field_size
341
+ if field_end > max_end_offset:
342
+ max_end_offset = field_end
343
+
344
+ if max_end_offset > self.size:
345
+ self.size = max_end_offset
346
+
347
+ @staticmethod
348
+ def _resolve_field_offset(field_def: FieldDef) -> InfoSize:
349
+ """Resolve field offsets to InfoSize values when possible."""
350
+ if isinstance(field_def.offset, InfoSize):
351
+ return field_def.offset
352
+ return InfoSize(0, 0)
353
+
354
+ @staticmethod
355
+ def _resolve_field_size(field_def: FieldDef) -> InfoSize:
356
+ """Resolve field sizes to InfoSize values when possible."""
357
+ if isinstance(field_def.size, InfoSize):
358
+ return field_def.size
359
+ return InfoSize(0, 0)
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: sltcodec
3
+ Version: 0.2.0
4
+ Summary: Decode and encode bytearrays according to struct layout definitions using sltcore.
5
+ Project-URL: Homepage, https://github.com/fangface-hub/StructLayoutToolkitCodec
6
+ Project-URL: Documentation, https://readthedocs.org
7
+ Project-URL: Repository, https://github.com/fangface-hub/StructLayoutToolkitCodec
8
+ Project-URL: Issues, https://github.com/fangface-hub/StructLayoutToolkitCodec/issues
9
+ Author: fangface
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: byte,deserialization,layout,serialization,struct
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: sltcalc>=0.1.0
15
+ Requires-Dist: sltcore>=1.3.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # StructLayoutToolkitCodec
19
+
20
+ sltcodec is a small package for decoding and encoding bytearrays according to structured layout definitions. It uses sltcore for bit-level access and provides a simple API for turning a layout definition into binary data and back again.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install sltcodec
26
+ ```
27
+
28
+ ## Quick Example
29
+
30
+ `FieldDef.description` is an optional human-readable note that can be attached to each field definition.
31
+
32
+ `decode` returns a `StructInstance`.
33
+ `encode` accepts a `StructInstance` and a destination `bytearray`.
34
+
35
+ ```python
36
+ from sltcore import InfoSize
37
+ from sltcodec import (FieldDef, FieldInstance, StructDef, StructInstance,
38
+ decode, encode)
39
+
40
+ struct_def = [
41
+ FieldDef(name="flag",
42
+ offset=InfoSize(0, 0),
43
+ size=InfoSize(0, 1),
44
+ type="bool",
45
+ scale=1.0,
46
+ description="Whether the feature is enabled"),
47
+ FieldDef(name="value",
48
+ offset=InfoSize(0, 1),
49
+ size=InfoSize(1, 0),
50
+ type="unsigned int",
51
+ scale=1.0,
52
+ description="The encoded numeric payload"),
53
+ ]
54
+
55
+ encoded = encode(
56
+ StructInstance(
57
+ struct_def=StructDef(fields=struct_def),
58
+ field_instances=[
59
+ FieldInstance(struct_def[0], True),
60
+ FieldInstance(struct_def[1], 0xA5),
61
+ ],
62
+ ),
63
+ bytearray(),
64
+ )
65
+ decoded = decode(struct_def, encoded)
66
+
67
+ print(encoded)
68
+ print(decoded)
69
+ print(decoded.field_instances)
70
+ ```
71
+
72
+ Output:
73
+
74
+ ```python
75
+ bytearray(b"\xd2\x80")
76
+ StructInstance(struct_def=StructDef(...), field_instances=[...])
77
+ [FieldInstance(field_def=FieldDef(name='flag', offset=InfoSize(byte=0, bit=0), size=InfoSize(byte=0, bit=1), type='bool', scale=1.0, repeat=None), value=True),
78
+ FieldInstance(field_def=FieldDef(name='value', offset=InfoSize(byte=0, bit=1), size=InfoSize(byte=1, bit=0), type='unsigned int', scale=1.0, repeat=None), value=165)]
79
+
80
+ ```
81
+
82
+ ## Nested Field Types
83
+
84
+ `FieldDef.type` can be a `StructDef`, not only a primitive type name.
85
+ This allows you to define an inline nested structure and keep `encode_field` / `decode_field` behavior symmetrical.
86
+
87
+ ```python
88
+ from sltcore import InfoSize
89
+ from sltcodec import FieldDef, FieldInstance, StructDef, StructInstance, decode, encode
90
+
91
+ child_struct_def = [
92
+ FieldDef(name="left", offset=InfoSize(0, 0), size=InfoSize(1, 0), type="unsigned int"),
93
+ FieldDef(name="right", offset=InfoSize(1, 0), size=InfoSize(1, 0), type="unsigned int"),
94
+ ]
95
+
96
+ parent_field_def = FieldDef(
97
+ name="pair",
98
+ offset=InfoSize(0, 0),
99
+ size=InfoSize(2, 0),
100
+ type=StructDef(
101
+ name="Pair",
102
+ description="Two adjacent unsigned ints",
103
+ fields=child_struct_def,
104
+ ),
105
+ )
106
+
107
+ encoded = encode(
108
+ StructInstance(
109
+ struct_def=StructDef(fields=[parent_field_def]),
110
+ field_instances=[
111
+ FieldInstance(
112
+ parent_field_def,
113
+ StructInstance(
114
+ struct_def=StructDef(fields=child_struct_def),
115
+ field_instances=[
116
+ FieldInstance(child_struct_def[0], 3),
117
+ FieldInstance(child_struct_def[1], 4),
118
+ ],
119
+ ),
120
+ )
121
+ ],
122
+ ),
123
+ bytearray(),
124
+ )
125
+
126
+ decoded = decode([parent_field_def], encoded)
127
+ print(encoded)
128
+ print(decoded)
129
+ print(decoded.field_instances)
130
+ ```
131
+
132
+ Output:
133
+
134
+ ```python
135
+ bytearray(b"\x03\x04")
136
+ StructInstance(struct_def=StructDef(...), field_instances=[...])
137
+ [FieldInstance(field_def=FieldDef(name='pair', offset=InfoSize(byte=0, bit=0), size=InfoSize(byte=2, bit=0), type=[...], scale=1.0, repeat=None),
138
+ value=StructInstance(struct_def=StructDef(...), field_instances=[...]))]
139
+ ```
140
+
141
+ ## Expression-Based Type Selection
142
+
143
+ `FieldDef.type` can also be an expression string.
144
+ The expression is evaluated with previously processed field values in scope, so you can switch decoding/encoding type dynamically.
145
+
146
+ ```python
147
+ from sltcore import InfoSize
148
+ from sltcodec import (FieldDef, FieldInstance, StructDef, StructInstance,
149
+ decode, encode)
150
+
151
+ struct_def = [
152
+ FieldDef(name="kind", offset=InfoSize(0, 0), size=InfoSize(1, 0), type="unsigned int"),
153
+ FieldDef(
154
+ name="payload",
155
+ offset=InfoSize(1, 0),
156
+ size="{1: 1, 2: 4}[kind]",
157
+ type="{1: 'int', 2: 'float'}[kind]",
158
+ ),
159
+ ]
160
+
161
+ # kind=1 -> payload is int (1 byte)
162
+ encoded_int = encode(
163
+ StructInstance(
164
+ struct_def=StructDef(fields=struct_def),
165
+ field_instances=[
166
+ FieldInstance(struct_def[0], 1),
167
+ FieldInstance(struct_def[1], 7),
168
+ ],
169
+ ),
170
+ bytearray(),
171
+ )
172
+ decoded_int = decode(struct_def, encoded_int)
173
+
174
+ # kind=2 -> payload is float (4 bytes)
175
+ encoded_float = encode(
176
+ StructInstance(
177
+ struct_def=StructDef(fields=struct_def),
178
+ field_instances=[
179
+ FieldInstance(struct_def[0], 2),
180
+ FieldInstance(struct_def[1], 1.5),
181
+ ],
182
+ ),
183
+ bytearray(),
184
+ )
185
+ decoded_float = decode(struct_def, encoded_float)
186
+
187
+ print(encoded_int, decoded_int)
188
+ print(encoded_float, decoded_float)
189
+ ```
190
+
191
+ ## Saving And Loading StructDef Dictionaries
192
+
193
+ You can persist reusable structure definitions by name with
194
+ `save_struct_def_dict` / `load_struct_def_dict`.
195
+
196
+ ```python
197
+ from pathlib import Path
198
+
199
+ from sltcore import InfoSize
200
+ from sltcodec import FieldDef, StructDef, load_struct_def_dict, save_struct_def_dict
201
+
202
+ struct_defs = {
203
+ "Header": StructDef(
204
+ name="Header",
205
+ description="Simple header",
206
+ fields=[
207
+ FieldDef(name="kind", offset=InfoSize(0, 0), size=InfoSize(1, 0), type="unsigned int"),
208
+ FieldDef(name="flags", offset=InfoSize(1, 0), size=InfoSize(1, 0), type="unsigned int"),
209
+ ],
210
+ )
211
+ }
212
+
213
+ path = Path("struct_defs.json")
214
+ save_struct_def_dict(path, struct_defs)
215
+ loaded = load_struct_def_dict(path)
216
+
217
+ print(loaded["Header"].name)
218
+ print(loaded["Header"].description)
219
+ ```
220
+
221
+ Example output:
222
+
223
+ ```python
224
+ bytearray(b"\x01\x07") StructInstance(struct_def=StructDef(...), field_instances=[...])
225
+ bytearray(b"\x02?\xc0\x00\x00") StructInstance(struct_def=StructDef(...), field_instances=[...])
226
+ ```
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ uv sync --all-extras
232
+ uv run pytest
233
+ ```
@@ -0,0 +1,7 @@
1
+ sltcodec/__init__.py,sha256=w7a_ZGDowo6-Gx2TjENJU23Ded6QkC9w6pBShADZj0s,394
2
+ sltcodec/codec.py,sha256=YPTjEBtzKT6vu63ErYDwxn4bFsbBZO5S4sUx7_fdwDg,16826
3
+ sltcodec/types.py,sha256=P1_g2ccJYpdygBboaBeb9H5YMY7n8WtH_MNKg2FtxG4,14416
4
+ sltcodec-0.2.0.dist-info/METADATA,sha256=UwgpHqOdJ_iwlcZA-0QeXLhnuwhrqPsq5Ofq5nJ5_QU,6871
5
+ sltcodec-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ sltcodec-0.2.0.dist-info/licenses/LICENSE,sha256=_WBHmfGHT8uGAnHK2W-hi7O_rO8t70lubivrqYujdIw,1065
7
+ sltcodec-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fangface
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.