From 0e44fc49cd81381a815a498d854616870a5612da Mon Sep 17 00:00:00 2001 From: Fernando Chorney Date: Thu, 19 Mar 2026 19:35:56 -0500 Subject: [PATCH] bool_t type. Reach 100% coverage --- .github/copilot-instructions.md | 50 +++++++++++++++++++++ docs/conf.py | 4 ++ src/pystructtype/__init__.py | 6 +++ src/pystructtype/bitstype.py | 30 ++++++++++--- src/pystructtype/structdataclass.py | 59 +++++++++++-------------- src/pystructtype/structtypes.py | 30 ++++++++----- src/pystructtype/utils.py | 11 ++++- test/__init__.py | 3 ++ test/examples.py | 4 ++ test/test_bitstype.py | 4 ++ test/test_examples.py | 4 ++ test/test_structdataclass_additional.py | 42 ++++++++++++++++++ test/test_structtypes.py | 42 ++++++++++++++++++ test/test_utils.py | 16 +++++++ uv.lock | 2 +- 15 files changed, 253 insertions(+), 54 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..f87d9eb --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +# Python 3.14 Best Practices and Copilot Instructions + +## Best Practices +- Use meaningful, description names for variables, functions, and classes. +- Follow the assigned Ruff configuration for code style and formatting. This can be found in the pyproject.toml file. +- Always use type hints in code and tests following the Mypy strict configuration. +- Document functions and classes with docstrings. Update existing docstrings if needed. +- Do not delete any existing comments. If necessary, update comments to reflect changes in the code, but do not remove them unless they are completely irrelevant or incorrect. +- Write simple, clear code; avoid unnecessary complexity when possible. +- Prefer list comprehensions and generator expressions for concise and efficient code. +- Prefer the walrus operator (:=) for inline assignments when it improves readability. +- Write unit tests for all functions and classes, ensuring good coverage and testing edge cases. +- Avoid global variables to reduce side effects. +- When possible, always apply DRY, KISS, and generally agreed upon Python best practices. + +## Stack & Tools +- Python 3.14 +- Dependency management: uv +- Linting: Ruff +- Type checking: Mypy +- Testing: pytest +- Documentation: Sphinx +- Packaging: pyproject.toml + +## Project Structure +- Use subfolders in the ./src directory to organize code by functionality or feature. + +## Error Handling & Logging +- Implement robust error handling and logging, including context capture. +- Use structured logging with appropriate log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL). +- Avoid try/catch blocks that are too broad; catch specific exceptions and handle them appropriately. +- Use early returns for error conditions; avoid deep nesting. +- Avoid unnecessary else statements; use the if-return pattern. + +## Testing +- Use pytest (not unittest) and pytest plugins for all tests. +- Place all tests in ./tests directory, mirroring the structure of the source code. +- All tests must have typing annotations and docstrings. +- For type checking in tests, import the following types individually if needed: +```python +from _pytest.capture import CaptureFixture +from _pytest.fixtures import FixtureRequest +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +``` + +## Dependencies +- This project is a library, so it should not have any dependencies if at all possible. +- If dependencies are necessary, they should be minimal and well-maintained. Always prefer standard library modules when possible. +- Use uv for dependency management and ensure that all dependencies are listed in pyproject.toml. \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index b691486..ee8fb70 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,7 @@ +""" +Sphinx configuration for pystructtype documentation. +""" + import tomllib from datetime import datetime diff --git a/src/pystructtype/__init__.py b/src/pystructtype/__init__.py index 0996d05..4780859 100644 --- a/src/pystructtype/__init__.py +++ b/src/pystructtype/__init__.py @@ -1,8 +1,13 @@ +""" +pystructtype: Public API for pystructtype package. +""" + from pystructtype.bitstype import BitsType from pystructtype.structdataclass import StructDataclass from pystructtype.structtypes import ( TypeInfo, TypeMeta, + bool_t, char_t, double_t, float_t, @@ -22,6 +27,7 @@ "StructDataclass", "TypeInfo", "TypeMeta", + "bool_t", "char_t", "double_t", "float_t", diff --git a/src/pystructtype/bitstype.py b/src/pystructtype/bitstype.py index 79181f1..d50f977 100644 --- a/src/pystructtype/bitstype.py +++ b/src/pystructtype/bitstype.py @@ -1,3 +1,7 @@ +""" +BitsType: Base class for bitfield structs. +""" + import itertools from collections.abc import Mapping from dataclasses import field @@ -17,10 +21,15 @@ class BitsType(StructDataclass): __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]] + _raw: int # Holds the raw integer value for the bitfield. + _meta: dict[str, int | list[int]] # Metadata mapping attribute names to bit positions. - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls: type[BitsType], **kwargs: object) -> None: + """ + Initialize subclass by setting up bitfield attributes and type annotations. + Ensures __bits_type__ and __bits_definition__ are present, wraps definition in MappingProxyType, + and sets up class-level fields and annotations for each bitfield. + """ super().__init_subclass__(**kwargs) # Check for required attributes if not hasattr(cls, "__bits_type__") or not hasattr(cls, "__bits_definition__"): @@ -35,10 +44,6 @@ def __init_subclass__(cls, **kwargs): 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 @@ -59,10 +64,17 @@ def __init_subclass__(cls, **kwargs): cls.__annotations__[key] = bool def __post_init__(self) -> None: + """ + Post-initialization to set up the _meta attribute from the class definition. + """ super().__post_init__() self._meta = dict(self.__bits_definition__) def _decode(self, data: list[int]) -> None: + """ + Decode the bitfield from a list of integers, updating the boolean attributes + according to the bit positions defined in _meta. + """ super()._decode(data) bin_data = int_to_bool_list(self._raw, self._byte_length) for k, v in self._meta.items(): @@ -73,6 +85,10 @@ def _decode(self, data: list[int]) -> None: setattr(self, k, bin_data[v]) def _encode(self) -> list[int]: + """ + Encode the boolean attributes into a list of integers representing the bitfield. + Updates _raw and returns the encoded list for further processing. + """ bin_data = list(itertools.repeat(False, self._byte_length * 8)) for k, v in self._meta.items(): if isinstance(v, list): diff --git a/src/pystructtype/structdataclass.py b/src/pystructtype/structdataclass.py index 6062c70..b50b9d6 100644 --- a/src/pystructtype/structdataclass.py +++ b/src/pystructtype/structdataclass.py @@ -1,9 +1,12 @@ +""" +StructDataclass: Base class for auto-decoding/encoding struct-like dataclasses. +""" + import inspect import re import struct from copy import deepcopy from dataclasses import dataclass, field, is_dataclass -from typing import ClassVar from pystructtype.structtypes import iterate_types @@ -27,17 +30,17 @@ class StructDataclass: subclass. """ - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls: type[StructDataclass], **kwargs: object) -> None: + """ + Automatically configure the subclass as a dataclass and set up default values for fields. + Handles special logic for list and non-list fields, default factories, and class variables. + """ 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: @@ -81,34 +84,20 @@ def __init_subclass__(cls, **kwargs): ) 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) - ] - ) + default_list = field( + default_factory=lambda d=default, s=type_iterator.type_meta.size: [ # type: ignore + 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: + """ + Initialize instance state and struct format after dataclass construction. + Computes struct format string and byte length for encoding/decoding. + """ self._state: list[StructState] = [] # Grab Struct Format @@ -140,7 +129,6 @@ def __post_init__(self) -> None: pass self._simplify_format() self._byte_length = struct.calcsize("=" + self.struct_fmt) - # print(f"{self.__class__.__name__}: {self._byte_length} : {self.struct_fmt}") def _simplify_format(self) -> None: """ @@ -151,7 +139,7 @@ def _simplify_format(self) -> None: # Expand any already condensed sections # This can happen if we have nested StructDataclasses expanded_format = "" - items = re.findall(r"([a-zA-Z]|\d+)", self.struct_fmt) + items = re.findall(r"([a-zA-Z?]|\d+)", self.struct_fmt) items_len = len(items) idx = 0 while idx < items_len: @@ -170,7 +158,7 @@ def _simplify_format(self) -> None: # Simplify the format by turning multiple consecutive letters into a number + letter combo simplified_format = "" - for group in (x[0] for x in re.findall(r"(\d*([a-zA-Z])\2*)", expanded_format)): + for group in (x[0] for x in re.findall(r"(\d*([a-zA-Z?])\2*)", expanded_format)): if re.match(r"\d+", group[0]): # Just pass through any format that we've explicitly kept # a number in front of @@ -269,9 +257,12 @@ def decode(self, data: list[int] | bytes, little_endian: bool = False) -> None: :param data: list of ints or a bytes object :param little_endian: True if decoding little_endian formatted data, else False + :raises ValueError: If the input data is not the correct length for the struct """ data = self._to_bytes(data) - + expected_len = struct.calcsize(self._endian(little_endian) + self.struct_fmt) + if len(data) != expected_len: + raise ValueError(f"Input data length {len(data)} does not match expected struct size {expected_len}") # Decode self._decode(list(struct.unpack(self._endian(little_endian) + self.struct_fmt, data))) diff --git a/src/pystructtype/structtypes.py b/src/pystructtype/structtypes.py index 07d80d0..a83264f 100644 --- a/src/pystructtype/structtypes.py +++ b/src/pystructtype/structtypes.py @@ -1,7 +1,11 @@ +""" +structtypes: Type system and helpers for pystructtype. +""" + import inspect from collections.abc import Generator from dataclasses import dataclass -from typing import Annotated, Any, TypeVar, get_args, get_origin, get_type_hints +from typing import Annotated, Any, ClassVar, TypeVar, get_args, get_origin, get_type_hints from pystructtype import structdataclass @@ -30,7 +34,7 @@ # y: Annotated[int, Bar(foo=2)] # u: Annotated[int, Bar[int](foo=2, bar=3)] -T = TypeVar("T", int, float, bytes, default=int) +T = TypeVar("T", int, float, bytes, bool, default=int) """Generic Data Type for TypeMeta Contents""" @@ -84,9 +88,9 @@ class TypeInfo: uint64_t = Annotated[int, TypeInfo("Q", 8)] """8 Byte Unsigned int Type""" -# TODO: Make a special Bool class to auto-convert from int to bool - # Named Types +bool_t = Annotated[bool, TypeInfo("?", 1)] +"""1 Byte bool Type""" float_t = Annotated[float, TypeInfo("f", 4)] """4 Byte float Type""" double_t = Annotated[float, TypeInfo("d", 8)] @@ -109,7 +113,7 @@ class TypeIterator: key: str base_type: type type_info: TypeInfo | None - type_meta: TypeMeta | None + type_meta: TypeMeta[Any] | None is_list: bool is_pystructtype: bool @@ -146,8 +150,15 @@ def iterate_types(cls: type) -> Generator[TypeIterator]: :param cls: A StructDataclass class object (not an instantiated object) :return: Yield a TypeIterator object + :raises TypeError: If cls is not a type """ + if not isinstance(cls, type): + raise TypeError("iterate_types expects a class type as input") for key, hint in get_type_hints(cls, include_extras=True).items(): + # Skip ClassVar and similar non-instance attributes + if get_origin(hint) is ClassVar or hint is ClassVar: + continue + # Grab the base type from a possibly annotated type hint base_type = type_from_annotation(hint) @@ -184,10 +195,9 @@ def iterate_types(cls: type) -> Generator[TypeIterator]: yield TypeIterator(key, base_type, type_info, type_meta, is_list, is_pystructtype) -def type_from_annotation(_type: Any) -> type: +def type_from_annotation(_type: Any) -> type[Any]: """ - Find the base type from an Annotated type, - or return it unchanged if not Annotated + Find the base type from an Annotated type, or return it unchanged if not Annotated. :param _type: Type or Annotated Type to check :return: Base type if Annotated, or the original passed in type otherwise @@ -202,6 +212,6 @@ def type_from_annotation(_type: Any) -> type: arg = t[0] # This will be the base type - return arg + return arg # type: ignore[no-any-return] # No origin, or the origin is not Annotated, just return the given type - return _type + return _type # type: ignore[no-any-return] diff --git a/src/pystructtype/utils.py b/src/pystructtype/utils.py index b26041b..c634772 100644 --- a/src/pystructtype/utils.py +++ b/src/pystructtype/utils.py @@ -1,16 +1,23 @@ +""" +utils: Utility functions for pystructtype. +""" + from collections.abc import Generator from typing import TypeVar -C = TypeVar("C") +C = TypeVar("C") # Generic type variable for list_chunks and other generic utilities. def list_chunks[C](_list: list[C], n: int) -> Generator[list[C]]: """ Yield successive n-sized chunks from a list. :param _list: List to chunk out - :param n: Size of chunks + :param n: Size of chunks (must be > 0) :return: Generator of n-sized chunks of _list + :raises ValueError: If n <= 0 """ + if n <= 0: + raise ValueError("Chunk size n must be greater than 0") yield from (_list[i : i + n] for i in range(0, len(_list), n)) diff --git a/test/__init__.py b/test/__init__.py index e69de29..e63031d 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -0,0 +1,3 @@ +""" +test: Test package for pystructtype. +""" diff --git a/test/examples.py b/test/examples.py index 9caad7b..0ca58af 100644 --- a/test/examples.py +++ b/test/examples.py @@ -1,3 +1,7 @@ +""" +Examples and integration tests for pystructtype. +""" + import itertools from dataclasses import field from enum import IntEnum diff --git a/test/test_bitstype.py b/test/test_bitstype.py index 81b1bc2..92a12ee 100644 --- a/test/test_bitstype.py +++ b/test/test_bitstype.py @@ -1,3 +1,7 @@ +""" +Tests for BitsType. +""" + from typing import ClassVar import pytest diff --git a/test/test_examples.py b/test/test_examples.py index b8837a7..dd36209 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -1,3 +1,7 @@ +""" +Integration test for SMXConfigType using TEST_CONFIG_DATA. +""" + from test.examples import TEST_CONFIG_DATA, SMXConfigType diff --git a/test/test_structdataclass_additional.py b/test/test_structdataclass_additional.py index 6eb1886..496eabc 100644 --- a/test/test_structdataclass_additional.py +++ b/test/test_structdataclass_additional.py @@ -1,3 +1,7 @@ +""" +Additional tests for StructDataclass. +""" + from dataclasses import is_dataclass from typing import Annotated @@ -340,6 +344,26 @@ class S(StructDataclass): a: Annotated[uint8_t, TypeMeta(default=[1, 2])] +def test_structdataclass_default_list_type() -> None: + class S(StructDataclass): + a: Annotated[list[uint8_t], TypeMeta(size=2, default=[1, 2])] + + x = S() + assert x.a == [1, 2] + + +def test_structdataclass_default_class() -> None: + class S(StructDataclass): + a: uint8_t + + class D(StructDataclass): + a: Annotated[list[S], TypeMeta(size=2, default=S)] + + w = S() + x = D() + assert [x.a[0].a, x.a[1].a] == [w.a, w.a] + + 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 @@ -423,3 +447,21 @@ class S5(StructDataclass): s5 = S5() assert isinstance(s5.a, list) and all(isinstance(x, Dummy) for x in s5.a) + + +def test_decode_value_error_on_wrong_length() -> None: + """ + Test that decode raises ValueError if input data length does not match expected struct size (line 265). + """ + + class S(StructDataclass): + a: uint8_t + b: uint8_t + + s = S() + # struct expects 2 bytes, provide only 1 + with pytest.raises(ValueError, match="Input data length 1 does not match expected struct size 2"): + s.decode([1]) + # struct expects 2 bytes, provide 3 + with pytest.raises(ValueError, match="Input data length 3 does not match expected struct size 2"): + s.decode([1, 2, 3]) diff --git a/test/test_structtypes.py b/test/test_structtypes.py index 5f924d0..0a39ebb 100644 --- a/test/test_structtypes.py +++ b/test/test_structtypes.py @@ -1,3 +1,7 @@ +""" +Tests for structtypes and StructDataclass. +""" + from typing import Annotated import pytest @@ -5,6 +9,7 @@ from pystructtype import ( StructDataclass, TypeMeta, + bool_t, char_t, double_t, float_t, @@ -145,6 +150,43 @@ class MyStruct(StructDataclass): assert list(iterate_types(MyStruct)) == results +def test_iterate_types_invalid_input() -> None: + """ + Test that iterate_types raises TypeError when input is not a class type. + """ + with pytest.raises(TypeError): + list(iterate_types(123)) # type: ignore[arg-type] + with pytest.raises(TypeError): + list(iterate_types("not_a_type")) # type: ignore[arg-type] + + def test_typemeta_eq_failure() -> None: with pytest.raises(TypeError): assert TypeMeta() == 2 + + +def test_bool_t_roundtrip() -> None: + """ + Test that bool_t fields are auto-converted to bool on decode and to int on encode. + """ + + class MyStructBool(StructDataclass): + flag: bool_t + flags: Annotated[list[bool_t], TypeMeta[int](size=3, default=True)] + + # Data: [True, False, True, True] + data = [1, 0, 1, 1] + s = MyStructBool() + assert s.flag is False + assert s.flags == [True, True, True] + + s.decode(data) + assert isinstance(s.flag, bool) + assert s.flag is True + assert s.flags == [False, True, True] + + # Now encode and check round-trip + s.flag = False + s.flags = [True, False, True] + encoded = s.encode() + assert list(encoded) == [0, 1, 0, 1] diff --git a/test/test_utils.py b/test/test_utils.py index 2ae5a0c..8ae36aa 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,3 +1,9 @@ +""" +Tests for utils. +""" + +import pytest + from pystructtype.utils import int_to_bool_list, list_chunks @@ -29,3 +35,13 @@ def test_int_to_bool_list() -> None: True, False, ] + + +def test_list_chunks_invalid_chunk_size() -> None: + """ + Test that list_chunks raises ValueError when n <= 0. + """ + with pytest.raises(ValueError): + list(list_chunks([1, 2, 3], 0)) + with pytest.raises(ValueError): + list(list_chunks([1, 2, 3], -1)) diff --git a/uv.lock b/uv.lock index 76b96f2..2670fc3 100644 --- a/uv.lock +++ b/uv.lock @@ -398,7 +398,7 @@ wheels = [ [[package]] name = "pystructtype" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } [package.dev-dependencies]