Declarative schema language¶
This page describes the wire vocabulary accepted by rustruct.Struct.
Function signatures are listed in Fields and declarations.
Byte order¶
A structure accepts byteorder="big", "little", or "network".
"network" aliases "big". Native byte order is not supported.
Byte order follows Python subclassing: class Child(Base) with no
byteorder= of its own uses Base’s. A structure used as another
structure’s field is a different relationship – composition, not
subclassing – and does not inherit that way: with no byteorder= of its
own, a nested structure defaults to "big" regardless of what the
structure it’s nested in declares. Give a nested structure its own
explicit byteorder= whenever it should match, or differ from, its
container.
Scalar annotations¶
Annotation |
Python value |
Width |
|---|---|---|
|
1 byte |
|
|
2 bytes |
|
|
4 bytes |
|
|
8 bytes |
|
|
4 / 8 bytes |
|
|
1 byte |
Integer values are range-checked during pack.
Field descriptors¶
Every field is either a bare annotation (name: Type, for a scalar or a
nested Struct) or an annotation with a descriptor as its default
(name: Type = descriptor(...)) for anything the annotation alone can’t
express – a runtime-determined length, a bit width, a conversion, or a
computed value.
Descriptor |
Python value |
Wire extent |
|---|---|---|
|
|
exact constant length |
|
|
expression or current-region remainder |
|
|
encoded byte length |
|
|
through a NUL terminator |
|
|
bit width inside a byte-aligned run |
|
|
element count expression |
|
|
current-region remainder |
|
typed nested value |
exact byte window |
|
branch value or default |
selected branch or zero bytes |
|
selected branch value |
selected branch |
|
converted value |
base field extent |
|
|
algorithm width |
|
base value |
base field extent |
Every descriptor, once¶
Each of these is a complete, minimal, runnable declaration. const= fixes
a value the caller never has to supply:
from rustruct import Struct, raw
class Magic(Struct):
signature: bytes = raw(4, const=b"RSTC")
assert Magic().pack() == b"RSTC"
len= accepts a constant, a sibling name, "*" for the region remainder,
or an expression lambda:
from rustruct import Struct, U8, slice
class Trailer(Struct):
kind: U8
data: bytes = slice(len="*")
assert Trailer(kind=1, data=b"xy").pack() == b"\x01xy"
string() follows the same len= rules as slice(), decoding to str:
from rustruct import Struct, U8, string
class Label(Struct):
size: U8
text: str = string(len="size")
assert Label(text="hi").pack() == b"\x02hi"
cstring() has no length field at all – pack writes a NUL after the text,
unpack reads through the first one:
from rustruct import Struct, cstring
class Tag(Struct):
name: str = cstring(max=16)
assert Tag(name="abc").pack() == b"abc\x00"
Consecutive bits() fields share one byte-aligned run, packed
most-significant bit first:
from rustruct import Struct, bits
class Flags(Struct):
a: int = bits(2)
b: int = bits(6)
assert Flags(a=1, b=5).pack() == b"\x45"
count= derives the count field from len(...) on pack; until_eof=True
instead repeats until the current region ends:
from rustruct import Struct, U16, array
class Words(Struct):
values: list = array(U16, until_eof=True)
assert Words(values=[1, 2, 3]).pack() == b"\x00\x01\x00\x02\x00\x03"
sized() gives a nested structure an exact byte window – size is
derived on pack, and the nested value cannot read past it on unpack:
from rustruct import Struct, U16, sized, slice
class Body(Struct):
payload: bytes = slice(len="*")
class Envelope(Struct):
size: U16
body: object = sized(Body, size="size")
assert Envelope(body=Body(payload=b"hi")).pack() == b"\x00\x02hi"
when() includes then only when pred (a field name or expression
lambda) is truthy; otherwise the field consumes zero wire bytes:
from rustruct import Struct, U8, U16, described, when
class Msg(Struct):
has_extra: U8 = described(0)
extra: object = when(pred="has_extra", then=U16, default=None)
assert Msg(has_extra=1, extra=9).pack() == b"\x01\x00\x09"
assert Msg(has_extra=0).pack() == b"\x00"
switch() selects a branch by exact match on on; default (optional)
handles every other tag:
from rustruct import Struct, U8, U16, slice, switch
class Frame(Struct):
kind: U8
body: object = switch(on="kind", cases={1: U16}, default=slice(len="*"))
assert Frame(kind=1, body=0x1234).pack() == b"\x01\x12\x34"
convert() wraps a base scalar/descriptor with Python-level decode/
encode; the core still only ever sees the base wire value:
import ipaddress
from rustruct import Struct, U32, convert
class Endpoint(Struct):
address: object = convert(U32, decode=ipaddress.IPv4Address, encode=int)
assert Endpoint(address=ipaddress.IPv4Address("192.0.2.1")).pack() == b"\xc0\x00\x02\x01"
digest() is always computed on pack, ignoring any caller-supplied value,
and verified on unpack unless verify=False:
from rustruct import Struct, U8, U32, digest, slice
class Chunk(Struct):
size: U8
data: bytes = slice(len="size")
crc: U32 = digest("crc32", over=("data",))
assert Chunk(data=b"abc").pack() == bytes.fromhex("03 616263 352441c2")
described() attaches only a construction default and/or help text; the
wire kind still comes from the annotation:
from rustruct import Struct, U8, described
class Header(Struct):
version: U8 = described(1, help="wire format version")
assert Header().pack() == b"\x01"
Combining several¶
A derived byte length, encoded text, and a counted array in one structure:
from rustruct import Struct, U8, U16, array, string
class Catalog(Struct, byteorder="big"):
name_size: U8
name: str = string(len="name_size")
item_count: U8
items: list = array(U16, count="item_count")
catalog = Catalog(name="tools", items=[10, 20])
wire = catalog.pack()
assert wire == b"\x05tools\x02\x00\x0a\x00\x14"
assert Catalog.unpack(wire) == Catalog(
name_size=5,
name="tools",
item_count=2,
items=[10, 20],
)
rustruct.string() supports UTF-8, ASCII, and Latin-1 with strict
errors. Lengths are encoded byte counts, not Unicode code-point counts.
Length, count, and size expressions¶
A descriptor option accepts:
an integer constant;
"*"for the remainder of the current region;a preceding field name;
a lambda over the current or enclosing field namespace.
References must be backward-resolvable during unpack. They cannot reach into a structure that has not yet been decoded.
When rustruct.slice()(len=…), rustruct.string()(len=…),
rustruct.array()(count=…), or rustruct.sized()(size=…)
refers to a sibling scalar, pack derives that scalar from the encoded value.
Derived fields remain visible after unpack. A rustruct.switch()
discriminant remains explicit input because a branch does not necessarily
identify one unique tag. A field referenced both ways – as a length/count/
size source in some branches and as a switch discriminant in others – stays
explicit input everywhere; pack checks it against the actual data instead of
computing it.
Regions and exact consumption¶
rustruct.Struct.unpack() requires exact top-level consumption.
rustruct.Struct.unpack_from() permits a tail and returns the new
position. rustruct.sized() creates an exact nested region: the
nested value cannot cross its endpoint and must consume the region
completely.
Greedy slices and arrays consume the remainder of their current region. A partial final array item is invalid.
Bits, constants, and digests¶
Consecutive rustruct.bits() fields are packed most-significant bit
first and must end on a byte boundary. signed=True selects signed
interpretation. const= declares a value that is emitted automatically and
verified during unpack.
Digest algorithms include CRC presets, the IPv4 Internet checksum, MD5,
SHA-1, and SHA-256. over="*" covers the enclosing region while treating the
digest field itself as zero. A tuple names sibling spans.
Limits¶
Field-level max= bounds dynamic bytes or text on both sides: unpack
rejects wire data whose length would exceed it, and pack rejects a
Python value that already exceeds it, before writing anything –
a value that violates max= never silently produces wire bytes this
same schema could not unpack again:
from rustruct import PackError, Struct, U8, slice
class Blob(Struct):
length: U8
data: bytes = slice(len="length", max=4)
try:
Blob(data=b"toolong").pack()
except PackError as exc:
assert exc.kind == "limit"
rustruct.compile()(max_default=…) sets the fallback dynamic
allocation limit, and rustruct.compile()(max_count=…) bounds
array element counts. Both compile options have finite defaults.