Headers and framing

A protocol may store the length before the flags or omit the message identifier. __header__ defines the framing that precedes the model fields:

from bytespec import Constructor, Flags, PayloadLength, ProtoModel, field
from bytespec.types import UInt8, VarUInt

class Packet(ProtoModel):
    __constructor__ = 0x12
    __header__ = (Constructor(1), PayloadLength(1), Flags(1))

    number: UInt8
    note: str | None = field(flag=0, prefix_length=1)

packet = Packet(number=7, note="A")
encoded = packet.encode()
assert Packet.decode(encoded) == packet
print(encoded.hex(" "))
12 03 01 07 01 41

Here, 12 is the constructor, 03 is the length of all fields, and 01 is the flags bitmap. The body takes three bytes: 07 and the string 01 41. The length excludes every header element, even when PayloadLength comes before Flags.

Without customization, the header is (Constructor(2), Flags(8), PayloadLength(4)): 14 bytes before the fields. The defaults are __constructor__ = 1 and __byte_order__ = ByteOrder.BIG.

Change the order

Reorder the tuple elements; the body fields keep their order:

class Reordered(ProtoModel):
    __constructor__ = 0x12
    __header__ = (Flags(1), Constructor(1), PayloadLength(VarUInt))

    number: UInt8
    note: str | None = field(flag=0, prefix_length=1)

packet = Reordered(number=7, note="A")
assert Reordered.decode(packet.encode()) == packet
print(packet.encode().hex(" "))
01 12 03 07 01 41

The header is set when the class is declared. Do not change its elements or settings after the schema has been built. For rules on reuse between classes, see Model inheritance.

Remove unnecessary elements

If there are no optional fields, the bitmap is unnecessary:

class Reading(ProtoModel):
    __header__ = (Constructor(1), PayloadLength(1))
    value: UInt8

reading = Reading(value=7)
assert Reading.decode(reading.encode()) == reading
print(reading.encode().hex(" "))
01 01 07

You can also omit Constructor: for example, use __header__ = (PayloadLength(2),) for required fields when the transport already determines the message type.

Without PayloadLength

If the fields determine the message boundary, you can omit the overall length:

class Reading(ProtoModel):
    __header__ = (Constructor(1),)
    value: UInt8

buffer = b"xx" + Reading(value=7).encode() + Reading(value=8).encode()
first, end = Reading.decode_from(buffer, 2)
second, end = Reading.decode_from(buffer, end)
print(first.value, second.value, end)
7 8 6

decode_from() returns the end of the last field read. It no longer skips an unknown body tail or knows a separate message boundary: the codec sees the entire supplied buffer, including the next message. If the input has an external boundary, pass only the permitted slice. Fields still check their own lengths; decode() still rejects bytes after the fields it has read. String, bytes, and list prefixes remain even without PayloadLength.

Without any framing

To write a structure into an existing format, use an empty tuple:

from bytespec import ByteOrder
from bytespec.types import UInt16

class Point(ProtoModel):
    __header__ = ()
    __byte_order__ = ByteOrder.LITTLE
    x: UInt16
    y: UInt16

point = Point(x=1, y=256)
assert Point.decode(point.encode()) == point
print(point.encode().hex(" "))
01 00 00 01

This produces exactly four bytes of field data. Models without fields are also supported: for example, an acknowledgment message can contain only framing.

class Ack(ProtoModel):
    __constructor__ = 0x12
    __header__ = (Constructor(1), PayloadLength(1))

class Empty(ProtoModel):
    __header__ = ()

assert Ack.decode(Ack().encode()) == Ack()
assert Empty.decode(b"") == Empty()
print(Ack().encode().hex(" "))
print(Empty().encode())
12 00
b''

Ack writes the constructor and a zero body length. Empty, with neither fields nor a header, writes empty bytes. Such an object is unsuitable as a list element: zero bytes cannot convey the number of elements (see Implementation requirements).

How nested model headers are written

A nested model uses its own byte order and __header__, but its Constructor is automatically skipped: the field annotation already specifies the type. The remaining elements keep their declared order:

from bytespec import ByteOrder, Constructor, Flags, PayloadLength
from bytespec.types import UInt8, UInt16

class Reading(ProtoModel):
    __constructor__ = 0x34
    __byte_order__ = ByteOrder.LITTLE
    __header__ = (PayloadLength(1), Constructor(1), Flags(1))
    value: UInt16

class Frame(ProtoModel):
    __header__ = ()
    reading: Reading
    tail: UInt8

reading = Reading(value=0x1234)
frame = Frame(reading=reading, tail=9)
assert Frame.decode(frame.encode()) == frame
print(reading.encode().hex(" "))
print(frame.encode().hex(" "))
02 34 00 34 12
02 00 34 12 09

Standalone encoding: length 02, constructor 34, flags 00, value 34 12. Nested encoding omits only the constructor; 09 is the next field of the outer model. An empty header on the outer class does not remove the nested model’s framing. With the default settings, a nested header takes 12 bytes: 8 bytes of flags and 4 bytes of length.

The same rule applies to list[Reading]:

class Batch(ProtoModel):
    __header__ = ()
    readings: list[Reading] = field(prefix_length=1)

batch = Batch(readings=[reading])
assert Batch.decode(batch.encode()) == batch
print(batch.encode().hex(" "))
04 02 00 34 12

04 is the encoded element size, including its remaining framing. Setting __header__ = () on the nested model removes that framing too; an example with structural fields ver/cmd/opcode/seq appears below. The nested model retains its own length rules; without PayloadLength, its known fields determine its end.

Protocol data stays in fields

ver, cmd, opcode, and seq are application values, not computed framing. Put them in a regular nested model:

class Header(ProtoModel):
    __header__ = ()
    ver: UInt8
    cmd: UInt8
    opcode: UInt16
    seq: UInt16

class Envelope(ProtoModel):
    __header__ = (PayloadLength(2),)
    header: Header
    payload: bytes = field(prefix_length=1)

packet = Envelope(header=Header(ver=1, cmd=2, opcode=18, seq=22), payload=b"OK")
assert Envelope.decode(packet.encode()) == packet
print(packet.encode().hex(" "))
00 09 01 02 00 12 00 16 02 4f 4b

00 09 is the body length, followed by six bytes of Header, then 02 4f 4b: bytes with their own prefix. The packet.header object holds application data; Envelope.__header__ describes framing. The nested model keeps its header without Constructor: see How nested model headers are written for an exact example.

What is checked in 0.1.0

Creating an element checks that its encoding is supported. When the model is built, Constructor requires an ordinary int (not bool), and Flags checks the bitmap capacity. For example, bit 8 does not fit in one byte:

from bytespec import SchemaError

try:
    class TooManyFlags(ProtoModel):
        __header__ = (Flags(1),)
        note: str | None = field(flag=8)
except SchemaError as error:
    print(error)
Flags encoding provides 8 bits, model has field with flag 8

Flag numbers are always limited to 0–63, including with Flags(VarUInt). The constructor’s own range is checked during encode, raising EncodeError; an incorrect constructor in a message raises DecodeError.

Optional fields require Flags in the final header. This is checked when the class is declared, including for inherited fields:

try:
    class MissingFlags(ProtoModel):
        __header__ = (PayloadLength(1),)
        note: str | None = field(flag=0)
except SchemaError as error:
    print(error)

class WithFlags(ProtoModel):
    __header__ = (Flags(1), PayloadLength(1))
    note: str | None = field(flag=0)

try:
    class WithoutFlags(WithFlags):
        __header__ = (PayloadLength(1),)
except SchemaError as error:
    print(error)
Found optional fields in model but no Flags presented in header
Found optional fields in model but no Flags presented in header

Include each kind of element at most once: header element kinds are not separately checked for uniqueness. A subclass’s changed header is checked again, even if the subclass declares no fields of its own.

Next: Codecs: custom formats and types explains how to specify the representation of an individual value.