bytespec 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- bytespec-0.1.0/PKG-INFO +103 -0
- bytespec-0.1.0/README.md +77 -0
- bytespec-0.1.0/pyproject.toml +72 -0
- bytespec-0.1.0/src/bytespec/__init__.py +21 -0
- bytespec-0.1.0/src/bytespec/base.py +52 -0
- bytespec-0.1.0/src/bytespec/codecs/__init__.py +48 -0
- bytespec-0.1.0/src/bytespec/codecs/_utils.py +16 -0
- bytespec-0.1.0/src/bytespec/codecs/base.py +60 -0
- bytespec-0.1.0/src/bytespec/codecs/bool.py +45 -0
- bytespec-0.1.0/src/bytespec/codecs/bytes.py +105 -0
- bytespec-0.1.0/src/bytespec/codecs/datetime.py +59 -0
- bytespec-0.1.0/src/bytespec/codecs/enum.py +49 -0
- bytespec-0.1.0/src/bytespec/codecs/float.py +17 -0
- bytespec-0.1.0/src/bytespec/codecs/int.py +118 -0
- bytespec-0.1.0/src/bytespec/codecs/list.py +77 -0
- bytespec-0.1.0/src/bytespec/codecs/map.py +17 -0
- bytespec-0.1.0/src/bytespec/codecs/model.py +40 -0
- bytespec-0.1.0/src/bytespec/codecs/str.py +88 -0
- bytespec-0.1.0/src/bytespec/codecs/uuid.py +36 -0
- bytespec-0.1.0/src/bytespec/codecs/varint.py +122 -0
- bytespec-0.1.0/src/bytespec/core.py +500 -0
- bytespec-0.1.0/src/bytespec/enums.py +14 -0
- bytespec-0.1.0/src/bytespec/errors.py +29 -0
- bytespec-0.1.0/src/bytespec/headers.py +143 -0
- bytespec-0.1.0/src/bytespec/missing.py +17 -0
- bytespec-0.1.0/src/bytespec/models.py +47 -0
- bytespec-0.1.0/src/bytespec/py.typed +0 -0
- bytespec-0.1.0/src/bytespec/resolver.py +196 -0
- bytespec-0.1.0/src/bytespec/resolvers.py +114 -0
- bytespec-0.1.0/src/bytespec/schema.py +35 -0
- bytespec-0.1.0/src/bytespec/types/__init__.py +25 -0
- bytespec-0.1.0/src/bytespec/types/spec.py +75 -0
bytespec-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ink-developer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
bytespec-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bytespec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declarative binary serialization and deserialization for Python
|
|
5
|
+
Keywords: binary,serialization,deserialization,codec,protocol
|
|
6
|
+
Author: ink-developer
|
|
7
|
+
Author-email: ink-developer <142109011+ink-developer@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: typing-extensions>=4.16.0
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://github.com/ink-developer/bytespec
|
|
22
|
+
Project-URL: Repository, https://github.com/ink-developer/bytespec
|
|
23
|
+
Project-URL: Issues, https://github.com/ink-developer/bytespec/issues
|
|
24
|
+
Project-URL: Documentation, https://bytespec.pymax.org
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# bytespec
|
|
28
|
+
|
|
29
|
+
English · [Русский](README.md)
|
|
30
|
+
|
|
31
|
+
Typed Python models over an explicit sequential binary representation.
|
|
32
|
+
One model reads and writes numbers, strings, lists, and nested objects;
|
|
33
|
+
you choose their representation, and the library tracks lengths and offsets.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from bytespec import Constructor, PayloadLength, ProtoModel, field
|
|
37
|
+
from bytespec.types import UInt32
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class User(ProtoModel):
|
|
41
|
+
__constructor__ = 0x12
|
|
42
|
+
__header__ = (Constructor(1), PayloadLength(1))
|
|
43
|
+
|
|
44
|
+
id: UInt32
|
|
45
|
+
name: str = field(prefix_length=1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
user = User(id=42, name="Anna")
|
|
49
|
+
data = user.encode()
|
|
50
|
+
restored = User.decode(data)
|
|
51
|
+
|
|
52
|
+
print(data.hex(" ")) # 12 09 00 00 00 2a 04 41 6e 6e 61
|
|
53
|
+
print(restored.name) # Anna
|
|
54
|
+
assert restored == user
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`12` identifies the message; `09` is the computed length of its fields.
|
|
58
|
+
Next come the four-byte `id` and a UTF-8 name with a one-byte length prefix.
|
|
59
|
+
In your application, these are ordinary `int` and `str` values. Your first
|
|
60
|
+
models can use the default framing without setting `__header__`; for an
|
|
61
|
+
existing format, you can customize or remove it.
|
|
62
|
+
|
|
63
|
+
bytespec fits custom or existing sequential binary protocols when you want
|
|
64
|
+
to work with classes without a separate schema language or code generation.
|
|
65
|
+
It supports optional fields through flags, `IntEnum`, UUID, datetime, numeric
|
|
66
|
+
encodings, and custom codecs. This is a small 0.1.0 library: for a flexible
|
|
67
|
+
binary parser DSL, consider Construct; for standard JSON/MessagePack, msgspec;
|
|
68
|
+
for a schema ecosystem with its own wire format, protobuf.
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
Python 3.10+:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install bytespec
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or `uv add bytespec`.
|
|
79
|
+
|
|
80
|
+
## Documentation
|
|
81
|
+
|
|
82
|
+
The full guide and API reference are available in Russian and English.
|
|
83
|
+
To build both versions from a checkout:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uv sync --locked --group docs
|
|
87
|
+
uv run --no-sync python -m sphinx -E -a -n -W --keep-going -b html -D language=ru docs docs/_build/html/ru
|
|
88
|
+
uv run --no-sync python -m sphinx -E -a -n -W --keep-going -b html -D language=en docs docs/_build/html/en
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Open `docs/_build/html/en/index.html`. The language switcher keeps you on
|
|
92
|
+
the same page. Start with **Getting started**, **Why bytespec?**, or
|
|
93
|
+
**Headers and framing**, then consult the **API reference**.
|
|
94
|
+
The [documentation sources](docs/index.rst) and
|
|
95
|
+
[build instructions](docs/building.rst) are in Russian; Sphinx applies
|
|
96
|
+
the [English translation catalogs](docs/locale/en/LC_MESSAGES/index.po)
|
|
97
|
+
when building the English version.
|
|
98
|
+
|
|
99
|
+
Take a known packet from your protocol, describe a few fields, and compare
|
|
100
|
+
the re-encoded result with the original bytes. That is a useful first check
|
|
101
|
+
of whether the library fits your format.
|
|
102
|
+
|
|
103
|
+
[MIT license](LICENSE).
|
bytespec-0.1.0/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# bytespec
|
|
2
|
+
|
|
3
|
+
English · [Русский](README.md)
|
|
4
|
+
|
|
5
|
+
Typed Python models over an explicit sequential binary representation.
|
|
6
|
+
One model reads and writes numbers, strings, lists, and nested objects;
|
|
7
|
+
you choose their representation, and the library tracks lengths and offsets.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from bytespec import Constructor, PayloadLength, ProtoModel, field
|
|
11
|
+
from bytespec.types import UInt32
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class User(ProtoModel):
|
|
15
|
+
__constructor__ = 0x12
|
|
16
|
+
__header__ = (Constructor(1), PayloadLength(1))
|
|
17
|
+
|
|
18
|
+
id: UInt32
|
|
19
|
+
name: str = field(prefix_length=1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
user = User(id=42, name="Anna")
|
|
23
|
+
data = user.encode()
|
|
24
|
+
restored = User.decode(data)
|
|
25
|
+
|
|
26
|
+
print(data.hex(" ")) # 12 09 00 00 00 2a 04 41 6e 6e 61
|
|
27
|
+
print(restored.name) # Anna
|
|
28
|
+
assert restored == user
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`12` identifies the message; `09` is the computed length of its fields.
|
|
32
|
+
Next come the four-byte `id` and a UTF-8 name with a one-byte length prefix.
|
|
33
|
+
In your application, these are ordinary `int` and `str` values. Your first
|
|
34
|
+
models can use the default framing without setting `__header__`; for an
|
|
35
|
+
existing format, you can customize or remove it.
|
|
36
|
+
|
|
37
|
+
bytespec fits custom or existing sequential binary protocols when you want
|
|
38
|
+
to work with classes without a separate schema language or code generation.
|
|
39
|
+
It supports optional fields through flags, `IntEnum`, UUID, datetime, numeric
|
|
40
|
+
encodings, and custom codecs. This is a small 0.1.0 library: for a flexible
|
|
41
|
+
binary parser DSL, consider Construct; for standard JSON/MessagePack, msgspec;
|
|
42
|
+
for a schema ecosystem with its own wire format, protobuf.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
Python 3.10+:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install bytespec
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or `uv add bytespec`.
|
|
53
|
+
|
|
54
|
+
## Documentation
|
|
55
|
+
|
|
56
|
+
The full guide and API reference are available in Russian and English.
|
|
57
|
+
To build both versions from a checkout:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv sync --locked --group docs
|
|
61
|
+
uv run --no-sync python -m sphinx -E -a -n -W --keep-going -b html -D language=ru docs docs/_build/html/ru
|
|
62
|
+
uv run --no-sync python -m sphinx -E -a -n -W --keep-going -b html -D language=en docs docs/_build/html/en
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Open `docs/_build/html/en/index.html`. The language switcher keeps you on
|
|
66
|
+
the same page. Start with **Getting started**, **Why bytespec?**, or
|
|
67
|
+
**Headers and framing**, then consult the **API reference**.
|
|
68
|
+
The [documentation sources](docs/index.rst) and
|
|
69
|
+
[build instructions](docs/building.rst) are in Russian; Sphinx applies
|
|
70
|
+
the [English translation catalogs](docs/locale/en/LC_MESSAGES/index.po)
|
|
71
|
+
when building the English version.
|
|
72
|
+
|
|
73
|
+
Take a known packet from your protocol, describe a few fields, and compare
|
|
74
|
+
the re-encoded result with the original bytes. That is a useful first check
|
|
75
|
+
of whether the library fits your format.
|
|
76
|
+
|
|
77
|
+
[MIT license](LICENSE).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bytespec"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Declarative binary serialization and deserialization for Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "ink-developer", email = "142109011+ink-developer@users.noreply.github.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
"typing-extensions>=4.16.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
keywords = [
|
|
18
|
+
"binary",
|
|
19
|
+
"serialization",
|
|
20
|
+
"deserialization",
|
|
21
|
+
"codec",
|
|
22
|
+
"protocol",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Programming Language :: Python",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/ink-developer/bytespec"
|
|
39
|
+
Repository = "https://github.com/ink-developer/bytespec"
|
|
40
|
+
Issues = "https://github.com/ink-developer/bytespec/issues"
|
|
41
|
+
Documentation = "https://bytespec.pymax.org"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.11.4,<0.12.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
dev = [
|
|
49
|
+
"mypy>=1.18",
|
|
50
|
+
"ruff>=0.13",
|
|
51
|
+
]
|
|
52
|
+
test = [
|
|
53
|
+
"pytest>=9.1.1",
|
|
54
|
+
]
|
|
55
|
+
docs = [
|
|
56
|
+
"construct>=2.10.70,<2.11",
|
|
57
|
+
"furo>=2025.12.19",
|
|
58
|
+
"sphinx>=8.1.3",
|
|
59
|
+
"sphinx-copybutton>=0.5.2",
|
|
60
|
+
"sphinx-intl>=2.4.0",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
testpaths = ["tests"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
target-version = "py310"
|
|
68
|
+
line-length = 100
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
python_version = "3.10"
|
|
72
|
+
strict = true
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
)
|
|
@@ -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
|
+
...
|
|
@@ -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
|
|
@@ -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
|