Skip to content

API reference

Everything below is importable from bytestruct (the exceptions also from bytestruct.exceptions).

Declarative binary records and composable codecs, on the standard library alone.

Fixed-size records are declared as dataclasses deriving from :class:BinaryStruct, with each field's wire type given by its annotation::

@dataclass
class Header(BinaryStruct, byte_order="<"):
    magic: Annotated[u32, Const(0x1F3D5B79)] = field(default=0x1F3D5B79, init=False)
    size: u32
    name: Annotated[str, PaddedString(16)]
    kind: Annotated[SomeIntEnum, u16]  # parsed into the enum type

header = Header.parse(data)  # short input -> TruncatedDataError, wrong magic -> UnexpectedConstantError
data = header.build()  # an unencodable value or a wrong constant -> BinaryBuildError

The layout is compiled into struct.Struct objects once, when the class is defined, so a parse is one unpack_from per byte-order run. Wire fields are positional __init__ parameters in wire order; a field declared = field(default=..., init=False) is left out of __init__: parse skips its bytes (padding) and build packs its default (on a Const field, the constant). Fields without a wire type may follow the wire fields and are ignored by parse/build. Records cannot be subclassed.

Variable-length values are described by codecs, which compose like construct's::

STRING = Aligned(4, Prefixed(U32_LE, CSTRING))  # u32 length, NUL-terminated UTF-8, padded to 4 bytes
OBJECT = Switch(U32_LE, {1: STRING, 2: U64_LE})  # parses to (tag, value)

A codec is anything with read(reader) and build(value): the :class:Codec combinators, the scalars (U32_LE, ...), Bytes(n), and record classes themselves. A record field annotated with a codec (name: Annotated[str, CSTRING]) makes the record variable-length; such fields follow the fixed-size ones. Formats with no declarative shape are read by hand with :class:ByteReader.

BytesLike module-attribute

BytesLike = Union[bytes, bytearray, memoryview]

Any buffer the parsers accept: bytes, bytearray or memoryview.

U8 module-attribute

U8 = Scalar('B')

An unsigned 8-bit integer.

U16_LE module-attribute

U16_LE = Scalar('<H')

A little-endian unsigned 16-bit integer.

U16_BE module-attribute

U16_BE = Scalar('>H')

A big-endian unsigned 16-bit integer.

U32_LE module-attribute

U32_LE = Scalar('<I')

A little-endian unsigned 32-bit integer.

U32_BE module-attribute

U32_BE = Scalar('>I')

A big-endian unsigned 32-bit integer.

U64_LE module-attribute

U64_LE = Scalar('<Q')

A little-endian unsigned 64-bit integer.

U64_BE module-attribute

U64_BE = Scalar('>Q')

A big-endian unsigned 64-bit integer.

I16_LE module-attribute

I16_LE = Scalar('<h')

A little-endian signed 16-bit integer.

I16_BE module-attribute

I16_BE = Scalar('>h')

A big-endian signed 16-bit integer.

I32_LE module-attribute

I32_LE = Scalar('<i')

A little-endian signed 32-bit integer.

I32_BE module-attribute

I32_BE = Scalar('>i')

A big-endian signed 32-bit integer.

I64_LE module-attribute

I64_LE = Scalar('<q')

A little-endian signed 64-bit integer.

I64_BE module-attribute

I64_BE = Scalar('>q')

A big-endian signed 64-bit integer.

F32_LE module-attribute

F32_LE = Scalar('<f')

A little-endian IEEE 754 single.

F32_BE module-attribute

F32_BE = Scalar('>f')

A big-endian IEEE 754 single.

F64_LE module-attribute

F64_LE = Scalar('<d')

A little-endian IEEE 754 double.

F64_BE module-attribute

F64_BE = Scalar('>d')

A big-endian IEEE 754 double.

PASS module-attribute

PASS = Pass()

The :class:Pass codec.

GREEDY_BYTES module-attribute

GREEDY_BYTES = GreedyBytes()

The :class:GreedyBytes codec.

GREEDY_STRING module-attribute

GREEDY_STRING = GreedyString()

A strict :class:GreedyString codec.

CSTRING module-attribute

CSTRING = CString()

A strict :class:CString codec.

u8 module-attribute

u8 = Annotated[int, Format('B')]

An unsigned 8-bit integer field.

u16 module-attribute

u16 = Annotated[int, Format('H')]

An unsigned 16-bit integer field, in the record's byte order.

u32 module-attribute

u32 = Annotated[int, Format('I')]

An unsigned 32-bit integer field, in the record's byte order.

u64 module-attribute

u64 = Annotated[int, Format('Q')]

An unsigned 64-bit integer field, in the record's byte order.

i16 module-attribute

i16 = Annotated[int, Format('h')]

A signed 16-bit integer field, in the record's byte order.

i32 module-attribute

i32 = Annotated[int, Format('i')]

A signed 32-bit integer field, in the record's byte order.

i64 module-attribute

i64 = Annotated[int, Format('q')]

A signed 64-bit integer field, in the record's byte order.

u32le module-attribute

u32le = Annotated[int, Format('I', '<')]

An unsigned 32-bit integer field, always little-endian.

u32be module-attribute

u32be = Annotated[int, Format('I', '>')]

An unsigned 32-bit integer field, always big-endian.

i8 module-attribute

i8 = Annotated[int, Format('b')]

A signed 8-bit integer field.

f32 module-attribute

f32 = Annotated[float, Format('f')]

An IEEE 754 single-precision float field, in the record's byte order.

f64 module-attribute

f64 = Annotated[float, Format('d')]

An IEEE 754 double-precision float field, in the record's byte order.

u16le module-attribute

u16le = Annotated[int, Format('H', '<')]

An unsigned 16-bit integer field, always little-endian.

u16be module-attribute

u16be = Annotated[int, Format('H', '>')]

An unsigned 16-bit integer field, always big-endian.

u64le module-attribute

u64le = Annotated[int, Format('Q', '<')]

An unsigned 64-bit integer field, always little-endian.

u64be module-attribute

u64be = Annotated[int, Format('Q', '>')]

An unsigned 64-bit integer field, always big-endian.

i16le module-attribute

i16le = Annotated[int, Format('h', '<')]

A signed 16-bit integer field, always little-endian.

i16be module-attribute

i16be = Annotated[int, Format('h', '>')]

A signed 16-bit integer field, always big-endian.

i32le module-attribute

i32le = Annotated[int, Format('i', '<')]

A signed 32-bit integer field, always little-endian.

i32be module-attribute

i32be = Annotated[int, Format('i', '>')]

A signed 32-bit integer field, always big-endian.

i64le module-attribute

i64le = Annotated[int, Format('q', '<')]

A signed 64-bit integer field, always little-endian.

i64be module-attribute

i64be = Annotated[int, Format('q', '>')]

A signed 64-bit integer field, always big-endian.

f32le module-attribute

f32le = Annotated[float, Format('f', '<')]

An IEEE 754 single-precision float field, always little-endian.

f32be module-attribute

f32be = Annotated[float, Format('f', '>')]

An IEEE 754 single-precision float field, always big-endian.

f64le module-attribute

f64le = Annotated[float, Format('d', '<')]

An IEEE 754 double-precision float field, always little-endian.

f64be module-attribute

f64be = Annotated[float, Format('d', '>')]

An IEEE 754 double-precision float field, always big-endian.

uuid_t module-attribute

uuid_t = Annotated[uuid.UUID, Uuid()]

A 16-byte UUID field, parsed into a :class:uuid.UUID.

ipv4 module-attribute

ipv4 = Annotated[ipaddress.IPv4Address, IPAddress(4)]

A 4-byte IPv4 address field, parsed into an :class:ipaddress.IPv4Address.

ipv6 module-attribute

ipv6 = Annotated[ipaddress.IPv6Address, IPAddress(6)]

A 16-byte IPv6 address field, parsed into an :class:ipaddress.IPv6Address.

ByteReader

Sequential reader over the window data[offset:end] of an in-memory buffer; every read*/unpack* returns exactly what was asked for or raises :class:TruncatedDataError.

Readers made by :meth:sub share data (nothing is copied) and have a narrower end. base is the absolute offset of data[0] in the outermost buffer, so errors report absolute offsets.

Source code in bytestruct/__init__.py
class ByteReader:
    """Sequential reader over the window ``data[offset:end]`` of an in-memory buffer; every ``read*``/``unpack*``
    returns exactly what was asked for or raises :class:`TruncatedDataError`.

    Readers made by :meth:`sub` share ``data`` (nothing is copied) and have a narrower ``end``. ``base`` is the
    absolute offset of ``data[0]`` in the outermost buffer, so errors report absolute offsets.
    """

    __slots__ = ("base", "data", "end", "offset")

    def __init__(self, data: BytesLike, offset: int = 0, base: int = 0) -> None:
        self.data = data if type(data) is bytes else bytes(data)
        self.base = base
        self.end = len(self.data)
        if not 0 <= offset <= self.end:  # offsets often come from the wire
            raise BinaryParseError(f"invalid offset {offset} in {self.end} bytes", offset=base)
        self.offset = offset

    @property
    def remaining(self) -> int:
        """The number of bytes left before ``end``."""
        return self.end - self.offset

    @property
    def absolute(self) -> int:
        """The current position as an absolute offset in the outermost buffer."""
        return self.base + self.offset

    def _invalid_size(self, size: int) -> BinaryParseError:
        if size < 0:  # a length computed from wire data
            return BinaryParseError(f"invalid size {size}", offset=self.absolute)
        return TruncatedDataError(f"expected {size} bytes, found {self.remaining}", offset=self.absolute)

    def read(self, size: int) -> bytes:
        """Consume and return the next ``size`` bytes."""
        offset = self.offset
        if not 0 <= size <= self.end - offset:
            raise self._invalid_size(size)
        self.offset = offset + size
        return self.data[offset : offset + size]

    def skip(self, size: int) -> None:
        """Consume the next ``size`` bytes without returning them."""
        if not 0 <= size <= self.end - self.offset:
            raise self._invalid_size(size)
        self.offset += size

    def sub(self, size: int) -> "ByteReader":
        """Consume the next ``size`` bytes and return a reader confined to them."""
        start = self.offset
        self.skip(size)
        reader = ByteReader.__new__(ByteReader)
        reader.data, reader.base, reader.offset, reader.end = self.data, self.base, start, start + size
        return reader

    def read_all(self) -> bytes:
        """Consume and return every remaining byte, up to ``end``."""
        return self.read(self.end - self.offset)

    def unpack(self, fmt: struct.Struct) -> tuple[Any, ...]:
        """Consume ``fmt.size`` bytes and return all the values ``fmt`` unpacks from them."""
        offset = self.offset
        if fmt.size > self.end - offset:
            raise self._invalid_size(fmt.size)
        self.offset = offset + fmt.size
        return fmt.unpack_from(self.data, offset)

    def unpack_one(self, fmt: struct.Struct) -> Any:
        """Consume ``fmt.size`` bytes and return the single value ``fmt`` unpacks from them."""
        offset = self.offset
        if fmt.size > self.end - offset:
            raise self._invalid_size(fmt.size)
        self.offset = offset + fmt.size
        return fmt.unpack_from(self.data, offset)[0]

    def read_cstring(self) -> bytes:
        """Read up to (and consume) a NUL terminator; the terminator is not returned."""
        end = self.data.find(b"\0", self.offset, self.end)
        if end == -1:
            raise TruncatedDataError("unterminated C string", offset=self.absolute)
        result = self.data[self.offset : end]
        self.offset = end + 1
        return result

remaining property

remaining: int

The number of bytes left before end.

absolute property

absolute: int

The current position as an absolute offset in the outermost buffer.

read

read(size: int) -> bytes

Consume and return the next size bytes.

Source code in bytestruct/__init__.py
def read(self, size: int) -> bytes:
    """Consume and return the next ``size`` bytes."""
    offset = self.offset
    if not 0 <= size <= self.end - offset:
        raise self._invalid_size(size)
    self.offset = offset + size
    return self.data[offset : offset + size]

skip

skip(size: int) -> None

Consume the next size bytes without returning them.

Source code in bytestruct/__init__.py
def skip(self, size: int) -> None:
    """Consume the next ``size`` bytes without returning them."""
    if not 0 <= size <= self.end - self.offset:
        raise self._invalid_size(size)
    self.offset += size

sub

sub(size: int) -> ByteReader

Consume the next size bytes and return a reader confined to them.

Source code in bytestruct/__init__.py
def sub(self, size: int) -> "ByteReader":
    """Consume the next ``size`` bytes and return a reader confined to them."""
    start = self.offset
    self.skip(size)
    reader = ByteReader.__new__(ByteReader)
    reader.data, reader.base, reader.offset, reader.end = self.data, self.base, start, start + size
    return reader

read_all

read_all() -> bytes

Consume and return every remaining byte, up to end.

Source code in bytestruct/__init__.py
def read_all(self) -> bytes:
    """Consume and return every remaining byte, up to ``end``."""
    return self.read(self.end - self.offset)

unpack

unpack(fmt: Struct) -> tuple[Any, ...]

Consume fmt.size bytes and return all the values fmt unpacks from them.

Source code in bytestruct/__init__.py
def unpack(self, fmt: struct.Struct) -> tuple[Any, ...]:
    """Consume ``fmt.size`` bytes and return all the values ``fmt`` unpacks from them."""
    offset = self.offset
    if fmt.size > self.end - offset:
        raise self._invalid_size(fmt.size)
    self.offset = offset + fmt.size
    return fmt.unpack_from(self.data, offset)

unpack_one

unpack_one(fmt: Struct) -> Any

Consume fmt.size bytes and return the single value fmt unpacks from them.

Source code in bytestruct/__init__.py
def unpack_one(self, fmt: struct.Struct) -> Any:
    """Consume ``fmt.size`` bytes and return the single value ``fmt`` unpacks from them."""
    offset = self.offset
    if fmt.size > self.end - offset:
        raise self._invalid_size(fmt.size)
    self.offset = offset + fmt.size
    return fmt.unpack_from(self.data, offset)[0]

read_cstring

read_cstring() -> bytes

Read up to (and consume) a NUL terminator; the terminator is not returned.

Source code in bytestruct/__init__.py
def read_cstring(self) -> bytes:
    """Read up to (and consume) a NUL terminator; the terminator is not returned."""
    end = self.data.find(b"\0", self.offset, self.end)
    if end == -1:
        raise TruncatedDataError("unterminated C string", offset=self.absolute)
    result = self.data[self.offset : end]
    self.offset = end + 1
    return result

Codec

Bases: Protocol[T]

Parses one wire value from a :class:ByteReader (read) and encodes one (build).

Anything with these two methods is a codec: the combinators below, the scalars (U32_LE, ...), Bytes(n), and :class:BinaryStruct record classes. Errors raised inside a nested value record the enclosing cases, elements and fields in :attr:BinaryDataError.path.

Source code in bytestruct/__init__.py
class Codec(Protocol[T]):
    """Parses one wire value from a :class:`ByteReader` (``read``) and encodes one (``build``).

    Anything with these two methods is a codec: the combinators below, the scalars (``U32_LE``, ...), ``Bytes(n)``,
    and :class:`BinaryStruct` record classes. Errors raised inside a nested value record the enclosing cases,
    elements and fields in :attr:`BinaryDataError.path`.
    """

    # Attributes rather than methods, so that a record class (whose ``build`` is an instance method, called as
    # ``Record.build(record)``) is a codec too.
    @property
    def read(self) -> Callable[[ByteReader], T]: ...

    @property
    def build(self) -> Callable[[T], bytes]: ...

CodecBase

Bases: Generic[T]

Base of the combinators: a codec with a parse shortcut.

Source code in bytestruct/__init__.py
class CodecBase(Generic[T]):
    """Base of the combinators: a codec with a ``parse`` shortcut."""

    def read(self, reader: ByteReader) -> T:
        """Parse one value at the reader's offset and advance past it."""
        raise NotImplementedError

    def build(self, value: T) -> bytes:
        """Encode ``value`` (raising :class:`BinaryBuildError` when it cannot be encoded)."""
        raise NotImplementedError

    def parse(self, data: BytesLike) -> T:
        """Parse the value from the start of ``data``; trailing bytes are ignored."""
        return self.read(ByteReader(data))

read

read(reader: ByteReader) -> T

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> T:
    """Parse one value at the reader's offset and advance past it."""
    raise NotImplementedError

build

build(value: T) -> bytes

Encode value (raising :class:BinaryBuildError when it cannot be encoded).

Source code in bytestruct/__init__.py
def build(self, value: T) -> bytes:
    """Encode ``value`` (raising :class:`BinaryBuildError` when it cannot be encoded)."""
    raise NotImplementedError

parse

parse(data: BytesLike) -> T

Parse the value from the start of data; trailing bytes are ignored.

Source code in bytestruct/__init__.py
def parse(self, data: BytesLike) -> T:
    """Parse the value from the start of ``data``; trailing bytes are ignored."""
    return self.read(ByteReader(data))

Scalar

Bases: Struct

A struct.Struct holding a single value, usable as a codec and with ByteReader.unpack_one.

Source code in bytestruct/__init__.py
class Scalar(struct.Struct):
    """A ``struct.Struct`` holding a single value, usable as a codec and with ``ByteReader.unpack_one``."""

    def read(self, reader: ByteReader) -> Any:
        """Parse the value at the reader's offset and advance past it."""
        return reader.unpack_one(self)

    def parse(self, data: BytesLike) -> Any:
        """Parse the value from the start of ``data``; trailing bytes are ignored."""
        return self.read(ByteReader(data))

    def build(self, value: Any) -> bytes:
        """Pack ``value``; out-of-range or wrongly-typed values raise :class:`BinaryBuildError`."""
        try:
            return self.pack(value)
        except (struct.error, OverflowError) as e:
            raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e

read

read(reader: ByteReader) -> Any

Parse the value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> Any:
    """Parse the value at the reader's offset and advance past it."""
    return reader.unpack_one(self)

parse

parse(data: BytesLike) -> Any

Parse the value from the start of data; trailing bytes are ignored.

Source code in bytestruct/__init__.py
def parse(self, data: BytesLike) -> Any:
    """Parse the value from the start of ``data``; trailing bytes are ignored."""
    return self.read(ByteReader(data))

build

build(value: Any) -> bytes

Pack value; out-of-range or wrongly-typed values raise :class:BinaryBuildError.

Source code in bytestruct/__init__.py
def build(self, value: Any) -> bytes:
    """Pack ``value``; out-of-range or wrongly-typed values raise :class:`BinaryBuildError`."""
    try:
        return self.pack(value)
    except (struct.error, OverflowError) as e:
        raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e

Pass

Bases: CodecBase[None]

No bytes; parses to None.

Source code in bytestruct/__init__.py
class Pass(CodecBase[None]):
    """No bytes; parses to ``None``."""

    def read(self, reader: ByteReader) -> None:
        """Parse one value at the reader's offset and advance past it."""
        return None

    def build(self, value: None) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        return b""

read

read(reader: ByteReader) -> None

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> None:
    """Parse one value at the reader's offset and advance past it."""
    return None

build

build(value: None) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: None) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    return b""

GreedyBytes

Bases: CodecBase[bytes]

Every remaining byte (of the enclosing :class:Prefixed, or of the buffer).

Source code in bytestruct/__init__.py
class GreedyBytes(CodecBase[bytes]):
    """Every remaining byte (of the enclosing :class:`Prefixed`, or of the buffer)."""

    def read(self, reader: ByteReader) -> bytes:
        """Parse one value at the reader's offset and advance past it."""
        return reader.read_all()

    def build(self, value: BytesLike) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        return bytes(value)

read

read(reader: ByteReader) -> bytes

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> bytes:
    """Parse one value at the reader's offset and advance past it."""
    return reader.read_all()

build

build(value: BytesLike) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: BytesLike) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    return bytes(value)

CString

Bases: CodecBase[str]

A NUL-terminated UTF-8 string. Building one with an embedded NUL (the peer would stop there) is an error.

errors is the UTF-8 decoding error handler (e.g. "replace" for names cut mid-character).

Source code in bytestruct/__init__.py
class CString(CodecBase[str]):
    """A NUL-terminated UTF-8 string. Building one with an embedded NUL (the peer would stop there) is an error.

    ``errors`` is the UTF-8 decoding error handler (e.g. ``"replace"`` for names cut mid-character).
    """

    def __init__(self, errors: str = "strict") -> None:
        self.errors = errors

    def read(self, reader: ByteReader) -> str:
        """Parse one value at the reader's offset and advance past it."""
        start = reader.absolute
        raw = reader.read_cstring()
        try:
            return raw.decode("utf-8", self.errors)
        except UnicodeDecodeError as e:
            raise BinaryParseError(f"invalid UTF-8 in C string: {e}", offset=start) from e

    def build(self, value: str) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        if "\0" in value:
            raise BinaryBuildError(f"embedded NUL in {value!r}")
        try:
            return value.encode("utf-8") + b"\0"
        except UnicodeEncodeError as e:
            raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e

read

read(reader: ByteReader) -> str

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> str:
    """Parse one value at the reader's offset and advance past it."""
    start = reader.absolute
    raw = reader.read_cstring()
    try:
        return raw.decode("utf-8", self.errors)
    except UnicodeDecodeError as e:
        raise BinaryParseError(f"invalid UTF-8 in C string: {e}", offset=start) from e

build

build(value: str) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: str) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    if "\0" in value:
        raise BinaryBuildError(f"embedded NUL in {value!r}")
    try:
        return value.encode("utf-8") + b"\0"
    except UnicodeEncodeError as e:
        raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e

GreedyString

Bases: CodecBase[str]

Every remaining byte (of the enclosing :class:Prefixed, or of the buffer), as a UTF-8 string.

Prefixed(U16_BE, GREEDY_STRING) is a length-prefixed string (construct's PascalString). errors is the UTF-8 decoding error handler (e.g. "replace").

Source code in bytestruct/__init__.py
class GreedyString(CodecBase[str]):
    """Every remaining byte (of the enclosing :class:`Prefixed`, or of the buffer), as a UTF-8 string.

    ``Prefixed(U16_BE, GREEDY_STRING)`` is a length-prefixed string (construct's ``PascalString``). ``errors``
    is the UTF-8 decoding error handler (e.g. ``"replace"``).
    """

    def __init__(self, errors: str = "strict") -> None:
        self.errors = errors

    def read(self, reader: ByteReader) -> str:
        """Parse one value at the reader's offset and advance past it."""
        start = reader.absolute
        try:
            return reader.read_all().decode("utf-8", self.errors)
        except UnicodeDecodeError as e:
            raise BinaryParseError(f"invalid UTF-8 in string: {e}", offset=start) from e

    def build(self, value: str) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        try:
            return value.encode("utf-8")
        except UnicodeEncodeError as e:
            raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e

read

read(reader: ByteReader) -> str

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> str:
    """Parse one value at the reader's offset and advance past it."""
    start = reader.absolute
    try:
        return reader.read_all().decode("utf-8", self.errors)
    except UnicodeDecodeError as e:
        raise BinaryParseError(f"invalid UTF-8 in string: {e}", offset=start) from e

build

build(value: str) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: str) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    try:
        return value.encode("utf-8")
    except UnicodeEncodeError as e:
        raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e

Prefixed

Bases: CodecBase[T]

inner, confined to the byte count that length precedes it with.

Source code in bytestruct/__init__.py
class Prefixed(CodecBase[T]):
    """``inner``, confined to the byte count that ``length`` precedes it with."""

    def __init__(self, length: Scalar, inner: Codec[T]) -> None:
        self.length = length
        self.inner = inner

    def read(self, reader: ByteReader) -> T:
        """Parse one value at the reader's offset and advance past it."""
        size = reader.unpack_one(self.length)
        end = reader.end
        stop = reader.offset + size
        if not reader.offset <= stop <= end:
            raise reader._invalid_size(size)
        reader.end = stop  # cheaper than a sub-reader
        try:
            return self.inner.read(reader)
        finally:
            reader.end = end
            reader.offset = stop

    def build(self, value: T) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        body = self.inner.build(value)
        try:
            return self.length.pack(len(body)) + body
        except struct.error as e:
            raise BinaryBuildError(f"cannot encode a length of {len(body)}: {e}") from e

read

read(reader: ByteReader) -> T

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> T:
    """Parse one value at the reader's offset and advance past it."""
    size = reader.unpack_one(self.length)
    end = reader.end
    stop = reader.offset + size
    if not reader.offset <= stop <= end:
        raise reader._invalid_size(size)
    reader.end = stop  # cheaper than a sub-reader
    try:
        return self.inner.read(reader)
    finally:
        reader.end = end
        reader.offset = stop

build

build(value: T) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: T) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    body = self.inner.build(value)
    try:
        return self.length.pack(len(body)) + body
    except struct.error as e:
        raise BinaryBuildError(f"cannot encode a length of {len(body)}: {e}") from e

Aligned

Bases: CodecBase[T]

inner, followed by NUL padding to a multiple of modulus bytes counted from where it starts.

Source code in bytestruct/__init__.py
class Aligned(CodecBase[T]):
    """``inner``, followed by NUL padding to a multiple of ``modulus`` bytes counted from where it starts."""

    def __init__(self, modulus: int, inner: Codec[T]) -> None:
        self.modulus = modulus
        self.inner = inner
        self._padding = [bytes(size) for size in range(modulus)]

    def read(self, reader: ByteReader) -> T:
        """Parse one value at the reader's offset and advance past it."""
        start = reader.offset
        value = self.inner.read(reader)
        reader.skip(-(reader.offset - start) % self.modulus)
        return value

    def build(self, value: T) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        data = self.inner.build(value)
        return data + self._padding[-len(data) % self.modulus]

read

read(reader: ByteReader) -> T

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> T:
    """Parse one value at the reader's offset and advance past it."""
    start = reader.offset
    value = self.inner.read(reader)
    reader.skip(-(reader.offset - start) % self.modulus)
    return value

build

build(value: T) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: T) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    data = self.inner.build(value)
    return data + self._padding[-len(data) % self.modulus]

PrefixedArray

Bases: CodecBase[list[T]]

An element count in count, followed by that many elements.

Source code in bytestruct/__init__.py
class PrefixedArray(CodecBase[list[T]]):
    """An element count in ``count``, followed by that many ``element``s."""

    def __init__(self, count: Scalar, element: Codec[T]) -> None:
        self.count = count
        self.element = element

    def read(self, reader: ByteReader) -> list[T]:
        """Parse one value at the reader's offset and advance past it."""
        count = reader.unpack_one(self.count)
        read = self.element.read
        result: list[T] = []
        try:
            for _ in range(count):
                result.append(read(reader))
        except BinaryDataError as e:
            e.within(f"[{len(result)}]")
            raise
        return result

    def build(self, value: list[T]) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        build = self.element.build
        parts = [self.count.build(len(value))]
        for index, element in enumerate(value):
            try:
                parts.append(build(element))
            except BinaryDataError as e:
                e.within(f"[{index}]")
                raise
        return b"".join(parts)

read

read(reader: ByteReader) -> list[T]

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> list[T]:
    """Parse one value at the reader's offset and advance past it."""
    count = reader.unpack_one(self.count)
    read = self.element.read
    result: list[T] = []
    try:
        for _ in range(count):
            result.append(read(reader))
    except BinaryDataError as e:
        e.within(f"[{len(result)}]")
        raise
    return result

build

build(value: list[T]) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: list[T]) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    build = self.element.build
    parts = [self.count.build(len(value))]
    for index, element in enumerate(value):
        try:
            parts.append(build(element))
        except BinaryDataError as e:
            e.within(f"[{index}]")
            raise
    return b"".join(parts)

Tuple

Bases: CodecBase[tuple[Any, ...]]

Consecutive values of different codecs, parsed into a tuple.

Source code in bytestruct/__init__.py
class Tuple(CodecBase[tuple[Any, ...]]):
    """Consecutive values of different codecs, parsed into a tuple."""

    def __init__(self, *codecs: Codec[Any]) -> None:
        self.codecs = codecs

    def read(self, reader: ByteReader) -> tuple[Any, ...]:
        """Parse one value at the reader's offset and advance past it."""
        return tuple([codec.read(reader) for codec in self.codecs])

    def build(self, value: tuple[Any, ...]) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        codecs = self.codecs
        if len(value) != len(codecs):
            raise BinaryBuildError(f"expected {len(codecs)} values, got {len(value)}")
        if len(codecs) == 2:  # the common (key, value) pair, without a join
            return codecs[0].build(value[0]) + codecs[1].build(value[1])
        return b"".join([codec.build(item) for codec, item in zip(codecs, value)])

read

read(reader: ByteReader) -> tuple[Any, ...]

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> tuple[Any, ...]:
    """Parse one value at the reader's offset and advance past it."""
    return tuple([codec.read(reader) for codec in self.codecs])

build

build(value: tuple[Any, ...]) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: tuple[Any, ...]) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    codecs = self.codecs
    if len(value) != len(codecs):
        raise BinaryBuildError(f"expected {len(codecs)} values, got {len(value)}")
    if len(codecs) == 2:  # the common (key, value) pair, without a join
        return codecs[0].build(value[0]) + codecs[1].build(value[1])
    return b"".join([codec.build(item) for codec, item in zip(codecs, value)])

Switch

Bases: CodecBase[tuple[Any, Any]]

A tag, followed by a value in the codec it selects from cases; parses to (tag, value).

A tag without a case uses default, or is an error when there is none.

Source code in bytestruct/__init__.py
class Switch(CodecBase[tuple[Any, Any]]):
    """A ``tag``, followed by a value in the codec it selects from ``cases``; parses to ``(tag, value)``.

    A tag without a case uses ``default``, or is an error when there is none.
    """

    def __init__(self, tag: Scalar, cases: Mapping[Any, Codec[Any]], *, default: Optional[Codec[Any]] = None) -> None:
        self.tag = tag
        self.cases = dict(cases)
        self.default = default
        self._names = {key: getattr(key, "name", None) or _show(key) for key in self.cases}

    def read(self, reader: ByteReader) -> tuple[Any, Any]:
        """Parse one value at the reader's offset and advance past it."""
        start = reader.absolute
        tag = reader.unpack_one(self.tag)
        case = self.cases.get(tag, self.default)
        if case is None:
            raise BinaryParseError(f"unknown tag {_show(tag)}", offset=start)
        try:
            return tag, case.read(reader)
        except BinaryDataError as e:
            e.within(self._names.get(tag) or _show(tag))
            raise

    def build(self, value: tuple[Any, Any]) -> bytes:
        """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
        tag, item = value
        case = self.cases.get(tag, self.default)
        if case is None:
            raise BinaryBuildError(f"unknown tag {_show(tag)}")
        head = self.tag.build(tag)
        try:
            return head + case.build(item)
        except BinaryDataError as e:
            e.within(self._names.get(tag) or _show(tag))
            raise

read

read(reader: ByteReader) -> tuple[Any, Any]

Parse one value at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> tuple[Any, Any]:
    """Parse one value at the reader's offset and advance past it."""
    start = reader.absolute
    tag = reader.unpack_one(self.tag)
    case = self.cases.get(tag, self.default)
    if case is None:
        raise BinaryParseError(f"unknown tag {_show(tag)}", offset=start)
    try:
        return tag, case.read(reader)
    except BinaryDataError as e:
        e.within(self._names.get(tag) or _show(tag))
        raise

build

build(value: tuple[Any, Any]) -> bytes

Encode value, raising :class:BinaryBuildError when it cannot be encoded.

Source code in bytestruct/__init__.py
def build(self, value: tuple[Any, Any]) -> bytes:
    """Encode ``value``, raising :class:`BinaryBuildError` when it cannot be encoded."""
    tag, item = value
    case = self.cases.get(tag, self.default)
    if case is None:
        raise BinaryBuildError(f"unknown tag {_show(tag)}")
    head = self.tag.build(tag)
    try:
        return head + case.build(item)
    except BinaryDataError as e:
        e.within(self._names.get(tag) or _show(tag))
        raise

OpenIntEnum

Bases: IntEnum

IntEnum that maps unknown values to cached pseudo-members instead of raising, so a value from a newer peer (an unknown opcode, status, level, ...) still parses. Declare at least one member.

Source code in bytestruct/__init__.py
class OpenIntEnum(enum.IntEnum):
    """``IntEnum`` that maps unknown values to cached pseudo-members instead of raising, so a value from a newer
    peer (an unknown opcode, status, level, ...) still parses. Declare at least one member."""

    @classmethod
    def _missing_(cls, value: object) -> Optional["OpenIntEnum"]:
        if not isinstance(value, int):
            return None
        member = int.__new__(cls, value)
        member._name_ = str(value)
        member._value_ = value
        # Registered so the next ``cls(value)`` returns the same object.
        return cast(OpenIntEnum, cls._value2member_map_.setdefault(value, member))

Format

Field annotation metadata: a single-value struct code, optionally pinned to a byte order.

Annotated[int, Format("H")] is how u16 is defined. Subclasses define custom fixed-size field types by overriding decode (unpacked value -> field value, raising :class:BinaryParseError) and/or encode (the reverse, raising :class:BinaryBuildError); only overridden ones are called.

Source code in bytestruct/__init__.py
class Format:
    """Field annotation metadata: a single-value ``struct`` code, optionally pinned to a byte order.

    ``Annotated[int, Format("H")]`` is how ``u16`` is defined. Subclasses define custom fixed-size field types by
    overriding ``decode`` (unpacked value -> field value, raising :class:`BinaryParseError`) and/or ``encode``
    (the reverse, raising :class:`BinaryBuildError`); only overridden ones are called.
    """

    def __init__(self, code: str, byte_order: Optional[str] = None) -> None:
        self.code = code
        self.byte_order = byte_order

    def decode(self, value: Any) -> Any:
        """Convert the unpacked wire value to the field value."""
        return value

    def encode(self, value: Any) -> Any:
        """Convert the field value to the value to pack."""
        return value

decode

decode(value: Any) -> Any

Convert the unpacked wire value to the field value.

Source code in bytestruct/__init__.py
def decode(self, value: Any) -> Any:
    """Convert the unpacked wire value to the field value."""
    return value

encode

encode(value: Any) -> Any

Convert the field value to the value to pack.

Source code in bytestruct/__init__.py
def encode(self, value: Any) -> Any:
    """Convert the field value to the value to pack."""
    return value

Bytes

Bases: Format

A fixed-size raw byte field; building it from a value of any other length is an error.

Source code in bytestruct/__init__.py
class Bytes(Format):
    """A fixed-size raw byte field; building it from a value of any other length is an error."""

    def __init__(self, size: int) -> None:
        super().__init__(f"{size}s")
        self.size = size

    def encode(self, value: Any) -> Any:
        """Convert the field value to the wire bytes, raising :class:`BinaryBuildError` when it does not fit."""
        if len(value) != self.size:
            raise BinaryBuildError(f"expected {self.size} bytes, got {len(value)}")
        return value

    # As a codec, outside of fixed-size records.
    def read(self, reader: ByteReader) -> bytes:
        """Read the next ``size`` bytes (as a codec, outside of fixed-size records)."""
        return reader.read(self.size)

    def build(self, value: BytesLike) -> bytes:
        """Encode ``value``, which must be exactly ``size`` bytes long (as a codec)."""
        return bytes(self.encode(value))

encode

encode(value: Any) -> Any

Convert the field value to the wire bytes, raising :class:BinaryBuildError when it does not fit.

Source code in bytestruct/__init__.py
def encode(self, value: Any) -> Any:
    """Convert the field value to the wire bytes, raising :class:`BinaryBuildError` when it does not fit."""
    if len(value) != self.size:
        raise BinaryBuildError(f"expected {self.size} bytes, got {len(value)}")
    return value

read

read(reader: ByteReader) -> bytes

Read the next size bytes (as a codec, outside of fixed-size records).

Source code in bytestruct/__init__.py
def read(self, reader: ByteReader) -> bytes:
    """Read the next ``size`` bytes (as a codec, outside of fixed-size records)."""
    return reader.read(self.size)

build

build(value: BytesLike) -> bytes

Encode value, which must be exactly size bytes long (as a codec).

Source code in bytestruct/__init__.py
def build(self, value: BytesLike) -> bytes:
    """Encode ``value``, which must be exactly ``size`` bytes long (as a codec)."""
    return bytes(self.encode(value))

PaddedString

Bases: Format

A fixed-size field holding a NUL-padded UTF-8 string of at most size bytes.

A string that fills the field has no terminator; a shorter one ends at its first NUL, and the bytes after it are ignored. errors is the UTF-8 decoding error handler (e.g. "replace" for names cut mid-character). Building NUL-pads the encoded string to the field size.

Source code in bytestruct/__init__.py
class PaddedString(Format):
    """A fixed-size field holding a NUL-padded UTF-8 string of at most ``size`` bytes.

    A string that fills the field has no terminator; a shorter one ends at its first NUL, and the bytes after it
    are ignored. ``errors`` is the UTF-8 decoding error handler (e.g. ``"replace"`` for names cut mid-character).
    Building NUL-pads the encoded string to the field size.
    """

    def __init__(self, size: int, errors: str = "strict") -> None:
        super().__init__(f"{size}s")
        self.size = size
        self.errors = errors

    def decode(self, value: Any) -> str:
        """Decode the bytes before the first NUL (the whole field when there is none)."""
        end = value.find(b"\0")
        try:
            return (value if end == -1 else value[:end]).decode("utf-8", self.errors)
        except UnicodeDecodeError as e:
            raise BinaryParseError(f"invalid UTF-8 in string field: {e}") from e

    def encode(self, value: Any) -> bytes:
        """Encode the string, raising :class:`BinaryBuildError` when it cannot be encoded, does not fit or contains a
        NUL byte."""
        try:
            encoded = value.encode("utf-8")
        except UnicodeEncodeError as e:
            raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e
        if b"\0" in encoded:  # parse would stop there
            raise BinaryBuildError("string contains a NUL byte")
        if len(encoded) > self.size:
            raise BinaryBuildError(f"string of {len(encoded)} bytes does not fit its {self.size}-byte field")
        return encoded

decode

decode(value: Any) -> str

Decode the bytes before the first NUL (the whole field when there is none).

Source code in bytestruct/__init__.py
def decode(self, value: Any) -> str:
    """Decode the bytes before the first NUL (the whole field when there is none)."""
    end = value.find(b"\0")
    try:
        return (value if end == -1 else value[:end]).decode("utf-8", self.errors)
    except UnicodeDecodeError as e:
        raise BinaryParseError(f"invalid UTF-8 in string field: {e}") from e

encode

encode(value: Any) -> bytes

Encode the string, raising :class:BinaryBuildError when it cannot be encoded, does not fit or contains a NUL byte.

Source code in bytestruct/__init__.py
def encode(self, value: Any) -> bytes:
    """Encode the string, raising :class:`BinaryBuildError` when it cannot be encoded, does not fit or contains a
    NUL byte."""
    try:
        encoded = value.encode("utf-8")
    except UnicodeEncodeError as e:
        raise BinaryBuildError(f"cannot encode {value!r}: {e}") from e
    if b"\0" in encoded:  # parse would stop there
        raise BinaryBuildError("string contains a NUL byte")
    if len(encoded) > self.size:
        raise BinaryBuildError(f"string of {len(encoded)} bytes does not fit its {self.size}-byte field")
    return encoded

Uuid

Bases: Format

A 16-byte UUID field, parsed into a :class:uuid.UUID.

Source code in bytestruct/__init__.py
class Uuid(Format):
    """A 16-byte UUID field, parsed into a :class:`uuid.UUID`."""

    def __init__(self) -> None:
        super().__init__("16s")

    def decode(self, value: Any) -> uuid.UUID:
        """Parse the 16 wire bytes into a :class:`uuid.UUID`."""
        return uuid.UUID(bytes=value)

    def encode(self, value: Any) -> bytes:
        """Return the UUID's 16 bytes."""
        return value.bytes

decode

decode(value: Any) -> uuid.UUID

Parse the 16 wire bytes into a :class:uuid.UUID.

Source code in bytestruct/__init__.py
def decode(self, value: Any) -> uuid.UUID:
    """Parse the 16 wire bytes into a :class:`uuid.UUID`."""
    return uuid.UUID(bytes=value)

encode

encode(value: Any) -> bytes

Return the UUID's 16 bytes.

Source code in bytestruct/__init__.py
def encode(self, value: Any) -> bytes:
    """Return the UUID's 16 bytes."""
    return value.bytes

IPAddress

Bases: Format

A packed IPv4 (4-byte) or IPv6 (16-byte) address field, parsed into an :mod:ipaddress object.

Source code in bytestruct/__init__.py
class IPAddress(Format):
    """A packed IPv4 (4-byte) or IPv6 (16-byte) address field, parsed into an :mod:`ipaddress` object."""

    def __init__(self, version: int) -> None:
        self.address_type = ipaddress.IPv4Address if version == 4 else ipaddress.IPv6Address
        super().__init__("4s" if version == 4 else "16s")

    def decode(self, value: Any) -> Union[ipaddress.IPv4Address, ipaddress.IPv6Address]:
        """Parse the packed wire bytes into an :mod:`ipaddress` address."""
        return self.address_type(value)

    def encode(self, value: Any) -> bytes:
        """Return the address's packed bytes."""
        return value.packed

decode

decode(value: Any) -> Union[ipaddress.IPv4Address, ipaddress.IPv6Address]

Parse the packed wire bytes into an :mod:ipaddress address.

Source code in bytestruct/__init__.py
def decode(self, value: Any) -> Union[ipaddress.IPv4Address, ipaddress.IPv6Address]:
    """Parse the packed wire bytes into an :mod:`ipaddress` address."""
    return self.address_type(value)

encode

encode(value: Any) -> bytes

Return the address's packed bytes.

Source code in bytestruct/__init__.py
def encode(self, value: Any) -> bytes:
    """Return the address's packed bytes."""
    return value.packed

Array

Bases: Format

A fixed-length array of a plain number type, parsed into a list: Annotated[list[int], Array(u64, 6)]. Elements use the record's byte order unless their alias pins one.

Source code in bytestruct/__init__.py
class Array(Format):
    """A fixed-length array of a plain number type, parsed into a list:
    ``Annotated[list[int], Array(u64, 6)]``. Elements use the record's byte order unless their alias pins one."""

    def __init__(self, element: Any, count: int, byte_order: str = "<") -> None:
        fmt: Format = get_args(element)[1]
        self.element = element
        self.count = count
        self.pinned = fmt.byte_order is not None
        self.struct = struct.Struct(f"{fmt.byte_order or byte_order}{count}{fmt.code}")
        super().__init__(f"{self.struct.size}s")

    def in_record(self, byte_order: str) -> "Array":
        """This array with unpinned elements in the enclosing record's ``byte_order``."""
        return self if self.pinned else Array(self.element, self.count, byte_order)

    def decode(self, value: Any) -> list[Any]:
        """Unpack the wire bytes into a list of ``count`` numbers."""
        return list(self.struct.unpack(value))

    def encode(self, value: Any) -> bytes:
        """Convert the field value to the wire bytes, raising :class:`BinaryBuildError` when it does not fit."""
        if len(value) != self.count:
            raise BinaryBuildError(f"expected {self.count} elements, got {len(value)}")
        try:
            return self.struct.pack(*value)
        except (struct.error, OverflowError) as e:
            raise BinaryBuildError(f"cannot encode: {e}") from e

in_record

in_record(byte_order: str) -> Array

This array with unpinned elements in the enclosing record's byte_order.

Source code in bytestruct/__init__.py
def in_record(self, byte_order: str) -> "Array":
    """This array with unpinned elements in the enclosing record's ``byte_order``."""
    return self if self.pinned else Array(self.element, self.count, byte_order)

decode

decode(value: Any) -> list[Any]

Unpack the wire bytes into a list of count numbers.

Source code in bytestruct/__init__.py
def decode(self, value: Any) -> list[Any]:
    """Unpack the wire bytes into a list of ``count`` numbers."""
    return list(self.struct.unpack(value))

encode

encode(value: Any) -> bytes

Convert the field value to the wire bytes, raising :class:BinaryBuildError when it does not fit.

Source code in bytestruct/__init__.py
def encode(self, value: Any) -> bytes:
    """Convert the field value to the wire bytes, raising :class:`BinaryBuildError` when it does not fit."""
    if len(value) != self.count:
        raise BinaryBuildError(f"expected {self.count} elements, got {len(value)}")
    try:
        return self.struct.pack(*value)
    except (struct.error, OverflowError) as e:
        raise BinaryBuildError(f"cannot encode: {e}") from e

Const

Field annotation metadata: the field must always hold value.

A wrong value raises :class:UnexpectedConstantError on parse and :class:BinaryBuildError on build. Only for fields whose value is not converted (numbers, Bytes, int enums): parse compares the raw unpacked value.

Source code in bytestruct/__init__.py
class Const:
    """Field annotation metadata: the field must always hold ``value``.

    A wrong value raises :class:`UnexpectedConstantError` on parse and :class:`BinaryBuildError` on build. Only for
    fields whose value is not converted (numbers, ``Bytes``, int enums): parse compares the raw unpacked value.
    """

    def __init__(self, value: Any) -> None:
        self.value = value

BinaryStruct

Base for dataclasses describing a binary record; see the module docstring.

Record classes are codecs themselves (read/build), so they nest inside other records and combinators.

Source code in bytestruct/__init__.py
class BinaryStruct:
    """Base for dataclasses describing a binary record; see the module docstring.

    Record classes are codecs themselves (``read``/``build``), so they nest inside other records and combinators.
    """

    # Computed once per record class by ``__init_subclass__``.
    SIZE: ClassVar[int]  # of the fixed-size fields (all of a record without codec fields)
    _fields: ClassVar[tuple[_Field, ...]]  # in wire order
    _codec_fields: ClassVar[tuple[_CodecField, ...]]
    _runs: ClassVar[tuple[_Run, ...]]
    _consts: ClassVar[tuple[_Field, ...]]
    _const_getter: ClassVar[Callable[[Any], Any]]  # instance -> its constant fields' values
    _const_values: ClassVar[Any]  # what ``_const_getter`` must return
    _decoders: ClassVar[tuple[_Field, ...]]
    _encoders: ClassVar[tuple[_Field, ...]]  # non-constant fields whose format converts values before packing
    _getter: ClassVar[Callable[[Any], tuple[Any, ...]]]  # instance -> its fixed-size field values
    _init_indices: ClassVar[Optional[list[int]]]  # the fixed-size fields passed to ``__init__``, if not all of them
    _plain: ClassVar[bool]  # parse is a single unpack straight into ``__init__``
    _plain_build: ClassVar[bool]  # build is a single pack of the field values

    def __init_subclass__(cls, byte_order: str = "<", **kwargs: Any) -> None:
        """Compile the record: runs once per class, when the ``class`` statement executes (before ``@dataclass``).

        It lays out the fields, groups them into one ``struct.Struct`` per byte-order run, precomputes the
        constant, decoder and encoder lists, and picks the ``_plain``/``_plain_build`` fast paths. See
        docs/how-it-works.md for the walkthrough.
        """
        super().__init_subclass__(**kwargs)
        if cls.__bases__ != (BinaryStruct,):
            raise TypeError(f"{cls.__name__}: records cannot be subclassed or mixed with other bases")
        fields, codec_fields = _layout(cls, byte_order)
        cls._fields = tuple(fields)
        cls._codec_fields = tuple(codec_fields)
        cls._runs = tuple(_runs(fields))
        cls._consts = tuple(f for f in fields if f.const is not _NO_CONST)
        cls._decoders = tuple(f for f in fields if _overrides(f.fmt, "decode"))
        # A constant was checked before packing, so it never needs encoding.
        cls._encoders = tuple(f for f in fields if _overrides(f.fmt, "encode") and f.const is _NO_CONST)
        cls.SIZE = sum(f.struct.size for f in fields)
        cls._getter = _tuple_getter([f.name for f in fields])  # pyright: ignore[reportAttributeAccessIssue]
        cls._init_indices = None if all(f.init for f in fields) else [f.index for f in fields if f.init]
        cls._plain = (
            len(cls._runs) == 1
            and not cls._consts
            and not cls._decoders
            and cls._init_indices is None
            and not cls._codec_fields
        )
        cls._plain_build = len(cls._runs) == 1 and not cls._consts and not cls._encoders
        if cls._consts:
            # ``attrgetter`` returns the bare value for one name and a tuple for several; compare likewise.
            cls._const_getter = operator.attrgetter(*(f.name for f in cls._consts))
            consts = [f.const for f in cls._consts]
            cls._const_values = consts[0] if len(consts) == 1 else tuple(consts)

    @classmethod
    def parse(cls, data: BytesLike) -> Self:
        """Parse the record from the start of ``data``; trailing bytes are ignored."""
        if cls._codec_fields:
            return cls.read(ByteReader(data))
        return cls._parse(data, 0, len(data), 0)

    @classmethod
    def read(cls, reader: ByteReader) -> Self:
        """Parse the record at the reader's offset and advance past it."""
        start = reader.offset
        if not cls._codec_fields:
            record = cls._parse(reader.data, start, reader.end, reader.base)
            reader.offset = start + cls.SIZE
            return record
        values = cls._unpack(reader.data, start, reader.end, reader.base)
        reader.offset = start + cls.SIZE
        for field in cls._codec_fields:
            try:
                values.append(field.codec.read(reader))
            except BinaryDataError as e:
                e.within(f"{cls.__name__}.{field.name}")
                raise
        return cls(*values)

    @classmethod
    def _parse(cls, data: BytesLike, start: int, end: int, base: int) -> Self:
        """Parse at ``data[start:end]``; ``base + start`` is that position's absolute offset (for errors)."""
        if cls._plain:
            if end - start < cls.SIZE:
                cls._raise_short(data, start, end, base)
            return cls(*cls._runs[0].struct.unpack_from(data, start))
        return cls(*cls._unpack(data, start, end, base))

    @classmethod
    def _unpack(cls, data: BytesLike, start: int, end: int, base: int) -> list[Any]:
        """The ``__init__`` arguments of the fixed-size fields at ``data[start:end]``."""
        if end - start < cls.SIZE:
            cls._raise_short(data, start, end, base)
        values: list[Any] = []
        for run in cls._runs:
            values.extend(run.struct.unpack_from(data, start + run.offset))
        # Constants first, so a wrong magic is reported as such.
        for field in cls._consts:
            if values[field.index] != field.const:
                raise cls._const_error(field, values[field.index], base + start)
        for field in cls._decoders:
            try:
                values[field.index] = field.fmt.decode(values[field.index])
            except BinaryParseError as e:
                raise e.located(record=cls.__name__, field=field.name, offset=base + start + field.offset) from e
        if cls._init_indices is not None:
            return [values[i] for i in cls._init_indices]
        return values

    @classmethod
    def _raise_short(cls, data: BytesLike, start: int, end: int, base: int) -> NoReturn:
        """A wrong constant that is already readable wins (``b"HTTP/1.1..."`` is not a short frame, it is not
        a frame of this record at all); otherwise the error names the first field that does not fit."""
        available = end - start
        for field in cls._consts:
            if field.offset + field.struct.size <= available:
                actual = field.struct.unpack_from(data, start + field.offset)[0]
                if actual != field.const:
                    raise cls._const_error(field, actual, base + start)
        field = next(f for f in cls._fields if f.offset + f.struct.size > available)
        raise TruncatedDataError(
            f"expected {field.struct.size} bytes, found {max(available - field.offset, 0)}",
            record=cls.__name__,
            field=field.name,
            offset=base + start + field.offset,
        )

    @classmethod
    def _const_error(cls, field: _Field, actual: Any, record_offset: int) -> UnexpectedConstantError:
        return UnexpectedConstantError(
            f"expected {_show(field.const)}, got {_show(actual)}",
            record=cls.__name__,
            field=field.name,
            offset=record_offset + field.offset,
        )

    def build(self) -> bytes:
        """Encode the record (as a codec, ``Record.build(record)``)."""
        if not self._codec_fields:
            return self._build_fixed()
        parts = [self._build_fixed()]
        for field in self._codec_fields:
            try:
                parts.append(field.codec.build(getattr(self, field.name)))
            except BinaryDataError as e:
                e.within(f"{type(self).__name__}.{field.name}")
                raise
        return b"".join(parts)

    def _build_fixed(self) -> bytes:
        """Encode the fixed-size fields: check the constants, run the encoders, then pack each run."""
        cls = type(self)
        if cls._plain_build:
            try:
                return cls._runs[0].struct.pack(*cls._getter(self))
            except (struct.error, OverflowError) as e:
                raise BinaryBuildError(f"cannot encode: {e}", record=cls.__name__) from e
        if cls._consts and cls._const_getter(self) != cls._const_values:
            field = next(f for f in cls._consts if getattr(self, f.name) != f.const)
            raise BinaryBuildError(
                f"expected {_show(field.const)}, got {_show(getattr(self, field.name))}",
                record=cls.__name__,
                field=field.name,
            )
        values = cls._getter(self)
        if cls._encoders:
            values = list(values)
            for field in cls._encoders:
                try:
                    values[field.index] = field.fmt.encode(values[field.index])
                except BinaryDataError as e:
                    raise e.located(record=cls.__name__, field=field.name) from e
        try:
            if len(cls._runs) == 1:
                return cls._runs[0].struct.pack(*values)
            return b"".join([run.struct.pack(*values[run.first : run.first + run.count]) for run in cls._runs])
        except (struct.error, OverflowError) as e:
            raise BinaryBuildError(f"cannot encode: {e}", record=cls.__name__) from e

parse classmethod

parse(data: BytesLike) -> Self

Parse the record from the start of data; trailing bytes are ignored.

Source code in bytestruct/__init__.py
@classmethod
def parse(cls, data: BytesLike) -> Self:
    """Parse the record from the start of ``data``; trailing bytes are ignored."""
    if cls._codec_fields:
        return cls.read(ByteReader(data))
    return cls._parse(data, 0, len(data), 0)

read classmethod

read(reader: ByteReader) -> Self

Parse the record at the reader's offset and advance past it.

Source code in bytestruct/__init__.py
@classmethod
def read(cls, reader: ByteReader) -> Self:
    """Parse the record at the reader's offset and advance past it."""
    start = reader.offset
    if not cls._codec_fields:
        record = cls._parse(reader.data, start, reader.end, reader.base)
        reader.offset = start + cls.SIZE
        return record
    values = cls._unpack(reader.data, start, reader.end, reader.base)
    reader.offset = start + cls.SIZE
    for field in cls._codec_fields:
        try:
            values.append(field.codec.read(reader))
        except BinaryDataError as e:
            e.within(f"{cls.__name__}.{field.name}")
            raise
    return cls(*values)

build

build() -> bytes

Encode the record (as a codec, Record.build(record)).

Source code in bytestruct/__init__.py
def build(self) -> bytes:
    """Encode the record (as a codec, ``Record.build(record)``)."""
    if not self._codec_fields:
        return self._build_fixed()
    parts = [self._build_fixed()]
    for field in self._codec_fields:
        try:
            parts.append(field.codec.build(getattr(self, field.name)))
        except BinaryDataError as e:
            e.within(f"{type(self).__name__}.{field.name}")
            raise
    return b"".join(parts)

bytestruct.exceptions

Errors raised while parsing or building binary data.

BinaryDataError

Bases: ValueError

Base for binary wire-format errors: data that could not be parsed, or a value that could not be encoded.

record, field and offset (absolute, in the outermost buffer) locate the failure when known, and path lists the enclosing cases, elements and fields, outermost first. They prefix the message, e.g. Header.magic at offset 0: expected 0x29b00b92, got 0x0 or DICTIONARY -> [2] -> STRING at offset 104: expected 200 bytes, found 16.

Source code in bytestruct/exceptions.py
class BinaryDataError(ValueError):
    """Base for binary wire-format errors: data that could not be parsed, or a value that could not be encoded.

    ``record``, ``field`` and ``offset`` (absolute, in the outermost buffer) locate the failure when known, and
    ``path`` lists the enclosing cases, elements and fields, outermost first. They prefix the message, e.g.
    ``Header.magic at offset 0: expected 0x29b00b92, got 0x0`` or
    ``DICTIONARY -> [2] -> STRING at offset 104: expected 200 bytes, found 16``.
    """

    def __init__(
        self,
        message: str,
        *,
        record: Optional[str] = None,
        field: Optional[str] = None,
        offset: Optional[int] = None,
    ) -> None:
        super().__init__(message)
        self.message = message
        self.record = record
        self.field = field
        self.offset = offset
        self.path: list[str] = []

    def __str__(self) -> str:
        name = ".".join(part for part in (self.record, self.field) if part)
        location = " -> ".join([*self.path, name] if name else self.path)
        if self.offset is not None:
            location = f"{location} at offset {self.offset}" if location else f"at offset {self.offset}"
        return f"{location}: {self.message}" if location else self.message

    def located(
        self, *, record: Optional[str] = None, field: Optional[str] = None, offset: Optional[int] = None
    ) -> "BinaryDataError":
        """Return a copy of this error (same type) with any missing location filled in."""
        error = type(self)(
            self.message,
            record=self.record or record,
            field=self.field or field,
            offset=self.offset if self.offset is not None else offset,
        )
        error.path = list(self.path)
        return error

    def within(self, step: str) -> None:
        """Record that the failure happened inside ``step`` (a case, element or field), as the error propagates."""
        self.path.insert(0, step)

located

located(*, record: Optional[str] = None, field: Optional[str] = None, offset: Optional[int] = None) -> BinaryDataError

Return a copy of this error (same type) with any missing location filled in.

Source code in bytestruct/exceptions.py
def located(
    self, *, record: Optional[str] = None, field: Optional[str] = None, offset: Optional[int] = None
) -> "BinaryDataError":
    """Return a copy of this error (same type) with any missing location filled in."""
    error = type(self)(
        self.message,
        record=self.record or record,
        field=self.field or field,
        offset=self.offset if self.offset is not None else offset,
    )
    error.path = list(self.path)
    return error

within

within(step: str) -> None

Record that the failure happened inside step (a case, element or field), as the error propagates.

Source code in bytestruct/exceptions.py
def within(self, step: str) -> None:
    """Record that the failure happened inside ``step`` (a case, element or field), as the error propagates."""
    self.path.insert(0, step)

BinaryParseError

Bases: BinaryDataError

Binary wire data could not be parsed.

Source code in bytestruct/exceptions.py
class BinaryParseError(BinaryDataError):
    """Binary wire data could not be parsed."""

BinaryBuildError

Bases: BinaryDataError

A value could not be encoded into its binary wire format.

Source code in bytestruct/exceptions.py
class BinaryBuildError(BinaryDataError):
    """A value could not be encoded into its binary wire format."""

TruncatedDataError

Bases: BinaryParseError

Binary wire data ended before the structure being parsed was complete.

Source code in bytestruct/exceptions.py
class TruncatedDataError(BinaryParseError):
    """Binary wire data ended before the structure being parsed was complete."""

UnexpectedConstantError

Bases: BinaryParseError

A constant field (magic, version, ...) in binary wire data did not have its expected value.

Source code in bytestruct/exceptions.py
class UnexpectedConstantError(BinaryParseError):
    """A constant field (magic, version, ...) in binary wire data did not have its expected value."""