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.
- dqlite_wire-0.1.0.dist-info/METADATA +65 -0
- dqlite_wire-0.1.0.dist-info/RECORD +16 -0
- dqlite_wire-0.1.0.dist-info/WHEEL +4 -0
- dqlite_wire-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- dqlitewire/__init__.py +27 -0
- dqlitewire/buffer.py +119 -0
- dqlitewire/codec.py +168 -0
- dqlitewire/constants.py +72 -0
- dqlitewire/exceptions.py +19 -0
- dqlitewire/messages/__init__.py +79 -0
- dqlitewire/messages/base.py +75 -0
- dqlitewire/messages/requests.py +494 -0
- dqlitewire/messages/responses.py +375 -0
- dqlitewire/py.typed +0 -0
- dqlitewire/tuples.py +163 -0
- dqlitewire/types.py +189 -0
|
@@ -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,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()
|
dqlitewire/constants.py
ADDED
|
@@ -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)
|
dqlitewire/exceptions.py
ADDED
|
@@ -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
|
+
...
|