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.
@@ -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