sqlalchemy-dqlite 0.1.0__tar.gz → 0.1.2__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.
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/PKG-INFO +1 -1
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/pyproject.toml +1 -1
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/src/sqlalchemydqlite/__init__.py +1 -1
- sqlalchemy_dqlite-0.1.2/src/sqlalchemydqlite/aio.py +199 -0
- sqlalchemy_dqlite-0.1.2/tests/integration/test_async_context.py +28 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/test_dialect.py +19 -0
- sqlalchemy_dqlite-0.1.0/src/sqlalchemydqlite/aio.py +0 -57
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/.github/workflows/publish-to-pypi.yml +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/.gitignore +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/DEVELOPMENT.md +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/LICENSE.md +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/README.md +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/src/sqlalchemydqlite/base.py +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/src/sqlalchemydqlite/py.typed +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/src/sqlalchemydqlite/requirements.py +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/__init__.py +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/conftest.py +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/integration/__init__.py +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/integration/conftest.py +0 -0
- {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/integration/test_orm_operations.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy-dqlite
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: SQLAlchemy 2.0 dialect for dqlite distributed SQLite
|
|
5
5
|
Project-URL: Homepage, https://github.com/antoineleclair/sqlalchemy-dqlite
|
|
6
6
|
Project-URL: Repository, https://github.com/antoineleclair/sqlalchemy-dqlite
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Async dqlite dialect for SQLAlchemy."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import pool
|
|
8
|
+
from sqlalchemy.engine import URL, AdaptedConnection
|
|
9
|
+
from sqlalchemy.engine.interfaces import DBAPIConnection
|
|
10
|
+
from sqlalchemy.pool import AsyncAdaptedQueuePool
|
|
11
|
+
from sqlalchemy.util import await_only
|
|
12
|
+
|
|
13
|
+
from sqlalchemydqlite.base import DqliteDialect
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncAdaptedCursor:
|
|
17
|
+
"""Adapts an AsyncCursor for SQLAlchemy's greenlet-based async engine.
|
|
18
|
+
|
|
19
|
+
Eagerly fetches all rows during execute() within the greenlet context,
|
|
20
|
+
then serves fetch* calls synchronously from the buffer. This matches
|
|
21
|
+
the pattern used by SQLAlchemy's aiosqlite dialect.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
server_side = False
|
|
25
|
+
|
|
26
|
+
def __init__(self, adapt_connection: "AsyncAdaptedConnection") -> None:
|
|
27
|
+
self._adapt_connection = adapt_connection
|
|
28
|
+
self._connection = adapt_connection._connection
|
|
29
|
+
self.description: Any = None
|
|
30
|
+
self.rowcount: int = -1
|
|
31
|
+
self.lastrowid: int | None = None
|
|
32
|
+
self.arraysize: int = 1
|
|
33
|
+
self._rows: deque[Any] = deque()
|
|
34
|
+
|
|
35
|
+
async def _async_soft_close(self) -> None:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
def close(self) -> None:
|
|
39
|
+
self._rows.clear()
|
|
40
|
+
|
|
41
|
+
def execute(self, operation: str, parameters: Any = None) -> Any:
|
|
42
|
+
cursor = self._connection.cursor()
|
|
43
|
+
if parameters is not None:
|
|
44
|
+
await_only(cursor.execute(operation, parameters))
|
|
45
|
+
else:
|
|
46
|
+
await_only(cursor.execute(operation))
|
|
47
|
+
|
|
48
|
+
if cursor.description:
|
|
49
|
+
self.description = cursor.description
|
|
50
|
+
self.lastrowid = self.rowcount = -1
|
|
51
|
+
self._rows = deque(await_only(cursor.fetchall()))
|
|
52
|
+
else:
|
|
53
|
+
self.description = None
|
|
54
|
+
self.lastrowid = cursor.lastrowid
|
|
55
|
+
self.rowcount = cursor.rowcount
|
|
56
|
+
|
|
57
|
+
await_only(cursor.close())
|
|
58
|
+
|
|
59
|
+
def executemany(self, operation: str, seq_of_parameters: Any) -> Any:
|
|
60
|
+
cursor = self._connection.cursor()
|
|
61
|
+
await_only(cursor.executemany(operation, seq_of_parameters))
|
|
62
|
+
self.description = None
|
|
63
|
+
self.lastrowid = cursor.lastrowid
|
|
64
|
+
self.rowcount = cursor.rowcount
|
|
65
|
+
await_only(cursor.close())
|
|
66
|
+
|
|
67
|
+
def fetchone(self) -> Any:
|
|
68
|
+
if self._rows:
|
|
69
|
+
return self._rows.popleft()
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def fetchmany(self, size: int | None = None) -> Sequence[Any]:
|
|
73
|
+
if size is None:
|
|
74
|
+
size = self.arraysize
|
|
75
|
+
return [self._rows.popleft() for _ in range(min(size, len(self._rows)))]
|
|
76
|
+
|
|
77
|
+
def fetchall(self) -> Sequence[Any]:
|
|
78
|
+
retval = list(self._rows)
|
|
79
|
+
self._rows.clear()
|
|
80
|
+
return retval
|
|
81
|
+
|
|
82
|
+
def setinputsizes(self, *inputsizes: Any) -> None:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def setoutputsize(self, size: int, column: int | None = None) -> None:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def __iter__(self) -> Any:
|
|
89
|
+
while self._rows:
|
|
90
|
+
yield self._rows.popleft()
|
|
91
|
+
|
|
92
|
+
def __next__(self) -> Any:
|
|
93
|
+
row = self.fetchone()
|
|
94
|
+
if row is None:
|
|
95
|
+
raise StopIteration
|
|
96
|
+
return row
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AsyncAdaptedConnection(AdaptedConnection):
|
|
100
|
+
"""Adapts an AsyncConnection for SQLAlchemy's greenlet-based async engine.
|
|
101
|
+
|
|
102
|
+
Provides sync-looking methods that internally use await_only() to
|
|
103
|
+
bridge to the underlying async connection within SQLAlchemy's
|
|
104
|
+
greenlet context.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, connection: Any) -> None:
|
|
108
|
+
self._connection = connection
|
|
109
|
+
|
|
110
|
+
def cursor(self) -> AsyncAdaptedCursor:
|
|
111
|
+
return AsyncAdaptedCursor(self)
|
|
112
|
+
|
|
113
|
+
def commit(self) -> None:
|
|
114
|
+
await_only(self._connection.commit())
|
|
115
|
+
|
|
116
|
+
def rollback(self) -> None:
|
|
117
|
+
await_only(self._connection.rollback())
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
await_only(self._connection.close())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DqliteDialect_aio(DqliteDialect): # noqa: N801
|
|
124
|
+
"""Async SQLAlchemy dialect for dqlite.
|
|
125
|
+
|
|
126
|
+
Use with SQLAlchemy's async engine:
|
|
127
|
+
create_async_engine("dqlite+aio://host:port/database")
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
driver = "dqlitedbapi_aio"
|
|
131
|
+
is_async = True
|
|
132
|
+
supports_statement_cache = True
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def get_pool_class(cls, url: URL) -> type[pool.Pool]:
|
|
136
|
+
return AsyncAdaptedQueuePool
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def import_dbapi(cls) -> Any:
|
|
140
|
+
from dqlitedbapi import aio
|
|
141
|
+
|
|
142
|
+
return aio
|
|
143
|
+
|
|
144
|
+
def connect(self, *cargs: Any, **cparams: Any) -> Any:
|
|
145
|
+
"""Create and wrap an async connection."""
|
|
146
|
+
raw_conn = self.loaded_dbapi.connect(*cargs, **cparams)
|
|
147
|
+
return AsyncAdaptedConnection(raw_conn)
|
|
148
|
+
|
|
149
|
+
def create_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
|
|
150
|
+
"""Create connection arguments from URL.
|
|
151
|
+
|
|
152
|
+
URL format: dqlite+aio://host:port/database
|
|
153
|
+
"""
|
|
154
|
+
host = url.host or "localhost"
|
|
155
|
+
port = url.port or 9001
|
|
156
|
+
database = url.database or "default"
|
|
157
|
+
|
|
158
|
+
address = f"{host}:{port}"
|
|
159
|
+
|
|
160
|
+
return [], {
|
|
161
|
+
"address": address,
|
|
162
|
+
"database": database,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def do_rollback(self, dbapi_connection: DBAPIConnection) -> None:
|
|
166
|
+
"""Rollback the current transaction."""
|
|
167
|
+
try:
|
|
168
|
+
dbapi_connection.rollback()
|
|
169
|
+
except Exception as e:
|
|
170
|
+
if "no transaction is active" not in str(e):
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
def do_commit(self, dbapi_connection: DBAPIConnection) -> None:
|
|
174
|
+
"""Commit the current transaction."""
|
|
175
|
+
try:
|
|
176
|
+
dbapi_connection.commit()
|
|
177
|
+
except Exception as e:
|
|
178
|
+
if "no transaction is active" not in str(e):
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
def _get_server_version_info(self, connection: Any) -> tuple[int, ...]:
|
|
182
|
+
"""Return the server version as a tuple."""
|
|
183
|
+
cursor = connection.connection.dbapi_connection.cursor()
|
|
184
|
+
cursor.execute("SELECT sqlite_version()")
|
|
185
|
+
row = cursor.fetchone()
|
|
186
|
+
cursor.close()
|
|
187
|
+
|
|
188
|
+
if row:
|
|
189
|
+
version_str = row[0]
|
|
190
|
+
return tuple(int(x) for x in version_str.split("."))
|
|
191
|
+
return (3, 0, 0)
|
|
192
|
+
|
|
193
|
+
def get_driver_connection(self, connection: Any) -> Any:
|
|
194
|
+
"""Return the driver-level connection."""
|
|
195
|
+
return connection
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Register the dialect
|
|
199
|
+
dialect = DqliteDialect_aio
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Integration tests for sync engine used inside an async context."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy import create_engine, text
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.integration
|
|
10
|
+
class TestSyncEngineInAsyncContext:
|
|
11
|
+
def test_sync_engine_inside_running_loop(self, engine_url: str) -> None:
|
|
12
|
+
"""Sync engine must work when called from inside a running event loop.
|
|
13
|
+
|
|
14
|
+
This simulates the scenario where a sync SQLAlchemy engine is used
|
|
15
|
+
during app startup inside an async server like uvicorn.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
async def _run_inside_loop() -> str:
|
|
19
|
+
engine = create_engine(engine_url)
|
|
20
|
+
with engine.connect() as conn:
|
|
21
|
+
result = conn.execute(text("SELECT 'ok'"))
|
|
22
|
+
row = result.fetchone()
|
|
23
|
+
engine.dispose()
|
|
24
|
+
assert row is not None
|
|
25
|
+
return str(row[0])
|
|
26
|
+
|
|
27
|
+
result = asyncio.run(_run_inside_loop())
|
|
28
|
+
assert result == "ok"
|
|
@@ -58,6 +58,25 @@ class TestDqliteDialectAio:
|
|
|
58
58
|
dbapi = DqliteDialect_aio.import_dbapi()
|
|
59
59
|
assert hasattr(dbapi, "aconnect")
|
|
60
60
|
|
|
61
|
+
def test_import_dbapi_has_paramstyle(self) -> None:
|
|
62
|
+
"""Async dbapi module must expose paramstyle for SQLAlchemy dialect init."""
|
|
63
|
+
dbapi = DqliteDialect_aio.import_dbapi()
|
|
64
|
+
assert dbapi.paramstyle == "qmark"
|
|
65
|
+
|
|
66
|
+
def test_import_dbapi_has_module_attributes(self) -> None:
|
|
67
|
+
"""Async dbapi module must expose PEP 249 attributes for SQLAlchemy."""
|
|
68
|
+
dbapi = DqliteDialect_aio.import_dbapi()
|
|
69
|
+
assert dbapi.apilevel == "2.0"
|
|
70
|
+
assert dbapi.threadsafety == 1
|
|
71
|
+
|
|
72
|
+
def test_create_async_engine(self) -> None:
|
|
73
|
+
"""create_async_engine must not raise during dialect initialization."""
|
|
74
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
75
|
+
|
|
76
|
+
engine = create_async_engine("dqlite+aio://localhost:19001/test")
|
|
77
|
+
assert engine.dialect.name == "dqlite"
|
|
78
|
+
assert engine.dialect.driver == "dqlitedbapi_aio"
|
|
79
|
+
|
|
61
80
|
|
|
62
81
|
class TestURLParsing:
|
|
63
82
|
def test_parse_basic_url(self) -> None:
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""Async dqlite dialect for SQLAlchemy."""
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
from sqlalchemy import pool
|
|
6
|
-
from sqlalchemy.dialects.sqlite.base import SQLiteDialect
|
|
7
|
-
from sqlalchemy.engine import URL
|
|
8
|
-
from sqlalchemy.pool import AsyncAdaptedQueuePool
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class DqliteDialect_aio(SQLiteDialect): # noqa: N801
|
|
12
|
-
"""Async SQLAlchemy dialect for dqlite.
|
|
13
|
-
|
|
14
|
-
Use with SQLAlchemy's async engine:
|
|
15
|
-
create_async_engine("dqlite+aio://host:port/database")
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
name = "dqlite"
|
|
19
|
-
driver = "dqlitedbapi_aio"
|
|
20
|
-
is_async = True
|
|
21
|
-
|
|
22
|
-
# dqlite uses qmark parameter style
|
|
23
|
-
paramstyle = "qmark"
|
|
24
|
-
|
|
25
|
-
@classmethod
|
|
26
|
-
def get_pool_class(cls, url: URL) -> type[pool.Pool]:
|
|
27
|
-
return AsyncAdaptedQueuePool
|
|
28
|
-
|
|
29
|
-
@classmethod
|
|
30
|
-
def import_dbapi(cls) -> Any:
|
|
31
|
-
from dqlitedbapi import aio
|
|
32
|
-
|
|
33
|
-
return aio
|
|
34
|
-
|
|
35
|
-
def create_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
|
|
36
|
-
"""Create connection arguments from URL.
|
|
37
|
-
|
|
38
|
-
URL format: dqlite+aio://host:port/database
|
|
39
|
-
"""
|
|
40
|
-
host = url.host or "localhost"
|
|
41
|
-
port = url.port or 9001
|
|
42
|
-
database = url.database or "default"
|
|
43
|
-
|
|
44
|
-
address = f"{host}:{port}"
|
|
45
|
-
|
|
46
|
-
return [], {
|
|
47
|
-
"address": address,
|
|
48
|
-
"database": database,
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
def get_driver_connection(self, connection: Any) -> Any:
|
|
52
|
-
"""Return the driver-level connection."""
|
|
53
|
-
return connection
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# Register the dialect
|
|
57
|
-
dialect = DqliteDialect_aio
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/integration/test_orm_operations.py
RENAMED
|
File without changes
|