sqlakit 0.22.9__tar.gz → 0.22.11__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 (37) hide show
  1. {sqlakit-0.22.9 → sqlakit-0.22.11}/PKG-INFO +1 -1
  2. {sqlakit-0.22.9 → sqlakit-0.22.11}/pyproject.toml +1 -1
  3. {sqlakit-0.22.9 → sqlakit-0.22.11}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/__init__.py +3 -1
  5. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_base.py +145 -50
  6. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_db.py +37 -0
  7. sqlakit-0.22.11/sqlakit/_registry.py +215 -0
  8. sqlakit-0.22.11/sqlakit/asyncio/__init__.py +12 -0
  9. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/asyncio/_db.py +39 -0
  10. sqlakit-0.22.11/sqlakit/asyncio/_registry.py +189 -0
  11. sqlakit-0.22.9/sqlakit/_registry.py +0 -71
  12. sqlakit-0.22.9/sqlakit/asyncio/__init__.py +0 -4
  13. sqlakit-0.22.9/sqlakit/asyncio/_registry.py +0 -59
  14. {sqlakit-0.22.9 → sqlakit-0.22.11}/LICENSE +0 -0
  15. {sqlakit-0.22.9 → sqlakit-0.22.11}/README.md +0 -0
  16. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_cli.py +0 -0
  17. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_discovery.py +0 -0
  18. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_linters.py +0 -0
  19. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_model.py +0 -0
  20. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_project.py +0 -0
  21. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_pycharm.py +0 -0
  22. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_query.py +0 -0
  23. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_recording.py +0 -0
  24. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_render.py +0 -0
  25. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_routing.py +0 -0
  26. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_sql.py +0 -0
  27. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_sql_formatter.py +0 -0
  28. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/_static.py +0 -0
  29. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/asyncio/orm.py +0 -0
  30. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/asyncio/sql.py +0 -0
  31. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/exceptions.py +0 -0
  32. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/orm.py +0 -0
  33. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/py.typed +0 -0
  34. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/pytest_plugin.py +0 -0
  35. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/sql.py +0 -0
  36. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/testing.py +0 -0
  37. {sqlakit-0.22.9 → sqlakit-0.22.11}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.22.9
3
+ Version: 0.22.11
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.22.9"
3
+ version = "0.22.11"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.22.9"
3
+ version = "0.22.11"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,7 +10,7 @@ from ._query import (
10
10
  orderable_columns,
11
11
  )
12
12
  from ._recording import Recording, Statement
13
- from ._registry import Databases, db
13
+ from ._registry import Databases, autocommit, db, transaction
14
14
  from ._routing import Router
15
15
  from .exceptions import (
16
16
  DEFAULT_ALIAS,
@@ -142,8 +142,10 @@ __all__ = [
142
142
  "UnregisteredDatabaseError",
143
143
  "UrlParts",
144
144
  "ValidationArgs",
145
+ "autocommit",
145
146
  "db",
146
147
  "import_models",
147
148
  "import_string",
148
149
  "orderable_columns",
150
+ "transaction",
149
151
  ]
@@ -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:
@@ -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))
@@ -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,6 +562,8 @@ 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
 
@@ -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,6 +643,25 @@ 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
 
646
+ @contextmanager
647
+ def override(self, db: Self) -> Iterator[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
664
+
620
665
  def _outer_to_join(self) -> _Outer[ConnectionT] | None:
621
666
  """Return the outer transaction new blocks join, if there is one."""
622
667
  outer = self._outer.get(None)
@@ -811,15 +856,20 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
811
856
  """Leave everything to [`configure`][sqlakit.Databases.configure]."""
812
857
  self._default: DatabaseT | None = None
813
858
  self._aliased: dict[str, DatabaseT] = {}
859
+ self._overridden: dict[str, DatabaseT] = {}
860
+ self._override = None
814
861
  self._routers: tuple[Any, ...] = ()
815
862
  self._using: ContextVar[str | None] = ContextVar(
816
863
  f"{type(self).__name__}.using", default=None
817
864
  )
818
865
 
819
866
  def __repr__(self) -> str:
820
- if not self.is_configured:
867
+ if self._built_its_own:
868
+ return super().__repr__()
869
+ held = self._override
870
+ if held is None:
821
871
  return f"{type(self).__name__}(unconfigured)"
822
- return super().__repr__()
872
+ return f"{type(self).__name__}({held!r})"
823
873
 
824
874
  def __getitem__(self, alias: str) -> Self | DatabaseT:
825
875
  """Return the database configured as ``alias``.
@@ -831,6 +881,8 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
831
881
  UnknownDatabaseError: if nothing is configured under that alias.
832
882
 
833
883
  """
884
+ if alias in self._overridden:
885
+ return self._overridden[alias]
834
886
  if alias == DEFAULT_ALIAS:
835
887
  return self if self._default is None else self._default
836
888
  try:
@@ -839,7 +891,11 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
839
891
  raise UnknownDatabaseError(alias, self.aliases) from None
840
892
 
841
893
  def __contains__(self, alias: str) -> bool:
842
- return alias == DEFAULT_ALIAS or alias in self._aliased
894
+ return (
895
+ alias == DEFAULT_ALIAS
896
+ or alias in self._aliased
897
+ or alias in self._overridden
898
+ )
843
899
 
844
900
  def register(self, alias: str, db: DatabaseT) -> None:
845
901
  """Put a database already built under an alias.
@@ -870,6 +926,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
870
926
  if self.is_configured:
871
927
  raise DefaultAliasError
872
928
  self._default = self._named(alias, db)
929
+ self._follow_default()
873
930
  return
874
931
  if alias in self._aliased:
875
932
  raise AliasInUseError(alias)
@@ -961,6 +1018,43 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
961
1018
  db._name = alias # noqa: SLF001
962
1019
  return db
963
1020
 
1021
+ @contextmanager
1022
+ def override(
1023
+ self,
1024
+ db: DatabaseT,
1025
+ *,
1026
+ alias: str = DEFAULT_ALIAS,
1027
+ ) -> Iterator[DatabaseT]:
1028
+ """Use another database under an alias until the block ends.
1029
+
1030
+ ```python
1031
+ with Database(TEST_URL) as test_db, db.override(test_db):
1032
+ create_user("ada") # runs on test_db
1033
+ ```
1034
+
1035
+ Every thread sees it. The database stays open when the block ends, so
1036
+ close it yourself.
1037
+ """
1038
+ before = self._overridden.get(alias)
1039
+ named = getattr(db, "_name", None)
1040
+ self._overridden[alias] = self._named(alias, db)
1041
+ self._follow_default()
1042
+ try:
1043
+ yield db
1044
+ finally:
1045
+ if before is None:
1046
+ del self._overridden[alias]
1047
+ else:
1048
+ self._overridden[alias] = before
1049
+ self._follow_default()
1050
+ if named is not None:
1051
+ db._name = named # noqa: SLF001
1052
+
1053
+ def _follow_default(self) -> None:
1054
+ """Work on the database the default alias leads to, unless it is this one."""
1055
+ held = self._overridden.get(DEFAULT_ALIAS, self._default)
1056
+ self._override = None if held is self else held
1057
+
964
1058
  def using(self, target: str | DatabaseT) -> _Using:
965
1059
  """Return that database, standing in for the default one.
966
1060
 
@@ -997,6 +1091,16 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
997
1091
  UnregisteredDatabaseError: if it holds it under none.
998
1092
 
999
1093
  """
1094
+ alias = self._held_as(db)
1095
+ if alias is None:
1096
+ raise UnregisteredDatabaseError(self.aliases)
1097
+ return alias
1098
+
1099
+ def _held_as(self, db: object) -> str | None:
1100
+ """Return the alias this registry holds a database under, or None."""
1101
+ for alias, held in self._overridden.items():
1102
+ if held is db:
1103
+ return alias
1000
1104
  # A configured registry is the default database itself, and a
1001
1105
  # registered one holds it.
1002
1106
  if db is self or db is self._default:
@@ -1004,7 +1108,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1004
1108
  for alias, held in self._aliased.items():
1005
1109
  if held is db:
1006
1110
  return alias
1007
- raise UnregisteredDatabaseError(self.aliases)
1111
+ return None
1008
1112
 
1009
1113
  def route(self, *routers: Router | RouterFunction | str) -> None:
1010
1114
  """Say which database a model lives on, for models that do not say it.
@@ -1038,16 +1142,17 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1038
1142
  opened with `using()` stands in for the default database.
1039
1143
  """
1040
1144
  placement = self._routed(model) or model.__db__
1041
- override = self._using.get()
1042
- if isinstance(placement, str):
1043
- if override is not None and placement == DEFAULT_ALIAS:
1044
- placement = override
1045
- return self[placement]
1046
- # A model pinned to the database itself follows `using()` as one on the
1047
- # default alias does, when that database is the one being stood in for.
1048
- if override is not None and self[DEFAULT_ALIAS] is placement:
1049
- return self[override]
1050
- return placement
1145
+ if not isinstance(placement, str):
1146
+ # A model pinned to a database this registry holds follows the
1147
+ # alias it is held under, `using()` and `override()` alike.
1148
+ alias = self._held_as(placement)
1149
+ if alias is None:
1150
+ return placement
1151
+ placement = alias
1152
+ redirected = self._using.get()
1153
+ if redirected is not None and placement == DEFAULT_ALIAS:
1154
+ placement = redirected
1155
+ return self[placement]
1051
1156
 
1052
1157
  def _routed(self, model: type[Any]) -> str | None:
1053
1158
  """Return what the first router says about this model, if anything."""
@@ -1059,13 +1164,18 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1059
1164
 
1060
1165
  @property
1061
1166
  def aliases(self) -> tuple[str, ...]:
1062
- """The aliases configured, the default one first."""
1063
- return (DEFAULT_ALIAS, *self._aliased)
1167
+ """The aliases configured or overridden, the default one first."""
1168
+ overridden = (
1169
+ alias
1170
+ for alias in self._overridden
1171
+ if alias != DEFAULT_ALIAS and alias not in self._aliased
1172
+ )
1173
+ return (DEFAULT_ALIAS, *self._aliased, *overridden)
1064
1174
 
1065
1175
  @property
1066
1176
  def is_configured(self) -> bool:
1067
1177
  """Whether this registry has a default database to reach."""
1068
- return "url" in self.__dict__ or self._default is not None
1178
+ return "url" in self.__dict__ or self._override is not None
1069
1179
 
1070
1180
  @property
1071
1181
  def _built_its_own(self) -> bool:
@@ -1073,39 +1183,13 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1073
1183
  return "url" in self.__dict__
1074
1184
 
1075
1185
  if not TYPE_CHECKING:
1076
- # Hidden from type checkers, which keep reading these off `Database`
1077
- # and its asyncio twin, signatures and all.
1078
- def _proxy(name: str, *, attribute: bool = False) -> Any: # noqa: ANN401, N805
1079
- """Proxy to the database this registry holds, or call its own.
1080
-
1081
- A registry handed a default proxies to that database. One that
1082
- `configure` built calls what it inherits, which `super()` reaches.
1083
- """
1084
-
1085
- def reach(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
1086
- held = self._default
1087
- found = (
1088
- getattr(held, name) if held is not None else getattr(super(), name)
1089
- )
1090
- return found if attribute else found(*args, **kwargs)
1091
-
1092
- return property(reach) if attribute else reach
1093
-
1094
- connection = _proxy("connection", attribute=True)
1095
- engine = _proxy("engine", attribute=True)
1096
- session = _proxy("session", attribute=True)
1097
- sql = _proxy("sql", attribute=True)
1098
- assert_queries = _proxy("assert_queries")
1099
- autocommit = _proxy("autocommit")
1100
- connect = _proxy("connect")
1101
- in_session = _proxy("in_session")
1102
- in_transaction = _proxy("in_transaction")
1103
- ping = _proxy("ping")
1104
- provisioned_tables = _proxy("provisioned_tables")
1105
- query = _proxy("query")
1106
- session_factory = _proxy("session_factory")
1107
- transaction = _proxy("transaction")
1108
- del _proxy
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
1109
1193
 
1110
1194
  @overload
1111
1195
  def configure(
@@ -1375,6 +1459,17 @@ class BaseRetryingTransaction:
1375
1459
  raise AssertionError # pragma: no cover
1376
1460
 
1377
1461
 
1462
+ def late_bound(source: object, registry: Any) -> Any: # noqa: ANN401
1463
+ """Return the database ``using=`` points at: the registry, an alias, or a call."""
1464
+ if source is None:
1465
+ return registry
1466
+ if isinstance(source, str):
1467
+ return registry[source]
1468
+ if callable(source):
1469
+ return source()
1470
+ return source
1471
+
1472
+
1378
1473
  def retry_matches(exc: BaseException, retry_on: RetryOn) -> bool:
1379
1474
  """Whether ``retry_on`` claims this exception is worth another attempt."""
1380
1475
  if isinstance(retry_on, type | tuple):
@@ -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:
@@ -149,6 +153,11 @@ class Database(BaseDatabase[sa.Connection, Session]):
149
153
  @contextmanager
150
154
  def connect(self) -> Iterator[sa.Connection]:
151
155
  """Open a connection and bind it, or reuse the one already bound."""
156
+ elsewhere = self._override
157
+ if elsewhere is not None:
158
+ with elsewhere.connect() as connection:
159
+ yield connection
160
+ return
152
161
  reuse = self._scope_to_reuse()
153
162
  if reuse is not None:
154
163
  with self._bound(self._reused(reuse)) as connection:
@@ -191,6 +200,11 @@ class Database(BaseDatabase[sa.Connection, Session]):
191
200
  @contextmanager
192
201
  def _autocommit(self) -> Iterator[sa.Connection]:
193
202
  """Open a connection in ``AUTOCOMMIT`` and bind it, or join the outer one."""
203
+ elsewhere = self._override
204
+ if elsewhere is not None:
205
+ with elsewhere.autocommit() as connection:
206
+ yield connection
207
+ return
194
208
  outer = self._connection_to_join()
195
209
  if outer is not None:
196
210
  with self._bound(outer, commit=True) as connection:
@@ -321,6 +335,11 @@ class Database(BaseDatabase[sa.Connection, Session]):
321
335
  flush, as ``sessionmaker()`` does it. Inside another block it runs on
322
336
  the connection already bound.
323
337
  """
338
+ elsewhere = self._override
339
+ if elsewhere is not None:
340
+ with elsewhere.session_factory() as session:
341
+ yield session
342
+ return
324
343
  reuse = self._scope_to_reuse()
325
344
  if reuse is not None:
326
345
  with self._bound(self._reused(reuse)):
@@ -385,6 +404,10 @@ class Database(BaseDatabase[sa.Connection, Session]):
385
404
  Every table of the metadata unless ``tables`` names fewer, as a second
386
405
  database wants.
387
406
  """
407
+ if self._override is not None:
408
+ with self._override.provisioned_tables(metadata, tables=tables):
409
+ yield
410
+ return
388
411
  with self.transaction() as connection:
389
412
  metadata.create_all(connection, tables=tables)
390
413
  try:
@@ -395,6 +418,8 @@ class Database(BaseDatabase[sa.Connection, Session]):
395
418
 
396
419
  def ping(self) -> bool:
397
420
  """Whether the database answers."""
421
+ if self._override is not None:
422
+ return self._override.ping()
398
423
  try:
399
424
  with self.engine.connect() as connection:
400
425
  connection.execute(sa.text("SELECT 1"))
@@ -472,6 +497,18 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
472
497
 
473
498
  def __enter__(self) -> sa.Connection:
474
499
  stack = ExitStack()
500
+ elsewhere = self.db._override # noqa: SLF001
501
+ if elsewhere is not None:
502
+ connection = stack.enter_context(
503
+ elsewhere.transaction(
504
+ savepoint=self.savepoint,
505
+ join_nested=self.join_nested,
506
+ rollback=self.rollback,
507
+ commit_on_error=self.commit_on_error,
508
+ )
509
+ )
510
+ self._stacks.append(stack)
511
+ return connection
475
512
  try:
476
513
  outer, savepoint = self.db._plan( # noqa: SLF001
477
514
  savepoint=self.savepoint,
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from contextlib import ExitStack, contextmanager
5
+ from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
6
+
7
+ from ._base import _DatabaseRegistryMixin, default_backoff, late_bound
8
+ from ._db import Database, RetryingTransaction
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable, Iterator
12
+
13
+ from ._base import RetryOn
14
+ from ._db import Transaction
15
+
16
+ __all__ = ["Databases", "autocommit", "db", "transaction"]
17
+
18
+ _FuncT = TypeVar("_FuncT", bound="Callable[..., Any]")
19
+
20
+
21
+ class Databases(_DatabaseRegistryMixin[Database], Database):
22
+ """The databases an application talks to, and the default one among them.
23
+
24
+ ```python
25
+ from sqlakit import db
26
+
27
+ db.configure(DB_URL)
28
+
29
+ db.session # the default database
30
+ db["replica"].session # another, if one was configured
31
+ ```
32
+
33
+ Use it when one database is enough and passing a handle around is not worth
34
+ it. `Database(url)` is the alternative.
35
+ """
36
+
37
+ _database_class = Database
38
+
39
+ @contextmanager
40
+ def transactions(
41
+ self,
42
+ **arguments: Any, # noqa: ANN401
43
+ ) -> Iterator[None]:
44
+ """Open a transaction on every database, not the default one alone.
45
+
46
+ A single database needs `transaction(rollback=True)`. A test harness
47
+ with several needs this:
48
+
49
+ ```python
50
+ with db.transactions(rollback=True):
51
+ yield
52
+ ```
53
+ """
54
+ with ExitStack() as stack:
55
+ for alias in self.aliases:
56
+ stack.enter_context(self[alias].transaction(**arguments))
57
+ yield
58
+
59
+ def dispose(self, *, close: bool = True) -> None:
60
+ """Dispose of every configured database, not just the default one."""
61
+ if self._built_its_own:
62
+ super().dispose(close=close)
63
+ elif self._default is not None:
64
+ self._default.dispose(close=close)
65
+ for db in self._aliased.values():
66
+ db.dispose(close=close)
67
+
68
+
69
+ db = Databases()
70
+ """The importable registry: one [`Databases`][sqlakit.Databases] for the process.
71
+
72
+ `db.configure(url)` fills it, and every module then reaches the same connections
73
+ by importing it. Reconfiguring is allowed until something connects, after which
74
+ it raises
75
+ [`DatabaseAlreadyConfiguredError`][sqlakit.DatabaseAlreadyConfiguredError]
76
+ and `dispose()` has to come first.
77
+ """
78
+
79
+
80
+ @overload
81
+ def transaction(func: _FuncT, /) -> _FuncT: ...
82
+
83
+
84
+ @overload
85
+ def transaction(
86
+ *,
87
+ using: str | Database | Callable[[], Database] | None = None,
88
+ savepoint: bool = False,
89
+ join_nested: bool = True,
90
+ rollback: bool = False,
91
+ commit_on_error: type[BaseException]
92
+ | tuple[type[BaseException], ...]
93
+ | None = None,
94
+ retry_on: None = None,
95
+ max_retries: int = 3,
96
+ backoff: Callable[[int], float] = default_backoff,
97
+ ) -> Callable[[_FuncT], _FuncT]: ...
98
+
99
+
100
+ @overload
101
+ def transaction(
102
+ *,
103
+ using: str | Database | Callable[[], Database] | None = None,
104
+ savepoint: bool = False,
105
+ join_nested: bool = True,
106
+ rollback: bool = False,
107
+ commit_on_error: type[BaseException]
108
+ | tuple[type[BaseException], ...]
109
+ | None = None,
110
+ retry_on: RetryOn,
111
+ max_retries: int = 3,
112
+ backoff: Callable[[int], float] = default_backoff,
113
+ ) -> RetryingTransaction: ...
114
+
115
+
116
+ def transaction( # noqa: PLR0913 (all keyword-only, as on `Database`)
117
+ func: _FuncT | None = None,
118
+ /,
119
+ *,
120
+ using: str | Database | Callable[[], Database] | None = None,
121
+ savepoint: bool = False,
122
+ join_nested: bool = True,
123
+ rollback: bool = False,
124
+ commit_on_error: type[BaseException]
125
+ | tuple[type[BaseException], ...]
126
+ | None = None,
127
+ retry_on: RetryOn | None = None,
128
+ max_retries: int = 3,
129
+ backoff: Callable[[int], float] = default_backoff,
130
+ ) -> _FuncT | Callable[[_FuncT], _FuncT] | RetryingTransaction:
131
+ """Like `Database.transaction`, but find the database on every call.
132
+
133
+ ```python
134
+ @transaction
135
+ def import_users() -> None: ...
136
+
137
+
138
+ @transaction(using="replica")
139
+ def rebuild_report() -> None: ...
140
+
141
+
142
+ @transaction(using=lambda: container.resolve(Database))
143
+ def create_user(name: str) -> None: ...
144
+ ```
145
+
146
+ ``using`` is an alias in [`sqlakit.db`][sqlakit.db], or a function that
147
+ returns the database. Without it, the default database of `sqlakit.db`.
148
+ The other arguments are those of `Database.transaction`.
149
+ """
150
+
151
+ def opened() -> Transaction:
152
+ found: Database = late_bound(using, db)
153
+ return found.transaction(
154
+ savepoint=savepoint,
155
+ join_nested=join_nested,
156
+ rollback=rollback,
157
+ commit_on_error=commit_on_error,
158
+ )
159
+
160
+ decorate: Callable[[_FuncT], _FuncT] | RetryingTransaction = (
161
+ _opening(opened)
162
+ if retry_on is None
163
+ else RetryingTransaction(
164
+ opened, retry_on=retry_on, max_retries=max_retries, backoff=backoff
165
+ )
166
+ )
167
+ if func is not None:
168
+ return decorate(func)
169
+ return decorate
170
+
171
+
172
+ @overload
173
+ def autocommit(func: _FuncT, /) -> _FuncT: ...
174
+
175
+
176
+ @overload
177
+ def autocommit(
178
+ *,
179
+ using: str | Database | Callable[[], Database] | None = None,
180
+ ) -> Callable[[_FuncT], _FuncT]: ...
181
+
182
+
183
+ def autocommit(
184
+ func: _FuncT | None = None,
185
+ /,
186
+ *,
187
+ using: str | Database | Callable[[], Database] | None = None,
188
+ ) -> _FuncT | Callable[[_FuncT], _FuncT]:
189
+ """Like `Database.autocommit`, but find the database on every call.
190
+
191
+ ``using`` works as in [`transaction`][sqlakit.transaction].
192
+ """
193
+
194
+ def opened() -> Any: # noqa: ANN401
195
+ found: Database = late_bound(using, db)
196
+ return found.autocommit()
197
+
198
+ decorate: Callable[[_FuncT], _FuncT] = _opening(opened)
199
+ if func is not None:
200
+ return decorate(func)
201
+ return decorate
202
+
203
+
204
+ def _opening(opened: Callable[[], Any]) -> Callable[[_FuncT], _FuncT]:
205
+ """Return a decorator that runs the function inside `opened()`."""
206
+
207
+ def decorate(func: _FuncT) -> _FuncT:
208
+ @functools.wraps(func)
209
+ def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
210
+ with opened():
211
+ return func(*args, **kwargs)
212
+
213
+ return cast("_FuncT", wrapper)
214
+
215
+ return decorate
@@ -0,0 +1,12 @@
1
+ from ._db import Database, RetryingTransaction, Transaction
2
+ from ._registry import Databases, autocommit, db, transaction
3
+
4
+ __all__ = [
5
+ "Database",
6
+ "Databases",
7
+ "RetryingTransaction",
8
+ "Transaction",
9
+ "autocommit",
10
+ "db",
11
+ "transaction",
12
+ ]
@@ -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:
@@ -183,6 +189,11 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
183
189
  @asynccontextmanager
184
190
  async def connect(self) -> AsyncIterator[AsyncConnection]:
185
191
  """Open a connection and bind it, or reuse the one already bound."""
192
+ elsewhere = self._override
193
+ if elsewhere is not None:
194
+ async with elsewhere.connect() as connection:
195
+ yield connection
196
+ return
186
197
  reuse = self._scope_to_reuse()
187
198
  if reuse is not None:
188
199
  async with self._bound(await self._areused(reuse)) as connection:
@@ -225,6 +236,11 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
225
236
  @asynccontextmanager
226
237
  async def _autocommit(self) -> AsyncIterator[AsyncConnection]:
227
238
  """Open a connection in ``AUTOCOMMIT`` and bind it, or join the outer one."""
239
+ elsewhere = self._override
240
+ if elsewhere is not None:
241
+ async with elsewhere.autocommit() as connection:
242
+ yield connection
243
+ return
228
244
  outer = self._connection_to_join()
229
245
  if outer is not None:
230
246
  async with self._bound(outer, commit=True) as connection:
@@ -355,6 +371,11 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
355
371
  flush, as ``async_sessionmaker()`` does it. Inside another block it
356
372
  runs on the connection already bound.
357
373
  """
374
+ elsewhere = self._override
375
+ if elsewhere is not None:
376
+ async with elsewhere.session_factory() as session:
377
+ yield session
378
+ return
358
379
  reuse = self._scope_to_reuse()
359
380
  if reuse is not None:
360
381
  async with self._bound(await self._areused(reuse)):
@@ -419,6 +440,10 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
419
440
  Every table of the metadata unless ``tables`` names fewer, as a second
420
441
  database wants.
421
442
  """
443
+ if self._override is not None:
444
+ async with self._override.provisioned_tables(metadata, tables=tables):
445
+ yield
446
+ return
422
447
  async with self.transaction() as connection:
423
448
  await connection.run_sync(metadata.create_all, tables=tables)
424
449
  try:
@@ -429,6 +454,8 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
429
454
 
430
455
  async def ping(self) -> bool:
431
456
  """Whether the database answers."""
457
+ if self._override is not None:
458
+ return await self._override.ping()
432
459
  try:
433
460
  async with self.engine.connect() as connection:
434
461
  await connection.execute(sa.text("SELECT 1"))
@@ -512,6 +539,18 @@ class Transaction(
512
539
 
513
540
  async def __aenter__(self) -> AsyncConnection:
514
541
  stack = AsyncExitStack()
542
+ elsewhere = self.db._override # noqa: SLF001
543
+ if elsewhere is not None:
544
+ connection = await stack.enter_async_context(
545
+ elsewhere.transaction(
546
+ savepoint=self.savepoint,
547
+ join_nested=self.join_nested,
548
+ rollback=self.rollback,
549
+ commit_on_error=self.commit_on_error,
550
+ )
551
+ )
552
+ self._stacks.append(stack)
553
+ return connection
515
554
  try:
516
555
  outer, savepoint = self.db._plan( # noqa: SLF001
517
556
  savepoint=self.savepoint,
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ from contextlib import AsyncExitStack, asynccontextmanager
5
+ from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
6
+
7
+ from sqlakit._base import _DatabaseRegistryMixin, default_backoff, late_bound
8
+
9
+ from ._db import Database, RetryingTransaction
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import AsyncIterator, Callable
13
+
14
+ from sqlakit._base import RetryOn
15
+
16
+ from ._db import Transaction
17
+
18
+ __all__ = ["Databases", "autocommit", "db", "transaction"]
19
+
20
+ _FuncT = TypeVar("_FuncT", bound="Callable[..., Any]")
21
+
22
+
23
+ class Databases(_DatabaseRegistryMixin[Database], Database):
24
+ """The databases an application talks to, and the default one among them.
25
+
26
+ The asyncio counterpart of [`sqlakit.Databases`][sqlakit.Databases].
27
+
28
+ ```python
29
+ from sqlakit.asyncio import db
30
+
31
+ db.configure(DB_URL)
32
+
33
+ db.session # the default database
34
+ db["replica"].session # another, if one was configured
35
+ ```
36
+ """
37
+
38
+ _database_class = Database
39
+
40
+ @asynccontextmanager
41
+ async def transactions(
42
+ self,
43
+ **arguments: Any, # noqa: ANN401
44
+ ) -> AsyncIterator[None]:
45
+ """Open a transaction on every database, not the default one alone.
46
+
47
+ A test harness with more than one database needs this, where a single
48
+ database needs `transaction(rollback=True)`.
49
+ """
50
+ async with AsyncExitStack() as stack:
51
+ for alias in self.aliases:
52
+ await stack.enter_async_context(self[alias].transaction(**arguments))
53
+ yield
54
+
55
+ async def dispose(self, *, close: bool = True) -> None:
56
+ """Dispose of every configured database, not just the default one."""
57
+ if self._built_its_own:
58
+ await super().dispose(close=close)
59
+ elif self._default is not None:
60
+ await self._default.dispose(close=close)
61
+ for db in self._aliased.values():
62
+ await db.dispose(close=close)
63
+
64
+
65
+ db = Databases()
66
+ """The database an application talks to. Configure it once, use it anywhere."""
67
+
68
+
69
+ @overload
70
+ def transaction(func: _FuncT, /) -> _FuncT: ...
71
+
72
+
73
+ @overload
74
+ def transaction(
75
+ *,
76
+ using: str | Database | Callable[[], Database] | None = None,
77
+ savepoint: bool = False,
78
+ join_nested: bool = True,
79
+ rollback: bool = False,
80
+ commit_on_error: type[BaseException]
81
+ | tuple[type[BaseException], ...]
82
+ | None = None,
83
+ retry_on: None = None,
84
+ max_retries: int = 3,
85
+ backoff: Callable[[int], float] = default_backoff,
86
+ ) -> Callable[[_FuncT], _FuncT]: ...
87
+
88
+
89
+ @overload
90
+ def transaction(
91
+ *,
92
+ using: str | Database | Callable[[], Database] | None = None,
93
+ savepoint: bool = False,
94
+ join_nested: bool = True,
95
+ rollback: bool = False,
96
+ commit_on_error: type[BaseException]
97
+ | tuple[type[BaseException], ...]
98
+ | None = None,
99
+ retry_on: RetryOn,
100
+ max_retries: int = 3,
101
+ backoff: Callable[[int], float] = default_backoff,
102
+ ) -> RetryingTransaction: ...
103
+
104
+
105
+ def transaction( # noqa: PLR0913 (all keyword-only, as on `Database`)
106
+ func: _FuncT | None = None,
107
+ /,
108
+ *,
109
+ using: str | Database | Callable[[], Database] | None = None,
110
+ savepoint: bool = False,
111
+ join_nested: bool = True,
112
+ rollback: bool = False,
113
+ commit_on_error: type[BaseException]
114
+ | tuple[type[BaseException], ...]
115
+ | None = None,
116
+ retry_on: RetryOn | None = None,
117
+ max_retries: int = 3,
118
+ backoff: Callable[[int], float] = default_backoff,
119
+ ) -> _FuncT | Callable[[_FuncT], _FuncT] | RetryingTransaction:
120
+ """Like `Database.transaction`, but find the database on every call.
121
+
122
+ The asyncio version of [`sqlakit.transaction`][sqlakit.transaction].
123
+ """
124
+
125
+ def opened() -> Transaction:
126
+ found: Database = late_bound(using, db)
127
+ return found.transaction(
128
+ savepoint=savepoint,
129
+ join_nested=join_nested,
130
+ rollback=rollback,
131
+ commit_on_error=commit_on_error,
132
+ )
133
+
134
+ decorate: Callable[[_FuncT], _FuncT] | RetryingTransaction = (
135
+ _opening(opened)
136
+ if retry_on is None
137
+ else RetryingTransaction(
138
+ opened, retry_on=retry_on, max_retries=max_retries, backoff=backoff
139
+ )
140
+ )
141
+ if func is not None:
142
+ return decorate(func)
143
+ return decorate
144
+
145
+
146
+ @overload
147
+ def autocommit(func: _FuncT, /) -> _FuncT: ...
148
+
149
+
150
+ @overload
151
+ def autocommit(
152
+ *,
153
+ using: str | Database | Callable[[], Database] | None = None,
154
+ ) -> Callable[[_FuncT], _FuncT]: ...
155
+
156
+
157
+ def autocommit(
158
+ func: _FuncT | None = None,
159
+ /,
160
+ *,
161
+ using: str | Database | Callable[[], Database] | None = None,
162
+ ) -> _FuncT | Callable[[_FuncT], _FuncT]:
163
+ """Like `Database.autocommit`, but find the database on every call.
164
+
165
+ The asyncio version of [`sqlakit.autocommit`][sqlakit.autocommit].
166
+ """
167
+
168
+ def opened() -> Any: # noqa: ANN401
169
+ found: Database = late_bound(using, db)
170
+ return found.autocommit()
171
+
172
+ decorate: Callable[[_FuncT], _FuncT] = _opening(opened)
173
+ if func is not None:
174
+ return decorate(func)
175
+ return decorate
176
+
177
+
178
+ def _opening(opened: Callable[[], Any]) -> Callable[[_FuncT], _FuncT]:
179
+ """Return a decorator that runs the function inside `opened()`."""
180
+
181
+ def decorate(func: _FuncT) -> _FuncT:
182
+ @functools.wraps(func)
183
+ async def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
184
+ async with opened():
185
+ return await func(*args, **kwargs)
186
+
187
+ return cast("_FuncT", wrapper)
188
+
189
+ return decorate
@@ -1,71 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from contextlib import ExitStack, contextmanager
4
- from typing import TYPE_CHECKING, Any
5
-
6
- from ._base import _DatabaseRegistryMixin
7
- from ._db import Database
8
-
9
- if TYPE_CHECKING:
10
- from collections.abc import Iterator
11
-
12
- __all__ = ["Databases", "db"]
13
-
14
-
15
- class Databases(_DatabaseRegistryMixin[Database], Database):
16
- """The databases an application talks to, and the default one among them.
17
-
18
- ```python
19
- from sqlakit import db
20
-
21
- db.configure(DB_URL)
22
-
23
- db.session # the default database
24
- db["replica"].session # another, if one was configured
25
- ```
26
-
27
- Use it when one database is enough and passing a handle around is not worth
28
- it. `Database(url)` is the alternative.
29
- """
30
-
31
- _database_class = Database
32
-
33
- @contextmanager
34
- def transactions(
35
- self,
36
- **arguments: Any, # noqa: ANN401
37
- ) -> Iterator[None]:
38
- """Open a transaction on every database, not the default one alone.
39
-
40
- A single database needs `transaction(rollback=True)`. A test harness
41
- with several needs this:
42
-
43
- ```python
44
- with db.transactions(rollback=True):
45
- yield
46
- ```
47
- """
48
- with ExitStack() as stack:
49
- for alias in self.aliases:
50
- stack.enter_context(self[alias].transaction(**arguments))
51
- yield
52
-
53
- def dispose(self, *, close: bool = True) -> None:
54
- """Dispose of every configured database, not just the default one."""
55
- if self._built_its_own:
56
- super().dispose(close=close)
57
- elif self._default is not None:
58
- self._default.dispose(close=close)
59
- for db in self._aliased.values():
60
- db.dispose(close=close)
61
-
62
-
63
- db = Databases()
64
- """The importable registry: one [`Databases`][sqlakit.Databases] for the process.
65
-
66
- `db.configure(url)` fills it, and every module then reaches the same connections
67
- by importing it. Reconfiguring is allowed until something connects, after which
68
- it raises
69
- [`DatabaseAlreadyConfiguredError`][sqlakit.DatabaseAlreadyConfiguredError]
70
- and `dispose()` has to come first.
71
- """
@@ -1,4 +0,0 @@
1
- from ._db import Database, RetryingTransaction, Transaction
2
- from ._registry import Databases, db
3
-
4
- __all__ = ["Database", "Databases", "RetryingTransaction", "Transaction", "db"]
@@ -1,59 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from contextlib import AsyncExitStack, asynccontextmanager
4
- from typing import TYPE_CHECKING, Any
5
-
6
- from sqlakit._base import _DatabaseRegistryMixin
7
-
8
- from ._db import Database
9
-
10
- if TYPE_CHECKING:
11
- from collections.abc import AsyncIterator
12
-
13
- __all__ = ["Databases", "db"]
14
-
15
-
16
- class Databases(_DatabaseRegistryMixin[Database], Database):
17
- """The databases an application talks to, and the default one among them.
18
-
19
- The asyncio counterpart of [`sqlakit.Databases`][sqlakit.Databases].
20
-
21
- ```python
22
- from sqlakit.asyncio import db
23
-
24
- db.configure(DB_URL)
25
-
26
- db.session # the default database
27
- db["replica"].session # another, if one was configured
28
- ```
29
- """
30
-
31
- _database_class = Database
32
-
33
- @asynccontextmanager
34
- async def transactions(
35
- self,
36
- **arguments: Any, # noqa: ANN401
37
- ) -> AsyncIterator[None]:
38
- """Open a transaction on every database, not the default one alone.
39
-
40
- A test harness with more than one database needs this, where a single
41
- database needs `transaction(rollback=True)`.
42
- """
43
- async with AsyncExitStack() as stack:
44
- for alias in self.aliases:
45
- await stack.enter_async_context(self[alias].transaction(**arguments))
46
- yield
47
-
48
- async def dispose(self, *, close: bool = True) -> None:
49
- """Dispose of every configured database, not just the default one."""
50
- if self._built_its_own:
51
- await super().dispose(close=close)
52
- elif self._default is not None:
53
- await self._default.dispose(close=close)
54
- for db in self._aliased.values():
55
- await db.dispose(close=close)
56
-
57
-
58
- db = Databases()
59
- """The database an application talks to. Configure it once, use it anywhere."""
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
File without changes
File without changes
File without changes
File without changes
File without changes