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/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."""