dqlite-wire 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.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: dqlite-wire
3
+ Version: 0.1.0
4
+ Summary: Pure Python wire protocol implementation for dqlite
5
+ Project-URL: Homepage, https://github.com/letsdiscodev/python-dqlite-wire
6
+ Project-URL: Repository, https://github.com/letsdiscodev/python-dqlite-wire
7
+ Project-URL: Issues, https://github.com/letsdiscodev/python-dqlite-wire/issues
8
+ Author-email: Antoine Leclair <antoineleclair@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE.md
11
+ Keywords: database,distributed,dqlite,sqlite,wire-protocol
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Database
19
+ Classifier: Topic :: Database :: Database Engines/Servers
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.13
22
+ Provides-Extra: dev
23
+ Requires-Dist: mypy>=1.0; extra == 'dev'
24
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.4; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # dqlite-wire
30
+
31
+ Pure Python wire protocol implementation for [dqlite](https://dqlite.io/), Canonical's distributed SQLite.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install dqlite-wire
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ from dqlitewire import MessageEncoder, MessageDecoder
43
+ from dqlitewire.messages import LeaderRequest, ClientRequest
44
+
45
+ # Encode a message
46
+ encoder = MessageEncoder()
47
+ data = encoder.encode(LeaderRequest())
48
+
49
+ # Decode a message
50
+ decoder = MessageDecoder()
51
+ decoder.feed(data)
52
+ message = decoder.decode()
53
+ ```
54
+
55
+ ## Protocol Reference
56
+
57
+ Based on the [dqlite wire protocol specification](https://canonical.com/dqlite/docs/reference/wire-protocol).
58
+
59
+ ## Development
60
+
61
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for setup and contribution guidelines.
62
+
63
+ ## License
64
+
65
+ MIT
@@ -0,0 +1,16 @@
1
+ dqlitewire/__init__.py,sha256=Nw4v-fI4v_ZlOKb5CkJtP7ceWGxt0U-qYAygzEswPUY,615
2
+ dqlitewire/buffer.py,sha256=SlDlwEj8YqO265bjCD3d2JJXNkMCyTWQJmmjxPDV2rE,3537
3
+ dqlitewire/codec.py,sha256=hIQIgqVPxLYmwsc3-eXOTNh_7cUPooDi-C7JOfCkhQY,5106
4
+ dqlitewire/constants.py,sha256=_3Nb4WUGIGb8QQ3CBCV_iNWc_sl_zHV1KD8MFX0Hv5A,1359
5
+ dqlitewire/exceptions.py,sha256=7tQFsoWklMDa3D-cu20RPTD-xbK-eTOzGuICOsDgMsA,307
6
+ dqlitewire/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ dqlitewire/tuples.py,sha256=qDDZw0GP-5a2nycyW8bmT8tRW5Oe58J0ZvX1j6zKoeQ,5058
8
+ dqlitewire/types.py,sha256=bYxsT3rWHH3NOHTitn2706B4nG017jxiRDfJ9cIYvww,6218
9
+ dqlitewire/messages/__init__.py,sha256=WqKHX47CL8yOwYCEAWH65lyHMEBK_w_OTaugUfNoyNY,1597
10
+ dqlitewire/messages/base.py,sha256=02aFW78sL7_Co2BI37DAZe_OqSDsK-NyiOEz3wJqbTE,2118
11
+ dqlitewire/messages/requests.py,sha256=6e73eFMn7OiKG0yWwLkEuBxO_-BB-09Qs0KDAoxjUq8,11719
12
+ dqlitewire/messages/responses.py,sha256=XrxiKFMzUT5GC8wpOoe-KjHXQ3F4cBZP7uDw7KYvADI,9847
13
+ dqlite_wire-0.1.0.dist-info/METADATA,sha256=ik1rWoFJ7EEBjmjiDV4EHK6lNfHPOjTL60iBSen0ynM,1907
14
+ dqlite_wire-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
15
+ dqlite_wire-0.1.0.dist-info/licenses/LICENSE.md,sha256=25ur0qy0m17OgHzc1Ve3NeLumyjGGgVBrN-JL3znfDg,1090
16
+ dqlite_wire-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Antoine Leclair and Greg Sadetsky
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.
dqlitewire/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Pure Python wire protocol implementation for dqlite."""
2
+
3
+ from dqlitewire.buffer import ReadBuffer, WriteBuffer
4
+ from dqlitewire.codec import MessageDecoder, MessageEncoder
5
+ from dqlitewire.constants import (
6
+ PROTOCOL_VERSION,
7
+ RequestType,
8
+ ResponseType,
9
+ ValueType,
10
+ )
11
+ from dqlitewire.exceptions import DecodeError, EncodeError, ProtocolError
12
+
13
+ __all__ = [
14
+ "MessageDecoder",
15
+ "MessageEncoder",
16
+ "PROTOCOL_VERSION",
17
+ "ProtocolError",
18
+ "DecodeError",
19
+ "EncodeError",
20
+ "ReadBuffer",
21
+ "WriteBuffer",
22
+ "RequestType",
23
+ "ResponseType",
24
+ "ValueType",
25
+ ]
26
+
27
+ __version__ = "0.1.0"
dqlitewire/buffer.py ADDED
@@ -0,0 +1,119 @@
1
+ """Buffer utilities for streaming protocol data."""
2
+
3
+ from dqlitewire.constants import HEADER_SIZE, WORD_SIZE
4
+
5
+
6
+ class WriteBuffer:
7
+ """Buffer for building wire protocol messages."""
8
+
9
+ def __init__(self) -> None:
10
+ self._data = bytearray()
11
+
12
+ def write(self, data: bytes) -> None:
13
+ """Append data to buffer."""
14
+ self._data.extend(data)
15
+
16
+ def write_padded(self, data: bytes) -> None:
17
+ """Append data with padding to word boundary."""
18
+ self._data.extend(data)
19
+ remainder = len(data) % WORD_SIZE
20
+ if remainder:
21
+ self._data.extend(b"\x00" * (WORD_SIZE - remainder))
22
+
23
+ def getvalue(self) -> bytes:
24
+ """Get buffer contents."""
25
+ return bytes(self._data)
26
+
27
+ def __len__(self) -> int:
28
+ return len(self._data)
29
+
30
+ def clear(self) -> None:
31
+ """Clear the buffer."""
32
+ self._data.clear()
33
+
34
+
35
+ class ReadBuffer:
36
+ """Buffer for reading wire protocol messages from a stream.
37
+
38
+ Handles partial reads and message framing.
39
+ """
40
+
41
+ def __init__(self) -> None:
42
+ self._data = bytearray()
43
+ self._pos = 0
44
+
45
+ def feed(self, data: bytes) -> None:
46
+ """Add received data to the buffer."""
47
+ self._data.extend(data)
48
+
49
+ def has_message(self) -> bool:
50
+ """Check if a complete message is available."""
51
+ available = len(self._data) - self._pos
52
+
53
+ if available < HEADER_SIZE:
54
+ return False
55
+
56
+ # Read size from header (first 4 bytes = size in words)
57
+ size_words = int.from_bytes(self._data[self._pos : self._pos + 4], "little")
58
+ total_size = HEADER_SIZE + (size_words * WORD_SIZE)
59
+
60
+ return available >= total_size
61
+
62
+ def peek_header(self) -> tuple[int, int, int] | None:
63
+ """Peek at the message header without consuming it.
64
+
65
+ Returns (size_in_words, message_type, schema_version) or None if not enough data.
66
+ """
67
+ available = len(self._data) - self._pos
68
+
69
+ if available < HEADER_SIZE:
70
+ return None
71
+
72
+ size_words = int.from_bytes(self._data[self._pos : self._pos + 4], "little")
73
+ msg_type = self._data[self._pos + 4]
74
+ schema_version = self._data[self._pos + 5]
75
+
76
+ return size_words, msg_type, schema_version
77
+
78
+ def read_message(self) -> bytes | None:
79
+ """Read a complete message from the buffer.
80
+
81
+ Returns the message data (including header) or None if not enough data.
82
+ """
83
+ if not self.has_message():
84
+ return None
85
+
86
+ size_words = int.from_bytes(self._data[self._pos : self._pos + 4], "little")
87
+ total_size = HEADER_SIZE + (size_words * WORD_SIZE)
88
+
89
+ message = bytes(self._data[self._pos : self._pos + total_size])
90
+ self._pos += total_size
91
+
92
+ # Compact buffer if we've consumed a lot
93
+ if self._pos > 4096:
94
+ self._data = self._data[self._pos :]
95
+ self._pos = 0
96
+
97
+ return message
98
+
99
+ def read_bytes(self, n: int) -> bytes | None:
100
+ """Read exactly n bytes from the buffer.
101
+
102
+ Returns None if not enough data available.
103
+ """
104
+ available = len(self._data) - self._pos
105
+ if available < n:
106
+ return None
107
+
108
+ data = bytes(self._data[self._pos : self._pos + n])
109
+ self._pos += n
110
+ return data
111
+
112
+ def available(self) -> int:
113
+ """Return number of bytes available to read."""
114
+ return len(self._data) - self._pos
115
+
116
+ def clear(self) -> None:
117
+ """Clear the buffer."""
118
+ self._data.clear()
119
+ self._pos = 0
dqlitewire/codec.py ADDED
@@ -0,0 +1,168 @@
1
+ """Message encoder and decoder for dqlite wire protocol."""
2
+
3
+ from dqlitewire.buffer import ReadBuffer, WriteBuffer
4
+ from dqlitewire.constants import HEADER_SIZE, PROTOCOL_VERSION, RequestType, ResponseType
5
+ from dqlitewire.exceptions import DecodeError
6
+ from dqlitewire.messages.base import Header, Message
7
+ from dqlitewire.messages.requests import (
8
+ AddRequest,
9
+ AssignRequest,
10
+ ClientRequest,
11
+ ClusterRequest,
12
+ ConnectRequest,
13
+ DescribeRequest,
14
+ DumpRequest,
15
+ ExecRequest,
16
+ ExecSqlRequest,
17
+ FinalizeRequest,
18
+ HeartbeatRequest,
19
+ InterruptRequest,
20
+ LeaderRequest,
21
+ OpenRequest,
22
+ PrepareRequest,
23
+ QueryRequest,
24
+ QuerySqlRequest,
25
+ RemoveRequest,
26
+ TransferRequest,
27
+ WeightRequest,
28
+ )
29
+ from dqlitewire.messages.responses import (
30
+ DbResponse,
31
+ DescriptionResponse,
32
+ EmptyResponse,
33
+ FailureResponse,
34
+ FilesResponse,
35
+ LeaderResponse,
36
+ MetadataResponse,
37
+ ResultResponse,
38
+ RowsResponse,
39
+ ServersResponse,
40
+ StmtResponse,
41
+ WelcomeResponse,
42
+ )
43
+
44
+ # Mapping from type codes to message classes
45
+ REQUEST_TYPES: dict[int, type[Message]] = {
46
+ RequestType.LEADER: LeaderRequest,
47
+ RequestType.CLIENT: ClientRequest,
48
+ RequestType.HEARTBEAT: HeartbeatRequest,
49
+ RequestType.OPEN: OpenRequest,
50
+ RequestType.PREPARE: PrepareRequest,
51
+ RequestType.EXEC: ExecRequest,
52
+ RequestType.QUERY: QueryRequest,
53
+ RequestType.FINALIZE: FinalizeRequest,
54
+ RequestType.EXEC_SQL: ExecSqlRequest,
55
+ RequestType.QUERY_SQL: QuerySqlRequest,
56
+ RequestType.INTERRUPT: InterruptRequest,
57
+ RequestType.CONNECT: ConnectRequest,
58
+ RequestType.ADD: AddRequest,
59
+ RequestType.ASSIGN: AssignRequest,
60
+ RequestType.REMOVE: RemoveRequest,
61
+ RequestType.DUMP: DumpRequest,
62
+ RequestType.CLUSTER: ClusterRequest,
63
+ RequestType.TRANSFER: TransferRequest,
64
+ RequestType.DESCRIBE: DescribeRequest,
65
+ RequestType.WEIGHT: WeightRequest,
66
+ }
67
+
68
+ RESPONSE_TYPES: dict[int, type[Message]] = {
69
+ ResponseType.FAILURE: FailureResponse,
70
+ ResponseType.LEADER: LeaderResponse,
71
+ ResponseType.WELCOME: WelcomeResponse,
72
+ ResponseType.DB: DbResponse,
73
+ ResponseType.STMT: StmtResponse,
74
+ ResponseType.RESULT: ResultResponse,
75
+ ResponseType.ROWS: RowsResponse,
76
+ ResponseType.EMPTY: EmptyResponse,
77
+ ResponseType.FILES: FilesResponse,
78
+ ResponseType.SERVERS: ServersResponse,
79
+ ResponseType.METADATA: MetadataResponse,
80
+ ResponseType.DESCRIPTION: DescriptionResponse,
81
+ }
82
+
83
+
84
+ class MessageEncoder:
85
+ """Encodes messages to wire protocol format."""
86
+
87
+ def __init__(self) -> None:
88
+ self._buffer = WriteBuffer()
89
+
90
+ def encode(self, message: Message) -> bytes:
91
+ """Encode a message to bytes."""
92
+ return message.encode()
93
+
94
+ def encode_handshake(self) -> bytes:
95
+ """Encode the protocol version handshake.
96
+
97
+ Must be sent before any other message.
98
+ """
99
+ return PROTOCOL_VERSION.to_bytes(8, "little")
100
+
101
+
102
+ class MessageDecoder:
103
+ """Decodes messages from wire protocol format."""
104
+
105
+ def __init__(self, is_request: bool = False) -> None:
106
+ """Initialize decoder.
107
+
108
+ Args:
109
+ is_request: If True, decode as request messages.
110
+ If False (default), decode as response messages.
111
+ """
112
+ self._buffer = ReadBuffer()
113
+ self._is_request = is_request
114
+ self._type_map = REQUEST_TYPES if is_request else RESPONSE_TYPES
115
+
116
+ def feed(self, data: bytes) -> None:
117
+ """Feed data to the decoder."""
118
+ self._buffer.feed(data)
119
+
120
+ def has_message(self) -> bool:
121
+ """Check if a complete message is available."""
122
+ return self._buffer.has_message()
123
+
124
+ def decode(self) -> Message | None:
125
+ """Decode the next message from the buffer.
126
+
127
+ Returns None if no complete message is available.
128
+ """
129
+ data = self._buffer.read_message()
130
+ if data is None:
131
+ return None
132
+
133
+ return self.decode_bytes(data)
134
+
135
+ def decode_bytes(self, data: bytes) -> Message:
136
+ """Decode a message from bytes."""
137
+ if len(data) < HEADER_SIZE:
138
+ raise DecodeError(f"Message too short: {len(data)} bytes")
139
+
140
+ header = Header.decode(data[:HEADER_SIZE])
141
+ body = data[HEADER_SIZE:]
142
+
143
+ msg_class = self._type_map.get(header.msg_type)
144
+ if msg_class is None:
145
+ raise DecodeError(f"Unknown message type: {header.msg_type}")
146
+
147
+ return msg_class.decode_body(body)
148
+
149
+ def decode_handshake(self) -> int | None:
150
+ """Decode protocol version handshake.
151
+
152
+ Returns the protocol version or None if not enough data.
153
+ """
154
+ data = self._buffer.read_bytes(8)
155
+ if data is None:
156
+ return None
157
+ return int.from_bytes(data, "little")
158
+
159
+
160
+ def decode_message(data: bytes, is_request: bool = False) -> Message:
161
+ """Convenience function to decode a single message."""
162
+ decoder = MessageDecoder(is_request=is_request)
163
+ return decoder.decode_bytes(data)
164
+
165
+
166
+ def encode_message(message: Message) -> bytes:
167
+ """Convenience function to encode a single message."""
168
+ return message.encode()
@@ -0,0 +1,72 @@
1
+ """Protocol constants for dqlite wire protocol."""
2
+
3
+ from enum import IntEnum
4
+
5
+ # Protocol version
6
+ PROTOCOL_VERSION = 1
7
+
8
+ # Word size in bytes (all messages are padded to 8-byte boundaries)
9
+ WORD_SIZE = 8
10
+
11
+ # Header size in bytes
12
+ HEADER_SIZE = 8
13
+
14
+ # Row markers
15
+ ROW_DONE_MARKER = 0xFFFFFFFFFFFFFFFF
16
+ ROW_PART_MARKER = 0xEEEEEEEEEEEEEEEE
17
+
18
+
19
+ class RequestType(IntEnum):
20
+ """Client to server message types."""
21
+
22
+ LEADER = 0
23
+ CLIENT = 1
24
+ HEARTBEAT = 2
25
+ OPEN = 3
26
+ PREPARE = 4
27
+ EXEC = 5
28
+ QUERY = 6
29
+ FINALIZE = 7
30
+ EXEC_SQL = 8
31
+ QUERY_SQL = 9
32
+ INTERRUPT = 10
33
+ CONNECT = 11
34
+ ADD = 12
35
+ ASSIGN = 13
36
+ REMOVE = 14
37
+ DUMP = 15
38
+ CLUSTER = 16
39
+ TRANSFER = 17
40
+ DESCRIBE = 18
41
+ WEIGHT = 19
42
+
43
+
44
+ class ResponseType(IntEnum):
45
+ """Server to client message types."""
46
+
47
+ FAILURE = 0
48
+ LEADER = 1 # Also called NODE
49
+ WELCOME = 2
50
+ NODE_LEGACY = 3
51
+ DB = 4
52
+ STMT = 5
53
+ RESULT = 6
54
+ ROWS = 7
55
+ EMPTY = 8
56
+ FILES = 9
57
+ SERVERS = 10 # Also called CLUSTER or NODES
58
+ METADATA = 11
59
+ DESCRIPTION = 12
60
+
61
+
62
+ class ValueType(IntEnum):
63
+ """Value types for parameters and row values."""
64
+
65
+ INTEGER = 1
66
+ FLOAT = 2
67
+ TEXT = 3
68
+ BLOB = 4
69
+ NULL = 5
70
+ UNIXTIME = 9 # Unix time (deprecated, maps to INTEGER)
71
+ ISO8601 = 10 # ISO8601 string (maps to TEXT)
72
+ BOOLEAN = 11 # Boolean (maps to INTEGER)
@@ -0,0 +1,19 @@
1
+ """Exceptions for dqlite wire protocol."""
2
+
3
+
4
+ class ProtocolError(Exception):
5
+ """Base exception for protocol errors."""
6
+
7
+ pass
8
+
9
+
10
+ class EncodeError(ProtocolError):
11
+ """Error during message encoding."""
12
+
13
+ pass
14
+
15
+
16
+ class DecodeError(ProtocolError):
17
+ """Error during message decoding."""
18
+
19
+ pass
@@ -0,0 +1,79 @@
1
+ """Message types for dqlite wire protocol."""
2
+
3
+ from dqlitewire.messages.base import Header, Message
4
+ from dqlitewire.messages.requests import (
5
+ AddRequest,
6
+ AssignRequest,
7
+ ClientRequest,
8
+ ClusterRequest,
9
+ ConnectRequest,
10
+ DescribeRequest,
11
+ DumpRequest,
12
+ ExecRequest,
13
+ ExecSqlRequest,
14
+ FinalizeRequest,
15
+ HeartbeatRequest,
16
+ InterruptRequest,
17
+ LeaderRequest,
18
+ OpenRequest,
19
+ PrepareRequest,
20
+ QueryRequest,
21
+ QuerySqlRequest,
22
+ RemoveRequest,
23
+ TransferRequest,
24
+ WeightRequest,
25
+ )
26
+ from dqlitewire.messages.responses import (
27
+ DbResponse,
28
+ DescriptionResponse,
29
+ EmptyResponse,
30
+ FailureResponse,
31
+ FilesResponse,
32
+ LeaderResponse,
33
+ MetadataResponse,
34
+ ResultResponse,
35
+ RowsResponse,
36
+ ServersResponse,
37
+ StmtResponse,
38
+ WelcomeResponse,
39
+ )
40
+
41
+ __all__ = [
42
+ # Base
43
+ "Header",
44
+ "Message",
45
+ # Requests
46
+ "LeaderRequest",
47
+ "ClientRequest",
48
+ "HeartbeatRequest",
49
+ "OpenRequest",
50
+ "PrepareRequest",
51
+ "ExecRequest",
52
+ "QueryRequest",
53
+ "FinalizeRequest",
54
+ "ExecSqlRequest",
55
+ "QuerySqlRequest",
56
+ "InterruptRequest",
57
+ "ConnectRequest",
58
+ "AddRequest",
59
+ "AssignRequest",
60
+ "RemoveRequest",
61
+ "DumpRequest",
62
+ "ClusterRequest",
63
+ "TransferRequest",
64
+ "DescribeRequest",
65
+ "WeightRequest",
66
+ # Responses
67
+ "FailureResponse",
68
+ "LeaderResponse",
69
+ "WelcomeResponse",
70
+ "DbResponse",
71
+ "StmtResponse",
72
+ "ResultResponse",
73
+ "RowsResponse",
74
+ "EmptyResponse",
75
+ "FilesResponse",
76
+ "ServersResponse",
77
+ "MetadataResponse",
78
+ "DescriptionResponse",
79
+ ]
@@ -0,0 +1,75 @@
1
+ """Base message types for dqlite wire protocol."""
2
+
3
+ import struct
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+ from typing import ClassVar
7
+
8
+ from dqlitewire.constants import HEADER_SIZE, WORD_SIZE
9
+
10
+
11
+ @dataclass
12
+ class Header:
13
+ """Message header.
14
+
15
+ Format (8 bytes):
16
+ - size: uint32 - Size of message body in words (8-byte units)
17
+ - type: uint8 - Message type code
18
+ - schema: uint8 - Schema version (currently always 0)
19
+ - reserved: uint16 - Reserved (always 0)
20
+ """
21
+
22
+ size_words: int
23
+ msg_type: int
24
+ schema: int = 0
25
+ reserved: int = 0
26
+
27
+ def encode(self) -> bytes:
28
+ """Encode header to bytes."""
29
+ return struct.pack(
30
+ "<IBBH",
31
+ self.size_words,
32
+ self.msg_type,
33
+ self.schema,
34
+ self.reserved,
35
+ )
36
+
37
+ @classmethod
38
+ def decode(cls, data: bytes) -> "Header":
39
+ """Decode header from bytes."""
40
+ if len(data) < HEADER_SIZE:
41
+ raise ValueError(f"Need {HEADER_SIZE} bytes for header, got {len(data)}")
42
+ size_words, msg_type, schema, reserved = struct.unpack("<IBBH", data[:HEADER_SIZE])
43
+ return cls(size_words, msg_type, schema, reserved)
44
+
45
+ @property
46
+ def body_size(self) -> int:
47
+ """Size of message body in bytes."""
48
+ return self.size_words * WORD_SIZE
49
+
50
+
51
+ class Message(ABC):
52
+ """Base class for all protocol messages."""
53
+
54
+ MSG_TYPE: ClassVar[int]
55
+
56
+ @abstractmethod
57
+ def encode_body(self) -> bytes:
58
+ """Encode message body (without header)."""
59
+ ...
60
+
61
+ def encode(self) -> bytes:
62
+ """Encode complete message with header."""
63
+ body = self.encode_body()
64
+ # Ensure body is word-aligned
65
+ if len(body) % WORD_SIZE != 0:
66
+ body += b"\x00" * (WORD_SIZE - (len(body) % WORD_SIZE))
67
+ size_words = len(body) // WORD_SIZE
68
+ header = Header(size_words, self.MSG_TYPE)
69
+ return header.encode() + body
70
+
71
+ @classmethod
72
+ @abstractmethod
73
+ def decode_body(cls, data: bytes) -> "Message":
74
+ """Decode message from body data (without header)."""
75
+ ...