bytespec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bytespec/__init__.py +21 -0
- bytespec/base.py +52 -0
- bytespec/codecs/__init__.py +48 -0
- bytespec/codecs/_utils.py +16 -0
- bytespec/codecs/base.py +60 -0
- bytespec/codecs/bool.py +45 -0
- bytespec/codecs/bytes.py +105 -0
- bytespec/codecs/datetime.py +59 -0
- bytespec/codecs/enum.py +49 -0
- bytespec/codecs/float.py +17 -0
- bytespec/codecs/int.py +118 -0
- bytespec/codecs/list.py +77 -0
- bytespec/codecs/map.py +17 -0
- bytespec/codecs/model.py +40 -0
- bytespec/codecs/str.py +88 -0
- bytespec/codecs/uuid.py +36 -0
- bytespec/codecs/varint.py +122 -0
- bytespec/core.py +500 -0
- bytespec/enums.py +14 -0
- bytespec/errors.py +29 -0
- bytespec/headers.py +143 -0
- bytespec/missing.py +17 -0
- bytespec/models.py +47 -0
- bytespec/py.typed +0 -0
- bytespec/resolver.py +196 -0
- bytespec/resolvers.py +114 -0
- bytespec/schema.py +35 -0
- bytespec/types/__init__.py +25 -0
- bytespec/types/spec.py +75 -0
- bytespec-0.1.0.dist-info/METADATA +103 -0
- bytespec-0.1.0.dist-info/RECORD +33 -0
- bytespec-0.1.0.dist-info/WHEEL +4 -0
- bytespec-0.1.0.dist-info/licenses/LICENSE +21 -0
bytespec/core.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
ClassVar,
|
|
8
|
+
get_type_hints,
|
|
9
|
+
)
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from typing_extensions import Self, dataclass_transform, override
|
|
13
|
+
|
|
14
|
+
from bytespec.base import DEFAULT_HEADER, ProtoModelBase
|
|
15
|
+
from bytespec.codecs import ICodec
|
|
16
|
+
from bytespec.codecs._utils import check_available
|
|
17
|
+
from bytespec.headers import Flags
|
|
18
|
+
from bytespec.missing import MISSING, MissingType
|
|
19
|
+
from bytespec.models import DefaultFactory, FieldInfo, PrefixLength
|
|
20
|
+
from bytespec.resolver import CodecResolver
|
|
21
|
+
from bytespec.resolvers import (
|
|
22
|
+
CodecFactory,
|
|
23
|
+
bool_codec_factory,
|
|
24
|
+
bytes_codec_factory,
|
|
25
|
+
datetime_codec_factory,
|
|
26
|
+
int_codec_factory,
|
|
27
|
+
str_codec_factory,
|
|
28
|
+
uuid_codec_factory,
|
|
29
|
+
)
|
|
30
|
+
from bytespec.schema import HeaderDecodeContext, HeaderEncodeContext, ModelSchema
|
|
31
|
+
|
|
32
|
+
from .errors import DecodeError, EncodeError, SchemaError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class FieldMetadata:
|
|
37
|
+
name: str
|
|
38
|
+
index: int
|
|
39
|
+
flag: int | None
|
|
40
|
+
default: Any
|
|
41
|
+
default_factory: DefaultFactory
|
|
42
|
+
annotation: Any
|
|
43
|
+
codec: ICodec[Any]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def field(
|
|
47
|
+
index: int | None = None,
|
|
48
|
+
*,
|
|
49
|
+
flag: int | None = None,
|
|
50
|
+
default: Any = MISSING,
|
|
51
|
+
default_factory: DefaultFactory = MISSING,
|
|
52
|
+
prefix_length: PrefixLength | None = None,
|
|
53
|
+
encoding: str = "utf-8",
|
|
54
|
+
codec: ICodec[Any] | None = None,
|
|
55
|
+
) -> Any:
|
|
56
|
+
"""Configure a serializable model field.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
index: The field position, starting at zero. If omitted, the smallest available index is
|
|
60
|
+
selected in field declaration order.
|
|
61
|
+
flag: The presence bit number (0–63). Required for ``T | None``; must be unique and fit
|
|
62
|
+
within the model's flags size.
|
|
63
|
+
default: The value used when the constructor argument is omitted. Explicit ``None`` differs
|
|
64
|
+
from having no default and is only valid for optional fields.
|
|
65
|
+
default_factory: A function with no arguments that creates a value when the field is
|
|
66
|
+
omitted. Cannot be specified together with ``default``.
|
|
67
|
+
prefix_length: The length prefix size: 1, 2, 4, or 8 bytes, or ``VarUInt``. ``None`` keeps
|
|
68
|
+
the codec's default setting. For a list, configures the list itself, not its items.
|
|
69
|
+
encoding: The encoding of text values. Defaults to UTF-8.
|
|
70
|
+
codec: A ready-to-use codec instance that replaces automatic selection. Other field settings
|
|
71
|
+
do not reconfigure this instance.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A field description for use in the body of a model class.
|
|
75
|
+
|
|
76
|
+
Note:
|
|
77
|
+
Setting compatibility is checked at model declaration. Unsupported or conflicting settings
|
|
78
|
+
raise ``SchemaError``.
|
|
79
|
+
"""
|
|
80
|
+
return FieldInfo(
|
|
81
|
+
index=index,
|
|
82
|
+
flag=flag,
|
|
83
|
+
default=default,
|
|
84
|
+
default_factory=default_factory,
|
|
85
|
+
prefix_length=prefix_length,
|
|
86
|
+
encoding=encoding,
|
|
87
|
+
codec=codec,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass_transform(field_specifiers=(field,))
|
|
92
|
+
class ProtoModel(ProtoModelBase):
|
|
93
|
+
"""The base model class for binary serialization.
|
|
94
|
+
|
|
95
|
+
Declare fields using annotations and create an instance with keyword arguments. ``encode()``
|
|
96
|
+
writes a message; ``decode()`` reconstructs an instance of the same class. Use ``field()`` to
|
|
97
|
+
configure individual fields.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
**kwargs: Field values. Omitted fields receive a default, the result of a factory, or
|
|
101
|
+
``None`` for optional fields; all other fields are required.
|
|
102
|
+
|
|
103
|
+
Note:
|
|
104
|
+
Annotations define the binary format, but explicitly supplied values are not fully
|
|
105
|
+
type-checked by the constructor. Fields are mutable. The schema is validated when a subclass
|
|
106
|
+
is declared. If a subclass declares its own fields, the parent's serializable fields are not
|
|
107
|
+
merged with them.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_scalar_codecs: ClassVar[dict[type, CodecFactory]] = {
|
|
111
|
+
int: int_codec_factory,
|
|
112
|
+
str: str_codec_factory,
|
|
113
|
+
bool: bool_codec_factory,
|
|
114
|
+
bytes: bytes_codec_factory,
|
|
115
|
+
datetime: datetime_codec_factory,
|
|
116
|
+
UUID: uuid_codec_factory,
|
|
117
|
+
}
|
|
118
|
+
_resolver = CodecResolver(_scalar_codecs)
|
|
119
|
+
|
|
120
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
121
|
+
"""Create an instance from named field values.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
**kwargs: Values of declared fields; omitted fields use the default, factory, or
|
|
125
|
+
``None`` for optional fields.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
TypeError: An unknown field, a missing required field, or an invalid type for a default
|
|
129
|
+
value or factory result.
|
|
130
|
+
|
|
131
|
+
Note:
|
|
132
|
+
After assigning the fields, ``__validate__`` is called if defined directly on the
|
|
133
|
+
concrete class. Its exceptions are not wrapped.
|
|
134
|
+
"""
|
|
135
|
+
field_names = {field.name for field in self.__schema__.fields}
|
|
136
|
+
unknown = kwargs.keys() - field_names
|
|
137
|
+
|
|
138
|
+
if unknown:
|
|
139
|
+
raise TypeError(f"Unknown fields: {', '.join(unknown)}")
|
|
140
|
+
|
|
141
|
+
for field in self.__schema__.fields:
|
|
142
|
+
value = kwargs.get(field.name, MISSING)
|
|
143
|
+
|
|
144
|
+
if value is MISSING:
|
|
145
|
+
if field.default is not MISSING and field.default_factory is not MISSING:
|
|
146
|
+
raise SchemaError(
|
|
147
|
+
f"{field.name}: default and default_factory cannot be specified together"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if field.default is not MISSING:
|
|
151
|
+
value = field.default
|
|
152
|
+
|
|
153
|
+
elif not isinstance(field.default_factory, MissingType):
|
|
154
|
+
value = field.default_factory()
|
|
155
|
+
elif field.flag is not None:
|
|
156
|
+
value = None
|
|
157
|
+
else:
|
|
158
|
+
raise TypeError(f"Missing required field: {field.name}")
|
|
159
|
+
|
|
160
|
+
if not self._is_valid_value(value, field.annotation):
|
|
161
|
+
raise TypeError(
|
|
162
|
+
f"{field.name}: invalid default value {value!r}; expected {field.annotation}, got {type(value).__name__}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
setattr(self, field.name, value)
|
|
166
|
+
|
|
167
|
+
validator = type(self).__dict__.get("__validate__")
|
|
168
|
+
|
|
169
|
+
if validator is not None:
|
|
170
|
+
validator(self)
|
|
171
|
+
|
|
172
|
+
def __init_subclass__(cls) -> None:
|
|
173
|
+
super().__init_subclass__()
|
|
174
|
+
|
|
175
|
+
model_codecs = cls._scalar_codecs.copy()
|
|
176
|
+
model_codecs.update(cls.configure_codecs())
|
|
177
|
+
cls._scalar_codecs = model_codecs
|
|
178
|
+
cls._resolver.scalar_codecs = cls._scalar_codecs
|
|
179
|
+
|
|
180
|
+
if "__header__" not in cls.__dict__:
|
|
181
|
+
proto_bases = [base for base in cls.__bases__ if issubclass(base, ProtoModelBase)]
|
|
182
|
+
custom_headers = [
|
|
183
|
+
base.__header__ for base in proto_bases if base.__header__ is not DEFAULT_HEADER
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
if custom_headers:
|
|
187
|
+
first_header = custom_headers[0]
|
|
188
|
+
|
|
189
|
+
if not all(header is first_header for header in custom_headers):
|
|
190
|
+
raise SchemaError(
|
|
191
|
+
f"{cls.__name__}: conflicting inherited headers; define __header__ explicitly"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
cls.__header__ = first_header
|
|
195
|
+
|
|
196
|
+
fields: list[FieldMetadata] = []
|
|
197
|
+
|
|
198
|
+
indexes: set[int] = set()
|
|
199
|
+
flags: set[int] = set()
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
raw_annotations = cls.__annotations__
|
|
203
|
+
resolved_annotations = get_type_hints(cls, include_extras=True)
|
|
204
|
+
except (NameError, TypeError) as exc:
|
|
205
|
+
raise SchemaError(f"{cls.__name__}: cannot resolve annotations: {exc}") from exc
|
|
206
|
+
|
|
207
|
+
for name, value in cls.__dict__.items():
|
|
208
|
+
if isinstance(value, FieldInfo) and name not in raw_annotations:
|
|
209
|
+
raise SchemaError(f"{cls.__name__}.{name}: field requires a type annotation")
|
|
210
|
+
|
|
211
|
+
for name in raw_annotations:
|
|
212
|
+
annotation = resolved_annotations[name]
|
|
213
|
+
raw_value = cls.__dict__.get(name, MISSING)
|
|
214
|
+
|
|
215
|
+
if raw_value is MISSING:
|
|
216
|
+
value = FieldInfo(None, None, MISSING, MISSING, None)
|
|
217
|
+
elif isinstance(raw_value, FieldInfo):
|
|
218
|
+
value = raw_value
|
|
219
|
+
else:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
if value.default is not MISSING and value.default_factory is not MISSING:
|
|
223
|
+
raise SchemaError(
|
|
224
|
+
f"{cls.__name__}.{name}: default and default_factory cannot be specified together"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
resolved_type = cls._resolver.resolve(annotation, value)
|
|
229
|
+
except SchemaError as exc:
|
|
230
|
+
exc.args = (f"{cls.__name__}.{name}: {exc}",)
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
if value.default is not MISSING:
|
|
234
|
+
if value.default is None:
|
|
235
|
+
if not resolved_type.optional:
|
|
236
|
+
raise SchemaError(
|
|
237
|
+
f"{cls.__name__}.{name}: None default requires an optional field"
|
|
238
|
+
)
|
|
239
|
+
elif not cls._is_valid_value(value.default, resolved_type.annotation):
|
|
240
|
+
raise SchemaError(
|
|
241
|
+
f"Invalid default value: {value.default!r}. "
|
|
242
|
+
+ f"Expected {resolved_type.annotation}, "
|
|
243
|
+
+ f"got {type(value.default).__name__}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if value.flag is not None and not resolved_type.optional:
|
|
247
|
+
raise SchemaError(f"{cls.__name__}.{name}: flag requires an optional field")
|
|
248
|
+
|
|
249
|
+
if resolved_type.optional:
|
|
250
|
+
if value.flag is None:
|
|
251
|
+
raise SchemaError(
|
|
252
|
+
f"{annotation} is a UnionType with NoneType but no flag index presented"
|
|
253
|
+
)
|
|
254
|
+
if not isinstance(value.flag, int) or not 0 <= value.flag < 64:
|
|
255
|
+
raise SchemaError(
|
|
256
|
+
f"{cls.__name__}.{name}: flag {value.flag!r} must be in range 0..63"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if value.flag in flags:
|
|
260
|
+
raise SchemaError(f"{cls.__name__}.{name}: duplicate flag {value.flag}")
|
|
261
|
+
|
|
262
|
+
flags.add(value.flag)
|
|
263
|
+
|
|
264
|
+
if value.index is not None:
|
|
265
|
+
index = value.index
|
|
266
|
+
if not isinstance(index, int) or index < 0:
|
|
267
|
+
raise SchemaError(f"{cls.__name__}.{name}: invalid field index {index!r}")
|
|
268
|
+
else:
|
|
269
|
+
index = 0
|
|
270
|
+
while index in indexes:
|
|
271
|
+
index += 1
|
|
272
|
+
|
|
273
|
+
codec = resolved_type.codec if not value.codec else value.codec
|
|
274
|
+
|
|
275
|
+
if not isinstance(codec, ICodec):
|
|
276
|
+
raise SchemaError(f"Invalid codec type: {type(codec)}")
|
|
277
|
+
|
|
278
|
+
fields.append(
|
|
279
|
+
FieldMetadata(
|
|
280
|
+
name=name,
|
|
281
|
+
index=index,
|
|
282
|
+
flag=value.flag,
|
|
283
|
+
annotation=annotation,
|
|
284
|
+
codec=codec,
|
|
285
|
+
default=value.default,
|
|
286
|
+
default_factory=value.default_factory,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if isinstance(raw_value, FieldInfo):
|
|
291
|
+
delattr(cls, name)
|
|
292
|
+
|
|
293
|
+
indexes.add(index)
|
|
294
|
+
|
|
295
|
+
fields.sort(key=lambda x: x.index)
|
|
296
|
+
own_fields = fields
|
|
297
|
+
|
|
298
|
+
parent_fields = cls.__schema__.fields if getattr(cls, "__schema__", None) else ()
|
|
299
|
+
|
|
300
|
+
effective_fields = tuple(own_fields or parent_fields)
|
|
301
|
+
|
|
302
|
+
has_flags = any(isinstance(element, Flags) for element in cls.__header__)
|
|
303
|
+
|
|
304
|
+
if any(field.flag is not None for field in effective_fields) and not has_flags:
|
|
305
|
+
raise SchemaError("Found optional fields in model but no Flags presented in header")
|
|
306
|
+
|
|
307
|
+
if fields and (
|
|
308
|
+
min(indexes) != 0 or max(indexes) != len(fields) - 1 or max(indexes) != len(indexes) - 1
|
|
309
|
+
):
|
|
310
|
+
raise SchemaError(
|
|
311
|
+
f"{cls.__name__}: field indexes must be unique and contiguous from 0, got {[field.index for field in fields]}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
cls.__schema__ = ModelSchema(
|
|
315
|
+
model=cls,
|
|
316
|
+
fields=effective_fields,
|
|
317
|
+
header=cls.__header__,
|
|
318
|
+
byte_order=cls.__byte_order__,
|
|
319
|
+
constructor=cls.__constructor__,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
for header in cls.__schema__.header:
|
|
323
|
+
header.validate(cls.__schema__)
|
|
324
|
+
|
|
325
|
+
def encode(self, include_constructor: bool = True) -> bytes:
|
|
326
|
+
"""Write the current model state as a single binary message.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
include_constructor: Write Constructor elements from __header__. False omits them
|
|
330
|
+
entirely, retaining the other elements.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
The __header__ elements in the specified order, followed by the fields. Optional fields
|
|
334
|
+
whose value is ``None`` are not written.
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
EncodeError: A required value is missing, or a field value cannot be written in the
|
|
338
|
+
selected representation.
|
|
339
|
+
|
|
340
|
+
Note:
|
|
341
|
+
Implementation errors in a custom codec are not wrapped automatically. __validate__ is
|
|
342
|
+
not called again.
|
|
343
|
+
"""
|
|
344
|
+
flags: int = 0 # u64
|
|
345
|
+
payload: bytes = b""
|
|
346
|
+
|
|
347
|
+
for field in self.__schema__.fields:
|
|
348
|
+
value = getattr(self, field.name, MISSING)
|
|
349
|
+
|
|
350
|
+
if value is MISSING or (value is None and field.flag is None):
|
|
351
|
+
raise EncodeError(f"{type(self).__name__}.{field.name}: missing required value")
|
|
352
|
+
|
|
353
|
+
if value is None:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
if field.flag is not None:
|
|
357
|
+
flags |= 1 << field.flag
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
payload += field.codec.encode(value, self.__byte_order__)
|
|
361
|
+
except EncodeError as exc:
|
|
362
|
+
exc.args = (f"{type(self).__name__}.{field.name}: {exc}",)
|
|
363
|
+
raise
|
|
364
|
+
|
|
365
|
+
ctx = HeaderEncodeContext(self.__schema__, include_constructor, len(payload), flags)
|
|
366
|
+
|
|
367
|
+
header = b"".join(element.encode(ctx) for element in self.__header__)
|
|
368
|
+
|
|
369
|
+
return header + payload
|
|
370
|
+
|
|
371
|
+
@classmethod
|
|
372
|
+
def decode_from(
|
|
373
|
+
cls,
|
|
374
|
+
buffer: bytes,
|
|
375
|
+
offset: int,
|
|
376
|
+
expect_constructor: bool = True,
|
|
377
|
+
) -> tuple[Self, int]:
|
|
378
|
+
"""Read a single message from the specified position in a buffer.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
buffer: A buffer containing the complete message.
|
|
382
|
+
offset: The nonnegative absolute position where the message starts.
|
|
383
|
+
expect_constructor: Read and validate Constructor elements. False omits them entirely;
|
|
384
|
+
the corresponding bytes must not be present in the input.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
A pair containing the new instance and the absolute position after the message. The
|
|
388
|
+
position can be passed to the next call to read the following message.
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
DecodeError: Incomplete or invalid data, an incorrect constructor, or a field extending
|
|
392
|
+
beyond the declared payload bounds.
|
|
393
|
+
ValueError: Negative offset.
|
|
394
|
+
|
|
395
|
+
Note:
|
|
396
|
+
Trailing data within the declared payload and unknown flag bits are skipped without
|
|
397
|
+
being preserved. Bytes after the message are left to the caller. Network fragments are
|
|
398
|
+
not accumulated between calls. Without PayloadLength, the end is determined by the known
|
|
399
|
+
fields, with no separate body boundary. Creating the instance runs its own __validate__;
|
|
400
|
+
validator exceptions propagate without wrapping.
|
|
401
|
+
"""
|
|
402
|
+
ctx = HeaderDecodeContext(cls.__schema__, expect_constructor)
|
|
403
|
+
|
|
404
|
+
for element in cls.__header__:
|
|
405
|
+
offset = element.decode(buffer, offset, ctx)
|
|
406
|
+
|
|
407
|
+
if ctx.payload_length is not None:
|
|
408
|
+
payload_start = offset
|
|
409
|
+
payload_end = payload_start + ctx.payload_length
|
|
410
|
+
|
|
411
|
+
check_available(buffer, payload_start, ctx.payload_length, codec=cls.__name__)
|
|
412
|
+
payload_buffer = buffer[:payload_end]
|
|
413
|
+
else:
|
|
414
|
+
payload_buffer = buffer
|
|
415
|
+
payload_end = None
|
|
416
|
+
|
|
417
|
+
args = {}
|
|
418
|
+
|
|
419
|
+
for field in cls.__schema__.fields: # it already sorted i think
|
|
420
|
+
if field.flag is not None and not (ctx.flags & (1 << field.flag)):
|
|
421
|
+
args[field.name] = None
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
value, offset = field.codec.decode(payload_buffer, cls.__byte_order__, offset)
|
|
426
|
+
except DecodeError as exc:
|
|
427
|
+
exc.args = (f"{cls.__name__}.{field.name}: {exc}",)
|
|
428
|
+
raise
|
|
429
|
+
args[field.name] = value
|
|
430
|
+
|
|
431
|
+
if payload_end is not None and offset > payload_end:
|
|
432
|
+
raise DecodeError(
|
|
433
|
+
f"{cls.__name__}.{field.name} at offset {offset}: exceeds payload boundary {payload_end}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if payload_end is not None and offset > payload_end:
|
|
437
|
+
raise DecodeError(
|
|
438
|
+
f"{cls.__name__} at offset {offset}: decoded past payload boundary {payload_end}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if payload_end is not None:
|
|
442
|
+
offset = payload_end
|
|
443
|
+
|
|
444
|
+
return cls(**args), offset
|
|
445
|
+
|
|
446
|
+
@classmethod
|
|
447
|
+
def decode(cls, buffer: bytes) -> Self:
|
|
448
|
+
"""Reconstruct a model from a buffer containing exactly one message.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
buffer: A complete binary message matching the model class.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
A new instance of the class with the field values read from the buffer.
|
|
455
|
+
|
|
456
|
+
Raises:
|
|
457
|
+
DecodeError: Incomplete/invalid data, an incorrect constructor, or extra bytes after the
|
|
458
|
+
end of the message.
|
|
459
|
+
|
|
460
|
+
Note:
|
|
461
|
+
For multiple messages in a single buffer, use ``decode_from``. Unknown trailing data
|
|
462
|
+
within the declared payload is skipped.
|
|
463
|
+
"""
|
|
464
|
+
model, offset = cls.decode_from(buffer, 0)
|
|
465
|
+
|
|
466
|
+
if offset != len(buffer):
|
|
467
|
+
raise DecodeError(
|
|
468
|
+
f"{cls.__name__} at offset {offset}: trailing data, {len(buffer) - offset} bytes"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
return model
|
|
472
|
+
|
|
473
|
+
@override
|
|
474
|
+
def __eq__(self, other: object) -> bool:
|
|
475
|
+
if type(self) is not type(other):
|
|
476
|
+
return NotImplemented
|
|
477
|
+
|
|
478
|
+
return all(
|
|
479
|
+
getattr(self, field.name) == getattr(other, field.name)
|
|
480
|
+
for field in self.__schema__.fields
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
@override
|
|
484
|
+
def __repr__(self) -> str:
|
|
485
|
+
fields = ", ".join(
|
|
486
|
+
f"{field.name}={getattr(self, field.name)!r}" for field in self.__schema__.fields
|
|
487
|
+
)
|
|
488
|
+
return f"{type(self).__name__}({fields})"
|
|
489
|
+
|
|
490
|
+
def __validate__(self) -> None:
|
|
491
|
+
"""Validate relationships between field values that have already been assigned.
|
|
492
|
+
|
|
493
|
+
Override this method in a concrete model and raise an exception if an invariant is violated.
|
|
494
|
+
The return value is ignored. The method is called automatically at the end of __init__,
|
|
495
|
+
including during decode, only if it is defined on the concrete class itself. To run parent
|
|
496
|
+
validation, call super().__validate__() from the child method.
|
|
497
|
+
|
|
498
|
+
Attribute assignments and encode do not call the method again. User exceptions propagate
|
|
499
|
+
without wrapping.
|
|
500
|
+
"""
|
bytespec/enums.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ByteOrder(str, Enum):
|
|
7
|
+
"""The byte order of the model's fixed-width numbers and prefixes.
|
|
8
|
+
|
|
9
|
+
``BIG`` means big-endian (``>``), and ``LITTLE`` means little-endian (``<``). This setting does
|
|
10
|
+
not affect varints, bytes contents, or the UUID representation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
BIG = ">"
|
|
14
|
+
LITTLE = "<"
|
bytespec/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BytespecError(Exception):
|
|
5
|
+
"""Common base class for schema, encoding, and decoding errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SchemaError(BytespecError):
|
|
9
|
+
"""Unsupported or conflicting model or codec types/settings.
|
|
10
|
+
|
|
11
|
+
Usually raised when declaring a model or creating a codec. Some configuration errors, such as a
|
|
12
|
+
non-text string encoding, surface during writing or reading.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EncodeError(BytespecError):
|
|
17
|
+
"""The value cannot be written in the selected binary representation.
|
|
18
|
+
|
|
19
|
+
For example, a number outside its range, a length prefix overflow, text that cannot be
|
|
20
|
+
represented in the encoding, or a missing required value.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DecodeError(BytespecError):
|
|
25
|
+
"""The buffer is incomplete or does not match the expected binary format.
|
|
26
|
+
|
|
27
|
+
For example, an incorrect constructor, missing bytes, an invalid value, or data after the
|
|
28
|
+
message when calling ``ProtoModel.decode()``.
|
|
29
|
+
"""
|
bytespec/headers.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from typing_extensions import override
|
|
4
|
+
|
|
5
|
+
from bytespec.codecs.map import UINT_CODEC_MAPPING
|
|
6
|
+
from bytespec.errors import DecodeError, SchemaError
|
|
7
|
+
from bytespec.models import UIntEncoding
|
|
8
|
+
from bytespec.types import VarUInt
|
|
9
|
+
|
|
10
|
+
from .schema import HeaderDecodeContext, HeaderEncodeContext, ModelSchema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HeaderElement(ABC):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def validate(self, schema: ModelSchema) -> None: ...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def encode(self, ctx: HeaderEncodeContext) -> bytes: ...
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def decode(
|
|
22
|
+
self,
|
|
23
|
+
buffer: bytes,
|
|
24
|
+
offset: int,
|
|
25
|
+
ctx: HeaderDecodeContext,
|
|
26
|
+
) -> int: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IntegerHeaderElement(HeaderElement):
|
|
30
|
+
def __init__(self, encoding: UIntEncoding) -> None:
|
|
31
|
+
self.encoding = encoding
|
|
32
|
+
|
|
33
|
+
codec = UINT_CODEC_MAPPING.get(self.encoding)
|
|
34
|
+
|
|
35
|
+
if not codec:
|
|
36
|
+
raise SchemaError(f"Invalid encdoing for HeaderElement {type(self).__name__}")
|
|
37
|
+
|
|
38
|
+
self.codec = codec
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Constructor(IntegerHeaderElement):
|
|
42
|
+
"""Model identifier from **constructor**, validated during decoding.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
encoding: Unsigned integer width in bytes (1, 2, 4, 8) or VarUInt.
|
|
46
|
+
|
|
47
|
+
Note:
|
|
48
|
+
The int type is validated when building the schema; the value range
|
|
49
|
+
is validated during encoding. Nested models skip this element.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
def validate(self, schema: ModelSchema) -> None:
|
|
54
|
+
if type(schema.constructor) is not int:
|
|
55
|
+
raise SchemaError(f"Invalid constructor type: {type(schema.constructor).__name__}")
|
|
56
|
+
|
|
57
|
+
@override
|
|
58
|
+
def decode(self, buffer: bytes, offset: int, ctx: HeaderDecodeContext) -> int:
|
|
59
|
+
if not ctx.expect_constructor:
|
|
60
|
+
return offset
|
|
61
|
+
|
|
62
|
+
value, offset = self.codec.decode(
|
|
63
|
+
buffer,
|
|
64
|
+
ctx.schema.byte_order,
|
|
65
|
+
offset,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if value != ctx.schema.constructor:
|
|
69
|
+
raise DecodeError(f"wrong constructor {value:#x}, expected {ctx.schema.constructor:#x}")
|
|
70
|
+
|
|
71
|
+
return offset
|
|
72
|
+
|
|
73
|
+
@override
|
|
74
|
+
def encode(self, ctx: HeaderEncodeContext) -> bytes:
|
|
75
|
+
if not ctx.include_constructor:
|
|
76
|
+
return b""
|
|
77
|
+
return self.codec.encode(ctx.schema.constructor, ctx.schema.byte_order)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PayloadLength(IntegerHeaderElement):
|
|
81
|
+
"""The size of all encoded fields, excluding the entire header.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
encoding: The unsigned integer width in bytes (1, 2, 4, 8), or VarUInt.
|
|
85
|
+
|
|
86
|
+
Note:
|
|
87
|
+
When reading, bounds the field buffer by the declared length. The position of this element
|
|
88
|
+
within the header does not change the meaning of the stored number.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
def validate(self, schema: ModelSchema) -> None: ...
|
|
93
|
+
|
|
94
|
+
@override
|
|
95
|
+
def decode(self, buffer: bytes, offset: int, ctx: HeaderDecodeContext) -> int:
|
|
96
|
+
value, offset = self.codec.decode(buffer, ctx.schema.byte_order, offset)
|
|
97
|
+
ctx.payload_length = value
|
|
98
|
+
return offset
|
|
99
|
+
|
|
100
|
+
@override
|
|
101
|
+
def encode(self, ctx: HeaderEncodeContext) -> bytes:
|
|
102
|
+
return self.codec.encode(ctx.payload_length, ctx.schema.byte_order)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Flags(IntegerHeaderElement):
|
|
106
|
+
"""A bitmap indicating the presence of optional fields, computed from their values.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
encoding: The unsigned integer width in bytes (1, 2, 4, 8), or VarUInt.
|
|
110
|
+
|
|
111
|
+
Note:
|
|
112
|
+
A field is present if its value is not None. Bit numbers are set using field(flag=...) and
|
|
113
|
+
are limited to 0–63; with a fixed width, all used bits are checked to ensure they fit.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
@override
|
|
117
|
+
def validate(self, schema: ModelSchema) -> None:
|
|
118
|
+
if self.encoding is VarUInt: # pyright: ignore[reportUnnecessaryComparison] # I hate pylance. Because of difference between static analyzer and real runtime
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
flagged_fields = [field for field in schema.fields if field.flag is not None]
|
|
122
|
+
|
|
123
|
+
if not flagged_fields:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
max_flag = max(field.flag for field in flagged_fields if field.flag is not None)
|
|
127
|
+
required_bits = max_flag + 1
|
|
128
|
+
available_bits = self.encoding * 8
|
|
129
|
+
|
|
130
|
+
if required_bits > available_bits:
|
|
131
|
+
raise SchemaError(
|
|
132
|
+
f"Flags encoding provides {available_bits} bits, model has field with flag {max_flag}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@override
|
|
136
|
+
def decode(self, buffer: bytes, offset: int, ctx: HeaderDecodeContext) -> int:
|
|
137
|
+
value, offset = self.codec.decode(buffer, ctx.schema.byte_order, offset)
|
|
138
|
+
ctx.flags = value
|
|
139
|
+
return offset
|
|
140
|
+
|
|
141
|
+
@override
|
|
142
|
+
def encode(self, ctx: HeaderEncodeContext) -> bytes:
|
|
143
|
+
return self.codec.encode(ctx.flags, ctx.schema.byte_order)
|