Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Sphinx configuration for pystructtype documentation.
"""

import tomllib
from datetime import datetime

Expand Down
6 changes: 6 additions & 0 deletions src/pystructtype/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,6 +27,7 @@
"StructDataclass",
"TypeInfo",
"TypeMeta",
"bool_t",
"char_t",
"double_t",
"float_t",
Expand Down
30 changes: 23 additions & 7 deletions src/pystructtype/bitstype.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
BitsType: Base class for bitfield structs.
"""

import itertools
from collections.abc import Mapping
from dataclasses import field
Expand All @@ -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__"):
Expand All @@ -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
Expand All @@ -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():
Expand All @@ -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):
Expand Down
59 changes: 25 additions & 34 deletions src/pystructtype/structdataclass.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)))

Expand Down
30 changes: 20 additions & 10 deletions src/pystructtype/structtypes.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"""


Expand Down Expand Up @@ -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)]
Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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]
11 changes: 9 additions & 2 deletions src/pystructtype/utils.py
Original file line number Diff line number Diff line change
@@ -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))


Expand Down
3 changes: 3 additions & 0 deletions test/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
test: Test package for pystructtype.
"""
4 changes: 4 additions & 0 deletions test/examples.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Examples and integration tests for pystructtype.
"""

import itertools
from dataclasses import field
from enum import IntEnum
Expand Down
4 changes: 4 additions & 0 deletions test/test_bitstype.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""
Tests for BitsType.
"""

from typing import ClassVar

import pytest
Expand Down
Loading