sqlakit 0.22.10__tar.gz → 0.22.12__tar.gz

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.
Files changed (34) hide show
  1. {sqlakit-0.22.10 → sqlakit-0.22.12}/PKG-INFO +4 -3
  2. {sqlakit-0.22.10 → sqlakit-0.22.12}/README.md +2 -2
  3. {sqlakit-0.22.10 → sqlakit-0.22.12}/pyproject.toml +3 -2
  4. {sqlakit-0.22.10 → sqlakit-0.22.12}/pyproject.toml.orig +3 -2
  5. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_base.py +68 -48
  6. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_db.py +20 -10
  7. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_model.py +1 -1
  8. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_registry.py +2 -2
  9. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_sql.py +3 -3
  10. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/asyncio/_db.py +22 -10
  11. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/asyncio/_registry.py +2 -2
  12. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/asyncio/orm.py +17 -9
  13. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/asyncio/sql.py +1 -3
  14. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/orm.py +11 -9
  15. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/pytest_plugin.py +4 -4
  16. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/sql.py +1 -3
  17. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/testing.py +2 -2
  18. {sqlakit-0.22.10 → sqlakit-0.22.12}/LICENSE +0 -0
  19. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/__init__.py +0 -0
  20. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_cli.py +0 -0
  21. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_discovery.py +0 -0
  22. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_linters.py +0 -0
  23. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_project.py +0 -0
  24. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_pycharm.py +0 -0
  25. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_query.py +0 -0
  26. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_recording.py +0 -0
  27. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_render.py +0 -0
  28. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_routing.py +0 -0
  29. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_sql_formatter.py +0 -0
  30. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/_static.py +0 -0
  31. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/asyncio/__init__.py +0 -0
  32. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/exceptions.py +0 -0
  33. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/py.typed +0 -0
  34. {sqlakit-0.22.10 → sqlakit-0.22.12}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.22.10
3
+ Version: 0.22.12
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
18
19
  Classifier: Topic :: Database
19
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
21
  Classifier: Typing :: Typed
@@ -387,7 +388,7 @@ there is nothing to await.
387
388
  ## `FastAPI` integration
388
389
 
389
390
  ```python
390
- from collections.abc import AsyncIterator
391
+ from collections.abc import AsyncGenerator
391
392
  from contextlib import asynccontextmanager
392
393
 
393
394
  from fastapi import FastAPI
@@ -400,7 +401,7 @@ db = Database("postgresql+psycopg://localhost/app")
400
401
 
401
402
 
402
403
  @asynccontextmanager
403
- async def lifespan(app: FastAPI) -> AsyncIterator[None]:
404
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
404
405
  yield
405
406
  await db.dispose() # close the pool on shutdown
406
407
 
@@ -356,7 +356,7 @@ there is nothing to await.
356
356
  ## `FastAPI` integration
357
357
 
358
358
  ```python
359
- from collections.abc import AsyncIterator
359
+ from collections.abc import AsyncGenerator
360
360
  from contextlib import asynccontextmanager
361
361
 
362
362
  from fastapi import FastAPI
@@ -369,7 +369,7 @@ db = Database("postgresql+psycopg://localhost/app")
369
369
 
370
370
 
371
371
  @asynccontextmanager
372
- async def lifespan(app: FastAPI) -> AsyncIterator[None]:
372
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
373
373
  yield
374
374
  await db.dispose() # close the pool on shutdown
375
375
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.22.10"
3
+ version = "0.22.12"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,6 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.12",
22
22
  "Programming Language :: Python :: 3.13",
23
23
  "Programming Language :: Python :: 3.14",
24
+ "Programming Language :: Python :: 3.15",
24
25
  "Topic :: Database",
25
26
  "Topic :: Software Development :: Libraries :: Python Modules",
26
27
  "Typing :: Typed",
@@ -178,7 +179,7 @@ skip = "./.venv,./site,./uv.lock,./.git"
178
179
  [dependency-groups]
179
180
  dev = [
180
181
  "pytest>=9.1.1",
181
- "ty>=0.0.84",
182
+ "ty>=0.0.87",
182
183
  "ruff>=0.16.6",
183
184
  "poethepoet>=0.48.0",
184
185
  "anyio>=4.14.2",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.22.10"
3
+ version = "0.22.12"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -16,6 +16,7 @@ classifiers = [
16
16
  "Programming Language :: Python :: 3.12",
17
17
  "Programming Language :: Python :: 3.13",
18
18
  "Programming Language :: Python :: 3.14",
19
+ "Programming Language :: Python :: 3.15",
19
20
  "Topic :: Database",
20
21
  "Topic :: Software Development :: Libraries :: Python Modules",
21
22
  "Typing :: Typed",
@@ -54,7 +55,7 @@ default-groups = ["dev", "docs"]
54
55
  [dependency-groups]
55
56
  dev = [
56
57
  "pytest>=9.1.1",
57
- "ty>=0.0.84",
58
+ "ty>=0.0.87",
58
59
  "ruff>=0.16.6",
59
60
  "poethepoet>=0.48.0",
60
61
  "anyio>=4.14.2",
@@ -5,7 +5,7 @@ import inspect
5
5
  import random
6
6
  import threading
7
7
  import time
8
- from collections.abc import Callable, Mapping
8
+ from collections.abc import Callable, Generator, Mapping
9
9
  from contextlib import ExitStack, contextmanager
10
10
  from contextvars import ContextVar
11
11
  from dataclasses import dataclass, replace
@@ -56,7 +56,7 @@ from .exceptions import (
56
56
 
57
57
  if TYPE_CHECKING:
58
58
  import logging
59
- from collections.abc import Iterator, Sequence
59
+ from collections.abc import Sequence
60
60
  from os import PathLike
61
61
 
62
62
  from sqlalchemy.engine import Engine
@@ -232,6 +232,9 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
232
232
  """
233
233
 
234
234
  _engine: Any = None # narrowed by the subclass
235
+ # The database to work on instead of this one, or None. Every public member
236
+ # checks it first, so a database without one pays one attribute read.
237
+ _override: Any = None
235
238
 
236
239
  def __init__(
237
240
  self,
@@ -299,6 +302,7 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
299
302
  self._listened: Any = None
300
303
  self._listening_lock = threading.Lock()
301
304
  self._name = alias or DEFAULT_ALIAS
305
+ self._override = None
302
306
 
303
307
  def __repr__(self) -> str:
304
308
  return f"{type(self).__name__}({self.url.render_as_string()!r})"
@@ -332,6 +336,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
332
336
  MissingSessionError: if no connection is bound.
333
337
 
334
338
  """
339
+ if self._override is not None:
340
+ return self._override.session
335
341
  try:
336
342
  scope = self._scope.get()
337
343
  except LookupError:
@@ -358,7 +364,7 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
358
364
  skip_queries_from: Sequence[str | PathLike[str]] = (),
359
365
  into: Recording | None = None,
360
366
  send_to: Callable[[Recording], object] | None = None,
361
- ) -> Iterator[Recording]:
367
+ ) -> Generator[Recording]:
362
368
  """Record the statements of this block, and what they add up to.
363
369
 
364
370
  ```python
@@ -384,6 +390,18 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
384
390
  after. `with` is right on either side, awaited or not: it listens, it does
385
391
  not run anything.
386
392
  """
393
+ if self._override is not None:
394
+ with self._override.recording(
395
+ label,
396
+ logger=logger,
397
+ echo=echo,
398
+ stacks=stacks,
399
+ skip_queries_from=skip_queries_from,
400
+ into=into,
401
+ send_to=send_to,
402
+ ) as recording:
403
+ yield recording
404
+ return
387
405
  recording = Recording(label=label) if into is None else into
388
406
  self._listen()
389
407
  recordings = self._recordings.set((*self._recordings.get(), recording))
@@ -412,7 +430,7 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
412
430
  *,
413
431
  at_most: int | None = None,
414
432
  duplicates: bool = True,
415
- ) -> Iterator[Recording]:
433
+ ) -> Generator[Recording]:
416
434
  """Assert what the block asks of this database.
417
435
 
418
436
  ```python
@@ -507,6 +525,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
507
525
 
508
526
  False under ``connect()`` and ``autocommit()``, which open none.
509
527
  """
528
+ if self._override is not None:
529
+ return self._override.in_transaction()
510
530
  return self._outer.get(None) is not None
511
531
 
512
532
  @property
@@ -542,11 +562,13 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
542
562
  this is False in a block that has only run statements on the
543
563
  connection. Reading it opens nothing.
544
564
  """
565
+ if self._override is not None:
566
+ return self._override.in_session()
545
567
  scope = self._scope.get(None)
546
568
  return scope is not None and scope.session is not None
547
569
 
548
570
  @contextmanager
549
- def unbound(self) -> Iterator[None]:
571
+ def unbound(self) -> Generator[None]:
550
572
  """Hide the block open around this one, for the code inside to open its own.
551
573
 
552
574
  A test opens a transaction for the whole test, so code that reaches for
@@ -563,6 +585,10 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
563
585
  A block opened inside still joins the transaction around it, and rolls
564
586
  back with it.
565
587
  """
588
+ if self._override is not None:
589
+ with self._override.unbound():
590
+ yield
591
+ return
566
592
  scope = self._scope.get(None)
567
593
  if scope is None:
568
594
  yield
@@ -617,9 +643,24 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
617
643
  outer = self._outer_to_join()
618
644
  return outer, savepoint or rollback or bool(outer and outer.savepoint)
619
645
 
620
- def _stand_in(self) -> Any: # noqa: ANN401
621
- """Return the database to open blocks on instead of this one, or None."""
622
- return None
646
+ @contextmanager
647
+ def override(self, db: Self) -> Generator[Self]:
648
+ """Use another database in place of this one until the block ends.
649
+
650
+ ```python
651
+ with Database(TEST_URL) as test_db, db.override(test_db):
652
+ create_user("ada") # runs on test_db
653
+ ```
654
+
655
+ Every thread sees it. The database stays open when the block ends, so
656
+ close it yourself.
657
+ """
658
+ before = self._override
659
+ self._override = None if db is self else db
660
+ try:
661
+ yield db
662
+ finally:
663
+ self._override = before
623
664
 
624
665
  def _outer_to_join(self) -> _Outer[ConnectionT] | None:
625
666
  """Return the outer transaction new blocks join, if there is one."""
@@ -816,6 +857,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
816
857
  self._default: DatabaseT | None = None
817
858
  self._aliased: dict[str, DatabaseT] = {}
818
859
  self._overridden: dict[str, DatabaseT] = {}
860
+ self._override = None
819
861
  self._routers: tuple[Any, ...] = ()
820
862
  self._using: ContextVar[str | None] = ContextVar(
821
863
  f"{type(self).__name__}.using", default=None
@@ -824,7 +866,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
824
866
  def __repr__(self) -> str:
825
867
  if self._built_its_own:
826
868
  return super().__repr__()
827
- held = self._stand_in()
869
+ held = self._override
828
870
  if held is None:
829
871
  return f"{type(self).__name__}(unconfigured)"
830
872
  return f"{type(self).__name__}({held!r})"
@@ -884,6 +926,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
884
926
  if self.is_configured:
885
927
  raise DefaultAliasError
886
928
  self._default = self._named(alias, db)
929
+ self._follow_default()
887
930
  return
888
931
  if alias in self._aliased:
889
932
  raise AliasInUseError(alias)
@@ -901,7 +944,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
901
944
  into: Recording | None = None,
902
945
  send_to: Callable[[Recording], object] | None = None,
903
946
  using: str | DatabaseT | Sequence[str | DatabaseT] | None = None,
904
- ) -> Iterator[Recording]:
947
+ ) -> Generator[Recording]:
905
948
  """Record every database this registry has, not the default one alone.
906
949
 
907
950
  ```python
@@ -953,7 +996,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
953
996
  send_to(together)
954
997
 
955
998
  @contextmanager
956
- def unbound(self) -> Iterator[None]:
999
+ def unbound(self) -> Generator[None]:
957
1000
  """Hide the block open on every database this registry holds."""
958
1001
  with ExitStack() as stack:
959
1002
  for alias in self.aliases:
@@ -981,7 +1024,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
981
1024
  db: DatabaseT,
982
1025
  *,
983
1026
  alias: str = DEFAULT_ALIAS,
984
- ) -> Iterator[DatabaseT]:
1027
+ ) -> Generator[DatabaseT]:
985
1028
  """Use another database under an alias until the block ends.
986
1029
 
987
1030
  ```python
@@ -995,6 +1038,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
995
1038
  before = self._overridden.get(alias)
996
1039
  named = getattr(db, "_name", None)
997
1040
  self._overridden[alias] = self._named(alias, db)
1041
+ self._follow_default()
998
1042
  try:
999
1043
  yield db
1000
1044
  finally:
@@ -1002,13 +1046,14 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1002
1046
  del self._overridden[alias]
1003
1047
  else:
1004
1048
  self._overridden[alias] = before
1049
+ self._follow_default()
1005
1050
  if named is not None:
1006
1051
  db._name = named # noqa: SLF001
1007
1052
 
1008
- def _stand_in(self) -> DatabaseT | None:
1009
- """Return the database the default alias leads to, if not this registry."""
1053
+ def _follow_default(self) -> None:
1054
+ """Work on the database the default alias leads to, unless it is this one."""
1010
1055
  held = self._overridden.get(DEFAULT_ALIAS, self._default)
1011
- return None if held is self else held
1056
+ self._override = None if held is self else held
1012
1057
 
1013
1058
  def using(self, target: str | DatabaseT) -> _Using:
1014
1059
  """Return that database, standing in for the default one.
@@ -1130,7 +1175,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1130
1175
  @property
1131
1176
  def is_configured(self) -> bool:
1132
1177
  """Whether this registry has a default database to reach."""
1133
- return "url" in self.__dict__ or self._stand_in() is not None
1178
+ return "url" in self.__dict__ or self._override is not None
1134
1179
 
1135
1180
  @property
1136
1181
  def _built_its_own(self) -> bool:
@@ -1138,38 +1183,13 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1138
1183
  return "url" in self.__dict__
1139
1184
 
1140
1185
  if not TYPE_CHECKING:
1141
- # Hidden from type checkers, which keep reading these off `Database`
1142
- # and its asyncio twin, signatures and all.
1143
- def _proxy(name: str, *, attribute: bool = False) -> Any: # noqa: ANN401, N805
1144
- """Proxy to the database this registry holds, or call its own.
1145
-
1146
- A registry handed a default, or overridden for a block, proxies to
1147
- that database. One that `configure` built calls what it inherits,
1148
- which `super()` reaches.
1149
- """
1150
-
1151
- def reach(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
1152
- held = self._stand_in()
1153
- found = (
1154
- getattr(held, name) if held is not None else getattr(super(), name)
1155
- )
1156
- return found if attribute else found(*args, **kwargs)
1157
-
1158
- return property(reach) if attribute else reach
1159
-
1160
- connection = _proxy("connection", attribute=True)
1161
- engine = _proxy("engine", attribute=True)
1162
- session = _proxy("session", attribute=True)
1163
- sql = _proxy("sql", attribute=True)
1164
- assert_queries = _proxy("assert_queries")
1165
- in_session = _proxy("in_session")
1166
- in_transaction = _proxy("in_transaction")
1167
- ping = _proxy("ping")
1168
- provisioned_tables = _proxy("provisioned_tables")
1169
- query = _proxy("query")
1170
- del _proxy
1171
- # `connect`, `autocommit`, `session_factory` and `transaction` are not
1172
- # proxied: the ones inherited look `_stand_in` up as the block opens.
1186
+ # Hidden from type checkers, which read it off `Database`.
1187
+ @property
1188
+ def sql(self) -> Any: # noqa: ANN401
1189
+ """The templates of the default database, wherever it was built."""
1190
+ if self._built_its_own or self._override is None:
1191
+ return super().sql
1192
+ return self._override.sql
1173
1193
 
1174
1194
  @overload
1175
1195
  def configure(
@@ -29,7 +29,7 @@ from ._base import (
29
29
  from .exceptions import TransactionRolledBackError
30
30
 
31
31
  if TYPE_CHECKING:
32
- from collections.abc import Callable, Iterator, Sequence
32
+ from collections.abc import Callable, Generator, Sequence
33
33
  from types import TracebackType
34
34
 
35
35
  from sqlalchemy.engine import Engine
@@ -98,6 +98,8 @@ class Database(BaseDatabase[sa.Connection, Session]):
98
98
  @property
99
99
  def engine(self) -> Engine:
100
100
  """The underlying engine, created on first access."""
101
+ if self._override is not None:
102
+ return self._override.engine
101
103
  if self._engine is None:
102
104
  with self._engine_lock:
103
105
  # Two threads reaching this at once would each build one, and
@@ -122,6 +124,8 @@ class Database(BaseDatabase[sa.Connection, Session]):
122
124
  MissingConnectionError: if no connection is bound.
123
125
 
124
126
  """
127
+ if self._override is not None:
128
+ return self._override.connection
125
129
  return self._reused(self._current_scope())
126
130
 
127
131
  def _create_session(self, connection: sa.Connection) -> Session:
@@ -147,9 +151,9 @@ class Database(BaseDatabase[sa.Connection, Session]):
147
151
  return cast("sa.Connection", scope.connection)
148
152
 
149
153
  @contextmanager
150
- def connect(self) -> Iterator[sa.Connection]:
154
+ def connect(self) -> Generator[sa.Connection]:
151
155
  """Open a connection and bind it, or reuse the one already bound."""
152
- elsewhere = self._stand_in()
156
+ elsewhere = self._override
153
157
  if elsewhere is not None:
154
158
  with elsewhere.connect() as connection:
155
159
  yield connection
@@ -194,9 +198,9 @@ class Database(BaseDatabase[sa.Connection, Session]):
194
198
  return autocommit
195
199
 
196
200
  @contextmanager
197
- def _autocommit(self) -> Iterator[sa.Connection]:
201
+ def _autocommit(self) -> Generator[sa.Connection]:
198
202
  """Open a connection in ``AUTOCOMMIT`` and bind it, or join the outer one."""
199
- elsewhere = self._stand_in()
203
+ elsewhere = self._override
200
204
  if elsewhere is not None:
201
205
  with elsewhere.autocommit() as connection:
202
206
  yield connection
@@ -324,14 +328,14 @@ class Database(BaseDatabase[sa.Connection, Session]):
324
328
  return transaction
325
329
 
326
330
  @contextmanager
327
- def session_factory(self) -> Iterator[Session]:
331
+ def session_factory(self) -> Generator[Session]:
328
332
  """Open a session for the block, and bind it.
329
333
 
330
334
  The session arrives at once, the connection on its first query or
331
335
  flush, as ``sessionmaker()`` does it. Inside another block it runs on
332
336
  the connection already bound.
333
337
  """
334
- elsewhere = self._stand_in()
338
+ elsewhere = self._override
335
339
  if elsewhere is not None:
336
340
  with elsewhere.session_factory() as session:
337
341
  yield session
@@ -361,7 +365,7 @@ class Database(BaseDatabase[sa.Connection, Session]):
361
365
  *,
362
366
  commit: bool = False,
363
367
  autocommit: bool = False,
364
- ) -> Iterator[sa.Connection]:
368
+ ) -> Generator[sa.Connection]:
365
369
  """Bind ``connection`` for the block, ending the session it opened.
366
370
 
367
371
  ``commit`` keeps that session's work. Closing a session that isolates
@@ -385,7 +389,7 @@ class Database(BaseDatabase[sa.Connection, Session]):
385
389
  metadata: sa.MetaData,
386
390
  *,
387
391
  tables: Sequence[sa.Table] | None = None,
388
- ) -> Iterator[None]:
392
+ ) -> Generator[None]:
389
393
  """Create these tables here, and drop them when the block ends.
390
394
 
391
395
  A test session opens this once, around everything that needs a schema:
@@ -400,6 +404,10 @@ class Database(BaseDatabase[sa.Connection, Session]):
400
404
  Every table of the metadata unless ``tables`` names fewer, as a second
401
405
  database wants.
402
406
  """
407
+ if self._override is not None:
408
+ with self._override.provisioned_tables(metadata, tables=tables):
409
+ yield
410
+ return
403
411
  with self.transaction() as connection:
404
412
  metadata.create_all(connection, tables=tables)
405
413
  try:
@@ -410,6 +418,8 @@ class Database(BaseDatabase[sa.Connection, Session]):
410
418
 
411
419
  def ping(self) -> bool:
412
420
  """Whether the database answers."""
421
+ if self._override is not None:
422
+ return self._override.ping()
413
423
  try:
414
424
  with self.engine.connect() as connection:
415
425
  connection.execute(sa.text("SELECT 1"))
@@ -487,7 +497,7 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
487
497
 
488
498
  def __enter__(self) -> sa.Connection:
489
499
  stack = ExitStack()
490
- elsewhere = self.db._stand_in() # noqa: SLF001
500
+ elsewhere = self.db._override # noqa: SLF001
491
501
  if elsewhere is not None:
492
502
  connection = stack.enter_context(
493
503
  elsewhere.transaction(
@@ -53,7 +53,7 @@ DatabaseT = TypeVar("DatabaseT", bound="BaseDatabase[Any, Any]")
53
53
  class DatabaseSource(Protocol):
54
54
  """The registry an alias is looked up in: the importable one, or a stand-in."""
55
55
 
56
- def __getitem__(self, alias: str) -> BaseDatabase[Any, Any]: ...
56
+ def __getitem__(self, alias: str, /) -> BaseDatabase[Any, Any]: ...
57
57
 
58
58
 
59
59
  class DatabaseDescriptor(Generic[DatabaseT]):
@@ -8,7 +8,7 @@ from ._base import _DatabaseRegistryMixin, default_backoff, late_bound
8
8
  from ._db import Database, RetryingTransaction
9
9
 
10
10
  if TYPE_CHECKING:
11
- from collections.abc import Callable, Iterator
11
+ from collections.abc import Callable, Generator
12
12
 
13
13
  from ._base import RetryOn
14
14
  from ._db import Transaction
@@ -40,7 +40,7 @@ class Databases(_DatabaseRegistryMixin[Database], Database):
40
40
  def transactions(
41
41
  self,
42
42
  **arguments: Any, # noqa: ANN401
43
- ) -> Iterator[None]:
43
+ ) -> Generator[None]:
44
44
  """Open a transaction on every database, not the default one alone.
45
45
 
46
46
  A single database needs `transaction(rollback=True)`. A test harness
@@ -6,7 +6,7 @@ import importlib.util
6
6
  import inspect
7
7
  import itertools
8
8
  import re
9
- from collections.abc import Mapping, Sized
9
+ from collections.abc import Generator, Mapping, Sized
10
10
  from contextlib import contextmanager
11
11
  from contextvars import ContextVar
12
12
  from dataclasses import dataclass, is_dataclass
@@ -49,7 +49,7 @@ from .exceptions import (
49
49
  )
50
50
 
51
51
  if TYPE_CHECKING:
52
- from collections.abc import Callable, Iterable, Iterator, Sequence
52
+ from collections.abc import Callable, Iterable, Sequence
53
53
 
54
54
  from pydantic import BaseModel, TypeAdapter
55
55
  from sqlalchemy.sql import Executable
@@ -86,7 +86,7 @@ _calls_kept: ContextVar[bool] = ContextVar("sqlakit.calls_kept", default=False)
86
86
 
87
87
 
88
88
  @contextmanager
89
- def calls_kept() -> Iterator[None]:
89
+ def calls_kept() -> Generator[None]:
90
90
  """Write a call that cannot be made with the values at hand as the call itself.
91
91
 
92
92
  An editor shows a template rendered with made-up values, or with none: a
@@ -37,7 +37,7 @@ from sqlakit._base import (
37
37
  from sqlakit.exceptions import MissingConnectionError, TransactionRolledBackError
38
38
 
39
39
  if TYPE_CHECKING:
40
- from collections.abc import AsyncIterator, Callable, Coroutine, Sequence
40
+ from collections.abc import AsyncGenerator, Callable, Coroutine, Sequence
41
41
  from types import TracebackType
42
42
 
43
43
  from sqlalchemy.ext.asyncio import AsyncTransaction
@@ -102,6 +102,8 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
102
102
  @property
103
103
  def engine(self) -> AsyncEngine:
104
104
  """The underlying engine, created on first access."""
105
+ if self._override is not None:
106
+ return self._override.engine
105
107
  if self._engine is None:
106
108
  with self._engine_lock:
107
109
  # Two threads reaching this at once would each build one, and
@@ -127,6 +129,8 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
127
129
  MissingConnectionError: if no connection is bound.
128
130
 
129
131
  """
132
+ if self._override is not None:
133
+ return self._override.connection
130
134
  scope = self._current_scope()
131
135
  if scope.connection is None:
132
136
  cell = scope.checkout
@@ -143,6 +147,8 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
143
147
 
144
148
  async def _aconnection(self) -> AsyncConnection:
145
149
  """Return the bound connection, checking it out first if lazy."""
150
+ if self._override is not None:
151
+ return await self._override._aconnection() # noqa: SLF001
146
152
  return await self._areused(self._current_scope())
147
153
 
148
154
  def _create_session(self, connection: AsyncConnection) -> AsyncSession:
@@ -181,9 +187,9 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
181
187
  return cast("AsyncConnection", scope.connection)
182
188
 
183
189
  @asynccontextmanager
184
- async def connect(self) -> AsyncIterator[AsyncConnection]:
190
+ async def connect(self) -> AsyncGenerator[AsyncConnection]:
185
191
  """Open a connection and bind it, or reuse the one already bound."""
186
- elsewhere = self._stand_in()
192
+ elsewhere = self._override
187
193
  if elsewhere is not None:
188
194
  async with elsewhere.connect() as connection:
189
195
  yield connection
@@ -228,9 +234,9 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
228
234
  return autocommit
229
235
 
230
236
  @asynccontextmanager
231
- async def _autocommit(self) -> AsyncIterator[AsyncConnection]:
237
+ async def _autocommit(self) -> AsyncGenerator[AsyncConnection]:
232
238
  """Open a connection in ``AUTOCOMMIT`` and bind it, or join the outer one."""
233
- elsewhere = self._stand_in()
239
+ elsewhere = self._override
234
240
  if elsewhere is not None:
235
241
  async with elsewhere.autocommit() as connection:
236
242
  yield connection
@@ -358,14 +364,14 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
358
364
  return transaction
359
365
 
360
366
  @asynccontextmanager
361
- async def session_factory(self) -> AsyncIterator[AsyncSession]:
367
+ async def session_factory(self) -> AsyncGenerator[AsyncSession]:
362
368
  """Open a session for the block, and bind it.
363
369
 
364
370
  The session arrives at once, the connection on its first query or
365
371
  flush, as ``async_sessionmaker()`` does it. Inside another block it
366
372
  runs on the connection already bound.
367
373
  """
368
- elsewhere = self._stand_in()
374
+ elsewhere = self._override
369
375
  if elsewhere is not None:
370
376
  async with elsewhere.session_factory() as session:
371
377
  yield session
@@ -395,7 +401,7 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
395
401
  *,
396
402
  commit: bool = False,
397
403
  autocommit: bool = False,
398
- ) -> AsyncIterator[AsyncConnection]:
404
+ ) -> AsyncGenerator[AsyncConnection]:
399
405
  """Bind ``connection`` for the block, ending the session it opened.
400
406
 
401
407
  ``commit`` keeps that session's work. Closing a session that isolates
@@ -419,7 +425,7 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
419
425
  metadata: sa.MetaData,
420
426
  *,
421
427
  tables: Sequence[sa.Table] | None = None,
422
- ) -> AsyncIterator[None]:
428
+ ) -> AsyncGenerator[None]:
423
429
  """Create these tables here, and drop them when the block ends.
424
430
 
425
431
  A test session opens this once, around everything that needs a schema:
@@ -434,6 +440,10 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
434
440
  Every table of the metadata unless ``tables`` names fewer, as a second
435
441
  database wants.
436
442
  """
443
+ if self._override is not None:
444
+ async with self._override.provisioned_tables(metadata, tables=tables):
445
+ yield
446
+ return
437
447
  async with self.transaction() as connection:
438
448
  await connection.run_sync(metadata.create_all, tables=tables)
439
449
  try:
@@ -444,6 +454,8 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
444
454
 
445
455
  async def ping(self) -> bool:
446
456
  """Whether the database answers."""
457
+ if self._override is not None:
458
+ return await self._override.ping()
447
459
  try:
448
460
  async with self.engine.connect() as connection:
449
461
  await connection.execute(sa.text("SELECT 1"))
@@ -527,7 +539,7 @@ class Transaction(
527
539
 
528
540
  async def __aenter__(self) -> AsyncConnection:
529
541
  stack = AsyncExitStack()
530
- elsewhere = self.db._stand_in() # noqa: SLF001
542
+ elsewhere = self.db._override # noqa: SLF001
531
543
  if elsewhere is not None:
532
544
  connection = await stack.enter_async_context(
533
545
  elsewhere.transaction(
@@ -9,7 +9,7 @@ from sqlakit._base import _DatabaseRegistryMixin, default_backoff, late_bound
9
9
  from ._db import Database, RetryingTransaction
10
10
 
11
11
  if TYPE_CHECKING:
12
- from collections.abc import AsyncIterator, Callable
12
+ from collections.abc import AsyncGenerator, Callable
13
13
 
14
14
  from sqlakit._base import RetryOn
15
15
 
@@ -41,7 +41,7 @@ class Databases(_DatabaseRegistryMixin[Database], Database):
41
41
  async def transactions(
42
42
  self,
43
43
  **arguments: Any, # noqa: ANN401
44
- ) -> AsyncIterator[None]:
44
+ ) -> AsyncGenerator[None]:
45
45
  """Open a transaction on every database, not the default one alone.
46
46
 
47
47
  A test harness with more than one database needs this, where a single
@@ -42,10 +42,16 @@ from sqlakit._query import (
42
42
  from sqlakit.exceptions import InstanceNotFoundError
43
43
 
44
44
  from ._db import Database
45
- from ._registry import Databases, db
45
+ from ._registry import db
46
46
 
47
47
  if TYPE_CHECKING:
48
- from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
48
+ from collections.abc import (
49
+ AsyncGenerator,
50
+ AsyncIterator,
51
+ Iterable,
52
+ Mapping,
53
+ Sequence,
54
+ )
49
55
 
50
56
  from sqlalchemy.engine import CursorResult, Result, ScalarResult
51
57
  from sqlalchemy.orm import InstrumentedAttribute
@@ -611,8 +617,8 @@ class QueryDescriptor(Generic[QueryT]):
611
617
  def __init__(self, query_class: type[QueryT]) -> None:
612
618
  self.query_class = query_class
613
619
 
614
- # `ClassVar` cannot hold a type variable, so the base declares the query
615
- # every model has and a read narrows it to the model it is read from.
620
+ # `QueryDescriptor(Query)` reads as a query of the model it is read from,
621
+ # any other query class as itself.
616
622
  @overload
617
623
  def __get__(
618
624
  self: QueryDescriptor[Query[Any]],
@@ -644,16 +650,18 @@ class ModelMixin(BaseModel[Database]):
644
650
  [`set_db`][sqlakit.asyncio.orm.ModelMixin.set_db].
645
651
  """
646
652
 
647
- __db__: ClassVar[str | Database] = DEFAULT_ALIAS
648
- __dbs__: ClassVar[Databases] = db
653
+ __db__ = DEFAULT_ALIAS
654
+ __dbs__ = db
649
655
 
650
- query: ClassVar[QueryDescriptor[Query[Any]]] = QueryDescriptor(Query)
656
+ # Not annotated: a model's `query = UserQuery.as_descriptor()` would take
657
+ # the declared type and read as `Query[User]`.
658
+ query = QueryDescriptor(Query)
651
659
 
652
660
  db: ClassVar[DatabaseDescriptor[Database]] = DatabaseDescriptor()
653
661
 
654
662
  @classmethod
655
663
  @asynccontextmanager
656
- async def provisioned_tables(cls, alias: str | None = None) -> AsyncIterator[None]:
664
+ async def provisioned_tables(cls, alias: str | None = None) -> AsyncGenerator[None]:
657
665
  """Create the tables that belong on this model's database, and drop them after.
658
666
 
659
667
  A test session opens this once, around everything that needs a schema:
@@ -668,7 +676,7 @@ class ModelMixin(BaseModel[Database]):
668
676
  With models on more than one database, name the alias, and each database
669
677
  gets the tables of the models pointed at it.
670
678
  """
671
- db = cls.db if alias is None else cls.__dbs__[alias]
679
+ db = cls.db if alias is None else cls.dbs[alias]
672
680
  async with db.provisioned_tables(cls.metadata, tables=tables_for(cls, db)):
673
681
  yield
674
682
 
@@ -256,9 +256,7 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
256
256
 
257
257
  """
258
258
  require_pydantic()
259
- return cast(
260
- "SQLRows[OtherT]", self._as(SQLRows, type_=type_, validation=validation)
261
- )
259
+ return self._as(SQLRows, type_=type_, validation=validation)
262
260
 
263
261
  def scalars(self) -> SQLRows[Any]:
264
262
  """Read the first column of each row instead of whole rows.
@@ -40,11 +40,11 @@ from ._query import (
40
40
  orderable,
41
41
  ordered,
42
42
  )
43
- from ._registry import Databases, db
43
+ from ._registry import db
44
44
  from .exceptions import InstanceNotFoundError
45
45
 
46
46
  if TYPE_CHECKING:
47
- from collections.abc import Iterable, Iterator, Mapping, Sequence
47
+ from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence
48
48
 
49
49
  from sqlalchemy.engine import CursorResult, Result, ScalarResult
50
50
  from sqlalchemy.orm import InstrumentedAttribute
@@ -593,8 +593,8 @@ class QueryDescriptor(Generic[QueryT]):
593
593
  def __init__(self, query_class: type[QueryT]) -> None:
594
594
  self.query_class = query_class
595
595
 
596
- # `ClassVar` cannot hold a type variable, so the base declares the query
597
- # every model has and a read narrows it to the model it is read from.
596
+ # `QueryDescriptor(Query)` reads as a query of the model it is read from,
597
+ # any other query class as itself.
598
598
  @overload
599
599
  def __get__(
600
600
  self: QueryDescriptor[Query[Any]],
@@ -625,16 +625,18 @@ class ModelMixin(BaseModel[Database]):
625
625
  importable registry, or one set with [`set_db`][sqlakit.orm.ModelMixin.set_db].
626
626
  """
627
627
 
628
- __db__: ClassVar[str | Database] = DEFAULT_ALIAS
629
- __dbs__: ClassVar[Databases] = db
628
+ __db__ = DEFAULT_ALIAS
629
+ __dbs__ = db
630
630
 
631
- query: ClassVar[QueryDescriptor[Query[Any]]] = QueryDescriptor(Query)
631
+ # Not annotated: a model's `query = UserQuery.as_descriptor()` would take
632
+ # the declared type and read as `Query[User]`.
633
+ query = QueryDescriptor(Query)
632
634
 
633
635
  db: ClassVar[DatabaseDescriptor[Database]] = DatabaseDescriptor()
634
636
 
635
637
  @classmethod
636
638
  @contextmanager
637
- def provisioned_tables(cls, alias: str | None = None) -> Iterator[None]:
639
+ def provisioned_tables(cls, alias: str | None = None) -> Generator[None]:
638
640
  """Create the tables that belong on this model's database, and drop them after.
639
641
 
640
642
  A test session opens this once, around everything that needs a schema:
@@ -649,7 +651,7 @@ class ModelMixin(BaseModel[Database]):
649
651
  With models on more than one database, name the alias, and each database
650
652
  gets the tables of the models pointed at it.
651
653
  """
652
- db = cls.db if alias is None else cls.__dbs__[alias]
654
+ db = cls.db if alias is None else cls.dbs[alias]
653
655
  with db.provisioned_tables(cls.metadata, tables=tables_for(cls, db)):
654
656
  yield
655
657
 
@@ -55,7 +55,7 @@ from ._registry import db as importable_db
55
55
  from .exceptions import UnknownDatabaseError
56
56
 
57
57
  if TYPE_CHECKING:
58
- from collections.abc import AsyncIterator, Iterator, Sequence
58
+ from collections.abc import AsyncIterator, Generator, Iterator, Sequence
59
59
 
60
60
  import sqlalchemy as sa
61
61
 
@@ -346,7 +346,7 @@ def assert_queries(sqlakit_db: Any) -> AssertQueries: # noqa: ANN401
346
346
  at_most: int | None = None,
347
347
  duplicates: bool = True,
348
348
  using: Any = None, # noqa: ANN401
349
- ) -> Iterator[Recording]:
349
+ ) -> Generator[Recording]:
350
350
  require_expectation(count, at_most, duplicates)
351
351
  recording = Recording()
352
352
  with ExitStack() as stack:
@@ -402,7 +402,7 @@ async def _sqlakit_async_transaction(
402
402
 
403
403
 
404
404
  @contextmanager
405
- def _reported(request: pytest.FixtureRequest, db: Any) -> Iterator[None]: # noqa: ANN401
405
+ def _reported(request: pytest.FixtureRequest, db: Any) -> Generator[None]: # noqa: ANN401
406
406
  """Record what the test runs, for the report, when one was asked for.
407
407
 
408
408
  The recording is named after the test and filed under the file it lives in,
@@ -435,7 +435,7 @@ def _named(node: pytest.Item) -> str:
435
435
 
436
436
 
437
437
  @contextmanager
438
- def _entered(blocks: list[Any]) -> Iterator[None]:
438
+ def _entered(blocks: list[Any]) -> Generator[None]:
439
439
  """Hold these blocks open, awaiting the ones that need it.
440
440
 
441
441
  An `asyncio` database builds its schema in a coroutine, and this fixture is
@@ -246,9 +246,7 @@ class SQLQuery(SQLRows[sa.Row[Any]]):
246
246
 
247
247
  """
248
248
  require_pydantic()
249
- return cast(
250
- "SQLRows[OtherT]", self._as(SQLRows, type_=type_, validation=validation)
251
- )
249
+ return self._as(SQLRows, type_=type_, validation=validation)
252
250
 
253
251
  def scalars(self) -> SQLRows[Any]:
254
252
  """Read the first column of each row instead of whole rows.
@@ -12,7 +12,7 @@ from .exceptions import UnknownDatabaseError
12
12
  from .types import AssertQueries
13
13
 
14
14
  if TYPE_CHECKING:
15
- from collections.abc import Iterator
15
+ from collections.abc import Generator
16
16
 
17
17
  __all__ = ["AssertQueries", "assert_queries"]
18
18
 
@@ -24,7 +24,7 @@ def assert_queries(
24
24
  at_most: int | None = None,
25
25
  duplicates: bool = True,
26
26
  using: str | Any = None, # noqa: ANN401
27
- ) -> Iterator[Recording]:
27
+ ) -> Generator[Recording]:
28
28
  """Assert what the block asks of the database.
29
29
 
30
30
  ```python
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes