Зачем bytespec?

Устройство присылает пакет: четырёхбайтовый user_id и необязательное UTF-8 имя с однобайтовой длиной. Перед полями — идентификатор сообщения, флаги и длина тела сообщения. В приложении нужны packet.user_id и packet.name, а в сети — именно этот формат байтов.

Опишите его одной моделью для чтения и записи:

from bytespec import Constructor, Flags, PayloadLength, ProtoModel, field
from bytespec.types import UInt32

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

    user_id: UInt32
    name: str | None = field(flag=0, prefix_length=1)

packet = Packet(user_id=42, name="Anna")
wire = packet.encode()
assert Packet.decode(wire) == packet
print(wire.hex(" "))
12 34 01 00 09 00 00 00 2a 04 41 6e 6e 61
① Constructor · 12 34

Идентификатор 0x1234 занимает два байта. Получатель сверяет его с моделью.

② Flags · 01

Крайний правый бит равен 1: name присутствует. Для None здесь будет 00.

③ PayloadLength · 00 09

После заголовка идут девять байт: четыре для user_id и пять для name.

④ user_id · 00 00 00 2a

Число 42. UInt32 остаётся обычным int в Python, но всегда занимает четыре байта.

⑤ name · 04 41 6e 6e 61

Длина 4 в одном байте, затем четыре байта UTF-8 текста Anna.

В Python это обычные int и str. В сообщении UInt32 занимает четыре байта, а prefix_length=1 отводит один байт для длины строки.

① class Packet(ProtoModel)

Один класс задаёт чтение и запись. Имена полей доступны в Python; в сообщение записываются их значения.

② __header__

Три элемента задают заголовок слева направо. Числа в скобках — размеры в байтах. Здесь заголовок занимает 2 + 1 + 2 = 5 байт.

③ field(flag=0, prefix_length=1)

flag=0 назначает имени крайний правый бит присутствия. prefix_length=1 выделяет один байт для длины текста. name=None убирает имя из сообщения.

Сохранять формат, работать с моделями

bytespec подходит для существующего или собственного последовательного бинарного формата, который хочется описать типизированными Python-моделями. Вы выбираете размеры целых чисел, префиксы, порядок байтов и заголовок, а библиотека сама находит границы полей, читает вложенные модели и отмечает присутствующие необязательные поля. Собственный codec полностью заменяет запись и чтение конкретного поля.

Например, уберём имя — маску и длину вручную менять не нужно:

absent = Packet(user_id=42).encode()
assert Packet.decode(absent).name is None
print(absent.hex(" "))
12 34 00 00 04 00 00 00 2a

Тело сообщения теперь содержит только user_id. Такое управление последовательными полями — ниша bytespec. Произвольные переходы по файлу и сложные битовые структуры не входят в её встроенную модель. Подробные настройки собраны в Header и framing, Строки, длины и настройки полей и Codecs: свой формат и свои типы.

Как выбрать инструмент

Инструмент

Когда брать

bytespec

Нужны типизированные Python-модели поверх явно заданного последовательного бинарного формата

msgspec

Нужен быстрый типизированный сериализатор для JSON, MessagePack, YAML или TOML

Construct

Нужен гибкий DSL для разбора и построения бинарных данных, в том числе сложных структур

protobuf

Вы выбираете протокол и нужна зрелая экосистема схем и компиляторов для обмена между разными языками

struct

Формат состоит из нескольких фиксированных скалярных полей

У msgspec удобный типизированный API и высокая производительность сериализаторов готовых форматов. Он не предназначен для описания произвольного существующего бинарного формата. Protocol Buffers объединяет схемы, компилятор и библиотеки разных языков, но использует собственный бинарный формат, а не описывает расположение байтов чужого протокола.

Construct значительно мощнее bytespec как DSL для разбора и построения данных. Его отправная точка — бинарная схема и логика разбора; у bytespec — типизированная Python-модель. DSL полезен, когда формат требует условного расположения полей, указателей, переходов, битовых структур или чтения с зависимостью от других частей файла.

Один формат: struct и Construct

Посмотрим, как прочитать тот же wire другими средствами. Сравниваем способ описания задачи; преимущества в скорости или размере здесь не заявлены.

Ручное чтение через struct

import struct

def parse_packet(data):
    constructor, flags, size = struct.unpack_from(">HBH", data)
    offset = 5
    user_id, = struct.unpack_from(">I", data, offset)
    offset += 4
    name = None
    if flags & 1:
        length = data[offset]
        offset += 1
        name = data[offset:offset + length].decode("utf-8")
    return user_id, name

assert parse_packet(wire) == (42, "Anna")
assert parse_packet(absent) == (42, None)

Для нескольких фиксированных чисел struct вполне достаточно. Здесь переменная строка добавила ручное отслеживание смещения, проверку флагов и чтение префикса. Это учебный декодер для корректного пакета: рабочий вариант потребует проверки границ буфера, constructor и длины тела сообщения, обработки ошибок и отдельной логики записи.

Описание через Construct

Для выполнения следующего блока нужен пакет construct:

from construct import (
    Byte, Const, If, Int16ub, Int32ub, PascalString,
    Prefixed, Rebuild, Struct, this,
)

PacketSchema = Struct(
    "constructor" / Const(0x1234, Int16ub),
    "flags" / Rebuild(Byte, lambda ctx: int(ctx.body.name is not None)),
    "body" / Prefixed(Int16ub, Struct(
        "user_id" / Int32ub,
        "name" / If(this._.flags & 1, PascalString(Byte, "utf8")),
    )),
)

parsed = PacketSchema.parse(wire)
assert (parsed.body.user_id, parsed.body.name) == (42, "Anna")
assert PacketSchema.build(parsed) == wire
assert PacketSchema.parse(absent).body.name is None
assert PacketSchema.build(PacketSchema.parse(absent)) == absent

Construct описывает этот пакет и вычисляет длину через Prefixed; If выражает условие через контекст. Разница с bytespec — в API и модели работы: в нашей Python-модели логический тип и настройки бинарного представления стоят рядом:

user_id: UInt32
name: str | None = field(flag=0, prefix_length=1)

Вложенные данные можно описать другой моделью, а codec отдельного поля — полностью заменить: Списки и вложенные модели и Codecs: свой формат и свои типы.

Когда bytespec не нужен

  • Нужен типизированный сериализатор JSON/MessagePack — начните с msgspec.

  • Контролируете обе стороны протокола и хотите зрелую экосистему схем для обмена между разными языками — рассмотрите protobuf.

  • Нужен очень гибкий DSL для разбора бинарных данных со сложными условиями, указателями и битовыми полями — Construct зачастую правильнее.

  • Формат состоит из нескольких фиксированных скалярных полей — struct может быть проще.

Попробуйте на одном пакете

Одного известного бинарного пакета и его ожидаемого hex достаточно для первой проверки. Опишите поля и заголовок, вызовите decode(), затем убедитесь, что encode() воспроизводит исходные байты:

known_packet = bytes.fromhex("12 34 01 00 09 00 00 00 2a 04 41 6e 6e 61")
restored = Packet.decode(known_packet)
assert restored.user_id == 42
assert restored.name == "Anna"
assert restored.encode() == known_packet

Первая модель поможет объявить первую модель; Header и framing — согласовать служебные байты; Codecs: свой формат и свои типы — полностью заменить codec поля, если встроенное представление не подходит вашему формату.