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.
- dqlite_wire-0.1.0/.github/workflows/publish-to-pypi.yml +50 -0
- dqlite_wire-0.1.0/.gitignore +46 -0
- dqlite_wire-0.1.0/DEVELOPMENT.md +79 -0
- dqlite_wire-0.1.0/LICENSE.md +21 -0
- dqlite_wire-0.1.0/PKG-INFO +65 -0
- dqlite_wire-0.1.0/README.md +37 -0
- dqlite_wire-0.1.0/pyproject.toml +59 -0
- dqlite_wire-0.1.0/src/dqlitewire/__init__.py +27 -0
- dqlite_wire-0.1.0/src/dqlitewire/buffer.py +119 -0
- dqlite_wire-0.1.0/src/dqlitewire/codec.py +168 -0
- dqlite_wire-0.1.0/src/dqlitewire/constants.py +72 -0
- dqlite_wire-0.1.0/src/dqlitewire/exceptions.py +19 -0
- dqlite_wire-0.1.0/src/dqlitewire/messages/__init__.py +79 -0
- dqlite_wire-0.1.0/src/dqlitewire/messages/base.py +75 -0
- dqlite_wire-0.1.0/src/dqlitewire/messages/requests.py +494 -0
- dqlite_wire-0.1.0/src/dqlitewire/messages/responses.py +375 -0
- dqlite_wire-0.1.0/src/dqlitewire/py.typed +0 -0
- dqlite_wire-0.1.0/src/dqlitewire/tuples.py +163 -0
- dqlite_wire-0.1.0/src/dqlitewire/types.py +189 -0
- dqlite_wire-0.1.0/tests/conftest.py +1 -0
- dqlite_wire-0.1.0/tests/test_buffer.py +157 -0
- dqlite_wire-0.1.0/tests/test_codec.py +184 -0
- dqlite_wire-0.1.0/tests/test_messages_requests.py +238 -0
- dqlite_wire-0.1.0/tests/test_messages_responses.py +242 -0
- dqlite_wire-0.1.0/tests/test_tuples.py +143 -0
- dqlite_wire-0.1.0/tests/test_types.py +354 -0
|
@@ -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()
|