├── dataclasses_struct ├── py.typed ├── ext │ ├── __init__.py │ └── mypy_plugin.py ├── _typing.py ├── __init__.py ├── field.py ├── types.py └── dataclass.py ├── codecov.yml ├── .gitignore ├── mypy.ini ├── docs ├── api-reference.md ├── types-reference.md ├── index.md └── guide.md ├── .readthedocs.yaml ├── .pre-commit-config.yaml ├── test ├── test_postponed_eval.py ├── test-mypy-plugin.yml ├── test_dataclasses_field.py ├── struct.c ├── test_cstruct.py ├── conftest.py ├── test_decorator.py ├── test_validation.py ├── test_fields.py └── test_pack_unpack.py ├── LICENSE ├── mkdocs.yml ├── pyproject.toml ├── README.md ├── .github └── workflows │ └── ci.yml └── requirements-docs.txt /dataclasses_struct/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dataclasses_struct/ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: off 4 | patch: off 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*_cache/ 2 | .coverage 3 | .dmypy.json 4 | /dist/ 5 | /site/ 6 | __pycache__/ 7 | coverage.xml 8 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = $MYPY_CONFIG_FILE_DIR/dataclasses_struct,$MYPY_CONFIG_FILE_DIR/test 3 | plugins = dataclasses_struct.ext.mypy_plugin 4 | -------------------------------------------------------------------------------- /docs/api-reference.md: -------------------------------------------------------------------------------- 1 | # API reference 2 | 3 | ::: dataclasses_struct.dataclass 4 | options: 5 | show_root_heading: false 6 | show_root_toc_entry: false 7 | -------------------------------------------------------------------------------- /docs/types-reference.md: -------------------------------------------------------------------------------- 1 | # Type annotations reference 2 | 3 | ::: dataclasses_struct.types 4 | options: 5 | show_root_heading: false 6 | show_root_toc_entry: false 7 | members_order: source 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-24.04 4 | tools: 5 | python: "3.12" 6 | mkdocs: 7 | configuration: mkdocs.yml 8 | fail_on_warning: true 9 | python: 10 | install: 11 | - requirements: requirements-docs.txt 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.10 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | - repo: https://github.com/astral-sh/uv-pre-commit 8 | rev: 0.9.16 9 | hooks: 10 | - id: uv-lock 11 | args: ["--check"] 12 | - id: uv-export 13 | args: ["--quiet", "--only-group", "docs", "-o", "requirements-docs.txt"] 14 | files: ^(requirements-docs\.txt|pyproject\.toml|uv\.lock)$ 15 | -------------------------------------------------------------------------------- /test/test_postponed_eval.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Annotated 4 | 5 | import dataclasses_struct as dcs 6 | 7 | 8 | def test_postponed() -> None: 9 | @dcs.dataclass_struct(size="std") 10 | class _: 11 | a: dcs.Char 12 | b: dcs.I8 13 | c: dcs.U8 14 | d: dcs.Bool 15 | e: dcs.I16 16 | f: dcs.U16 17 | g: dcs.I32 18 | h: dcs.U32 19 | i: dcs.I64 20 | j: dcs.U64 21 | k: dcs.F32 22 | l: dcs.F64 # noqa: E741 23 | m: Annotated[bytes, 10] 24 | -------------------------------------------------------------------------------- /dataclasses_struct/_typing.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info >= (3, 10): 4 | from typing import TypeGuard 5 | else: 6 | from typing_extensions import TypeGuard 7 | 8 | if sys.version_info >= (3, 11): 9 | from typing import Unpack, dataclass_transform 10 | else: 11 | from typing_extensions import Unpack, dataclass_transform 12 | 13 | if sys.version_info >= (3, 12): 14 | from collections.abc import Buffer 15 | else: 16 | from typing_extensions import Buffer 17 | 18 | 19 | __all__ = [ 20 | "Buffer", 21 | "TypeGuard", 22 | "Unpack", 23 | "dataclass_transform", 24 | ] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Harry Mander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: dataclasses-struct 2 | site_url: https://dataclasses-struct.readthedocs.io 3 | nav: 4 | - index.md 5 | - guide.md 6 | - api-reference.md 7 | - types-reference.md 8 | repo_url: https://github.com/harrymander/dataclasses-struct 9 | theme: 10 | name: material 11 | features: 12 | - content.code.annotate 13 | icon: 14 | repo: fontawesome/brands/github 15 | palette: 16 | - media: "(prefers-color-scheme)" 17 | toggle: 18 | icon: material/brightness-auto 19 | name: Switch to light mode 20 | - media: "(prefers-color-scheme: light)" 21 | scheme: default 22 | toggle: 23 | icon: material/brightness-7 24 | name: Switch to dark mode 25 | - media: "(prefers-color-scheme: dark)" 26 | scheme: slate 27 | toggle: 28 | icon: material/brightness-4 29 | name: Switch to system preference 30 | plugins: 31 | - search 32 | - mkdocstrings 33 | - autorefs 34 | markdown_extensions: 35 | - attr_list 36 | - md_in_html 37 | - pymdownx.highlight: 38 | anchor_linenums: true 39 | line_spans: __span 40 | pygments_lang_class: true 41 | - pymdownx.inlinehilite 42 | - pymdownx.snippets 43 | - pymdownx.superfences 44 | - pymdownx.details 45 | - pymdownx.magiclink 46 | - admonition 47 | watch: 48 | - dataclasses_struct 49 | -------------------------------------------------------------------------------- /dataclasses_struct/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib import metadata 2 | 3 | __version__ = metadata.version(__package__) 4 | 5 | from .dataclass import ( 6 | DataclassStructInternal, 7 | DataclassStructProtocol, 8 | dataclass_struct, 9 | get_struct_size, 10 | is_dataclass_struct, 11 | ) 12 | from .field import ( 13 | BoolField, 14 | CharField, 15 | FloatingPointField, 16 | IntField, 17 | NativeIntField, 18 | PointerField, 19 | SignedStdIntField, 20 | SizeField, 21 | StdIntField, 22 | UnsignedStdIntField, 23 | ) 24 | from .types import ( 25 | F16, 26 | F32, 27 | F64, 28 | I8, 29 | I16, 30 | I32, 31 | I64, 32 | U8, 33 | U16, 34 | U32, 35 | U64, 36 | Bool, 37 | Char, 38 | Int, 39 | LengthPrefixed, 40 | Long, 41 | LongLong, 42 | PadAfter, 43 | PadBefore, 44 | Pointer, 45 | Short, 46 | SignedChar, 47 | SignedSize, 48 | UnsignedChar, 49 | UnsignedInt, 50 | UnsignedLong, 51 | UnsignedLongLong, 52 | UnsignedShort, 53 | UnsignedSize, 54 | ) 55 | 56 | __all__ = ( 57 | "F16", 58 | "F32", 59 | "F64", 60 | "I8", 61 | "I16", 62 | "I32", 63 | "I64", 64 | "U8", 65 | "U16", 66 | "U32", 67 | "U64", 68 | "Bool", 69 | "BoolField", 70 | "Char", 71 | "CharField", 72 | "DataclassStructInternal", 73 | "DataclassStructProtocol", 74 | "FloatingPointField", 75 | "Int", 76 | "IntField", 77 | "LengthPrefixed", 78 | "Long", 79 | "LongLong", 80 | "NativeIntField", 81 | "PadAfter", 82 | "PadBefore", 83 | "Pointer", 84 | "PointerField", 85 | "Short", 86 | "SignedChar", 87 | "SignedSize", 88 | "SignedStdIntField", 89 | "SizeField", 90 | "StdIntField", 91 | "UnsignedChar", 92 | "UnsignedInt", 93 | "UnsignedLong", 94 | "UnsignedLongLong", 95 | "UnsignedShort", 96 | "UnsignedSize", 97 | "UnsignedStdIntField", 98 | "dataclass_struct", 99 | "get_struct_size", 100 | "is_dataclass_struct", 101 | ) 102 | -------------------------------------------------------------------------------- /dataclasses_struct/ext/mypy_plugin.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Optional 2 | 3 | from mypy.nodes import ArgKind, Argument, Var 4 | from mypy.plugin import ClassDefContext 5 | from mypy.plugin import Plugin as BasePlugin 6 | from mypy.plugins.common import add_attribute_to_class, add_method_to_class 7 | from mypy.plugins.dataclasses import dataclass_class_maker_callback 8 | from mypy.types import TypeType, TypeVarId, TypeVarType 9 | 10 | DATACLASS_STRUCT_DECORATOR = "dataclasses_struct.dataclass.dataclass_struct" 11 | 12 | 13 | def transform_dataclass_struct(ctx: ClassDefContext) -> bool: 14 | buffer_type = ctx.api.named_type("dataclasses_struct._typing.Buffer") 15 | bytes_type = ctx.api.named_type("builtins.bytes") 16 | tvd = TypeVarType( 17 | "T", 18 | f"{ctx.cls.info.fullname}.T", 19 | TypeVarId(-1), 20 | [], 21 | ctx.api.named_type("builtins.object"), 22 | ctx.api.named_type("builtins.object"), 23 | ) 24 | add_method_to_class(ctx.api, ctx.cls, "pack", [], bytes_type) 25 | add_method_to_class( 26 | ctx.api, 27 | ctx.cls, 28 | "from_packed", 29 | [ 30 | Argument( 31 | Var("data", buffer_type), 32 | buffer_type, 33 | None, 34 | ArgKind.ARG_POS, 35 | ) 36 | ], 37 | tvd, 38 | self_type=TypeType(tvd), 39 | tvar_def=tvd, 40 | is_classmethod=True, 41 | ) 42 | add_attribute_to_class( 43 | ctx.api, 44 | ctx.cls, 45 | "__dataclass_struct__", 46 | ctx.api.named_type( 47 | "dataclasses_struct.dataclass.DataclassStructInternal" 48 | ), 49 | is_classvar=True, 50 | ) 51 | 52 | # Not sure if this is the right thing to do here... needed because 53 | # @dataclass_transform doesn't seem to work with mypy when using this 54 | # custom plugin. 55 | dataclass_class_maker_callback(ctx) 56 | 57 | return True 58 | 59 | 60 | class Plugin(BasePlugin): 61 | def get_class_decorator_hook_2( 62 | self, fullname: str 63 | ) -> Optional[Callable[[ClassDefContext], bool]]: 64 | if fullname == DATACLASS_STRUCT_DECORATOR: 65 | return transform_dataclass_struct 66 | return None 67 | 68 | 69 | def plugin(version: str) -> type[Plugin]: 70 | return Plugin 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dataclasses-struct" 3 | version = "1.4.0" 4 | description = "Converting dataclasses to and from fixed-length binary data using struct" 5 | readme = "README.md" 6 | authors = [ 7 | { name = "Harry Mander", email = "harrymander96@gmail.com" } 8 | ] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Programming Language :: Python", 12 | "Programming Language :: Python :: Implementation :: CPython", 13 | "Programming Language :: Python :: Implementation :: PyPy", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Intended Audience :: Developers", 24 | "Intended Audience :: Information Technology", 25 | "Topic :: Software Development :: Libraries :: Python Modules", 26 | "Typing :: Typed", 27 | ] 28 | requires-python = ">=3.9.0" 29 | dependencies = [ 30 | "typing-extensions>=4.12.2 ; python_full_version < '3.12'", 31 | ] 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/harrymander/dataclasses-struct" 35 | Repository = "https://github.com/harrymander/dataclasses-struct" 36 | Documentation = "https://harrymander.xyz/dataclasses-struct" 37 | 38 | [dependency-groups] 39 | dev = [ 40 | "mypy>=1.16.0", 41 | "pytest>=8.3.4", 42 | "pytest-cov>=5.0.0", 43 | "pytest-mypy-plugins>=3.1.2", 44 | "ruff>=0.9.4", 45 | ] 46 | docs = [ 47 | "mkdocs-material>=9.6.14", 48 | "mkdocstrings[python]>=0.29.1", 49 | "ruff>=0.9.4", 50 | ] 51 | 52 | [tool.uv.dependency-groups] 53 | docs = {requires-python = ">=3.12"} 54 | 55 | [build-system] 56 | requires = ["hatchling"] 57 | build-backend = "hatchling.build" 58 | 59 | 60 | [tool.pytest.ini_options] 61 | testpaths = ["test"] 62 | markers = [ 63 | "cc: marks a test as requiring a C compiler" 64 | ] 65 | 66 | [tool.coverage.run] 67 | source = ["dataclasses_struct/"] 68 | omit = ["dataclasses_struct/ext/**/*.py", "dataclasses_struct/_typing.py"] 69 | 70 | [tool.ruff] 71 | line-length = 79 72 | 73 | [tool.ruff.lint] 74 | select = [ 75 | "B", # flake8-bugbear 76 | "E", "W", # pycodestyle errors and warnings 77 | "F", # pyflakes 78 | "I", # isort 79 | "RUF", # ruff-specific 80 | "UP", # pyupgrade 81 | ] 82 | -------------------------------------------------------------------------------- /test/test-mypy-plugin.yml: -------------------------------------------------------------------------------- 1 | - case: test_is_dataclass 2 | mypy_config: 'plugins = dataclasses_struct.ext.mypy_plugin' 3 | main: | 4 | import dataclasses_struct as dcs 5 | 6 | @dcs.dataclass_struct() 7 | class Test: 8 | x: int 9 | 10 | t = Test(x=1) # should work 11 | t = Test(1) # should work 12 | reveal_type(t.x) # N: Revealed type is "builtins.int" 13 | 14 | 15 | - case: test_pack_returns_bytes 16 | mypy_config: 'plugins = dataclasses_struct.ext.mypy_plugin' 17 | main: | 18 | import dataclasses_struct as dcs 19 | 20 | @dcs.dataclass_struct() 21 | class Test: 22 | x: int 23 | 24 | t = Test(2) 25 | reveal_type(t.pack()) # N: Revealed type is "builtins.bytes" 26 | 27 | 28 | - case: test_dataclass_struct_attribute 29 | mypy_config: 'plugins = dataclasses_struct.ext.mypy_plugin' 30 | main: | 31 | import dataclasses_struct as dcs 32 | 33 | @dcs.dataclass_struct() 34 | class Test: 35 | x: int 36 | 37 | reveal_type(Test.__dataclass_struct__.size) # N: Revealed type is "builtins.int" 38 | reveal_type(Test.__dataclass_struct__.format) # N: Revealed type is "builtins.str" 39 | reveal_type(Test.__dataclass_struct__.mode) # N: Revealed type is "builtins.str" 40 | 41 | 42 | - case: test_from_packed_returns_instance 43 | mypy_config: 'plugins = dataclasses_struct.ext.mypy_plugin' 44 | main: | 45 | import dataclasses_struct as dcs 46 | 47 | @dcs.dataclass_struct() 48 | class Test: 49 | x: int 50 | 51 | t = Test.from_packed(Test(1).pack()) 52 | reveal_type(t) # N: Revealed type is "main.Test" 53 | 54 | - case: test_from_packed_supports_bytearray_argument 55 | mypy_config: 'plugins = dataclasses_struct.ext.mypy_plugin' 56 | main: | 57 | import dataclasses_struct as dcs 58 | 59 | @dcs.dataclass_struct() 60 | class Test: 61 | x: int 62 | 63 | t = Test.from_packed(bytearray(Test(1).pack())) 64 | reveal_type(t) # N: Revealed type is "main.Test" 65 | 66 | - case: test_from_packed_supports_mmap_argument 67 | mypy_config: 'plugins = dataclasses_struct.ext.mypy_plugin' 68 | main: | 69 | import mmap 70 | import tempfile 71 | from pathlib import Path 72 | 73 | import dataclasses_struct as dcs 74 | 75 | @dcs.dataclass_struct() 76 | class Test: 77 | x: int 78 | 79 | packed = Test(1).pack() 80 | with tempfile.TemporaryDirectory() as tempdir: 81 | path = Path(tempdir) / "data" 82 | path.write_bytes(packed) 83 | with path.open("rb+") as f, mmap.mmap(f.fileno(), 0) as mapped: 84 | t = Test.from_packed(mapped) 85 | 86 | reveal_type(t) # N: Revealed type is "main.Test" 87 | -------------------------------------------------------------------------------- /test/test_dataclasses_field.py: -------------------------------------------------------------------------------- 1 | from dataclasses import field 2 | from typing import Any 3 | 4 | import pytest 5 | from conftest import ( 6 | raises_default_value_invalid_type_error, 7 | raises_default_value_out_of_range_error, 8 | ) 9 | 10 | import dataclasses_struct as dcs 11 | 12 | 13 | def test_dataclasses_field_empty() -> None: 14 | @dcs.dataclass_struct() 15 | class T: 16 | x: int = field() 17 | 18 | T(12) 19 | 20 | with pytest.raises( 21 | TypeError, 22 | match="missing 1 required positional argument", 23 | ): 24 | T() 25 | 26 | 27 | def parametrize_field_kwargs(val: Any) -> pytest.MarkDecorator: 28 | """ 29 | Parametrise dataclasses.field kwargs on 'default' and 'default_kwargs'. 30 | """ 31 | return pytest.mark.parametrize( 32 | "field_kwargs", 33 | ({"default": val}, {"default_factory": lambda: val}), 34 | ids=("default", "default_factory"), 35 | ) 36 | 37 | 38 | @parametrize_field_kwargs(100) 39 | def test_dataclasses_field_default(field_kwargs) -> None: 40 | @dcs.dataclass_struct() 41 | class T: 42 | x: int = field(**field_kwargs) 43 | 44 | t = T() 45 | assert t.x == 100 46 | 47 | t = T(200) 48 | assert t.x == 200 49 | 50 | 51 | @parametrize_field_kwargs(100.0) 52 | def test_dataclasses_field_default_wrong_type_fails(field_kwargs) -> None: 53 | with raises_default_value_invalid_type_error(): 54 | 55 | @dcs.dataclass_struct() 56 | class _: 57 | x: int = field(**field_kwargs) 58 | 59 | 60 | @parametrize_field_kwargs(-100) 61 | def test_dataclasses_field_default_invalid_value_fails(field_kwargs) -> None: 62 | with raises_default_value_out_of_range_error(): 63 | 64 | @dcs.dataclass_struct() 65 | class _: 66 | x: dcs.UnsignedInt = field(**field_kwargs) 67 | 68 | 69 | @parametrize_field_kwargs(200) 70 | def test_dataclasses_field_no_init(field_kwargs) -> None: 71 | @dcs.dataclass_struct() 72 | class T: 73 | x: int = field() 74 | y: int = field(init=False, **field_kwargs) 75 | 76 | t = T(100) 77 | assert t.x == 100 78 | assert t.y == 200 79 | 80 | with pytest.raises( 81 | TypeError, 82 | match=r"takes 2 positional arguments but 3 were given$", 83 | ): 84 | T(1, 2) 85 | 86 | 87 | class DefaultFactoryCallCounter: 88 | def __init__(self): 89 | self.call_count = 0 90 | 91 | def __call__(self) -> int: 92 | self.call_count += 1 93 | return 1 94 | 95 | 96 | def test_default_factory_called_once_during_class_creation() -> None: 97 | factory = DefaultFactoryCallCounter() 98 | 99 | @dcs.dataclass_struct() 100 | class _: 101 | x: int = field(default_factory=factory) 102 | 103 | assert factory.call_count == 1 104 | 105 | 106 | def test_default_factory_not_called_during_class_creation_if_validate_defaults_is_false() -> ( # noqa: E501 107 | None 108 | ): 109 | factory = DefaultFactoryCallCounter() 110 | 111 | @dcs.dataclass_struct(validate_defaults=False) 112 | class _: 113 | x: int = field(default_factory=factory) 114 | 115 | assert factory.call_count == 0 116 | -------------------------------------------------------------------------------- /test/struct.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | #ifdef _MSC_VER 8 | #define _CRT_SECURE_NO_WARNINGS 9 | #endif 10 | 11 | #ifndef TEST_NATIVE_INTS 12 | // If using std sizes, then structs do not have native alignment (i.e. they are 13 | // packed) 14 | #define TEST_PACKED 15 | #endif 16 | 17 | #ifdef TEST_PACKED 18 | #pragma pack(push, 1) 19 | #endif // TEST_PACKED 20 | struct test { 21 | // Types that work on both native and std sizes 22 | bool test_bool; 23 | float test_float; 24 | double test_double; 25 | char test_char; 26 | char test_char_array[10]; 27 | 28 | #ifdef TEST_NATIVE_INTS 29 | signed char test_signed_char; 30 | unsigned char test_unsigned_char; 31 | signed short test_signed_short; 32 | unsigned short test_unsigned_short; 33 | signed int test_signed_int; 34 | unsigned int test_unsigned_int; 35 | signed long test_signed_long; 36 | unsigned long test_unsigned_long; 37 | signed long long test_signed_long_long; 38 | unsigned long long test_unsigned_long_long; 39 | size_t test_size; // ssize_t is POSIX-only 40 | void *test_pointer; 41 | #else 42 | uint8_t test_uint8; 43 | int8_t test_int8; 44 | uint16_t test_uint16; 45 | int16_t test_int16; 46 | uint32_t test_uint32; 47 | int32_t test_int32; 48 | uint64_t test_uint64; 49 | int64_t test_int64; 50 | #endif // TEST_NATIVE_INS 51 | }; 52 | 53 | struct container { 54 | struct test t1; 55 | struct test t2; 56 | }; 57 | #ifdef TEST_PACKED 58 | #pragma pack(pop) 59 | #endif // TEST_PACKED 60 | 61 | int main(const int argc, const char *argv[]) 62 | { 63 | if (argc < 2) { 64 | fprintf(stderr, "usage: %s [outfile]\n", argv[0]); 65 | return 1; 66 | } 67 | 68 | const struct test test = { 69 | .test_bool = true, 70 | .test_float = 1.5, 71 | .test_double = 2.5, 72 | .test_char = '!', 73 | .test_char_array = "123456789", 74 | 75 | #ifdef TEST_NATIVE_INTS 76 | .test_signed_char = -10, 77 | .test_unsigned_char = 10, 78 | .test_signed_short = -500, 79 | .test_unsigned_short = 500, 80 | .test_signed_int = -5000, 81 | .test_unsigned_int = 5000, 82 | .test_signed_long = -6000, 83 | .test_unsigned_long = 6000, 84 | .test_signed_long_long = -7000, 85 | .test_unsigned_long_long = 7000, 86 | .test_size = 8000, 87 | .test_pointer = (void *)0, 88 | #else 89 | .test_uint8 = UINT8_MAX, 90 | .test_int8 = INT8_MIN, 91 | .test_uint16 = UINT16_MAX, 92 | .test_int16 = INT16_MIN, 93 | .test_uint32 = UINT32_MAX, 94 | .test_int32 = INT32_MIN, 95 | .test_uint64 = UINT64_MAX, 96 | .test_int64 = INT64_MIN, 97 | #endif // TEST_NATIVE_INTS 98 | }; 99 | 100 | const struct container container = {test, test}; 101 | 102 | FILE *fp = fopen(argv[1], "wb"); 103 | if (!fp) { 104 | fprintf(stderr, "cannot open file: %s\n", strerror(errno)); 105 | return 1; 106 | } 107 | 108 | int ret = fwrite(&container, sizeof(container), 1, fp) != 1; 109 | if (ret) { 110 | fprintf(stderr, "write error\n"); 111 | } 112 | fclose(fp); 113 | return ret != 0; 114 | } 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dataclasses-struct 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/dataclasses-struct)](https://pypi.org/project/dataclasses-struct/) 4 | [![Python versions](https://img.shields.io/pypi/pyversions/dataclasses-struct)](https://pypi.org/project/dataclasses-struct/) 5 | [![Tests status](https://github.com/harrymander/dataclasses-struct/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/harrymander/dataclasses-struct/actions/workflows/ci.yml) 6 | [![Code coverage](https://img.shields.io/codecov/c/gh/harrymander/dataclasses-struct)](https://app.codecov.io/gh/harrymander/dataclasses-struct) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/harrymander/dataclasses-struct/blob/main/LICENSE) 8 | [![Documentation](https://img.shields.io/badge/Documentation-blue)](https://harrymander.xyz/dataclasses-struct) 9 | 10 | A simple Python package that combines 11 | [`dataclasses`](https://docs.python.org/3/library/dataclasses.html) with 12 | [`struct`](https://docs.python.org/3/library/struct.html) for packing and 13 | unpacking Python dataclasses to fixed-length `bytes` representations. 14 | 15 | **Documentation**: https://harrymander.xyz/dataclasses-struct 16 | 17 | ## Example 18 | 19 | ```python 20 | from typing import Annotated 21 | 22 | import dataclasses_struct as dcs 23 | 24 | @dcs.dataclass_struct() 25 | class Test: 26 | x: int 27 | y: float 28 | z: dcs.UnsignedShort 29 | s: Annotated[bytes, 10] # fixed-length byte array of length 10 30 | 31 | @dcs.dataclass_struct() 32 | class Container: 33 | test1: Test 34 | test2: Test 35 | ``` 36 | 37 | ```python 38 | >>> dcs.is_dataclass_struct(Test) 39 | True 40 | >>> t1 = Test(100, -0.25, 0xff, b'12345') 41 | >>> dcs.is_dataclass_struct(t1) 42 | True 43 | >>> t1 44 | Test(x=100, y=-0.25, z=255, s=b'12345') 45 | >>> packed = t1.pack() 46 | >>> packed 47 | b'd\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xd0\xbf\xff\x0012345\x00\x00\x00\x00\x00' 48 | >>> Test.from_packed(packed) 49 | Test(x=100, y=-0.25, z=255, s=b'12345\x00\x00\x00\x00\x00') 50 | >>> t2 = Test(1, 100, 12, b'hello, world') 51 | >>> c = Container(t1, t2) 52 | >>> Container.from_packed(c.pack()) 53 | Container(test1=Test(x=100, y=-0.25, z=255, s=b'12345\x00\x00\x00\x00\x00'), test2=Test(x=1, y=100.0, z=12, s=b'hello, wor')) 54 | ``` 55 | 56 | ## Installation 57 | 58 | This package is available on pypi: 59 | 60 | ``` 61 | pip install dataclasses-struct 62 | ``` 63 | 64 | To work correctly with [`mypy`](https://www.mypy-lang.org/), an extension is 65 | required; add to your `mypy.ini`: 66 | 67 | ```ini 68 | [mypy] 69 | plugins = dataclasses_struct.ext.mypy_plugin 70 | ``` 71 | 72 | See [the docs](https://harrymander.xyz/dataclasses-struct/guide/#type-checking) 73 | for more info on type checking. 74 | 75 | ## Development and contributing 76 | 77 | Pull requests are welcomed! 78 | 79 | This project uses [uv](https://docs.astral.sh/uv/) for packaging and dependency 80 | management. To install all dependencies (including development dependencies) 81 | into a virtualenv for local development: 82 | 83 | ``` 84 | uv sync 85 | ``` 86 | 87 | Uses `pytest` for testing: 88 | 89 | ``` 90 | uv run pytest 91 | ``` 92 | 93 | (You may omit the `uv run` if the virtualenv is activated.) 94 | 95 | Uses `ruff` for linting and formatting, which is enforced on pull requests: 96 | 97 | ``` 98 | uv run ruff format 99 | uv run ruff check 100 | ``` 101 | 102 | See `pyproject.toml` for the list of enabled checks. I recommend installing the 103 | provided [`pre-commmit`](https://pre-commit.com/) hooks to ensure new commits 104 | pass linting: 105 | 106 | ``` 107 | pre-commit install 108 | ``` 109 | 110 | This will help speed-up pull requests by reducing the chance of failing CI 111 | checks. 112 | 113 | PRs must also pass `mypy` checks (`uv run mypy`). 114 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["*"] 6 | pull_request: 7 | branches: ["*"] 8 | 9 | env: 10 | UV_VERSION: "0.9.16" 11 | 12 | jobs: 13 | pre-commit-checks: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v4 18 | - name: Run pre-commit checks 19 | uses: pre-commit/action@v3.0.1 20 | 21 | tests: 22 | needs: pre-commit-checks 23 | strategy: 24 | matrix: 25 | pyversion: ["3.9", "3.10", "3.11", "3.12", "3.13"] 26 | os: ["Ubuntu", "Windows", "macOS"] 27 | include: 28 | - os: Windows 29 | image: windows-latest 30 | - os: Ubuntu 31 | image: ubuntu-latest 32 | - os: macOS 33 | image: macos-latest 34 | name: ${{ matrix.os }} / ${{ matrix.pyversion }} 35 | runs-on: ${{ matrix.image }} 36 | defaults: 37 | run: 38 | shell: bash 39 | env: 40 | UV_LOCKED: true 41 | steps: 42 | - name: Checkout repo 43 | uses: actions/checkout@v4 44 | 45 | - name: Set up Python ${{ matrix.pyversion }} 46 | uses: actions/setup-python@v5 47 | with: 48 | python-version: ${{ matrix.pyversion }} 49 | 50 | - name: Setup uv 51 | uses: astral-sh/setup-uv@v7 52 | with: 53 | version: ${{ env.UV_VERSION }} 54 | enable-cache: true 55 | 56 | - name: Install Python dependencies 57 | run: uv sync 58 | 59 | - name: Python type checking 60 | run: uv run mypy 61 | 62 | - name: Configure MSVC 63 | uses: ilammy/msvc-dev-cmd@v1 64 | if: matrix.os == 'Windows' 65 | 66 | - name: Run tests 67 | run: uv run pytest --color=yes --cov --cov-report=xml 68 | 69 | - name: Upload coverage reports to Codecov 70 | uses: codecov/codecov-action@v4 71 | if: matrix.os == 'Ubuntu' && matrix.pyversion == '3.13' 72 | with: 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | files: coverage.xml 75 | 76 | build-docs: 77 | needs: tests 78 | runs-on: ubuntu-latest 79 | steps: 80 | - name: Checkout repo 81 | uses: actions/checkout@v4 82 | - name: Set up Python 83 | uses: actions/setup-python@v5 84 | with: 85 | python-version: "3.12" 86 | - name: Setup uv 87 | uses: astral-sh/setup-uv@v7 88 | with: 89 | version: ${{ env.UV_VERSION }} 90 | enable-cache: true 91 | cache-dependency-glob: requirements-docs.txt 92 | activate-environment: true 93 | - name: Install Python dependencies 94 | run: uv pip install -r requirements-docs.txt 95 | - name: Configure mkdocs for GitHub Pages 96 | run: | 97 | sed -i 's,^site_url: .*$,site_url: https://harrymander.xyz/dataclasses-struct/,g' mkdocs.yml 98 | diff=$(git diff mkdocs.yml) 99 | if [ -z "$diff" ]; then 100 | echo "mkdocs.yml was not modified!" 101 | exit 1 102 | fi 103 | - name: Build site 104 | run: mkdocs build --strict --site-dir site 105 | - name: Upload artifact 106 | uses: actions/upload-pages-artifact@v3 107 | id: deployment 108 | with: 109 | path: site/ 110 | 111 | publish-docs: 112 | if: "${{ github.ref == 'refs/heads/main' }}" 113 | needs: build-docs 114 | runs-on: ubuntu-latest 115 | permissions: 116 | pages: write 117 | id-token: write 118 | environment: 119 | # The current branch must have access to the github-pages environment. 120 | # See 'Deployment branches and tags' settings for 'github-pages' 121 | # environment in 122 | # github.com/harrymander/dataclasses-struct/settings/environments 123 | name: github-pages 124 | url: ${{ steps.deployment.outputs.page_url }} 125 | steps: 126 | - name: Deploy to GitHub Pages 127 | id: deployment 128 | uses: actions/deploy-pages@v4 129 | -------------------------------------------------------------------------------- /test/test_cstruct.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | from pathlib import Path 4 | from typing import Annotated 5 | 6 | import pytest 7 | 8 | import dataclasses_struct as dcs 9 | 10 | pytestmark = pytest.mark.cc 11 | 12 | 13 | def run(*args: str) -> None: 14 | subprocess.run(args, check=True) 15 | 16 | 17 | if sys.platform.startswith("win"): 18 | 19 | def _compile_cstruct(outdir: Path, native: bool) -> Path: 20 | exe_path = outdir / "struct-tester.exe" 21 | run( 22 | "cl.exe", 23 | "test\\struct.c", 24 | f"/Fo:{outdir}", 25 | "/WX", 26 | f"/{'D' if native else 'U'}TEST_NATIVE_INTS", 27 | "/link", 28 | f"/out:{exe_path}", 29 | ) 30 | return exe_path 31 | 32 | else: 33 | 34 | def _compile_cstruct(outdir: Path, native: bool) -> Path: 35 | exe_path = outdir / "struct-tester" 36 | run( 37 | "cc", 38 | "-o", 39 | str(exe_path), 40 | "test/struct.c", 41 | "-Wall", 42 | "-Werror", 43 | f"-{'D' if native else 'U'}TEST_NATIVE_INTS", 44 | ) 45 | return exe_path 46 | 47 | 48 | def _cstruct(tmp_path: Path, native: bool) -> bytes: 49 | exe = _compile_cstruct(tmp_path, native) 50 | outpath = tmp_path / "struct" 51 | run(str(exe), str(outpath)) 52 | with open(outpath, "rb") as f: 53 | return f.read() 54 | 55 | 56 | @pytest.fixture 57 | def native_cstruct(tmp_path: Path) -> bytes: 58 | return _cstruct(tmp_path, native=True) 59 | 60 | 61 | def test_unpack_from_cstruct_with_native_size(native_cstruct: bytes): 62 | @dcs.dataclass_struct(size="native") 63 | class Test: 64 | test_bool: bool = True 65 | test_float: dcs.F32 = 1.5 66 | test_double: float = 2.5 67 | test_char: bytes = b"!" 68 | test_char_array: Annotated[bytes, 10] = b"123456789\0" 69 | 70 | test_signed_char: dcs.SignedChar = -10 71 | test_unsigned_char: dcs.UnsignedChar = 10 72 | test_signed_short: dcs.Short = -500 73 | test_unsigned_short: dcs.UnsignedShort = 500 74 | test_signed_int: int = -5000 75 | test_unsigned_int: dcs.UnsignedInt = 5000 76 | test_signed_long: dcs.Long = -6000 77 | test_unsigned_long: dcs.UnsignedLong = 6000 78 | test_signed_long_long: dcs.LongLong = -7000 79 | test_unsigned_long_long: dcs.UnsignedLongLong = 7000 80 | test_size: dcs.UnsignedSize = 8000 81 | test_pointer: dcs.Pointer = 0 82 | 83 | @dcs.dataclass_struct(size="native") 84 | class Container: 85 | t1: Test 86 | t2: Test 87 | 88 | c = Container(Test(), Test()) 89 | assert len(c.pack()) == len(native_cstruct) 90 | assert c == Container.from_packed(native_cstruct) 91 | 92 | 93 | @pytest.fixture 94 | def std_cstruct(tmp_path: Path) -> bytes: 95 | return _cstruct(tmp_path, native=False) 96 | 97 | 98 | def uint_max(n: int) -> int: 99 | return 2**n - 1 100 | 101 | 102 | def int_min(n: int) -> int: 103 | return -(2 ** (n - 1)) 104 | 105 | 106 | def test_unpack_from_cstruct_with_std_size(std_cstruct: bytes): 107 | @dcs.dataclass_struct(size="std") 108 | class Test: 109 | test_bool: bool = True 110 | test_float: dcs.F32 = 1.5 111 | test_double: float = 2.5 112 | test_char: bytes = b"!" 113 | test_char_array: Annotated[bytes, 10] = b"123456789\0" 114 | 115 | test_uint8: dcs.U8 = uint_max(8) 116 | test_int8: dcs.I8 = int_min(8) 117 | test_uint16: dcs.U16 = uint_max(16) 118 | test_int16: dcs.I16 = int_min(16) 119 | test_uint32: dcs.U32 = uint_max(32) 120 | test_int32: dcs.I32 = int_min(32) 121 | test_uint64: dcs.U64 = uint_max(64) 122 | test_int64: dcs.I64 = int_min(64) 123 | 124 | @dcs.dataclass_struct(size="std") 125 | class Container: 126 | t1: Test 127 | t2: Test 128 | 129 | c = Container(Test(), Test()) 130 | assert len(c.pack()) == len(std_cstruct) 131 | assert c == Container.from_packed(std_cstruct) 132 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from collections.abc import Iterable 3 | from contextlib import contextmanager 4 | from typing import Callable, List # noqa: UP035 5 | 6 | import pytest 7 | 8 | import dataclasses_struct as dcs 9 | 10 | std_byteorders = ("native", "big", "little", "network") 11 | native_byteorders = ("native",) 12 | 13 | ALL_VALID_SIZE_BYTEORDER_PAIRS = ( 14 | *(("native", e) for e in native_byteorders), 15 | *(("std", e) for e in std_byteorders), 16 | ) 17 | 18 | 19 | ParametrizeDecorator = Callable[[Callable], pytest.MarkDecorator] 20 | 21 | TestFields = list[tuple[type, str]] 22 | native_only_int_fields: TestFields = [ 23 | (dcs.SignedChar, "b"), 24 | (dcs.UnsignedChar, "B"), 25 | (dcs.Short, "h"), 26 | (dcs.UnsignedShort, "H"), 27 | (int, "i"), 28 | (dcs.Int, "i"), 29 | (dcs.UnsignedInt, "I"), 30 | (dcs.Long, "l"), 31 | (dcs.UnsignedLong, "L"), 32 | (dcs.LongLong, "q"), 33 | (dcs.UnsignedLongLong, "Q"), 34 | (dcs.SignedSize, "n"), 35 | (dcs.UnsignedSize, "N"), 36 | (dcs.Pointer, "P"), 37 | ] 38 | std_only_int_fields: TestFields = [ 39 | (dcs.U8, "B"), 40 | (dcs.U16, "H"), 41 | (dcs.U32, "I"), 42 | (dcs.U64, "Q"), 43 | (dcs.I8, "b"), 44 | (dcs.I16, "h"), 45 | (dcs.I32, "i"), 46 | (dcs.I64, "q"), 47 | ] 48 | float_fields: TestFields = [ 49 | (dcs.F16, "e"), 50 | (dcs.F32, "f"), 51 | (dcs.F64, "d"), 52 | (float, "d"), 53 | ] 54 | bool_fields: TestFields = [(dcs.Bool, "?"), (bool, "?")] 55 | char_fields: TestFields = [(dcs.Char, "c"), (bytes, "c")] 56 | common_fields: TestFields = float_fields + bool_fields + char_fields 57 | 58 | 59 | def parametrize_fields( 60 | fields: TestFields, field_argname: str, format_argname=None 61 | ): 62 | fields_iter: Iterable 63 | if format_argname: 64 | argnames = ",".join((field_argname, format_argname)) 65 | fields_iter = fields 66 | else: 67 | argnames = field_argname 68 | fields_iter = (field[0] for field in fields) 69 | 70 | def mark(f): 71 | return pytest.mark.parametrize(argnames, fields_iter)(f) 72 | 73 | return mark 74 | 75 | 76 | def parametrize_std_byteorders( 77 | argname: str = "byteorder", 78 | ) -> ParametrizeDecorator: 79 | def mark(f) -> pytest.MarkDecorator: 80 | return pytest.mark.parametrize(argname, std_byteorders)(f) 81 | 82 | return mark 83 | 84 | 85 | def parametrize_all_sizes_and_byteorders( 86 | size_argname: str = "size", byteorder_argname: str = "byteorder" 87 | ) -> ParametrizeDecorator: 88 | def mark(f) -> pytest.MarkDecorator: 89 | return pytest.mark.parametrize( 90 | ",".join((size_argname, byteorder_argname)), 91 | ALL_VALID_SIZE_BYTEORDER_PAIRS, 92 | )(f) 93 | 94 | return mark 95 | 96 | 97 | def parametrize_all_list_types() -> ParametrizeDecorator: 98 | def mark(f) -> pytest.MarkDecorator: 99 | return pytest.mark.parametrize( 100 | "list_type", 101 | [list, List], # noqa: UP006 102 | )(f) 103 | 104 | return mark 105 | 106 | 107 | skipif_kw_only_not_supported = pytest.mark.skipif( 108 | sys.version_info < (3, 10), 109 | reason="kw_only added in Python 3.10", 110 | ) 111 | 112 | 113 | @contextmanager 114 | def raises_default_value_out_of_range_error(): 115 | with pytest.raises(ValueError, match=r"^value out of range for"): 116 | yield 117 | 118 | 119 | @contextmanager 120 | def raises_default_value_invalid_type_error(): 121 | with pytest.raises(TypeError, match=r"^invalid type for field: expected"): 122 | yield 123 | 124 | 125 | @contextmanager 126 | def raises_unsupported_size_mode(supported_mode: str): 127 | with pytest.raises( 128 | TypeError, 129 | match=rf"^field .+? only supported in {supported_mode} size mode$", 130 | ): 131 | yield 132 | 133 | 134 | @contextmanager 135 | def raises_field_type_not_supported(): 136 | with pytest.raises(TypeError, match=r"^type not supported:"): 137 | yield 138 | 139 | 140 | @contextmanager 141 | def raises_invalid_field_annotation(): 142 | with pytest.raises(TypeError, match=r"^invalid field annotation:"): 143 | yield 144 | -------------------------------------------------------------------------------- /test/test_decorator.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import re 3 | 4 | import pytest 5 | from conftest import parametrize_all_sizes_and_byteorders 6 | 7 | import dataclasses_struct as dcs 8 | 9 | 10 | def test_no_parens_fails(): 11 | msg = "dataclass_struct() takes 0 positional arguments but 1 was given" 12 | with pytest.raises(TypeError, match=rf"^{re.escape(msg)}$"): 13 | 14 | @dcs.dataclass_struct 15 | class _: # type: ignore 16 | pass 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "kwargs", 21 | ( 22 | # Invalid byteorder with explicit arg size='native' 23 | {"size": "native", "byteorder": "big"}, 24 | {"size": "native", "byteorder": "little"}, 25 | {"size": "native", "byteorder": "network"}, 26 | # Invalid byteorder with default arg size='native' 27 | {"byteorder": "big"}, 28 | {"byteorder": "little"}, 29 | {"byteorder": "network"}, 30 | # Invalid parameters 31 | {"byteorder": "invalid_byteorder"}, 32 | {"size": "invalid_size"}, 33 | {"size": "std", "byteorder": "invalid_byteorder"}, 34 | ), 35 | ) 36 | def test_invalid_decorator_args(kwargs): 37 | with pytest.raises(ValueError): 38 | 39 | @dcs.dataclass_struct(**kwargs) 40 | class _: 41 | pass 42 | 43 | 44 | @parametrize_all_sizes_and_byteorders() 45 | def test_valid_sizes_and_byteorders(size, byteorder) -> None: 46 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 47 | class _: 48 | pass 49 | 50 | 51 | @pytest.mark.parametrize( 52 | "size,byteorder,mode", 53 | ( 54 | ("native", "native", "@"), 55 | ("std", "native", "="), 56 | ("std", "little", "<"), 57 | ("std", "big", ">"), 58 | ("std", "network", "!"), 59 | ), 60 | ) 61 | def test_mode_char(size, byteorder, mode: str) -> None: 62 | @dcs.dataclass_struct(size=size, byteorder=byteorder) # type: ignore 63 | class Test: 64 | pass 65 | 66 | assert Test.__dataclass_struct__.mode == mode 67 | assert Test.__dataclass_struct__.format == mode 68 | 69 | 70 | def test_default_mode_char_is_native() -> None: 71 | @dcs.dataclass_struct() 72 | class Test: 73 | pass 74 | 75 | assert Test.__dataclass_struct__.mode == "@" 76 | assert Test.__dataclass_struct__.format == "@" 77 | 78 | 79 | @parametrize_all_sizes_and_byteorders() 80 | def test_empty_class_has_zero_size(size, byteorder) -> None: 81 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 82 | class Test: 83 | pass 84 | 85 | assert Test.__dataclass_struct__.size == 0 86 | 87 | 88 | def test_class_is_dataclass_struct() -> None: 89 | @dcs.dataclass_struct() 90 | class Test: 91 | pass 92 | 93 | assert dcs.is_dataclass_struct(Test) 94 | 95 | 96 | def test_object_is_dataclass_struct() -> None: 97 | @dcs.dataclass_struct() 98 | class Test: 99 | pass 100 | 101 | assert dcs.is_dataclass_struct(Test()) 102 | 103 | 104 | def test_object_is_dataclass() -> None: 105 | @dcs.dataclass_struct() 106 | class Test: 107 | pass 108 | 109 | assert dataclasses.is_dataclass(Test()) 110 | 111 | 112 | def test_class_is_dataclass() -> None: 113 | @dcs.dataclass_struct() 114 | class Test: 115 | pass 116 | 117 | assert dataclasses.is_dataclass(Test) 118 | 119 | 120 | def test_undecorated_class_is_not_dataclass_struct() -> None: 121 | class Test: 122 | pass 123 | 124 | assert not dcs.is_dataclass_struct(Test) 125 | 126 | 127 | def test_undecorated_object_is_not_dataclass_struct() -> None: 128 | class Test: 129 | pass 130 | 131 | assert not dcs.is_dataclass_struct(Test()) 132 | 133 | 134 | def test_stdlib_dataclass_class_is_not_dataclass_struct() -> None: 135 | @dataclasses.dataclass 136 | class Test: 137 | pass 138 | 139 | assert not dcs.is_dataclass_struct(Test) 140 | 141 | 142 | def test_stdlib_dataclass_object_is_not_dataclass_struct() -> None: 143 | @dataclasses.dataclass 144 | class Test: 145 | pass 146 | 147 | assert not dcs.is_dataclass_struct(Test()) 148 | 149 | 150 | @pytest.mark.parametrize( 151 | "kwarg,value", 152 | [("slots", True), ("weakref_slot", True)], 153 | ) 154 | def test_unsupported_dataclass_kwarg_fails(kwarg: str, value): 155 | escaped = re.escape(kwarg) 156 | with pytest.raises( 157 | ValueError, 158 | match=rf"^dataclass '{escaped}' keyword argument is not supported$", 159 | ): 160 | dcs.dataclass_struct(**{kwarg: value}) 161 | -------------------------------------------------------------------------------- /dataclasses_struct/field.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import ctypes 3 | from typing import Any, ClassVar, Generic, Literal, TypeVar, Union 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | class Field(abc.ABC, Generic[T]): 9 | is_native: bool = True 10 | is_std: bool = True 11 | field_type: Union[type[T], tuple[type[T], ...]] 12 | 13 | @abc.abstractmethod 14 | def format(self) -> str: ... 15 | 16 | def validate_default(self, val: T) -> None: 17 | pass 18 | 19 | def __repr__(self) -> str: 20 | return f"{type(self).__name__}" 21 | 22 | 23 | class BoolField(Field[bool]): 24 | field_type = bool 25 | 26 | def format(self) -> str: 27 | return "?" 28 | 29 | 30 | class CharField(Field[bytes]): 31 | field_type = bytes 32 | 33 | def format(self) -> str: 34 | return "c" 35 | 36 | def validate_default(self, val: bytes) -> None: 37 | if len(val) != 1: 38 | raise ValueError("value must be a single byte") 39 | 40 | 41 | class IntField(Field[int]): 42 | field_type = int 43 | 44 | def __init__( 45 | self, 46 | fmt: str, 47 | signed: bool, 48 | size: int, 49 | ): 50 | if signed and fmt.isupper(): 51 | raise ValueError( 52 | "signed integer should have lowercase format string" 53 | ) 54 | 55 | self.signed = signed 56 | self.size = size 57 | self._format = fmt 58 | 59 | nbits = self.size * 8 60 | if signed: 61 | exp = 1 << (nbits - 1) 62 | self.min_ = -exp 63 | self.max_ = exp - 1 64 | else: 65 | self.min_ = 0 66 | self.max_ = (1 << nbits) - 1 67 | 68 | def format(self) -> str: 69 | return self._format 70 | 71 | def validate_default(self, val: int) -> None: 72 | if not (self.min_ <= val <= self.max_): 73 | sign = "signed" if self.signed else "unsigned" 74 | n = self.size * 8 75 | raise ValueError(f"value out of range for {n}-bit {sign} integer") 76 | 77 | def __repr__(self) -> str: 78 | sign = "signed" if self.signed else "unsigned" 79 | return f"{super().__repr__()}({sign}, {self.size * 8}-bit)" 80 | 81 | 82 | class StdIntField(IntField): 83 | is_native = False 84 | _unsigned_formats: ClassVar = { 85 | 1: "B", 86 | 2: "H", 87 | 4: "I", 88 | 8: "Q", 89 | } 90 | 91 | def __init__(self, signed: bool, size: Literal[1, 2, 4, 8]): 92 | fmt = self._unsigned_formats[size] 93 | if signed: 94 | fmt = fmt.lower() 95 | super().__init__(fmt, signed, size) 96 | 97 | 98 | class SignedStdIntField(StdIntField): 99 | def __init__(self, size: Literal[1, 2, 4, 8]): 100 | super().__init__(True, size) 101 | 102 | 103 | class UnsignedStdIntField(StdIntField): 104 | def __init__(self, size: Literal[1, 2, 4, 8]): 105 | super().__init__(False, size) 106 | 107 | 108 | class FloatingPointField(Field[float]): 109 | field_type = (int, float) 110 | 111 | def __init__(self, format: str): 112 | self._format = format 113 | 114 | def format(self) -> str: 115 | return self._format 116 | 117 | 118 | class NativeIntField(IntField): 119 | is_std = False 120 | 121 | def __init__(self, fmt: str, ctype_name: str): 122 | size = ctypes.sizeof(getattr(ctypes, f"c_{ctype_name}")) 123 | signed = not ctype_name.startswith("u") 124 | super().__init__(fmt, signed, size) 125 | 126 | 127 | class SizeField(IntField): 128 | is_std = False 129 | 130 | def __init__(self, signed: bool): 131 | fmt = "n" if signed else "N" 132 | size = ctypes.sizeof(ctypes.c_ssize_t if signed else ctypes.c_size_t) 133 | super().__init__(fmt, signed, size) 134 | 135 | def validate_default(self, val: int) -> None: 136 | if not (self.min_ <= val <= self.max_): 137 | sign = "signed" if self.signed else "unsigned" 138 | raise ValueError(f"value out of range for {sign} size type") 139 | 140 | 141 | class PointerField(IntField): 142 | is_std = False 143 | 144 | def __init__(self): 145 | super().__init__("P", False, ctypes.sizeof(ctypes.c_void_p)) 146 | 147 | def format(self) -> str: 148 | return "P" 149 | 150 | def validate_default(self, val: int) -> None: 151 | if not (self.min_ <= val <= self.max_): 152 | raise ValueError("value out of range for system pointer") 153 | 154 | 155 | builtin_fields: dict[type[Any], Field[Any]] = { 156 | int: NativeIntField("i", "int"), 157 | float: FloatingPointField("d"), 158 | bool: BoolField(), 159 | bytes: CharField(), 160 | } 161 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # dataclasses-struct 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/dataclasses-struct)](https://pypi.org/project/dataclasses-struct/) 4 | [![Python versions](https://img.shields.io/pypi/pyversions/dataclasses-struct)](https://pypi.org/project/dataclasses-struct/) 5 | [![Tests status](https://github.com/harrymander/dataclasses-struct/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/harrymander/dataclasses-struct/actions/workflows/ci.yml) 6 | [![Code coverage](https://img.shields.io/codecov/c/gh/harrymander/dataclasses-struct)](https://app.codecov.io/gh/harrymander/dataclasses-struct) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/harrymander/dataclasses-struct/blob/main/LICENSE) 8 | 9 | A simple Python package that combines 10 | [`dataclasses`](https://docs.python.org/3/library/dataclasses.html) with 11 | [`struct`](https://docs.python.org/3/library/struct.html) for packing and 12 | unpacking Python dataclasses to fixed-length `bytes` representations. 13 | 14 | ## Installation 15 | 16 | This package is available on pypi: 17 | 18 | ``` 19 | pip install dataclasses-struct 20 | ``` 21 | 22 | To work correctly with [`mypy`](https://www.mypy-lang.org/), an extension is 23 | required; add to your `mypy.ini`: 24 | 25 | ```ini 26 | [mypy] 27 | plugins = dataclasses_struct.ext.mypy_plugin 28 | ``` 29 | 30 | (See [the guide](guide.md#type-checking) for more details on type checking.) 31 | 32 | ## Quick start 33 | 34 | By default, dataclass-structs use native sizes, alignment, and byte ordering 35 | (endianness). 36 | 37 | ```python 38 | import dataclasses 39 | from typing import Annotated 40 | 41 | import dataclasses_struct as dcs # (1)! 42 | 43 | @dcs.dataclass_struct(size="native", byteorder="native") # (2)! 44 | class Vector2d: 45 | x: dcs.F64 # (3)! 46 | y: float #(4)! 47 | 48 | @dcs.dataclass_struct(kw_only=True) #(5)! 49 | class Object: 50 | position: Vector2d #(6)! 51 | velocity: Vector2d = dataclasses.field( # (7)! 52 | default_factory=lambda: Vector2d(0, 0) 53 | ) 54 | name: Annotated[bytes, 8] #(8)! 55 | ``` 56 | 57 | 1. This convention of importing `dataclasses_struct` under the alias `dcs` is 58 | used throughout these docs, but you don't have to follow this if you don't 59 | want to. 60 | 2. The `size` and `byteorder` keyword arguments control the size, alignment, and 61 | endianness of the class' packed binary representation. The default mode 62 | `"native"` is native for both arguments. 63 | 3. A double precision floating point, equivalent to `double` in C. 64 | 4. The builtin `float` type is an alias to `dcs.F64`. 65 | 5. The [`dataclass_struct`][dataclasses_struct.dataclass_struct] decorator 66 | supports most of the keyword arguments supported by the stdlib 67 | [`dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) 68 | decorator. 69 | 6. Classes decorated with 70 | [`dcs.dataclass_struct`][dataclasses_struct.dataclass_struct] can be used as 71 | fields in other dataclass-structs provided they have the same size and 72 | byteorder modes. 73 | 7. The stdlib 74 | [`dataclasses.field`](https://docs.python.org/3/library/dataclasses.html#dataclasses.field) 75 | function can be used for more complex field configurations, such as using 76 | a mutable value as a field default. 77 | 8. Fixed-length `bytes` arrays can be represented by annotating a field with 78 | a non-zero positive integer using `typing.Annotated`. Values longer than the 79 | length will be truncated and values shorted will be zero-padded. 80 | 81 | Instances of decorated classes have a 82 | [`pack`][dataclasses_struct.dataclass.DataclassStructProtocol.pack] method that 83 | returns the packed representation of the object in `bytes`: 84 | 85 | ```python 86 | >>> obj = Object(position=Vector2d(1.5, -5.6), name=b"object1") 87 | >>> obj 88 | Object(position=Vector2d(x=1.5, y=-5.6), velocity=Vector2d(x=0, y=0), name=b'object1') 89 | >>> packed = obj.pack() 90 | >>> packed 91 | b'\x00\x00\x00\x00\x00\x00\xf8?ffffff\x16\xc0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00object1\x00' 92 | ``` 93 | 94 | Decorated classes have an 95 | [`from_packed`][dataclasses_struct.dataclass.DataclassStructProtocol.from_packed] 96 | class method that takes the packed representation and returns an instance of the 97 | class: 98 | 99 | ```python 100 | >>> Object.from_packed(packed) 101 | Object(position=Vector2d(x=1.5, y=-5.6), velocity=Vector2d(x=0.0, y=0.0), name=b'object1\x00') 102 | ``` 103 | 104 | In `size="native"` mode, integer type names follow the standard C integer type 105 | names: 106 | 107 | ```python 108 | @dcs.dataclass_struct() 109 | class NativeIntegers: 110 | c_int: dcs.Int 111 | c_int_alias: int # (1)! 112 | c_unsigned_short: dcs.UnsignedShort 113 | void_pointer: dcs.Pointer # (2)! 114 | size_t: dcs.UnsignedSize 115 | 116 | # etc. 117 | ``` 118 | 119 | 1. Alias to `dcs.Int`. 120 | 2. Equivalent to `void *` pointer in C. 121 | 122 | In `size="std"` mode, integer type names follow the standard [fixed-width 123 | integer type](https://en.cppreference.com/w/c/types/integer.html#Types) names in 124 | C: 125 | 126 | ```python 127 | @dcs.dataclass_struct(size="std") 128 | class StdIntegers: 129 | int8_t: dcs.I8 130 | int32_t: dcs.I32 131 | uint64_t: dcs.U64 132 | 133 | # etc. 134 | ``` 135 | 136 | See [the guide](guide.md#supported-type-annotations) for the full list of 137 | supported field types. 138 | -------------------------------------------------------------------------------- /dataclasses_struct/types.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from . import field 4 | 5 | Char = bytes 6 | """Single char type. Supported in both size modes.""" 7 | 8 | Bool = Annotated[bool, field.BoolField()] 9 | """Boolean type. Supported in both size modes.""" 10 | 11 | I8 = Annotated[int, field.SignedStdIntField(1)] 12 | """Fixed-width 8-bit signed integer. Supported with `size="std"`.""" 13 | 14 | U8 = Annotated[int, field.UnsignedStdIntField(1)] 15 | """Fixed-width 8-bit unsigned integer. Supported with `size="std"`.""" 16 | 17 | I16 = Annotated[int, field.SignedStdIntField(2)] 18 | """Fixed-width 16-bit signed integer. Supported with `size="std"`.""" 19 | 20 | U16 = Annotated[int, field.UnsignedStdIntField(2)] 21 | """Fixed-width 16-bit unsigned integer. Supported with `size="std"`.""" 22 | 23 | I32 = Annotated[int, field.SignedStdIntField(4)] 24 | """Fixed-width 32-bit signed integer. Supported with `size="std"`.""" 25 | 26 | U32 = Annotated[int, field.UnsignedStdIntField(4)] 27 | """Fixed-width 32-bit unsigned integer. Supported with `size="std"`.""" 28 | 29 | I64 = Annotated[int, field.SignedStdIntField(8)] 30 | """Fixed-width 64-bit signed integer. Supported with `size="std"`.""" 31 | 32 | U64 = Annotated[int, field.UnsignedStdIntField(8)] 33 | """Fixed-width 64-bit unsigned integer. Supported with `size="std"`.""" 34 | 35 | 36 | # Native integer types 37 | SignedChar = Annotated[int, field.NativeIntField("b", "byte")] 38 | """Equivalent to native C `signed char`. Supported with `size="native"`.""" 39 | 40 | UnsignedChar = Annotated[int, field.NativeIntField("B", "ubyte")] 41 | """Equivalent to native C `unsigned char`. Supported with `size="native"`.""" 42 | 43 | Short = Annotated[int, field.NativeIntField("h", "short")] 44 | """Equivalent to native C `short`. Supported with `size="native"`.""" 45 | 46 | UnsignedShort = Annotated[int, field.NativeIntField("H", "ushort")] 47 | """Equivalent to native C `unsigned short`. Supported with `size="native"`.""" 48 | 49 | Int = Annotated[int, field.NativeIntField("i", "int")] 50 | """Equivalent to native C `int`. Supported with `size="native"`.""" 51 | 52 | UnsignedInt = Annotated[int, field.NativeIntField("I", "uint")] 53 | """Equivalent to native C `unsigned int`. Supported with `size="native"`.""" 54 | 55 | Long = Annotated[int, field.NativeIntField("l", "long")] 56 | """Equivalent to native C `long`. Supported with `size="native"`.""" 57 | 58 | UnsignedLong = Annotated[int, field.NativeIntField("L", "ulong")] 59 | """Equivalent to native C `unsigned long`. Supported with `size="native"`.""" 60 | 61 | LongLong = Annotated[int, field.NativeIntField("q", "longlong")] 62 | """Equivalent to native C `long long`. Supported with `size="native"`.""" 63 | 64 | UnsignedLongLong = Annotated[int, field.NativeIntField("Q", "ulonglong")] 65 | """Equivalent to native C `unsigned long long`. Supported with 66 | `size="native"`.""" 67 | 68 | 69 | # Native size types 70 | UnsignedSize = Annotated[int, field.SizeField(signed=False)] 71 | """Equivalent to native C `size_t`. Supported with `size="native"`.""" 72 | 73 | SignedSize = Annotated[int, field.SizeField(signed=True)] 74 | """Equivalent to native C `ssize_t` (a POSIX extension type). Supported with 75 | `size="native"`.""" 76 | 77 | # Native pointer types 78 | Pointer = Annotated[int, field.PointerField()] 79 | """Equivalent to native C `void *` pointer. Supported with `size="native"`.""" 80 | 81 | # Floating point types 82 | F16 = Annotated[float, field.FloatingPointField("e")] 83 | """Half-precision floating point number. Supported in both size modes. 84 | 85 | Some compilers provide support for half precision floats on certain platforms 86 | (e.g. [GCC](https://gcc.gnu.org/onlinedocs/gcc/Half-Precision.html), 87 | [Clang](https://clang.llvm.org/docs/LanguageExtensions.html#half-precision-floating-point)). 88 | It is also available as 89 | [`std::float16_t`](https://en.cppreference.com/w/cpp/types/floating-point.html) 90 | in C++23. 91 | """ 92 | 93 | F32 = Annotated[float, field.FloatingPointField("f")] 94 | """Single-precision floating point number, equivalent to `float` in C. 95 | Supported in both size modes.""" 96 | 97 | F64 = Annotated[float, field.FloatingPointField("d")] 98 | """Double-precision floating point number, equivalent to `double` in C. 99 | Supported in both size modes.""" 100 | 101 | 102 | class LengthPrefixed(field.Field[bytes]): 103 | """ 104 | Length-prefixed byte array, also known as a 'Pascal string'. 105 | 106 | Packed to a fixed-length array of bytes, where the first byte is the length 107 | of the data. Data shorter than the maximum size is padded with zero bytes. 108 | 109 | Must be used to annotate a `bytes` field with `typing.Annotated`: 110 | 111 | ```python 112 | import dataclasses_struct as dcs 113 | 114 | @dcs.dataclass_struct() 115 | class Example: 116 | fixed_length: Annotated[bytes, dcs.LengthPrefixed(10)] 117 | ``` 118 | 119 | Args: 120 | size: The maximum size of the string including the length byte. Must be 121 | between 2 and 256 inclusive. The maximum array length that can be 122 | stored without truncation is `size - 1`. 123 | 124 | Raises: 125 | ValueError: If `size` is outside the valid range. 126 | """ 127 | 128 | field_type = bytes 129 | 130 | def __init__(self, size: int): 131 | if not (isinstance(size, int) and 2 <= size <= 256): 132 | raise ValueError("size must be an int between 2 and 256") 133 | self.size = size 134 | 135 | def format(self) -> str: 136 | return f"{self.size}p" 137 | 138 | def __repr__(self) -> str: 139 | return f"{type(self).__name__}({self.size})" 140 | 141 | def validate_default(self, val: bytes) -> None: 142 | if len(val) > self.size - 1: 143 | msg = f"bytes cannot be longer than {self.size - 1} bytes" 144 | raise ValueError(msg) 145 | 146 | 147 | class _Padding: 148 | before: bool 149 | 150 | def __init__(self, size: int): 151 | if not isinstance(size, int) or size < 0: 152 | raise ValueError("padding size must be non-negative int") 153 | self.size = size 154 | 155 | def __repr__(self) -> str: 156 | return f"{type(self).__name__}({self.size})" 157 | 158 | 159 | class PadBefore(_Padding): 160 | """Add zero-bytes padding before the field. 161 | 162 | Should be used with `typing.Annotated`. 163 | 164 | ```python 165 | from typing import Annotated 166 | import dataclasses_struct as dcs 167 | 168 | @dcs.dataclass_struct() 169 | class Padded: 170 | x: Annotated[int, dcs.PadBefore(5)] 171 | ``` 172 | 173 | Args: 174 | size: The number of padding bytes to add before the field. 175 | """ 176 | 177 | before = True 178 | 179 | def __init__(self, size: int): 180 | super().__init__(size) 181 | 182 | 183 | class PadAfter(_Padding): 184 | """Add zero-bytes padding after the field. 185 | 186 | Should be used with `typing.Annotated`. 187 | 188 | ```python 189 | from typing import Annotated 190 | import dataclasses_struct as dcs 191 | 192 | @dcs.dataclass_struct() 193 | class Padded: 194 | x: Annotated[int, dcs.PadAfter(5)] 195 | ``` 196 | 197 | Args: 198 | size: The number of padding bytes to add after the field. 199 | """ 200 | 201 | before = False 202 | 203 | def __init__(self, size: int): 204 | super().__init__(size) 205 | -------------------------------------------------------------------------------- /test/test_validation.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | from contextlib import contextmanager 3 | from dataclasses import field 4 | from typing import Annotated 5 | 6 | import pytest 7 | from conftest import ( 8 | bool_fields, 9 | char_fields, 10 | float_fields, 11 | native_only_int_fields, 12 | parametrize_all_list_types, 13 | parametrize_all_sizes_and_byteorders, 14 | parametrize_fields, 15 | parametrize_std_byteorders, 16 | raises_default_value_invalid_type_error, 17 | raises_default_value_out_of_range_error, 18 | std_only_int_fields, 19 | ) 20 | 21 | import dataclasses_struct as dcs 22 | 23 | 24 | def int_min_max(nbits: int, signed: bool) -> tuple[int, int]: 25 | if signed: 26 | exp = 2 ** (nbits - 1) 27 | return -exp, exp - 1 28 | else: 29 | return 0, 2**nbits - 1 30 | 31 | 32 | std_int_out_of_range_vals = [] 33 | std_int_boundary_vals = [] 34 | for field_type, nbits, signed in ( 35 | (dcs.I8, 8, True), 36 | (dcs.U8, 8, False), 37 | (dcs.I16, 16, True), 38 | (dcs.U16, 16, False), 39 | (dcs.I32, 32, True), 40 | (dcs.U32, 32, False), 41 | (dcs.I64, 64, True), 42 | (dcs.U64, 64, False), 43 | ): 44 | min_, max_ = int_min_max(nbits, signed) 45 | std_int_out_of_range_vals.append((field_type, min_ - 1)) 46 | std_int_out_of_range_vals.append((field_type, max_ + 1)) 47 | std_int_boundary_vals.append((field_type, min_)) 48 | std_int_boundary_vals.append((field_type, max_)) 49 | 50 | 51 | @pytest.mark.parametrize("field_type,default", std_int_boundary_vals) 52 | @parametrize_std_byteorders() 53 | def test_std_int_default(field_type, default, byteorder) -> None: 54 | @dcs.dataclass_struct(size="std", byteorder=byteorder) 55 | class Class: 56 | field: field_type = default 57 | 58 | assert Class().field == default 59 | 60 | 61 | @pytest.mark.parametrize("field_type,default", std_int_out_of_range_vals) 62 | @parametrize_std_byteorders() 63 | def test_std_int_default_out_of_range_fails( 64 | field_type, default, byteorder 65 | ) -> None: 66 | with raises_default_value_out_of_range_error(): 67 | 68 | @dcs.dataclass_struct(size="std", byteorder=byteorder) 69 | class _: 70 | x: field_type = default 71 | 72 | 73 | @pytest.mark.parametrize("field_type,default", std_int_out_of_range_vals) 74 | @parametrize_std_byteorders() 75 | def test_std_int_default_out_of_range_with_unvalidated_does_not_fail( 76 | field_type, default, byteorder 77 | ) -> None: 78 | @dcs.dataclass_struct( 79 | size="std", 80 | byteorder=byteorder, 81 | validate_defaults=False, 82 | ) 83 | class Class: 84 | x: field_type = default 85 | 86 | assert Class().x == default 87 | 88 | 89 | native_int_out_of_range_vals = [] 90 | native_int_boundary_vals = [] 91 | for field_type, ctype_type, signed in ( 92 | (dcs.SignedChar, ctypes.c_byte, True), 93 | (dcs.UnsignedChar, ctypes.c_ubyte, False), 94 | (dcs.Short, ctypes.c_short, True), 95 | (dcs.UnsignedShort, ctypes.c_ushort, False), 96 | (int, ctypes.c_int, True), 97 | (dcs.Int, ctypes.c_int, True), 98 | (dcs.UnsignedInt, ctypes.c_uint, False), 99 | (dcs.Long, ctypes.c_long, True), 100 | (dcs.UnsignedLong, ctypes.c_ulong, False), 101 | (dcs.LongLong, ctypes.c_longlong, True), 102 | (dcs.UnsignedLongLong, ctypes.c_ulonglong, False), 103 | (dcs.SignedSize, ctypes.c_ssize_t, True), 104 | (dcs.UnsignedSize, ctypes.c_size_t, False), 105 | (dcs.Pointer, ctypes.c_void_p, False), 106 | ): 107 | min_, max_ = int_min_max(ctypes.sizeof(ctype_type) * 8, signed) 108 | native_int_out_of_range_vals.append((field_type, min_ - 1)) 109 | native_int_out_of_range_vals.append((field_type, max_ + 1)) 110 | native_int_boundary_vals.append((field_type, min_)) 111 | native_int_boundary_vals.append((field_type, max_)) 112 | 113 | 114 | @pytest.mark.parametrize("field_type,default", native_int_boundary_vals) 115 | def test_native_int_default(field_type, default) -> None: 116 | @dcs.dataclass_struct(size="native", byteorder="native") 117 | class Class: 118 | field: field_type = default 119 | 120 | assert Class().field == default 121 | 122 | 123 | @pytest.mark.parametrize("field_type,default", native_int_out_of_range_vals) 124 | def test_native_int_default_out_of_range_fails(field_type, default) -> None: 125 | with raises_default_value_out_of_range_error(): 126 | 127 | @dcs.dataclass_struct(size="native", byteorder="native") 128 | class _: 129 | x: field_type = default 130 | 131 | 132 | @pytest.mark.parametrize("field_type,default", native_int_out_of_range_vals) 133 | def test_native_int_default_out_of_range_with_unvalidated_does_not_fail( 134 | field_type, default 135 | ) -> None: 136 | @dcs.dataclass_struct( 137 | size="native", 138 | byteorder="native", 139 | validate_defaults=False, 140 | ) 141 | class Class: 142 | x: field_type = default 143 | 144 | assert Class().x == default 145 | 146 | 147 | def parametrize_invalid_int_defaults(f): 148 | return pytest.mark.parametrize( 149 | "default", 150 | ( 151 | "wrong", 152 | 1.5, 153 | "1", 154 | None, 155 | ), 156 | )(f) 157 | 158 | 159 | @parametrize_fields(std_only_int_fields, "int_type") 160 | @parametrize_std_byteorders() 161 | @parametrize_invalid_int_defaults 162 | def test_std_int_default_wrong_type_fails( 163 | int_type, byteorder, default 164 | ) -> None: 165 | with raises_default_value_invalid_type_error(): 166 | 167 | @dcs.dataclass_struct(size="std", byteorder=byteorder) 168 | class _: 169 | x: int_type = default 170 | 171 | 172 | @parametrize_fields(native_only_int_fields, "int_type") 173 | @parametrize_invalid_int_defaults 174 | def test_native_int_default_wrong_type_fails(int_type, default) -> None: 175 | with raises_default_value_invalid_type_error(): 176 | 177 | @dcs.dataclass_struct(size="native", byteorder="native") 178 | class _: 179 | x: int_type = default 180 | 181 | 182 | @parametrize_all_sizes_and_byteorders() 183 | @parametrize_fields(char_fields, "char_field") 184 | def test_char_default(byteorder, size, char_field) -> None: 185 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 186 | class Test: 187 | field: char_field = b"1" 188 | 189 | assert Test().field == b"1" 190 | 191 | 192 | @parametrize_all_sizes_and_byteorders() 193 | @parametrize_fields(char_fields, "field_type") 194 | def test_char_default_wrong_type_fails(byteorder, size, field_type) -> None: 195 | with raises_default_value_invalid_type_error(): 196 | 197 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 198 | class _: 199 | x: field_type = "s" 200 | 201 | 202 | @parametrize_all_sizes_and_byteorders() 203 | @pytest.mark.parametrize("c", (b"", b"ab")) 204 | @parametrize_fields(char_fields, "field_type") 205 | def test_char_default_wrong_length_fails( 206 | field_type, byteorder, size, c: bytes 207 | ) -> None: 208 | with pytest.raises(ValueError, match=r"^value must be a single byte$"): 209 | 210 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 211 | class _: 212 | x: field_type = c 213 | 214 | 215 | @parametrize_all_sizes_and_byteorders() 216 | @parametrize_fields(char_fields, "char_field") 217 | def test_bytes_array_default(byteorder, size, char_field) -> None: 218 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 219 | class Test: 220 | field: Annotated[char_field, 10] = b"123" 221 | 222 | assert Test().field == b"123" 223 | 224 | 225 | @parametrize_all_sizes_and_byteorders() 226 | def test_bytes_array_default_too_long_fails(byteorder, size) -> None: 227 | with pytest.raises( 228 | ValueError, 229 | match=r"^bytes cannot be longer than 8 bytes$", 230 | ): 231 | 232 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 233 | class _: 234 | x: Annotated[bytes, 8] = b"123456789" 235 | 236 | 237 | @pytest.mark.parametrize("default", (b"", b"123", b"1234")) 238 | def test_length_prefixed_bytes_default(default: bytes) -> None: 239 | @dcs.dataclass_struct() 240 | class T: 241 | x: Annotated[bytes, dcs.LengthPrefixed(5)] = default 242 | 243 | t = T() 244 | assert t.x == default 245 | 246 | 247 | def test_length_prefixed_bytes_default_too_long_fails() -> None: 248 | with pytest.raises( 249 | ValueError, 250 | match=r"^bytes cannot be longer than 4 bytes$", 251 | ): 252 | 253 | @dcs.dataclass_struct() 254 | class _: 255 | x: Annotated[bytes, dcs.LengthPrefixed(5)] = b"12345" 256 | 257 | 258 | @parametrize_all_sizes_and_byteorders() 259 | @parametrize_fields(float_fields, "float_field") 260 | @pytest.mark.parametrize("default", (10, 10.12)) 261 | def test_float_default(size, byteorder, float_field, default) -> None: 262 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 263 | class Test: 264 | field: float_field = default 265 | 266 | assert Test().field == default 267 | 268 | 269 | @parametrize_all_sizes_and_byteorders() 270 | @parametrize_fields(float_fields, "float_field") 271 | @pytest.mark.parametrize("default", ("wrong", "1.5", None)) 272 | def test_float_default_wrong_type_fails( 273 | byteorder, size, float_field, default 274 | ) -> None: 275 | with raises_default_value_invalid_type_error(): 276 | 277 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 278 | class _: 279 | x: float_field = default # type: ignore 280 | 281 | 282 | @parametrize_all_sizes_and_byteorders() 283 | @parametrize_fields(bool_fields, "bool_field") 284 | def test_bool_default(byteorder, size, bool_field) -> None: 285 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 286 | class Test: 287 | field: bool_field = False 288 | 289 | assert Test().field is False 290 | 291 | 292 | @parametrize_all_sizes_and_byteorders() 293 | @pytest.mark.parametrize("default", ("wrong", "1.5", None, "False")) 294 | def test_bool_default_wrong_type_fails(byteorder, size, default) -> None: 295 | with raises_default_value_invalid_type_error(): 296 | 297 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 298 | class _: 299 | x: bool = default 300 | 301 | 302 | def test_nested_dataclass_default_wrong_type_fails() -> None: 303 | @dcs.dataclass_struct() 304 | class A: 305 | x: int 306 | 307 | @dcs.dataclass_struct() 308 | class B: 309 | x: int 310 | 311 | with raises_default_value_invalid_type_error(): 312 | 313 | @dcs.dataclass_struct() 314 | class _: 315 | a: A = field(default_factory=lambda: B(100)) # type: ignore 316 | 317 | 318 | @contextmanager 319 | def raises_fixed_length_array_wrong_length_error(expected: int, actual: int): 320 | with pytest.raises( 321 | ValueError, 322 | match=( 323 | r"^fixed-length array must have length of " 324 | rf"{expected}, got {actual}$" 325 | ), 326 | ): 327 | yield 328 | 329 | 330 | @pytest.mark.parametrize("default", ([], [1, 2], [1, 2, 3, 4])) 331 | @parametrize_all_list_types() 332 | def test_array_default_wrong_length_fails(list_type, default: list[int]): 333 | with raises_fixed_length_array_wrong_length_error(3, len(default)): 334 | 335 | @dcs.dataclass_struct() 336 | class _: 337 | x: Annotated[list_type[int], 3] = field( 338 | default_factory=lambda: default 339 | ) 340 | 341 | 342 | @pytest.mark.parametrize("default", [1, (1, 2, 3)], ids=["scalar", "tuple"]) 343 | @parametrize_all_list_types() 344 | def test_array_default_wrong_type_fails(list_type, default) -> None: 345 | with raises_default_value_invalid_type_error(): 346 | 347 | @dcs.dataclass_struct() 348 | class _: 349 | x: Annotated[list_type[int], 3] = field( 350 | default_factory=lambda: default 351 | ) 352 | 353 | 354 | @parametrize_all_list_types() 355 | def test_array_default_item_out_of_range_fails(list_type) -> None: 356 | with raises_default_value_out_of_range_error(): 357 | 358 | @dcs.dataclass_struct() 359 | class _: 360 | x: Annotated[list_type[dcs.UnsignedInt], 3] = field( 361 | default_factory=lambda: [1, -2, 3] 362 | ) 363 | 364 | 365 | @parametrize_all_list_types() 366 | def test_2d_array_default_item_out_of_range_fails(list_type) -> None: 367 | with raises_default_value_out_of_range_error(): 368 | 369 | @dcs.dataclass_struct() 370 | class _: 371 | x: Annotated[ 372 | list_type[Annotated[list_type[dcs.UnsignedInt], 2]], 3 373 | ] = field(default_factory=lambda: [[1, 2], [-3, 4], [5, 6]]) 374 | 375 | 376 | @parametrize_all_list_types() 377 | def test_array_default_item_wrong_type_fails(list_type) -> None: 378 | with raises_default_value_invalid_type_error(): 379 | 380 | @dcs.dataclass_struct() 381 | class _: 382 | x: Annotated[list_type[int], 3] = field( 383 | default_factory=lambda: [1, 2.0, 3] 384 | ) 385 | 386 | 387 | @parametrize_all_list_types() 388 | def test_2d_array_default_item_wrong_type_fails(list_type) -> None: 389 | with raises_default_value_invalid_type_error(): 390 | 391 | @dcs.dataclass_struct() 392 | class _: 393 | x: Annotated[list_type[Annotated[list_type[int], 2]], 3] = field( 394 | default_factory=lambda: [[1, 2], [3, 4.0], [5, 6]] 395 | ) 396 | 397 | 398 | @parametrize_all_list_types() 399 | @pytest.mark.parametrize("default", ([], [1], [1, 2, 3])) 400 | def test_2d_array_default_item_wrong_length_fails( 401 | list_type, default: list[int] 402 | ) -> None: 403 | with raises_fixed_length_array_wrong_length_error(2, len(default)): 404 | 405 | @dcs.dataclass_struct() 406 | class _: 407 | x: Annotated[list_type[Annotated[list_type[int], 2]], 3] = field( 408 | default_factory=lambda: [[1, 2], default, [3, 4]] 409 | ) 410 | -------------------------------------------------------------------------------- /test/test_fields.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import itertools 3 | import re 4 | from contextlib import contextmanager 5 | from re import escape 6 | from typing import Annotated 7 | 8 | import pytest 9 | from conftest import ( 10 | ALL_VALID_SIZE_BYTEORDER_PAIRS, 11 | common_fields, 12 | native_only_int_fields, 13 | parametrize_all_list_types, 14 | parametrize_all_sizes_and_byteorders, 15 | parametrize_fields, 16 | parametrize_std_byteorders, 17 | raises_field_type_not_supported, 18 | raises_invalid_field_annotation, 19 | raises_unsupported_size_mode, 20 | skipif_kw_only_not_supported, 21 | std_only_int_fields, 22 | ) 23 | 24 | import dataclasses_struct as dcs 25 | 26 | 27 | def assert_same_format(t1, t2) -> None: 28 | assert t1.__dataclass_struct__.format == t2.__dataclass_struct__.format 29 | 30 | 31 | @parametrize_fields( 32 | native_only_int_fields + common_fields, "field_type", "fmt" 33 | ) 34 | def test_native_size_field_has_correct_format(field_type, fmt) -> None: 35 | @dcs.dataclass_struct(byteorder="native", size="native") 36 | class Test: 37 | field: field_type 38 | 39 | assert Test.__dataclass_struct__.format[1:] == fmt 40 | 41 | 42 | @parametrize_fields(std_only_int_fields, "field_type") 43 | def test_invalid_native_size_fields_fails(field_type) -> None: 44 | with raises_unsupported_size_mode("standard"): 45 | 46 | @dcs.dataclass_struct(byteorder="native", size="native") 47 | class _: 48 | field: field_type 49 | 50 | 51 | @parametrize_fields(std_only_int_fields, "field_type") 52 | @parametrize_all_list_types() 53 | def test_array_with_invalid_native_size_fields_fails( 54 | field_type, list_type 55 | ) -> None: 56 | with raises_unsupported_size_mode("standard"): 57 | 58 | @dcs.dataclass_struct(byteorder="native", size="native") 59 | class _: 60 | field: Annotated[list_type[field_type], 2] 61 | 62 | 63 | @parametrize_fields(std_only_int_fields + common_fields, "field_type", "fmt") 64 | @parametrize_std_byteorders() 65 | def test_valid_std_size_field_has_correct_format( 66 | byteorder, field_type, fmt 67 | ) -> None: 68 | @dcs.dataclass_struct(byteorder=byteorder, size="std") 69 | class Test: 70 | field: field_type 71 | 72 | assert Test.__dataclass_struct__.format[1:] == fmt 73 | 74 | 75 | @parametrize_fields(native_only_int_fields, "field_type") 76 | @parametrize_std_byteorders() 77 | def test_invalid_std_size_fields_fails(byteorder, field_type) -> None: 78 | with raises_unsupported_size_mode("native"): 79 | 80 | @dcs.dataclass_struct(byteorder=byteorder, size="std") 81 | class _: 82 | field: field_type 83 | 84 | 85 | @parametrize_fields(native_only_int_fields, "field_type") 86 | @parametrize_all_list_types() 87 | @parametrize_std_byteorders() 88 | def test_array_with_invalid_std_size_fields_fails( 89 | field_type, list_type, byteorder 90 | ) -> None: 91 | with raises_unsupported_size_mode("native"): 92 | 93 | @dcs.dataclass_struct(byteorder=byteorder, size="std") 94 | class _: 95 | field: Annotated[list_type[field_type], 2] 96 | 97 | 98 | def test_builtin_int_is_int() -> None: 99 | @dcs.dataclass_struct(byteorder="native", size="native") 100 | class Builtin: 101 | x: int 102 | 103 | @dcs.dataclass_struct(byteorder="native", size="native") 104 | class Field: 105 | x: dcs.Int 106 | 107 | assert_same_format(Builtin, Field) 108 | 109 | 110 | @parametrize_all_sizes_and_byteorders() 111 | def test_builtin_bool_is_bool(byteorder, size) -> None: 112 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 113 | class Builtin: 114 | x: bool 115 | 116 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 117 | class Field: 118 | x: dcs.Bool 119 | 120 | assert_same_format(Builtin, Field) 121 | 122 | 123 | @parametrize_all_sizes_and_byteorders() 124 | def test_builtin_float_is_f64(byteorder, size) -> None: 125 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 126 | class Builtin: 127 | x: float 128 | 129 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 130 | class Field: 131 | x: dcs.F64 132 | 133 | assert_same_format(Builtin, Field) 134 | 135 | 136 | @parametrize_all_sizes_and_byteorders() 137 | def test_builtin_bytes_is_char(byteorder, size) -> None: 138 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 139 | class Builtin: 140 | x: bytes 141 | 142 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 143 | class Field: 144 | x: dcs.Char 145 | 146 | assert_same_format(Builtin, Field) 147 | 148 | 149 | @dataclasses.dataclass 150 | class DataClassTest: 151 | pass 152 | 153 | 154 | class VanillaClassTest: 155 | pass 156 | 157 | 158 | @parametrize_all_sizes_and_byteorders() 159 | @pytest.mark.parametrize( 160 | "field_type", [str, list, dict, DataClassTest, VanillaClassTest] 161 | ) 162 | def test_invalid_field_types_fail(byteorder, size, field_type) -> None: 163 | with raises_field_type_not_supported(): 164 | 165 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 166 | class _: 167 | x: field_type 168 | 169 | 170 | @parametrize_all_sizes_and_byteorders() 171 | def test_valid_bytes_length_has_correct_format(size, byteorder) -> None: 172 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 173 | class Test: 174 | field: Annotated[bytes, 3] 175 | 176 | assert Test.__dataclass_struct__.format[1:] == "3s" 177 | 178 | 179 | @pytest.mark.parametrize("length", (-1, 0, 1.0, "1")) 180 | @parametrize_all_sizes_and_byteorders() 181 | def test_invalid_bytes_length_fails(size, byteorder, length: int) -> None: 182 | with pytest.raises( 183 | ValueError, 184 | match=r"^bytes length must be positive non-zero int$", 185 | ): 186 | 187 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 188 | class _: 189 | x: Annotated[bytes, length] 190 | 191 | 192 | @pytest.mark.parametrize("length", (-1, 0, 1.0, "1")) 193 | @parametrize_all_list_types() 194 | def test_invalid_array_length_fails(length: int, list_type) -> None: 195 | with pytest.raises( 196 | ValueError, 197 | match=r"^fixed-length array length must be positive non-zero int$", 198 | ): 199 | 200 | @dcs.dataclass_struct() 201 | class _: 202 | x: Annotated[list_type[int], length] 203 | 204 | 205 | @parametrize_all_sizes_and_byteorders() 206 | @parametrize_all_list_types() 207 | def test_unannotated_list_fails(size, byteorder, list_type) -> None: 208 | with pytest.raises( 209 | TypeError, 210 | match=r"^list types must be marked as a fixed-length using Annotated, " 211 | r"ex: Annotated\[list\[int\], 5\]$", 212 | ): 213 | 214 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 215 | class _: 216 | x: list_type[int] 217 | 218 | 219 | @parametrize_all_sizes_and_byteorders() 220 | @parametrize_all_list_types() 221 | def test_annotated_list_with_invalid_arg_type_fails( 222 | size, byteorder, list_type 223 | ) -> None: 224 | with raises_field_type_not_supported(): 225 | 226 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 227 | class _: 228 | x: Annotated[list_type[str], 5] 229 | 230 | 231 | @parametrize_all_sizes_and_byteorders() 232 | def test_annotated_list_without_arg_type_fails(size, byteorder) -> None: 233 | with raises_invalid_field_annotation(): 234 | 235 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 236 | class _: 237 | x: Annotated[list, 5] 238 | 239 | 240 | @parametrize_all_sizes_and_byteorders() 241 | def test_type_annotated_with_invalid_type_fails(byteorder, size) -> None: 242 | with raises_invalid_field_annotation(): 243 | 244 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 245 | class _: 246 | x: Annotated[int, dcs.I64] 247 | 248 | 249 | def test_type_annotated_with_unsupported_field_type_fails() -> None: 250 | with raises_invalid_field_annotation(): 251 | 252 | @dcs.dataclass_struct() 253 | class _: 254 | x: Annotated[float, dcs.NativeIntField("i", "int")] 255 | 256 | 257 | @contextmanager 258 | def raises_too_many_annotations_error(extra: object): 259 | extra = re.escape(str(extra)) 260 | with pytest.raises(TypeError, match=rf"^too many annotations: {extra}$"): 261 | yield 262 | 263 | 264 | @parametrize_all_sizes_and_byteorders() 265 | def test_bytes_with_too_many_annotations_fails(byteorder, size) -> None: 266 | with raises_too_many_annotations_error(12): 267 | 268 | @dcs.dataclass_struct(byteorder=byteorder, size=size) 269 | class _: 270 | x: Annotated[bytes, 1, 12] 271 | 272 | 273 | def test_length_prefixed_bytes_format() -> None: 274 | @dcs.dataclass_struct() 275 | class T: 276 | x: Annotated[bytes, dcs.LengthPrefixed(256)] 277 | 278 | assert T.__dataclass_struct__.format[1:] == "256p" 279 | 280 | 281 | @parametrize_all_sizes_and_byteorders() 282 | def test_length_prefixed_bytes_has_same_size_as_length( 283 | byteorder, size 284 | ) -> None: 285 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 286 | class T: 287 | x: Annotated[bytes, dcs.LengthPrefixed(256)] 288 | 289 | assert dcs.get_struct_size(T) == 256 290 | 291 | 292 | @pytest.mark.parametrize("size", (1, 257, "100", 100.0)) 293 | def test_length_prefixed_bytes_invalid_size_fails(size: int): 294 | with pytest.raises( 295 | ValueError, 296 | match=r"^size must be an int between 2 and 256$", 297 | ): 298 | 299 | @dcs.dataclass_struct() 300 | class _: 301 | x: Annotated[bytes, dcs.LengthPrefixed(size)] 302 | 303 | 304 | def test_length_prefixed_bytes_fails_when_annotating_non_bytes_type() -> None: 305 | with raises_invalid_field_annotation(): 306 | 307 | @dcs.dataclass_struct() 308 | class _: 309 | x: Annotated[int, dcs.LengthPrefixed(100)] 310 | 311 | 312 | def test_bytes_annotated_with_integer_and_length_prefixed_bytes_fails() -> ( 313 | None 314 | ): 315 | with raises_too_many_annotations_error(100): 316 | 317 | @dcs.dataclass_struct() 318 | class _: 319 | x: Annotated[int, dcs.LengthPrefixed(100), 100] 320 | 321 | 322 | def test_bytes_annotated_with_multiple_length_prefixed_bytess_fails() -> None: 323 | with raises_too_many_annotations_error("LengthPrefixed(100)"): 324 | 325 | @dcs.dataclass_struct() 326 | class _: 327 | x: Annotated[int, dcs.LengthPrefixed(100), dcs.LengthPrefixed(100)] 328 | 329 | 330 | def parametrize_all_size_and_byteorder_combinations() -> pytest.MarkDecorator: 331 | """ 332 | All combinations of size and byteorder, including invalid combinations. 333 | """ 334 | return pytest.mark.parametrize( 335 | "nested_size_byteorder,container_size_byteorder", 336 | itertools.combinations(ALL_VALID_SIZE_BYTEORDER_PAIRS, 2), 337 | ) 338 | 339 | 340 | @contextmanager 341 | def raises_mismatched_nested_class_error( 342 | nested_size, 343 | nested_byteorder, 344 | container_size, 345 | container_byteorder, 346 | ): 347 | exp_msg = f""" 348 | byteorder and size of nested dataclass-struct does not 349 | match that of container (expected '{container_size}' size and 350 | '{container_byteorder}' byteorder, got '{nested_size}' size and 351 | '{nested_byteorder}' byteorder) 352 | """ 353 | exp_msg = " ".join(exp_msg.split()) 354 | with pytest.raises(TypeError, match=f"^{escape(exp_msg)}$"): 355 | yield 356 | 357 | 358 | @parametrize_all_size_and_byteorder_combinations() 359 | def test_nested_dataclass_with_mismatched_size_and_byteorder_fails( 360 | nested_size_byteorder, container_size_byteorder 361 | ) -> None: 362 | nested_size, nested_byteorder = nested_size_byteorder 363 | container_size, container_byteorder = container_size_byteorder 364 | with raises_mismatched_nested_class_error( 365 | nested_size, 366 | nested_byteorder, 367 | container_size, 368 | container_byteorder, 369 | ): 370 | 371 | @dcs.dataclass_struct(size=nested_size, byteorder=nested_byteorder) 372 | class Nested: 373 | pass 374 | 375 | @dcs.dataclass_struct( 376 | size=container_size, 377 | byteorder=container_byteorder, 378 | ) 379 | class _: 380 | y: Nested 381 | 382 | 383 | @parametrize_all_size_and_byteorder_combinations() 384 | @parametrize_all_list_types() 385 | def test_list_of_dataclass_structs_with_mismatched_size_and_byteorder_fails( 386 | nested_size_byteorder, container_size_byteorder, list_type 387 | ) -> None: 388 | nested_size, nested_byteorder = nested_size_byteorder 389 | container_size, container_byteorder = container_size_byteorder 390 | with raises_mismatched_nested_class_error( 391 | nested_size, 392 | nested_byteorder, 393 | container_size, 394 | container_byteorder, 395 | ): 396 | 397 | @dcs.dataclass_struct(size=nested_size, byteorder=nested_byteorder) 398 | class Nested: 399 | pass 400 | 401 | @dcs.dataclass_struct( 402 | size=container_size, 403 | byteorder=container_byteorder, 404 | ) 405 | class _: 406 | y: Annotated[list_type[Nested], 2] 407 | 408 | 409 | @pytest.mark.parametrize("size", (-1, 1.0, "1")) 410 | @pytest.mark.parametrize("padding", (dcs.PadBefore, dcs.PadAfter)) 411 | def test_invalid_padding_size_fails(size: int, padding: type) -> None: 412 | with pytest.raises( 413 | ValueError, 414 | match=r"^padding size must be non-negative int$", 415 | ): 416 | 417 | @dcs.dataclass_struct() 418 | class _: 419 | x: Annotated[int, padding(size)] 420 | 421 | 422 | def test_str_type_annotations() -> None: 423 | @dcs.dataclass_struct(size="std") 424 | class _: 425 | a: "dcs.Char" 426 | b: "dcs.I8" 427 | c: "dcs.U8" 428 | d: "dcs.Bool" 429 | e: "dcs.I16" 430 | f: "dcs.U16" 431 | g: "dcs.I32" 432 | h: "dcs.U32" 433 | i: "dcs.I64" 434 | j: "dcs.U64" 435 | k: "dcs.F32" 436 | l: "dcs.F64" # noqa: E741 437 | m: "Annotated[bytes, 10]" 438 | 439 | 440 | @skipif_kw_only_not_supported 441 | def test_kw_only_marker() -> None: 442 | from dataclasses import KW_ONLY # type: ignore 443 | 444 | @dcs.dataclass_struct() 445 | class T: 446 | arg1: int 447 | arg2: int 448 | _: KW_ONLY 449 | kwarg1: float 450 | kwarg2: bool 451 | 452 | with pytest.raises(TypeError): 453 | T(1, 2, 1.2, False) # type: ignore 454 | -------------------------------------------------------------------------------- /docs/guide.md: -------------------------------------------------------------------------------- 1 | # Guide 2 | 3 | ## The `dataclass_struct` decorator 4 | 5 | Use the [`dataclass_struct`][dataclasses_struct.dataclass_struct] decorator to convert a class into a [stdlib 6 | `dataclass`](https://docs.python.org/3/library/dataclasses.html) with struct 7 | packing/unpacking functionality: 8 | 9 | ```python 10 | def dataclass_struct( 11 | *, 12 | size: Literal["native", "std"] = "native", 13 | byteorder: Literal["native", "big", "little", "network"] = "native", 14 | validate_defaults: bool = True, 15 | **dataclass_kwargs, 16 | ): 17 | ... 18 | ``` 19 | 20 | The `size` argument can be either `"native"` (the default) or `"std"` and 21 | controls the size and alignment of fields: 22 | 23 | | `size` | `byteorder` | Notes | 24 | | ------------------------------- | ----------- | ------------------------------------------------------------------ | 25 | | [`"native"`](#native-size-mode) | `"native"` | The default. Native alignment and padding. | 26 | | [`"std"`](#standard-size-mode) | `"native"` | Standard integer sizes and system endianness, no alignment/padding. | 27 | | [`"std"`](#standard-size-mode) | `"little"` | Standard integer sizes and little endian, no alignment/padding. | 28 | | [`"std"`](#standard-size-mode) | `"big"` | Standard integer sizes and big endian, no alignment/padding. | 29 | | [`"std"`](#standard-size-mode) | `"network"` | Equivalent to `byteorder="big"`. | 30 | 31 | Decorated classes are transformed to a standard Python 32 | [dataclass](https://docs.python.org/3/library/dataclasses.html) with boilerplate 33 | `__init__`, `__repr__`, `__eq__` etc. auto-generated. The additional 34 | `dataclass_kwargs` keyword arguments will be passed through to the [stdlib 35 | `dataclass` decorator](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass): 36 | all standard keyword arguments are supported except for `slots` and 37 | `weakref_slot`. 38 | 39 | 40 | In addition to the standard `dataclass` methods, two methods 41 | are added to the class: 42 | 43 | * [`pack`][dataclasses_struct.dataclass.DataclassStructProtocol.pack], which 44 | packs an instance of the class to `bytes`. 45 | * [`from_packed`][dataclasses_struct.dataclass.DataclassStructProtocol.from_packed], 46 | which is a class method that returns a new instance of the class from its 47 | packed representation in an object that implements the [buffer 48 | protococol](https://docs.python.org/3/c-api/buffer.html) (`bytes`, 49 | `bytearray`, [memory-mapped file 50 | objects](https://docs.python.org/3/library/mmap.html) etc.). 51 | 52 | A class attribute named 53 | [`__dataclass_struct__`][dataclasses_struct.dataclass.DataclassStructProtocol.__dataclass_struct__] 54 | is also added (see [Inspecting 55 | dataclass-structs](#inspecting-dataclass-structs)). 56 | 57 | ## Default value validation 58 | 59 | Default attribute values will be validated against their expected type and 60 | allowable value range. For example, 61 | 62 | ```python 63 | import dataclasses_struct as dcs 64 | 65 | @dcs.dataclass_struct() 66 | class Test: 67 | x: dcs.UnsignedChar = -1 68 | ``` 69 | 70 | will raise a `ValueError`. This can be disabled by passing 71 | `validate_defaults=False` to the decorator. 72 | 73 | ## Inspecting dataclass-structs 74 | 75 | A class or object can be checked to see if it is a dataclass-struct using the 76 | [`is_dataclass_struct`][dataclasses_struct.is_dataclass_struct] function. 77 | 78 | 79 | ```python 80 | >>> dcs.is_dataclass_struct(Test) 81 | True 82 | >>> t = Test(100) 83 | >>> dcs.is_dataclass_struct(t) 84 | True 85 | ``` 86 | 87 | The [`get_struct_size`][dataclasses_struct.get_struct_size] function will return 88 | the size in bytes of the packed representation of a dataclass-struct class or an 89 | instance of one. 90 | 91 | ```python 92 | >>> dcs.get_struct_size(Test) 93 | 234 94 | ``` 95 | 96 | An additional class attribute, 97 | [`__dataclass_struct__`][dataclasses_struct.dataclass.DataclassStructProtocol.__dataclass_struct__], 98 | is added to the decorated class that contains the packed size, [`struct` format 99 | string](https://docs.python.org/3/library/struct.html#format-strings), and 100 | `struct` mode. 101 | 102 | ```python 103 | >>> Test.__dataclass_struct__.size 104 | 234 105 | >>> Test.__dataclass_struct__.format 106 | '@cc??bBhHiIQqqNnPfdd100s4xqq2x3xq2x' 107 | >>> Test.__dataclass_struct__.mode 108 | '@' 109 | ``` 110 | 111 | ## Native size mode 112 | 113 | In `"native"` mode (the default), the struct is packed based on the platform and 114 | compiler on which Python was built: padding bytes may be added to maintain 115 | proper alignment of the fields and byte ordering (endianness) follows that of 116 | the platform. (The `byteorder` argument must also be `"native"`.) 117 | 118 | In `"native"` size mode, integer type sizes follow those of the standard C 119 | integer types of the platform (`int`, `unsigned short` etc.). 120 | 121 | ```python 122 | @dcs.dataclass_struct() 123 | class NativeStruct: 124 | signed_char: dcs.SignedChar 125 | signed_short: dcs.Short 126 | unsigned_long_long: dcs.UnsignedLongLong 127 | void_pointer: dcs.Pointer 128 | ``` 129 | 130 | ## Standard size mode 131 | 132 | In `"std"` mode, the struct is packed without any additional padding for 133 | alignment. 134 | 135 | The `"std"` size mode supports four different `byteorder` values: `"native"` 136 | (the default), `"little"`, `"big"`, and `"network"`. The `"native"` setting uses 137 | the system byte order (similar to `"native"` size mode, but without alignment). 138 | The `"network"` setting is equivalent to `"big"`. 139 | 140 | The `"std"` size uses platform-independent integer sizes, similar to using the 141 | integer types from `stdint.h` in C. When used with `byteorder` set to 142 | `"little"`, `"big"`, or `"network"`, it is appropriate for marshalling data 143 | across different platforms. 144 | 145 | ```python 146 | @dcs.dataclass_struct(size="std", byteorder="native") 147 | class NativeStruct: 148 | int8_t: dcs.I8 149 | uint64_t: dcs.U64 150 | ``` 151 | 152 | ## Supported type annotations 153 | 154 | See the [reference page](types-reference.md) for the complete list of type 155 | annotations. 156 | 157 | ### Native integer types 158 | 159 | These types are only supported in `"native"` size mode. Their native Python 160 | types are all `int`. 161 | 162 | | Type annotation | Equivalent C type | 163 | | ------------------------------------ | --------------------------- | 164 | | `SignedChar` | `signed char` | 165 | | `UnsignedChar` | `unsigned char` | 166 | | `Short` | `short` | 167 | | `UnsignedShort` | `unsigned short` | 168 | | `Int` | `int` | 169 | | `int` (builtin type, alias to `Int`) | `int` | 170 | | `UnsignedInt` | `unsigned int` | 171 | | `Long` | `long` | 172 | | `UnsignedLong` | `unsigned long` | 173 | | `LongLong` | `long long` | 174 | | `UnsignedLongLong` | `unsigned long long` | 175 | | `UnsignedSize` | `size_t` | 176 | | `SignedSize` | `ssize_t` (POSIX extension) | 177 | | `Pointer` | `void *` | 178 | 179 | ### Standard integer types 180 | 181 | These types are only supported in `"std"` size mode. Their native Python types 182 | are all `int`. 183 | 184 | | Type annotation | Equivalent C type | 185 | | ------------------------------------ | --------------------------- | 186 | | `I8` | `int8_t` | 187 | | `U8` | `uint8_t` | 188 | | `I16` | `int16_t` | 189 | | `U16` | `uint16_t` | 190 | | `I32` | `int32_t` | 191 | | `U32` | `uint32_t` | 192 | | `I64` | `int64_t` | 193 | | `U64` | `uint64_t` | 194 | 195 | ### Floating point types 196 | 197 | Supported in both size modes. The native Python type is `float`. 198 | 199 | | Type annotation | Equivalent C type | 200 | | ------------------------------------ | --------------------------- | 201 | | `F16` | Extension type (see below) | 202 | | `F32` | `float` | 203 | | `F64` | `double` | 204 | | `float` (builtin alias to `F64`) | `double` | 205 | 206 | `F16` is a half precision floating point. Some compilers provide support for it 207 | on certain platforms (e.g. 208 | [GCC](https://gcc.gnu.org/onlinedocs/gcc/Half-Precision.html), 209 | [Clang](https://clang.llvm.org/docs/LanguageExtensions.html#half-precision-floating-point)). 210 | It is also available as 211 | [`std::float16_t`](https://en.cppreference.com/w/cpp/types/floating-point.html) 212 | in C++23. 213 | 214 | Note that floating point fields are always packed and unpacked using the IEEE 215 | 754 format, regardless of the underlying format used by the platform. 216 | 217 | ### Boolean 218 | 219 | The builtin `bool` type or `dataclasses_struct.Bool` type can be used to 220 | represent a boolean, which uses a single byte in either native or standard size 221 | modes. 222 | 223 | 224 | ### Nested structs 225 | 226 | Classes decorated with `dataclass_struct` can be used as fields in other 227 | classes, as long as they have the same `size` and `byteorder` settings. 228 | 229 | ```python 230 | @dcs.dataclass_struct() 231 | class Vector2d: 232 | x: float 233 | y: float 234 | 235 | @dcs.dataclass_struct() 236 | class Vectors: 237 | direction: Vector2d 238 | velocity: Vector2d 239 | 240 | # Will raise TypeError: 241 | @dcs.dataclass_struct(size="std") 242 | class VectorsStd: 243 | direction: Vector2d 244 | velocity: Vector2d 245 | ``` 246 | 247 | Default values for nested class fields cannot be set directly, as Python doesn't 248 | allow using mutable default values in dataclasses. To get around this, pass 249 | `frozen=True` to the inner class' `dataclass_struct` decorator. Alternatively, 250 | pass a zero-argument callable that returns an instance of the class to the 251 | `default_factory` keyword argument of 252 | [`dataclasses.field`](https://docs.python.org/3/library/dataclasses.html#dataclasses.field). 253 | For example: 254 | 255 | ```python 256 | from dataclasses import field 257 | 258 | @dcs.dataclass_struct() 259 | class VectorsStd: 260 | direction: Vector2d 261 | velocity: Vector2d = field(default_factory=lambda: Vector2d(0, 0)) 262 | ``` 263 | 264 | The return type of the `default_factory` will be validated unless 265 | `validate_defaults=False` is passed to the `dataclass_struct` decorator. Note 266 | that this means the callable passed to `default_factory` will be called once 267 | during class creation. 268 | 269 | ### Characters 270 | 271 | In both size modes, a single byte can be packed by annotating a field with the 272 | builtin `bytes` type or the `dataclasses_struct.Char` type. The field's 273 | unpacked Python representation will be a `bytes` of length 1. 274 | 275 | ```python 276 | @dcs.dataclass_struct() 277 | class Chars: 278 | char: dcs.Char = b'x' 279 | builtin: bytes = b'\x04' 280 | ``` 281 | 282 | ### Bytes arrays 283 | 284 | #### Fixed-length 285 | 286 | Fixed-length byte arrays can be represented in both size modes by annotating a 287 | field with `typing.Annotated` and a positive length. The field's unpacked Python 288 | representation will be a `bytes` object zero-padded or truncated to the 289 | specified length. 290 | 291 | ```python 292 | from typing import Annotated 293 | 294 | @dcs.dataclass_struct() 295 | class FixedLength: 296 | fixed: Annotated[bytes, 10] 297 | ``` 298 | 299 | ```python 300 | >>> FixedLength.from_packed(FixedLength(b'Hello, world!').pack()) 301 | FixedLength(fixed=b'Hello, wor') 302 | ``` 303 | 304 | !!! tip "Tip: null-terminated strings" 305 | 306 | Fixed-length `bytes` arrays are truncated to the exact length specified in 307 | the `Annotated` argument. If you require `bytes` arrays to always be 308 | null-terminated (e.g. for passing to a C API), add a [`PadAfter` 309 | annotation](#manual-padding) to the field: 310 | 311 | ```python 312 | @dcs.dataclass_struct() 313 | class FixedLengthNullTerminated: 314 | # Equivalent to `unsigned char[11]` in C 315 | fixed: Annotated[bytes, 10, dcs.PadAfter(1)] 316 | ``` 317 | 318 | ```python 319 | >>> FixedLengthNullTerminated(b"0123456789A").pack() 320 | b'0123456789\x00' 321 | ``` 322 | 323 | #### Length-prefixed 324 | 325 | One issue with fixed-length `bytes` arrays is that data shorter than the length 326 | will be zero-padded when unpacking to the Python type: 327 | 328 | ```python 329 | >>> packed = FixedLength(b'Hello').pack() 330 | >>> packed 331 | b'Hello\x00\x00\x00\x00\x00' 332 | >>> FixedLength.from_packed(packed) 333 | FixedLength(fixed=b'Hello\x00\x00\x00\x00\x00') 334 | ``` 335 | 336 | An alternative is to use *length-prefixed arrays*, also known as [*Pascal 337 | strings*](https://en.wikipedia.org/wiki/Pascal_string). These store the length 338 | of the array in the first byte, meaning that the available length without 339 | truncation is 255. To use length-prefixed arrays, annotate a `bytes` with 340 | [`LengthPrefixed`][dataclasses_struct.LengthPrefixed]: 341 | 342 | ```python 343 | from typing import Annotated 344 | 345 | @dcs.dataclass_struct() 346 | class PascalStrings: 347 | s: Annotated[bytes, dcs.LengthPrefixed(10)] # (1)! 348 | ``` 349 | 350 | 1. The length passed to `LengthPrefixed` must be between 2 and 256 inclusive. 351 | 352 | ```python 353 | >>> packed = PascalStrings(b"12345").pack() 354 | >>> packed 355 | b'\x05Hello\x00\x00\x00\x00' 356 | >>> PascalStrings.from_packed(packed) 357 | PascalStrings(s=b'Hello') 358 | ``` 359 | 360 | !!! note 361 | 362 | The size passed to [`LengthPrefixed`][dataclasses_struct.LengthPrefixed] is 363 | the size of the packed representation of the field *including the size 364 | byte*, so the maximum length the array can be without truncation is one less 365 | than the size. 366 | 367 | ### Fixed-length arrays 368 | 369 | Fixed-length arrays can be represented by annotating a `list` field with 370 | `typing.Annotated` and a positive length. 371 | 372 | ```python 373 | from typing import Annotated 374 | 375 | @dcs.dataclass_struct() 376 | class FixedLength: 377 | fixed: Annotated[list[int], 5] 378 | ``` 379 | 380 | ```python 381 | >>> FixedLength.from_packed(FixedLength([1, 2, 3, 4, 5]).pack()) 382 | FixedLength(fixed=[1, 2, 3, 4, 5]) 383 | ``` 384 | 385 | The values stored in fixed-length arrays can also be classes 386 | decorated with `dataclass_struct`. 387 | 388 | ```python 389 | from typing import Annotated 390 | 391 | @dcs.dataclass_struct() 392 | class Vector2d: 393 | x: float 394 | y: float 395 | 396 | @dcs.dataclass_struct() 397 | class FixedLength: 398 | fixed: Annotated[list[Vector2d], 3] 399 | ``` 400 | 401 | ```python 402 | >>> FixedLength.from_packed(FixedLength([Vector2d(1.0, 2.0), Vector2d(3.0, 4.0), Vector2d(5.0, 6.0)]).pack()) 403 | FixedLength(fixed=[Vector2d(x=1.0, y=2.0), Vector2d(x=3.0, y=4.0), Vector2d(x=5.0, y=6.0)]) 404 | ``` 405 | 406 | Fixed-length arrays can also be multi-dimensional by nesting Annotated 407 | `list` types. 408 | 409 | ```python 410 | from typing import Annotated 411 | 412 | @dcs.dataclass_struct() 413 | class TwoDimArray: 414 | fixed: Annotated[list[Annotated[list[int], 2]], 3] 415 | ``` 416 | 417 | ```python 418 | >>> TwoDimArray.from_packed(TwoDimArray([[1, 2], [3, 4], [5, 6]]).pack()) 419 | TwoDimArray(fixed=[[1, 2], [3, 4], [5, 6]]) 420 | ``` 421 | 422 | As with [nested structs](#nested-structs), a `default_factory` must be used to 423 | set a default value. For example: 424 | 425 | ```python 426 | from dataclasses import field 427 | from typing import Annotated 428 | 429 | @dcs.dataclass_struct() 430 | class DefaultArray: 431 | x: Annotated[list[int], 3] = field(default_factory=lambda: [1, 2, 3]) 432 | ``` 433 | 434 | The returned default value's length and type and values of its items will be 435 | validated unless `validate_defaults=False` is passed to the `dataclass_struct` 436 | decorator. 437 | 438 | ### Manual padding 439 | 440 | Padding can be manually controlled by annotating a type with 441 | [`PadBefore`][dataclasses_struct.PadBefore] or 442 | [`PadAfter`][dataclasses_struct.PadAfter]: 443 | 444 | ```python 445 | @dcs.dataclass_struct() 446 | class WithPadding: 447 | # 4 padding bytes will be added before this field 448 | pad_before: Annotated[int, dcs.PadBefore(4)] 449 | 450 | # 2 padding bytes will be added before this field 451 | pad_after: Annotated[int, dcs.PadAfter(2)] 452 | 453 | # 3 padding bytes will be added before this field and 2 after 454 | pad_before_and_after: Annotated[int, dcs.PadBefore(3), dcs.PadAfter(2)] 455 | ``` 456 | 457 | A `b'\x00'` will be inserted into the packed representation for each padding 458 | byte. 459 | 460 | ```python 461 | >>> padded = WithPadding(100, 200, 300) 462 | >>> packed = padded.pack() 463 | >>> packed 464 | b'\x00\x00\x00\x00d\x00\x00\x00\xc8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00,\x01\x00\x00\x00\x00' 465 | >>> WithPadding.from_packed(packed) 466 | WithPadding(pad_before=100, pad_after=200, pad_before_and_after=300) 467 | ``` 468 | 469 | ## Type checking 470 | 471 | ### Mypy 472 | 473 | To work correctly with [`mypy`](https://www.mypy-lang.org/), an extension is 474 | required; add to your `mypy.ini`: 475 | 476 | ```ini 477 | [mypy] 478 | plugins = dataclasses_struct.ext.mypy_plugin 479 | ``` 480 | 481 | ### Pyright/Pylance 482 | 483 | Due to current limitations, Microsoft's 484 | [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) 485 | Visual Studio extension and its open-source core 486 | [Pyright](https://github.com/microsoft/pyright) will report an [attribute access 487 | error](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#reportAttributeAccessIssue) 488 | on the `pack` and `from_packed` methods: 489 | 490 | ```python 491 | import dataclasses_struct as dcs 492 | 493 | @dcs.dataclass_struct() 494 | class Test: 495 | x: int 496 | 497 | t = Test(10) 498 | t.pack() 499 | # pyright error: Cannot access attribute "pack" for class "Test" 500 | ``` 501 | 502 | A fix for this is planned in the future. As a workaround in the meantime, you 503 | can add stubs for the generated functions and attribute to the class: 504 | 505 | ```python 506 | from typing import ClassVar, TYPE_CHECKING 507 | from collections.abc import Buffer # import from typing_extensions on Python <3.12 508 | import dataclasses_struct as dcs 509 | 510 | @dcs.dataclass_struct() 511 | class Test: 512 | x: int 513 | 514 | if TYPE_CHECKING: 515 | 516 | __dataclass_struct__: ClassVar[dcs.DataclassStructInternal] 517 | 518 | def pack(self) -> bytes: ... 519 | 520 | @classmethod 521 | def from_packed(cls, data: Buffer) -> "Test": ... 522 | ``` 523 | 524 | The 525 | [`DataclassStructProtocol`][dataclasses_struct.dataclass.DataclassStructProtocol] 526 | class can then be used as a type hint where packing/unpacking is required. E.g. 527 | 528 | ```python 529 | def pack_dataclass_struct_to_file(path: str, struct: dcs.DataclassStructProtocol): 530 | data = struct.pack() 531 | with open(path, "wb") as f: 532 | f.write(data) 533 | 534 | pack_dataclass_struct_to_file(Test(x=12)) 535 | ``` 536 | -------------------------------------------------------------------------------- /test/test_pack_unpack.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import itertools 3 | import mmap 4 | import struct 5 | from pathlib import Path 6 | from typing import Annotated 7 | 8 | import pytest 9 | from conftest import ( 10 | bool_fields, 11 | char_fields, 12 | float_fields, 13 | native_byteorders, 14 | native_only_int_fields, 15 | parametrize_all_list_types, 16 | parametrize_all_sizes_and_byteorders, 17 | parametrize_fields, 18 | parametrize_std_byteorders, 19 | skipif_kw_only_not_supported, 20 | std_byteorders, 21 | std_only_int_fields, 22 | ) 23 | 24 | import dataclasses_struct as dcs 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "size,byteorder,field_type", 29 | [ 30 | *( 31 | ("native", byteorder, field_type[0]) 32 | for byteorder, field_type in itertools.product( 33 | native_byteorders, native_only_int_fields 34 | ) 35 | ), 36 | *( 37 | ("std", byteorder, field_type[0]) 38 | for byteorder, field_type in itertools.product( 39 | std_byteorders, std_only_int_fields 40 | ) 41 | ), 42 | ], 43 | ) 44 | def test_pack_unpack_int(size, byteorder, field_type) -> None: 45 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 46 | class T: 47 | x: field_type 48 | 49 | t = T(1) 50 | packed = t.pack() 51 | unpacked = T.from_packed(packed) 52 | assert type(unpacked.x) is int 53 | assert t == unpacked 54 | 55 | 56 | @parametrize_all_sizes_and_byteorders() 57 | @parametrize_fields(float_fields, "field_type") 58 | @pytest.mark.parametrize("value", (1.5, 2.0, 2)) 59 | def test_pack_unpack_floats(size, byteorder, field_type, value) -> None: 60 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 61 | class T: 62 | x: field_type 63 | 64 | t = T(value) 65 | packed = t.pack() 66 | unpacked = T.from_packed(packed) 67 | assert type(unpacked.x) is float 68 | assert t == unpacked 69 | 70 | 71 | @parametrize_all_sizes_and_byteorders() 72 | @parametrize_fields(char_fields, "field_type") 73 | def test_pack_unpack_char(size, byteorder, field_type) -> None: 74 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 75 | class T: 76 | x: field_type 77 | 78 | t = T(b"x") 79 | packed = t.pack() 80 | unpacked = T.from_packed(packed) 81 | assert type(unpacked.x) is bytes 82 | assert t == unpacked 83 | 84 | 85 | @parametrize_all_sizes_and_byteorders() 86 | @parametrize_fields(bool_fields, "field_type") 87 | @pytest.mark.parametrize("value", (True, False)) 88 | def test_pack_unpack_bool(size, byteorder, field_type, value) -> None: 89 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 90 | class T: 91 | x: field_type 92 | 93 | t = T(value) 94 | packed = t.pack() 95 | unpacked = T.from_packed(packed) 96 | assert type(unpacked.x) is bool 97 | assert t == unpacked 98 | 99 | 100 | @parametrize_all_sizes_and_byteorders() 101 | def test_pack_unpack_bytes_exact_length(size, byteorder) -> None: 102 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 103 | class T: 104 | x: Annotated[bytes, 3] 105 | 106 | t = T(b"123") 107 | packed = t.pack() 108 | unpacked = T.from_packed(packed) 109 | assert type(unpacked.x) is bytes 110 | assert t == unpacked 111 | 112 | 113 | @parametrize_all_sizes_and_byteorders() 114 | def test_packed_bytes_longer_than_length_is_truncated(size, byteorder) -> None: 115 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 116 | class T: 117 | x: Annotated[bytes, 3] 118 | 119 | t = T(b"12345") 120 | packed = t.pack() 121 | assert len(packed) == 3 122 | unpacked = T.from_packed(packed) 123 | assert unpacked.x == b"123" 124 | 125 | 126 | @parametrize_all_sizes_and_byteorders() 127 | def test_packed_bytes_shorter_than_length_is_zero_padded( 128 | size, byteorder 129 | ) -> None: 130 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 131 | class T: 132 | x: Annotated[bytes, 5] 133 | 134 | t = T(b"123") 135 | packed = t.pack() 136 | assert len(packed) == 5 137 | unpacked = T.from_packed(packed) 138 | assert unpacked.x == b"123\0\0" 139 | 140 | 141 | @parametrize_all_sizes_and_byteorders() 142 | def test_packed_length_prefixed_bytes_shorter_than_size_is_zero_padded( 143 | size, byteorder 144 | ) -> None: 145 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 146 | class T: 147 | x: Annotated[bytes, dcs.LengthPrefixed(5)] 148 | 149 | packed = T(b"123").pack() 150 | assert packed == b"\x03123\x00" 151 | 152 | 153 | @parametrize_all_sizes_and_byteorders() 154 | def test_packed_length_prefixed_bytes_greater_than_size_is_truncated( 155 | size, byteorder 156 | ) -> None: 157 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 158 | class T: 159 | x: Annotated[bytes, dcs.LengthPrefixed(5)] 160 | 161 | packed = T(b"1234").pack() 162 | assert packed == b"\x041234" 163 | 164 | 165 | @parametrize_all_sizes_and_byteorders() 166 | def test_pack_unpack_empty_length_prefixed_bytes(size, byteorder) -> None: 167 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 168 | class T: 169 | x: Annotated[bytes, dcs.LengthPrefixed(5)] 170 | 171 | packed = T(b"").pack() 172 | assert T.from_packed(packed) == T(b"") 173 | 174 | 175 | @parametrize_all_sizes_and_byteorders() 176 | def test_pack_unpack_length_prefixed_bytes_shorter_than_size( 177 | size, byteorder 178 | ) -> None: 179 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 180 | class T: 181 | x: Annotated[bytes, dcs.LengthPrefixed(5)] 182 | 183 | packed = T(b"123").pack() 184 | assert T.from_packed(packed) == T(b"123") 185 | 186 | 187 | @parametrize_all_sizes_and_byteorders() 188 | def test_pack_unpack_length_prefixed_bytes_exact_size(size, byteorder) -> None: 189 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 190 | class T: 191 | x: Annotated[bytes, dcs.LengthPrefixed(5)] 192 | 193 | packed = T(b"1234").pack() 194 | assert T.from_packed(packed) == T(b"1234") 195 | 196 | 197 | @parametrize_all_sizes_and_byteorders() 198 | def test_pack_unpack_length_prefixed_bytes_longer_than_size( 199 | size, byteorder 200 | ) -> None: 201 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 202 | class T: 203 | x: Annotated[bytes, dcs.LengthPrefixed(5)] 204 | 205 | packed = T(b"12345").pack() 206 | assert T.from_packed(packed) == T(b"1234") 207 | 208 | 209 | @parametrize_all_sizes_and_byteorders() 210 | def test_pack_unpack_nested(size, byteorder) -> None: 211 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 212 | class Nested: 213 | x: float 214 | y: Annotated[bytes, 3] 215 | 216 | assert dcs.get_struct_size(Nested) == struct.calcsize("@ d3b") 217 | 218 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 219 | class Container: 220 | x: dcs.F32 221 | item1: Annotated[Nested, dcs.PadBefore(10)] 222 | item2: Annotated[Nested, dcs.PadAfter(12)] 223 | y: bool 224 | 225 | c = Container(1, Nested(2, b"123"), Nested(5, b"456"), False) 226 | unpacked = Container.from_packed(c.pack()) 227 | assert type(unpacked.item1) is Nested 228 | assert type(unpacked.item2) is Nested 229 | assert c == unpacked 230 | 231 | 232 | @parametrize_all_sizes_and_byteorders() 233 | def test_pack_unpack_double_nested(size, byteorder) -> None: 234 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 235 | class Nested1: 236 | x: float 237 | y: Annotated[bytes, 3] 238 | 239 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 240 | class Nested2: 241 | nested1: Annotated[Nested1, dcs.PadBefore(12)] 242 | nested2: Annotated[Nested1, dcs.PadBefore(12)] 243 | 244 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 245 | class Container: 246 | x: bool 247 | item1: Nested2 248 | item2: Nested2 249 | y: float 250 | 251 | c = Container( 252 | True, 253 | Nested2(Nested1(2, b"abc"), Nested1(7, b"123")), 254 | Nested2(Nested1(12, b"def"), Nested1(-1, b"456")), 255 | 2, 256 | ) 257 | unpacked = Container.from_packed(c.pack()) 258 | assert type(unpacked.item1) is Nested2 259 | assert type(unpacked.item1.nested1) is Nested1 260 | assert type(unpacked.item1.nested2) is Nested1 261 | assert type(unpacked.item2) is Nested2 262 | assert type(unpacked.item2.nested1) is Nested1 263 | assert type(unpacked.item2.nested2) is Nested1 264 | assert c == unpacked 265 | 266 | 267 | @parametrize_std_byteorders() 268 | @parametrize_all_list_types() 269 | @parametrize_fields(std_only_int_fields, "int_type") 270 | def test_pack_unpack_array_of_std_int_types( 271 | byteorder, list_type, int_type 272 | ) -> None: 273 | @dcs.dataclass_struct(size="std", byteorder=byteorder) 274 | class T: 275 | x: Annotated[list_type[int_type], 5] 276 | 277 | t = T([1, 2, 3, 4, 5]) 278 | packed = t.pack() 279 | unpacked = T.from_packed(packed) 280 | assert isinstance(unpacked.x, list) 281 | assert t == unpacked 282 | 283 | 284 | @parametrize_all_list_types() 285 | @parametrize_fields(native_only_int_fields, "int_type") 286 | def test_pack_unpack_array_of_native_int_types(list_type, int_type) -> None: 287 | @dcs.dataclass_struct() 288 | class T: 289 | x: Annotated[list_type[int_type], 5] 290 | 291 | t = T([1, 2, 3, 4, 5]) 292 | packed = t.pack() 293 | unpacked = T.from_packed(packed) 294 | assert isinstance(unpacked.x, list) 295 | assert t == unpacked 296 | 297 | 298 | @parametrize_all_sizes_and_byteorders() 299 | @parametrize_all_list_types() 300 | @parametrize_fields(float_fields, "float_type") 301 | def test_pack_unpack_array_of_float_types( 302 | size, byteorder, list_type, float_type 303 | ) -> None: 304 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 305 | class T: 306 | x: Annotated[list_type[float_type], 5] 307 | 308 | t = T([1.0, 2.0, 3.0, 4.0, 5.0]) 309 | packed = t.pack() 310 | unpacked = T.from_packed(packed) 311 | assert isinstance(unpacked.x, list) 312 | assert t == unpacked 313 | 314 | 315 | @parametrize_all_sizes_and_byteorders() 316 | @parametrize_all_list_types() 317 | def test_pack_unpack_array_of_dataclass_struct( 318 | size, byteorder, list_type 319 | ) -> None: 320 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 321 | class Nested: 322 | x: float 323 | y: float 324 | 325 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 326 | class T: 327 | x: Annotated[list_type[Nested], 2] 328 | 329 | t = T([Nested(1.0, 2.0), Nested(3.0, 4.0)]) 330 | packed = t.pack() 331 | unpacked = T.from_packed(packed) 332 | assert isinstance(unpacked.x, list) 333 | assert t == unpacked 334 | 335 | 336 | @parametrize_all_list_types() 337 | def test_pack_unpack_2d_array_of_primitives(list_type) -> None: 338 | @dcs.dataclass_struct() 339 | class T: 340 | x: Annotated[list_type[Annotated[list_type[int], 3]], 2] 341 | 342 | t = T([[1, 2, 3], [4, 5, 6]]) 343 | packed = t.pack() 344 | unpacked = T.from_packed(packed) 345 | assert isinstance(unpacked.x, list) 346 | assert t == unpacked 347 | 348 | 349 | @parametrize_all_sizes_and_byteorders() 350 | @parametrize_all_list_types() 351 | def test_pack_unpack_2d_array_of_dataclass_struct( 352 | size, byteorder, list_type 353 | ) -> None: 354 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 355 | class Nested: 356 | x: float 357 | y: float 358 | 359 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 360 | class T: 361 | x: Annotated[list_type[Annotated[list_type[Nested], 3]], 2] 362 | 363 | t = T( 364 | [ 365 | [Nested(1.0, 2.0), Nested(3.0, 4.0), Nested(5.0, 6.0)], 366 | [Nested(7.0, 8.0), Nested(9.0, 10.0), Nested(11.0, 12.0)], 367 | ] 368 | ) 369 | packed = t.pack() 370 | unpacked = T.from_packed(packed) 371 | assert isinstance(unpacked.x, list) 372 | assert t == unpacked 373 | 374 | 375 | def assert_true_has_correct_padding( 376 | packed: bytes, 377 | expected_num_before: int, 378 | exected_num_after: int, 379 | ) -> None: 380 | __tracebackhide__ = True 381 | expected = ( 382 | (expected_num_before * b"\x00") 383 | + b"\x01" 384 | + (exected_num_after * b"\x00") 385 | ) 386 | assert packed == expected 387 | 388 | 389 | @pytest.mark.parametrize("padding", (dcs.PadBefore, dcs.PadAfter)) 390 | @parametrize_all_sizes_and_byteorders() 391 | def test_pack_padding_zero(size, byteorder, padding: type) -> None: 392 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 393 | class T: 394 | x: Annotated[bool, padding(0)] 395 | 396 | assert T(True).pack() == b"\x01" 397 | 398 | 399 | @parametrize_all_sizes_and_byteorders() 400 | def test_pack_padding_before(size, byteorder) -> None: 401 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 402 | class Test: 403 | x: Annotated[bool, dcs.PadBefore(5)] 404 | 405 | t = Test(True) 406 | assert_true_has_correct_padding(t.pack(), 5, 0) 407 | 408 | 409 | @parametrize_all_sizes_and_byteorders() 410 | def test_pack_padding_after(size, byteorder) -> None: 411 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 412 | class Test: 413 | x: Annotated[bool, dcs.PadAfter(5)] 414 | 415 | t = Test(True) 416 | assert_true_has_correct_padding(t.pack(), 0, 5) 417 | 418 | 419 | @parametrize_all_sizes_and_byteorders() 420 | def test_pack_padding_before_and_after(size, byteorder) -> None: 421 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 422 | class Test: 423 | x: Annotated[bool, dcs.PadBefore(10), dcs.PadAfter(5)] 424 | 425 | t = Test(True) 426 | assert_true_has_correct_padding(t.pack(), 10, 5) 427 | 428 | 429 | @parametrize_all_sizes_and_byteorders() 430 | def test_pack_padding_before_and_after_with_after_before_before( 431 | size, byteorder 432 | ) -> None: 433 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 434 | class Test: 435 | x: Annotated[bool, dcs.PadAfter(5), dcs.PadBefore(10)] 436 | 437 | t = Test(True) 438 | assert_true_has_correct_padding(t.pack(), 10, 5) 439 | 440 | 441 | @parametrize_all_sizes_and_byteorders() 442 | def test_pack_padding_multiple(size, byteorder) -> None: 443 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 444 | class Test: 445 | x: Annotated[ 446 | bool, 447 | dcs.PadBefore(4), 448 | dcs.PadAfter(5), 449 | dcs.PadBefore(0), 450 | dcs.PadAfter(3), 451 | dcs.PadBefore(10), 452 | ] 453 | 454 | t = Test(True) 455 | assert_true_has_correct_padding(t.pack(), 14, 8) 456 | 457 | 458 | @parametrize_all_sizes_and_byteorders() 459 | def test_pack_padding_with_bytes(size, byteorder) -> None: 460 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 461 | class Test: 462 | a: Annotated[bytes, dcs.PadBefore(2), 4, dcs.PadAfter(3)] 463 | 464 | t = Test(b"1234") 465 | assert t.pack() == b"\x00\x001234\x00\x00\x00" 466 | 467 | 468 | @parametrize_all_sizes_and_byteorders() 469 | @parametrize_all_list_types() 470 | def test_pack_unpack_with_padding_around_fixed_size_array( 471 | size, byteorder, list_type 472 | ) -> None: 473 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 474 | class Test: 475 | a: Annotated[list_type[bool], dcs.PadBefore(2), 4, dcs.PadAfter(3)] 476 | 477 | t = Test([True, True, False, True]) 478 | packed = t.pack() 479 | assert packed == b"\x00\x00\x01\x01\x00\x01\x00\x00\x00" 480 | assert Test.from_packed(packed) == t 481 | 482 | 483 | @parametrize_all_sizes_and_byteorders() 484 | @parametrize_all_list_types() 485 | def test_pack_unpack_fixed_size_array_with_padding( 486 | size, byteorder, list_type 487 | ) -> None: 488 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 489 | class Test: 490 | a: Annotated[ 491 | list_type[Annotated[bytes, dcs.PadBefore(2), dcs.PadAfter(3)]], 4 492 | ] 493 | 494 | items = [b"1", b"2", b"3", b"4"] 495 | t = Test(items) 496 | packed = t.pack() 497 | 498 | exp_packed_bytes: list[int] = [] 499 | for i in items: 500 | exp_packed_bytes.extend(0 for _ in range(2)) 501 | exp_packed_bytes.append(i[0]) 502 | exp_packed_bytes.extend(0 for _ in range(3)) 503 | 504 | exp_packed = bytes(exp_packed_bytes) 505 | assert packed == exp_packed 506 | assert Test.from_packed(packed) == t 507 | 508 | 509 | @parametrize_all_sizes_and_byteorders() 510 | @parametrize_all_list_types() 511 | def test_pack_unpack_list_of_byte_arrays(size, byteorder, list_type) -> None: 512 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 513 | class T: 514 | x: Annotated[list_type[Annotated[bytes, 5]], 4] 515 | 516 | t = T([b"", b"1234", b"123456", b"12345"]) 517 | packed = t.pack() 518 | unpacked = T.from_packed(packed) 519 | assert isinstance(unpacked.x, list) 520 | assert unpacked == T([b"\0" * 5, b"1234\0", b"12345", b"12345"]) 521 | 522 | 523 | @parametrize_all_sizes_and_byteorders() 524 | def test_unpack_padding(size, byteorder) -> None: 525 | @dcs.dataclass_struct(size=size, byteorder=byteorder) 526 | class Test: 527 | x: Annotated[bool, dcs.PadAfter(2)] 528 | y: Annotated[bool, dcs.PadBefore(2), dcs.PadAfter(7)] 529 | 530 | unpacked = Test.from_packed( 531 | b"\x00" + (b"\x00" * 4) + b"\x01" + (b"\x00" * 7) 532 | ) 533 | assert unpacked == Test(False, True) 534 | 535 | 536 | @skipif_kw_only_not_supported 537 | def test_pack_unpack_with_kw_only() -> None: 538 | @dcs.dataclass_struct(kw_only=True) # type: ignore 539 | class KwOnly: 540 | x: int 541 | y: bool 542 | z: float 543 | 544 | kw_only = KwOnly(z=-5.0, x=12, y=True) 545 | packed = kw_only.pack() 546 | unpacked = KwOnly.from_packed(packed) 547 | assert unpacked == kw_only 548 | 549 | 550 | @skipif_kw_only_not_supported 551 | def test_pack_unpack_with_nested_kw_only() -> None: 552 | @dcs.dataclass_struct(kw_only=True) # type: ignore 553 | class KwOnly: 554 | x: int 555 | y: bool 556 | z: float 557 | 558 | @dcs.dataclass_struct() 559 | class Container: 560 | a: KwOnly 561 | b: KwOnly 562 | 563 | c = Container(KwOnly(y=True, z=-5.0, x=12), KwOnly(z=0.25, x=100, y=False)) 564 | packed = c.pack() 565 | unpacked = Container.from_packed(packed) 566 | assert unpacked == c 567 | 568 | 569 | def test_pack_unpack_with_no_init_args_initialised_with_defaults() -> None: 570 | @dcs.dataclass_struct(init=False) 571 | class T: 572 | x: int = 1 573 | y: int = 2 574 | 575 | t = T() 576 | assert t.x == 1 577 | assert t.y == 2 578 | 579 | t.x = 3 580 | t.y = 4 581 | unpacked = T.from_packed(t.pack()) 582 | assert unpacked.x == 3 583 | assert unpacked.y == 4 584 | 585 | 586 | def test_pack_unpack_with_no_init_args_initialised_in_user_init() -> None: 587 | @dcs.dataclass_struct(init=False) 588 | class T: 589 | x: int 590 | y: int 591 | 592 | def __init__(self) -> None: 593 | self.x = 1 594 | self.y = 2 595 | 596 | t = T() 597 | assert t.x == 1 598 | assert t.y == 2 599 | 600 | t.x = 3 601 | t.y = 4 602 | unpacked = T.from_packed(t.pack()) 603 | assert unpacked.x == 3 604 | assert unpacked.y == 4 605 | 606 | 607 | def test_pack_unpack_with_specific_field_no_init() -> None: 608 | @dcs.dataclass_struct() 609 | class T: 610 | x: int = dataclasses.field(default=-100) 611 | y: int = dataclasses.field(default=200, init=False) 612 | 613 | t = T(x=100) 614 | assert t.x == 100 615 | assert t.y == 200 616 | 617 | t.y = -200 618 | unpacked = T.from_packed(t.pack()) 619 | assert unpacked.x == 100 620 | assert unpacked.y == -200 621 | 622 | 623 | def test_pack_unpack_with_no_init_in_decorator_overriding_fields_init() -> ( 624 | None 625 | ): 626 | @dcs.dataclass_struct(init=False) 627 | class T: 628 | x: int = dataclasses.field(init=True, default=100) 629 | y: int = dataclasses.field(init=True, default=200) 630 | 631 | t = T() 632 | assert t.x == 100 633 | assert t.y == 200 634 | 635 | t.x = -100 636 | t.y = -200 637 | unpacked = T.from_packed(t.pack()) 638 | assert unpacked.x == -100 639 | assert unpacked.y == -200 640 | 641 | 642 | def test_pack_unpack_no_init_fields_with_validate_defaults_false() -> None: 643 | @dcs.dataclass_struct(validate_defaults=False) 644 | class T: 645 | x: int = dataclasses.field(init=False, default=1) 646 | 647 | t = T() 648 | assert t.x == 1 649 | 650 | t.x = -1 651 | unpacked = T.from_packed(t.pack()) 652 | assert unpacked.x == -1 653 | 654 | 655 | def test_pack_unpack_bytearray() -> None: 656 | @dcs.dataclass_struct() 657 | class T: 658 | x: dcs.Int = 100 659 | y: dcs.F32 = -1.5 660 | z: Annotated[bytes, 12] = b"hello, world" 661 | 662 | t = T() 663 | packed_array = bytearray(t.pack()) 664 | assert T.from_packed(packed_array) == t 665 | 666 | 667 | def test_pack_unpack_mmap(tmp_path: Path) -> None: 668 | @dcs.dataclass_struct() 669 | class T: 670 | x: dcs.Int = 100 671 | y: dcs.F32 = -1.5 672 | z: Annotated[bytes, 12] = b"hello, world" 673 | 674 | path = tmp_path / "data" 675 | 676 | t = T() 677 | packed = t.pack() 678 | path.write_bytes(packed) 679 | 680 | with path.open("rb+") as f, mmap.mmap(f.fileno(), 0) as mapped: 681 | unpacked = T.from_packed(mapped) 682 | 683 | assert unpacked == t 684 | 685 | # Check that the file isn't changed 686 | assert path.read_bytes() == packed 687 | -------------------------------------------------------------------------------- /dataclasses_struct/dataclass.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import sys 3 | from collections.abc import Generator, Iterator 4 | from struct import Struct 5 | from typing import ( 6 | Annotated, 7 | Any, 8 | Callable, 9 | ClassVar, 10 | Generic, 11 | Literal, 12 | Protocol, 13 | TypedDict, 14 | TypeVar, 15 | Union, 16 | get_args, 17 | get_origin, 18 | get_type_hints, 19 | overload, 20 | ) 21 | 22 | from ._typing import Buffer, TypeGuard, Unpack, dataclass_transform 23 | from .field import Field, builtin_fields 24 | from .types import PadAfter, PadBefore 25 | 26 | if sys.version_info >= (3, 10): 27 | from dataclasses import KW_ONLY as _KW_ONLY_MARKER 28 | else: 29 | # Placeholder for KW_ONLY on Python 3.9 30 | 31 | class _KW_ONLY_MARKER_TYPE: 32 | pass 33 | 34 | _KW_ONLY_MARKER = _KW_ONLY_MARKER_TYPE() 35 | 36 | 37 | def _separate_padding_from_annotation_args(args) -> tuple[int, int, object]: 38 | pad_before = pad_after = 0 39 | extra_arg = None # should be Field or integer for bytes/list types 40 | for arg in args: 41 | if isinstance(arg, PadBefore): 42 | pad_before += arg.size 43 | elif isinstance(arg, PadAfter): 44 | pad_after += arg.size 45 | elif extra_arg is not None: 46 | raise TypeError(f"too many annotations: {arg}") 47 | else: 48 | extra_arg = arg 49 | 50 | return pad_before, pad_after, extra_arg 51 | 52 | 53 | def _format_str_with_padding(fmt: str, pad_before: int, pad_after: int) -> str: 54 | return "".join( 55 | ( 56 | (f"{pad_before}x" if pad_before else ""), 57 | fmt, 58 | (f"{pad_after}x" if pad_after else ""), 59 | ) 60 | ) 61 | 62 | 63 | T = TypeVar("T") 64 | 65 | 66 | _SIZE_BYTEORDER_MODE_CHAR: dict[tuple[str, str], str] = { 67 | ("native", "native"): "@", 68 | ("std", "native"): "=", 69 | ("std", "little"): "<", 70 | ("std", "big"): ">", 71 | ("std", "network"): "!", 72 | } 73 | _MODE_CHAR_SIZE_BYTEORDER: dict[str, tuple[str, str]] = { 74 | v: k for k, v in _SIZE_BYTEORDER_MODE_CHAR.items() 75 | } 76 | 77 | 78 | @dataclasses.dataclass 79 | class _FieldInfo: 80 | name: str 81 | field: Field[Any] 82 | type_: type 83 | init: bool 84 | 85 | 86 | class DataclassStructInternal(Generic[T]): 87 | struct: Struct 88 | cls: type[T] 89 | _fields: list[_FieldInfo] 90 | 91 | @property 92 | def format(self) -> str: 93 | """ 94 | The format string used by the `struct` module to pack/unpack data. 95 | 96 | See https://docs.python.org/3/library/struct.html#format-strings. 97 | """ 98 | return self.struct.format 99 | 100 | @property 101 | def size(self) -> int: 102 | """Size of the packed representation in bytes.""" 103 | return self.struct.size 104 | 105 | @property 106 | def mode(self) -> str: 107 | """ 108 | The `struct` mode character that determines size, alignment, and 109 | byteorder. 110 | 111 | This is the first character of the `format` field. See 112 | https://docs.python.org/3/library/struct.html#byte-order-size-and-alignment 113 | for more info. 114 | """ 115 | return self.format[0] 116 | 117 | def __init__( 118 | self, 119 | fmt: str, 120 | cls: type, 121 | fields: list[_FieldInfo], 122 | ): 123 | self.struct = Struct(fmt) 124 | self.cls = cls 125 | self._fields = fields 126 | 127 | def _flattened_attrs(self, outer_self: T) -> list[Any]: 128 | """ 129 | Returns a list of all attributes of `outer_self`, including those of 130 | any nested structs. 131 | """ 132 | attrs: list[Any] = [] 133 | for field in self._fields: 134 | attr = getattr(outer_self, field.name) 135 | self._flatten_attr(attrs, attr) 136 | return attrs 137 | 138 | @staticmethod 139 | def _flatten_attr(attrs: list[Any], attr: object) -> None: 140 | if is_dataclass_struct(attr): 141 | attrs.extend(attr.__dataclass_struct__._flattened_attrs(attr)) 142 | elif isinstance(attr, list): 143 | for sub_attr in attr: 144 | DataclassStructInternal._flatten_attr(attrs, sub_attr) 145 | else: 146 | attrs.append(attr) 147 | 148 | def _pack(self, obj: T) -> bytes: 149 | return self.struct.pack(*self._flattened_attrs(obj)) 150 | 151 | def _arg_generator(self, args: Iterator) -> Generator: 152 | for field in self._fields: 153 | yield from DataclassStructInternal._generate_args_recursively( 154 | args, field.field, field.type_ 155 | ) 156 | 157 | @staticmethod 158 | def _generate_args_recursively( 159 | args: Iterator, 160 | field: Field[Any], 161 | field_type: type, 162 | ) -> Generator: 163 | if is_dataclass_struct(field_type): 164 | yield field_type.__dataclass_struct__._init_from_args(args) 165 | elif isinstance(field, _FixedLengthArrayField): 166 | items: list = [] 167 | for _ in range(field.n): 168 | items.extend( 169 | DataclassStructInternal._generate_args_recursively( 170 | args, field.item_field, field.item_type 171 | ) 172 | ) 173 | yield items 174 | else: 175 | yield field_type(next(args)) 176 | 177 | def _init_from_args(self, args: Iterator) -> T: 178 | """ 179 | Returns an instance of self.cls, consuming args 180 | """ 181 | kwargs = {} 182 | no_init_args = {} 183 | 184 | for field, arg in zip(self._fields, self._arg_generator(args)): 185 | if field.init: 186 | kwargs[field.name] = arg 187 | else: 188 | no_init_args[field.name] = arg 189 | 190 | obj = self.cls(**kwargs) 191 | for name, arg in no_init_args.items(): 192 | setattr(obj, name, arg) 193 | return obj 194 | 195 | def _unpack(self, data: Buffer) -> T: 196 | return self._init_from_args(iter(self.struct.unpack(data))) 197 | 198 | 199 | class DataclassStructProtocol(Protocol): 200 | __dataclass_struct__: ClassVar[DataclassStructInternal] 201 | """ 202 | Internal data used by the library for packing and unpacking structs. 203 | 204 | See 205 | [`DataclassStructInternal`][dataclasses_struct.DataclassStructInternal]. 206 | """ 207 | 208 | @classmethod 209 | def from_packed(cls: type[T], data: Buffer) -> T: 210 | """Return an instance of the class from its packed representation. 211 | 212 | Args: 213 | data: The packed representation of the class as returned by 214 | [`pack`][dataclasses_struct.dataclass.DataclassStructProtocol.pack]. 215 | 216 | Returns: 217 | An instance of the class unpacked from `data`. 218 | 219 | Raises: 220 | struct.error: If `data` is the wrong length. 221 | """ 222 | ... 223 | 224 | def pack(self) -> bytes: 225 | """Return the packed representation in `bytes` of the object. 226 | 227 | Returns: 228 | The packed representation. Can be used to instantiate a new object 229 | with 230 | [`from_packed`][dataclasses_struct.dataclass.DataclassStructProtocol.from_packed]. 231 | 232 | Raises: 233 | struct.error: If any of the fields are out of range or the wrong 234 | type. 235 | """ 236 | ... 237 | 238 | 239 | @overload 240 | def is_dataclass_struct( 241 | obj: type, 242 | ) -> TypeGuard[type[DataclassStructProtocol]]: ... 243 | 244 | 245 | @overload 246 | def is_dataclass_struct(obj: object) -> TypeGuard[DataclassStructProtocol]: ... 247 | 248 | 249 | def is_dataclass_struct( 250 | obj: Union[type, object], 251 | ) -> Union[ 252 | TypeGuard[DataclassStructProtocol], 253 | TypeGuard[type[DataclassStructProtocol]], 254 | ]: 255 | """Determine whether a type or object is a dataclass-struct. 256 | 257 | Args: 258 | obj: A class or object. 259 | 260 | Returns: 261 | `True` if obj is a class that has been decorated with 262 | [`dataclass_struct`][dataclasses_struct.dataclass_struct] or is an 263 | instance of one. 264 | """ 265 | return ( 266 | dataclasses.is_dataclass(obj) 267 | and hasattr(obj, "__dataclass_struct__") 268 | and isinstance(obj.__dataclass_struct__, DataclassStructInternal) 269 | ) 270 | 271 | 272 | def get_struct_size(cls_or_obj: object) -> int: 273 | """Get the size of the packed representation of the struct in bytes. 274 | 275 | Args: 276 | cls_or_obj: A class that has been decorated with 277 | [`dataclass_struct`][dataclasses_struct.dataclass_struct] or an 278 | instance of one. 279 | 280 | Returns: 281 | The size of the packed representation in bytes. 282 | 283 | Raises: 284 | TypeError: if `cls_or_obj` is not a dataclass-struct. 285 | """ 286 | if not is_dataclass_struct(cls_or_obj): 287 | raise TypeError(f"{cls_or_obj} is not a dataclass_struct") 288 | return cls_or_obj.__dataclass_struct__.size 289 | 290 | 291 | class _BytesField(Field[bytes]): 292 | field_type = bytes 293 | 294 | def __init__(self, n: object): 295 | if not isinstance(n, int) or n < 1: 296 | raise ValueError("bytes length must be positive non-zero int") 297 | 298 | self.n = n 299 | 300 | def format(self) -> str: 301 | return f"{self.n}s" 302 | 303 | def validate_default(self, val: bytes) -> None: 304 | if len(val) > self.n: 305 | raise ValueError(f"bytes cannot be longer than {self.n} bytes") 306 | 307 | def __repr__(self) -> str: 308 | return f"{super().__repr__()}({self.n})" 309 | 310 | 311 | class _NestedField(Field): 312 | field_type: type[DataclassStructProtocol] 313 | 314 | def __init__(self, cls: type[DataclassStructProtocol]): 315 | self.field_type = cls 316 | 317 | def format(self) -> str: 318 | # Return the format without the byteorder specifier at the beginning 319 | return self.field_type.__dataclass_struct__.format[1:] 320 | 321 | 322 | class _FixedLengthArrayField(Field[list]): 323 | field_type = list 324 | 325 | def __init__(self, item_type_annotation: Any, mode: str, n: object): 326 | if not isinstance(n, int) or n < 1: 327 | raise ValueError( 328 | "fixed-length array length must be positive non-zero int" 329 | ) 330 | 331 | self.item_field, self.item_type, self.pad_before, self.pad_after = ( 332 | _resolve_field(item_type_annotation, mode) 333 | ) 334 | self.n = n 335 | self.is_native = self.item_field.is_native 336 | self.is_std = self.item_field.is_std 337 | 338 | def format(self) -> str: 339 | fmt = _format_str_with_padding( 340 | self.item_field.format(), 341 | self.pad_before, 342 | self.pad_after, 343 | ) 344 | return fmt * self.n 345 | 346 | def __repr__(self) -> str: 347 | return f"{super().__repr__()}({self.item_field!r}, {self.n})" 348 | 349 | def validate_default(self, val: list) -> None: 350 | n = len(val) 351 | if n != self.n: 352 | msg = f"fixed-length array must have length of {self.n}, got {n}" 353 | raise ValueError(msg) 354 | 355 | for i in val: 356 | _validate_field_default(self.item_field, i) 357 | 358 | 359 | def _validate_modes_match(mode: str, nested_mode: str) -> None: 360 | if mode != nested_mode: 361 | size, byteorder = _MODE_CHAR_SIZE_BYTEORDER[nested_mode] 362 | exp_size, exp_byteorder = _MODE_CHAR_SIZE_BYTEORDER[mode] 363 | msg = ( 364 | "byteorder and size of nested dataclass-struct does not " 365 | f"match that of container (expected '{exp_size}' size and " 366 | f"'{exp_byteorder}' byteorder, got '{size}' size and " 367 | f"'{byteorder}' byteorder)" 368 | ) 369 | raise TypeError(msg) 370 | 371 | 372 | def _resolve_field( 373 | annotation: Any, 374 | mode: str, 375 | ) -> tuple[Field[Any], type, int, int]: 376 | """ 377 | Returns 4-tuple of: 378 | * field 379 | * type 380 | * number of padding bytes before 381 | * number of padding bytes after 382 | 383 | Valid type annotations are: 384 | 385 | 1. | Annotated[, ] 386 | 387 | Supported builtin types. 388 | 389 | 2. Annotated[, Field(...), ] 390 | 391 | (These are the types defined in dataclasses_struct.types e.g. U32, F32). 392 | 393 | 3. | Annotated[, ] 394 | 395 | Must have the same size and byteorder as the container. 396 | 397 | 4. Annotated[bytes, , ] 398 | 399 | Where is >0. 400 | 401 | 5. Annotated[list[], , ] 402 | 403 | Where is >0 and is one of the above. 404 | 405 | is an optional mixture of PadBefore and PadAfter annotations, 406 | which may be repeated. E.g. 407 | 408 | Annotated[int, PadBefore(5), PadAfter(2), PadBefore(3)] 409 | """ # noqa: E501 410 | 411 | if get_origin(annotation) == Annotated: 412 | type_, *args = get_args(annotation) 413 | pad_before, pad_after, annotation_arg = ( 414 | _separate_padding_from_annotation_args(args) 415 | ) 416 | else: 417 | pad_before = pad_after = 0 418 | type_ = annotation 419 | annotation_arg = None 420 | 421 | field: Field[Any] 422 | if annotation_arg is None: 423 | if get_origin(type_) is list: 424 | msg = ( 425 | "list types must be marked as a fixed-length using " 426 | "Annotated, ex: Annotated[list[int], 5]" 427 | ) 428 | raise TypeError(msg) 429 | 430 | # Must be either a nested type or one of the supported builtins 431 | if is_dataclass_struct(type_): 432 | _validate_modes_match(mode, type_.__dataclass_struct__.mode) 433 | field = _NestedField(type_) 434 | else: 435 | opt_field = builtin_fields.get(type_) 436 | if opt_field is None: 437 | raise TypeError(f"type not supported: {annotation}") 438 | field = opt_field 439 | elif isinstance(annotation_arg, Field) and issubclass( 440 | type_, annotation_arg.field_type 441 | ): 442 | field = annotation_arg 443 | elif get_origin(type_) is list: 444 | item_annotations = get_args(type_) 445 | assert len(item_annotations) == 1 446 | field = _FixedLengthArrayField( 447 | item_annotations[0], mode, annotation_arg 448 | ) 449 | elif issubclass(type_, bytes): 450 | field = _BytesField(annotation_arg) 451 | else: 452 | raise TypeError(f"invalid field annotation: {annotation!r}") 453 | 454 | return field, type_, pad_before, pad_after 455 | 456 | 457 | def _get_default_from_dataclasses_field(field: dataclasses.Field) -> Any: 458 | if field.default is not dataclasses.MISSING: 459 | return field.default 460 | 461 | if field.default_factory is not dataclasses.MISSING: 462 | return field.default_factory() 463 | 464 | return dataclasses.MISSING 465 | 466 | 467 | def _validate_field_default(field: Field[T], val: Any) -> None: 468 | if not isinstance(val, field.field_type): 469 | msg = ( 470 | "invalid type for field: expected " 471 | f"{field.field_type} got {type(val)}" 472 | ) 473 | raise TypeError(msg) 474 | 475 | field.validate_default(val) 476 | 477 | 478 | def _validate_and_parse_field( 479 | cls: type, 480 | *, 481 | name: str, 482 | field_type: type, 483 | is_native: bool, 484 | validate_defaults: bool, 485 | mode: str, 486 | init: bool, 487 | ) -> tuple[str, _FieldInfo]: 488 | """Returns format string and info.""" 489 | field, type_, pad_before, pad_after = _resolve_field(field_type, mode) 490 | 491 | if is_native: 492 | if not field.is_native: 493 | raise TypeError( 494 | f"field {field} only supported in standard size mode" 495 | ) 496 | elif not field.is_std: 497 | raise TypeError(f"field {field} only supported in native size mode") 498 | 499 | init_field = init 500 | if hasattr(cls, name): 501 | val = getattr(cls, name) 502 | if isinstance(val, dataclasses.Field): 503 | if not val.init: 504 | init_field = False 505 | 506 | if validate_defaults: 507 | val = _get_default_from_dataclasses_field(val) 508 | 509 | if validate_defaults and val is not dataclasses.MISSING: 510 | _validate_field_default(field, val) 511 | 512 | return ( 513 | _format_str_with_padding(field.format(), pad_before, pad_after), 514 | _FieldInfo(name, field, type_, init_field), 515 | ) 516 | 517 | 518 | def _make_pack_method() -> Callable: 519 | func = """ 520 | def pack(self) -> bytes: 521 | '''Pack to bytes using struct.pack.''' 522 | return self.__dataclass_struct__._pack(self) 523 | """ 524 | 525 | scope: dict[str, Any] = {} 526 | exec(func, {}, scope) 527 | return scope["pack"] 528 | 529 | 530 | def _make_unpack_method(cls: type) -> classmethod: 531 | func = """ 532 | def from_packed(cls, data: Buffer) -> cls_type: 533 | '''Unpack from bytes.''' 534 | return cls.__dataclass_struct__._unpack(data) 535 | """ 536 | 537 | scope: dict[str, Any] = {"cls_type": cls, "Buffer": Buffer} 538 | exec(func, {}, scope) 539 | return classmethod(scope["from_packed"]) 540 | 541 | 542 | def _make_class( 543 | cls: type, 544 | mode: str, 545 | is_native: bool, 546 | validate_defaults: bool, 547 | dataclass_kwargs, 548 | ) -> type[DataclassStructProtocol]: 549 | cls_annotations = get_type_hints(cls, include_extras=True) 550 | struct_format = [mode] 551 | fields: list[_FieldInfo] = [] 552 | init = dataclass_kwargs.get("init", True) 553 | for name, field in cls_annotations.items(): 554 | if field is _KW_ONLY_MARKER: 555 | # KW_ONLY is handled by stdlib dataclass, nothing to do on our end. 556 | continue 557 | 558 | fmt, field = _validate_and_parse_field( 559 | cls, 560 | name=name, 561 | field_type=field, 562 | is_native=is_native, 563 | validate_defaults=validate_defaults, 564 | mode=mode, 565 | init=init, 566 | ) 567 | struct_format.append(fmt) 568 | fields.append(field) 569 | 570 | setattr( # noqa: B010 571 | cls, 572 | "__dataclass_struct__", 573 | DataclassStructInternal("".join(struct_format), cls, fields), 574 | ) 575 | setattr(cls, "pack", _make_pack_method()) # noqa: B010 576 | setattr(cls, "from_packed", _make_unpack_method(cls)) # noqa: B010 577 | 578 | return dataclasses.dataclass(cls, **dataclass_kwargs) 579 | 580 | 581 | class _DataclassKwargsPre310(TypedDict, total=False): 582 | init: bool 583 | repr: bool 584 | eq: bool 585 | order: bool 586 | unsafe_hash: bool 587 | frozen: bool 588 | 589 | 590 | if sys.version_info >= (3, 10): 591 | 592 | class DataclassKwargs(_DataclassKwargsPre310, total=False): 593 | match_args: bool 594 | kw_only: bool 595 | else: 596 | 597 | class DataclassKwargs(_DataclassKwargsPre310, total=False): 598 | pass 599 | 600 | 601 | @overload 602 | def dataclass_struct( 603 | *, 604 | size: Literal["native"] = "native", 605 | byteorder: Literal["native"] = "native", 606 | validate_defaults: bool = True, 607 | **dataclass_kwargs: Unpack[DataclassKwargs], 608 | ) -> Callable[[type], type]: ... 609 | 610 | 611 | @overload 612 | def dataclass_struct( 613 | *, 614 | size: Literal["std"], 615 | byteorder: Literal["native", "big", "little", "network"] = "native", 616 | validate_defaults: bool = True, 617 | **dataclass_kwargs: Unpack[DataclassKwargs], 618 | ) -> Callable[[type], type]: ... 619 | 620 | 621 | @dataclass_transform() 622 | def dataclass_struct( 623 | *, 624 | size: Literal["native", "std"] = "native", 625 | byteorder: Literal["native", "big", "little", "network"] = "native", 626 | validate_defaults: bool = True, 627 | **dataclass_kwargs: Unpack[DataclassKwargs], 628 | ) -> Callable[[type], type]: 629 | """Create a dataclass struct. 630 | 631 | Should be used as a decorator on a class: 632 | 633 | ```python 634 | import dataclasses_struct as dcs 635 | 636 | @dcs.dataclass_struct() 637 | class A: 638 | data: dcs.Pointer 639 | size: dcs.UnsignedSize 640 | ``` 641 | 642 | The allowed `size` and `byteorder` argument combinations are as as follows. 643 | 644 | | `size` | `byteorder` | Notes | 645 | | ---------- | ----------- | ------------------------------------------------------------------ | 646 | | `"native"` | `"native"` | The default. Native alignment and padding. | 647 | | `"std"` | `"native"` | Standard integer sizes and system endianness, no alignment/padding. | 648 | | `"std"` | `"little"` | Standard integer sizes and little endian, no alignment/padding. | 649 | | `"std"` | `"big"` | Standard integer sizes and big endian, no alignment/padding. | 650 | | `"std"` | `"network"` | Equivalent to `byteorder="big"`. | 651 | 652 | Args: 653 | size: The size mode. 654 | byteorder: The byte order of the generated struct. If `size="native"`, 655 | only `"native"` is allowed. 656 | validate_defaults: Whether to validate the default values of any 657 | fields. 658 | dataclass_kwargs: Any additional keyword arguments to pass to the 659 | [stdlib 660 | `dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass) 661 | decorator. The `slots` and `weakref_slot` keyword arguments are not 662 | supported. 663 | 664 | Raises: 665 | ValueError: If the `size` and `byteorder` args are invalid or if 666 | `validate_defaults=True` and any of the fields' default values are 667 | invalid for their type. 668 | TypeError: If any of the fields' type annotations are invalid or 669 | not supported. 670 | """ # noqa: E501 671 | is_native = size == "native" 672 | if is_native: 673 | if byteorder != "native": 674 | raise ValueError("'native' size requires 'native' byteorder") 675 | elif size != "std": 676 | raise ValueError(f"invalid size: {size}") 677 | if byteorder not in ("native", "big", "little", "network"): 678 | raise ValueError(f"invalid byteorder: {byteorder}") 679 | 680 | for kwarg in ("slots", "weakref_slot"): 681 | if kwarg in dataclass_kwargs: 682 | msg = f"dataclass '{kwarg}' keyword argument is not supported" 683 | raise ValueError(msg) 684 | 685 | def decorator(cls: type) -> type: 686 | return _make_class( 687 | cls, 688 | mode=_SIZE_BYTEORDER_MODE_CHAR[(size, byteorder)], 689 | is_native=is_native, 690 | validate_defaults=validate_defaults, 691 | dataclass_kwargs=dataclass_kwargs, 692 | ) 693 | 694 | return decorator 695 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --only-group docs -o requirements-docs.txt 3 | babel==2.17.0 ; python_full_version >= '3.12' \ 4 | --hash=sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d \ 5 | --hash=sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2 6 | # via mkdocs-material 7 | backrefs==5.8 ; python_full_version >= '3.12' \ 8 | --hash=sha256:2cab642a205ce966af3dd4b38ee36009b31fa9502a35fd61d59ccc116e40a6bd \ 9 | --hash=sha256:2e1c15e4af0e12e45c8701bd5da0902d326b2e200cafcd25e49d9f06d44bb61b \ 10 | --hash=sha256:a66851e4533fb5b371aa0628e1fee1af05135616b86140c9d787a2ffdf4b8fdc \ 11 | --hash=sha256:bbef7169a33811080d67cdf1538c8289f76f0942ff971222a16034da88a73486 \ 12 | --hash=sha256:c67f6638a34a5b8730812f5101376f9d41dc38c43f1fdc35cb54700f6ed4465d \ 13 | --hash=sha256:e3a63b073867dbefd0536425f43db618578528e3896fb77be7141328642a1585 14 | # via mkdocs-material 15 | certifi==2025.4.26 ; python_full_version >= '3.12' \ 16 | --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ 17 | --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 18 | # via requests 19 | charset-normalizer==3.4.2 ; python_full_version >= '3.12' \ 20 | --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ 21 | --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ 22 | --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ 23 | --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ 24 | --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ 25 | --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ 26 | --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ 27 | --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ 28 | --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ 29 | --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ 30 | --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ 31 | --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ 32 | --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ 33 | --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ 34 | --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ 35 | --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ 36 | --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ 37 | --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ 38 | --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ 39 | --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ 40 | --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ 41 | --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ 42 | --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ 43 | --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ 44 | --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ 45 | --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ 46 | --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ 47 | --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ 48 | --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ 49 | --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ 50 | --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ 51 | --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ 52 | --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ 53 | --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ 54 | --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ 55 | --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ 56 | --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ 57 | --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ 58 | --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ 59 | --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ 60 | --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ 61 | --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ 62 | --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ 63 | --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ 64 | --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ 65 | --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ 66 | --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ 67 | --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ 68 | --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ 69 | --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ 70 | --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ 71 | --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ 72 | --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ 73 | --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ 74 | --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ 75 | --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ 76 | --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ 77 | --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ 78 | --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ 79 | --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ 80 | --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ 81 | --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ 82 | --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ 83 | --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ 84 | --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ 85 | --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ 86 | --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f 87 | # via requests 88 | click==8.2.1 ; python_full_version >= '3.12' \ 89 | --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ 90 | --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b 91 | # via mkdocs 92 | colorama==0.4.6 ; python_full_version >= '3.12' \ 93 | --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ 94 | --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 95 | # via 96 | # click 97 | # griffe 98 | # mkdocs 99 | # mkdocs-material 100 | ghp-import==2.1.0 ; python_full_version >= '3.12' \ 101 | --hash=sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619 \ 102 | --hash=sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343 103 | # via mkdocs 104 | griffe==1.15.0 ; python_full_version >= '3.12' \ 105 | --hash=sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3 \ 106 | --hash=sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea 107 | # via mkdocstrings-python 108 | idna==3.10 ; python_full_version >= '3.12' \ 109 | --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ 110 | --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 111 | # via requests 112 | jinja2==3.1.6 ; python_full_version >= '3.12' \ 113 | --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ 114 | --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 115 | # via 116 | # mkdocs 117 | # mkdocs-material 118 | # mkdocstrings 119 | markdown==3.8 ; python_full_version >= '3.12' \ 120 | --hash=sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc \ 121 | --hash=sha256:7df81e63f0df5c4b24b7d156eb81e4690595239b7d70937d0409f1b0de319c6f 122 | # via 123 | # mkdocs 124 | # mkdocs-autorefs 125 | # mkdocs-material 126 | # mkdocstrings 127 | # pymdown-extensions 128 | markupsafe==3.0.2 ; python_full_version >= '3.12' \ 129 | --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ 130 | --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ 131 | --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ 132 | --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ 133 | --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ 134 | --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ 135 | --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ 136 | --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ 137 | --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ 138 | --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ 139 | --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ 140 | --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ 141 | --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ 142 | --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ 143 | --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ 144 | --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ 145 | --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ 146 | --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ 147 | --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ 148 | --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ 149 | --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ 150 | --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ 151 | --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ 152 | --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ 153 | --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ 154 | --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ 155 | --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ 156 | --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ 157 | --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ 158 | --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ 159 | --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ 160 | --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ 161 | --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ 162 | --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ 163 | --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ 164 | --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ 165 | --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ 166 | --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ 167 | --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ 168 | --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ 169 | --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ 170 | --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ 171 | --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ 172 | --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ 173 | --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ 174 | --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ 175 | --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ 176 | --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ 177 | --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ 178 | --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ 179 | --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ 180 | --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ 181 | --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ 182 | --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ 183 | --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ 184 | --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ 185 | --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ 186 | --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ 187 | --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ 188 | --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ 189 | --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 190 | # via 191 | # jinja2 192 | # mkdocs 193 | # mkdocs-autorefs 194 | # mkdocstrings 195 | mergedeep==1.3.4 ; python_full_version >= '3.12' \ 196 | --hash=sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8 \ 197 | --hash=sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307 198 | # via 199 | # mkdocs 200 | # mkdocs-get-deps 201 | mkdocs==1.6.1 ; python_full_version >= '3.12' \ 202 | --hash=sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2 \ 203 | --hash=sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e 204 | # via 205 | # mkdocs-autorefs 206 | # mkdocs-material 207 | # mkdocstrings 208 | mkdocs-autorefs==1.4.3 ; python_full_version >= '3.12' \ 209 | --hash=sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9 \ 210 | --hash=sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75 211 | # via 212 | # mkdocstrings 213 | # mkdocstrings-python 214 | mkdocs-get-deps==0.2.0 ; python_full_version >= '3.12' \ 215 | --hash=sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c \ 216 | --hash=sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134 217 | # via mkdocs 218 | mkdocs-material==9.7.0 ; python_full_version >= '3.12' \ 219 | --hash=sha256:602b359844e906ee402b7ed9640340cf8a474420d02d8891451733b6b02314ec \ 220 | --hash=sha256:da2866ea53601125ff5baa8aa06404c6e07af3c5ce3d5de95e3b52b80b442887 221 | mkdocs-material-extensions==1.3.1 ; python_full_version >= '3.12' \ 222 | --hash=sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443 \ 223 | --hash=sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31 224 | # via mkdocs-material 225 | mkdocstrings==1.0.0 ; python_full_version >= '3.12' \ 226 | --hash=sha256:351a006dbb27aefce241ade110d3cd040c1145b7a3eb5fd5ac23f03ed67f401a \ 227 | --hash=sha256:4c50eb960bff6e05dfc631f6bc00dfabffbcb29c5ff25f676d64daae05ed82fa 228 | # via mkdocstrings-python 229 | mkdocstrings-python==2.0.1 ; python_full_version >= '3.12' \ 230 | --hash=sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90 \ 231 | --hash=sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732 232 | # via mkdocstrings 233 | packaging==24.2 ; python_full_version >= '3.12' \ 234 | --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \ 235 | --hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f 236 | # via mkdocs 237 | paginate==0.5.7 ; python_full_version >= '3.12' \ 238 | --hash=sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945 \ 239 | --hash=sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591 240 | # via mkdocs-material 241 | pathspec==0.12.1 ; python_full_version >= '3.12' \ 242 | --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ 243 | --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 244 | # via mkdocs 245 | platformdirs==4.3.8 ; python_full_version >= '3.12' \ 246 | --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ 247 | --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 248 | # via mkdocs-get-deps 249 | pygments==2.19.1 ; python_full_version >= '3.12' \ 250 | --hash=sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f \ 251 | --hash=sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c 252 | # via mkdocs-material 253 | pymdown-extensions==10.15 ; python_full_version >= '3.12' \ 254 | --hash=sha256:0e5994e32155f4b03504f939e501b981d306daf7ec2aa1cd2eb6bd300784f8f7 \ 255 | --hash=sha256:46e99bb272612b0de3b7e7caf6da8dd5f4ca5212c0b273feb9304e236c484e5f 256 | # via 257 | # mkdocs-material 258 | # mkdocstrings 259 | python-dateutil==2.9.0.post0 ; python_full_version >= '3.12' \ 260 | --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ 261 | --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 262 | # via ghp-import 263 | pyyaml==6.0.2 ; python_full_version >= '3.12' \ 264 | --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ 265 | --hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \ 266 | --hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \ 267 | --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ 268 | --hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \ 269 | --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ 270 | --hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \ 271 | --hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \ 272 | --hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \ 273 | --hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \ 274 | --hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \ 275 | --hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \ 276 | --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \ 277 | --hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \ 278 | --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ 279 | --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ 280 | --hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \ 281 | --hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \ 282 | --hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \ 283 | --hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \ 284 | --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ 285 | --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ 286 | --hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \ 287 | --hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \ 288 | --hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \ 289 | --hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \ 290 | --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ 291 | --hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \ 292 | --hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \ 293 | --hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \ 294 | --hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \ 295 | --hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \ 296 | --hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \ 297 | --hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \ 298 | --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ 299 | --hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \ 300 | --hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \ 301 | --hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \ 302 | --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ 303 | --hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \ 304 | --hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \ 305 | --hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \ 306 | --hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \ 307 | --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ 308 | --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ 309 | --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 310 | # via 311 | # mkdocs 312 | # mkdocs-get-deps 313 | # pymdown-extensions 314 | # pyyaml-env-tag 315 | pyyaml-env-tag==1.1 ; python_full_version >= '3.12' \ 316 | --hash=sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04 \ 317 | --hash=sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff 318 | # via mkdocs 319 | requests==2.32.4 ; python_full_version >= '3.12' \ 320 | --hash=sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c \ 321 | --hash=sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422 322 | # via mkdocs-material 323 | ruff==0.11.10 ; python_full_version >= '3.12' \ 324 | --hash=sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca \ 325 | --hash=sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f \ 326 | --hash=sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad \ 327 | --hash=sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b \ 328 | --hash=sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125 \ 329 | --hash=sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641 \ 330 | --hash=sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224 \ 331 | --hash=sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947 \ 332 | --hash=sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4 \ 333 | --hash=sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58 \ 334 | --hash=sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5 \ 335 | --hash=sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed \ 336 | --hash=sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6 \ 337 | --hash=sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2 \ 338 | --hash=sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19 \ 339 | --hash=sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523 \ 340 | --hash=sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1 \ 341 | --hash=sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2 342 | six==1.17.0 ; python_full_version >= '3.12' \ 343 | --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ 344 | --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 345 | # via python-dateutil 346 | urllib3==2.6.0 ; python_full_version >= '3.12' \ 347 | --hash=sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f \ 348 | --hash=sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1 349 | # via requests 350 | watchdog==6.0.0 ; python_full_version >= '3.12' \ 351 | --hash=sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a \ 352 | --hash=sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2 \ 353 | --hash=sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f \ 354 | --hash=sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c \ 355 | --hash=sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c \ 356 | --hash=sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c \ 357 | --hash=sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0 \ 358 | --hash=sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13 \ 359 | --hash=sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134 \ 360 | --hash=sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa \ 361 | --hash=sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e \ 362 | --hash=sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379 \ 363 | --hash=sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a \ 364 | --hash=sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11 \ 365 | --hash=sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 \ 366 | --hash=sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b \ 367 | --hash=sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f \ 368 | --hash=sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c \ 369 | --hash=sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112 \ 370 | --hash=sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948 \ 371 | --hash=sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881 \ 372 | --hash=sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860 \ 373 | --hash=sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3 \ 374 | --hash=sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680 \ 375 | --hash=sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26 \ 376 | --hash=sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26 \ 377 | --hash=sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e \ 378 | --hash=sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8 \ 379 | --hash=sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c \ 380 | --hash=sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2 381 | # via mkdocs 382 | --------------------------------------------------------------------------------