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/_base.py
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from contextlib import ExitStack, contextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import (
|
|
11
|
+
TYPE_CHECKING,
|
|
12
|
+
Any,
|
|
13
|
+
Generic,
|
|
14
|
+
Self,
|
|
15
|
+
TypeVar,
|
|
16
|
+
cast,
|
|
17
|
+
overload,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
import sqlalchemy as sa
|
|
21
|
+
import sqlalchemy.event
|
|
22
|
+
from typing_extensions import Unpack
|
|
23
|
+
|
|
24
|
+
from ._discovery import import_string
|
|
25
|
+
from ._recording import (
|
|
26
|
+
Recording,
|
|
27
|
+
Statement,
|
|
28
|
+
caller_stack,
|
|
29
|
+
check,
|
|
30
|
+
require_expectation,
|
|
31
|
+
)
|
|
32
|
+
from ._routing import Router, as_router
|
|
33
|
+
from .exceptions import (
|
|
34
|
+
DEFAULT_ALIAS,
|
|
35
|
+
ConflictingDatabaseUrlError,
|
|
36
|
+
DatabaseAlreadyConfiguredError,
|
|
37
|
+
DatabaseNotConfiguredError,
|
|
38
|
+
MissingConnectionError,
|
|
39
|
+
MissingDatabaseUrlError,
|
|
40
|
+
MissingDefaultDatabaseError,
|
|
41
|
+
MissingSessionError,
|
|
42
|
+
RetryNotSupportedError,
|
|
43
|
+
UnknownDatabaseError,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
import logging
|
|
48
|
+
from collections.abc import Iterator, Sequence
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
from sqlalchemy.engine import Engine
|
|
52
|
+
|
|
53
|
+
from ._sql import Templates
|
|
54
|
+
from .types import DatabaseConfig, EngineArgs, SessionArgs, UrlParts
|
|
55
|
+
|
|
56
|
+
RouterFunction = Callable[[type[Any]], str | None]
|
|
57
|
+
TemplatesLike = str | Path | Sequence[str | Path] | Templates
|
|
58
|
+
"""Where a database's SQL templates are: a path, several, or the object."""
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"DEFAULT_ALIAS",
|
|
62
|
+
"DEFAULT_ENGINE_ARGS",
|
|
63
|
+
"DEFAULT_SESSION_ARGS",
|
|
64
|
+
"BaseDatabase",
|
|
65
|
+
"BaseRetryingTransaction",
|
|
66
|
+
"RetryOn",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
DEFAULT_ENGINE_ARGS: EngineArgs = {
|
|
70
|
+
# Catch connections dropped by the server, a proxy or a failover.
|
|
71
|
+
"pool_pre_ping": True,
|
|
72
|
+
# Reopen before the idle timeouts of MySQL, PgBouncer and cloud balancers.
|
|
73
|
+
"pool_recycle": 1800,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
DEFAULT_SESSION_ARGS: SessionArgs = {
|
|
77
|
+
# Attributes stay readable after a commit. Under asyncio the lazy SELECT
|
|
78
|
+
# that expiry would trigger fails with MissingGreenlet.
|
|
79
|
+
"expire_on_commit": False,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
RetryOn = (
|
|
84
|
+
type[BaseException]
|
|
85
|
+
| tuple[type[BaseException], ...]
|
|
86
|
+
| Callable[[BaseException], bool]
|
|
87
|
+
)
|
|
88
|
+
"""Exception types, or a predicate over the exception."""
|
|
89
|
+
|
|
90
|
+
_random = random.SystemRandom()
|
|
91
|
+
|
|
92
|
+
ConnectionT = TypeVar("ConnectionT")
|
|
93
|
+
SessionT = TypeVar("SessionT")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(slots=True)
|
|
97
|
+
class _Scope(Generic[ConnectionT, SessionT]):
|
|
98
|
+
"""The connection bound to a context, and the session opened on top of it.
|
|
99
|
+
|
|
100
|
+
The context variable holds this object, not the session, so a session
|
|
101
|
+
opened later, including in a task that copies the context, still
|
|
102
|
+
belongs to the block that bound the connection.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
connection: ConnectionT
|
|
106
|
+
session: SessionT | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(slots=True)
|
|
110
|
+
class _Outer(Generic[ConnectionT]):
|
|
111
|
+
"""The connection of the innermost transaction bound to a context.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
connection: What blocks below reuse, with ``join_nested``.
|
|
115
|
+
join_nested: Whether blocks below reuse that connection.
|
|
116
|
+
savepoint: Whether nested blocks run as savepoints.
|
|
117
|
+
session_savepoint: Whether this block's session needs a savepoint of its
|
|
118
|
+
own. Two savepoint owners on one connection release each other's out
|
|
119
|
+
of order, so only one may have it.
|
|
120
|
+
scope: The block's own scope, whose session the savepoint is for.
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
connection: ConnectionT
|
|
125
|
+
join_nested: bool = True
|
|
126
|
+
savepoint: bool = False
|
|
127
|
+
session_savepoint: bool = False
|
|
128
|
+
scope: Any = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
132
|
+
"""What the sync and async databases share: everything that is not IO.
|
|
133
|
+
|
|
134
|
+
Binding to the context lives here. Opening and closing connections and
|
|
135
|
+
sessions is left to the subclass.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
_engine: Any = None # narrowed by the subclass
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
url: str | sa.URL | None = None,
|
|
143
|
+
engine_args: EngineArgs | None = None,
|
|
144
|
+
session_args: SessionArgs | None = None,
|
|
145
|
+
templates: TemplatesLike | None = None,
|
|
146
|
+
**parts: Unpack[UrlParts],
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Build a database on ``url``, or on the parts to make one from.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
Database("postgresql+psycopg://localhost/app")
|
|
152
|
+
|
|
153
|
+
Database(
|
|
154
|
+
drivername="postgresql+psycopg",
|
|
155
|
+
host="localhost",
|
|
156
|
+
database="app",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
Database(DB_URL, templates="app/sql") # where `sql` reads templates from
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
MissingDatabaseUrlError: if given neither a ``url`` nor the parts to
|
|
164
|
+
build one.
|
|
165
|
+
ConflictingDatabaseUrlError: if given both. Either is an
|
|
166
|
+
``InvalidDatabaseConfigError``.
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
config = cast("DatabaseConfig", dict(parts))
|
|
170
|
+
if url is not None:
|
|
171
|
+
config["url"] = url
|
|
172
|
+
self.url = sa.make_url(url_from_config(config))
|
|
173
|
+
self.templates = templates
|
|
174
|
+
self.engine_args = DEFAULT_ENGINE_ARGS | (engine_args or {})
|
|
175
|
+
self.session_args = DEFAULT_SESSION_ARGS | (session_args or {})
|
|
176
|
+
# Dropped so that reconfiguring does not keep sessions built from the
|
|
177
|
+
# arguments of the previous configuration.
|
|
178
|
+
self._sessionmaker = None
|
|
179
|
+
self._engine_lock = threading.Lock()
|
|
180
|
+
self._scope: ContextVar[_Scope[ConnectionT, SessionT]] = ContextVar(
|
|
181
|
+
f"{type(self).__name__}.scope"
|
|
182
|
+
)
|
|
183
|
+
self._outer: ContextVar[_Outer[ConnectionT] | None] = ContextVar(
|
|
184
|
+
f"{type(self).__name__}.outer"
|
|
185
|
+
)
|
|
186
|
+
self._recordings: ContextVar[tuple[Recording, ...]] = ContextVar(
|
|
187
|
+
f"{type(self).__name__}.recordings", default=()
|
|
188
|
+
)
|
|
189
|
+
self._stacks: ContextVar[bool] = ContextVar(
|
|
190
|
+
f"{type(self).__name__}.stacks", default=False
|
|
191
|
+
)
|
|
192
|
+
self._listening = 0
|
|
193
|
+
self._listening_lock = threading.Lock()
|
|
194
|
+
self._name = DEFAULT_ALIAS
|
|
195
|
+
|
|
196
|
+
def __repr__(self) -> str:
|
|
197
|
+
return f"{type(self).__name__}({self.url.render_as_string()!r})"
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def connection(self) -> ConnectionT:
|
|
201
|
+
"""The connection bound to the current context.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
MissingConnectionError: if no connection is bound.
|
|
205
|
+
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
return self._scope.get().connection
|
|
209
|
+
except LookupError:
|
|
210
|
+
raise MissingConnectionError from None
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def session(self) -> SessionT:
|
|
214
|
+
"""The session bound to the current context.
|
|
215
|
+
|
|
216
|
+
Opened on first use, on the current
|
|
217
|
+
[`connection`][sqlakit.Database.connection], and closed when that block
|
|
218
|
+
exits.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
MissingSessionError: if no connection is bound.
|
|
222
|
+
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
scope = self._scope.get()
|
|
226
|
+
except LookupError:
|
|
227
|
+
raise MissingSessionError from None
|
|
228
|
+
if scope.session is None:
|
|
229
|
+
scope.session = self._create_session(scope.connection)
|
|
230
|
+
return scope.session
|
|
231
|
+
|
|
232
|
+
@contextmanager
|
|
233
|
+
def recording(
|
|
234
|
+
self,
|
|
235
|
+
label: str | None = None,
|
|
236
|
+
*,
|
|
237
|
+
logger: logging.Logger | None = None,
|
|
238
|
+
echo: bool = False,
|
|
239
|
+
stacks: bool = False,
|
|
240
|
+
into: Recording | None = None,
|
|
241
|
+
) -> Iterator[Recording]:
|
|
242
|
+
"""Record the statements of this block, and what they add up to.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
logger = logging.getLogger(__name__)
|
|
246
|
+
|
|
247
|
+
with db.recording("GET /users", logger=logger) as record:
|
|
248
|
+
build_report()
|
|
249
|
+
|
|
250
|
+
record.count, record.duplicates, record.slowest
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
``logger`` writes a summary when the block ends, at a level the numbers
|
|
254
|
+
choose. ``echo`` prints the statements instead, coloured where `rich` is
|
|
255
|
+
installed. ``stacks`` has every statement remember the frames that led to it,
|
|
256
|
+
at the cost of a stack walk each time.
|
|
257
|
+
|
|
258
|
+
Blocks nest, each recording what runs inside it, and the listeners come off
|
|
259
|
+
after. `with` is right on either side, awaited or not: it listens, it does
|
|
260
|
+
not run anything.
|
|
261
|
+
"""
|
|
262
|
+
recording = Recording(label=label) if into is None else into
|
|
263
|
+
self._listen()
|
|
264
|
+
recordings = self._recordings.set((*self._recordings.get(), recording))
|
|
265
|
+
asked = self._stacks.set(stacks or self._stacks.get())
|
|
266
|
+
try:
|
|
267
|
+
yield recording
|
|
268
|
+
finally:
|
|
269
|
+
self._stacks.reset(asked)
|
|
270
|
+
self._recordings.reset(recordings)
|
|
271
|
+
self._silence()
|
|
272
|
+
if logger is not None:
|
|
273
|
+
recording.log(logger)
|
|
274
|
+
if echo:
|
|
275
|
+
recording.echo()
|
|
276
|
+
|
|
277
|
+
@contextmanager
|
|
278
|
+
def assert_queries(
|
|
279
|
+
self,
|
|
280
|
+
count: int | None = None,
|
|
281
|
+
*,
|
|
282
|
+
at_most: int | None = None,
|
|
283
|
+
duplicates: bool = True,
|
|
284
|
+
) -> Iterator[Recording]:
|
|
285
|
+
"""Assert what the block asks of this database.
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
with db.assert_queries(2):
|
|
289
|
+
User.query.order_by("name").page(limit=10)
|
|
290
|
+
|
|
291
|
+
with db.assert_queries(at_most=5):
|
|
292
|
+
render(dashboard)
|
|
293
|
+
|
|
294
|
+
with db.assert_queries(duplicates=False):
|
|
295
|
+
render(users) # the N+1 test, without a number
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The three checks stand alone or together. What fails prints the statements,
|
|
299
|
+
numbered and timed, with the repeated ones pointing at each other.
|
|
300
|
+
|
|
301
|
+
This watches one database. `sqlakit.testing.assert_queries` watches the
|
|
302
|
+
importable registries instead, or whichever database it is given.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
count: The statements the block is expected to run.
|
|
306
|
+
at_most: A ceiling, for a number that would be brittle.
|
|
307
|
+
duplicates: Whether a statement may run more than once.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
TypeError: if there is nothing to assert.
|
|
311
|
+
|
|
312
|
+
"""
|
|
313
|
+
require_expectation(count, at_most, duplicates)
|
|
314
|
+
with self.recording() as recording:
|
|
315
|
+
yield recording
|
|
316
|
+
check(recording, count=count, at_most=at_most, duplicates=duplicates)
|
|
317
|
+
|
|
318
|
+
def _listen(self) -> None:
|
|
319
|
+
with self._listening_lock:
|
|
320
|
+
if self._listening == 0:
|
|
321
|
+
engine = getattr(self.engine, "sync_engine", self.engine)
|
|
322
|
+
sa.event.listen(engine, "before_cursor_execute", self._statement_began)
|
|
323
|
+
sa.event.listen(engine, "after_cursor_execute", self._statement_ended)
|
|
324
|
+
self._listening += 1
|
|
325
|
+
|
|
326
|
+
def _silence(self) -> None:
|
|
327
|
+
with self._listening_lock:
|
|
328
|
+
self._listening -= 1
|
|
329
|
+
if self._listening == 0:
|
|
330
|
+
engine = getattr(self.engine, "sync_engine", self.engine)
|
|
331
|
+
sa.event.remove(engine, "before_cursor_execute", self._statement_began)
|
|
332
|
+
sa.event.remove(engine, "after_cursor_execute", self._statement_ended)
|
|
333
|
+
|
|
334
|
+
def _statement_began(self, connection: Any, *_arguments: Any) -> None: # noqa: ANN401
|
|
335
|
+
connection.info.setdefault("sqlakit_started", []).append(time.perf_counter())
|
|
336
|
+
|
|
337
|
+
def _statement_ended(
|
|
338
|
+
self,
|
|
339
|
+
connection: Any, # noqa: ANN401
|
|
340
|
+
_cursor: Any, # noqa: ANN401
|
|
341
|
+
statement: str,
|
|
342
|
+
parameters: Any, # noqa: ANN401
|
|
343
|
+
_context: Any, # noqa: ANN401
|
|
344
|
+
_many: bool, # noqa: FBT001
|
|
345
|
+
) -> None:
|
|
346
|
+
starts = connection.info.get("sqlakit_started")
|
|
347
|
+
if not starts:
|
|
348
|
+
# Began before the listeners attached; no start time, not recorded.
|
|
349
|
+
return
|
|
350
|
+
started = starts.pop()
|
|
351
|
+
recordings = self._recordings.get()
|
|
352
|
+
if not recordings or statement.split(None, 1)[0].upper() in _CONTROL:
|
|
353
|
+
return
|
|
354
|
+
record = Statement(
|
|
355
|
+
sql=statement,
|
|
356
|
+
parameters=parameters,
|
|
357
|
+
duration=time.perf_counter() - started,
|
|
358
|
+
database=self._name,
|
|
359
|
+
stack=caller_stack() if self._stacks.get() else (),
|
|
360
|
+
)
|
|
361
|
+
for recording in recordings:
|
|
362
|
+
recording.statements.append(record)
|
|
363
|
+
|
|
364
|
+
def in_transaction(self) -> bool:
|
|
365
|
+
"""Whether a transaction is bound to the current context.
|
|
366
|
+
|
|
367
|
+
False under ``connect()`` and ``autocommit()``, which open none.
|
|
368
|
+
"""
|
|
369
|
+
return self._outer.get(None) is not None
|
|
370
|
+
|
|
371
|
+
def in_session(self) -> bool:
|
|
372
|
+
"""Whether a session is open in the current context.
|
|
373
|
+
|
|
374
|
+
A block opens one when something first asks for [`session`][sqlakit.Database.session], so
|
|
375
|
+
this is False in a block that has only run statements on the
|
|
376
|
+
connection. Reading it opens nothing.
|
|
377
|
+
"""
|
|
378
|
+
scope = self._scope.get(None)
|
|
379
|
+
return scope is not None and scope.session is not None
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def engine(self) -> Any: # noqa: ANN401
|
|
383
|
+
"""The engine underneath, which the subclass makes."""
|
|
384
|
+
raise NotImplementedError # pragma: no cover - the subclass has it
|
|
385
|
+
|
|
386
|
+
def _create_session(self, connection: ConnectionT) -> SessionT:
|
|
387
|
+
raise NotImplementedError # pragma: no cover - the subclass has it
|
|
388
|
+
|
|
389
|
+
def _session_args_for(self, connection: ConnectionT) -> dict[str, Any]:
|
|
390
|
+
"""How a session joins the transaction already open on ``connection``."""
|
|
391
|
+
outer = self._outer.get(None)
|
|
392
|
+
if outer is None or outer.connection is not connection:
|
|
393
|
+
return {}
|
|
394
|
+
if outer.session_savepoint and outer.scope is self._scope.get(None):
|
|
395
|
+
return {"join_transaction_mode": "create_savepoint"}
|
|
396
|
+
# Spelled out: SQLAlchemy's default would open a savepoint of its own
|
|
397
|
+
# whenever the connection is already inside one.
|
|
398
|
+
return {"join_transaction_mode": "rollback_only"}
|
|
399
|
+
|
|
400
|
+
def _plan(self, *, savepoint: bool, rollback: bool) -> tuple[_Outer | None, bool]:
|
|
401
|
+
"""Decide what a transaction joins, and whether it is a savepoint.
|
|
402
|
+
|
|
403
|
+
A block to be rolled back needs a savepoint to roll back to, and a
|
|
404
|
+
block inside an isolated one stays isolated.
|
|
405
|
+
"""
|
|
406
|
+
outer = self._outer_to_join()
|
|
407
|
+
return outer, savepoint or rollback or bool(outer and outer.savepoint)
|
|
408
|
+
|
|
409
|
+
def _outer_to_join(self) -> _Outer[ConnectionT] | None:
|
|
410
|
+
"""Return the outer transaction new blocks join, if there is one."""
|
|
411
|
+
outer = self._outer.get(None)
|
|
412
|
+
return outer if outer is not None and outer.join_nested else None
|
|
413
|
+
|
|
414
|
+
def _connection_to_join(self) -> ConnectionT | None:
|
|
415
|
+
outer = self._outer_to_join()
|
|
416
|
+
return outer.connection if outer is not None else None
|
|
417
|
+
|
|
418
|
+
def _connection_to_reuse(self) -> ConnectionT | None:
|
|
419
|
+
"""Return the bound connection a new block reuses, if it may.
|
|
420
|
+
|
|
421
|
+
One connection per context: a block that only needs a connection takes
|
|
422
|
+
the one already bound, whether a transaction, ``autocommit()`` or
|
|
423
|
+
another ``connect()`` opened it. ``join_nested=False`` opts out.
|
|
424
|
+
"""
|
|
425
|
+
outer = self._outer.get(None)
|
|
426
|
+
if outer is not None and not outer.join_nested:
|
|
427
|
+
return None
|
|
428
|
+
scope = self._scope.get(None)
|
|
429
|
+
return scope.connection if scope is not None else None
|
|
430
|
+
|
|
431
|
+
@contextmanager
|
|
432
|
+
def _bind(
|
|
433
|
+
self,
|
|
434
|
+
connection: ConnectionT,
|
|
435
|
+
) -> Iterator[_Scope[ConnectionT, SessionT]]:
|
|
436
|
+
"""Bind a scope holding ``connection`` to the current context.
|
|
437
|
+
|
|
438
|
+
Every block gets a scope, and so a session, of its own. Ending that
|
|
439
|
+
session is left to the caller, which knows whether it takes an
|
|
440
|
+
``await``.
|
|
441
|
+
"""
|
|
442
|
+
scope = _Scope[ConnectionT, SessionT](connection)
|
|
443
|
+
token = self._scope.set(scope)
|
|
444
|
+
try:
|
|
445
|
+
yield scope
|
|
446
|
+
finally:
|
|
447
|
+
self._scope.reset(token)
|
|
448
|
+
|
|
449
|
+
@contextmanager
|
|
450
|
+
def _set_outer(
|
|
451
|
+
self,
|
|
452
|
+
connection: ConnectionT | None,
|
|
453
|
+
*,
|
|
454
|
+
join_nested: bool = True,
|
|
455
|
+
savepoint: bool = False,
|
|
456
|
+
session_savepoint: bool = False,
|
|
457
|
+
) -> Iterator[_Outer[ConnectionT] | None]:
|
|
458
|
+
"""Make ``connection`` the outer one for this context. See `_Outer`.
|
|
459
|
+
|
|
460
|
+
``None`` leaves this context without an outer transaction at all, which
|
|
461
|
+
is what ``autocommit()`` needs: blocks under it must not join a
|
|
462
|
+
transaction its own connection is not part of.
|
|
463
|
+
"""
|
|
464
|
+
outer = (
|
|
465
|
+
_Outer(
|
|
466
|
+
connection,
|
|
467
|
+
join_nested=join_nested,
|
|
468
|
+
savepoint=savepoint,
|
|
469
|
+
session_savepoint=session_savepoint,
|
|
470
|
+
)
|
|
471
|
+
if connection is not None
|
|
472
|
+
else None
|
|
473
|
+
)
|
|
474
|
+
token = self._outer.set(outer)
|
|
475
|
+
try:
|
|
476
|
+
yield outer
|
|
477
|
+
finally:
|
|
478
|
+
self._outer.reset(token)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
DatabaseT = TypeVar("DatabaseT", bound="BaseDatabase[Any, Any]")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class _Using:
|
|
485
|
+
"""A database standing in for the default one, while a block of it is open.
|
|
486
|
+
|
|
487
|
+
What `db.using(alias)` returns. Everything a database does, it does; what
|
|
488
|
+
it adds is the redirection: for as long as one of its blocks is open, a
|
|
489
|
+
model that lives on the default database resolves here instead.
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
def __init__(
|
|
493
|
+
self,
|
|
494
|
+
db: Any, # noqa: ANN401
|
|
495
|
+
override: ContextVar[str | None],
|
|
496
|
+
alias: str,
|
|
497
|
+
) -> None:
|
|
498
|
+
self._db = db
|
|
499
|
+
self._override = override
|
|
500
|
+
self._alias = alias
|
|
501
|
+
|
|
502
|
+
def __repr__(self) -> str:
|
|
503
|
+
return f"{type(self).__name__}({self._alias!r}, {self._db!r})"
|
|
504
|
+
|
|
505
|
+
def __getattr__(self, name: str) -> Any: # noqa: ANN401
|
|
506
|
+
"""Everything else is the database's own."""
|
|
507
|
+
return getattr(self._db, name)
|
|
508
|
+
|
|
509
|
+
def __enter__(self) -> Any: # noqa: ANN401
|
|
510
|
+
"""Redirect for the block, opening nothing."""
|
|
511
|
+
self._token = self._override.set(self._alias)
|
|
512
|
+
return self._db
|
|
513
|
+
|
|
514
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
515
|
+
self._override.reset(self._token)
|
|
516
|
+
|
|
517
|
+
def connect(self, **arguments: Any) -> _Redirected: # noqa: ANN401
|
|
518
|
+
"""Open a connection here, and redirect while it is open."""
|
|
519
|
+
return self._redirect(self._db.connect(**arguments))
|
|
520
|
+
|
|
521
|
+
def transaction(self, **arguments: Any) -> _Redirected: # noqa: ANN401
|
|
522
|
+
"""Open a transaction here, and redirect while it is open."""
|
|
523
|
+
return self._redirect(self._db.transaction(**arguments))
|
|
524
|
+
|
|
525
|
+
def autocommit(self, **arguments: Any) -> _Redirected: # noqa: ANN401
|
|
526
|
+
"""Open an autocommit block here, and redirect while it is open."""
|
|
527
|
+
return self._redirect(self._db.autocommit(**arguments))
|
|
528
|
+
|
|
529
|
+
def session_factory(self, **arguments: Any) -> _Redirected: # noqa: ANN401
|
|
530
|
+
"""Open a session here, and redirect while it is open."""
|
|
531
|
+
return self._redirect(self._db.session_factory(**arguments))
|
|
532
|
+
|
|
533
|
+
def _redirect(self, block: Any) -> _Redirected: # noqa: ANN401
|
|
534
|
+
return _Redirected(block, self._override, self._alias)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class _Redirected:
|
|
538
|
+
"""A block of another database, with the redirection around it.
|
|
539
|
+
|
|
540
|
+
Awaited or not, whichever the block underneath is.
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
def __init__(
|
|
544
|
+
self,
|
|
545
|
+
block: Any, # noqa: ANN401
|
|
546
|
+
override: ContextVar[str | None],
|
|
547
|
+
alias: str,
|
|
548
|
+
) -> None:
|
|
549
|
+
self._block = block
|
|
550
|
+
self._override = override
|
|
551
|
+
self._alias = alias
|
|
552
|
+
|
|
553
|
+
def __enter__(self) -> Any: # noqa: ANN401
|
|
554
|
+
self._token = self._override.set(self._alias)
|
|
555
|
+
try:
|
|
556
|
+
return self._block.__enter__()
|
|
557
|
+
except BaseException:
|
|
558
|
+
self._override.reset(self._token)
|
|
559
|
+
raise
|
|
560
|
+
|
|
561
|
+
def __exit__(self, *exc_info: object) -> Any: # noqa: ANN401
|
|
562
|
+
try:
|
|
563
|
+
return self._block.__exit__(*exc_info)
|
|
564
|
+
finally:
|
|
565
|
+
self._override.reset(self._token)
|
|
566
|
+
|
|
567
|
+
async def __aenter__(self) -> Any: # noqa: ANN401
|
|
568
|
+
self._token = self._override.set(self._alias)
|
|
569
|
+
try:
|
|
570
|
+
return await self._block.__aenter__()
|
|
571
|
+
except BaseException:
|
|
572
|
+
self._override.reset(self._token)
|
|
573
|
+
raise
|
|
574
|
+
|
|
575
|
+
async def __aexit__(self, *exc_info: object) -> Any: # noqa: ANN401
|
|
576
|
+
try:
|
|
577
|
+
return await self._block.__aexit__(*exc_info)
|
|
578
|
+
finally:
|
|
579
|
+
self._override.reset(self._token)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
583
|
+
"""The registry half of the database an application imports.
|
|
584
|
+
|
|
585
|
+
Mixed into the concrete `Databases` on either side, which adds the database
|
|
586
|
+
half and the disposal the asyncio one awaits.
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
_database_class: type[DatabaseT]
|
|
590
|
+
|
|
591
|
+
def __init__(self) -> None:
|
|
592
|
+
"""Leave everything to [`configure`][sqlakit.Databases.configure]."""
|
|
593
|
+
self._aliased: dict[str, DatabaseT] = {}
|
|
594
|
+
self._routers: tuple[Any, ...] = ()
|
|
595
|
+
self._using: ContextVar[str | None] = ContextVar(
|
|
596
|
+
f"{type(self).__name__}.using", default=None
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def __repr__(self) -> str:
|
|
600
|
+
if not self.is_configured:
|
|
601
|
+
return f"{type(self).__name__}(unconfigured)"
|
|
602
|
+
return super().__repr__()
|
|
603
|
+
|
|
604
|
+
def __getitem__(self, alias: str) -> Self | DatabaseT:
|
|
605
|
+
"""Return the database configured as ``alias``.
|
|
606
|
+
|
|
607
|
+
``db["default"]`` is this one: what the code reaches without an alias.
|
|
608
|
+
|
|
609
|
+
Raises:
|
|
610
|
+
UnknownDatabaseError: if nothing is configured under that alias.
|
|
611
|
+
|
|
612
|
+
"""
|
|
613
|
+
if alias == DEFAULT_ALIAS:
|
|
614
|
+
return self
|
|
615
|
+
try:
|
|
616
|
+
return self._aliased[alias]
|
|
617
|
+
except KeyError:
|
|
618
|
+
raise UnknownDatabaseError(alias, self.aliases) from None
|
|
619
|
+
|
|
620
|
+
def __contains__(self, alias: str) -> bool:
|
|
621
|
+
return alias == DEFAULT_ALIAS or alias in self._aliased
|
|
622
|
+
|
|
623
|
+
@contextmanager
|
|
624
|
+
def recording(
|
|
625
|
+
self,
|
|
626
|
+
label: str | None = None,
|
|
627
|
+
*,
|
|
628
|
+
logger: logging.Logger | None = None,
|
|
629
|
+
echo: bool = False,
|
|
630
|
+
stacks: bool = False,
|
|
631
|
+
into: Recording | None = None,
|
|
632
|
+
) -> Iterator[Recording]:
|
|
633
|
+
"""Record every database this registry has, not the default one alone.
|
|
634
|
+
|
|
635
|
+
```python
|
|
636
|
+
with db.recording() as record:
|
|
637
|
+
move_the_reports()
|
|
638
|
+
|
|
639
|
+
record.databases # ("default", "warehouse")
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Statements say which database ran them. `db["warehouse"].recording()` records
|
|
643
|
+
that one on its own.
|
|
644
|
+
"""
|
|
645
|
+
together = Recording(label=label) if into is None else into
|
|
646
|
+
databases = (self, *self._aliased.values())
|
|
647
|
+
with ExitStack() as stack:
|
|
648
|
+
for db in databases:
|
|
649
|
+
stack.enter_context(
|
|
650
|
+
BaseDatabase.recording(db, label, stacks=stacks, into=together)
|
|
651
|
+
)
|
|
652
|
+
try:
|
|
653
|
+
yield together
|
|
654
|
+
finally:
|
|
655
|
+
if logger is not None:
|
|
656
|
+
together.log(logger)
|
|
657
|
+
if echo:
|
|
658
|
+
together.echo()
|
|
659
|
+
|
|
660
|
+
@staticmethod
|
|
661
|
+
def _named(alias: str, db: DatabaseT) -> DatabaseT:
|
|
662
|
+
"""Let a database say which alias it answers to, when it is recorded."""
|
|
663
|
+
db._name = alias # noqa: SLF001
|
|
664
|
+
return db
|
|
665
|
+
|
|
666
|
+
def using(self, alias: str) -> _Using:
|
|
667
|
+
"""Return the database under that alias, standing in for the default one.
|
|
668
|
+
|
|
669
|
+
The block opens on it, and models that live on the default database resolve
|
|
670
|
+
there for as long as it is open:
|
|
671
|
+
|
|
672
|
+
```python
|
|
673
|
+
with db.using("replica").connect():
|
|
674
|
+
report = build_report()
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Models that live somewhere else stay where they are, and a query that named
|
|
678
|
+
its own database with `using()` still wins. Entered on its own, as `with
|
|
679
|
+
db.using("replica"):`, it redirects and opens nothing.
|
|
680
|
+
|
|
681
|
+
Raises:
|
|
682
|
+
UnknownDatabaseError: if nothing is configured under that alias.
|
|
683
|
+
|
|
684
|
+
"""
|
|
685
|
+
if alias not in self:
|
|
686
|
+
raise UnknownDatabaseError(alias, self.aliases)
|
|
687
|
+
return _Using(self[alias], self._using, alias)
|
|
688
|
+
|
|
689
|
+
def route(self, *routers: Router | RouterFunction | str) -> None:
|
|
690
|
+
"""Say which database a model lives on, for models that do not say it.
|
|
691
|
+
|
|
692
|
+
Each router takes a model and returns an alias, or None to leave the question
|
|
693
|
+
to the next one. A dotted path is imported, which is what settings hand over:
|
|
694
|
+
|
|
695
|
+
```python
|
|
696
|
+
db.route(lambda model: "warehouse" if is_report(model) else None)
|
|
697
|
+
db.route("app.db.routing")
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Placement is structural: reads, writes and the tables `provisioned_tables()`
|
|
701
|
+
creates all follow it. Called with nothing, it clears the policy, which
|
|
702
|
+
leaves `__db__` on a model as the only answer.
|
|
703
|
+
"""
|
|
704
|
+
self._routers = tuple(
|
|
705
|
+
as_router(import_string(router) if isinstance(router, str) else router)
|
|
706
|
+
for router in routers
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
@property
|
|
710
|
+
def routers(self) -> tuple[Any, ...]:
|
|
711
|
+
"""The placement policy in force, in the order it is asked."""
|
|
712
|
+
return self._routers
|
|
713
|
+
|
|
714
|
+
def db_for(self, model: type[Any]) -> BaseDatabase[Any, Any]:
|
|
715
|
+
"""Return the database a model lives on.
|
|
716
|
+
|
|
717
|
+
The routers first, then the model's own ``__db__``, unless a block
|
|
718
|
+
opened with `using()` stands in for the default database.
|
|
719
|
+
"""
|
|
720
|
+
placement = self._routed(model) or model.__db__
|
|
721
|
+
if isinstance(placement, str):
|
|
722
|
+
override = self._using.get()
|
|
723
|
+
if override is not None and placement == DEFAULT_ALIAS:
|
|
724
|
+
placement = override
|
|
725
|
+
return self[placement]
|
|
726
|
+
return placement
|
|
727
|
+
|
|
728
|
+
def _routed(self, model: type[Any]) -> str | None:
|
|
729
|
+
"""Return what the first router says about this model, if anything."""
|
|
730
|
+
for router in self._routers:
|
|
731
|
+
alias = router(model)
|
|
732
|
+
if alias is not None:
|
|
733
|
+
return alias
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
@property
|
|
737
|
+
def aliases(self) -> tuple[str, ...]:
|
|
738
|
+
"""The aliases configured, the default one first."""
|
|
739
|
+
return (DEFAULT_ALIAS, *self._aliased)
|
|
740
|
+
|
|
741
|
+
@property
|
|
742
|
+
def is_configured(self) -> bool:
|
|
743
|
+
"""Whether [`configure`][sqlakit.Databases.configure] has been called."""
|
|
744
|
+
return "url" in self.__dict__
|
|
745
|
+
|
|
746
|
+
@overload
|
|
747
|
+
def configure(
|
|
748
|
+
self,
|
|
749
|
+
url: str | sa.URL | None = None,
|
|
750
|
+
engine_args: EngineArgs | None = None,
|
|
751
|
+
session_args: SessionArgs | None = None,
|
|
752
|
+
routers: Sequence[Router | RouterFunction | str] = (),
|
|
753
|
+
templates: TemplatesLike | None = None,
|
|
754
|
+
**parts: Unpack[UrlParts],
|
|
755
|
+
) -> None: ...
|
|
756
|
+
|
|
757
|
+
@overload
|
|
758
|
+
def configure(
|
|
759
|
+
self,
|
|
760
|
+
url: Mapping[str, DatabaseConfig],
|
|
761
|
+
*,
|
|
762
|
+
routers: Sequence[Router | RouterFunction | str] = (),
|
|
763
|
+
templates: TemplatesLike | None = None,
|
|
764
|
+
) -> None: ...
|
|
765
|
+
|
|
766
|
+
def configure(
|
|
767
|
+
self,
|
|
768
|
+
url: str | sa.URL | Mapping[str, DatabaseConfig] | None = None,
|
|
769
|
+
engine_args: EngineArgs | None = None,
|
|
770
|
+
session_args: SessionArgs | None = None,
|
|
771
|
+
routers: Sequence[Router | RouterFunction | str] = (),
|
|
772
|
+
templates: TemplatesLike | None = None,
|
|
773
|
+
**parts: Unpack[UrlParts],
|
|
774
|
+
) -> None:
|
|
775
|
+
"""Point this database at ``url``, or at several keyed by alias.
|
|
776
|
+
|
|
777
|
+
Call it once, at startup. Settings arrive as a URL or as the parts to build
|
|
778
|
+
one from:
|
|
779
|
+
|
|
780
|
+
```python
|
|
781
|
+
db.configure(DB_URL)
|
|
782
|
+
|
|
783
|
+
db.configure(
|
|
784
|
+
drivername="postgresql+psycopg",
|
|
785
|
+
host=DB_HOST,
|
|
786
|
+
port=DB_PORT,
|
|
787
|
+
database=DB_NAME,
|
|
788
|
+
)
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
A mapping configures this database as its ``"default"`` and builds the rest
|
|
792
|
+
alongside it, each with its own pool, connection and transactions:
|
|
793
|
+
|
|
794
|
+
```python
|
|
795
|
+
db.configure(
|
|
796
|
+
{
|
|
797
|
+
"default": {"url": PRIMARY_URL, "engine_args": {"pool_size": 20}},
|
|
798
|
+
"replica": {"url": REPLICA_URL},
|
|
799
|
+
}
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
db["replica"].session # the replica; `db.session` is the default one
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
``routers`` says where a model lives when the model does not, as `route`
|
|
806
|
+
takes them. ``templates`` is where the SQL templates of every one of them
|
|
807
|
+
live. Reconfiguring is allowed until something connects; afterwards, dispose
|
|
808
|
+
of the engines first.
|
|
809
|
+
|
|
810
|
+
Raises:
|
|
811
|
+
DatabaseAlreadyConfiguredError: if a database has already connected.
|
|
812
|
+
MissingDefaultDatabaseError: if a mapping carries no ``"default"``.
|
|
813
|
+
MissingDatabaseUrlError: if an entry says nowhere to connect.
|
|
814
|
+
ConflictingDatabaseUrlError: if one says it twice over. Either is an
|
|
815
|
+
``InvalidDatabaseConfigError``.
|
|
816
|
+
|
|
817
|
+
"""
|
|
818
|
+
if not isinstance(url, Mapping):
|
|
819
|
+
self._reject_if_connected()
|
|
820
|
+
super().__init__(url, engine_args, session_args, templates, **parts)
|
|
821
|
+
self.route(*routers)
|
|
822
|
+
return
|
|
823
|
+
if DEFAULT_ALIAS not in url:
|
|
824
|
+
raise MissingDefaultDatabaseError(tuple(url))
|
|
825
|
+
self._reject_if_connected()
|
|
826
|
+
configs = {
|
|
827
|
+
alias: (url_from_config(config), config) for alias, config in url.items()
|
|
828
|
+
}
|
|
829
|
+
default_url, default = configs[DEFAULT_ALIAS]
|
|
830
|
+
super().__init__(
|
|
831
|
+
default_url,
|
|
832
|
+
default.get("engine_args"),
|
|
833
|
+
default.get("session_args"),
|
|
834
|
+
templates,
|
|
835
|
+
)
|
|
836
|
+
self._aliased = {
|
|
837
|
+
alias: self._named(
|
|
838
|
+
alias,
|
|
839
|
+
self._database_class(
|
|
840
|
+
database_url,
|
|
841
|
+
config.get("engine_args"),
|
|
842
|
+
config.get("session_args"),
|
|
843
|
+
templates,
|
|
844
|
+
),
|
|
845
|
+
)
|
|
846
|
+
for alias, (database_url, config) in configs.items()
|
|
847
|
+
if alias != DEFAULT_ALIAS
|
|
848
|
+
}
|
|
849
|
+
self.route(*routers)
|
|
850
|
+
|
|
851
|
+
def _reject_if_connected(self) -> None:
|
|
852
|
+
connected = self.is_configured and self._engine is not None
|
|
853
|
+
if connected or any(
|
|
854
|
+
db._engine is not None # noqa: SLF001
|
|
855
|
+
for db in self._aliased.values()
|
|
856
|
+
):
|
|
857
|
+
raise DatabaseAlreadyConfiguredError
|
|
858
|
+
|
|
859
|
+
if not TYPE_CHECKING:
|
|
860
|
+
# Hidden from type checkers: seeing it, they would take every attribute
|
|
861
|
+
# to exist and stop reporting typos. It is reached when normal lookup
|
|
862
|
+
# fails, which is what an unconfigured database looks like.
|
|
863
|
+
def __getattr__(self, name: str) -> object:
|
|
864
|
+
# Only the database's own attributes are worth explaining. Anything
|
|
865
|
+
# else is a name that does not exist, and saying so is what lets
|
|
866
|
+
# `hasattr`, `copy` and every library that introspects work.
|
|
867
|
+
if (
|
|
868
|
+
not name.startswith("_")
|
|
869
|
+
and hasattr(type(self), name)
|
|
870
|
+
and not self.is_configured
|
|
871
|
+
):
|
|
872
|
+
raise DatabaseNotConfiguredError from None
|
|
873
|
+
raise AttributeError(name)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
_CONTROL = frozenset(
|
|
877
|
+
# Transaction control is not a query, and which of these reach a cursor
|
|
878
|
+
# depends on the driver, and counting them would make a recording mean
|
|
879
|
+
# something different on SQLite than on PostgreSQL.
|
|
880
|
+
{"BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE", "PRAGMA"}
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
URL_PARTS = (
|
|
884
|
+
"drivername",
|
|
885
|
+
"username",
|
|
886
|
+
"password",
|
|
887
|
+
"host",
|
|
888
|
+
"port",
|
|
889
|
+
"database",
|
|
890
|
+
"query",
|
|
891
|
+
)
|
|
892
|
+
"""The parts a configuration is spelled with instead of a ``url``."""
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def url_from_config(config: DatabaseConfig) -> str | sa.URL:
|
|
896
|
+
"""Read the URL out of a configuration, however it was spelled.
|
|
897
|
+
|
|
898
|
+
Raises:
|
|
899
|
+
MissingDatabaseUrlError: if given neither a ``url`` nor the parts to build
|
|
900
|
+
one.
|
|
901
|
+
ConflictingDatabaseUrlError: if given both.
|
|
902
|
+
|
|
903
|
+
"""
|
|
904
|
+
parts = {part: config[part] for part in URL_PARTS if part in config}
|
|
905
|
+
if "url" in config:
|
|
906
|
+
if parts:
|
|
907
|
+
raise ConflictingDatabaseUrlError(tuple(parts))
|
|
908
|
+
return config["url"]
|
|
909
|
+
if "drivername" not in parts:
|
|
910
|
+
raise MissingDatabaseUrlError
|
|
911
|
+
return sa.URL.create(**parts) # ty: ignore[invalid-argument-type]
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
class BaseRetryingTransaction:
|
|
915
|
+
"""What ``transaction()`` returns when given ``retry_on``.
|
|
916
|
+
|
|
917
|
+
A decorator, and deliberately not a context manager: retrying re-runs the
|
|
918
|
+
block, which a ``with`` statement cannot do. Entering one is rejected by
|
|
919
|
+
type checkers and raises at runtime.
|
|
920
|
+
"""
|
|
921
|
+
|
|
922
|
+
def __init__(
|
|
923
|
+
self,
|
|
924
|
+
transaction: Callable[[], Any],
|
|
925
|
+
*,
|
|
926
|
+
retry_on: RetryOn,
|
|
927
|
+
max_retries: int = 3,
|
|
928
|
+
backoff: Callable[[int], float] | None = None,
|
|
929
|
+
) -> None:
|
|
930
|
+
self.transaction = transaction
|
|
931
|
+
self.retry_on = retry_on
|
|
932
|
+
self.max_retries = max_retries
|
|
933
|
+
self.backoff = backoff or default_backoff
|
|
934
|
+
|
|
935
|
+
def _retry(self, exc: BaseException, *, attempt: int) -> bool:
|
|
936
|
+
return attempt < self.max_retries and retry_matches(exc, self.retry_on)
|
|
937
|
+
|
|
938
|
+
if not TYPE_CHECKING:
|
|
939
|
+
# Hidden from type checkers, which reject `with` on this class outright.
|
|
940
|
+
# Defined for the error message.
|
|
941
|
+
def __enter__(self) -> None:
|
|
942
|
+
raise RetryNotSupportedError
|
|
943
|
+
|
|
944
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
945
|
+
raise AssertionError # pragma: no cover
|
|
946
|
+
|
|
947
|
+
async def __aenter__(self) -> None:
|
|
948
|
+
raise RetryNotSupportedError
|
|
949
|
+
|
|
950
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
951
|
+
raise AssertionError # pragma: no cover
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def retry_matches(exc: BaseException, retry_on: RetryOn) -> bool:
|
|
955
|
+
"""Whether ``retry_on`` claims this exception is worth another attempt."""
|
|
956
|
+
if isinstance(retry_on, type | tuple):
|
|
957
|
+
return isinstance(exc, retry_on)
|
|
958
|
+
return retry_on(exc)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def default_backoff(attempt: int) -> float:
|
|
962
|
+
"""Exponential backoff with jitter: ~0.1s, ~0.2s, ~0.4s, ..."""
|
|
963
|
+
return 0.1 * (2**attempt) * (0.5 + _random.random())
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def fix_sqlite_transactions(engine: Engine) -> None:
|
|
967
|
+
"""Make the stdlib SQLite driver emit real transactions.
|
|
968
|
+
|
|
969
|
+
``sqlite3`` never emits ``BEGIN`` on its own, which leaves ``SAVEPOINT``
|
|
970
|
+
and nested transactions broken. The workaround is SQLAlchemy's, and covers
|
|
971
|
+
``pysqlite`` and ``aiosqlite`` alike.
|
|
972
|
+
"""
|
|
973
|
+
if engine.dialect.name != "sqlite":
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
@sa.event.listens_for(engine, "connect")
|
|
977
|
+
def disable_implicit_begin(dbapi_connection: Any, _record: object) -> None: # noqa: ANN401
|
|
978
|
+
dbapi_connection.isolation_level = None
|
|
979
|
+
|
|
980
|
+
@sa.event.listens_for(engine, "begin")
|
|
981
|
+
def emit_begin(connection: sa.Connection) -> None:
|
|
982
|
+
# SQLAlchemy signals a begin under AUTOCOMMIT too, where a real
|
|
983
|
+
# transaction would undo what AUTOCOMMIT was asked for.
|
|
984
|
+
if _is_autocommit(connection):
|
|
985
|
+
return
|
|
986
|
+
connection.exec_driver_sql("BEGIN")
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _is_autocommit(connection: sa.Connection) -> bool:
|
|
990
|
+
"""Whether ``connection`` runs in ``AUTOCOMMIT``, per block or per engine."""
|
|
991
|
+
if connection.get_execution_options().get("isolation_level") == "AUTOCOMMIT":
|
|
992
|
+
return True
|
|
993
|
+
return getattr(connection.dialect, "_on_connect_isolation_level", None) == (
|
|
994
|
+
"AUTOCOMMIT"
|
|
995
|
+
)
|