Header и framing

Протокол может хранить длину перед flags или обходиться без идентификатора сообщения. __header__ задаёт служебную часть перед полями модели:

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

Здесь 12 — constructor, 03 — длина всех полей, 01 — flags. Body занимает три байта: 07 и строка 01 41. В длину не входит ни один элемент header, даже если PayloadLength стоит перед Flags.

Без настройки используется (Constructor(2), Flags(8), PayloadLength(4)): 14 байт перед полями. По умолчанию __constructor__ = 1 и __byte_order__ = ByteOrder.BIG.

Изменить порядок

Переставьте элементы tuple; поля body при этом сохраняют свой порядок:

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

Header задаётся при объявлении класса. Менять его элементы и настройки после создания схемы не следует. Правила переиспользования между классами: Наследование моделей.

Убрать ненужные элементы

Если optional-полей нет, bitmap не нужен:

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

Можно аналогично убрать Constructor: например, __header__ = (PayloadLength(2),) для обязательных полей, когда тип сообщения уже определён транспортом.

Без PayloadLength

Если граница сообщения определяется полями, общую длину можно не передавать:

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() возвращает конец последнего прочитанного поля. Он больше не пропускает неизвестный хвост body и не знает отдельную границу сообщения: codec видит весь переданный буфер, включая следующее сообщение. Для входа с внешней границей передавайте только разрешённый срез. Проверки собственных размеров полей остаются; decode() по-прежнему запрещает байты после прочитанных полей. Prefix строки, bytes или списка не исчезает вместе с PayloadLength.

Вообще без framing

Для записи структуры в уже существующий формат задайте пустой 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

Здесь ровно четыре байта полей. Модели без полей тоже поддерживаются: например, сообщение-подтверждение может содержать только 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 записывает constructor и нулевую длину body. Empty без полей и header записывает пустые bytes. Такой объект не подходит для элемента списка: по нулю байтов нельзя восстановить количество элементов (см. Требования к реализации).

Как записывается header вложенной модели

Вложенная модель использует свой порядок байтов и свой __header__, но её Constructor автоматически пропускается: тип уже известен из аннотации поля. Остальные элементы остаются в указанном порядке:

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

Отдельная запись: длина 02, constructor 34, flags 00, value 34 12. Во вложенной записи отсутствует только constructor; 09 — следующее поле внешней модели. Пустой header внешнего класса не убирает framing вложенного. При стандартных настройках вложенный header занимает 12 байт: 8 байт flags и 4 байта длины.

Для 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 — размер закодированного элемента вместе с оставшимся framing. Вложенный __header__ = () убирает и его; пример структурных полей ver/cmd/opcode/seq показан ниже. Собственные правила длины вложенной модели сохраняются; без PayloadLength конец определяется её известными полями.

Данные протокола остаются полями

ver, cmd, opcode и seq — значения приложения, а не вычисляемое framing. Вынесите их в обычную вложенную модель:

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 — длина body; дальше шесть байтов Header, затем 02 4f 4b — bytes с собственным префиксом. Объект packet.header содержит данные приложения; Envelope.__header__ описывает framing. Вложенная модель сохраняет свой header без Constructor: точный пример — Как записывается header вложенной модели.

Что проверяется в 0.1.0

При создании элемента проверяется поддерживаемое encoding. При сборке модели Constructor требует обычный int (не bool), а Flags проверяет вместимость bitmap. Например, бит 8 не помещается в один байт:

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

Номера flags всегда ограничены 0–63, в том числе для Flags(VarUInt). Диапазон самого constructor проверяется при encode, с EncodeError; неверный constructor в сообщении даёт DecodeError.

Для optional-полей требуется Flags в итоговом header. Это проверяется при объявлении класса, в том числе для унаследованных полей:

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

Задавайте каждый вид элемента не более одного раза: уникальность видов элементов header отдельно не проверяется. Изменённый header подкласса проверяется заново, даже если подкласс не объявляет собственных полей.

Далее — Codecs: свой формат и свои типы: как задать представление отдельного значения.