sqlakit 0.1.0__py3-none-any.whl
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.
- sqlakit/__init__.py +99 -0
- sqlakit/_base.py +995 -0
- sqlakit/_db.py +540 -0
- sqlakit/_discovery.py +79 -0
- sqlakit/_model.py +275 -0
- sqlakit/_query.py +1156 -0
- sqlakit/_recording.py +385 -0
- sqlakit/_registry.py +69 -0
- sqlakit/_routing.py +43 -0
- sqlakit/_sql.py +408 -0
- sqlakit/asyncio/__init__.py +4 -0
- sqlakit/asyncio/_db.py +552 -0
- sqlakit/asyncio/_registry.py +57 -0
- sqlakit/asyncio/orm.py +662 -0
- sqlakit/asyncio/sql.py +222 -0
- sqlakit/exceptions.py +423 -0
- sqlakit/orm.py +645 -0
- sqlakit/py.typed +0 -0
- sqlakit/sql.py +211 -0
- sqlakit/testing.py +91 -0
- sqlakit/types.py +104 -0
- sqlakit-0.1.0.dist-info/METADATA +408 -0
- sqlakit-0.1.0.dist-info/RECORD +25 -0
- sqlakit-0.1.0.dist-info/WHEEL +4 -0
- sqlakit-0.1.0.dist-info/licenses/LICENSE +21 -0
sqlakit/asyncio/_db.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import itertools
|
|
6
|
+
import logging
|
|
7
|
+
from contextlib import (
|
|
8
|
+
AbstractAsyncContextManager,
|
|
9
|
+
AsyncContextDecorator,
|
|
10
|
+
AsyncExitStack,
|
|
11
|
+
asynccontextmanager,
|
|
12
|
+
)
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Self, TypeVar, cast, overload
|
|
15
|
+
|
|
16
|
+
import sqlalchemy as sa
|
|
17
|
+
import sqlalchemy.exc
|
|
18
|
+
from sqlalchemy.ext.asyncio import (
|
|
19
|
+
AsyncConnection,
|
|
20
|
+
AsyncEngine,
|
|
21
|
+
AsyncSession,
|
|
22
|
+
async_sessionmaker,
|
|
23
|
+
create_async_engine,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from sqlakit._base import (
|
|
27
|
+
BaseDatabase,
|
|
28
|
+
BaseRetryingTransaction,
|
|
29
|
+
default_backoff,
|
|
30
|
+
fix_sqlite_transactions,
|
|
31
|
+
)
|
|
32
|
+
from sqlakit.exceptions import TransactionRolledBackError
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import AsyncIterator, Callable, Coroutine, Sequence
|
|
36
|
+
from types import TracebackType
|
|
37
|
+
|
|
38
|
+
from sqlalchemy.ext.asyncio import AsyncTransaction
|
|
39
|
+
|
|
40
|
+
from sqlakit._base import RetryOn, _Scope
|
|
41
|
+
|
|
42
|
+
from .orm import Query
|
|
43
|
+
from .sql import SQL
|
|
44
|
+
|
|
45
|
+
ModelT = TypeVar("ModelT")
|
|
46
|
+
_FuncT = TypeVar("_FuncT", bound="Callable[..., Any]")
|
|
47
|
+
|
|
48
|
+
__all__ = ["Database", "RetryingTransaction", "Transaction"]
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger("sqlakit")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Database(BaseDatabase[AsyncConnection, AsyncSession]):
|
|
54
|
+
"""The asyncio counterpart of [`sqlakit.Database`][sqlakit.Database].
|
|
55
|
+
|
|
56
|
+
Scoping is the same: connections and sessions live in the context, tasks
|
|
57
|
+
inherit them, transactions nest the same way. Every method that touches the
|
|
58
|
+
database is awaited.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
_engine: AsyncEngine | None = None
|
|
62
|
+
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
|
63
|
+
|
|
64
|
+
@cached_property
|
|
65
|
+
def sql(self) -> SQL:
|
|
66
|
+
"""The SQL templates of this database.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
db = Database(DB_URL, templates="app/sql")
|
|
70
|
+
|
|
71
|
+
await db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Templates need `sqlakit[sql]`, and nothing else here does, so the layer is
|
|
75
|
+
imported when it is first reached.
|
|
76
|
+
"""
|
|
77
|
+
# Here rather than at the top, so that `import sqlakit` stays free of
|
|
78
|
+
# the optional layer and of what it imports.
|
|
79
|
+
from .sql import SQL # noqa: PLC0415
|
|
80
|
+
|
|
81
|
+
return SQL(self)
|
|
82
|
+
|
|
83
|
+
def query(self, model: type[ModelT]) -> Query[ModelT]:
|
|
84
|
+
"""Build a query over a mapped class, on this database.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
await db.query(User).where(User.is_active).order_by("name").page(limit=20)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Any declarative class works, with no model layer under it. A model
|
|
91
|
+
that has one reaches the same builder as `User.query`, on the database
|
|
92
|
+
the model belongs to.
|
|
93
|
+
"""
|
|
94
|
+
# Here rather than at the top: `orm` imports this module.
|
|
95
|
+
from .orm import Query # noqa: PLC0415
|
|
96
|
+
|
|
97
|
+
return Query(model, self)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def engine(self) -> AsyncEngine:
|
|
101
|
+
"""The underlying engine, created on first access."""
|
|
102
|
+
if self._engine is None:
|
|
103
|
+
with self._engine_lock:
|
|
104
|
+
# Two threads reaching this at once would each build one, and
|
|
105
|
+
# the pool of the loser would never be disposed.
|
|
106
|
+
if self._engine is None:
|
|
107
|
+
self._engine = self._create_engine()
|
|
108
|
+
return self._engine
|
|
109
|
+
|
|
110
|
+
def _create_engine(self) -> AsyncEngine:
|
|
111
|
+
engine = create_async_engine(self.url, **self.engine_args)
|
|
112
|
+
fix_sqlite_transactions(engine.sync_engine)
|
|
113
|
+
return engine
|
|
114
|
+
|
|
115
|
+
def _create_session(self, connection: AsyncConnection) -> AsyncSession:
|
|
116
|
+
if self._sessionmaker is None:
|
|
117
|
+
self._sessionmaker = async_sessionmaker(**self.session_args)
|
|
118
|
+
return self._sessionmaker(
|
|
119
|
+
bind=connection,
|
|
120
|
+
**self._session_args_for(connection),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@asynccontextmanager
|
|
124
|
+
async def connect(self) -> AsyncIterator[AsyncConnection]:
|
|
125
|
+
"""Open a connection and bind it, or reuse the one already bound."""
|
|
126
|
+
outer = self._connection_to_reuse()
|
|
127
|
+
if outer is not None:
|
|
128
|
+
async with self._bound(outer) as connection:
|
|
129
|
+
yield connection
|
|
130
|
+
return
|
|
131
|
+
async with self.engine.connect() as opened, self._bound(opened) as connection:
|
|
132
|
+
yield connection
|
|
133
|
+
|
|
134
|
+
@overload
|
|
135
|
+
def autocommit(self, func: _FuncT) -> _FuncT: ...
|
|
136
|
+
|
|
137
|
+
@overload
|
|
138
|
+
def autocommit(
|
|
139
|
+
self,
|
|
140
|
+
func: None = None,
|
|
141
|
+
) -> AbstractAsyncContextManager[AsyncConnection]: ...
|
|
142
|
+
|
|
143
|
+
def autocommit(
|
|
144
|
+
self,
|
|
145
|
+
func: _FuncT | None = None,
|
|
146
|
+
) -> _FuncT | AbstractAsyncContextManager[AsyncConnection]:
|
|
147
|
+
"""Run in ``AUTOCOMMIT``, where every statement commits on its own.
|
|
148
|
+
|
|
149
|
+
For read-only work, which has nothing to commit, and for statements that
|
|
150
|
+
cannot run inside a transaction: ``VACUUM``, ``CREATE DATABASE``,
|
|
151
|
+
``CREATE INDEX CONCURRENTLY``. Inside a transaction it joins that one, since
|
|
152
|
+
there is nothing else to commit into.
|
|
153
|
+
|
|
154
|
+
A context manager and a decorator, with or without parentheses.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
func: The function to decorate, when used as a bare decorator.
|
|
158
|
+
|
|
159
|
+
"""
|
|
160
|
+
autocommit = self._autocommit()
|
|
161
|
+
if func is not None:
|
|
162
|
+
return autocommit(func)
|
|
163
|
+
return autocommit
|
|
164
|
+
|
|
165
|
+
@asynccontextmanager
|
|
166
|
+
async def _autocommit(self) -> AsyncIterator[AsyncConnection]:
|
|
167
|
+
"""Open a connection in ``AUTOCOMMIT`` and bind it, or join the outer one."""
|
|
168
|
+
outer = self._connection_to_join()
|
|
169
|
+
if outer is not None:
|
|
170
|
+
async with self._bound(outer, commit=True) as connection:
|
|
171
|
+
yield connection
|
|
172
|
+
return
|
|
173
|
+
async with self.engine.connect() as opened:
|
|
174
|
+
await opened.execution_options(isolation_level="AUTOCOMMIT")
|
|
175
|
+
with self._set_outer(None):
|
|
176
|
+
async with self._bound(opened, commit=True) as connection:
|
|
177
|
+
yield connection
|
|
178
|
+
|
|
179
|
+
@overload
|
|
180
|
+
def transaction(self, func: _FuncT) -> _FuncT: ...
|
|
181
|
+
|
|
182
|
+
@overload
|
|
183
|
+
def transaction(
|
|
184
|
+
self,
|
|
185
|
+
func: None = None,
|
|
186
|
+
*,
|
|
187
|
+
savepoint: bool = False,
|
|
188
|
+
join_nested: bool = True,
|
|
189
|
+
rollback: bool = False,
|
|
190
|
+
commit_on_error: type[BaseException]
|
|
191
|
+
| tuple[type[BaseException], ...]
|
|
192
|
+
| None = None,
|
|
193
|
+
retry_on: None = None,
|
|
194
|
+
max_retries: int = 3,
|
|
195
|
+
backoff: Callable[[int], float] = default_backoff,
|
|
196
|
+
) -> Transaction: ...
|
|
197
|
+
|
|
198
|
+
@overload
|
|
199
|
+
def transaction(
|
|
200
|
+
self,
|
|
201
|
+
func: None = None,
|
|
202
|
+
*,
|
|
203
|
+
savepoint: bool = False,
|
|
204
|
+
join_nested: bool = True,
|
|
205
|
+
rollback: bool = False,
|
|
206
|
+
commit_on_error: type[BaseException]
|
|
207
|
+
| tuple[type[BaseException], ...]
|
|
208
|
+
| None = None,
|
|
209
|
+
retry_on: RetryOn,
|
|
210
|
+
max_retries: int = 3,
|
|
211
|
+
backoff: Callable[[int], float] = default_backoff,
|
|
212
|
+
) -> RetryingTransaction: ...
|
|
213
|
+
|
|
214
|
+
def transaction( # noqa: PLR0913 (all keyword-only; this is the main API)
|
|
215
|
+
self,
|
|
216
|
+
func: _FuncT | None = None,
|
|
217
|
+
*,
|
|
218
|
+
savepoint: bool = False,
|
|
219
|
+
join_nested: bool = True,
|
|
220
|
+
rollback: bool = False,
|
|
221
|
+
commit_on_error: type[BaseException]
|
|
222
|
+
| tuple[type[BaseException], ...]
|
|
223
|
+
| None = None,
|
|
224
|
+
retry_on: RetryOn | None = None,
|
|
225
|
+
max_retries: int = 3,
|
|
226
|
+
backoff: Callable[[int], float] = default_backoff,
|
|
227
|
+
) -> _FuncT | Transaction | RetryingTransaction:
|
|
228
|
+
"""Run a transaction on a connection bound to the current context.
|
|
229
|
+
|
|
230
|
+
Commits when the block exits, rolls back if it raises. Inside another
|
|
231
|
+
transaction it takes part in that one rather than opening a second
|
|
232
|
+
connection: the outermost block commits.
|
|
233
|
+
|
|
234
|
+
A context manager and a decorator, the latter with or without parentheses:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
async with db.transaction():
|
|
238
|
+
...
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@db.transaction
|
|
242
|
+
async def import_users() -> None: ...
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
func: The function to decorate, when used as a bare decorator.
|
|
247
|
+
savepoint: Run as a savepoint when nested, so this block can fail and be
|
|
248
|
+
rolled back on its own, and the blocks below it with it. Off by
|
|
249
|
+
default: a savepoint costs a round trip.
|
|
250
|
+
join_nested: Whether blocks below reuse this connection. Turn it off to
|
|
251
|
+
let them reach the database on their own, seeing nothing of this
|
|
252
|
+
block and surviving its rollback.
|
|
253
|
+
rollback: Roll back on the way out rather than commit. Implies
|
|
254
|
+
``savepoint``, and is what wraps a test.
|
|
255
|
+
commit_on_error: Exception types whose escape still commits. The
|
|
256
|
+
exception propagates; what was written before it stays.
|
|
257
|
+
retry_on: Exception types, or a predicate over the exception, worth
|
|
258
|
+
another attempt. Decorator only: retrying re-runs the block, so this
|
|
259
|
+
returns a [`RetryingTransaction`][sqlakit.asyncio.RetryingTransaction] that
|
|
260
|
+
type checkers refuse to
|
|
261
|
+
enter. Only the block that owns the transaction retries.
|
|
262
|
+
max_retries: How many further attempts ``retry_on`` may buy.
|
|
263
|
+
backoff: Seconds to wait before attempt ``n``, counted from zero.
|
|
264
|
+
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def new_transaction() -> Transaction:
|
|
268
|
+
return Transaction(
|
|
269
|
+
self,
|
|
270
|
+
savepoint=savepoint,
|
|
271
|
+
join_nested=join_nested,
|
|
272
|
+
rollback=rollback,
|
|
273
|
+
commit_on_error=commit_on_error,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
transaction: Transaction | RetryingTransaction = new_transaction()
|
|
277
|
+
if retry_on is not None:
|
|
278
|
+
transaction = RetryingTransaction(
|
|
279
|
+
new_transaction,
|
|
280
|
+
retry_on=retry_on,
|
|
281
|
+
max_retries=max_retries,
|
|
282
|
+
backoff=backoff,
|
|
283
|
+
)
|
|
284
|
+
if func is not None:
|
|
285
|
+
return transaction(func)
|
|
286
|
+
return transaction
|
|
287
|
+
|
|
288
|
+
@asynccontextmanager
|
|
289
|
+
async def session_factory(self) -> AsyncIterator[AsyncSession]:
|
|
290
|
+
"""Open a new connection and a session on top of it, and bind both."""
|
|
291
|
+
async with self.connect():
|
|
292
|
+
yield self.session
|
|
293
|
+
|
|
294
|
+
@asynccontextmanager
|
|
295
|
+
async def _bound(
|
|
296
|
+
self,
|
|
297
|
+
connection: AsyncConnection,
|
|
298
|
+
*,
|
|
299
|
+
commit: bool = False,
|
|
300
|
+
) -> AsyncIterator[AsyncConnection]:
|
|
301
|
+
"""Bind ``connection`` for the block, ending the session it opened.
|
|
302
|
+
|
|
303
|
+
``commit`` keeps that session's work. Closing a session that isolates
|
|
304
|
+
itself with a savepoint rolls back to it, discarding what the blocks
|
|
305
|
+
below committed.
|
|
306
|
+
"""
|
|
307
|
+
with self._bind(connection) as scope:
|
|
308
|
+
done = False
|
|
309
|
+
try:
|
|
310
|
+
yield connection
|
|
311
|
+
done = True
|
|
312
|
+
finally:
|
|
313
|
+
if scope.session is not None:
|
|
314
|
+
if commit and done:
|
|
315
|
+
await scope.session.commit()
|
|
316
|
+
await scope.session.close()
|
|
317
|
+
|
|
318
|
+
@asynccontextmanager
|
|
319
|
+
async def provisioned_tables(
|
|
320
|
+
self,
|
|
321
|
+
metadata: sa.MetaData,
|
|
322
|
+
*,
|
|
323
|
+
tables: Sequence[sa.Table] | None = None,
|
|
324
|
+
) -> AsyncIterator[None]:
|
|
325
|
+
"""Create these tables here, and drop them when the block ends.
|
|
326
|
+
|
|
327
|
+
What a test session opens once, around everything that needs a schema:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
@pytest.fixture(scope="session")
|
|
331
|
+
async def tables():
|
|
332
|
+
async with db.provisioned_tables(Model.metadata):
|
|
333
|
+
yield
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Every table of the metadata unless ``tables`` names fewer, which is what a
|
|
337
|
+
second database wants.
|
|
338
|
+
"""
|
|
339
|
+
async with self.transaction() as connection:
|
|
340
|
+
await connection.run_sync(metadata.create_all, tables=tables)
|
|
341
|
+
try:
|
|
342
|
+
yield
|
|
343
|
+
finally:
|
|
344
|
+
async with self.transaction() as connection:
|
|
345
|
+
await connection.run_sync(metadata.drop_all, tables=tables)
|
|
346
|
+
|
|
347
|
+
async def ping(self) -> bool:
|
|
348
|
+
"""Whether the database answers."""
|
|
349
|
+
try:
|
|
350
|
+
async with self.engine.connect() as connection:
|
|
351
|
+
await connection.execute(sa.text("SELECT 1"))
|
|
352
|
+
except sa.exc.SQLAlchemyError:
|
|
353
|
+
return False
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
async def dispose(self, *, close: bool = True) -> None:
|
|
357
|
+
"""Dispose of the engine and its connection pool."""
|
|
358
|
+
with self._engine_lock:
|
|
359
|
+
engine, self._engine = self._engine, None
|
|
360
|
+
if engine is not None:
|
|
361
|
+
await engine.dispose(close=close)
|
|
362
|
+
|
|
363
|
+
async def __aenter__(self) -> Self:
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
367
|
+
await self.dispose()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class Transaction(
|
|
371
|
+
AsyncContextDecorator,
|
|
372
|
+
AbstractAsyncContextManager["AsyncConnection"],
|
|
373
|
+
):
|
|
374
|
+
"""The transaction an awaited block runs, as an object.
|
|
375
|
+
|
|
376
|
+
What [`Database.transaction`][sqlakit.asyncio.Database.transaction] returns.
|
|
377
|
+
|
|
378
|
+
Kept as a class rather than a generator so that it also works as a
|
|
379
|
+
decorator, and so that a single instance can be entered more than once,
|
|
380
|
+
which is what ``@db.transaction()`` on a coroutine function does.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self,
|
|
385
|
+
db: Database,
|
|
386
|
+
*,
|
|
387
|
+
savepoint: bool = False,
|
|
388
|
+
join_nested: bool = True,
|
|
389
|
+
rollback: bool = False,
|
|
390
|
+
commit_on_error: type[BaseException]
|
|
391
|
+
| tuple[type[BaseException], ...]
|
|
392
|
+
| None = None,
|
|
393
|
+
) -> None:
|
|
394
|
+
self.db = db
|
|
395
|
+
self.savepoint = savepoint
|
|
396
|
+
self.join_nested = join_nested
|
|
397
|
+
self.rollback = rollback
|
|
398
|
+
self.commit_on_error = commit_on_error
|
|
399
|
+
self._stacks: list[AsyncExitStack] = []
|
|
400
|
+
|
|
401
|
+
def _recreate_cm(self) -> Transaction:
|
|
402
|
+
"""Give every decorated call a transaction of its own.
|
|
403
|
+
|
|
404
|
+
`AsyncContextDecorator` reuses one instance for all calls, and two of
|
|
405
|
+
them running at once would unwind each other's blocks.
|
|
406
|
+
"""
|
|
407
|
+
return Transaction(
|
|
408
|
+
self.db,
|
|
409
|
+
savepoint=self.savepoint,
|
|
410
|
+
join_nested=self.join_nested,
|
|
411
|
+
rollback=self.rollback,
|
|
412
|
+
commit_on_error=self.commit_on_error,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
async def __aenter__(self) -> AsyncConnection:
|
|
416
|
+
stack = AsyncExitStack()
|
|
417
|
+
try:
|
|
418
|
+
outer, savepoint = self.db._plan( # noqa: SLF001
|
|
419
|
+
savepoint=self.savepoint,
|
|
420
|
+
rollback=self.rollback,
|
|
421
|
+
)
|
|
422
|
+
if outer is not None:
|
|
423
|
+
connection = outer.connection
|
|
424
|
+
# Without a savepoint the block only takes part in the
|
|
425
|
+
# transaction around it, which commits it.
|
|
426
|
+
transaction = await connection.begin_nested() if savepoint else None
|
|
427
|
+
# The block's savepoint isolates it; a session opened inside
|
|
428
|
+
# must not add a second one on the same connection.
|
|
429
|
+
session_savepoint = outer.session_savepoint and not savepoint
|
|
430
|
+
else:
|
|
431
|
+
connection = await stack.enter_async_context(self.db.engine.connect())
|
|
432
|
+
transaction = await connection.begin()
|
|
433
|
+
session_savepoint = savepoint
|
|
434
|
+
# Unwound in reverse: session, context, transaction, connection.
|
|
435
|
+
stack.push_async_exit(self._finish(transaction))
|
|
436
|
+
bound = stack.enter_context(
|
|
437
|
+
self.db._set_outer( # noqa: SLF001
|
|
438
|
+
connection,
|
|
439
|
+
join_nested=self.join_nested,
|
|
440
|
+
savepoint=savepoint,
|
|
441
|
+
session_savepoint=session_savepoint,
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
scope = stack.enter_context(self.db._bind(connection)) # noqa: SLF001
|
|
445
|
+
if bound is not None:
|
|
446
|
+
bound.scope = scope
|
|
447
|
+
stack.push_async_exit(self._close_session(scope))
|
|
448
|
+
except BaseException:
|
|
449
|
+
await stack.aclose()
|
|
450
|
+
raise
|
|
451
|
+
self._stacks.append(stack)
|
|
452
|
+
return connection
|
|
453
|
+
|
|
454
|
+
async def __aexit__(
|
|
455
|
+
self,
|
|
456
|
+
exc_type: type[BaseException] | None,
|
|
457
|
+
exc: BaseException | None,
|
|
458
|
+
traceback: TracebackType | None,
|
|
459
|
+
) -> None:
|
|
460
|
+
await self._stacks.pop().__aexit__(exc_type, exc, traceback)
|
|
461
|
+
|
|
462
|
+
def _close_session(
|
|
463
|
+
self,
|
|
464
|
+
scope: _Scope[AsyncConnection, AsyncSession],
|
|
465
|
+
) -> Callable[..., Coroutine[None, None, None]]:
|
|
466
|
+
"""Commit the block's session, if it opened one, then close it.
|
|
467
|
+
|
|
468
|
+
The block is the unit of work, so what its session holds belongs to the
|
|
469
|
+
transaction; closing first would roll it back.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
async def close_session(
|
|
473
|
+
_exc_type: object,
|
|
474
|
+
exc: BaseException | None,
|
|
475
|
+
_traceback: object,
|
|
476
|
+
) -> None:
|
|
477
|
+
if scope.session is None:
|
|
478
|
+
return
|
|
479
|
+
if self._keeps(exc):
|
|
480
|
+
await scope.session.commit()
|
|
481
|
+
await scope.session.close()
|
|
482
|
+
|
|
483
|
+
return close_session
|
|
484
|
+
|
|
485
|
+
def _finish(
|
|
486
|
+
self,
|
|
487
|
+
transaction: AsyncTransaction | None,
|
|
488
|
+
) -> Callable[..., Coroutine[None, None, None]]:
|
|
489
|
+
"""Commit or roll back, unless this block only takes part in another."""
|
|
490
|
+
|
|
491
|
+
async def finish(
|
|
492
|
+
_exc_type: object,
|
|
493
|
+
exc: BaseException | None,
|
|
494
|
+
_traceback: object,
|
|
495
|
+
) -> None:
|
|
496
|
+
if transaction is None:
|
|
497
|
+
return
|
|
498
|
+
if not transaction.is_active:
|
|
499
|
+
# Rolled back from inside the block. Say so, unless an
|
|
500
|
+
# exception is already on its way out with the reason.
|
|
501
|
+
if exc is None:
|
|
502
|
+
raise TransactionRolledBackError
|
|
503
|
+
return
|
|
504
|
+
if self._keeps(exc) and not self.rollback:
|
|
505
|
+
await transaction.commit()
|
|
506
|
+
else:
|
|
507
|
+
await transaction.rollback()
|
|
508
|
+
|
|
509
|
+
return finish
|
|
510
|
+
|
|
511
|
+
def _keeps(self, exc: BaseException | None) -> bool:
|
|
512
|
+
"""Whether the block's work is kept rather than undone."""
|
|
513
|
+
if exc is None:
|
|
514
|
+
return True
|
|
515
|
+
return self.commit_on_error is not None and isinstance(
|
|
516
|
+
exc, self.commit_on_error
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class RetryingTransaction(BaseRetryingTransaction):
|
|
521
|
+
"""A transaction that runs its block again.
|
|
522
|
+
|
|
523
|
+
What [`Database.transaction`][sqlakit.asyncio.Database.transaction] returns
|
|
524
|
+
when it is given ``retry_on``.
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
def __call__(self, func: _FuncT) -> _FuncT:
|
|
528
|
+
@functools.wraps(func)
|
|
529
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
530
|
+
transaction = self.transaction()
|
|
531
|
+
if transaction.db.in_transaction():
|
|
532
|
+
# Only the block that owns the transaction can restart it: a
|
|
533
|
+
# retry inside would keep the snapshot that caused the
|
|
534
|
+
# conflict, and the outer transaction fails anyway.
|
|
535
|
+
logger.debug(
|
|
536
|
+
"%s runs inside another transaction; retrying is up to "
|
|
537
|
+
"whoever opened it.",
|
|
538
|
+
getattr(func, "__qualname__", func),
|
|
539
|
+
)
|
|
540
|
+
async with transaction:
|
|
541
|
+
return await func(*args, **kwargs)
|
|
542
|
+
for attempt in itertools.count():
|
|
543
|
+
try:
|
|
544
|
+
async with self.transaction():
|
|
545
|
+
return await func(*args, **kwargs)
|
|
546
|
+
except Exception as exc:
|
|
547
|
+
if not self._retry(exc, attempt=attempt):
|
|
548
|
+
raise
|
|
549
|
+
await asyncio.sleep(self.backoff(attempt))
|
|
550
|
+
raise AssertionError # pragma: no cover - the loop returns or raises
|
|
551
|
+
|
|
552
|
+
return cast("_FuncT", wrapper)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from sqlakit._base import _DatabaseRegistryMixin
|
|
7
|
+
|
|
8
|
+
from ._db import Database
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
|
|
13
|
+
__all__ = ["Databases", "db"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Databases(_DatabaseRegistryMixin[Database], Database):
|
|
17
|
+
"""The databases an application talks to, and the default one among them.
|
|
18
|
+
|
|
19
|
+
The asyncio counterpart of [`sqlakit.Databases`][sqlakit.Databases].
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from sqlakit.asyncio import db
|
|
23
|
+
|
|
24
|
+
db.configure(DB_URL)
|
|
25
|
+
|
|
26
|
+
db.session # the default database
|
|
27
|
+
db["replica"].session # another, if one was configured
|
|
28
|
+
```
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_database_class = Database
|
|
32
|
+
|
|
33
|
+
@asynccontextmanager
|
|
34
|
+
async def transactions(
|
|
35
|
+
self,
|
|
36
|
+
**arguments: Any, # noqa: ANN401
|
|
37
|
+
) -> AsyncIterator[None]:
|
|
38
|
+
"""Open a transaction on every database, not the default one alone.
|
|
39
|
+
|
|
40
|
+
What a test harness with more than one database needs, in the place a
|
|
41
|
+
single database needs `transaction(rollback=True)`.
|
|
42
|
+
"""
|
|
43
|
+
async with AsyncExitStack() as stack:
|
|
44
|
+
for alias in self.aliases:
|
|
45
|
+
await stack.enter_async_context(self[alias].transaction(**arguments))
|
|
46
|
+
yield
|
|
47
|
+
|
|
48
|
+
async def dispose(self, *, close: bool = True) -> None:
|
|
49
|
+
"""Dispose of every configured database, not just the default one."""
|
|
50
|
+
if self.is_configured:
|
|
51
|
+
await super().dispose(close=close)
|
|
52
|
+
for db in self._aliased.values():
|
|
53
|
+
await db.dispose(close=close)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
db = Databases()
|
|
57
|
+
"""The database an application talks to. Configure it once, use it anywhere."""
|