Files and multiple messages

After encode(), you have ordinary bytes. You can save them or concatenate them with other messages. These examples use a small model:

from bytespec import ProtoModel

class Message(ProtoModel):
    text: str

Save and read a file

from pathlib import Path

message = Message(text="Hello")
path = Path("message.bin")
path.write_bytes(message.encode())

restored = Message.decode(path.read_bytes())
print(restored.text)  # Hello

Use the binary operations write_bytes() and read_bytes(): no additional text encoding is needed.

Modify and encode again

restored.text = "Updated"
print(Message.decode(restored.encode()).text)  # Updated

Model attributes are mutable. Each encode() call reads their current values, including determining again which optional fields are present. Bytes returned by an earlier call do not change.

Read two messages from one buffer

decode() expects exactly one message. If the buffer contains several, use decode_from():

buffer = Message(text="One").encode() + Message(text="Two").encode()

first, offset = Message.decode_from(buffer, 0)
second, offset = Message.decode_from(buffer, offset)

print(first.text, second.text)  # One Two
print(buffer[offset:])         # b''

The second result is the absolute position after the decoded message. Pass it to the next call. The starting position must be non-negative.

Start decoding after a transport prefix

buffer = b"MSG:" + Message(text="Hello").encode() + b"NEXT"
message, end = Message.decode_from(buffer, 4)

print(message.text)  # Hello
print(buffer[end:])  # b'NEXT'

end refers to the entire buffer, not to a slice after MSG:. The remaining bytes are left to the caller.

Both methods read an existing buffer. They do not accumulate network fragments between calls: an incomplete message raises DecodeError. Assemble it first using your transport. Size checks and rules for extra data are described in Boundaries and unknown data.

If the buffer contains messages of different types, the application must choose the correct class for each call. There is no automatic model selection by identifier; see Binary / wire format for header details.

Encoding without Constructor

If an outer layer already writes the identifier, you can skip this element while keeping the other header parts:

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

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

reading = Reading(value=7)
encoded = reading.encode(include_constructor=False)
restored, end = Reading.decode_from(b"xx" + encoded, 2, expect_constructor=False)
assert restored == reading
print(encoded.hex(" "), end)
00 01 07 5

Both parameters mean that the Constructor bytes are entirely absent, wherever the element appears in the header. expect_constructor=False does not read and ignore an arbitrary identifier in the input. decode() has no such parameter; use decode_from() and check the final offset if needed. ModelCodec applies this pair of parameters automatically for nested models.

Next: Binary / wire format covers exact bytes, boundaries, and unknown data.