pax_global_header00006660000000000000000000000064151627021560014517gustar00rootroot0000000000000052 comment=f6de50bbcff53958153d1d6fc90352c53d73326e matejcik-construct-classes-f6de50b/000077500000000000000000000000001516270215600174605ustar00rootroot00000000000000matejcik-construct-classes-f6de50b/.gitignore000066400000000000000000000000521516270215600214450ustar00rootroot00000000000000__pycache__ dist/ .python-version uv.lock matejcik-construct-classes-f6de50b/CHANGELOG.rst000066400000000000000000000027631516270215600215110ustar00rootroot00000000000000Changelog ========= All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to `Semantic Versioning`_. Unreleased ------------ Please see all `Unreleased Changes`_ for more information. .. _Unreleased Changes: https://github.com/matejcik/construct-classes/compare/v0.2.3...HEAD 0.2.3 - 2026-03-31 ------------------ Changed ~~~~~~~ - Use :code:`flit_core` for building the package. 0.2.2 - 2025-08-26 -------------------- Removed ~~~~~~~ - Drop support for Pythons 3.9 and older. This was broken in 0.2 and improperly marked by the package metadata. 0.2.1 - 2025-08-25 -------------------- Fixed ~~~~~ - Fix exception when creating a subclass of a subclass of :code:`Struct`. 0.2.0 - 2025-08-25 -------------------- Added ~~~~~ - Allow pass-through of dataclass arguments via class attributes. Incompatible changes ~~~~~~~~~~~~~~~~~~~~ - Subclasses of :code:`Struct` are now :code:`kw_only` by default. This will break any constructor invocations using positional arguments. You can explicitly set :code:`kw_only=False` on your :code:`Struct` subclass to restore the old behavior. 0.1.2 - 2022-10-07 -------------------- Fixed ~~~~~ - Support for dataclasses that do not contain all the attributes described in :code:`SUBCON`. 0.1.1 - 2022-10-05 ------------------ Initial version. .. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/ .. _Semantic Versioning: https://semver.org/spec/v2.0.0.html matejcik-construct-classes-f6de50b/LICENSE000066400000000000000000000020511516270215600204630ustar00rootroot00000000000000Copyright 2022 matejcik Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. matejcik-construct-classes-f6de50b/README.rst000066400000000000000000000074361516270215600211610ustar00rootroot00000000000000================= construct-classes ================= .. image:: https://img.shields.io/pypi/v/construct-classes.svg :target: https://pypi.python.org/pypi/construct-classes .. .. image:: https://readthedocs.org/projects/construct-classes/badge/?version=latest .. :target: https://construct-classes.readthedocs.io/en/latest/?badge=latest .. :alt: Documentation Status .. image:: https://pyup.io/repos/github/trezor/construct-classes/shield.svg :target: https://pyup.io/repos/github/trezor/construct-classes/ :alt: Updates Parse your binary data into dataclasses. Pack your dataclasses into binary data. :code:`construct-classes` rely on `construct`_ for parsing and packing. The programmer needs to manually write the Construct expressions. There is also no type verification, so it is the programmer's responsibility that the dataclass and the Construct expression match. For fully type annotated experience, install `construct-typing`_. This package typechecks with `mypy`_ and `pyright`_. .. _construct: https://construct.readthedocs.io/en/latest/ .. _construct-typing: https://github.com/timrid/construct-typing .. _mypy: https://mypy.readthedocs.io/en/stable/ .. _pyright: https://github.com/microsoft/pyright Usage ----- Any child of :code:`Struct` is a Python dataclass. It expects a Construct :code:`Struct` expression in the :code:`SUBCON` attribute. The names of the attributes of the dataclass must match the names of the fields in the Construct struct. .. code-block:: python import construct as c from construct_classes import Struct, subcon class BasicStruct(Struct): x: int y: int description: str SUBCON = c.Struct( "x" / c.Int32ul, "y" / c.Int32ul, "description" / c.PascalString(c.Int8ul, "utf8"), ) data = b"\x01\x00\x00\x00\x02\x00\x00\x00\x05hello" parsed = BasicStruct.parse(data) print(parsed) # BasicStruct(x=1, y=2, description='hello') new_data = BasicStruct(x=100, y=200, description="world") print(new_data.build()) # b'\x64\x00\x00\x00\xc8\x00\x00\x00\x05world' :code:`construct-classes` support nested structs, but you need to declare them explicitly: .. code-block:: python class LargerStruct(Struct): # specify the subclass type: basic: BasicStruct = subcon(BasicStruct) # in case of a list, specify the item type: basic_array: List[BasicStruct] = subcon(BasicStruct) # the `subcon()` function supports all arguments of `dataclass.field`: default_array: List[BasicStruct] = subcon(BasicStruct, default_factory=list) # to refer to the subcon, use the `SUBCON` class attribute: SUBCON = c.Struct( "basic" / BasicStruct.SUBCON, "basic_array" / c.Array(2, BasicStruct.SUBCON), "default_array" / c.PrefixedArray(c.Int8ul, BasicStruct.SUBCON), ) Use :code:`dataclasses.field()` to specify attributes on fields that are not subcons. By default, subclasses of :code:`Struct` are :code:`kw_only`. This is specifically to allow setting default values on any fields regardless of order, so that your attributes can be listed in the subcon order. However, you can pass any valid dataclass parameters to the :code:`Struct` class via class attributes: .. code-block:: python class MyStruct(Struct, kw_only=False, frozen=True): a: int b: int my_struct = MyStruct(1, 2) # ok my_struct.a = 2 # FrozenInstanceError Installing ---------- Install using pip: $ pip install construct-classes Changelog ~~~~~~~~~ See `CHANGELOG.rst`_. .. _CHANGELOG.rst: https://github.com/matejcik/construct-classes/blob/master/CHANGELOG.rst Footer ------ * Free software: MIT License .. * Documentation: https://construct-classes.readthedocs.io. matejcik-construct-classes-f6de50b/pyproject.toml000066400000000000000000000023221516270215600223730ustar00rootroot00000000000000[project] name = "construct-classes" version = "0.2.3" description = "Parse your binary structs into dataclasses" authors = [{ name = "matejcik", email = "ja@matejcik.cz" }] requires-python = ">=3.10,<4.0" readme = "README.rst" license = "MIT" classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] dependencies = [ "construct~=2.10", ] [project.urls] Homepage = "https://github.com/matejcik/construct-classes" Repository = "https://github.com/matejcik/construct-classes" [dependency-groups] dev = [ "pytest>5", "black>=22.8.0,<23", "isort>=5.10.1,<6", "flake8>=5.0.4,<6", "construct-typing>=0.5.2,<0.6", "typing-extensions>4.2", ] [build-system] requires = ["flit_core>=3.4,<4"] build-backend = "flit_core.buildapi" [tool.flit.sdist] include = [ "CHANGELOG.rst", "LICENSE", "README.rst", "tests/", ] [tool.isort] profile = "black" matejcik-construct-classes-f6de50b/src/000077500000000000000000000000001516270215600202475ustar00rootroot00000000000000matejcik-construct-classes-f6de50b/src/construct_classes/000077500000000000000000000000001516270215600240105ustar00rootroot00000000000000matejcik-construct-classes-f6de50b/src/construct_classes/__init__.py000066400000000000000000000055021516270215600261230ustar00rootroot00000000000000import dataclasses import typing as t import construct as c if t.TYPE_CHECKING: from typing_extensions import dataclass_transform else: def dataclass_transform(**kwargs: t.Any) -> t.Any: def inner(cls: t.Any) -> t.Any: return cls return inner # workaround for mypy self type bug Self = t.TypeVar("Self", bound="Struct") def subcon( cls: "t.Type[Struct]", *, metadata: t.Optional[t.Dict[str, t.Any]] = None, **kwargs: t.Any, ) -> t.Any: if metadata is None: metadata = {} metadata["substruct"] = cls return dataclasses.field(metadata=metadata, **kwargs) @dataclass_transform(field_specifiers=(subcon,), kw_only_default=True) class _StructMeta(type): def __new__( cls, name: str, bases: t.Tuple[type, ...], namespace: t.Dict[str, t.Any], *, kw_only: bool = True, **kwargs: t.Any, ) -> type: new_cls = super().__new__(cls, name, bases, namespace) if any(isinstance(b, _StructMeta) for b in bases): # skip over `Struct` itself, which does not have a StructMeta class in its # bases. return dataclasses.dataclass(kw_only=kw_only, **kwargs)(new_cls) else: return new_cls class Struct(metaclass=_StructMeta): SUBCON: t.ClassVar["c.Construct[c.Container[t.Any], t.Dict[str, t.Any]]"] def build(self) -> bytes: return self.SUBCON.build(dataclasses.asdict(self)) @staticmethod def _decontainerize(item: t.Any) -> t.Any: if isinstance(item, c.ListContainer): return [Struct._decontainerize(i) for i in item] return item @classmethod def from_parsed(cls: t.Type[Self], data: c.Container) -> Self: args = {} for field in dataclasses.fields(cls): field_data = data.get(field.name) subcls = field.metadata.get("substruct") if subcls is None: args[field.name] = field_data continue if isinstance(field_data, c.ListContainer): args[field.name] = [subcls.from_parsed(d) for d in field_data] elif isinstance(field_data, c.Container): args[field.name] = subcls.from_parsed(field_data) elif field_data is None: continue else: raise ValueError( f"Mismatched type for field {field.name}: expected a struct, found {type(field_data)}" ) for key in args: args[key] = cls._decontainerize(args[key]) return cls(**args) @classmethod def parse(cls: t.Type[Self], data: bytes) -> Self: result = cls.SUBCON.parse(data) return cls.from_parsed(result) # check that `Struct` itself is not transformed assert not dataclasses.is_dataclass(Struct) matejcik-construct-classes-f6de50b/src/construct_classes/py.typed000066400000000000000000000000001516270215600254750ustar00rootroot00000000000000matejcik-construct-classes-f6de50b/tests/000077500000000000000000000000001516270215600206225ustar00rootroot00000000000000matejcik-construct-classes-f6de50b/tests/test_construct_classes.py000066400000000000000000000042231516270215600257750ustar00rootroot00000000000000import typing as t from dataclasses import FrozenInstanceError import construct as c import pytest from construct_classes import Struct, subcon class BasicStruct(Struct): a: int b: int SUBCON = c.Struct( "a" / c.Int8ub, "b" / c.Int8ub, ) def test_basic(): bs = BasicStruct(a=5, b=10) compiled = bs.build() parsed = BasicStruct.parse(compiled) assert parsed == bs class SubconStruct(Struct): a: int b: BasicStruct = subcon(BasicStruct) SUBCON = c.Struct( "a" / c.Int8ub, "b" / BasicStruct.SUBCON, ) def test_subcon(): ss = SubconStruct(a=5, b=BasicStruct(a=10, b=20)) compiled = ss.build() parsed = SubconStruct.parse(compiled) assert parsed == ss substr = parsed.b.build() assert substr in compiled class WithDefaultFactory(Struct): array: t.List[BasicStruct] = subcon(BasicStruct, default_factory=list) SUBCON = c.Struct( "array" / c.Array(2, BasicStruct.SUBCON), ) def test_default(): dd = WithDefaultFactory() assert dd.array == [] class MoreFieldsInConstruct(Struct): a: int SUBCON = c.Struct( "a" / c.Int8ub, "b" / c.Tell, ) def test_more_fields(): MoreFieldsInConstruct.parse(b"\x01") class DefaultsNotLast(Struct): a: int = 1 b: int SUBCON = c.Struct( "a" / c.Int8ub, "b" / c.Int8ub, ) def test_defaults_not_last(): rebuilt = DefaultsNotLast(b=5).build() reparsed = DefaultsNotLast.parse(rebuilt) assert reparsed.a == 1 assert reparsed.b == 5 class DataclassPassthrough(Struct, frozen=True, eq=False): a: int SUBCON = c.Struct( "a" / c.Int8ub, ) def test_dataclass_passthrough(): a = DataclassPassthrough(a=1) with pytest.raises(FrozenInstanceError): a.a = 2 # type: ignore # yes, it is an error assert a.a == 1 # eq is not implemented b = DataclassPassthrough(a=1) assert a != b def test_indirect_descendant(): """ regression test for #5: a subclass of a subclass of a Struct would have caused an exception on definition """ class Sub(BasicStruct): pass