dqlite-wire 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.
@@ -0,0 +1,50 @@
1
+ name: Publish to PyPI
2
+
3
+ on: push
4
+
5
+ jobs:
6
+ build:
7
+ name: Build distribution
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v6
11
+ with:
12
+ persist-credentials: false
13
+ - name: Set up Python
14
+ uses: actions/setup-python@v6
15
+ with:
16
+ python-version: "3.x"
17
+ - name: Install pypa/build
18
+ run: >-
19
+ python3 -m
20
+ pip install
21
+ build
22
+ --user
23
+ - name: Build a binary wheel and a source tarball
24
+ run: python3 -m build
25
+ - name: Store the distribution packages
26
+ uses: actions/upload-artifact@v5
27
+ with:
28
+ name: python-package-distributions
29
+ path: dist/
30
+
31
+ publish-to-pypi:
32
+ name: >-
33
+ Publish to PyPI
34
+ if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
35
+ needs:
36
+ - build
37
+ runs-on: ubuntu-latest
38
+ environment:
39
+ name: pypi
40
+ url: https://pypi.org/p/dqlite-wire
41
+ permissions:
42
+ id-token: write
43
+ steps:
44
+ - name: Download all the dists
45
+ uses: actions/download-artifact@v6
46
+ with:
47
+ name: python-package-distributions
48
+ path: dist/
49
+ - name: Publish to PyPI
50
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,46 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ .venv/
25
+ venv/
26
+ ENV/
27
+
28
+ # IDE
29
+ .idea/
30
+ .vscode/
31
+ *.swp
32
+ *.swo
33
+
34
+ # Testing
35
+ .pytest_cache/
36
+ .coverage
37
+ htmlcov/
38
+ .tox/
39
+ .nox/
40
+
41
+ # mypy
42
+ .mypy_cache/
43
+
44
+ # Distribution
45
+ dist/
46
+ build/
@@ -0,0 +1,79 @@
1
+ # Development Guide
2
+
3
+ ## Prerequisites
4
+
5
+ - Python 3.13+
6
+ - [uv](https://github.com/astral-sh/uv)
7
+
8
+ ## Setup
9
+
10
+ ```bash
11
+ # Install uv (if not already installed)
12
+ curl -LsSf https://astral.sh/uv/install.sh | sh
13
+
14
+ # Create virtual environment and install dependencies
15
+ uv venv --python 3.13
16
+ uv pip install -e ".[dev]"
17
+ ```
18
+
19
+ ## Development Tools
20
+
21
+ This project uses modern Python tooling:
22
+
23
+ | Tool | Purpose | Command |
24
+ |------|---------|---------|
25
+ | **pytest** | Testing framework | `pytest` |
26
+ | **ruff** | Linter (replaces flake8, isort, etc.) | `ruff check` |
27
+ | **ruff format** | Code formatter (replaces black) | `ruff format` |
28
+ | **mypy** | Static type checker | `mypy src` |
29
+
30
+ ## Running Tests
31
+
32
+ ```bash
33
+ # Run all tests
34
+ .venv/bin/pytest
35
+
36
+ # Run with verbose output
37
+ .venv/bin/pytest -v
38
+
39
+ # Run with coverage
40
+ .venv/bin/pytest --cov=dqlitewire
41
+ ```
42
+
43
+ ## Linting
44
+
45
+ ```bash
46
+ # Check for issues
47
+ .venv/bin/ruff check src tests
48
+
49
+ # Auto-fix issues
50
+ .venv/bin/ruff check --fix src tests
51
+ ```
52
+
53
+ ## Formatting
54
+
55
+ ```bash
56
+ # Format all files
57
+ .venv/bin/ruff format src tests
58
+
59
+ # Check formatting without modifying
60
+ .venv/bin/ruff format --check src tests
61
+ ```
62
+
63
+ ## Type Checking
64
+
65
+ ```bash
66
+ # Run mypy with strict mode
67
+ .venv/bin/mypy src
68
+ ```
69
+
70
+ ## Pre-commit Workflow
71
+
72
+ Before committing, run all checks:
73
+
74
+ ```bash
75
+ .venv/bin/ruff format src tests
76
+ .venv/bin/ruff check --fix src tests
77
+ .venv/bin/mypy src
78
+ .venv/bin/pytest
79
+ ```
@@ -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.
@@ -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,37 @@
1
+ # dqlite-wire
2
+
3
+ Pure Python wire protocol implementation for [dqlite](https://dqlite.io/), Canonical's distributed SQLite.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install dqlite-wire
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from dqlitewire import MessageEncoder, MessageDecoder
15
+ from dqlitewire.messages import LeaderRequest, ClientRequest
16
+
17
+ # Encode a message
18
+ encoder = MessageEncoder()
19
+ data = encoder.encode(LeaderRequest())
20
+
21
+ # Decode a message
22
+ decoder = MessageDecoder()
23
+ decoder.feed(data)
24
+ message = decoder.decode()
25
+ ```
26
+
27
+ ## Protocol Reference
28
+
29
+ Based on the [dqlite wire protocol specification](https://canonical.com/dqlite/docs/reference/wire-protocol).
30
+
31
+ ## Development
32
+
33
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for setup and contribution guidelines.
34
+
35
+ ## License
36
+
37
+ MIT
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dqlite-wire"
7
+ version = "0.1.0"
8
+ description = "Pure Python wire protocol implementation for dqlite"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "MIT"
12
+ authors = [{ name = "Antoine Leclair", email = "antoineleclair@gmail.com" }]
13
+ keywords = ["dqlite", "sqlite", "distributed", "database", "wire-protocol"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Database",
22
+ "Topic :: Database :: Database Engines/Servers",
23
+ "Typing :: Typed",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/letsdiscodev/python-dqlite-wire"
28
+ Repository = "https://github.com/letsdiscodev/python-dqlite-wire"
29
+ Issues = "https://github.com/letsdiscodev/python-dqlite-wire/issues"
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=8.0", "pytest-cov>=4.0", "mypy>=1.0", "ruff>=0.4"]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/dqlitewire"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ pythonpath = ["src"]
40
+
41
+ [tool.mypy]
42
+ strict = true
43
+ python_version = "3.13"
44
+
45
+ [tool.ruff]
46
+ target-version = "py313"
47
+ line-length = 100
48
+ src = ["src", "tests"]
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "UP", "B", "SIM"]
52
+
53
+ [tool.ruff.lint.isort]
54
+ known-first-party = ["dqlitewire"]
55
+
56
+ [tool.ruff.format]
57
+ quote-style = "double"
58
+ indent-style = "space"
59
+ docstring-code-format = true
@@ -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"
@@ -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
@@ -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()