pystructtype 0.1.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.
pystructtype/__init__.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import itertools
|
|
3
|
+
import re
|
|
4
|
+
import struct
|
|
5
|
+
from collections.abc import Callable, Generator
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass, field, is_dataclass
|
|
8
|
+
from typing import (
|
|
9
|
+
Annotated,
|
|
10
|
+
Any,
|
|
11
|
+
TypeVar,
|
|
12
|
+
cast,
|
|
13
|
+
get_args,
|
|
14
|
+
get_origin,
|
|
15
|
+
get_type_hints,
|
|
16
|
+
overload,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_chunks(_list: list, n: int) -> Generator[list]:
|
|
21
|
+
"""
|
|
22
|
+
Yield successive n-sized chunks from a list.
|
|
23
|
+
:param _list: List to chunk out
|
|
24
|
+
:param n: Size of chunks
|
|
25
|
+
:return: Generator of n-sized chunks of _list
|
|
26
|
+
"""
|
|
27
|
+
yield from (_list[i : i + n] for i in range(0, len(_list), n))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def type_from_annotation(_type: type) -> type:
|
|
31
|
+
"""
|
|
32
|
+
Find the base type from an Annotated type, or return it unchanged if
|
|
33
|
+
not Annotated
|
|
34
|
+
:param _type: Type to check
|
|
35
|
+
:return: Annotated base type or the given type if not Annotated
|
|
36
|
+
"""
|
|
37
|
+
# If we have an origin for the given type, and it's Annotated
|
|
38
|
+
if (origin := get_origin(_type)) and origin is Annotated:
|
|
39
|
+
# Keep running `get_args` on the first element of whatever
|
|
40
|
+
# `get_args` returns, until we get nothing back
|
|
41
|
+
arg = _type
|
|
42
|
+
t: Any = _type
|
|
43
|
+
while t := get_args(t):
|
|
44
|
+
arg = t[0]
|
|
45
|
+
|
|
46
|
+
# This will be the base type
|
|
47
|
+
return arg
|
|
48
|
+
# No origin, or the origin is not Annotated, just return the given type
|
|
49
|
+
return _type
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
T = TypeVar("T", int, float, default=int)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class TypeMeta[T]:
|
|
57
|
+
size: int = 1
|
|
58
|
+
default: T | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class TypeInfo:
|
|
63
|
+
format: str
|
|
64
|
+
byte_size: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# TODO: Support proper "c-string" types
|
|
68
|
+
|
|
69
|
+
# Fixed Size Types
|
|
70
|
+
char_t = Annotated[int, TypeInfo("c", 1)]
|
|
71
|
+
int8_t = Annotated[int, TypeInfo("b", 1)]
|
|
72
|
+
uint8_t = Annotated[int, TypeInfo("B", 1)]
|
|
73
|
+
int16_t = Annotated[int, TypeInfo("h", 2)]
|
|
74
|
+
uint16_t = Annotated[int, TypeInfo("H", 2)]
|
|
75
|
+
int32_t = Annotated[int, TypeInfo("i", 4)]
|
|
76
|
+
uint32_t = Annotated[int, TypeInfo("I", 4)]
|
|
77
|
+
int64_t = Annotated[int, TypeInfo("q", 8)]
|
|
78
|
+
uint64_t = Annotated[int, TypeInfo("Q", 8)]
|
|
79
|
+
|
|
80
|
+
# TODO: Make a special Bool class to auto-convert from int to bool
|
|
81
|
+
|
|
82
|
+
# Named Types
|
|
83
|
+
float_t = Annotated[float, TypeInfo("f", 4)]
|
|
84
|
+
double_t = Annotated[float, TypeInfo("d", 8)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class TypeIterator:
|
|
89
|
+
key: str
|
|
90
|
+
base_type: type
|
|
91
|
+
type_info: TypeInfo | None
|
|
92
|
+
type_meta: TypeMeta | None
|
|
93
|
+
is_list: bool
|
|
94
|
+
is_pystructtype: bool
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def size(self):
|
|
98
|
+
return getattr(self.type_meta, "size", 1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def iterate_types(cls) -> Generator[TypeIterator]:
|
|
102
|
+
for key, hint in get_type_hints(cls, include_extras=True).items():
|
|
103
|
+
# Grab the base type from a possibly annotated type hint
|
|
104
|
+
base_type = type_from_annotation(hint)
|
|
105
|
+
|
|
106
|
+
# Determine if the type is a list
|
|
107
|
+
# ex. list[bool] (yes) vs bool (no)
|
|
108
|
+
is_list = issubclass(origin, list) if (origin := get_origin(base_type)) else False
|
|
109
|
+
|
|
110
|
+
# Grab the type hints top args and look for any TypeMeta objects
|
|
111
|
+
type_args = get_args(hint)
|
|
112
|
+
type_meta = next((x for x in type_args if isinstance(x, TypeMeta)), None)
|
|
113
|
+
|
|
114
|
+
# type_args has the possibility of being nested within more tuples
|
|
115
|
+
# drill down the type_args until we hit empty, then we know we're at the bottom
|
|
116
|
+
# which is where type_info will exist
|
|
117
|
+
if type_args and len(type_args) > 1:
|
|
118
|
+
while args := get_args(type_args[0]):
|
|
119
|
+
type_args = args
|
|
120
|
+
|
|
121
|
+
# Find the TypeInfo object on the lowest rung of the type_args
|
|
122
|
+
type_info = next((x for x in type_args if isinstance(x, TypeInfo)), None)
|
|
123
|
+
|
|
124
|
+
# At this point we may have possibly drilled down into `type_args` to find the true base type
|
|
125
|
+
if type_args:
|
|
126
|
+
base_type = type_from_annotation(type_args[0])
|
|
127
|
+
|
|
128
|
+
# Determine if we are a subclass of a pystructtype
|
|
129
|
+
# If we have a type_info object in the Annotation, or we're actually a subtype of StructDataclass
|
|
130
|
+
is_pystructtype = type_info is not None or (
|
|
131
|
+
inspect.isclass(base_type) and issubclass(base_type, StructDataclass)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
yield TypeIterator(key, base_type, type_info, type_meta, is_list, is_pystructtype)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class StructState:
|
|
139
|
+
name: str
|
|
140
|
+
struct_fmt: str
|
|
141
|
+
size: int
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class StructDataclass:
|
|
145
|
+
def __post_init__(self):
|
|
146
|
+
self._state: list[StructState] = []
|
|
147
|
+
# Grab Struct Format
|
|
148
|
+
self.struct_fmt = ""
|
|
149
|
+
for type_iterator in iterate_types(self.__class__):
|
|
150
|
+
if type_iterator.type_info:
|
|
151
|
+
self._state.append(
|
|
152
|
+
StructState(
|
|
153
|
+
type_iterator.key,
|
|
154
|
+
type_iterator.type_info.format,
|
|
155
|
+
type_iterator.size,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
self.struct_fmt += (
|
|
159
|
+
f"{type_iterator.size if type_iterator.size > 1 else ''}{type_iterator.type_info.format}"
|
|
160
|
+
)
|
|
161
|
+
elif inspect.isclass(type_iterator.base_type) and issubclass(type_iterator.base_type, StructDataclass):
|
|
162
|
+
attr = getattr(self, type_iterator.key)
|
|
163
|
+
if type_iterator.is_list:
|
|
164
|
+
fmt = attr[0].struct_fmt
|
|
165
|
+
else:
|
|
166
|
+
fmt = attr.struct_fmt
|
|
167
|
+
self._state.append(StructState(type_iterator.key, fmt, type_iterator.size))
|
|
168
|
+
self.struct_fmt += fmt * type_iterator.size
|
|
169
|
+
else:
|
|
170
|
+
# We have no TypeInfo object, and we're not a StructDataclass
|
|
171
|
+
# This means we're a regularly defined class variable, and we
|
|
172
|
+
# Don't have to do anything about this.
|
|
173
|
+
pass
|
|
174
|
+
self._simplify_format()
|
|
175
|
+
self._byte_length = struct.calcsize("=" + self.struct_fmt)
|
|
176
|
+
# print(f"{self.__class__.__name__}: {self._byte_length} : {self.struct_fmt}")
|
|
177
|
+
|
|
178
|
+
def _simplify_format(self) -> None:
|
|
179
|
+
# First expand the format
|
|
180
|
+
expanded_format = ""
|
|
181
|
+
items = re.findall(r"([a-zA-Z]|\d+)", self.struct_fmt)
|
|
182
|
+
items_len = len(items)
|
|
183
|
+
idx = 0
|
|
184
|
+
while idx < items_len:
|
|
185
|
+
if "0" <= (item := items[idx]) <= "9":
|
|
186
|
+
idx += 1
|
|
187
|
+
expanded_format += items[idx] * int(item)
|
|
188
|
+
else:
|
|
189
|
+
expanded_format += item
|
|
190
|
+
idx += 1
|
|
191
|
+
|
|
192
|
+
simplified_format = ""
|
|
193
|
+
for group in (x[0] for x in re.findall(r"(([a-zA-Z])\2*)", expanded_format)):
|
|
194
|
+
group_len = len(group)
|
|
195
|
+
simplified_format += f"{group_len if group_len > 1 else ''}{group[0]}"
|
|
196
|
+
|
|
197
|
+
self.struct_fmt = simplified_format
|
|
198
|
+
|
|
199
|
+
def size(self) -> int:
|
|
200
|
+
return sum(state.size for state in self._state)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _endian(little_endian: bool) -> str:
|
|
204
|
+
return "<" if little_endian else ">"
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _to_bytes(data: list[int] | bytes) -> bytes:
|
|
208
|
+
if isinstance(data, bytes):
|
|
209
|
+
return data
|
|
210
|
+
return bytes(data)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _to_list(data: list[int] | bytes) -> list[int]:
|
|
214
|
+
if isinstance(data, bytes):
|
|
215
|
+
return list(data)
|
|
216
|
+
return data
|
|
217
|
+
|
|
218
|
+
def _decode(self, data: list[int]) -> None:
|
|
219
|
+
idx = 0
|
|
220
|
+
|
|
221
|
+
for state in self._state:
|
|
222
|
+
attr = getattr(self, state.name)
|
|
223
|
+
|
|
224
|
+
if isinstance(attr, list) and isinstance(attr[0], StructDataclass):
|
|
225
|
+
list_idx = 0
|
|
226
|
+
sub_struct_byte_length = attr[0].size()
|
|
227
|
+
while list_idx < state.size:
|
|
228
|
+
instance: StructDataclass = attr[list_idx]
|
|
229
|
+
instance._decode(data[idx : idx + sub_struct_byte_length])
|
|
230
|
+
list_idx += 1
|
|
231
|
+
idx += sub_struct_byte_length
|
|
232
|
+
elif isinstance(attr, StructDataclass):
|
|
233
|
+
if state.size != 1:
|
|
234
|
+
raise Exception("This should be a size of one, dingus")
|
|
235
|
+
|
|
236
|
+
sub_struct_byte_length = attr.size()
|
|
237
|
+
attr._decode(data[idx : idx + sub_struct_byte_length])
|
|
238
|
+
idx += sub_struct_byte_length
|
|
239
|
+
elif state.size == 1:
|
|
240
|
+
setattr(self, state.name, data[idx])
|
|
241
|
+
idx += 1
|
|
242
|
+
else:
|
|
243
|
+
list_idx = 0
|
|
244
|
+
while list_idx < state.size:
|
|
245
|
+
getattr(self, state.name)[list_idx] = data[idx]
|
|
246
|
+
list_idx += 1
|
|
247
|
+
idx += 1
|
|
248
|
+
|
|
249
|
+
def decode(self, data: list[int] | bytes, little_endian=False) -> None:
|
|
250
|
+
data = self._to_bytes(data)
|
|
251
|
+
|
|
252
|
+
# Decode
|
|
253
|
+
self._decode(list(struct.unpack(self._endian(little_endian) + self.struct_fmt, data)))
|
|
254
|
+
|
|
255
|
+
def _encode(self) -> list[int]:
|
|
256
|
+
result: list[int] = []
|
|
257
|
+
|
|
258
|
+
for state in self._state:
|
|
259
|
+
attr = getattr(self, state.name)
|
|
260
|
+
|
|
261
|
+
if isinstance(attr, list) and isinstance(attr[0], StructDataclass):
|
|
262
|
+
item: StructDataclass
|
|
263
|
+
for item in attr:
|
|
264
|
+
result.extend(item._encode())
|
|
265
|
+
elif isinstance(attr, StructDataclass):
|
|
266
|
+
if state.size != 1:
|
|
267
|
+
raise Exception("This should be a size of one, dingus")
|
|
268
|
+
result.extend(attr._encode())
|
|
269
|
+
elif state.size == 1:
|
|
270
|
+
result.append(getattr(self, state.name))
|
|
271
|
+
else:
|
|
272
|
+
result.extend(getattr(self, state.name))
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
def encode(self, little_endian=False) -> bytes:
|
|
276
|
+
result = self._encode()
|
|
277
|
+
return struct.pack(self._endian(little_endian) + self.struct_fmt, *result)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@overload
|
|
281
|
+
def struct_dataclass(_cls: type[StructDataclass]) -> type[StructDataclass]: ...
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@overload
|
|
285
|
+
def struct_dataclass(_cls: None) -> Callable[[type[StructDataclass]], type[StructDataclass]]: ...
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def struct_dataclass(
|
|
289
|
+
_cls: type[StructDataclass] | None = None,
|
|
290
|
+
) -> Callable[[type[StructDataclass]], type[StructDataclass]] | type[StructDataclass]:
|
|
291
|
+
def inner(_cls: type[StructDataclass]) -> type[StructDataclass]:
|
|
292
|
+
new_cls = _cls
|
|
293
|
+
|
|
294
|
+
# new_cls should not already be a dataclass
|
|
295
|
+
if is_dataclass(new_cls):
|
|
296
|
+
return cast(type[StructDataclass], new_cls)
|
|
297
|
+
|
|
298
|
+
# Make sure any fields without a default have one
|
|
299
|
+
for type_iterator in iterate_types(new_cls):
|
|
300
|
+
if not type_iterator.is_pystructtype:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
if not type_iterator.type_meta or type_iterator.type_meta.size == 1:
|
|
304
|
+
if type_iterator.is_list:
|
|
305
|
+
raise Exception("You said this should be size 1, so this shouldn't be a list")
|
|
306
|
+
|
|
307
|
+
# Set a default if it does not yet exist
|
|
308
|
+
if not getattr(new_cls, type_iterator.key, None):
|
|
309
|
+
default: type | int | float = type_iterator.base_type
|
|
310
|
+
if type_iterator.type_meta and type_iterator.type_meta.default:
|
|
311
|
+
default = type_iterator.type_meta.default
|
|
312
|
+
if isinstance(default, list):
|
|
313
|
+
raise Exception("A default for a size 1 should not be a list")
|
|
314
|
+
|
|
315
|
+
# Create a new instance of the class
|
|
316
|
+
if inspect.isclass(default):
|
|
317
|
+
default = field(default_factory=lambda d=default: d()) # type: ignore
|
|
318
|
+
else:
|
|
319
|
+
default = field(default_factory=lambda d=default: deepcopy(d)) # type: ignore
|
|
320
|
+
|
|
321
|
+
setattr(new_cls, type_iterator.key, default)
|
|
322
|
+
else:
|
|
323
|
+
# This assumes we want multiple items of base_type, so make sure the given base_type is
|
|
324
|
+
# properly set to be a list as well
|
|
325
|
+
if not type_iterator.is_list:
|
|
326
|
+
raise Exception("You want a list, so make it a list you dummy")
|
|
327
|
+
|
|
328
|
+
# We have a meta type and the size is > 1 so make the default a field
|
|
329
|
+
default = type_iterator.base_type
|
|
330
|
+
if type_iterator.type_meta and type_iterator.type_meta.default:
|
|
331
|
+
default = type_iterator.type_meta.default
|
|
332
|
+
|
|
333
|
+
default_list = []
|
|
334
|
+
if isinstance(default, list):
|
|
335
|
+
# TODO: Implement having the entire list be a default rather than needing to set each
|
|
336
|
+
# TODO: element as the same base object.
|
|
337
|
+
pass
|
|
338
|
+
else:
|
|
339
|
+
# Create a new instance of the class
|
|
340
|
+
if inspect.isclass(default):
|
|
341
|
+
default_list = field(
|
|
342
|
+
default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore
|
|
343
|
+
d() for _ in range(s)
|
|
344
|
+
]
|
|
345
|
+
)
|
|
346
|
+
else:
|
|
347
|
+
default_list = field(
|
|
348
|
+
default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore
|
|
349
|
+
deepcopy(d) for _ in range(s)
|
|
350
|
+
]
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
setattr(new_cls, type_iterator.key, default_list)
|
|
354
|
+
return cast(type[StructDataclass], dataclass(new_cls))
|
|
355
|
+
|
|
356
|
+
if _cls is None:
|
|
357
|
+
return inner
|
|
358
|
+
return inner(_cls)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def int_to_bool_list(data: int | list[int], byte_length: int) -> list[bool]:
|
|
362
|
+
"""
|
|
363
|
+
Converts integer or a list of integers into a list of bools representing the bits
|
|
364
|
+
|
|
365
|
+
ex. ord("A") = [False, True, False, False, False, False, False, True]
|
|
366
|
+
|
|
367
|
+
ex. [ord("A"), ord("B")] = [False, True, False, False, False, False, False, True,
|
|
368
|
+
False, True, False, False, False, False, True, False]
|
|
369
|
+
|
|
370
|
+
:param data: Integer(s) to be converted
|
|
371
|
+
:param byte_length: Number of bytes to extract from integer(s)
|
|
372
|
+
:return: List of bools representing each bit in the data
|
|
373
|
+
"""
|
|
374
|
+
# Convert a single int into a list, so we can assume we're always working with a list here
|
|
375
|
+
data = [data] if isinstance(data, int) else data
|
|
376
|
+
|
|
377
|
+
# The amount of bits we end up with will be the number of bytes we expect in the int times 8 (8 bits in a byte)
|
|
378
|
+
# For example uint8_t would have 1 byte, but uint16_t would have 2 bytes
|
|
379
|
+
byte_size = (byte_length * 8) // len(data)
|
|
380
|
+
|
|
381
|
+
bit_strs = []
|
|
382
|
+
for val in data:
|
|
383
|
+
# Convert the int(s) in to a string of bits (add 2 to account for the `0b` prefix)
|
|
384
|
+
tmp_str = format(val, f"#0{byte_size + 2}b")
|
|
385
|
+
# Cut off the `0b` prefix of the bit string, and reverse it
|
|
386
|
+
bit_strs.append(tmp_str.removeprefix("0b")[::-1])
|
|
387
|
+
# Convert the bit_str to a list of ints
|
|
388
|
+
bit_list = map(int, "".join(bit_strs[::-1]))
|
|
389
|
+
# Convert the bit list to bools and return
|
|
390
|
+
return list(map(bool, bit_list))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class BitsType(StructDataclass):
|
|
394
|
+
_raw: Any
|
|
395
|
+
_meta: dict
|
|
396
|
+
_meta_tuple: tuple
|
|
397
|
+
|
|
398
|
+
def __post_init__(self):
|
|
399
|
+
super().__post_init__()
|
|
400
|
+
|
|
401
|
+
self._meta = {k: v for k, v in zip(*self._meta_tuple, strict=False)}
|
|
402
|
+
|
|
403
|
+
def _decode(self, data: list[int]) -> None:
|
|
404
|
+
# First call the super function to put the values in to _raw
|
|
405
|
+
super()._decode(data)
|
|
406
|
+
|
|
407
|
+
# Combine all data in _raw as binary and convert to bools
|
|
408
|
+
bin_data = int_to_bool_list(self._raw, self._byte_length)
|
|
409
|
+
|
|
410
|
+
for k, v in self._meta.items():
|
|
411
|
+
if isinstance(v, list):
|
|
412
|
+
steps = []
|
|
413
|
+
for idx in v:
|
|
414
|
+
steps.append(bin_data[idx])
|
|
415
|
+
setattr(self, k, steps)
|
|
416
|
+
else:
|
|
417
|
+
setattr(self, k, bin_data[v])
|
|
418
|
+
|
|
419
|
+
def _encode(self) -> list[int]:
|
|
420
|
+
bin_data = list(itertools.repeat(False, self._byte_length * 8))
|
|
421
|
+
for k, v in self._meta.items():
|
|
422
|
+
if isinstance(v, list):
|
|
423
|
+
steps = getattr(self, k)
|
|
424
|
+
for idx, bit_idx in enumerate(v):
|
|
425
|
+
bin_data[bit_idx] = steps[idx]
|
|
426
|
+
else:
|
|
427
|
+
bin_data[v] = getattr(self, k)
|
|
428
|
+
|
|
429
|
+
if isinstance(self._raw, list):
|
|
430
|
+
self._raw = [
|
|
431
|
+
sum(v << i for i, v in enumerate(chunk))
|
|
432
|
+
for chunk in list_chunks(bin_data, (self._byte_length // len(self._raw)) * 8)
|
|
433
|
+
][::-1]
|
|
434
|
+
else:
|
|
435
|
+
self._raw = sum(v << i for i, v in enumerate(bin_data))
|
|
436
|
+
|
|
437
|
+
# Run the super function to return the data in self._raw()
|
|
438
|
+
return super()._encode()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def bits(_type: Any, definition: dict[str, int | list[int]]) -> Callable[[type[BitsType]], type[StructDataclass]]:
|
|
442
|
+
def inner(_cls: type[BitsType]) -> type[StructDataclass]:
|
|
443
|
+
# Create class attributes based on the definition
|
|
444
|
+
# TODO: Maybe a sanity check to make sure the definition is the right format, and no overlapping bits, etc
|
|
445
|
+
|
|
446
|
+
new_cls = _cls
|
|
447
|
+
|
|
448
|
+
new_cls.__annotations__["_raw"] = _type
|
|
449
|
+
|
|
450
|
+
new_cls._meta = field(default_factory=dict)
|
|
451
|
+
new_cls.__annotations__["_meta"] = dict[str, int]
|
|
452
|
+
|
|
453
|
+
# Convert the definition to a named tuple, so it's Immutable
|
|
454
|
+
meta_tuple = (tuple(definition.keys()), tuple(definition.values()))
|
|
455
|
+
new_cls._meta_tuple = field(default_factory=lambda d=meta_tuple: d) # type: ignore
|
|
456
|
+
new_cls.__annotations__["_meta_tuple"] = tuple
|
|
457
|
+
|
|
458
|
+
# TODO: Support int, or list of ints as defaults
|
|
459
|
+
# TODO: Support dict, and dict of lists, or list of dicts, etc for definition
|
|
460
|
+
# TODO: ex. definition = {"a": {"b": 0, "c": [1, 2, 3]}, "d": [4, 5, 6], "e": {"f": 7}}
|
|
461
|
+
# TODO: Can't decide if the line above this is a good idea or not
|
|
462
|
+
for key, value in definition.items():
|
|
463
|
+
if isinstance(value, list):
|
|
464
|
+
setattr(
|
|
465
|
+
new_cls,
|
|
466
|
+
key,
|
|
467
|
+
field(default_factory=lambda v=len(value): [False for _ in range(v)]), # type: ignore # noqa: B008
|
|
468
|
+
)
|
|
469
|
+
new_cls.__annotations__[key] = list[bool]
|
|
470
|
+
else:
|
|
471
|
+
setattr(new_cls, key, False)
|
|
472
|
+
new_cls.__annotations__[key] = bool
|
|
473
|
+
|
|
474
|
+
return struct_dataclass(new_cls)
|
|
475
|
+
|
|
476
|
+
return inner
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# XXX: This is how class decorators essentially work
|
|
480
|
+
# @foo
|
|
481
|
+
# class gotem(): ...
|
|
482
|
+
#
|
|
483
|
+
# is equal to: foo(gotem)
|
|
484
|
+
#
|
|
485
|
+
# @foo()
|
|
486
|
+
# class gotem(): ...
|
|
487
|
+
#
|
|
488
|
+
# is equal to: foo()(gotem)
|
|
489
|
+
#
|
|
490
|
+
# @foo(bar=2)
|
|
491
|
+
# class gotem(): ...
|
|
492
|
+
#
|
|
493
|
+
# is equal to: foo(bar=2)(gotem)
|
pystructtype/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pystructtype
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Leverage Python Types to Define C-Struct Interfaces
|
|
5
|
+
Project-URL: Homepage, https://github.com/fchorney/pystructtype
|
|
6
|
+
Project-URL: Documentation, https://pystructtype.readthedocs.io/en/latest/
|
|
7
|
+
Project-URL: Repository, https://github.com/fchorney/pystructtype
|
|
8
|
+
Project-URL: Issues, https://github.com/fchorney/pystructtype/issues
|
|
9
|
+
Author-email: fchorney <github@djsbx.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE.txt
|
|
12
|
+
Keywords: cstruct,struct,type
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.13
|
|
21
|
+
Requires-Dist: loguru>=0.7.3
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# PyStructTypes
|
|
25
|
+
|
|
26
|
+
Leverage Python Types to Define C-Struct Interfaces
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Reasoning
|
|
30
|
+
|
|
31
|
+
I made this project for 2 reasons:
|
|
32
|
+
1. I wanted to see if I could leverage the typing system to effectively automatically
|
|
33
|
+
decode and encode c-type structs in python.
|
|
34
|
+
2. Build a tool to do this for a separate project I am working on.
|
|
35
|
+
|
|
36
|
+
I am aware of other very similar c-type struct to python class libraries available,
|
|
37
|
+
but I wanted to try something new so here we are.
|
|
38
|
+
|
|
39
|
+
This may or may not end up being super useful, as there are quite a few bits of
|
|
40
|
+
hacky metaprogramming to get the type system to play nicely for what I want, but
|
|
41
|
+
perhaps over time it can be cleaned up and made more useful.
|
|
42
|
+
|
|
43
|
+
# StructDataclass
|
|
44
|
+
|
|
45
|
+
The `StructDataclass` class is based off of the `Dataclass` class, and thus
|
|
46
|
+
is used in a similar fashion.
|
|
47
|
+
|
|
48
|
+
# Basic Structs
|
|
49
|
+
|
|
50
|
+
Basic structs can mostly be copied over 1:1
|
|
51
|
+
|
|
52
|
+
```c
|
|
53
|
+
struct MyStruct {
|
|
54
|
+
int16_t myNum;
|
|
55
|
+
char myLetter;
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
@struct_dataclass
|
|
61
|
+
class MyStruct(StructDataclass):
|
|
62
|
+
myNum: int16_t
|
|
63
|
+
myLetter: char_t
|
|
64
|
+
|
|
65
|
+
s = MyStruct()
|
|
66
|
+
s.decode([4, 2, 65])
|
|
67
|
+
# MyStruct(myNum=1026, myLetter=b"A")
|
|
68
|
+
s.decode([4, 2, 65], little_endian=True)
|
|
69
|
+
# MyStruct(myNum=516, myLetter=b"A")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For arrays of basic elements, you need to Annotate them with
|
|
73
|
+
the `TypeMeta` object, and set their type to `list[_type_]`.
|
|
74
|
+
|
|
75
|
+
```c
|
|
76
|
+
struct MyStruct {
|
|
77
|
+
uint8_t myInts[4];
|
|
78
|
+
uint16_t myBiggerInts[2];
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
```python
|
|
82
|
+
@struct_dataclass
|
|
83
|
+
class MyStruct(StructDataclass):
|
|
84
|
+
myInts: Annotated[list[uint8_t], TypeMeta(size=4)]
|
|
85
|
+
myBiggerInts: Annotated[list[uint16_t], TypeMeta(size=2)]
|
|
86
|
+
|
|
87
|
+
s = MyStruct()
|
|
88
|
+
s.decode([0, 64, 128, 255, 16, 0, 255, 255])
|
|
89
|
+
# MyStruct(myInts=[0, 64, 128, 255], myBiggerInts=[4096, 65535])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
You can also set defaults for both basic types and lists.
|
|
93
|
+
|
|
94
|
+
All values will default to 0 or the initialized value for the chosen class if no
|
|
95
|
+
specific value is set.
|
|
96
|
+
|
|
97
|
+
List defaults will set all items in the list to the same value. Currently
|
|
98
|
+
setting a complete default list for all values is not implemented.
|
|
99
|
+
|
|
100
|
+
```c
|
|
101
|
+
struct MyStruct P
|
|
102
|
+
uint8_t myInt = 5;
|
|
103
|
+
uint8_t myInts[2];
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
@struct_dataclass
|
|
108
|
+
class MyStruct(StructDataclass):
|
|
109
|
+
myInt: uint8_t = 5
|
|
110
|
+
myInts: Annnotated[list[uint8_t], TypeMeta(size=2, default=1)]
|
|
111
|
+
|
|
112
|
+
s = MyStruct()
|
|
113
|
+
# MyStruct(myInt=5, myInts=[1, 1])
|
|
114
|
+
s.decode([10, 5, 6])
|
|
115
|
+
# MyStruct(myInt=10, myInts=[5, 6])
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
# The Bits Abstraction
|
|
119
|
+
|
|
120
|
+
This library includes a `bits` abstraction to map bits to variables for easier access.
|
|
121
|
+
|
|
122
|
+
One example of this is converting a C enum like so:
|
|
123
|
+
|
|
124
|
+
```c
|
|
125
|
+
enum ConfigFlags {
|
|
126
|
+
lights_flag = 1 << 0,
|
|
127
|
+
platform_flag = 1 << 1,
|
|
128
|
+
};
|
|
129
|
+
#pragma pack(push, 1)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
@bits(uint8_t, {"lights_flag": 0, "platform_flag": 1})
|
|
134
|
+
class FlagsType(BitsType): ...
|
|
135
|
+
|
|
136
|
+
f = FlagsType()
|
|
137
|
+
f.decode([3])
|
|
138
|
+
# FlagsType(lights_flag=True, platform_flag=True)
|
|
139
|
+
f.decode([2])
|
|
140
|
+
# FlagsType(lights_flag=False, platform_flag=True)
|
|
141
|
+
f.decode([1])
|
|
142
|
+
# FlagsType(lights_flag=True, platform_flag=False)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
# Custom StructDataclass Processing and Extensions
|
|
146
|
+
|
|
147
|
+
There may be times when you want to make the python class do
|
|
148
|
+
cool fun python class type of stuff with the data structure.
|
|
149
|
+
We can extend the class functions `_decode` and `_encode` to
|
|
150
|
+
handle this processing.
|
|
151
|
+
|
|
152
|
+
In this example, lets say you want to be able to read/write the
|
|
153
|
+
class object as a list, using `__getitem__` and `__setitem__` as well
|
|
154
|
+
as keeping the data in a different data structure than what the
|
|
155
|
+
c struct defines.
|
|
156
|
+
|
|
157
|
+
```c
|
|
158
|
+
struct MyStruct {
|
|
159
|
+
uint8_t enabledSensors[5];
|
|
160
|
+
};
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
@struct_dataclass
|
|
165
|
+
class EnabledSensors(StructDataclass):
|
|
166
|
+
# We can define the actual data we are ingesting here
|
|
167
|
+
# This mirrors the `uint8_t enabledSensors[5]` data
|
|
168
|
+
_raw: Annotated[list[uint8_t], TypeMeta(size=5)]
|
|
169
|
+
|
|
170
|
+
# We use this to store the data in the way we actually want
|
|
171
|
+
_data: list[list[bool]] = field(default_factory=list)
|
|
172
|
+
|
|
173
|
+
def _decode(self, data: list[int]) -> None:
|
|
174
|
+
# First call the super function. This will store the raw values into `_raw`
|
|
175
|
+
super()._decode(data)
|
|
176
|
+
|
|
177
|
+
# Erase everything in self._data to remove any old data
|
|
178
|
+
self._data = []
|
|
179
|
+
|
|
180
|
+
# 2 Panels are packed into a single uint8_t, the left most 4 bits for the first
|
|
181
|
+
# and the right most 4 bits for the second
|
|
182
|
+
for bitlist in (list(map(bool, map(int, format(_byte, "#010b")[2:]))) for _byte in self._raw):
|
|
183
|
+
self._data.append(bitlist[0:4])
|
|
184
|
+
self._data.append(bitlist[4:])
|
|
185
|
+
|
|
186
|
+
# Remove the last item in self._data as there are only 9 panels
|
|
187
|
+
del self._data[-1]
|
|
188
|
+
|
|
189
|
+
def _encode(self) -> list[int]:
|
|
190
|
+
# Modify self._raw with updated values from self._data
|
|
191
|
+
for idx, items in enumerate(list_chunks(self._data, 2)):
|
|
192
|
+
# Last chunk
|
|
193
|
+
if len(items) == 1:
|
|
194
|
+
items.append([False, False, False, False])
|
|
195
|
+
self._raw[idx] = sum(v << i for i, v in enumerate(list(itertools.chain.from_iterable(items))[::-1]))
|
|
196
|
+
|
|
197
|
+
# Run the super function to return the encoded data from self._raw()
|
|
198
|
+
return super()._encode()
|
|
199
|
+
|
|
200
|
+
def __getitem__(self, index: int) -> list[bool]:
|
|
201
|
+
# This lets us access the data with square brackets
|
|
202
|
+
# ex. `config.enabled_sensors[Panel.UP][Sensor.RIGHT]`
|
|
203
|
+
return self._data[index]
|
|
204
|
+
|
|
205
|
+
def __setitem__(self, index: int, value: list[bool]) -> None:
|
|
206
|
+
# Only use this to set a complete set for a panel
|
|
207
|
+
# ex. `config.enabled_sensors[Panel.UP] = [True, True, False, True]`
|
|
208
|
+
if len(value) != 4 or not all(isinstance(x, bool) for x in value):
|
|
209
|
+
raise Exception("must set all 4 items at once")
|
|
210
|
+
|
|
211
|
+
s = EnabledSensors()
|
|
212
|
+
s.decode([15, 15, 15, 15, 0])
|
|
213
|
+
|
|
214
|
+
# The `self._data` here would look like:
|
|
215
|
+
# [
|
|
216
|
+
# [False, False, False, False],
|
|
217
|
+
# [True, True, True, True],
|
|
218
|
+
# [False, False, False, False],
|
|
219
|
+
# [True, True, True, True],
|
|
220
|
+
# [False, False, False, False],
|
|
221
|
+
# [True, True, True, True],
|
|
222
|
+
# [False, False, False, False],
|
|
223
|
+
# [True, True, True, True],
|
|
224
|
+
# [False, False, False, False],
|
|
225
|
+
# [False, False, False, False]
|
|
226
|
+
# ]
|
|
227
|
+
|
|
228
|
+
# With the get/set functioned defined, we can access the data
|
|
229
|
+
# with square accessors.
|
|
230
|
+
# s[1][2] == True
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
# StructDataclass is Composable
|
|
234
|
+
|
|
235
|
+
You can use StructDataclasses in other StructDataclasses to create more complex
|
|
236
|
+
structs.
|
|
237
|
+
|
|
238
|
+
```c
|
|
239
|
+
struct RGB {
|
|
240
|
+
uint8_t r;
|
|
241
|
+
uint8_t g;
|
|
242
|
+
uint8_t b;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
struct LEDS {
|
|
246
|
+
RGB lights[3];
|
|
247
|
+
};
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
@struct_dataclass
|
|
252
|
+
class RGB(StructDataclass):
|
|
253
|
+
r: uint8_t
|
|
254
|
+
g: uint8_t
|
|
255
|
+
b: uint8_t
|
|
256
|
+
|
|
257
|
+
@struct_dataclass
|
|
258
|
+
class LEDS(StructDataclass):
|
|
259
|
+
lights: Annotated[list[RGB], TypeMeta(size=3])]
|
|
260
|
+
|
|
261
|
+
l = LEDS()
|
|
262
|
+
l.decode([1, 2, 3, 4, 5, 6, 7, 8, 9])
|
|
263
|
+
# LEDS(lights=[RGB(r=1, g=2, b=3), RGB(r=4, g=5, b=6), RGB(r=7, g=8, b=9)])
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
# Future Updates
|
|
267
|
+
|
|
268
|
+
- Bitfield: Similar to the `Bits` abstraction. An easy way to define bitfields
|
|
269
|
+
- C-Strings: Make a base class to handle C strings (arrays of chars)
|
|
270
|
+
- Potentially more ways to define bits (dicts/lists/etc).
|
|
271
|
+
- Potentially allowing list defaults to be entire pre-defined lists.
|
|
272
|
+
- ???
|
|
273
|
+
|
|
274
|
+
# Examples
|
|
275
|
+
|
|
276
|
+
You can see a more fully fledged example in the `test/examples.py` file.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pystructtype/__init__.py,sha256=OLjKzt0Rer02l0WpG0TtICYhqyyxeGiHmLQeU_Aq284,18097
|
|
2
|
+
pystructtype/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
pystructtype-0.1.0.dist-info/METADATA,sha256=uQWihEXins96KB5SX70jU08ZpshjITFhcq5oA9tR1oE,7984
|
|
4
|
+
pystructtype-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
pystructtype-0.1.0.dist-info/licenses/LICENSE.txt,sha256=2Vm7iCFyISqDsn9ED-_pZOrTEUneAIuMCk8TKdIL9W4,1073
|
|
6
|
+
pystructtype-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Fernando Chorney
|
|
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.
|