diff --git a/README.md b/README.md index 24dd40d..45a25b0 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,6 @@ struct MyStruct { ``` ```python -@struct_dataclass class MyStruct(StructDataclass): myNum: int16_t myLetter: char_t @@ -61,7 +60,6 @@ struct MyStruct { }; ``` ```python -@struct_dataclass class MyStruct(StructDataclass): myInts: Annotated[list[uint8_t], TypeMeta(size=4)] myBiggerInts: Annotated[list[uint16_t], TypeMeta(size=2)] @@ -87,7 +85,6 @@ struct MyStruct { ``` ```python -@struct_dataclass class MyStruct(StructDataclass): myInt: uint8_t = 5 myInts: Annnotated[list[uint8_t], TypeMeta(size=2, default=1)] @@ -117,7 +114,6 @@ struct MyStruct { }; ``` ```python -@struct_dataclass class MyStruct(StructDataclass): myStr: Annotated[string_t, TypeMeta[str](chunk_size=3)] myStrList: Annotated[list[string_t], TypeMeta[str](size=2, chunk_size=3)] @@ -147,8 +143,9 @@ enum ConfigFlags { ``` ```python -@bits(uint8_t, {"lights_flag": 0, "platform_flag": 1}) -class FlagsType(BitsType): ... +class FlagsType(BitsType): + __bits_type__ = uint8_t + __bits_definition__ = {"lights_flag": 0, "platform_flag": 1} f = FlagsType() f.decode([3]) @@ -178,7 +175,6 @@ struct MyStruct { ``` ```python -@struct_dataclass class EnabledSensors(StructDataclass): # We can define the actual data we are ingesting here # This mirrors the `uint8_t enabledSensors[5]` data @@ -264,15 +260,13 @@ struct LEDS { ``` ```python -@struct_dataclass class RGB(StructDataclass): r: uint8_t g: uint8_t b: uint8_t -@struct_dataclass class LEDS(StructDataclass): - lights: Annotated[list[RGB], TypeMeta(size=3])] + lights: Annotated[list[RGB], TypeMeta(size=3)] l = LEDS() l.decode([1, 2, 3, 4, 5, 6, 7, 8, 9]) @@ -288,4 +282,4 @@ l.decode([1, 2, 3, 4, 5, 6, 7, 8, 9]) # Examples -You can see a more fully fledged example in the `test/examples.py` file. \ No newline at end of file +You can see a more fully fledged example in the `test/examples.py` file. diff --git a/src/pystructtype/__init__.py b/src/pystructtype/__init__.py index 1a7a340..0996d05 100644 --- a/src/pystructtype/__init__.py +++ b/src/pystructtype/__init__.py @@ -1,5 +1,5 @@ -from pystructtype.bitstype import BitsType, bits -from pystructtype.structdataclass import StructDataclass, struct_dataclass +from pystructtype.bitstype import BitsType +from pystructtype.structdataclass import StructDataclass from pystructtype.structtypes import ( TypeInfo, TypeMeta, @@ -22,7 +22,6 @@ "StructDataclass", "TypeInfo", "TypeMeta", - "bits", "char_t", "double_t", "float_t", @@ -31,7 +30,6 @@ "int32_t", "int64_t", "string_t", - "struct_dataclass", "uint8_t", "uint16_t", "uint32_t", diff --git a/src/pystructtype/bitstype.py b/src/pystructtype/bitstype.py index 1e26bb0..79181f1 100644 --- a/src/pystructtype/bitstype.py +++ b/src/pystructtype/bitstype.py @@ -1,61 +1,79 @@ import itertools -from collections.abc import Callable +from collections.abc import Mapping from dataclasses import field -from typing import Annotated, Any +from types import MappingProxyType +from typing import Annotated, ClassVar -from pystructtype.structdataclass import StructDataclass, struct_dataclass +from pystructtype.structdataclass import StructDataclass from pystructtype.structtypes import TypeMeta from pystructtype.utils import int_to_bool_list class BitsType(StructDataclass): """ - Class to auto-magically decode/encode struct data into separate variables - for separate bits based on the given definition + Base class for bitfield structs. Subclasses must define __bits_type__ and __bits_definition__. """ - _raw: Any + __bits_type__: ClassVar[type] + __bits_definition__: ClassVar[dict[str, int | list[int]] | Mapping[str, int | list[int]]] + + _raw: int _meta: dict[str, int | list[int]] - _meta_tuple: tuple[tuple[str, ...], tuple[int | list[int], ...]] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + # Check for required attributes + if not hasattr(cls, "__bits_type__") or not hasattr(cls, "__bits_definition__"): + raise TypeError( + "Subclasses of BitsType must define __bits_type__ and __bits_definition__ class attributes." + ) + bits_type = cls.__bits_type__ + definition = cls.__bits_definition__ + + # Automatically wrap in MappingProxyType if it's a dict and not already immutable + if isinstance(definition, dict) and not isinstance(definition, MappingProxyType): + definition = MappingProxyType(definition) + cls.__bits_definition__ = definition + + # # Remove __bits_type__ and __bits_definition__ from __annotations__ if present + # cls.__annotations__.pop("__bits_type__", None) + # cls.__annotations__.pop("__bits_definition__", None) + + # Set the correct type for the raw data + cls._raw = 0 + cls.__annotations__["_raw"] = bits_type + + cls._meta = field(default_factory=dict) + + # Create the defined attributes, defaults, and annotations in the class + for key, value in definition.items(): + if isinstance(value, list): + setattr( + cls, + key, + field(default_factory=lambda v=len(value): [False for _ in range(v)]), # type: ignore + ) + cls.__annotations__[key] = Annotated[list[bool], TypeMeta(size=len(value))] + else: + setattr(cls, key, False) + cls.__annotations__[key] = bool def __post_init__(self) -> None: super().__post_init__() - - # Convert the _meta_tuple data into a dictionary and put it into _meta - self._meta = dict(zip(*self._meta_tuple, strict=False)) + self._meta = dict(self.__bits_definition__) def _decode(self, data: list[int]) -> None: - """ - Internal decoding function - - :param data: A list of ints to decode - """ - # First call the super function to put the values in to _raw super()._decode(data) - - # Combine all data in _raw as binary and convert to bools bin_data = int_to_bool_list(self._raw, self._byte_length) - - # Apply bits to the defined structure for k, v in self._meta.items(): if isinstance(v, list): - steps = [] - for idx in v: - steps.append(bin_data[idx]) + steps = [bin_data[idx] for idx in v] setattr(self, k, steps) else: setattr(self, k, bin_data[v]) def _encode(self) -> list[int]: - """ - Internal encoding function - - :returns: A list of encoded ints - """ - # Fill a correctly sized variable with all False/0 bits bin_data = list(itertools.repeat(False, self._byte_length * 8)) - - # Assign the correct values from the defined attributes into bin_data for k, v in self._meta.items(): if isinstance(v, list): steps = getattr(self, k) @@ -63,76 +81,6 @@ def _encode(self) -> list[int]: bin_data[bit_idx] = steps[idx] else: bin_data[v] = getattr(self, k) - - # Convert bin_data back into their correct integer locations self._raw = sum(int(v) << i for i, v in enumerate(bin_data)) - - # Run the super function to return the data in self._raw() + # Return _raw as a list of bytes (little-endian) return super()._encode() - - -def bits(_type: Any, definition: dict[str, int | list[int]]) -> Callable[[type[BitsType]], type[StructDataclass]]: - """ - Decorator that does a bunch of metaprogramming magic to properly set up the - defined Subclass of StructDataclass for Bits handling - - The definition must be a dict of ints or a list of ints. The int values denote the position of the bits. - - Example: - @bits(uint8_t, {"a": 0, "b": [1, 2, 4], "c": 3}) - class MyBits(BitsType): ... - - For an uint8_t defined as 0b01010101, the resulting class will be: - MyBits(a=1, b=[0, 1, 1], c=0) - - :param _type: The type of data that the bits are stored in (ex. uint8_t, etc.) - :param definition: The bits definition that defines attributes and bit locations - :return: A Callable that performs the metaprogramming magic and returns the modified StructDataclass - """ - - def inner(_cls: type[BitsType]) -> type[StructDataclass]: - """ - The inner function to modify a StructDataclass into a BitsType class - - :param _cls: A Subclass of BitsType - :return: Modified StructDataclass - """ - # Create class attributes based on the definition - # TODO: Maybe a sanity check to make sure the definition is the right format, and no overlapping bits, etc - - new_cls = _cls - - # Set the correct type for the raw data - new_cls.__annotations__["_raw"] = _type - - # Override the annotations for the _meta attribute, and set a default - # TODO: This probably isn't really needed unless we end up changing the int value to bool or something - new_cls._meta = field(default_factory=dict) - new_cls.__annotations__["_meta"] = dict[str, int] - - # Convert the definition to a named tuple, so it's Immutable - meta_tuple = (tuple(definition.keys()), tuple(definition.values())) - new_cls._meta_tuple = field(default_factory=lambda d=meta_tuple: d) # type: ignore - new_cls.__annotations__["_meta_tuple"] = tuple - - # TODO: Support int, or list of ints as defaults - # TODO: Support dict, and dict of lists, or list of dicts, etc for definition - # TODO: ex. definition = {"a": {"b": 0, "c": [1, 2, 3]}, "d": [4, 5, 6], "e": {"f": 7}} - # TODO: Can't decide if the line above this is a good idea or not - # Create the defined attributes, defaults, and annotations in the class - for key, value in definition.items(): - if isinstance(value, list): - # Use Annotated with TypeMeta(size=len=value) for list fields - setattr( - new_cls, - key, - field(default_factory=lambda v=len(value): [False for _ in range(v)]), # type: ignore - ) - new_cls.__annotations__[key] = Annotated[list[bool], TypeMeta(size=len(value))] - else: - setattr(new_cls, key, False) - new_cls.__annotations__[key] = bool - - return struct_dataclass(new_cls) - - return inner diff --git a/src/pystructtype/structdataclass.py b/src/pystructtype/structdataclass.py index cb97de6..6062c70 100644 --- a/src/pystructtype/structdataclass.py +++ b/src/pystructtype/structdataclass.py @@ -1,10 +1,9 @@ import inspect import re import struct -from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass, field, is_dataclass -from typing import TypeVar, overload +from typing import ClassVar from pystructtype.structtypes import iterate_types @@ -28,6 +27,87 @@ class StructDataclass: subclass. """ + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + # If the class is already a dataclass, skip + if is_dataclass(cls): + return + # Make sure any fields without a default have one + for type_iterator in iterate_types(cls): + if type_iterator.key.startswith("__"): + # Ignore double underscore vars + continue + + if not type_iterator.is_pystructtype and not inspect.isclass(type_iterator.base_type): + continue + if not type_iterator.type_meta or type_iterator.type_meta.size == 1: + if type_iterator.is_list: + raise ValueError(f"Attribute {type_iterator.key} is defined as a list type but has size set to 1") + if not getattr(cls, type_iterator.key, None): + default = type_iterator.base_type + if type_iterator.type_meta: + if type_iterator.type_meta.default is not None: + default = type_iterator.type_meta.default + if isinstance(default, list): + raise TypeError(f"default value for {type_iterator.key} attribute can not be a list") + if inspect.isclass(default): + default = field(default_factory=default) + setattr(cls, type_iterator.key, default) + continue + if inspect.isclass(default): + default = field(default_factory=default) + else: + default = field(default_factory=lambda d=default: deepcopy(d)) # type: ignore + setattr(cls, type_iterator.key, default) + else: + if not type_iterator.is_list: + raise ValueError(f"Attribute {type_iterator.key} is not a list type but has a size > 1") + if type_iterator.type_meta and type_iterator.type_meta.default: + default = type_iterator.type_meta.default + if isinstance(default, list): + default_tuple = tuple(deepcopy(default)) + default_list = field(default_factory=lambda d=default_tuple: list(d)) # type: ignore + elif inspect.isclass(default): + default_list = field( + default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore + d() for _ in range(s) + ] + ) + else: + default_list = field( + default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore + deepcopy(d) for _ in range(s) + ] + ) + else: + default = type_iterator.base_type + if inspect.isclass(default): + default_list = field( + default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore + d() for _ in range(s) + ] + ) + else: + default_list = field( + default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore + deepcopy(d) for _ in range(s) + ] + ) + setattr(cls, type_iterator.key, default_list) + # Remove ClassVar-annotated keys from __annotations__ and class dict before dataclass(cls) + classvar_keys = [k for k, v in list(cls.__annotations__.items()) if getattr(v, "__origin__", None) is ClassVar] + # Save and remove from class dict + classvar_backup = {} + for k in classvar_keys: + cls.__annotations__.pop(k, None) + if hasattr(cls, k): + classvar_backup[k] = getattr(cls, k) + delattr(cls, k) + dataclass(cls) + # Restore classvars + for k, v in classvar_backup.items(): + setattr(cls, k, v) + def __post_init__(self) -> None: self._state: list[StructState] = [] @@ -237,149 +317,3 @@ def encode(self, little_endian: bool = False) -> bytes: """ result = self._encode() return struct.pack(self._endian(little_endian) + self.struct_fmt, *result) - - -D = TypeVar("D", bound=StructDataclass) -"""Generic Data Type bound to StructDataclass""" - - -@overload -def struct_dataclass[D](_cls: type[D]) -> type[D]: - """ - Overload for using a bare decorator - - @struct_dataclass - class foo(StructDataclass): ... - - Equivalent to: struct_dataclass(foo) - - :param _cls: Subtype of StructDataclass - :return: Modified Subtype of StructDataclass - """ - pass # pragma: no cover - - -@overload -def struct_dataclass[D](_cls: None) -> Callable[[type[D]], type[D]]: - """ - Overload for using called decorator - - @struct_dataclass() - class foo(StructDataclass): ... - - Equivalent to: struct_dataclass()(foo) - - :param _cls: None - :return: Callable that takes in a Subtype of StructDataclass and returns a modified Subtype - """ - pass # pragma: no cover - - -def struct_dataclass[D]( - _cls: type[D] | None = None, -) -> Callable[[type[D]], type[D]] | type[D]: - """ - Decorator that does a bunch of metaprogramming magic to properly set up - the defined Subclass of a StructDataclass - - :param _cls: A Subclass of StructDataclass or None - :return: A Modified Subclass of a StructDataclass or a Callable that performs the same actions - """ - - def inner(_cls: type[D]) -> type[D]: - """ - The inner function for `struct_dataclass` that actually does all the work - - :param _cls: A Subclass of StructDataclass - :return: A Modified Subclass of a StructDataclass - """ - new_cls = _cls - - # new_cls should not already be a dataclass, - # but it will be a subtype of Dataclass by the end of this function - if is_dataclass(new_cls): - # Just try to cast it again, and return - return new_cls - - # Make sure any fields without a default have one - # This prevents Dataclass from being mad that we might have attributes defined with - # defaults interwoven between ones that don't - for type_iterator in iterate_types(new_cls): - # If the current type is just a base type, then we can essentially ignore it - # These are typically used for extra processing and not included in the decode/encode - if not type_iterator.is_pystructtype and not inspect.isclass(type_iterator.base_type): - continue - - if not type_iterator.type_meta or type_iterator.type_meta.size == 1: - # This type either has no metadata, or is defined as having a size of 1 and is - # therefore not a list - if type_iterator.is_list: - raise ValueError(f"Attribute {type_iterator.key} is defined as a list type but has size set to 1") - - # Set a default if it does not yet exist - if not getattr(new_cls, type_iterator.key, None): - default: type | int | float | bytes = type_iterator.base_type - if type_iterator.type_meta: - if type_iterator.type_meta.default is not None: - default = type_iterator.type_meta.default - if isinstance(default, list): - raise TypeError(f"default value for {type_iterator.key} attribute can not be a list") - if inspect.isclass(default): - default = field(default_factory=default) - setattr(new_cls, type_iterator.key, default) - continue - # Create a new instance of the class, or value - if inspect.isclass(default): - default = field(default_factory=default) - else: - default = field(default_factory=lambda d=default: deepcopy(d)) # type: ignore - setattr(new_cls, type_iterator.key, default) - else: - # This assumes we want multiple items of base_type, so make sure the given base_type is - # properly set to be a list as well - if not type_iterator.is_list: - raise ValueError(f"Attribute {type_iterator.key} is not a list type but has a size > 1") - - if type_iterator.type_meta and type_iterator.type_meta.default: - default = type_iterator.type_meta.default - if isinstance(default, list): - # Avoid mutable default in lambda by using tuple and converting back to list - default_tuple = tuple(deepcopy(default)) - default_list = field(default_factory=lambda d=default_tuple: list(d)) - elif inspect.isclass(default): - default_list = field( - default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore - d() for _ in range(s) - ] - ) - else: - default_list = field( - default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore - deepcopy(d) for _ in range(s) - ] - ) - else: - default = type_iterator.base_type - if inspect.isclass(default): - default_list = field( - default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore - d() for _ in range(s) - ] - ) - else: - default_list = field( - default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore - deepcopy(d) for _ in range(s) - ] - ) - - setattr(new_cls, type_iterator.key, default_list) - return dataclass(new_cls) - - # If we use the decorator with empty parens, we simply return the inner callable - if _cls is None: - return inner - - # If we use the decorator with no parens, we return the result of passing the _cls - # to the inner callable - return inner(_cls) diff --git a/src/pystructtype/structtypes.py b/src/pystructtype/structtypes.py index 389c496..07d80d0 100644 --- a/src/pystructtype/structtypes.py +++ b/src/pystructtype/structtypes.py @@ -153,7 +153,8 @@ def iterate_types(cls: type) -> Generator[TypeIterator]: # Determine if the type is a list # ex. list[bool] (yes) vs bool (no) - is_list = issubclass(origin, list) if (origin := get_origin(base_type)) else False + origin = get_origin(base_type) + is_list = issubclass(origin, list) if isinstance(origin, type) else False # Grab the first args value and look for any TypeMeta objects within type_args = get_args(hint) diff --git a/test/examples.py b/test/examples.py index e1ce21e..9caad7b 100644 --- a/test/examples.py +++ b/test/examples.py @@ -1,9 +1,9 @@ import itertools from dataclasses import field from enum import IntEnum -from typing import Annotated +from typing import Annotated, ClassVar -from pystructtype import BitsType, StructDataclass, TypeMeta, bits, struct_dataclass, uint8_t, uint16_t +from pystructtype import BitsType, StructDataclass, TypeMeta, uint8_t, uint16_t from pystructtype.utils import list_chunks TEST_CONFIG_DATA = [ @@ -299,14 +299,16 @@ class Sensor(IntEnum): DOWN = 3 -@bits(uint8_t, {"autolights": 0, "fsr": 1}) class FlagsType(BitsType): + __bits_type__: ClassVar = uint8_t + __bits_definition__: ClassVar = {"autolights": 0, "fsr": 1} autolights: bool fsr: bool -@bits(uint16_t, {"steps": [0, 1, 2, 3, 4, 5, 6, 7, 8]}) class PanelMaskType(BitsType): + __bits_type__: ClassVar = uint16_t + __bits_definition__: ClassVar = {"steps": [0, 1, 2, 3, 4, 5, 6, 7, 8]} steps: list[bool] def __getitem__(self, index: int) -> bool: @@ -321,7 +323,6 @@ def __setitem__(self, index: int, value: bool) -> None: self.steps[index] = value -@struct_dataclass class EnabledSensors(StructDataclass): # We can define the actual data we are ingesting here _raw: Annotated[list[uint8_t], TypeMeta(size=5)] @@ -368,7 +369,6 @@ def __setitem__(self, index: int, value: list[bool]) -> None: self._data[index] = value -@struct_dataclass class PackedPanelSettingsType(StructDataclass): load_cell_low_threshold: uint8_t load_cell_high_threshold: uint8_t @@ -382,14 +382,12 @@ class PackedPanelSettingsType(StructDataclass): reserved: uint16_t -@struct_dataclass class RGBType(StructDataclass): r: uint8_t g: uint8_t b: uint8_t -@struct_dataclass class SMXConfigType(StructDataclass): master_version: uint8_t = 0xFF diff --git a/test/test_bitstype.py b/test/test_bitstype.py index cf640c0..81b1bc2 100644 --- a/test/test_bitstype.py +++ b/test/test_bitstype.py @@ -1,24 +1,29 @@ -from pystructtype import BitsType, bits, uint8_t, uint16_t +from typing import ClassVar +import pytest -# Use a list of length > 1 for 'b', and ensure the bits decorator works with the current structdataclass logic -@bits(uint8_t, {"a": 0, "b": [1, 2], "c": 3}) +from pystructtype import BitsType, uint8_t, uint16_t + + +# Use a list of length > 1 for 'b', and ensure the bits logic works with the current structdataclass logic class MyBits(BitsType): + __bits_type__: ClassVar = uint8_t + __bits_definition__: ClassVar = {"a": 0, "b": [1, 2], "c": 3} a: bool b: list[bool] c: bool -@bits(uint16_t, {"x": list(range(16))}) class MyBits16(BitsType): + __bits_type__: ClassVar = uint16_t + __bits_definition__: ClassVar = {"x": list(range(16))} x: list[bool] def test_bits_decode_encode_bool_fields(): # 0b00001101 = 13 b = MyBits() - b._raw = 13 - b._decode([13]) + b.decode([13]) assert b.a assert not b.b[0] assert b.b[1] @@ -29,7 +34,7 @@ def test_bits_decode_encode_bool_fields(): b.c = False encoded = b._encode() b2 = MyBits() - b2._decode(encoded) + b2.decode(encoded) assert not b2.a assert b2.b[0] assert not b2.b[1] @@ -38,44 +43,50 @@ def test_bits_decode_encode_bool_fields(): def test_bits_decode_encode_list(): b = MyBits16() - b._raw = 0b1010101010101010 - b._decode([0b10101010, 0b10101010]) - # Print actual value for debugging - print("Decoded b.x:", b.x) + b.decode([0b10101010, 0b10101010]) + # Accept any bit order, just check length and type assert isinstance(b.x, list) assert len(b.x) == 16 + # Now encode and check round-trip b.x = [True, False] * 8 - encoded = b._encode() + encoded = b.encode() + b2 = MyBits16() - b2._decode(encoded) + b2.decode(encoded) assert b2.x == [True, False] * 8 def test_bits_edge_cases(): # All bits set b = MyBits() - b._raw = 0xFF - b._decode([0xFF]) + b.decode([0xFF]) assert b.a assert all(b.b) - assert b.c or not b.c # c is bit 3, could be True or False + assert b.c + # All bits clear b._raw = 0 - b._decode([0]) + b.decode([0]) assert not b.a + # noinspection PyTypeChecker assert all(not x for x in b.b) assert not b.c -def test_bits_type_meta_and_tuple(): +def test_bits_type_meta(): b = MyBits() - # _meta and _meta_tuple should be set up + # _meta and _meta should be set up assert isinstance(b._meta, dict) - assert isinstance(b._meta_tuple, tuple) # Should match the definition assert set(b._meta.keys()) == {"a", "b", "c"} assert b._meta["a"] == 0 assert b._meta["b"] == [1, 2] assert b._meta["c"] == 3 + + +def test_type_error_missing_attributes() -> None: + with pytest.raises(TypeError): + # noinspection PyUnusedLocal + class X(BitsType): ... diff --git a/test/test_examples.py b/test/test_examples.py new file mode 100644 index 0000000..b8837a7 --- /dev/null +++ b/test/test_examples.py @@ -0,0 +1,9 @@ +from test.examples import TEST_CONFIG_DATA, SMXConfigType + + +def test_example_builds() -> None: + s = SMXConfigType() + s.decode(TEST_CONFIG_DATA, little_endian=True) + + assert s + assert s._to_list(s.encode(little_endian=True)) == TEST_CONFIG_DATA diff --git a/test/test_structdataclass_additional.py b/test/test_structdataclass_additional.py index 9a36cb2..6eb1886 100644 --- a/test/test_structdataclass_additional.py +++ b/test/test_structdataclass_additional.py @@ -3,12 +3,11 @@ import pytest -from pystructtype import StructDataclass, TypeMeta, int8_t, struct_dataclass, uint8_t +from pystructtype import StructDataclass, TypeMeta, int8_t, uint8_t # Test _simplify_format for various struct formats def test_simplify_format_merges_repeats() -> None: - @struct_dataclass class S(StructDataclass): a: uint8_t b: Annotated[list[uint8_t], TypeMeta(size=4)] @@ -59,7 +58,6 @@ def test_endian() -> None: # Test size method def test_size() -> None: - @struct_dataclass class S(StructDataclass): a: Annotated[list[uint8_t], TypeMeta(size=3)] @@ -69,12 +67,10 @@ class S(StructDataclass): # Test nested StructDataclass encoding/decoding def test_nested_structdataclass() -> None: - @struct_dataclass class Inner(StructDataclass): x: uint8_t y: uint8_t - @struct_dataclass class Outer(StructDataclass): inner: Inner z: uint8_t @@ -94,7 +90,6 @@ class Outer(StructDataclass): def test_decode_list_of_base_types() -> None: # Test _decode: attr is not a list, not a StructDataclass, state.size > 1 # This will hit the 'else' branch for a list of base types - @struct_dataclass class SList(StructDataclass): a: Annotated[list[uint8_t], TypeMeta(size=2)] @@ -106,7 +101,6 @@ class SList(StructDataclass): # Test error paths in _decode and _encode def test_decode_encode_errors() -> None: - @struct_dataclass class S(StructDataclass): a: Annotated[list[uint8_t], TypeMeta(size=2)] @@ -122,20 +116,6 @@ class S(StructDataclass): s._encode() -# Test struct_dataclass decorator with and without parens -def test_struct_dataclass_decorator_variants() -> None: - @struct_dataclass - class S1(StructDataclass): - a: uint8_t - - @struct_dataclass # Remove parens to match the overload signature - class S2(StructDataclass): - a: uint8_t - - assert is_dataclass(S1) - assert is_dataclass(S2) - - # Test struct_dataclass: already a dataclass def test_struct_dataclass_already_dataclass() -> None: from dataclasses import dataclass as dc @@ -144,13 +124,11 @@ def test_struct_dataclass_already_dataclass() -> None: class S(StructDataclass): a: uint8_t - result = struct_dataclass(S) - assert is_dataclass(result) + assert is_dataclass(S) # Test default value logic in struct_dataclass def test_struct_dataclass_default_value() -> None: - @struct_dataclass class S(StructDataclass): a: uint8_t @@ -161,8 +139,7 @@ class S(StructDataclass): # Test exception for list type with size=1 def test_struct_dataclass_list_type_size_one() -> None: with pytest.raises(ValueError): - - @struct_dataclass + # noinspection PyUnusedLocal class S(StructDataclass): a: Annotated[list[uint8_t], TypeMeta(size=1)] # Should raise because list type with size=1 is not allowed @@ -171,8 +148,7 @@ class S(StructDataclass): # Test exception for non-list type with size>1 def test_struct_dataclass_nonlist_type_size_gt_one() -> None: with pytest.raises(ValueError): - - @struct_dataclass + # noinspection PyUnusedLocal class S(StructDataclass): a: Annotated[uint8_t, TypeMeta(size=2)] # Should raise because non-list type with size>1 is not allowed @@ -181,8 +157,7 @@ class S(StructDataclass): # Test exception for default value as list def test_struct_dataclass_default_list_exception() -> None: with pytest.raises(TypeError): - - @struct_dataclass + # noinspection PyUnusedLocal class S(StructDataclass): a: Annotated[uint8_t, TypeMeta(default=[1, 2])] # Should raise because default value for attribute cannot be a list @@ -194,7 +169,6 @@ class Dummy: def __init__(self) -> None: self.x = 1 - @struct_dataclass class S(StructDataclass): a: Dummy @@ -205,7 +179,6 @@ class S(StructDataclass): # Test struct_dataclass: default is a value def test_struct_dataclass_default_value_field() -> None: - @struct_dataclass class S(StructDataclass): a: Annotated[uint8_t, TypeMeta(default=5)] @@ -215,7 +188,6 @@ class S(StructDataclass): # Test struct_dataclass: default for list of values def test_struct_dataclass_default_list_of_values() -> None: - @struct_dataclass class S(StructDataclass): a: Annotated[list[uint8_t], TypeMeta(size=2, default=7)] @@ -229,7 +201,6 @@ class Dummy: def __init__(self) -> None: self.x = 1 - @struct_dataclass class S(StructDataclass): a: Annotated[list[Dummy], TypeMeta(size=2)] @@ -238,7 +209,6 @@ class S(StructDataclass): def test_regular_class_attribute_is_ignored() -> None: - @struct_dataclass class S(StructDataclass): a: int b: str @@ -256,7 +226,6 @@ class S(StructDataclass): def test_encode_decode_list_of_base_types() -> None: - @struct_dataclass class S(StructDataclass): a: Annotated[list[uint8_t], TypeMeta(size=3)] @@ -269,7 +238,6 @@ class S(StructDataclass): def test_structdataclass_regular_field_ignored() -> None: - @struct_dataclass class S(StructDataclass): a: int b: str @@ -282,7 +250,6 @@ class S(StructDataclass): # Covers: 259, 275 (non-pystructtype, non-class field is ignored) def test_structdataclass_non_pystructtype_non_class_field() -> None: - @struct_dataclass class S(StructDataclass): a: int @@ -294,8 +261,7 @@ class S(StructDataclass): # Covers: 311 (not type_iterator.is_list) def test_structdataclass_nonlist_type_with_size_gt_one() -> None: with pytest.raises(ValueError): - - @struct_dataclass + # noinspection PyUnusedLocal class S(StructDataclass): a: Annotated[int, TypeMeta(size=2)] @@ -303,8 +269,7 @@ class S(StructDataclass): # Covers: 328, 345-346, 348 (default is a list, default is a class) def test_structdataclass_default_list_typeerror() -> None: with pytest.raises(TypeError): - - @struct_dataclass + # noinspection PyUnusedLocal class S(StructDataclass): a: Annotated[int, TypeMeta(default=[1, 2])] @@ -315,7 +280,6 @@ def __init__(self) -> None: self.x = 1 # Use an instance as the default to trigger deepcopy path - @struct_dataclass class S(StructDataclass): a: Annotated[Dummy, TypeMeta(default=Dummy())] @@ -329,7 +293,6 @@ class Dummy: def __init__(self) -> None: self.x = 1 - @struct_dataclass class S(StructDataclass): a: Annotated[list[Dummy], TypeMeta(size=2)] @@ -338,11 +301,9 @@ class S(StructDataclass): def test_structdataclass_list_of_structdataclass_encode_decode() -> None: - @struct_dataclass class Inner(StructDataclass): x: uint8_t - @struct_dataclass class Outer(StructDataclass): inners: Annotated[list[Inner], TypeMeta(size=2)] @@ -360,24 +321,21 @@ class Outer(StructDataclass): def test_structdataclass_list_type_size_one_error() -> None: with pytest.raises(ValueError): - - @struct_dataclass + # noinspection PyUnusedLocal class S(StructDataclass): a: Annotated[list[uint8_t], TypeMeta(size=1)] def test_structdataclass_nonlist_type_size_gt_one_error() -> None: with pytest.raises(ValueError): - - @struct_dataclass + # noinspection PyUnusedLocal class S(StructDataclass): a: Annotated[uint8_t, TypeMeta(size=2)] def test_structdataclass_default_list_type_error() -> None: with pytest.raises(TypeError): - - @struct_dataclass + # noinspection PyUnusedLocal class S(StructDataclass): a: Annotated[uint8_t, TypeMeta(default=[1, 2])] @@ -386,15 +344,13 @@ def test_structdataclass_invalid_branches_all() -> None: # 259, 275: non-pystructtype, non-class field is ignored (already covered by regular field tests) # 311: not type_iterator.is_list, but size > 1 with pytest.raises(ValueError): - - @struct_dataclass + # noinspection PyUnusedLocal class S1(StructDataclass): a: Annotated[int, TypeMeta(size=2)] # 328, 345-346: default is a list with pytest.raises(TypeError): - - @struct_dataclass + # noinspection PyUnusedLocal class S2(StructDataclass): a: Annotated[int, TypeMeta(default=[1, 2])] @@ -403,7 +359,6 @@ class Dummy: def __init__(self) -> None: self.x = 1 - @struct_dataclass class S3(StructDataclass): a: Annotated[Dummy, TypeMeta(default=Dummy)] @@ -411,7 +366,6 @@ class S3(StructDataclass): assert isinstance(s3.a, Dummy) # Test for default is an instance (should use deepcopy) - @struct_dataclass class S3b(StructDataclass): a: Annotated[Dummy, TypeMeta(default=Dummy())] @@ -419,7 +373,6 @@ class S3b(StructDataclass): assert isinstance(s3b.a, Dummy) # 368, 379: default_list for list of classes - @struct_dataclass class S4(StructDataclass): a: Annotated[list[Dummy], TypeMeta(size=2)] @@ -427,15 +380,13 @@ class S4(StructDataclass): assert isinstance(s4.a, list) and all(isinstance(x, Dummy) for x in s4.a) # 311: list type with size == 1 with pytest.raises(ValueError): - - @struct_dataclass + # noinspection PyUnusedLocal class S5(StructDataclass): a: Annotated[list[int], TypeMeta(size=1)] def test_structdataclass_defensive_branches() -> None: # 259, 275: non-pystructtype, non-class field is ignored (should not raise, just skip) - @struct_dataclass class S1(StructDataclass): a: int # not Annotated, not a class, should be ignored @@ -445,15 +396,13 @@ class S1(StructDataclass): # 311: not type_iterator.is_list, but size > 1 (should raise ValueError) with pytest.raises(ValueError): - - @struct_dataclass + # noinspection PyUnusedLocal class S2(StructDataclass): a: Annotated[int, TypeMeta(size=2)] # 345-346: default is a list (should raise TypeError) with pytest.raises(TypeError): - - @struct_dataclass + # noinspection PyUnusedLocal class S3(StructDataclass): a: Annotated[int, TypeMeta(default=[1, 2])] @@ -462,7 +411,6 @@ class Dummy: def __init__(self) -> None: self.x = 1 - @struct_dataclass class S4(StructDataclass): a: Annotated[Dummy, TypeMeta(default=Dummy)] @@ -470,7 +418,6 @@ class S4(StructDataclass): assert isinstance(s4.a, Dummy) # 368, 379: default_list for list of classes (should use default_factory for list of Dummy) - @struct_dataclass class S5(StructDataclass): a: Annotated[list[Dummy], TypeMeta(size=2)] diff --git a/test/test_structtypes.py b/test/test_structtypes.py index e06645a..5f924d0 100644 --- a/test/test_structtypes.py +++ b/test/test_structtypes.py @@ -13,7 +13,6 @@ int32_t, int64_t, string_t, - struct_dataclass, uint8_t, uint16_t, uint32_t, @@ -23,99 +22,83 @@ def test_char_t() -> None: - @struct_dataclass - class MyStruct(StructDataclass): + class MyStructChar(StructDataclass): foo: char_t data = [ord(b"A")] - s = MyStruct() + s = MyStructChar() s.decode(data) - assert s.foo == b"A" - e = s.encode() assert s._to_list(e) == data def test_string_t() -> None: - @struct_dataclass - class MyStruct(StructDataclass): + class MyStructString(StructDataclass): foo: string_t bar: Annotated[string_t, TypeMeta[bytes](chunk_size=3)] - data = MyStruct._to_list(b"ABCD") - s = MyStruct() + data = MyStructString._to_list(b"ABCD") + s = MyStructString() s.decode(data) - assert s.foo == b"A" assert s.bar == b"BCD" - e = s.encode() assert s._to_list(e) == data def test_unsigned_int() -> None: - @struct_dataclass - class MyStruct(StructDataclass): + class MyStructUnsigned(StructDataclass): foo8: uint8_t foo16: uint16_t foo32: uint32_t foo64: uint64_t data = [254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254] - s = MyStruct() + s = MyStructUnsigned() s.decode(data) - assert s.foo8 == 254 - assert s.foo16 == 65_278 - assert s.foo32 == 4_278_124_286 - assert s.foo64 == 18_374_403_900_871_474_942 - + assert s.foo16 == 65278 + assert s.foo32 == 4278124286 + assert s.foo64 == 18374403900871474942 e = s.encode() assert s._to_list(e) == data def test_signed_int() -> None: - @struct_dataclass - class MyStruct(StructDataclass): + class MyStructSigned(StructDataclass): foo8: int8_t foo16: int16_t foo32: int32_t foo64: int64_t data = [254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254, 254] - s = MyStruct() + s = MyStructSigned() s.decode(data) - assert s.foo8 == -2 assert s.foo16 == -258 - assert s.foo32 == -16_843_010 - assert s.foo64 == -72_340_172_838_076_674 - + assert s.foo32 == -16843010 + assert s.foo64 == -72340172838076674 e = s.encode() assert s._to_list(e) == data def test_floating_points() -> None: - @struct_dataclass - class MyStruct(StructDataclass): + class MyStructFloat(StructDataclass): foo: float_t bar: double_t data = [68, 154, 82, 43, 65, 157, 111, 52, 87, 243, 91, 168] - s = MyStruct() + s = MyStructFloat() s.decode(data) - assert s.foo == 1234.5677490234375 assert s.bar == 123456789.987654321 - e = s.encode() assert s._to_list(e) == data def test_basic_type_lists() -> None: - @struct_dataclass - class MyStruct(StructDataclass): + class MyStructList(StructDataclass): int_type: Annotated[list[uint8_t], TypeMeta[int](size=2)] float_type: Annotated[list[float_t], TypeMeta[float](size=2)] char_type: Annotated[list[char_t], TypeMeta[bytes](size=2)] @@ -123,13 +106,11 @@ class MyStruct(StructDataclass): int_data = [1, 2] float_data = [68, 154, 82, 43, 67, 153, 81, 42] - char_data = MyStruct._to_list(b"AB") - string_data = MyStruct._to_list(b"ABCD") - + char_data = MyStructList._to_list(b"AB") + string_data = MyStructList._to_list(b"ABCD") data = int_data + float_data + char_data + string_data - s = MyStruct() + s = MyStructList() s.decode(data) - assert s.int_type == [1, 2] assert s.float_type == [1234.5677490234375, 306.63409423828125] assert s.char_type == [b"A", b"B"] @@ -148,7 +129,6 @@ def test_nested_annotation(self) -> None: def test_iterate_types() -> None: - @struct_dataclass class MyStruct(StructDataclass): foo: Annotated[list[uint8_t], TypeMeta[int](size=2)]