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/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .core import ProtoModel, field
|
|
6
|
+
from .enums import ByteOrder
|
|
7
|
+
from .errors import BytespecError, DecodeError, EncodeError, SchemaError
|
|
8
|
+
from .headers import Constructor, Flags, PayloadLength
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ByteOrder",
|
|
12
|
+
"BytespecError",
|
|
13
|
+
"Constructor",
|
|
14
|
+
"DecodeError",
|
|
15
|
+
"EncodeError",
|
|
16
|
+
"Flags",
|
|
17
|
+
"PayloadLength",
|
|
18
|
+
"ProtoModel",
|
|
19
|
+
"SchemaError",
|
|
20
|
+
"field",
|
|
21
|
+
]
|
bytespec/base.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from types import NoneType, UnionType
|
|
4
|
+
from typing import Annotated, Any, ClassVar, Final, Union, get_args, get_origin
|
|
5
|
+
|
|
6
|
+
from bytespec.enums import ByteOrder
|
|
7
|
+
from bytespec.headers import Constructor, Flags, HeaderElement, PayloadLength
|
|
8
|
+
from bytespec.resolvers import CodecFactory
|
|
9
|
+
from bytespec.schema import ModelSchema
|
|
10
|
+
|
|
11
|
+
DEFAULT_HEADER: Final[tuple[HeaderElement, ...]] = (Constructor(2), Flags(8), PayloadLength(4))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProtoModelBase:
|
|
15
|
+
__constructor__: int = 0x1
|
|
16
|
+
__byte_order__: ByteOrder = ByteOrder.BIG
|
|
17
|
+
__header__: ClassVar[tuple[HeaderElement, ...]] = DEFAULT_HEADER
|
|
18
|
+
__schema__: ModelSchema
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def _is_valid_value(cls, value: Any, annotation: Any) -> bool:
|
|
22
|
+
origin = get_origin(annotation)
|
|
23
|
+
|
|
24
|
+
if origin is Annotated:
|
|
25
|
+
base_type, *_ = get_args(annotation)
|
|
26
|
+
return cls._is_valid_value(value, base_type)
|
|
27
|
+
|
|
28
|
+
if origin in (Union, UnionType):
|
|
29
|
+
return any(
|
|
30
|
+
(arg is NoneType and value is None)
|
|
31
|
+
or (arg is not NoneType and cls._is_valid_value(value, arg))
|
|
32
|
+
for arg in get_args(annotation)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if origin is list:
|
|
36
|
+
return isinstance(value, list)
|
|
37
|
+
|
|
38
|
+
return isinstance(value, annotation)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def configure_codecs(cls) -> dict[type, CodecFactory]:
|
|
42
|
+
"""Define additional codec selection rules for field types.
|
|
43
|
+
|
|
44
|
+
Override this classmethod in the model. Rules apply at class declaration, extending
|
|
45
|
+
inherited rules and replacing matching keys.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A mapping from Python types to codec factories. A factory receives the annotation and
|
|
49
|
+
``FieldInfo`` and returns a ready-to-use codec instance. There are no additional rules
|
|
50
|
+
by default.
|
|
51
|
+
"""
|
|
52
|
+
return {}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from .base import ICodec
|
|
4
|
+
from .bool import BoolCodec
|
|
5
|
+
from .bytes import BytesCodec, FixedBytesCodec
|
|
6
|
+
from .datetime import DatetimeCodec
|
|
7
|
+
from .enum import EnumCodec
|
|
8
|
+
from .float import Float32Codec, Float64Codec
|
|
9
|
+
from .int import (
|
|
10
|
+
Int8Codec,
|
|
11
|
+
Int16Codec,
|
|
12
|
+
Int32Codec,
|
|
13
|
+
Int64Codec,
|
|
14
|
+
UInt8Codec,
|
|
15
|
+
UInt16Codec,
|
|
16
|
+
UInt32Codec,
|
|
17
|
+
UInt64Codec,
|
|
18
|
+
)
|
|
19
|
+
from .list import ListCodec
|
|
20
|
+
from .model import ModelCodec
|
|
21
|
+
from .str import StrCodec
|
|
22
|
+
from .uuid import UUIDCodec
|
|
23
|
+
from .varint import VarIntCodec, VarUIntCodec
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BoolCodec",
|
|
27
|
+
"BytesCodec",
|
|
28
|
+
"DatetimeCodec",
|
|
29
|
+
"EnumCodec",
|
|
30
|
+
"FixedBytesCodec",
|
|
31
|
+
"Float32Codec",
|
|
32
|
+
"Float64Codec",
|
|
33
|
+
"ICodec",
|
|
34
|
+
"Int8Codec",
|
|
35
|
+
"Int16Codec",
|
|
36
|
+
"Int32Codec",
|
|
37
|
+
"Int64Codec",
|
|
38
|
+
"ListCodec",
|
|
39
|
+
"ModelCodec",
|
|
40
|
+
"StrCodec",
|
|
41
|
+
"UInt8Codec",
|
|
42
|
+
"UInt16Codec",
|
|
43
|
+
"UInt32Codec",
|
|
44
|
+
"UInt64Codec",
|
|
45
|
+
"UUIDCodec",
|
|
46
|
+
"VarIntCodec",
|
|
47
|
+
"VarUIntCodec",
|
|
48
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from bytespec.errors import DecodeError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def check_available(buffer: bytes, offset: int, size: int, *, codec: str) -> None:
|
|
7
|
+
if offset < 0:
|
|
8
|
+
raise ValueError(f"offset must be non-negative, got {offset}")
|
|
9
|
+
if size < 0:
|
|
10
|
+
raise ValueError(f"size must be non-negative, got {size}")
|
|
11
|
+
|
|
12
|
+
available = max(0, len(buffer) - offset)
|
|
13
|
+
if available < size:
|
|
14
|
+
raise DecodeError(
|
|
15
|
+
f"{codec} at offset {offset}: expected {size} bytes, available {available}"
|
|
16
|
+
)
|
bytespec/codecs/base.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from typing import Any, Generic, Protocol, TypeVar
|
|
4
|
+
|
|
5
|
+
from typing_extensions import runtime_checkable
|
|
6
|
+
|
|
7
|
+
from bytespec.enums import ByteOrder
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class ICodec(Protocol, Generic[T]):
|
|
14
|
+
"""Interface for writing and reading a single value of type T.
|
|
15
|
+
|
|
16
|
+
An instance can be passed to ``field(codec=...)``. Inheriting from ICodec is optional: encode
|
|
17
|
+
and decode methods that follow this contract are sufficient. A codec can be reused; the read
|
|
18
|
+
position is passed through offset.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
|
|
24
|
+
def encode(self, value: T, byte_order: ByteOrder, /) -> bytes:
|
|
25
|
+
"""Write a single value in the selected representation.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
value: The value to write.
|
|
29
|
+
byte_order: The byte order of fixed-width numbers and prefixes.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The binary representation of the value.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
EncodeError: The value cannot be represented. A custom codec must raise this error
|
|
36
|
+
itself for data errors.
|
|
37
|
+
"""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def decode(self, buffer: bytes, byte_order: ByteOrder, offset: int, /) -> tuple[T, int]:
|
|
41
|
+
"""Read a single value starting at offset.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
buffer: The buffer containing the encoded value.
|
|
45
|
+
byte_order: The byte order of fixed-width numbers and prefixes.
|
|
46
|
+
offset: The nonnegative absolute position where the value starts.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The value and the absolute position after it in the same buffer. The remaining bytes do
|
|
50
|
+
not have to be consumed.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
DecodeError: There are not enough bytes, or the value is invalid. The implementation
|
|
54
|
+
must check bounds and contents itself.
|
|
55
|
+
|
|
56
|
+
Note:
|
|
57
|
+
A list item codec must advance offset by a positive number of bytes while staying within
|
|
58
|
+
the supplied buffer.
|
|
59
|
+
"""
|
|
60
|
+
...
|
bytespec/codecs/bool.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
|
|
5
|
+
from typing_extensions import override
|
|
6
|
+
|
|
7
|
+
from bytespec.codecs import ICodec
|
|
8
|
+
from bytespec.enums import ByteOrder
|
|
9
|
+
from bytespec.errors import DecodeError
|
|
10
|
+
|
|
11
|
+
from ._utils import check_available
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BoolCodec(ICodec[bool]):
|
|
15
|
+
"""A Boolean value in one byte: 0 or 1."""
|
|
16
|
+
|
|
17
|
+
@override
|
|
18
|
+
def encode(
|
|
19
|
+
self,
|
|
20
|
+
value: bool,
|
|
21
|
+
byte_order: ByteOrder,
|
|
22
|
+
) -> bytes:
|
|
23
|
+
"""Write the truth value of value as 0/1 without strict type checking."""
|
|
24
|
+
return struct.pack(byte_order.value + "B", 1 if value else 0)
|
|
25
|
+
|
|
26
|
+
@override
|
|
27
|
+
def decode(
|
|
28
|
+
self,
|
|
29
|
+
buffer: bytes,
|
|
30
|
+
byte_order: ByteOrder,
|
|
31
|
+
offset: int,
|
|
32
|
+
) -> tuple[bool, int]:
|
|
33
|
+
"""Read a bool and return it with its absolute end offset.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
DecodeError: The byte is missing or is neither 0 nor 1.
|
|
37
|
+
ValueError: Negative offset.
|
|
38
|
+
"""
|
|
39
|
+
check_available(buffer, offset, 1, codec=type(self).__name__)
|
|
40
|
+
value = struct.unpack_from(byte_order.value + "B", buffer, offset)[0]
|
|
41
|
+
|
|
42
|
+
if value not in (0, 1):
|
|
43
|
+
raise DecodeError(f"BoolCodec at offset {offset}: invalid bool value {value}")
|
|
44
|
+
|
|
45
|
+
return bool(value), offset + 1
|
bytespec/codecs/bytes.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from typing_extensions import override
|
|
4
|
+
|
|
5
|
+
from bytespec.codecs import ICodec
|
|
6
|
+
from bytespec.enums import ByteOrder
|
|
7
|
+
from bytespec.errors import EncodeError, SchemaError
|
|
8
|
+
from bytespec.models import PrefixLength
|
|
9
|
+
|
|
10
|
+
from ._utils import check_available
|
|
11
|
+
from .map import UINT_CODEC_MAPPING
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BytesCodec(ICodec[bytes]):
|
|
15
|
+
"""Bytes prefixed with their length.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
prefix_length: Prefix size: 1, 2, 4, or 8 bytes, or ``VarUInt``. Defaults to 4 bytes. The
|
|
19
|
+
prefix itself is not included in the stored length.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
SchemaError: Unsupported prefix, including ``VarInt``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, prefix_length: PrefixLength = 4) -> None:
|
|
26
|
+
self.prefix_length = prefix_length
|
|
27
|
+
|
|
28
|
+
codec = UINT_CODEC_MAPPING.get(prefix_length)
|
|
29
|
+
if codec is None:
|
|
30
|
+
raise SchemaError(f"BytesCodec: invalid length prefix {prefix_length!r}")
|
|
31
|
+
|
|
32
|
+
self.codec = codec
|
|
33
|
+
|
|
34
|
+
super().__init__()
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
def encode(self, value: bytes, byte_order: ByteOrder) -> bytes:
|
|
38
|
+
"""Write the length of value followed by the bytes themselves.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
EncodeError: The length does not fit in the selected prefix.
|
|
42
|
+
"""
|
|
43
|
+
return self.codec.encode(len(value), byte_order) + value
|
|
44
|
+
|
|
45
|
+
@override
|
|
46
|
+
def decode(self, buffer: bytes, byte_order: ByteOrder, offset: int) -> tuple[bytes, int]:
|
|
47
|
+
"""Read the prefix and return the bytes with their absolute end offset.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
DecodeError: The prefix or declared data is incomplete or invalid.
|
|
51
|
+
ValueError: Negative offset.
|
|
52
|
+
"""
|
|
53
|
+
length, start = self.codec.decode(buffer, byte_order, offset)
|
|
54
|
+
end = start + length
|
|
55
|
+
|
|
56
|
+
check_available(buffer, start, length, codec=type(self).__name__)
|
|
57
|
+
|
|
58
|
+
return buffer[start:end], end
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FixedBytesCodec(BytesCodec):
|
|
62
|
+
"""Exactly length bytes without a length prefix.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
length: The nonnegative fixed size of the value.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
SchemaError: Negative length.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, length: int) -> None:
|
|
72
|
+
if length < 0:
|
|
73
|
+
raise SchemaError(f"FixedBytesCodec: length must be non-negative, got {length}")
|
|
74
|
+
self.length = length
|
|
75
|
+
|
|
76
|
+
@override
|
|
77
|
+
def encode(self, value: bytes, byte_order: ByteOrder) -> bytes:
|
|
78
|
+
"""Return value after checking its length; byte order has no effect.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
EncodeError: The size of value is not equal to length.
|
|
82
|
+
"""
|
|
83
|
+
if len(value) != self.length:
|
|
84
|
+
raise EncodeError(f"FixedBytesCodec: expected {self.length} bytes, got {len(value)}")
|
|
85
|
+
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
@override
|
|
89
|
+
def decode(
|
|
90
|
+
self,
|
|
91
|
+
buffer: bytes,
|
|
92
|
+
byte_order: ByteOrder,
|
|
93
|
+
offset: int,
|
|
94
|
+
) -> tuple[bytes, int]:
|
|
95
|
+
"""Read length bytes and return them with their absolute end offset.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
DecodeError: There are not enough bytes.
|
|
99
|
+
ValueError: Negative offset.
|
|
100
|
+
"""
|
|
101
|
+
end = offset + self.length
|
|
102
|
+
|
|
103
|
+
check_available(buffer, offset, self.length, codec=type(self).__name__)
|
|
104
|
+
|
|
105
|
+
return buffer[offset:end], end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from typing_extensions import override
|
|
6
|
+
|
|
7
|
+
from bytespec.codecs import ICodec
|
|
8
|
+
from bytespec.enums import ByteOrder
|
|
9
|
+
from bytespec.errors import DecodeError
|
|
10
|
+
from bytespec.models import PrefixLength
|
|
11
|
+
|
|
12
|
+
from .str import StrCodec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DatetimeCodec(ICodec[datetime]):
|
|
16
|
+
"""A datetime as a length-prefixed ISO 8601 string.
|
|
17
|
+
|
|
18
|
+
Preserves the UTC offset from isoformat(), but not the time zone name. A datetime without tzinfo
|
|
19
|
+
remains naive after reading.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
prefix_length: Prefix size: 1, 2, 4, or 8 bytes, or ``VarUInt``.
|
|
23
|
+
encoding: Encoding of the ISO string, UTF-8 by default.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
SchemaError: Unsupported prefix or unknown encoding.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, prefix_length: PrefixLength = 4, encoding: str = "utf-8") -> None:
|
|
30
|
+
self.codec = StrCodec(prefix_length, encoding=encoding)
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
def encode(self, value: datetime, byte_order: ByteOrder) -> bytes:
|
|
34
|
+
"""Write value.isoformat() as a length-prefixed string.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
EncodeError: The string cannot be represented in the encoding, or its length does not
|
|
38
|
+
fit in the prefix.
|
|
39
|
+
SchemaError: The selected codec is not a text encoding.
|
|
40
|
+
"""
|
|
41
|
+
return self.codec.encode(value.isoformat(), byte_order)
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def decode(self, buffer: bytes, byte_order: ByteOrder, offset: int) -> tuple[datetime, int]:
|
|
45
|
+
"""Restore a datetime and return it with its absolute end offset.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
DecodeError: There are not enough bytes, or the string or ISO date is invalid.
|
|
49
|
+
SchemaError: The selected codec is not a text encoding.
|
|
50
|
+
ValueError: Negative offset.
|
|
51
|
+
"""
|
|
52
|
+
value, end = self.codec.decode(buffer, byte_order, offset)
|
|
53
|
+
try:
|
|
54
|
+
decoded = datetime.fromisoformat(value)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise DecodeError(
|
|
57
|
+
f"DatetimeCodec at offset {offset}: invalid datetime {value!r}"
|
|
58
|
+
) from exc
|
|
59
|
+
return decoded, end
|
bytespec/codecs/enum.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
from bytespec.codecs import ICodec
|
|
9
|
+
from bytespec.enums import ByteOrder
|
|
10
|
+
from bytespec.errors import DecodeError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EnumCodec(ICodec[Enum]):
|
|
14
|
+
"""An enum encoded using a codec for its value.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
enum_type: The enum class used to reconstruct members via enum_type(value).
|
|
18
|
+
value_codec: The codec for .value, such as StrCodec or UInt8Codec.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
enum_type: type[Enum],
|
|
24
|
+
value_codec: ICodec[Any],
|
|
25
|
+
) -> None:
|
|
26
|
+
self.enum_type = enum_type
|
|
27
|
+
self.value_codec = value_codec
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def encode(self, value: Enum, byte_order: ByteOrder) -> bytes:
|
|
31
|
+
"""Write value.value using the selected value codec."""
|
|
32
|
+
return self.value_codec.encode(value.value, byte_order)
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
def decode(self, buffer: bytes, byte_order: ByteOrder, offset: int) -> tuple[Enum, int]:
|
|
36
|
+
"""Read an enum member and return it with its absolute end offset.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
DecodeError: The decoded value is not an enum member, or the value codec rejected the
|
|
40
|
+
data.
|
|
41
|
+
"""
|
|
42
|
+
value, end = self.value_codec.decode(buffer, byte_order, offset)
|
|
43
|
+
try:
|
|
44
|
+
decoded = self.enum_type(value)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise DecodeError(
|
|
47
|
+
f"EnumCodec ({self.enum_type.__name__}) at offset {offset}: invalid value {value!r}"
|
|
48
|
+
) from exc
|
|
49
|
+
return decoded, end
|
bytespec/codecs/float.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from .int import IntegerCodec
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Float32Codec(IntegerCodec[float]):
|
|
7
|
+
"""IEEE 754 binary32: four bytes, rounding the Python float."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, struct_format: str = "f", length: int = 4) -> None:
|
|
10
|
+
super().__init__(struct_format, length)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Float64Codec(Float32Codec):
|
|
14
|
+
"""IEEE 754 binary64: eight bytes in the model's byte order."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, struct_format: str = "d", length: int = 8) -> None:
|
|
17
|
+
super().__init__(struct_format, length)
|
bytespec/codecs/int.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from typing import Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from typing_extensions import override
|
|
7
|
+
|
|
8
|
+
from bytespec.codecs import ICodec
|
|
9
|
+
from bytespec.enums import ByteOrder
|
|
10
|
+
from bytespec.errors import EncodeError, SchemaError
|
|
11
|
+
|
|
12
|
+
from ._utils import check_available
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IntegerCodec(ICodec[T], Generic[T]):
|
|
18
|
+
def __init__(self, struct_format: str, length: int) -> None:
|
|
19
|
+
try:
|
|
20
|
+
struct.calcsize(ByteOrder.BIG.value + struct_format)
|
|
21
|
+
except struct.error as exc:
|
|
22
|
+
raise SchemaError(
|
|
23
|
+
f"{type(self).__name__}: invalid struct format {struct_format!r}"
|
|
24
|
+
) from exc
|
|
25
|
+
self.struct_format = struct_format
|
|
26
|
+
self.length = length
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def encode(
|
|
30
|
+
self,
|
|
31
|
+
value: T,
|
|
32
|
+
byte_order: ByteOrder,
|
|
33
|
+
) -> bytes:
|
|
34
|
+
"""Write a fixed-width number in the specified byte order.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
EncodeError: The value cannot be packed in the selected numeric format.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
return struct.pack(byte_order.value + self.struct_format, value)
|
|
41
|
+
except (struct.error, OverflowError) as exc:
|
|
42
|
+
raise EncodeError(
|
|
43
|
+
f"{type(self).__name__} ({self.struct_format}): cannot encode {value!r}"
|
|
44
|
+
) from exc
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
def decode(
|
|
48
|
+
self,
|
|
49
|
+
buffer: bytes,
|
|
50
|
+
byte_order: ByteOrder,
|
|
51
|
+
offset: int,
|
|
52
|
+
) -> tuple[T, int]:
|
|
53
|
+
"""Read a number and return it with its absolute end offset.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
DecodeError: There are not enough bytes for the number.
|
|
57
|
+
ValueError: Negative offset.
|
|
58
|
+
"""
|
|
59
|
+
size = struct.calcsize(byte_order.value + self.struct_format)
|
|
60
|
+
check_available(buffer, offset, size, codec=type(self).__name__)
|
|
61
|
+
value = struct.unpack_from(byte_order.value + self.struct_format, buffer, offset)[0]
|
|
62
|
+
return value, offset + self.length
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class UInt8Codec(IntegerCodec[int]):
|
|
66
|
+
"""Unsigned 8-bit integer: 0–255, one byte by default."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, struct_format: str = "B", length: int = 1) -> None:
|
|
69
|
+
super().__init__(struct_format, length)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Int8Codec(UInt8Codec):
|
|
73
|
+
"""Signed 8-bit integer: -128–127, one byte by default."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, struct_format: str = "b", length: int = 1) -> None:
|
|
76
|
+
super().__init__(struct_format, length)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UInt16Codec(IntegerCodec[int]):
|
|
80
|
+
"""Unsigned 16-bit integer: 0–65535, two bytes by default."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, struct_format: str = "H", length: int = 2) -> None:
|
|
83
|
+
super().__init__(struct_format, length)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Int16Codec(UInt16Codec):
|
|
87
|
+
"""Signed 16-bit integer: -32768–32767, two bytes by default."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, struct_format: str = "h", length: int = 2) -> None:
|
|
90
|
+
super().__init__(struct_format, length)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class UInt32Codec(IntegerCodec[int]):
|
|
94
|
+
"""Unsigned 32-bit integer: 0 .. 2**32 - 1, four bytes."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, struct_format: str = "I", length: int = 4) -> None:
|
|
97
|
+
super().__init__(struct_format, length)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Int32Codec(UInt32Codec):
|
|
101
|
+
"""Signed 32-bit integer: -2**31 .. 2**31 - 1, four bytes."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, struct_format: str = "i", length: int = 4) -> None:
|
|
104
|
+
super().__init__(struct_format, length)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class UInt64Codec(IntegerCodec[int]):
|
|
108
|
+
"""Unsigned 64-bit integer: 0 .. 2**64 - 1, eight bytes."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, struct_format: str = "Q", length: int = 8) -> None:
|
|
111
|
+
super().__init__(struct_format, length)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Int64Codec(UInt64Codec):
|
|
115
|
+
"""Signed 64-bit integer: -2**63 .. 2**63 - 1, eight bytes."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, struct_format: str = "q", length: int = 8) -> None:
|
|
118
|
+
super().__init__(struct_format, length)
|
bytespec/codecs/list.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from typing_extensions import override
|
|
6
|
+
|
|
7
|
+
from bytespec.codecs import ICodec
|
|
8
|
+
from bytespec.enums import ByteOrder
|
|
9
|
+
from bytespec.errors import SchemaError
|
|
10
|
+
from bytespec.models import PrefixLength
|
|
11
|
+
|
|
12
|
+
from ._utils import check_available
|
|
13
|
+
from .map import UINT_CODEC_MAPPING
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ListCodec(ICodec[list[Any]]):
|
|
17
|
+
"""A list prefixed with the total size of its encoded items in bytes.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
item_codec: The codec for a single item. When reading, it must advance offset by a positive
|
|
21
|
+
number of bytes within the supplied buffer.
|
|
22
|
+
prefix_length: Prefix size: 1, 2, 4, or 8 bytes, or ``VarUInt``. Defaults to 4 bytes. The
|
|
23
|
+
prefix does not store the item count.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
SchemaError: Unsupported length prefix.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, item_codec: ICodec[Any], prefix_length: PrefixLength = 4) -> None:
|
|
30
|
+
self.item_codec = item_codec
|
|
31
|
+
self.prefix_length = prefix_length
|
|
32
|
+
|
|
33
|
+
codec = UINT_CODEC_MAPPING.get(prefix_length)
|
|
34
|
+
if codec is None:
|
|
35
|
+
raise SchemaError(f"ListCodec: invalid length prefix {prefix_length!r}")
|
|
36
|
+
|
|
37
|
+
self.codec = codec
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def encode(self, data: list[Any], byte_order: ByteOrder) -> bytes:
|
|
41
|
+
"""Write consecutive items prefixed with their total byte length.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
EncodeError: The size does not fit in the prefix, or an item cannot be encoded by the
|
|
45
|
+
selected codec.
|
|
46
|
+
"""
|
|
47
|
+
payload = bytearray()
|
|
48
|
+
|
|
49
|
+
for value in data:
|
|
50
|
+
payload.extend(self.item_codec.encode(value, byte_order))
|
|
51
|
+
|
|
52
|
+
return self.codec.encode(len(payload), byte_order) + payload
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
def decode(self, buffer: bytes, byte_order: ByteOrder, offset: int) -> tuple[list[Any], int]:
|
|
56
|
+
"""Read a list and return it with its absolute end offset.
|
|
57
|
+
|
|
58
|
+
Items are read from a separate buffer containing the list contents; offsets in their errors
|
|
59
|
+
refer to that buffer.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
DecodeError: The prefix or data is incomplete, or the item codec rejected the data.
|
|
63
|
+
ValueError: Negative offset.
|
|
64
|
+
"""
|
|
65
|
+
length, start = self.codec.decode(buffer, byte_order, offset)
|
|
66
|
+
end = start + length
|
|
67
|
+
|
|
68
|
+
check_available(buffer, start, length, codec=type(self).__name__)
|
|
69
|
+
|
|
70
|
+
values = buffer[start:end]
|
|
71
|
+
data = []
|
|
72
|
+
item_offset = 0
|
|
73
|
+
while item_offset < len(values):
|
|
74
|
+
value, item_offset = self.item_codec.decode(values, byte_order, item_offset)
|
|
75
|
+
data.append(value)
|
|
76
|
+
|
|
77
|
+
return data, end
|
bytespec/codecs/map.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (c) 2026 ink-developer
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from bytespec.codecs.base import ICodec
|
|
6
|
+
from bytespec.codecs.varint import VarUIntCodec
|
|
7
|
+
from bytespec.types import VarUInt
|
|
8
|
+
|
|
9
|
+
from .int import UInt8Codec, UInt16Codec, UInt32Codec, UInt64Codec
|
|
10
|
+
|
|
11
|
+
UINT_CODEC_MAPPING: dict[int | Annotated[Any, Any], ICodec[Any]] = {
|
|
12
|
+
1: UInt8Codec(),
|
|
13
|
+
2: UInt16Codec(),
|
|
14
|
+
4: UInt32Codec(),
|
|
15
|
+
8: UInt64Codec(),
|
|
16
|
+
VarUInt: VarUIntCodec(),
|
|
17
|
+
}
|