diff --git a/README.md b/README.md index ffe4d7c..a0d326d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ A Python library for defining and manipulating C-like packed structures with sup - **Bit-Level Precision**: Full support for bit-fields and non-byte-aligned data - **Nested Structures**: Create complex hierarchical data structures - **Endianness Control**: Support for big-endian, little-endian, and native byte order -- **Array Support**: Built-in support for fixed-size arrays +- **Array Support**: Fixed-size arrays of primitives, of structs, and arbitrarily-nested arrays of arrays (multi-dimensional) +- **Numeric Operators**: Arithmetic and ordering operators are available only on numeric types (`c_unsigned_int`, `c_signed_int`, `c_float`); using them on non-numeric types (`c_bool`, `c_char`, `c_raw_bytes`, `c_padding`, `c_array`) raises `TypeError` - **C Compatibility**: Generate structures that are binary-compatible with C's `__attribute__((packed))` structs - **Zero Dependencies**: Only requires `bitstruct` package @@ -119,14 +120,29 @@ All data types inherit from the base `Type` class and support bit-level precisio #### Arrays -- **`c_array(type, type_size_bits, array_size)`**: Fixed-size array - - `type_size_bits` must be multiple of 8 +- **`c_array(template, array_size)`**: Fixed-size array. + - `template` is a *template instance* of any `Type` or `Struct`; it is deep-copied for each element. + - Arrays can be nested (array of arrays) to build multi-dimensional arrays of arbitrary rank. + ```python # Array of 5 unsigned 8-bit integers - values = c_array(c_unsigned_int, 8, 5) + values = c_array(c_unsigned_int(8), 5) values.set_value([10, 20, 30, 40, 50]) + + # Array of 3 structs + point = Struct({"x": c_signed_int(16), "y": c_signed_int(16)}) + points = c_array(point, 3) + points[0].set_data(x=1, y=2) + + # 2-D array (array of arrays of arrays of structs is supported too) + matrix = c_array(c_array(c_unsigned_int(8), 4), 3) # 3x4 matrix + matrix.set_value([[1, 2, 3, 4], + [5, 6, 7, 8], + [9, 10, 11, 12]]) ``` + Indexing returns the wrapped element (a `Type` or `Struct`), so `arr[i].value`, `arr[i].set_data(...)` and `arr[r][c]` all work as expected. The element still compares equal to its scalar value (`arr[i] == 10`). + ### Struct Class The `Struct` class represents a collection of typed fields: @@ -287,7 +303,7 @@ from packed_struct import Struct, c_array, c_unsigned_int # Array of integers data = Struct({ "header": c_unsigned_int(16), - "values": c_array(c_unsigned_int, 8, 10) # 10 bytes + "values": c_array(c_unsigned_int(8), 10) # 10 bytes }) data.set_data(header=0xFFFF) @@ -298,6 +314,36 @@ for i, element in enumerate(data["values"]): print(f"values[{i}] = {element.value}") ``` +### Multi-dimensional arrays of nested structs + +For a more complex case combining nested structs, arrays inside structs and arrays of arrays of structs, see +[`examples/complex/warehouse_grid.py`](https://github.com/lu-maca/py-packed-struct/blob/main/examples/complex/warehouse_grid.py). + +```python +from packed_struct import Struct, c_array, c_char, c_unsigned_int + +location = Struct({"lat": c_unsigned_int(16), "lon": c_unsigned_int(16)}) + +bin_tmpl = Struct({ + "id": c_unsigned_int(8), + "loc": location, # nested struct + "label": c_char(8 * 8), + "samples": c_array(c_unsigned_int(16), 4), # array inside struct +}) + +warehouse = Struct({ + "version": c_unsigned_int(8), + "grid": c_array(c_array(bin_tmpl, 3), 2), # 2x3 grid of Bin structs +}) + +warehouse.set_data(version=1) +cell = warehouse["grid"][0][1] # walk the matrix +cell.set_data(id=7, label="B0-1", samples=[10, 11, 12, 13]) +cell.get_data()["loc"].set_data(lat=45000, lon=9001) + +blob = warehouse.pack() +``` + ## Use Cases - **Embedded Systems**: Communicate with hardware devices using binary protocols @@ -313,7 +359,9 @@ for i, element in enumerate(data["values"]): | C-like structures | ✅ Supported | | Bit-fields | ✅ Supported | | Nested structures | ✅ Supported | -| Arrays | ✅ Supported | +| Arrays of primitives and structs | ✅ Supported | +| Multi-dimensional arrays (array of arrays) | ✅ Supported | +| Arithmetic operators on numeric types only | ✅ Supported | | Byte endianness | ✅ Supported (big, little, native) | | Bit endianness | 🚧 Planned | | Dynamic arrays | 🚧 Planned | diff --git a/examples/array/array_of_ints.py b/examples/array/array_of_ints.py new file mode 100644 index 0000000..2aeb023 --- /dev/null +++ b/examples/array/array_of_ints.py @@ -0,0 +1,45 @@ +"""Example: array of integers + +Shows how to use c_array with a primitive type to represent a fixed-size +sequence of values — equivalent to the following C struct: + + struct { + uint8_t samples[4]; + uint16_t checksum; + } +""" + +from packed_struct import * + +msg = Struct( + { + "samples": c_array(c_unsigned_int(8), 4), + "checksum": c_unsigned_int(16), + } +) + +# Set the array elements individually … +msg["samples"][0].set_value(10) +msg["samples"][1].set_value(20) +msg["samples"][2].set_value(30) +msg["samples"][3].set_value(40) + +# … or all at once via set_data() +msg.set_data(samples=[10, 20, 30, 40], checksum=0xABCD) + +packed = msg.pack() +print("packed :", packed.hex()) # 0a141e28abcd + +# Unpack back into a fresh struct +msg2 = Struct( + { + "samples": c_array(c_unsigned_int(8), 4), + "checksum": c_unsigned_int(16), + } +) +msg2.unpack(packed) + +print("samples:", [msg2["samples"][i] for i in range(4)]) # [10, 20, 30, 40] +print("checksum:", hex(msg2["checksum"])) # 0xabcd +print(msg2["checksum"] == 0xABCD) +print(msg2["samples"][2] == 30) \ No newline at end of file diff --git a/examples/array/array_of_structs.py b/examples/array/array_of_structs.py new file mode 100644 index 0000000..5b06b33 --- /dev/null +++ b/examples/array/array_of_structs.py @@ -0,0 +1,58 @@ +"""Example: array of structs + +Shows how to use c_array with a Struct template to represent a fixed-size +array of composite records — equivalent to the following C struct: + + typedef struct { + int16_t x; + int16_t y; + } Point; + + struct { + uint8_t count; + Point waypoints[3]; + } +""" + +from packed_struct import * + +point = Struct({"x": c_signed_int(16), "y": c_signed_int(16)}) + +route = Struct( + { + "count": c_unsigned_int(8), + "waypoints": c_array(point, 3), + } +) + +# Set each waypoint separately +route["waypoints"][0].set_data(x=0, y=0) +route["waypoints"][1].set_data(x=100, y=-50) +route["waypoints"][2].set_data(x=200, y=75) +route.set_data(count=3) + +# Or set all waypoints at once via set_data() with a list of dicts +route.set_data(waypoints=[ + {"x": 0, "y": 0}, + {"x": 100, "y": -50}, + {"x": 200, "y": 75}, +]) + +packed = route.pack() +print("packed :", packed.hex()) +print("size :", route.size, "bits /", len(packed), "bytes") + +# Unpack back +point2 = Struct({"x": c_signed_int(16), "y": c_signed_int(16)}) +route2 = Struct( + { + "count": c_unsigned_int(8), + "waypoints": c_array(point2, 3), + } +) +route2.unpack(packed) + +print("count:", route2["count"]) +for i in range(3): + wp = route2["waypoints"][i] + print(f" waypoint[{i}]: x={wp['x']}, y={wp['y']}") diff --git a/examples/complex/warehouse_grid.py b/examples/complex/warehouse_grid.py new file mode 100644 index 0000000..8f34e88 --- /dev/null +++ b/examples/complex/warehouse_grid.py @@ -0,0 +1,110 @@ +"""Example: complex composition. + +Demonstrates the full expressive power of the library by combining: + * nested ``Struct``s, + * ``c_array`` *inside* a ``Struct``, + * ``c_array`` of ``c_array`` (a 2D matrix), + * the matrix elements themselves being ``Struct``s. + +Equivalent C declarations:: + + typedef struct { + uint16_t lat; // latitude (encoded) + uint16_t lon; // longitude (encoded) + } Location; + + typedef struct { + uint8_t id; + Location loc; // <-- nested struct + char label[8]; + uint16_t samples[4]; // <-- array inside struct + } Bin; + + struct Warehouse { + uint8_t version; + Bin grid[2][3]; // <-- array of array of struct + }; +""" + +from packed_struct import ( + Struct, + c_array, + c_char, + c_unsigned_int, +) + +ROWS = 2 +COLS = 3 +SAMPLES_PER_BIN = 4 +LABEL_BYTES = 8 + +# --------------------------------------------------------------------------- +# Templates +# --------------------------------------------------------------------------- +location_tmpl = Struct({ + "lat": c_unsigned_int(16), + "lon": c_unsigned_int(16), +}) + +bin_tmpl = Struct({ + "id": c_unsigned_int(8), + "loc": location_tmpl, # nested struct + "label": c_char(LABEL_BYTES * 8), + "samples": c_array(c_unsigned_int(16), SAMPLES_PER_BIN), # array inside struct +}) + + +def make_warehouse() -> Struct: + """Build a fresh Warehouse struct (templates are deep-copied internally).""" + return Struct({ + "version": c_unsigned_int(8), + # array of array of Bin + "grid": c_array(c_array(bin_tmpl, COLS), ROWS), + }) + + +# --------------------------------------------------------------------------- +# Populate +# --------------------------------------------------------------------------- +warehouse = make_warehouse() +warehouse.set_data(version=1) + +for r in range(ROWS): + for c in range(COLS): + bin_ = warehouse["grid"][r][c] # the actual Bin Struct (deep-copied template) + bin_data = bin_.get_data() # dict {field_name: Type/Struct/c_array} + bin_data["loc"].set_data(lat=45000 + r, lon=9000 + c) # nested struct + bin_.set_data( + id=r * COLS + c, + label=f"B{r}-{c}", + samples=[r * 100 + c * 10 + k for k in range(SAMPLES_PER_BIN)], + ) + +# --------------------------------------------------------------------------- +# Pack +# --------------------------------------------------------------------------- +packed = warehouse.pack() +print("packed :", packed.hex()) +print("size :", warehouse.size, "bits /", len(packed), "bytes") + +# --------------------------------------------------------------------------- +# Unpack into a fresh instance and verify +# --------------------------------------------------------------------------- +restored = make_warehouse() +restored.unpack(packed) + +print("\nrestored.version =", restored["version"]) +for r in range(ROWS): + for c in range(COLS): + b = restored["grid"][r][c] + loc = b.get_data()["loc"] + print( + f" grid[{r}][{c}]: id={b['id']:>2} " + f"loc=({loc['lat']}, {loc['lon']}) " + f"label={b['label']!r:<10} " + f"samples={[b['samples'][k].value for k in range(SAMPLES_PER_BIN)]}" + ) + +# Round-trip sanity check +assert restored.pack() == packed, "round-trip mismatch" +print("\nround-trip OK") diff --git a/examples/nested-struct/nested_struct.py b/examples/nested-struct/nested_struct.py new file mode 100644 index 0000000..ec94266 --- /dev/null +++ b/examples/nested-struct/nested_struct.py @@ -0,0 +1,61 @@ +"""Example: pack / unpack round-trip with nested structs + +Shows how to serialize a struct to bytes and deserialize it back — +equivalent to the following C structs: + + typedef struct { + char brand[10]; + uint8_t size; + } Shoe; + + typedef struct { + char tshirt[10]; + char shorts[10]; + Shoe shoes; + } Dresses; + + typedef struct { + char name[10]; + uint8_t age; + float weight; + Dresses dresses; + } Person; +""" + +from packed_struct import * + + +def make_person_struct(): + shoes = Struct({"brand": c_char(10 * 8), "size": c_unsigned_int(8)}) + dresses = Struct({"tshirt": c_char(10 * 8), "shorts": c_char(10 * 8), "shoes": shoes}) + person = Struct({"name": c_char(10 * 8), "age": c_unsigned_int(8), "weight": c_float(32), "dresses": dresses}) + return person, dresses, shoes + + +# --- Sender side --- +sender, sender_dresses, sender_shoes = make_person_struct() + +sender.set_data(name="Alice", age=30, weight=58.5) +sender_dresses.set_data(tshirt="white", shorts="blue") +sender_shoes.set_data(brand="Nike", size=38) + +payload = sender.pack(byte_endianness="big") +print("payload :", payload.hex()) +print("size :", sender.size, "bits /", len(payload), "bytes") + +# --- Receiver side --- +receiver, receiver_dresses, receiver_shoes = make_person_struct() +receiver.unpack(payload, byte_endianness="big") + +# get_data() returns the internal dict of Type/Struct objects +top = receiver.get_data() +dr = receiver_dresses.get_data() +sh = receiver_shoes.get_data() + +print("\nReceived:") +print(" name :", top["name"].value) +print(" age :", top["age"].value) +print(" weight :", top["weight"].value) +print(" tshirt :", dr["tshirt"].value) +print(" shorts :", dr["shorts"].value) +print(" brand :", sh["brand"].value, " size:", sh["size"].value) diff --git a/packed_struct/types.py b/packed_struct/types.py index 319ca8e..eea1866 100644 --- a/packed_struct/types.py +++ b/packed_struct/types.py @@ -1,5 +1,6 @@ """This module wraps the `bitstruct` package to implement a C like packed struct (https://bitstruct.readthedocs.io/en/latest/index.html)""" +import copy import bitstruct as bstruct from typing import Union @@ -27,11 +28,120 @@ def __init__(self, bits: int) -> None: def __repr__(self) -> str: return str(self.value) + def __eq__(self, other): + if isinstance(other, Type): + return self.value == other.value + return self.value == other + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return id(self) + def set_value(self, value): self.value = value -class c_unsigned_int(Type): +class _NumericType(Type): + """Intermediate base class for numeric types (integers and floats). + + Adds arithmetic and ordering operations on top of :class:`Type`. Non-numeric + subclasses (booleans, chars, raw bytes, padding, arrays) intentionally do + *not* inherit from this class so that meaningless operations like + ``c_char(...) * 2`` or ``c_bool(...) - 1`` raise a ``TypeError`` instead of + silently returning a value. + """ + + def __add__(self, other): + if isinstance(other, Type): + return self.value + other.value + return self.value + other + + def __sub__(self, other): + if isinstance(other, Type): + return self.value - other.value + return self.value - other + + def __mul__(self, other): + if isinstance(other, Type): + return self.value * other.value + return self.value * other + + def __truediv__(self, other): + if isinstance(other, Type): + return self.value / other.value + return self.value / other + + def __floordiv__(self, other): + if isinstance(other, Type): + return self.value // other.value + return self.value // other + + def __mod__(self, other): + if isinstance(other, Type): + return self.value % other.value + return self.value % other + + def __pow__(self, other): + if isinstance(other, Type): + return self.value ** other.value + return self.value ** other + + def __radd__(self, other): + return other + self.value + + def __rsub__(self, other): + return other - self.value + + def __rmul__(self, other): + return other * self.value + + def __rtruediv__(self, other): + return other / self.value + + def __rfloordiv__(self, other): + return other // self.value + + def __rmod__(self, other): + return other % self.value + + def __rpow__(self, other): + return other ** self.value + + def __neg__(self): + return -self.value + + def __pos__(self): + return +self.value + + def __abs__(self): + return abs(self.value) + + def __lt__(self, other): + if isinstance(other, Type): + return self.value < other.value + return self.value < other + + def __le__(self, other): + if isinstance(other, Type): + return self.value <= other.value + return self.value <= other + + def __gt__(self, other): + if isinstance(other, Type): + return self.value > other.value + return self.value > other + + def __ge__(self, other): + if isinstance(other, Type): + return self.value >= other.value + return self.value >= other + + __hash__ = Type.__hash__ + + +class c_unsigned_int(_NumericType): """`u` stands for unsigned integer, according to bitstruct [doc](https://bitstruct.readthedocs.io/en/latest/index.html#functions) Argument: @@ -43,7 +153,7 @@ def __init__(self, bits: int) -> None: self.fmt: str = f"u{bits}" -class c_signed_int(Type): +class c_signed_int(_NumericType): """`s` stands for signed integer, according to bitstruct [doc](https://bitstruct.readthedocs.io/en/latest/index.html#functions) Argument: @@ -55,7 +165,7 @@ def __init__(self, bits: int) -> None: self.fmt: str = f"s{bits}" -class c_float(Type): +class c_float(_NumericType): """`f` stands for float (16, 32, 64 bits), according to bitstruct [doc](https://bitstruct.readthedocs.io/en/latest/index.html#functions) Argument: @@ -128,20 +238,28 @@ def __init__(self, bits: int) -> None: class c_array(Type): - """ - Type size shall be specified in bits and shall be a multiple of 8 + """Array of a primitive Type or of a Struct. + + Always pass a *template instance* as the first argument; it is deep-copied + for each element. + + Examples:: + + c_array(c_unsigned_int(8), 3) # array of 3 uint8 + c_array(c_float(32), 4) # array of 4 float32 + c_array(Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}), 2) """ - def __init__(self, type_name: Type, type_size_bits: int, array_size: int) -> None: + def __init__(self, template: Union["Type", "Struct"], array_size: int) -> None: if array_size < 0: raise UserWarning("array_size must be positive") - if type_size_bits % 8 != 0: - raise UserWarning("type_size_bits must be a multiples of 8 bits") - super().__init__(type_size_bits * array_size) + if not isinstance(template, (Type, Struct)): + raise TypeError(f"template must be a Type or Struct instance, got {type(template)}") + + super().__init__(template.size * array_size) self._array_size = array_size - self.value = [] - for _ in range(0, array_size): - self.value.append(type_name(type_size_bits)) + self.value = [copy.deepcopy(template) for _ in range(array_size)] + self.fmt = "" for value in self.value: self.fmt += value.fmt @@ -160,14 +278,50 @@ def set_value(self, values: Union[list, str]): f"Length of array ({self._array_size}) is different from the length of the provided list ({len(values)})" ) for provided_value, value in zip(values, self.value): - value.set_value(provided_value) + if isinstance(value, Struct): + value.set_data(**provided_value) + elif isinstance(value, c_array): + value.set_value(provided_value) + else: + value.set_value(provided_value) + + +def _flatten_values(item) -> list: + """Flatten an item (``Type``, ``Struct`` or ``c_array``, possibly nested) + into a flat list of scalar values, in the order they should be packed. + """ + if isinstance(item, Struct): + return item.value + if isinstance(item, c_array): + out = [] + for element in item.value: + out += _flatten_values(element) + return out + return [item.value] + + +def _set_from_unpacked(item, unpacked, idx: int) -> int: + """Walk ``item`` (``Type``/``Struct``/``c_array``, possibly nested), + consuming scalar values from ``unpacked`` starting at ``idx`` and writing + them onto leaves. Return the next index to consume. + """ + if isinstance(item, Struct): + for _, child in item._data.items(): + idx = _set_from_unpacked(child, unpacked, idx) + return idx + if isinstance(item, c_array): + for element in item.value: + idx = _set_from_unpacked(element, unpacked, idx) + return idx + item.value = unpacked[idx] + return idx + 1 class Struct: """Definition of a C-like packed struct Argument: - `data_dict`: dictionary of data to be included in the packed struct (key: name, item: data type) + `data_dict` dictionary of data to be included in the packed struct (key: name, item: data type) Nota bene: Data types can only be of type `Type` or of type `Struct`, for nested structures @@ -228,6 +382,10 @@ def __getitem__(self, data): raise KeyError(f"Data {data} not found in struct") def __getattr__(self, data): + # Guard against __getattr__ being called before _data is set (e.g. during + # copy.deepcopy reconstruction), which would otherwise cause infinite recursion. + if '_data' not in self.__dict__: + raise AttributeError(data) try: return self._data[data].value except KeyError: @@ -261,19 +419,12 @@ def fmt(self) -> str: @property def value(self) -> list: - """Return the list of values of all data""" - # this can be managed in a more pythonic way maybe with list comprehension, - # but a comprehension creates a list of lists when multiple Structs are nested + """Return the flat list of values of all data (recursing into nested + Structs and arbitrarily-nested c_arrays). + """ values = [] for _, item in self._data.items(): - val = item.value - if isinstance(item, c_array): - for element in val: - values.append(element.value) - elif isinstance(val, list): - values += val - else: - values.append(val) + values += _flatten_values(item) return values """Public methods""" @@ -287,7 +438,7 @@ def pack(self, byte_endianness: str = "=") -> bytes: according to specified format Argument: - `byte_endianness`: shall be "big", "little" or "=" (default: "=", i.e. native) + `byte_endianness` shall be "big", "little" or "=" (default: "=", i.e. native) """ check_array = [True if x is not None else False for x in self.value] @@ -335,13 +486,10 @@ def unpack( Return a dict containing data. Arguments: - * `byte_string`: the byte string you want to unpack - - * `byte_endianness`: shall be "big", "little" or "=" (default: "=", i.e. native) - - * `text_encoding`: passed to `bytes.decode()` - - * `text_errors`: passed to `bytes.decode()` + `byte_string` the byte string you want to unpack + `byte_endianness` shall be "big", "little" or "=" (default: "=", i.e. native) + `text_encoding` passed to `bytes.decode()` + `text_errors` passed to `bytes.decode()` """ if not byte_endianness in BYTE_ENDIANNESS.keys(): raise Exception("Byte endianness shall be 'little', 'big' or '='") @@ -356,15 +504,7 @@ def unpack( def recursive_set(dict_item, idx: int): for _, item in dict_item.items(): - if isinstance(item, Struct): - idx = recursive_set(item._data, idx) - elif isinstance(item, c_array): - for element in item: - element.set_value(unpacked[idx]) - idx += 1 - else: - item.value = unpacked[idx] - idx += 1 + idx = _set_from_unpacked(item, unpacked, idx) return idx recursive_set(self._data, i) diff --git a/tests/tests.py b/tests/tests.py index 93be933..8f30c86 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -332,39 +332,34 @@ class ArrayTest: @test def test_array_correct(): - type_size = 8 array_size = 5 - data = c_array(c_unsigned_int, type_size, array_size) - - expected_size = type_size * array_size + data = c_array(c_unsigned_int(8), array_size) + + expected_size = 8 * array_size non_blocking_assert(data.size == expected_size, f"array size not correct, expected {expected_size}, current {data.size}") non_blocking_assert(len(data.value) == array_size, f"array length not correct, expected {array_size}, current {len(data.value)}") @test def test_array_format(): - type_size = 8 - array_size = 3 - data = c_array(c_unsigned_int, type_size, array_size) - + data = c_array(c_unsigned_int(8), 3) + expected_fmt = "u8u8u8" non_blocking_assert(data.fmt == expected_fmt, f"array format not correct, expected {expected_fmt}, current {data.fmt}") @test def test_array_set_value(): - type_size = 8 - array_size = 3 - data = c_array(c_unsigned_int, type_size, array_size) - + data = c_array(c_unsigned_int(8), 3) + values = [1, 2, 3] data.set_value(values) - + for i, val in enumerate(values): - non_blocking_assert(data[i].value == val, f"array[{i}] value not set correctly, expected {val}, current {data[i].value}") + non_blocking_assert(data[i] == val, f"array[{i}] value not set correctly, expected {val}, current {data[i]}") @test def test_array_negative_size(): try: - data = c_array(c_unsigned_int, 8, -1) + data = c_array(c_unsigned_int(8), -1) raise TestException() except UserWarning as e: expected_msg = "array_size must be positive" @@ -377,42 +372,123 @@ def test_array_negative_size(): non_blocking_assert(False, "array allows for negative array_size") @test - def test_array_not_multiple_of_8(): + def test_array_wrong_value_length(): + data = c_array(c_unsigned_int(8), 3) try: - data = c_array(c_unsigned_int, 7, 3) + data.set_value([1, 2]) # Too few values raise TestException() except UserWarning as e: - expected_msg = "type_size_bits must be a multiples of 8 bits" non_blocking_assert( - str(e) == expected_msg, - f"wrong warning (expected: {expected_msg}, current {str(e)})", + "Length of array" in str(e), + f"wrong warning message for incorrect value length: {str(e)}", ) except Exception as e: if isinstance(e, TestException): - non_blocking_assert(False, "array allows for type_size_bits not multiple of 8") + non_blocking_assert(False, "array set_value allows for incorrect length") @test - def test_array_wrong_value_length(): - data = c_array(c_unsigned_int, 8, 3) + def test_array_iteration(): + data = c_array(c_unsigned_int(8), 3) + count = 0 + for item in data: + count += 1 + non_blocking_assert(count == 3, f"array iteration failed, expected 3 items, got {count}") + + +class ArrayOfStructTest: + + @test + def test_array_of_struct_creation(): + point = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + arr = c_array(point, 3) + + expected_size = point.size * 3 # 48 bits + non_blocking_assert(arr.size == expected_size, f"array-of-struct size not correct, expected {expected_size}, current {arr.size}") + non_blocking_assert(len(arr.value) == 3, f"array-of-struct length not correct, expected 3, current {len(arr.value)}") + + @test + def test_array_of_struct_format(): + point = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + arr = c_array(point, 2) + + expected_fmt = "u8u8u8u8" + non_blocking_assert(arr.fmt == expected_fmt, f"array-of-struct format not correct, expected {expected_fmt}, current {arr.fmt}") + + @test + def test_array_of_struct_elements_are_independent(): + """Deep copies must be independent; modifying one must not affect others.""" + point = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + arr = c_array(point, 2) + + arr[0].set_data(x=1, y=2) + arr[1].set_data(x=3, y=4) + + non_blocking_assert(arr[0]["x"] == 1, f"arr[0].x expected 1, got {arr[0]['x']}") + non_blocking_assert(arr[0]["y"] == 2, f"arr[0].y expected 2, got {arr[0]['y']}") + non_blocking_assert(arr[1]["x"] == 3, f"arr[1].x expected 3, got {arr[1]['x']}") + non_blocking_assert(arr[1]["y"] == 4, f"arr[1].y expected 4, got {arr[1]['y']}") + + @test + def test_array_of_struct_set_value(): + point = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + arr = c_array(point, 3) + + arr.set_value([{"x": 10, "y": 20}, {"x": 30, "y": 40}, {"x": 50, "y": 60}]) + + non_blocking_assert(arr[0]["x"] == 10, f"arr[0].x expected 10, got {arr[0]['x']}") + non_blocking_assert(arr[1]["y"] == 40, f"arr[1].y expected 40, got {arr[1]['y']}") + non_blocking_assert(arr[2]["x"] == 50, f"arr[2].x expected 50, got {arr[2]['x']}") + + @test + def test_array_of_struct_wrong_length(): + point = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + arr = c_array(point, 3) try: - data.set_value([1, 2]) # Too few values + arr.set_value([{"x": 1, "y": 2}]) # too few raise TestException() except UserWarning as e: non_blocking_assert( "Length of array" in str(e), - f"wrong warning message for incorrect value length: {str(e)}", + f"wrong warning for incorrect value length: {str(e)}", ) except Exception as e: if isinstance(e, TestException): - non_blocking_assert(False, "array set_value allows for incorrect length") + non_blocking_assert(False, "array-of-struct set_value allows for incorrect length") @test - def test_array_iteration(): - data = c_array(c_unsigned_int, 8, 3) + def test_struct_with_array_of_struct_pack_unpack(): + """A Struct containing a c_array of Structs can be packed and unpacked.""" + point = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + outer = Struct({"points": c_array(point, 2), "count": c_unsigned_int(8)}) + + outer["points"][0].set_data(x=7, y=14) + outer["points"][1].set_data(x=21, y=28) + outer.set_data(count=2) + + packed = outer.pack() + non_blocking_assert(isinstance(packed, bytes), f"pack should return bytes, got {type(packed)}") + non_blocking_assert(len(packed) == (8 + 8 + 8 + 8 + 8) // 8, f"packed length unexpected: {len(packed)}") + + # Rebuild an identical empty struct and unpack into it + point2 = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + outer2 = Struct({"points": c_array(point2, 2), "count": c_unsigned_int(8)}) + outer2.unpack(packed) + + non_blocking_assert(outer2["points"][0]["x"] == 7, f"unpack x[0] expected 7, got {outer2['points'][0]['x']}") + non_blocking_assert(outer2["points"][0]["y"] == 14, f"unpack y[0] expected 14, got {outer2['points'][0]['y']}") + non_blocking_assert(outer2["points"][1]["x"] == 21, f"unpack x[1] expected 21, got {outer2['points'][1]['x']}") + non_blocking_assert(outer2["points"][1]["y"] == 28, f"unpack y[1] expected 28, got {outer2['points'][1]['y']}") + non_blocking_assert(outer2["count"] == 2, f"unpack count expected 2, got {outer2['count']}") + + @test + def test_array_of_struct_iteration(): + point = Struct({"x": c_unsigned_int(8), "y": c_unsigned_int(8)}) + arr = c_array(point, 4) count = 0 - for item in data: + for element in arr: + non_blocking_assert(isinstance(element, Struct), f"element should be a Struct, got {type(element)}") count += 1 - non_blocking_assert(count == 3, f"array iteration failed, expected 3 items, got {count}") + non_blocking_assert(count == 4, f"expected 4 elements, got {count}") class StructTest: