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.
Files changed (20) hide show
  1. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/PKG-INFO +1 -1
  2. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/pyproject.toml +1 -1
  3. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/src/sqlalchemydqlite/__init__.py +1 -1
  4. sqlalchemy_dqlite-0.1.2/src/sqlalchemydqlite/aio.py +199 -0
  5. sqlalchemy_dqlite-0.1.2/tests/integration/test_async_context.py +28 -0
  6. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/test_dialect.py +19 -0
  7. sqlalchemy_dqlite-0.1.0/src/sqlalchemydqlite/aio.py +0 -57
  8. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/.github/workflows/publish-to-pypi.yml +0 -0
  9. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/.gitignore +0 -0
  10. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/DEVELOPMENT.md +0 -0
  11. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/LICENSE.md +0 -0
  12. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/README.md +0 -0
  13. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/src/sqlalchemydqlite/base.py +0 -0
  14. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/src/sqlalchemydqlite/py.typed +0 -0
  15. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/src/sqlalchemydqlite/requirements.py +0 -0
  16. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/__init__.py +0 -0
  17. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/conftest.py +0 -0
  18. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/integration/__init__.py +0 -0
  19. {sqlalchemy_dqlite-0.1.0 → sqlalchemy_dqlite-0.1.2}/tests/integration/conftest.py +0 -0
  20. {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.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlalchemy-dqlite"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "SQLAlchemy 2.0 dialect for dqlite distributed SQLite"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -4,4 +4,4 @@ from sqlalchemydqlite.base import DqliteDialect
4
4
 
5
5
  __all__ = ["DqliteDialect"]
6
6
 
7
- __version__ = "0.1.0"
7
+ __version__ = "0.1.2"
@@ -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