From 65b0895861006a825ff14033d6d8c656137ff4e2 Mon Sep 17 00:00:00 2001 From: MatrixEditor <58256046+MatrixEditor@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:55:25 +0200 Subject: [PATCH 1/3] feat: new struct-level AlignTo class --- src/caterpillar/fields/__init__.py | 134 ++++++++++++++++------------- src/caterpillar/fields/common.py | 98 +++++++++++++++++++-- src/caterpillar/model/_base.py | 80 +++++++++++------ src/caterpillar/model/_bitfield.py | 91 ++++++++++++++++---- src/caterpillar/model/_struct.py | 35 +++++++- src/caterpillar/py.py | 47 ++++++++++ 6 files changed, 369 insertions(+), 116 deletions(-) diff --git a/src/caterpillar/fields/__init__.py b/src/caterpillar/fields/__init__.py index 572caa4..e5a9094 100644 --- a/src/caterpillar/fields/__init__.py +++ b/src/caterpillar/fields/__init__.py @@ -12,11 +12,19 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from ._base import DEFAULT_OPTION, INVALID_DEFAULT, Field, singleton +from ._base import ( + DEFAULT_OPTION, + IGNORED_DEFAULT, + INVALID_DEFAULT, + Field, + has_default, + singleton, +) from ._mixin import Chain, FieldMixin, FieldStruct, Operator, get_args, get_kwargs from .common import ( ENUM_STRICT, Aligned, + AlignTo, AsLengthRef, Bytes, Computed, @@ -144,147 +152,153 @@ uintptr, uintptr_fn, ) +from .unnamed import Unnamed, sized from .varint import VARINT_LSB, VarInt, vint __all__ = [ + "CTX_DIGEST", + "CTX_DIGEST_ALGO", + "CTX_DIGEST_HOOK", + "CTX_DIGEST_OBJ", + "DEFAULT_OPTION", + "ENUM_STRICT", + "HMAC", + "IGNORED_DEFAULT", + "INVALID_DEFAULT", + "MAC", + "PTR_STRICT", + "VARINT_LSB", + "Adler", "Adler_Algo", "Adler_Field", - "Adler", "Algorithm", - "align", + "AlignTo", "Aligned", "And", "AsLengthRef", - "boolean", "Branch", "Bytes", "Bz2Compressed", + "CString", "Chain", - "char", "Compressed", "Computed", "ConditionalChain", "Const", "ConstBytes", "ConstString", + "Crc32", "Crc32_Algo", "Crc32_Field", - "Crc32", - "CString", - "CTX_DIGEST_ALGO", - "CTX_DIGEST_HOOK", - "CTX_DIGEST_OBJ", - "CTX_DIGEST", - "DEFAULT_OPTION", "Digest", "DigestField", "DigestFieldAction", - "double", "Else", "ElseIf", "Encrypted", "End", - "ENUM_STRICT", "Enum", "Field", "FieldMixin", "FieldStruct", - "float16", - "float32", - "float64", - "get_args", - "get_kwargs", - "HMAC", "HMACAlgorithm", - "If", - "Int", - "int16", - "int24", - "int32", - "int64", - "int8", - "intptr_fn", - "intptr", - "INVALID_DEFAULT", "IOHook", "IPv4Address", "IPv6Address", + "If", + "Int", "KeyCipher", - "Lazy", "LZMACompressed", "LZOCompressed", - "MAC", + "Lazy", "MACAddress", + "Md5", "Md5_Algo", "Md5_Field", - "Md5", "Memory", - "offintptr", - "offuintptr", "Operator", "Or", "Otherwise", "Padded", - "padding", "Padding", "Pass", - "pointer", "Pointer", "PostPad", - "Prefixed", "PrePad", - "psize", - "pssize", - "PTR_STRICT", + "Prefixed", "PyStructFormattedField", - "relative_pointer", "RelativePointer", + "Sha1", "Sha1_Algo", "Sha1_Field", - "Sha1", + "Sha2_224", "Sha2_224_Algo", "Sha2_224_Field", - "Sha2_224", + "Sha2_256", "Sha2_256_Algo", "Sha2_256_Field", - "Sha2_256", + "Sha2_384", "Sha2_384_Algo", "Sha2_384_Field", - "Sha2_384", + "Sha2_512", "Sha2_512_Algo", "Sha2_512_Field", - "Sha2_512", + "Sha3_224", "Sha3_224_Algo", "Sha3_224_Field", - "Sha3_224", + "Sha3_256", "Sha3_256_Algo", "Sha3_256_Field", - "Sha3_256", + "Sha3_384", "Sha3_384_Algo", "Sha3_384_Field", - "Sha3_384", + "Sha3_512", "Sha3_512_Algo", "Sha3_512_Field", - "Sha3_512", - "singleton", "Start", "String", "Timestamp", "Transformer", "UInt", + "Unnamed", + "Uuid", + "VarInt", + "When", + "Xor", + "ZLibCompressed", + "align", + "boolean", + "char", + "double", + "float16", + "float32", + "float64", + "get_args", + "get_kwargs", + "has_default", + "int8", + "int16", + "int24", + "int32", + "int64", + "intptr", + "intptr_fn", + "offintptr", + "offuintptr", + "padding", + "pointer", + "psize", + "pssize", + "relative_pointer", + "singleton", + "sized", + "uint8", "uint16", "uint24", "uint32", "uint64", - "uint8", - "uintptr_fn", "uintptr", - "Uuid", - "VARINT_LSB", - "VarInt", + "uintptr_fn", "vint", "void_ptr", - "When", - "Xor", - "ZLibCompressed", ] diff --git a/src/caterpillar/fields/common.py b/src/caterpillar/fields/common.py index 033198e..955cd42 100644 --- a/src/caterpillar/fields/common.py +++ b/src/caterpillar/fields/common.py @@ -1075,9 +1075,7 @@ def unpack_single(self, context: ContextLike) -> _MemoryOT: if size is Ellipsis: return memoryview(stream.read()) - return memoryview( - read_exact(context, size, "Memory field") - ) # pyright: ignore[reportReturnType] + return memoryview(read_exact(context, size, "Memory field")) # pyright: ignore[reportReturnType] class Bytes(Memory[bytes, bytes]): @@ -1711,9 +1709,7 @@ def pack_single(self, obj: _PrefixIOT, context: ContextLike) -> None: self.prefix.pack_single(len(obj), context) else: try: - self.prefix.__pack__( - len(obj), context - ) # pyright: ignore[reportArgumentType] + self.prefix.__pack__(len(obj), context) # pyright: ignore[reportArgumentType] finally: context[CTX_FIELD] = outer_field context[CTX_SEQ] = outer_seq @@ -2521,9 +2517,7 @@ def __init__( fill: Buffer | int = 0x00, strict: bool = False, ) -> None: - self.struct: _StructLike[_IT, _OT] = ( - getstruct(struct) or struct - ) # pyright: ignore[reportAttributeAccessIssue] + self.struct: _StructLike[_IT, _OT] = getstruct(struct) or struct # pyright: ignore[reportAttributeAccessIssue] self.before: int | ContextLambda[int] = before self.after: int | ContextLambda[int] = after fill_bytes = _normalize_fill(fill) @@ -2706,3 +2700,89 @@ def PostPad( slash syntax, e.g. ``PostPad(2)(uint8)`` or ``uint8 / PostPad(2)``. """ return _PadSpec("after", length, fill=fill, strict=strict) + + +class AlignTo: + """ + A trailing alignment spec for ``@struct``, ``@union`` and + ``@bitfield`` classes (``align_to=`` keyword argument). + + Unlike :class:`Aligned`, which pads a wrapped field relative to the + *absolute* stream position, ``AlignTo`` pads the *model itself* so that the + number of bytes it consumes (on unpacking) or writes (on packing) is always + a multiple of :attr:`value` - regardless of where the model happens to be + embedded in a larger stream. + + Example usage: + + >>> @struct(align_to=4) + ... class Format: + ... a: uint8 + ... + >>> sizeof(Format) + 4 + >>> unpack(Format, b"\\x01\\x00\\x00\\x00") + Format(a=1) + >>> unpack(Format, b"\\x01\\xff\\xff\\xff") + Traceback (most recent call last): + ... + ValueError: Expected 3 bytes of padding (fill=b'\\x00\\x00\\x00'), got b'\\xff\\xff\\xff' + + Use the class directly to customize the fill pattern or disable verification: + + >>> @struct(align_to=AlignTo(4, fill=0xFF, strict=False)) + ... class Format: + ... a: uint8 + + :param value: The alignment in bytes, which must be a power of 2. Can be a + plain integer or a context lambda for dynamic alignment. + :param fill: The fill pattern used for padding. Accepts a single integer + (0-255) or a bytes-like pattern, which is repeated/truncated + to the required padding length. Defaults to zero-padding. + :param strict: When `True` (default), unpacking verifies that the parsed + padding bytes actually match `fill` and raises `ValueError` + if they don't. When `False`, padding bytes are consumed + without verification. + :raises ValueError: If `value` is not a power of 2, or (when `strict`) if + parsed padding doesn't match `fill`. + :raises DynamicSizeError: If a dynamic `value` is used and the size is + requested statically (e.g. via `sizeof`). + """ + + __slots__: tuple[str, ...] = ("fill", "strict", "value") + + def __init__( + self, + value: int | ContextLambda[int], + fill: Buffer | int = 0x00, + strict: bool = True, + ) -> None: + if not callable(value): + _validate_alignment(value) + self.value: int | ContextLambda[int] = value + self.fill: bytes = _normalize_fill(fill) + self.strict: bool = strict + + def resolve(self, context: ContextLike) -> int: + """ + Resolve (and validate) the configured alignment value against `context`. + """ + value = self.value(context) if callable(self.value) else self.value + _validate_alignment(value) + return value + + def padding(self, size: int, context: ContextLike) -> int: + """ + Compute the number of padding bytes needed for `size` bytes to become + a multiple of the resolved alignment. + """ + return _align_padding(size, self.resolve(context)) + + def fill_bytes(self, length: int) -> bytes: + """ + Expand (repeat and truncate) the configured fill pattern to `length` bytes. + """ + if length == 0: + return b"" + fill = self.fill + return (fill * ((length + len(fill) - 1) // len(fill)))[:length] diff --git a/src/caterpillar/model/_base.py b/src/caterpillar/model/_base.py index c8f3022..d13e72f 100644 --- a/src/caterpillar/model/_base.py +++ b/src/caterpillar/model/_base.py @@ -13,10 +13,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # pyright: reportPrivateUsage=false, reportAny=false, reportExplicitAny=false -from caterpillar.byteorder import Inherit -import re import dataclasses as dc - +import re from collections.abc import Iterable from typing import Annotated, Any, Generic, get_args, get_origin @@ -34,30 +32,31 @@ _StreamType, _StructLike, ) +from caterpillar.byteorder import Inherit from caterpillar.context import ( CTX_FIELD, CTX_OBJECT, + CTX_ORDER, + CTX_PARENT, CTX_PATH, CTX_ROOT, CTX_SEQ, CTX_STREAM, O_CONTEXT_FACTORY, Context, - CTX_PARENT, - CTX_ORDER, ) -from caterpillar.exception import StructException, ValidationError -from caterpillar.fields import INVALID_DEFAULT, Const, Field, FieldMixin +from caterpillar.exception import StructException, ValidationError, DynamicSizeError +from caterpillar.fields import INVALID_DEFAULT, Const, Field, FieldMixin, AlignTo +from caterpillar.fields._base import IGNORED_DEFAULT, has_default from caterpillar.options import ( + O_DEFAULT_STRUCT_ENDIAN, S_DISCARD_CONST, S_DISCARD_UNNAMED, S_REPLACE_TYPES, S_UNION, - O_DEFAULT_STRUCT_ENDIAN, ) from caterpillar.shared import ATTR_ACTION_PACK, ATTR_ACTION_UNPACK, Action -from caterpillar.fields._base import has_default, IGNORED_DEFAULT class _Member: def __init__( @@ -139,14 +138,15 @@ class Sequence(Generic[_SeqModelT, _SeqIT, _SeqOT], FieldMixin[_SeqIT, _SeqOT]): """ __slots__: tuple[str, ...] = ( - "model", - "fields", - "order", + "_members", + "align_to", "arch", - "options", "field_options", - "_members", + "fields", "is_union", + "model", + "options", + "order", ) def __init__( @@ -156,12 +156,18 @@ def __init__( arch: _ArchLike | None = None, options: Iterable[_OptionLike] | None = None, field_options: Iterable[_OptionLike] | None = None, + align_to: int | _ContextLambda[int] | AlignTo | None = None, ) -> None: self.model: _SeqModelT = model self.arch: _ArchLike | None = arch self.order: _EndianLike | None = order self.options: set[_OptionLike] = set(options or []) self.field_options: set[_OptionLike] = set(field_options or []) + self.align_to: AlignTo | None = ( + align_to + if align_to is None or isinstance(align_to, AlignTo) + else AlignTo(align_to) + ) # these fields will be set or used while processing the model type self._members: dict[str, Field] = {} @@ -503,13 +509,20 @@ def __size__(self, context: _ContextLike) -> int: context[CTX_PATH] = f"{base_path}.{member.name}" size = field.__size__(context) if self.is_union: - if size > max_size: - max_size = size + max_size = max(max_size, size) else: total += size context[CTX_PATH] = base_path - return max_size if self.is_union else total + size = max_size if self.is_union else total + if self.align_to is not None: + if callable(self.align_to.value): + raise DynamicSizeError( + "Structs with a dynamic align_to value don't have a fixed size", + context, + ) + size += self.align_to.padding(size, context) + return size def _resolve_order(self, context: _ContextLike) -> _EndianLike | None: resolved = None @@ -557,9 +570,8 @@ def unpack_one(self, context: _ContextLike) -> _SeqOT: context[ctx_object] = factory(_parent=context) base_path: str = context[ctx_path] stream: _StreamType = context[CTX_STREAM] - start = pos = max_size = 0 - if self.is_union: - start: int = stream.tell() + start: int = stream.tell() if self.is_union or self.align_to is not None else 0 + pos = max_size = 0 for member in fields: if member.is_action: @@ -589,6 +601,19 @@ def unpack_one(self, context: _ContextLike) -> _SeqOT: if self.is_union: # Reset the stream position stream.seek(start + max_size) + + if self.align_to is not None: + consumed = stream.tell() - start + pad = self.align_to.padding(consumed, context) + if pad: + data = stream.read(pad) + expected = self.align_to.fill_bytes(pad) + if self.align_to.strict and data != expected: + raise ValueError( + f"Expected {pad} bytes of padding (fill={expected!r}), " + + f"got {data!r}" + ) + context[ctx_path] = base_path return obj # pyright: ignore[reportReturnType] @@ -611,9 +636,7 @@ def __unpack__(self, context: _ContextLike) -> _SeqOT: # See __pack__ for more information field = context.get("_field") if field and context[CTX_SEQ]: - return unpack_seq( - context, self.unpack_one - ) # pyright: ignore[reportReturnType] + return unpack_seq(context, self.unpack_one) # pyright: ignore[reportReturnType] return self.unpack_one(this_context) def get_value(self, obj: _SeqIT, name: str, field: Field) -> Any | None: @@ -637,6 +660,8 @@ def pack_one(self, obj: _SeqIT, context: _ContextLike) -> None: fields = self.fields base_path: str = context[CTX_PATH] ctx_path = CTX_PATH + stream: _StreamType = context[CTX_STREAM] + start: int = stream.tell() if self.is_union or self.align_to is not None else 0 for member in fields: if member.is_action: @@ -661,7 +686,7 @@ def pack_one(self, obj: _SeqIT, context: _ContextLike) -> None: else: # REVISIT: this line might not be necessary if const fields already # use their internal value. - value = field.default if field.default != INVALID_DEFAULT else None + value = field.default if has_default(field.default) else None field.__pack__(value, context) if self.is_union: @@ -675,6 +700,13 @@ def pack_one(self, obj: _SeqIT, context: _ContextLike) -> None: # REVISIT: are constant values allowed here? + name validation? value = self.get_value(obj, name, union_field) union_field.__pack__(value, context) + + if self.align_to is not None: + written = stream.tell() - start + pad = self.align_to.padding(written, context) + if pad: + stream.write(self.align_to.fill_bytes(pad)) + context[ctx_path] = base_path def __pack__(self, obj: _SeqIT, context: _ContextLike) -> None: diff --git a/src/caterpillar/model/_bitfield.py b/src/caterpillar/model/_bitfield.py index d44d66e..ec74ded 100644 --- a/src/caterpillar/model/_bitfield.py +++ b/src/caterpillar/model/_bitfield.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # pyright: reportPrivateUsage=false +from caterpillar.fields._base import has_default import dataclasses import enum from collections.abc import Iterable @@ -34,8 +35,14 @@ _EndianLike, _OptionLike, _StructLike, + ContextLambda, +) +from caterpillar.byteorder import ( + LITTLE_ENDIAN_FMT, + O_DEFAULT_ENDIAN, + LittleEndian, + Inherit, ) -from caterpillar.byteorder import LITTLE_ENDIAN_FMT, O_DEFAULT_ENDIAN, LittleEndian, Inherit from caterpillar.context import ( CTX_FIELD, CTX_OBJECT, @@ -44,8 +51,8 @@ O_CONTEXT_FACTORY, Context, ) -from caterpillar.exception import StructException, ValidationError -from caterpillar.fields import INVALID_DEFAULT, Field, Pass +from caterpillar.exception import StructException, ValidationError, DynamicSizeError +from caterpillar.fields import INVALID_DEFAULT, Field, Pass, AlignTo from caterpillar.fields.common import Int from caterpillar.options import ( B_GROUP_END, @@ -395,13 +402,13 @@ class BitfieldEntry: """ __slots__: tuple[str, ...] = ( + "action", "bit", - "width", - "name", "factory", - "action", "low_mask", + "name", "signed", + "width", ) def __init__( @@ -610,8 +617,8 @@ class allows for the declarative definition of compact memory representations """ __slots__: tuple[str, ...] = ( - "groups", "alignment", + "groups", ) def __init__( @@ -622,6 +629,7 @@ def __init__( options: Iterable[_OptionLike] | None = None, field_options: Iterable[_OptionLike] | None = None, alignment: int | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> None: self.alignment: int = alignment or DEFAULT_ALIGNMENT # These fields remain private and will be deleted after processing @@ -636,6 +644,7 @@ def __init__( arch=arch, options=options, field_options=field_options, + align_to=align_to, ) # Add additional options based on the struct's type self.options.difference_update(GLOBAL_STRUCT_OPTIONS, GLOBAL_UNION_OPTIONS) @@ -879,9 +888,7 @@ def _process_alignment_option( return False @override - def _process_field( - self, name: str, annotation: _AnnotationT, default: Any - ) -> Field: + def _process_field(self, name: str, annotation: Any, default: Any) -> Field: arch: _ArchLike | None = self.arch order: _EndianLike | None = ( self.order @@ -1001,7 +1008,15 @@ def __size__(self, context: _ContextLike) -> int: :rtype: int """ # size is different as our model includes correct padding - return sum(group.get_size(context) for group in self.groups) + size = sum(group.get_size(context) for group in self.groups) + if self.align_to is not None: + if callable(self.align_to.value): + raise DynamicSizeError( + "Bitfields with a dynamic align_to value don't have a fixed size", + context, + ) + size += self.align_to.padding(size, context) + return size def __bits__(self) -> int: """ @@ -1025,6 +1040,9 @@ def unpack_one(self, context: _ContextLike) -> _VT: field: Field | None = context.get(CTX_FIELD) base_path: str = context[CTX_PATH] members = self._members + stream = context[CTX_STREAM] + start = stream.tell() if self.align_to is not None else 0 + # REVISIT order: _EndianLike = ( field.order @@ -1044,7 +1062,7 @@ def unpack_one(self, context: _ContextLike) -> _VT: init_data[name] = value else: - raw_data = context[CTX_STREAM].read(group.get_size()) + raw_data = stream.read(group.bit_count // 8) if not raw_data: # set context path to next entry for debugging context[CTX_PATH] = f"{base_path}.{group.entries[0].name}" @@ -1056,7 +1074,7 @@ def unpack_one(self, context: _ContextLike) -> _VT: for entry in group.entries: # each entry may be an action context[CTX_PATH] = f"{base_path}.{entry.name}" - if entry.is_action(): + if entry.action is not None: func = getattr(entry.action, ATTR_ACTION_UNPACK, None) if func: func(context) @@ -1065,7 +1083,9 @@ def unpack_one(self, context: _ContextLike) -> _VT: if entry.name not in members: continue - value = (raw_value >> entry.shift(group.bit_count)) & entry.low_mask + shift = max(group.bit_count - entry.bit - entry.width, 0) + value = (raw_value >> shift) & entry.low_mask + # value = (raw_value >> entry.shift(group.bit_count)) & entry.low_mask if entry.factory: value = entry.factory.from_int(value) if entry.signed and value >= 1 << (entry.width - 1): @@ -1073,6 +1093,18 @@ def unpack_one(self, context: _ContextLike) -> _VT: init_data[entry.name] = value context[CTX_OBJECT][entry.name] = value + if self.align_to is not None: + consumed = stream.tell() - start + pad = self.align_to.padding(consumed, context) + if pad: + data = stream.read(pad) + expected = self.align_to.fill_bytes(pad) + if self.align_to.strict and data != expected: + raise ValueError( + f"Expected {pad} bytes of padding (fill={expected!r}), " + + f"got {data!r}" + ) + return self.model(**init_data) # pyright: ignore[reportCallIssue] @override @@ -1085,6 +1117,8 @@ def pack_one(self, obj: _VT, context: _ContextLike) -> None: base_path = context[CTX_PATH] field: Field | None = context.get(CTX_FIELD) members = self._members + stream = context[CTX_STREAM] + start = stream.tell() if self.align_to is not None else 0 # REVISIT order: _EndianLike = ( field.order @@ -1100,7 +1134,7 @@ def pack_one(self, obj: _VT, context: _ContextLike) -> None: if name in members: value = self.get_value(obj, name, field) else: - value = field.default if field.default != INVALID_DEFAULT else None + value = field.default if has_default(field.default) else None field.__pack__(value, context) else: @@ -1135,10 +1169,15 @@ def pack_one(self, obj: _VT, context: _ContextLike) -> None: context, ) - value |= (entry_value & entry.low_mask) << entry.shift( - group.bit_count - ) - context[CTX_STREAM].write(value.to_bytes(group.bit_count // 8, endian)) + shift = max(group.bit_count - entry.bit - entry.width, 0) + value |= (entry_value & entry.low_mask) << shift + stream.write(value.to_bytes(group.bit_count // 8, endian)) + + if self.align_to is not None: + written = stream.tell() - start + pad = self.align_to.padding(written, context) + if pad: + stream.write(self.align_to.fill_bytes(pad)) @override def add_action(self, action: _ActionLike) -> None: @@ -1198,6 +1237,7 @@ def make_bitfield( options: Iterable[_OptionLike] | None = None, field_options: Iterable[_OptionLike] | None = None, alignment: int | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type: """Create a ``Bitfield`` model from a class definition. @@ -1219,6 +1259,10 @@ def make_bitfield( :type field_options: Iterable[_OptionLike] | None, optional :param alignment: Optional bit alignment constraint, defaults to None :type alignment: int | None, optional + :param align_to: Optional trailing byte alignment applied to the + whole bitfield after all groups have been processed, defaults + to None + :type align_to: int | ContextLambda[int] | AlignTo | None, optional :return: The generated Bitfield model class :rtype: type """ @@ -1229,6 +1273,7 @@ def make_bitfield( options=options, field_options=field_options, alignment=alignment, + align_to=align_to, ) return b.model @@ -1246,6 +1291,7 @@ def bitfield( options: Iterable[_OptionLike] | None = None, field_options: Iterable[_OptionLike] | None = None, alignment: int | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type[_VT]: ... @overload @dataclass_transform( @@ -1261,6 +1307,7 @@ def bitfield( options: Iterable[_OptionLike] | None = None, field_options: Iterable[_OptionLike] | None = None, alignment: int | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> Callable[[type[_VT]], type[_VT]]: ... @dataclass_transform( kw_only_default=True, field_specifiers=(dataclasses.field, Invisible) @@ -1275,6 +1322,7 @@ def bitfield( options: Iterable[_OptionLike] | None = None, field_options: Iterable[_OptionLike] | None = None, alignment: int | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type[_VT] | Callable[[type[_VT]], type[_VT]]: """Decorator or direct transformer for creating a ``Bitfield`` model. @@ -1316,6 +1364,9 @@ def bitfield( :type field_options: Iterable[_OptionLike] | None, optional :param alignment: Optional bit alignment constraint, defaults to None :type alignment: int | None, optional + :param align_to: Optional trailing byte alignment applied to the + whole bitfield after all groups have been processed + :type align_to: int | ContextLambda[int] | AlignTo | None, optional :return: The decorated Bitfield model class or a decorator function :rtype: type[_VT] | Callable[[type[_VT]], type[_VT]] """ @@ -1328,6 +1379,7 @@ def wrap(cls: type[_VT]) -> type[_VT]: arch=arch, field_options=field_options, alignment=alignment, + align_to=align_to, ) if ty is not None: @@ -1338,6 +1390,7 @@ def wrap(cls: type[_VT]) -> type[_VT]: arch=arch, field_options=field_options, alignment=alignment, + align_to=align_to, ) return wrap diff --git a/src/caterpillar/model/_struct.py b/src/caterpillar/model/_struct.py index 6592f2f..2938b91 100644 --- a/src/caterpillar/model/_struct.py +++ b/src/caterpillar/model/_struct.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # pyright: reportAny=false, reportExplicitAny=false, reportPrivateUsage=false +from caterpillar.fields._base import has_default import dataclasses as dc import inspect from collections.abc import Collection, Iterable @@ -37,10 +38,12 @@ _LengthT, _OptionLike, _StreamType, - _StructLike, EndianLike, + _StructLike, + EndianLike, + ContextLambda, ) from caterpillar.exception import InvalidValueError -from caterpillar.fields import INVALID_DEFAULT, Field +from caterpillar.fields import INVALID_DEFAULT, Field, AlignTo from caterpillar.fields.conditional import apply_conditional_markers from caterpillar.options import ( GLOBAL_STRUCT_OPTIONS, @@ -85,6 +88,7 @@ def __init__( field_options: Iterable[_OptionLike] | None = None, kw_only: bool = False, hook_cls: type["UnionHook[_ModelT]"] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> None: self.kw_only: bool = kw_only # Cache of init=False field names (e.g. via Invisible); computed lazily @@ -100,6 +104,7 @@ def __init__( arch=arch, options=options, field_options=field_options, + align_to=align_to, ) setattr(self.model, ATTR_STRUCT, self) # Add additional options based on the struct's type @@ -219,7 +224,7 @@ def unpack_one(self, context: _ContextLike) -> _ModelT: def get_value(self, obj: _ModelT, name: str, field: Field) -> Any | None: value = getattr(obj, name, INVALID_DEFAULT) if value is INVALID_DEFAULT: - if field is not None and field.default is not INVALID_DEFAULT: + if field is not None and has_default(field.default): return field.default if field is not None and field._has_cond: return None @@ -516,6 +521,7 @@ def new( order: _EndianLike | None = None, arch: _ArchLike | None = None, field_options: Iterable[_OptionLike] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type[_ModelT]: ... @overload @dataclass_transform(kw_only_default=True, field_specifiers=(dc.field, Invisible)) @@ -527,6 +533,7 @@ def new( order: _EndianLike | None = None, arch: _ArchLike | None = None, field_options: Iterable[_OptionLike] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type[_ModelT]: ... @overload @dataclass_transform(field_specifiers=(dc.field, Invisible)) @@ -538,6 +545,7 @@ def new( order: _EndianLike | None = None, arch: _ArchLike | None = None, field_options: Iterable[_OptionLike] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> Callable[[_ModelT], type[_ModelT]]: ... @overload @dataclass_transform(kw_only_default=True, field_specifiers=(dc.field, Invisible)) @@ -549,6 +557,7 @@ def new( order: _EndianLike | None = None, arch: _ArchLike | None = None, field_options: Iterable[_OptionLike] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> Callable[[_ModelT], type[_ModelT]]: ... @dataclass_transform(field_specifiers=(dc.field, Invisible)) @staticmethod @@ -559,6 +568,7 @@ def new( order: _EndianLike | None = None, arch: _ArchLike | None = None, field_options: Iterable[_OptionLike] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type[_ModelT] | Callable[[_ModelT], type[_ModelT]]: """Decorator or direct constructor for creating a ``Struct`` model. @@ -593,6 +603,9 @@ def new( :param field_options: Additional options applied at the field level, defaults to None :type field_options: Iterable[_OptionLike] | None, optional + :param align_to: Optional trailing alignment applied to the whole + struct after all fields have been processed. + :type align_to: int | ContextLambda[int] | AlignTo | None, optional :return: A transformed Struct model class, or a decorator if ``ty`` is None :rtype: type[_ModelT] | Callable[[_ModelT], type[_ModelT]] """ @@ -605,6 +618,7 @@ def wrap(cls: type[_ModelT]) -> type[_ModelT]: options=options, field_options=field_options, kw_only=kw_only, + align_to=align_to, ) if ty is not None: @@ -615,6 +629,7 @@ def wrap(cls: type[_ModelT]) -> type[_ModelT]: options=options, field_options=field_options, kw_only=kw_only, + align_to=align_to, ) return wrap # pyright: ignore[reportReturnType] @@ -629,6 +644,7 @@ def make_struct( field_options: Iterable[_OptionLike] | None = None, kw_only: bool = False, hook_cls: type["UnionHook[_ModelT]"] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type[_ModelT]: """Internal helper that performs the actual Struct model creation. @@ -654,6 +670,9 @@ def make_struct( :type kw_only: bool, optional :param hook_cls: Optional hook class for union handling, defaults to None :type hook_cls: type["UnionHook[_ModelT]"] | None, optional + :param align_to: Optional trailing alignment applied to the whole + struct after all fields have been processed, defaults to None + :type align_to: int | ContextLambda[int] | AlignTo | None, optional :return: The generated Struct model class :rtype: type[_ModelT] """ @@ -665,6 +684,7 @@ def make_struct( field_options=field_options, kw_only=kw_only, hook_cls=hook_cls, + align_to=align_to, ) return s.model @@ -776,6 +796,7 @@ def union( field_options: Iterable[_OptionLike] | None = None, kw_only: bool = False, hook_cls: type[UnionHook[_ModelT]] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> Callable[[type[_ModelT]], type[_ModelT]]: ... @overload @dataclass_transform(field_specifiers=(dc.field, Invisible)) @@ -789,6 +810,7 @@ def union( field_options: Iterable[_OptionLike] | None = None, kw_only: bool = False, hook_cls: type[UnionHook[_ModelT]] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type[_ModelT]: ... @dataclass_transform(field_specifiers=(dc.field, Invisible)) def union( @@ -801,6 +823,7 @@ def union( field_options: Iterable[_OptionLike] | None = None, kw_only: bool = False, hook_cls: type[UnionHook[_ModelT]] | None = None, + align_to: int | ContextLambda[int] | AlignTo | None = None, ) -> type[_ModelT] | Callable[[type[_ModelT]], type[_ModelT]]: """ Decorator to create a Union class. @@ -809,6 +832,8 @@ def union( :param options: Additional options specifying what to include in the final class. :param order: Optional configuration value for the byte order of a field. :param arch: Global architecture definition (will be inferred on all fields). + :param align_to: Optional trailing alignment applied to the whole union + after its (largest) member has been processed :return: The created Union class or a wrapper function if cls is not provided. """ @@ -823,6 +848,7 @@ def wrap(cls: type[_ModelT]) -> type[_ModelT]: field_options=field_options, kw_only=kw_only, hook_cls=hook_cls, + align_to=align_to, ) if cls is not None: @@ -834,6 +860,7 @@ def wrap(cls: type[_ModelT]) -> type[_ModelT]: field_options=field_options, kw_only=kw_only, hook_cls=hook_cls, + align_to=align_to, ) - return wrap + return wrap \ No newline at end of file diff --git a/src/caterpillar/py.py b/src/caterpillar/py.py index a390564..aa1701e 100644 --- a/src/caterpillar/py.py +++ b/src/caterpillar/py.py @@ -15,6 +15,28 @@ from typing import Annotated as f from ._common import WithoutContextVar, iseof, pack_seq, unpack_seq +from .abc import ( + ActionLike, + ArchLike, + ArrayFactoryLike, + ContainsBits, + ContainsStruct, + ContextFactoryLike, + ContextLambda, + ContextLike, + EndianLike, + OptionLike, + StructLike, + SupportsActionPack, + SupportsActionUnpack, + SupportsBits, + SupportsPack, + SupportsSetEndian, + SupportsSize, + SupportsType, + SupportsUnpack, + SwitchLambda, +) from .byteorder import ( AARCH64, AMD, @@ -187,6 +209,7 @@ "GLOBAL_STRUCT_OPTIONS", "GLOBAL_UNION_OPTIONS", "HMAC", + "IGNORED_DEFAULT", "INVALID_DEFAULT", "MAC", "MIPS", @@ -209,13 +232,17 @@ "S_UNION", "VARINT_LSB", "Action", + "ActionLike", "Adler", "Adler_Algo", "Adler_Field", "Algorithm", + "AlignTo", "Aligned", "And", "Arch", + "ArchLike", + "ArrayFactoryLike", "AsLengthRef", "BigEndian", "BinaryExpression", @@ -237,8 +264,13 @@ "Const", "ConstBytes", "ConstString", + "ContainsBits", + "ContainsStruct", "Context", + "ContextFactoryLike", + "ContextLambda", "ContextLength", + "ContextLike", "ContextPath", "Crc32", "Crc32_Algo", @@ -255,6 +287,7 @@ "Encrypted", "End", "EndGroup", + "EndianLike", "Enum", "EnumFactory", "ExprMixin", @@ -286,6 +319,7 @@ "NewGroup", "Operator", "OptionError", + "OptionLike", "Or", "Otherwise", "Padded", @@ -337,6 +371,16 @@ "Struct", "StructDefMixin", "StructException", + "StructLike", + "SupportsActionPack", + "SupportsActionUnpack", + "SupportsBits", + "SupportsPack", + "SupportsSetEndian", + "SupportsSize", + "SupportsType", + "SupportsUnpack", + "SwitchLambda", "SysNative", "TemplateFieldRef", "TemplateTypeVar", @@ -345,6 +389,7 @@ "UInt", "UnaryExpression", "UnionHook", + "Unnamed", "Uuid", "ValidationError", "VarInt", @@ -373,6 +418,7 @@ "get_kwargs", "getbits", "getstruct", + "has_default", "has_flag", "hasstruct", "int8", @@ -403,6 +449,7 @@ "set_struct_flags", "set_union_flags", "singleton", + "sized", "sizeof", "struct", "struct_factory", From 7f2760c0ba3b2f2453c6d5996fba00e68cba7d0a Mon Sep 17 00:00:00 2001 From: MatrixEditor <58256046+MatrixEditor@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:09:09 +0200 Subject: [PATCH 2/3] tests: add AlignTo tests + fix getstruct typing --- src/caterpillar/shared.py | 11 +- test/_Py/model/test_align_to.py | 254 ++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 test/_Py/model/test_align_to.py diff --git a/src/caterpillar/shared.py b/src/caterpillar/shared.py index de55cda..82eee7c 100644 --- a/src/caterpillar/shared.py +++ b/src/caterpillar/shared.py @@ -42,9 +42,9 @@ and won't be stored as part of the struct model. """ -from typing import TYPE_CHECKING, Any, Generic, overload +from typing import TYPE_CHECKING, Any, Final, Generic, Literal, overload -from typing_extensions import Buffer, Final, Literal, TypeIs, override +from typing_extensions import Buffer, TypeIs, override from caterpillar.abc import ( _IT, @@ -61,6 +61,7 @@ if TYPE_CHECKING: from caterpillar.fields import FieldStruct + from caterpillar.model import Struct # --- Shared Concepts --- # TODO: This section needs some docs @@ -297,7 +298,7 @@ def getstruct( obj: type[_IT], /, __default: None = None, -) -> "FieldStruct[_IT, _IT]": ... +) -> "Struct[_IT]": ... @overload def getstruct( obj: _ContainsStruct[_IT, _OT], @@ -309,10 +310,10 @@ def getstruct( obj: object, /, __default: _StructLike | None = None, -) -> "_StructLike | FieldStruct | None": ... +) -> "_StructLike | Struct[_IT] | None": ... def getstruct( obj: object, /, __default: _StructLike | None = None -) -> "_StructLike | FieldStruct | None": +) -> "_StructLike | Struct[_IT] | None": """ Get the structure attribute of the given object. diff --git a/test/_Py/model/test_align_to.py b/test/_Py/model/test_align_to.py new file mode 100644 index 0000000..f7f8b0c --- /dev/null +++ b/test/_Py/model/test_align_to.py @@ -0,0 +1,254 @@ +import pytest + +from caterpillar.py import ( + AlignTo, + DynamicSizeError, + StructDefMixin, + bitfield, + getstruct, + pack, + sizeof, + struct, + this, + union, + unpack, +) +from caterpillar.shortcuts import f +from caterpillar.types import int3_t, int5_t, uint8_t, uint32_t + + +# --------------------------------------------------------------------------- # +# AlignTo (spec object) tests +# --------------------------------------------------------------------------- # +def test_align_to_rejects_non_power_of_two(): + with pytest.raises(ValueError): + AlignTo(0) + with pytest.raises(ValueError): + AlignTo(3) + + +def test_align_to_accepts_dynamic(): + # a callable value is only validated once resolved against a context + spec = AlignTo(lambda context: 4) + assert callable(spec.value) + + +def test_align_to_default_fill_and_strict(): + spec = AlignTo(4) + assert spec.fill == b"\x00" + assert spec.strict is True + + +def test_align_to_fill_bytes(): + spec = AlignTo(8, fill=b"\xde\xad") + assert spec.fill_bytes(0) == b"" + assert spec.fill_bytes(1) == b"\xde" + assert spec.fill_bytes(5) == b"\xde\xad\xde\xad\xde" + + +# --------------------------------------------------------------------------- # +# @struct(align_to=...) +# --------------------------------------------------------------------------- # +def test_struct_align_to_int(): + @struct(align_to=4) + class Format: + a: uint8_t + + assert isinstance(getstruct(Format).align_to, AlignTo) + assert getstruct(Format).align_to.value == 4 + + +def test_struct_align_to_padding(): + @struct(align_to=4) + class Format: + a: uint8_t + + assert sizeof(Format) == 4 + obj = Format(a=1) + assert pack(obj) == b"\x01\x00\x00\x00" + assert unpack(Format, b"\x01\x00\x00\x00") == obj + + +def test_struct_align_to_already_aligned(): + @struct(align_to=4) + class Format: + a: uint32_t + + assert sizeof(Format) == 4 + obj = Format(a=0x11223344) + assert pack(obj) == b"\x44\x33\x22\x11" + assert unpack(Format, b"\x44\x33\x22\x11") == obj + + +def test_struct_align_to_custom_fill_pattern(): + @struct(align_to=AlignTo(4, fill=b"\xde\xad")) + class Format: + a: uint8_t + + obj = Format(a=1) + assert pack(obj) == b"\x01\xde\xad\xde" + assert unpack(Format, b"\x01\xde\xad\xde") == obj + + +def test_struct_align_to_strict_rejects_mismatched_padding(): + @struct(align_to=4) + class Format: + a: uint8_t + + with pytest.raises(ValueError): + unpack(Format, b"\x01\xff\xff\xff") + + +def test_struct_align_to_lenient_skips_verification(): + @struct(align_to=AlignTo(4, strict=False)) + class Format: + a: uint8_t + + obj = unpack(Format, b"\x01\xff\xff\xff") + assert obj.a == 1 + + +def test_struct_align_to_dynamic_value(): + @struct(align_to=this.alignment) + class Format: + alignment: uint8_t + a: uint8_t + + obj = Format(alignment=4, a=1) + # 2 bytes consumed (alignment + a) -> padded up to the next multiple of 4 + assert pack(obj) == b"\x04\x01\x00\x00" + assert unpack(Format, b"\x04\x01\x00\x00") == obj + + +def test_struct_align_to_context_lambda(): + @struct(align_to=lambda context: 4) + class Format: + a: uint8_t + + obj = Format(a=1) + assert pack(obj) == b"\x01\x00\x00\x00" + assert unpack(Format, b"\x01\x00\x00\x00") == obj + + +def test_struct_align_to_nested_struct(): + @struct(align_to=4) + class Inner: + a: uint8_t + + @struct + class Outer: + b: uint8_t + inner: Inner + c: uint8_t + + assert sizeof(Inner) == 4 + assert sizeof(Outer) == 6 + obj = Outer(b=1, inner=Inner(a=2), c=3) + data = b"\x01\x02\x00\x00\x00\x03" + assert pack(obj) == data + assert unpack(Outer, data) == obj + + +def test_struct_align_to_inside_array(): + # Each array element must be individually padded to its own alignment, + # not just the array as a whole. For array-wide alignment, use Aligned() + @struct(align_to=4) + class Small(StructDefMixin): + a: uint8_t + + @struct + class Outer: + items: f[list[Small], Small[2]] + + obj = Outer(items=[Small(a=1), Small(a=2)]) + data = b"\x01\x00\x00\x00\x02\x00\x00\x00" + assert pack(obj) == data + unpacked = unpack(Outer, data) + assert unpacked.items[0] == Small(a=1) + assert unpacked.items[1] == Small(a=2) + + +# --------------------------------------------------------------------------- # +# @union(align_to=...) +# --------------------------------------------------------------------------- # +def test_union_align_to_padding(): + @union(align_to=8) + class U: + a: uint8_t + b: uint32_t + + assert sizeof(U) == 8 + obj = U(b=0x11223344, a=0) + data = pack(obj) + assert len(data) == 8 + assert data[:4] == b"\x44\x33\x22\x11" + assert data[4:] == b"\x00\x00\x00\x00" + unpacked = unpack(U, data) + assert unpacked.b == 0x11223344 + + +def test_union_align_to_strict(): + @union(align_to=8) + class U: + a: uint8_t + b: uint32_t + + with pytest.raises(ValueError): + unpack(U, b"\x44\x33\x22\x11\xff\xff\xff\xff") + + +# --------------------------------------------------------------------------- # +# @bitfield(align_to=...) +# --------------------------------------------------------------------------- # +def test_bitfield_align_to_padding(): + @bitfield(align_to=4) + class Packet: + version: int3_t + type: int5_t + + assert sizeof(Packet) == 4 + obj = Packet(version=1, type=2) + data = pack(obj) + assert len(data) == 4 + assert data[1:] == b"\x00\x00\x00" + assert unpack(Packet, data) == obj + + +def test_bitfield_align_to_strict(): + @bitfield(align_to=4) + class Packet: + version: int3_t + type: int5_t + + with pytest.raises(ValueError): + unpack(Packet, b"\x11\xff\xff\xff") + + +def test_bitfield_align_to_with_bit_group_alignment(): + # alignment= (bit-group alignment, in bits) and align_to= (trailing + # byte alignment) are independent and must work without interference. + @bitfield(alignment=16, align_to=8) + class Both: + version: int3_t + type: int5_t + + assert sizeof(Both) == 8 + obj = Both(version=1, type=2) + data = pack(obj) + assert len(data) == 8 + assert unpack(Both, data) == obj + + +def test_bitfield_align_to_dynamic_value(): + @bitfield(align_to=lambda context: 4) + class Packet: + version: int3_t + type: int5_t + + with pytest.raises(DynamicSizeError): + sizeof(Packet) + + obj = Packet(version=1, type=2) + data = pack(obj) + assert len(data) == 4 + assert unpack(Packet, data) == obj From 96e8d52b59ba080c7d27dda21f78f3d1ec0eef1d Mon Sep 17 00:00:00 2001 From: MatrixEditor <58256046+MatrixEditor@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:19:10 +0200 Subject: [PATCH 3/3] docs: add AlignTo documentation+tutorial --- docs/sphinx/source/library/fields/common.rst | 12 ++++ docs/sphinx/source/library/model/bitfield.rst | 5 ++ docs/sphinx/source/library/model/struct.rst | 6 ++ .../sphinx/source/tutorial/basics/padding.rst | 68 +++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/docs/sphinx/source/library/fields/common.rst b/docs/sphinx/source/library/fields/common.rst index f039da1..8596b05 100644 --- a/docs/sphinx/source/library/fields/common.rst +++ b/docs/sphinx/source/library/fields/common.rst @@ -331,6 +331,18 @@ Special Structs .. versionadded:: 2.4.0 +.. autoclass:: caterpillar.fields.AlignTo + :members: + + A trailing, self-contained alignment spec used via the ``align_to=`` + keyword on :func:`~caterpillar.py.struct`, :func:`~caterpillar.py.union` + and :func:`~caterpillar.py.bitfield`. Unlike :class:`Aligned`, which pads + relative to the *absolute* stream position, ``align_to=`` pads the model + itself so its own packed size is always a multiple of the alignment - + regardless of where it is embedded. See :ref:`tutorial-align_to`. + + .. versionadded:: 2.10.0 + .. autoclass:: caterpillar.fields.Computed :members: diff --git a/docs/sphinx/source/library/model/bitfield.rst b/docs/sphinx/source/library/model/bitfield.rst index 87391fd..078a51e 100644 --- a/docs/sphinx/source/library/model/bitfield.rst +++ b/docs/sphinx/source/library/model/bitfield.rst @@ -89,6 +89,11 @@ Main Interface .. versionchanged:: 2.5.0 Added the ``alignment`` parameter. + .. versionchanged:: 2.10.0 + Added the ``align_to`` parameter for whole-bitfield trailing byte + alignment (distinct from the bit-group ``alignment`` parameter + above). See :ref:`tutorial-align_to`. + Default Factory Classes ----------------------- diff --git a/docs/sphinx/source/library/model/struct.rst b/docs/sphinx/source/library/model/struct.rst index f8e794a..ffedd84 100644 --- a/docs/sphinx/source/library/model/struct.rst +++ b/docs/sphinx/source/library/model/struct.rst @@ -29,6 +29,12 @@ Standard Interface .. autofunction:: caterpillar.model.struct + .. versionchanged:: 2.10.0 + Added the ``align_to`` parameter (also accepted by :func:`union` + above) for whole-model trailing byte alignment. See + :ref:`tutorial-align_to`. + + .. autofunction:: caterpillar.model.pack .. autofunction:: caterpillar.model.pack_into diff --git a/docs/sphinx/source/tutorial/basics/padding.rst b/docs/sphinx/source/tutorial/basics/padding.rst index b93cf18..ec8f04c 100644 --- a/docs/sphinx/source/tutorial/basics/padding.rst +++ b/docs/sphinx/source/tutorial/basics/padding.rst @@ -16,3 +16,71 @@ length using the `padding` keyword. This is useful when you need to ensure that certain fields are aligned or when the structure requires reserved spaces. >>> field = padding[10] # greedy or dynamic size + + +.. _tutorial-align_to: + +Whole-model alignment +---------------------- + +.. versionadded:: 2.10.0 + +The :code:`padding` field above reserves space explicitly, as an extra field +of its own. Sometimes what you actually want is different: a whole +:code:`@struct`, :code:`@union` or :code:`@bitfield` class whose *total* +packed size is always rounded up to a multiple of some alignment. +Pass :code:`align_to=` to the decorator instead: + +>>> @struct(align_to=4) +... class Format: +... a: uint8 +... +>>> sizeof(Format) +4 +>>> unpack(Format, b"\x01\x00\x00\x00") +Format(a=1) + +This is deliberately different from :class:`~caterpillar.fields.Aligned` and +:func:`~caterpillar.fields.align`, which both pad relative to the *absolute* +stream position. :code:`align_to=` instead measures *this model's own* start +and end, so the padding only ever depends on the model's own content - not on +where it happens to be embedded: + +>>> @struct +... class Outer: +... b: uint8 +... inner: Format +... c: uint8 +... +>>> pack(Outer(b=1, inner=Format(a=2), c=3)) +b'\x01\x02\x00\x00\x00\x03' + +A bare :code:`int` or context lambda is accepted directly, as shown above. +Use :class:`~caterpillar.fields.AlignTo` explicitly to customize the fill +byte (or a multi-byte fill pattern) and/or relax verification: + +>>> @struct(align_to=AlignTo(4, fill=0xFF, strict=False)) +... class Lenient: +... a: uint8 + +With the default :code:`strict=True`, unpacking verifies that the padding +bytes actually match :code:`fill` and raises :code:`ValueError` if they +don't. With :code:`strict=False`, padding bytes are consumed without +verification. + +:code:`align_to=` also works on :code:`@union` (padding after the largest +member) and :code:`@bitfield` (padding after all bit-groups have been +finalized to whole bytes), and applies per-element when the model is used +inside an array, so each element keeps its own alignment: + +>>> @struct +... class Many: +... items: Format[2] +... +>>> pack(Many(items=[Format(a=1), Format(a=2)])) +b'\x01\x00\x00\x00\x02\x00\x00\x00' + +A dynamic (context-dependent) alignment value is supported too, but makes the +model's size undeterminable ahead of time - :func:`~caterpillar.model.sizeof` +raises :class:`~caterpillar.exception.DynamicSizeError` in that case. +