Первая модель

Понадобятся Python 3.10+ и установленный bytespec:

pip install bytespec

или

uv add bytespec

Выполняйте блоки по порядку в одном файле user.py.

1. Описать данные

from bytespec import ProtoModel

class User(ProtoModel):
    name: str
    active: bool

Класс описывает два поля: строку name и логическое значение active. Наследование от ProtoModel добавляет запись и чтение байтов. Для этих типов дополнительные настройки не нужны.

2. Создать пользователя

user = User(name="Anna", active=True)
print(user.name, user.active)
Anna True

Это обычные атрибуты Python-объекта. Оба поля обязательны; передавайте значения по именам.

3. Записать в байты

encoded = user.encode()
print(type(encoded).__name__, len(encoded))
bytes 23

encode() вернул 23 байта. Их можно сохранить в файл или передать по сети. Посмотрим на все байты сообщения:

print(encoded.hex(" "))
00 01 00 00 00 00 00 00 00 00 00 00 00 09 00 00 00 04 41 6e 6e 61 01
Как читать эту строку?

Каждая пара, например 04 или 6e, обозначает один байт в шестнадцатеричной записи: после цифр 0–9 идут буквы a–f. 04 — число 4, 09 — 9, 41 — 65. hex(" ") только показывает байты с пробелами; сами данные не меняются.

4. Прочитать обратно

decoded = User.decode(encoded)
print(decoded.name, decoded.active)
print(decoded == user, decoded is user)
Anna True
True False

Восстановились те же значения, но это новый объект. User.decode() знает типы и порядок полей из класса User. Имена полей в сообщение не записываются: получателю нужен тот же класс с теми же настройками.

Основной цикл уже готов: класс → объект → bytes → новый объект.

5. Связать код и байты

Поля занимают последние 9 байт. Перед ними bytespec добавил 14 служебных байт — заголовок (header). Его три части видны на схеме:

Constructor

2 байта

Идентификатор сообщения.

Flags

8 байт

Наличие необязательных полей.

PayloadLength

4 байта

Размер всех полей в байтах.

Поля

9 байт

name, затем active.

Заголовок: 2 + 8 + 4 = 14 байт. Всё сообщение: 14 + 9 = 23 байта. Настраивать заголовок для этого примера не требуется.

user = User(name="Anna", active=True)
encoded = user.encode()
assert User.decode(encoded) == user

Теперь можно пропустить известные 14 байт заголовка:

print(encoded[14:].hex(" "))
00 00 00 04 41 6e 6e 61 01
① Длина имени · 00 00 00 04

Число 4: следующие четыре байта принадлежат строке name. Это префикс длины — число перед данными, обозначающее их размер.

② Имя · 41 6e 6e 61

Буквы A, n, n, a в кодировке UTF-8. Каждая занимает один байт. Для других букв размер может отличаться: например, «я» занимает два.

③ Активен · 01

True записывается как 01, False — как 00. После имени читается ровно один байт.

В name входят четыре байта длины и четыре байта текста. active занимает ещё один: 4 + 4 + 1 = 9. Так bytespec находит конец строки и начало следующего поля.

Что находится в 14 байтах заголовка?
  • 00 01 — значение __constructor__ = 1 по умолчанию. При чтении bytespec сверяет его с выбранным классом; сам класс не выбирает.

  • Следующие восемь 00 — Flags. Необязательных полей пока нет, поэтому все биты равны нулю.

  • 00 00 00 09 — PayloadLength: девять байт полей после заголовка.

Payload — содержимое сообщения после заголовка, здесь name и active вместе с префиксом строки. Заголовок в эту длину не входит. Настройки для существующего протокола разобраны в Header и framing.

Почему длина 4 записана как 00 00 00 04?

Для длины строки по умолчанию выделено четыре байта. Старшие байты идут первыми; такой порядок байтов (byte order) называется big-endian. При little-endian то же число выглядело бы как 04 00 00 00. Настраивать это нужно, когда вы описываете формат с другим порядком байтов.

Термины собраны в Основные понятия. Далее — Поля и значения по умолчанию: добавим значение по умолчанию для active.