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/_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
+ )