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