Recipes

Choose a task and start with known bytes. These examples need no network connection; all packets are already in memory.

Read a device packet from TCP

A device sent 10 00 04 01 02 4f 4b. Its protocol defines a one-byte command, a two-byte payload length, a version, and data with a one-byte length prefix. Describe a reply to command 0x10:

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

class Reply(ProtoModel):
    __constructor__ = 0x10
    __header__ = (Constructor(1), PayloadLength(2))
    version: UInt8
    data: bytes = field(prefix_length=1)

received = bytes.fromhex("10 00 04 01 02 4f 4b")
reply = Reply.decode(received)
print(reply.version, reply.data)
assert reply.encode() == received
1 b'OK'
① Command · 10

The value of __constructor__ = 0x10. A message for a different command will fail validation by Reply.

② Payload length · 00 04

Four bytes after the header: the version, the data length, and two bytes for OK.

③ Version · 01

UInt8 reads one byte and returns the ordinary integer 1.

④ Data · 02 4f 4b

Length 2, followed by the bytes for OK. data remains bytes, not a string.

Here, received already contains a complete packet. A single TCP recv() call may return part of a packet or several packets at once: accumulate the required bytes first. bytespec parses a buffer; it does not manage the socket.

Read multiple messages from one buffer

A buffer contains two replies in the previous format. Continue with the Reply class:

buffer = received + received
first, offset = Reply.decode_from(buffer, 0)
print(first.data, offset)
second, offset = Reply.decode_from(buffer, offset)
print(second.data, offset)
assert offset == len(buffer)
b'OK' 7
b'OK' 14

The first reply occupies bytes 0–6. The returned offset 7 points to the start of the second reply. After the second, it is 14: the end of the buffer. decode() accepts exactly one message; use decode_from() for multiple messages. Handling the remainder of a buffer: Files and multiple messages.

Read a format without the bytespec header

A sensor sends exactly two bytes: temperature 23 and battery level 87. No metadata bytes may be added:

from bytespec import ProtoModel
from bytespec.types import UInt8

class SensorReading(ProtoModel):
    __header__ = ()
    temperature: UInt8
    battery: UInt8

received = bytes.fromhex("17 57")
reading = SensorReading.decode(received)
print(reading.temperature, reading.battery)
assert reading.encode() == received
23 87

__header__ = () removes all header elements. The first byte belongs directly to temperature, the second to battery. Field sizes determine the boundaries in this fixed format. Other options: Headers and framing.

Read a little-endian number

A device stores the counter value 258 as two bytes, 02 01, least significant byte first. Set this byte order:

from bytespec import ByteOrder, ProtoModel
from bytespec.types import UInt16

class DeviceCounter(ProtoModel):
    __header__ = ()
    __byte_order__ = ByteOrder.LITTLE
    value: UInt16

received = bytes.fromhex("02 01")
counter = DeviceCounter.decode(received)
print(counter.value)
assert counter.encode() == received
258

The number is 2 + 1 * 256. With the default big-endian order, the same bytes would mean 513. The setting applies to fixed-width numbers and prefixes in this model; it does not reorder text bytes.

Store a custom application type

Your application represents an identifier as a UserId object, while the device expects a four-byte integer. Codecs: custom formats and types has the complete example: the UserId class, its codec, and using it through field(codec=...). A codec only converts an individual field.

Build a nested message

Now let us combine the features we have covered: an author model, a list of tags, and an optional note. You can run this example as a complete script.

from bytespec import ProtoModel, field
from bytespec.types import UInt32

class User(ProtoModel):
    id: UInt32
    name: str

class Message(ProtoModel):
    author: User
    text: str
    tags: list[str] = field(default_factory=list)
    note: str | None = field(flag=0)

message = Message(
    author=User(id=42, name="Anna"),
    text="Hello!",
    tags=["news"],
)
encoded = message.encode()
decoded = Message.decode(encoded)

print(decoded.author.name)  # Anna
print(decoded.text)         # Hello!
print(decoded.tags)         # ['news']
print(decoded.note)         # None

Creation and decoding work just as they did for the first model. Tags are restored as a list, the author as a User, and the omitted note as None. These structures needed no separate serialization calls.

Let us change the note and encode the message again:

decoded.note = "Updated"
updated = Message.decode(decoded.encode())
print(updated.note)  # Updated

Try this example with empty tags and with note="": both values are preserved. Only note=None removes the optional field from the message.

You can now try a model on a single packet from your own protocol. Headers and framing shows how to configure framing bytes; Validating model values explains how to check relationships between fields; Handling errors covers error handling. To read several messages from one buffer, open Files and multiple messages.