dqlite-client 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_client-0.1.0/.github/workflows/publish-to-pypi.yml +50 -0
- dqlite_client-0.1.0/.gitignore +46 -0
- dqlite_client-0.1.0/DEVELOPMENT.md +71 -0
- dqlite_client-0.1.0/PKG-INFO +78 -0
- dqlite_client-0.1.0/README.md +46 -0
- dqlite_client-0.1.0/pyproject.toml +64 -0
- dqlite_client-0.1.0/src/dqliteclient/__init__.py +82 -0
- dqlite_client-0.1.0/src/dqliteclient/cluster.py +105 -0
- dqlite_client-0.1.0/src/dqliteclient/connection.py +146 -0
- dqlite_client-0.1.0/src/dqliteclient/exceptions.py +37 -0
- dqlite_client-0.1.0/src/dqliteclient/node_store.py +45 -0
- dqlite_client-0.1.0/src/dqliteclient/pool.py +130 -0
- dqlite_client-0.1.0/src/dqliteclient/protocol.py +213 -0
- dqlite_client-0.1.0/src/dqliteclient/py.typed +0 -0
- dqlite_client-0.1.0/src/dqliteclient/retry.py +51 -0
- dqlite_client-0.1.0/tests/conftest.py +67 -0
- dqlite_client-0.1.0/tests/integration/conftest.py +29 -0
- dqlite_client-0.1.0/tests/integration/test_single_node.py +286 -0
- dqlite_client-0.1.0/tests/test_cluster.py +95 -0
- dqlite_client-0.1.0/tests/test_connection.py +101 -0
- dqlite_client-0.1.0/tests/test_node_store.py +36 -0
- dqlite_client-0.1.0/tests/test_pool.py +69 -0
- dqlite_client-0.1.0/tests/test_protocol.py +104 -0
- dqlite_client-0.1.0/tests/test_retry.py +65 -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-client
|
|
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,71 @@
|
|
|
1
|
+
# Development Guide
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Python 3.13+
|
|
6
|
+
- [uv](https://github.com/astral-sh/uv) - Fast Python package manager
|
|
7
|
+
- Docker (for integration tests)
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
Start by also cloning [dqlite-wire](https://github.com/letsdiscodev/python-dqlite-wire).
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Install uv (if not already installed)
|
|
15
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
16
|
+
|
|
17
|
+
# Create virtual environment and install dependencies
|
|
18
|
+
uv venv --python 3.13
|
|
19
|
+
uv pip install -e "../python-dqlite-wire" -e ".[dev]"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Development Tools
|
|
23
|
+
|
|
24
|
+
| Tool | Purpose | Command |
|
|
25
|
+
|------|---------|---------|
|
|
26
|
+
| **pytest** | Testing framework | `pytest` |
|
|
27
|
+
| **ruff** | Linter (replaces flake8, isort, etc.) | `ruff check` |
|
|
28
|
+
| **ruff format** | Code formatter (replaces black) | `ruff format` |
|
|
29
|
+
| **mypy** | Static type checker | `mypy src` |
|
|
30
|
+
|
|
31
|
+
## Running Tests
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Run unit tests only
|
|
35
|
+
.venv/bin/pytest tests/ --ignore=tests/integration
|
|
36
|
+
|
|
37
|
+
# Run all tests (requires Docker cluster)
|
|
38
|
+
cd ../dqlite-test-cluster && docker compose up -d
|
|
39
|
+
.venv/bin/pytest tests/
|
|
40
|
+
|
|
41
|
+
# Run with verbose output
|
|
42
|
+
.venv/bin/pytest -v
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Linting & Formatting
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Lint
|
|
49
|
+
.venv/bin/ruff check src tests
|
|
50
|
+
|
|
51
|
+
# Auto-fix lint issues
|
|
52
|
+
.venv/bin/ruff check --fix src tests
|
|
53
|
+
|
|
54
|
+
# Format
|
|
55
|
+
.venv/bin/ruff format src tests
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Type Checking
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
.venv/bin/mypy src
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Pre-commit Workflow
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
.venv/bin/ruff format src tests
|
|
68
|
+
.venv/bin/ruff check --fix src tests
|
|
69
|
+
.venv/bin/mypy src
|
|
70
|
+
.venv/bin/pytest tests/ --ignore=tests/integration
|
|
71
|
+
```
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dqlite-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for dqlite with connection pooling and leader detection
|
|
5
|
+
Project-URL: Homepage, https://github.com/letsdiscodev/python-dqlite-client
|
|
6
|
+
Project-URL: Repository, https://github.com/letsdiscodev/python-dqlite-client
|
|
7
|
+
Project-URL: Issues, https://github.com/letsdiscodev/python-dqlite-client/issues
|
|
8
|
+
Author-email: Antoine Leclair <antoineleclair@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: async,asyncio,database,distributed,dqlite,sqlite
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
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
|
+
Requires-Dist: dqlite-wire>=0.1.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
29
|
+
Provides-Extra: yaml
|
|
30
|
+
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# dqlite-client
|
|
34
|
+
|
|
35
|
+
Async Python client for [dqlite](https://dqlite.io/), following asyncpg patterns.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install dqlite-client
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import asyncio
|
|
47
|
+
from dqliteclient import connect
|
|
48
|
+
|
|
49
|
+
async def main():
|
|
50
|
+
conn = await connect("localhost:9001")
|
|
51
|
+
async with conn.transaction():
|
|
52
|
+
await conn.execute("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, name TEXT)")
|
|
53
|
+
await conn.execute("INSERT INTO test (name) VALUES (?)", ["hello"])
|
|
54
|
+
rows = await conn.fetch("SELECT * FROM test")
|
|
55
|
+
for row in rows:
|
|
56
|
+
print(row)
|
|
57
|
+
await conn.close()
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Connection Pooling
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from dqliteclient import create_pool
|
|
66
|
+
|
|
67
|
+
pool = await create_pool(["localhost:9001", "localhost:9002", "localhost:9003"])
|
|
68
|
+
async with pool.acquire() as conn:
|
|
69
|
+
rows = await conn.fetch("SELECT 1")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for setup and contribution guidelines.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# dqlite-client
|
|
2
|
+
|
|
3
|
+
Async Python client for [dqlite](https://dqlite.io/), following asyncpg patterns.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install dqlite-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from dqliteclient import connect
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
conn = await connect("localhost:9001")
|
|
19
|
+
async with conn.transaction():
|
|
20
|
+
await conn.execute("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, name TEXT)")
|
|
21
|
+
await conn.execute("INSERT INTO test (name) VALUES (?)", ["hello"])
|
|
22
|
+
rows = await conn.fetch("SELECT * FROM test")
|
|
23
|
+
for row in rows:
|
|
24
|
+
print(row)
|
|
25
|
+
await conn.close()
|
|
26
|
+
|
|
27
|
+
asyncio.run(main())
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Connection Pooling
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from dqliteclient import create_pool
|
|
34
|
+
|
|
35
|
+
pool = await create_pool(["localhost:9001", "localhost:9002", "localhost:9003"])
|
|
36
|
+
async with pool.acquire() as conn:
|
|
37
|
+
rows = await conn.fetch("SELECT 1")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for setup and contribution guidelines.
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dqlite-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python client for dqlite with connection pooling and leader detection"
|
|
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", "async", "asyncio"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Database",
|
|
23
|
+
"Topic :: Database :: Database Engines/Servers",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["dqlite-wire>=0.1.0"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/letsdiscodev/python-dqlite-client"
|
|
30
|
+
Repository = "https://github.com/letsdiscodev/python-dqlite-client"
|
|
31
|
+
Issues = "https://github.com/letsdiscodev/python-dqlite-client/issues"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = ["pytest>=8.0", "pytest-cov>=4.0", "pytest-asyncio>=0.23", "mypy>=1.0", "ruff>=0.4"]
|
|
35
|
+
yaml = ["pyyaml>=6.0"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/dqliteclient"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
pythonpath = ["src"]
|
|
43
|
+
asyncio_mode = "auto"
|
|
44
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
45
|
+
|
|
46
|
+
[tool.mypy]
|
|
47
|
+
strict = true
|
|
48
|
+
python_version = "3.13"
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
target-version = "py313"
|
|
52
|
+
line-length = 100
|
|
53
|
+
src = ["src", "tests"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.isort]
|
|
59
|
+
known-first-party = ["dqliteclient", "dqlitewire"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.format]
|
|
62
|
+
quote-style = "double"
|
|
63
|
+
indent-style = "space"
|
|
64
|
+
docstring-code-format = true
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Async Python client for dqlite."""
|
|
2
|
+
|
|
3
|
+
from dqliteclient.cluster import ClusterClient
|
|
4
|
+
from dqliteclient.connection import DqliteConnection
|
|
5
|
+
from dqliteclient.exceptions import (
|
|
6
|
+
ClusterError,
|
|
7
|
+
ConnectionError,
|
|
8
|
+
DqliteError,
|
|
9
|
+
OperationalError,
|
|
10
|
+
ProtocolError,
|
|
11
|
+
)
|
|
12
|
+
from dqliteclient.node_store import MemoryNodeStore, NodeStore
|
|
13
|
+
from dqliteclient.pool import ConnectionPool
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"connect",
|
|
17
|
+
"create_pool",
|
|
18
|
+
"DqliteConnection",
|
|
19
|
+
"ConnectionPool",
|
|
20
|
+
"ClusterClient",
|
|
21
|
+
"NodeStore",
|
|
22
|
+
"MemoryNodeStore",
|
|
23
|
+
"DqliteError",
|
|
24
|
+
"ConnectionError",
|
|
25
|
+
"ProtocolError",
|
|
26
|
+
"ClusterError",
|
|
27
|
+
"OperationalError",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def connect(
|
|
34
|
+
address: str,
|
|
35
|
+
*,
|
|
36
|
+
database: str = "default",
|
|
37
|
+
timeout: float = 10.0,
|
|
38
|
+
) -> DqliteConnection:
|
|
39
|
+
"""Connect to a dqlite node.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
address: Node address in "host:port" format
|
|
43
|
+
database: Database name to open
|
|
44
|
+
timeout: Connection timeout in seconds
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A connected DqliteConnection
|
|
48
|
+
"""
|
|
49
|
+
conn = DqliteConnection(address, database=database, timeout=timeout)
|
|
50
|
+
await conn.connect()
|
|
51
|
+
return conn
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def create_pool(
|
|
55
|
+
addresses: list[str],
|
|
56
|
+
*,
|
|
57
|
+
database: str = "default",
|
|
58
|
+
min_size: int = 1,
|
|
59
|
+
max_size: int = 10,
|
|
60
|
+
timeout: float = 10.0,
|
|
61
|
+
) -> ConnectionPool:
|
|
62
|
+
"""Create a connection pool with automatic leader detection.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
addresses: List of node addresses in "host:port" format
|
|
66
|
+
database: Database name to open
|
|
67
|
+
min_size: Minimum number of connections to maintain
|
|
68
|
+
max_size: Maximum number of connections
|
|
69
|
+
timeout: Connection timeout in seconds
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
An initialized ConnectionPool
|
|
73
|
+
"""
|
|
74
|
+
pool = ConnectionPool(
|
|
75
|
+
addresses,
|
|
76
|
+
database=database,
|
|
77
|
+
min_size=min_size,
|
|
78
|
+
max_size=max_size,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
await pool.initialize()
|
|
82
|
+
return pool
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Cluster management and leader detection for dqlite."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from dqliteclient.connection import DqliteConnection
|
|
6
|
+
from dqliteclient.exceptions import ClusterError
|
|
7
|
+
from dqliteclient.node_store import MemoryNodeStore, NodeInfo, NodeStore
|
|
8
|
+
from dqliteclient.protocol import DqliteProtocol
|
|
9
|
+
from dqliteclient.retry import retry_with_backoff
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClusterClient:
|
|
13
|
+
"""Client with automatic leader detection and failover."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
node_store: NodeStore,
|
|
18
|
+
*,
|
|
19
|
+
timeout: float = 10.0,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Initialize cluster client.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
node_store: Store for cluster node information
|
|
25
|
+
timeout: Connection timeout in seconds
|
|
26
|
+
"""
|
|
27
|
+
self._node_store = node_store
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
self._leader_address: str | None = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_addresses(cls, addresses: list[str], timeout: float = 10.0) -> "ClusterClient":
|
|
33
|
+
"""Create cluster client from list of addresses."""
|
|
34
|
+
store = MemoryNodeStore(addresses)
|
|
35
|
+
return cls(store, timeout=timeout)
|
|
36
|
+
|
|
37
|
+
async def find_leader(self) -> str:
|
|
38
|
+
"""Find the current cluster leader.
|
|
39
|
+
|
|
40
|
+
Returns the leader address.
|
|
41
|
+
"""
|
|
42
|
+
nodes = await self._node_store.get_nodes()
|
|
43
|
+
|
|
44
|
+
if not nodes:
|
|
45
|
+
raise ClusterError("No nodes configured")
|
|
46
|
+
|
|
47
|
+
errors: list[str] = []
|
|
48
|
+
|
|
49
|
+
for node in nodes:
|
|
50
|
+
try:
|
|
51
|
+
leader_address = await self._query_leader(node.address)
|
|
52
|
+
if leader_address:
|
|
53
|
+
self._leader_address = leader_address
|
|
54
|
+
return leader_address
|
|
55
|
+
except Exception as e:
|
|
56
|
+
errors.append(f"{node.address}: {e}")
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
raise ClusterError(f"Could not find leader. Errors: {'; '.join(errors)}")
|
|
60
|
+
|
|
61
|
+
async def _query_leader(self, address: str) -> str | None:
|
|
62
|
+
"""Query a node for the current leader."""
|
|
63
|
+
host, port_str = address.rsplit(":", 1)
|
|
64
|
+
port = int(port_str)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
reader, writer = await asyncio.wait_for(
|
|
68
|
+
asyncio.open_connection(host, port),
|
|
69
|
+
timeout=self._timeout,
|
|
70
|
+
)
|
|
71
|
+
except (TimeoutError, OSError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
protocol = DqliteProtocol(reader, writer)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
await protocol.handshake()
|
|
78
|
+
node_id, leader_addr = await protocol.get_leader()
|
|
79
|
+
|
|
80
|
+
# If address is empty, this node is the leader
|
|
81
|
+
if not leader_addr:
|
|
82
|
+
return address
|
|
83
|
+
|
|
84
|
+
return leader_addr
|
|
85
|
+
finally:
|
|
86
|
+
protocol.close()
|
|
87
|
+
await protocol.wait_closed()
|
|
88
|
+
|
|
89
|
+
async def connect(self, database: str = "default") -> DqliteConnection:
|
|
90
|
+
"""Connect to the cluster leader.
|
|
91
|
+
|
|
92
|
+
Returns a connection to the current leader.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
async def try_connect() -> DqliteConnection:
|
|
96
|
+
leader = await self.find_leader()
|
|
97
|
+
conn = DqliteConnection(leader, database=database, timeout=self._timeout)
|
|
98
|
+
await conn.connect()
|
|
99
|
+
return conn
|
|
100
|
+
|
|
101
|
+
return await retry_with_backoff(try_connect, max_attempts=5)
|
|
102
|
+
|
|
103
|
+
async def update_nodes(self, nodes: list[NodeInfo]) -> None:
|
|
104
|
+
"""Update the node store with new node information."""
|
|
105
|
+
await self._node_store.set_nodes(nodes)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""High-level connection interface for dqlite."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from dqliteclient.exceptions import ConnectionError
|
|
9
|
+
from dqliteclient.protocol import DqliteProtocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DqliteConnection:
|
|
13
|
+
"""High-level async connection to a dqlite database."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
address: str,
|
|
18
|
+
*,
|
|
19
|
+
database: str = "default",
|
|
20
|
+
timeout: float = 10.0,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Initialize connection (does not connect yet).
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
address: Node address in "host:port" format
|
|
26
|
+
database: Database name to open
|
|
27
|
+
timeout: Connection timeout in seconds
|
|
28
|
+
"""
|
|
29
|
+
self._address = address
|
|
30
|
+
self._database = database
|
|
31
|
+
self._timeout = timeout
|
|
32
|
+
self._protocol: DqliteProtocol | None = None
|
|
33
|
+
self._db_id: int | None = None
|
|
34
|
+
self._in_transaction = False
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def address(self) -> str:
|
|
38
|
+
"""Get the connection address."""
|
|
39
|
+
return self._address
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_connected(self) -> bool:
|
|
43
|
+
"""Check if connected."""
|
|
44
|
+
return self._protocol is not None
|
|
45
|
+
|
|
46
|
+
async def connect(self) -> None:
|
|
47
|
+
"""Establish connection to the database."""
|
|
48
|
+
if self._protocol is not None:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
host, port_str = self._address.rsplit(":", 1)
|
|
52
|
+
port = int(port_str)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
reader, writer = await asyncio.wait_for(
|
|
56
|
+
asyncio.open_connection(host, port),
|
|
57
|
+
timeout=self._timeout,
|
|
58
|
+
)
|
|
59
|
+
except TimeoutError as e:
|
|
60
|
+
raise ConnectionError(f"Connection to {self._address} timed out") from e
|
|
61
|
+
except OSError as e:
|
|
62
|
+
raise ConnectionError(f"Failed to connect to {self._address}: {e}") from e
|
|
63
|
+
|
|
64
|
+
self._protocol = DqliteProtocol(reader, writer)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
await self._protocol.handshake()
|
|
68
|
+
self._db_id = await self._protocol.open_database(self._database)
|
|
69
|
+
except Exception:
|
|
70
|
+
self._protocol.close()
|
|
71
|
+
self._protocol = None
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
"""Close the connection."""
|
|
76
|
+
if self._protocol is not None:
|
|
77
|
+
self._protocol.close()
|
|
78
|
+
await self._protocol.wait_closed()
|
|
79
|
+
self._protocol = None
|
|
80
|
+
self._db_id = None
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> "DqliteConnection":
|
|
83
|
+
await self.connect()
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
87
|
+
await self.close()
|
|
88
|
+
|
|
89
|
+
def _ensure_connected(self) -> tuple[DqliteProtocol, int]:
|
|
90
|
+
"""Ensure we're connected and return protocol and db_id."""
|
|
91
|
+
if self._protocol is None or self._db_id is None:
|
|
92
|
+
raise ConnectionError("Not connected")
|
|
93
|
+
return self._protocol, self._db_id
|
|
94
|
+
|
|
95
|
+
async def execute(self, sql: str, params: list[Any] | None = None) -> tuple[int, int]:
|
|
96
|
+
"""Execute a SQL statement.
|
|
97
|
+
|
|
98
|
+
Returns (last_insert_id, rows_affected).
|
|
99
|
+
"""
|
|
100
|
+
protocol, db_id = self._ensure_connected()
|
|
101
|
+
return await protocol.exec_sql(db_id, sql, params)
|
|
102
|
+
|
|
103
|
+
async def fetch(self, sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
|
|
104
|
+
"""Execute a query and return results as list of dicts."""
|
|
105
|
+
protocol, db_id = self._ensure_connected()
|
|
106
|
+
columns, rows = await protocol.query_sql(db_id, sql, params)
|
|
107
|
+
return [dict(zip(columns, row, strict=True)) for row in rows]
|
|
108
|
+
|
|
109
|
+
async def fetchall(self, sql: str, params: list[Any] | None = None) -> list[list[Any]]:
|
|
110
|
+
"""Execute a query and return results as list of lists."""
|
|
111
|
+
protocol, db_id = self._ensure_connected()
|
|
112
|
+
_, rows = await protocol.query_sql(db_id, sql, params)
|
|
113
|
+
return rows
|
|
114
|
+
|
|
115
|
+
async def fetchone(self, sql: str, params: list[Any] | None = None) -> dict[str, Any] | None:
|
|
116
|
+
"""Execute a query and return the first result."""
|
|
117
|
+
results = await self.fetch(sql, params)
|
|
118
|
+
return results[0] if results else None
|
|
119
|
+
|
|
120
|
+
async def fetchval(self, sql: str, params: list[Any] | None = None) -> Any:
|
|
121
|
+
"""Execute a query and return the first column of the first row."""
|
|
122
|
+
protocol, db_id = self._ensure_connected()
|
|
123
|
+
_, rows = await protocol.query_sql(db_id, sql, params)
|
|
124
|
+
if rows and rows[0]:
|
|
125
|
+
return rows[0][0]
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
@asynccontextmanager
|
|
129
|
+
async def transaction(self) -> AsyncIterator[None]:
|
|
130
|
+
"""Context manager for transactions."""
|
|
131
|
+
if self._in_transaction:
|
|
132
|
+
# Nested transaction - just yield
|
|
133
|
+
yield
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
await self.execute("BEGIN")
|
|
137
|
+
self._in_transaction = True
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
yield
|
|
141
|
+
await self.execute("COMMIT")
|
|
142
|
+
except Exception:
|
|
143
|
+
await self.execute("ROLLBACK")
|
|
144
|
+
raise
|
|
145
|
+
finally:
|
|
146
|
+
self._in_transaction = False
|