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.
+