sqlakit 0.22.8__tar.gz → 0.22.10__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.8 → sqlakit-0.22.10}/PKG-INFO +1 -1
  2. {sqlakit-0.22.8 → sqlakit-0.22.10}/pyproject.toml +1 -1
  3. {sqlakit-0.22.8 → sqlakit-0.22.10}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/__init__.py +3 -1
  5. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_base.py +99 -24
  6. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_db.py +27 -0
  7. sqlakit-0.22.10/sqlakit/_registry.py +215 -0
  8. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_sql.py +4 -1
  9. sqlakit-0.22.10/sqlakit/asyncio/__init__.py +12 -0
  10. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/asyncio/_db.py +27 -0
  11. sqlakit-0.22.10/sqlakit/asyncio/_registry.py +189 -0
  12. sqlakit-0.22.8/sqlakit/_registry.py +0 -71
  13. sqlakit-0.22.8/sqlakit/asyncio/__init__.py +0 -4
  14. sqlakit-0.22.8/sqlakit/asyncio/_registry.py +0 -59
  15. {sqlakit-0.22.8 → sqlakit-0.22.10}/LICENSE +0 -0
  16. {sqlakit-0.22.8 → sqlakit-0.22.10}/README.md +0 -0
  17. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_cli.py +0 -0
  18. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_discovery.py +0 -0
  19. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_linters.py +0 -0
  20. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_model.py +0 -0
  21. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_project.py +0 -0
  22. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_pycharm.py +0 -0
  23. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_query.py +0 -0
  24. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_recording.py +0 -0
  25. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_render.py +0 -0
  26. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_routing.py +0 -0
  27. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_sql_formatter.py +0 -0
  28. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/_static.py +0 -0
  29. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/asyncio/orm.py +0 -0
  30. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/asyncio/sql.py +0 -0
  31. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/exceptions.py +0 -0
  32. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/orm.py +0 -0
  33. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/py.typed +0 -0
  34. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/pytest_plugin.py +0 -0
  35. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/sql.py +0 -0
  36. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/testing.py +0 -0
  37. {sqlakit-0.22.8 → sqlakit-0.22.10}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.22.8
3
+ Version: 0.22.10
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.8"
3
+ version = "0.22.10"
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.8"
3
+ version = "0.22.10"
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
  ]
@@ -617,6 +617,10 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
617
617
  outer = self._outer_to_join()
618
618
  return outer, savepoint or rollback or bool(outer and outer.savepoint)
619
619
 
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
623
+
620
624
  def _outer_to_join(self) -> _Outer[ConnectionT] | None:
621
625
  """Return the outer transaction new blocks join, if there is one."""
622
626
  outer = self._outer.get(None)
@@ -811,15 +815,19 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
811
815
  """Leave everything to [`configure`][sqlakit.Databases.configure]."""
812
816
  self._default: DatabaseT | None = None
813
817
  self._aliased: dict[str, DatabaseT] = {}
818
+ self._overridden: dict[str, DatabaseT] = {}
814
819
  self._routers: tuple[Any, ...] = ()
815
820
  self._using: ContextVar[str | None] = ContextVar(
816
821
  f"{type(self).__name__}.using", default=None
817
822
  )
818
823
 
819
824
  def __repr__(self) -> str:
820
- if not self.is_configured:
825
+ if self._built_its_own:
826
+ return super().__repr__()
827
+ held = self._stand_in()
828
+ if held is None:
821
829
  return f"{type(self).__name__}(unconfigured)"
822
- return super().__repr__()
830
+ return f"{type(self).__name__}({held!r})"
823
831
 
824
832
  def __getitem__(self, alias: str) -> Self | DatabaseT:
825
833
  """Return the database configured as ``alias``.
@@ -831,6 +839,8 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
831
839
  UnknownDatabaseError: if nothing is configured under that alias.
832
840
 
833
841
  """
842
+ if alias in self._overridden:
843
+ return self._overridden[alias]
834
844
  if alias == DEFAULT_ALIAS:
835
845
  return self if self._default is None else self._default
836
846
  try:
@@ -839,7 +849,11 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
839
849
  raise UnknownDatabaseError(alias, self.aliases) from None
840
850
 
841
851
  def __contains__(self, alias: str) -> bool:
842
- return alias == DEFAULT_ALIAS or alias in self._aliased
852
+ return (
853
+ alias == DEFAULT_ALIAS
854
+ or alias in self._aliased
855
+ or alias in self._overridden
856
+ )
843
857
 
844
858
  def register(self, alias: str, db: DatabaseT) -> None:
845
859
  """Put a database already built under an alias.
@@ -961,6 +975,41 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
961
975
  db._name = alias # noqa: SLF001
962
976
  return db
963
977
 
978
+ @contextmanager
979
+ def override(
980
+ self,
981
+ db: DatabaseT,
982
+ *,
983
+ alias: str = DEFAULT_ALIAS,
984
+ ) -> Iterator[DatabaseT]:
985
+ """Use another database under an alias until the block ends.
986
+
987
+ ```python
988
+ with Database(TEST_URL) as test_db, db.override(test_db):
989
+ create_user("ada") # runs on test_db
990
+ ```
991
+
992
+ Every thread sees it. The database stays open when the block ends, so
993
+ close it yourself.
994
+ """
995
+ before = self._overridden.get(alias)
996
+ named = getattr(db, "_name", None)
997
+ self._overridden[alias] = self._named(alias, db)
998
+ try:
999
+ yield db
1000
+ finally:
1001
+ if before is None:
1002
+ del self._overridden[alias]
1003
+ else:
1004
+ self._overridden[alias] = before
1005
+ if named is not None:
1006
+ db._name = named # noqa: SLF001
1007
+
1008
+ def _stand_in(self) -> DatabaseT | None:
1009
+ """Return the database the default alias leads to, if not this registry."""
1010
+ held = self._overridden.get(DEFAULT_ALIAS, self._default)
1011
+ return None if held is self else held
1012
+
964
1013
  def using(self, target: str | DatabaseT) -> _Using:
965
1014
  """Return that database, standing in for the default one.
966
1015
 
@@ -997,6 +1046,16 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
997
1046
  UnregisteredDatabaseError: if it holds it under none.
998
1047
 
999
1048
  """
1049
+ alias = self._held_as(db)
1050
+ if alias is None:
1051
+ raise UnregisteredDatabaseError(self.aliases)
1052
+ return alias
1053
+
1054
+ def _held_as(self, db: object) -> str | None:
1055
+ """Return the alias this registry holds a database under, or None."""
1056
+ for alias, held in self._overridden.items():
1057
+ if held is db:
1058
+ return alias
1000
1059
  # A configured registry is the default database itself, and a
1001
1060
  # registered one holds it.
1002
1061
  if db is self or db is self._default:
@@ -1004,7 +1063,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1004
1063
  for alias, held in self._aliased.items():
1005
1064
  if held is db:
1006
1065
  return alias
1007
- raise UnregisteredDatabaseError(self.aliases)
1066
+ return None
1008
1067
 
1009
1068
  def route(self, *routers: Router | RouterFunction | str) -> None:
1010
1069
  """Say which database a model lives on, for models that do not say it.
@@ -1038,16 +1097,17 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1038
1097
  opened with `using()` stands in for the default database.
1039
1098
  """
1040
1099
  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
1100
+ if not isinstance(placement, str):
1101
+ # A model pinned to a database this registry holds follows the
1102
+ # alias it is held under, `using()` and `override()` alike.
1103
+ alias = self._held_as(placement)
1104
+ if alias is None:
1105
+ return placement
1106
+ placement = alias
1107
+ redirected = self._using.get()
1108
+ if redirected is not None and placement == DEFAULT_ALIAS:
1109
+ placement = redirected
1110
+ return self[placement]
1051
1111
 
1052
1112
  def _routed(self, model: type[Any]) -> str | None:
1053
1113
  """Return what the first router says about this model, if anything."""
@@ -1059,13 +1119,18 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1059
1119
 
1060
1120
  @property
1061
1121
  def aliases(self) -> tuple[str, ...]:
1062
- """The aliases configured, the default one first."""
1063
- return (DEFAULT_ALIAS, *self._aliased)
1122
+ """The aliases configured or overridden, the default one first."""
1123
+ overridden = (
1124
+ alias
1125
+ for alias in self._overridden
1126
+ if alias != DEFAULT_ALIAS and alias not in self._aliased
1127
+ )
1128
+ return (DEFAULT_ALIAS, *self._aliased, *overridden)
1064
1129
 
1065
1130
  @property
1066
1131
  def is_configured(self) -> bool:
1067
1132
  """Whether this registry has a default database to reach."""
1068
- return "url" in self.__dict__ or self._default is not None
1133
+ return "url" in self.__dict__ or self._stand_in() is not None
1069
1134
 
1070
1135
  @property
1071
1136
  def _built_its_own(self) -> bool:
@@ -1078,12 +1143,13 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1078
1143
  def _proxy(name: str, *, attribute: bool = False) -> Any: # noqa: ANN401, N805
1079
1144
  """Proxy to the database this registry holds, or call its own.
1080
1145
 
1081
- A registry handed a default proxies to that database. One that
1082
- `configure` built calls what it inherits, which `super()` reaches.
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.
1083
1149
  """
1084
1150
 
1085
1151
  def reach(self: Any, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
1086
- held = self._default
1152
+ held = self._stand_in()
1087
1153
  found = (
1088
1154
  getattr(held, name) if held is not None else getattr(super(), name)
1089
1155
  )
@@ -1096,16 +1162,14 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
1096
1162
  session = _proxy("session", attribute=True)
1097
1163
  sql = _proxy("sql", attribute=True)
1098
1164
  assert_queries = _proxy("assert_queries")
1099
- autocommit = _proxy("autocommit")
1100
- connect = _proxy("connect")
1101
1165
  in_session = _proxy("in_session")
1102
1166
  in_transaction = _proxy("in_transaction")
1103
1167
  ping = _proxy("ping")
1104
1168
  provisioned_tables = _proxy("provisioned_tables")
1105
1169
  query = _proxy("query")
1106
- session_factory = _proxy("session_factory")
1107
- transaction = _proxy("transaction")
1108
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.
1109
1173
 
1110
1174
  @overload
1111
1175
  def configure(
@@ -1375,6 +1439,17 @@ class BaseRetryingTransaction:
1375
1439
  raise AssertionError # pragma: no cover
1376
1440
 
1377
1441
 
1442
+ def late_bound(source: object, registry: Any) -> Any: # noqa: ANN401
1443
+ """Return the database ``using=`` points at: the registry, an alias, or a call."""
1444
+ if source is None:
1445
+ return registry
1446
+ if isinstance(source, str):
1447
+ return registry[source]
1448
+ if callable(source):
1449
+ return source()
1450
+ return source
1451
+
1452
+
1378
1453
  def retry_matches(exc: BaseException, retry_on: RetryOn) -> bool:
1379
1454
  """Whether ``retry_on`` claims this exception is worth another attempt."""
1380
1455
  if isinstance(retry_on, type | tuple):
@@ -149,6 +149,11 @@ class Database(BaseDatabase[sa.Connection, Session]):
149
149
  @contextmanager
150
150
  def connect(self) -> Iterator[sa.Connection]:
151
151
  """Open a connection and bind it, or reuse the one already bound."""
152
+ elsewhere = self._stand_in()
153
+ if elsewhere is not None:
154
+ with elsewhere.connect() as connection:
155
+ yield connection
156
+ return
152
157
  reuse = self._scope_to_reuse()
153
158
  if reuse is not None:
154
159
  with self._bound(self._reused(reuse)) as connection:
@@ -191,6 +196,11 @@ class Database(BaseDatabase[sa.Connection, Session]):
191
196
  @contextmanager
192
197
  def _autocommit(self) -> Iterator[sa.Connection]:
193
198
  """Open a connection in ``AUTOCOMMIT`` and bind it, or join the outer one."""
199
+ elsewhere = self._stand_in()
200
+ if elsewhere is not None:
201
+ with elsewhere.autocommit() as connection:
202
+ yield connection
203
+ return
194
204
  outer = self._connection_to_join()
195
205
  if outer is not None:
196
206
  with self._bound(outer, commit=True) as connection:
@@ -321,6 +331,11 @@ class Database(BaseDatabase[sa.Connection, Session]):
321
331
  flush, as ``sessionmaker()`` does it. Inside another block it runs on
322
332
  the connection already bound.
323
333
  """
334
+ elsewhere = self._stand_in()
335
+ if elsewhere is not None:
336
+ with elsewhere.session_factory() as session:
337
+ yield session
338
+ return
324
339
  reuse = self._scope_to_reuse()
325
340
  if reuse is not None:
326
341
  with self._bound(self._reused(reuse)):
@@ -472,6 +487,18 @@ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
472
487
 
473
488
  def __enter__(self) -> sa.Connection:
474
489
  stack = ExitStack()
490
+ elsewhere = self.db._stand_in() # noqa: SLF001
491
+ if elsewhere is not None:
492
+ connection = stack.enter_context(
493
+ elsewhere.transaction(
494
+ savepoint=self.savepoint,
495
+ join_nested=self.join_nested,
496
+ rollback=self.rollback,
497
+ commit_on_error=self.commit_on_error,
498
+ )
499
+ )
500
+ self._stacks.append(stack)
501
+ return connection
475
502
  try:
476
503
  outer, savepoint = self.db._plan( # noqa: SLF001
477
504
  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
@@ -613,6 +613,9 @@ def sql_macros(path: Path | str, source: str | None = None) -> list[SqlMacro]:
613
613
  problem = f"its arguments in {path.name}:{line} name one twice: {params}"
614
614
  raise MacroDefinitionError(name, problem, line=line)
615
615
  named = offset + statement.start("name")
616
+ # The body may start below its `SELECT`, where its lines count from.
617
+ body = statement.group("body")
618
+ body_at = offset + statement.start("body") + len(body) - len(body.lstrip())
616
619
  found.append(
617
620
  SqlMacro(
618
621
  name,
@@ -621,7 +624,7 @@ def sql_macros(path: Path | str, source: str | None = None) -> list[SqlMacro]:
621
624
  doc=" ".join(one for one in doc if one),
622
625
  path=path,
623
626
  line=line,
624
- body_line=line,
627
+ body_line=source.count("\n", 0, body_at) + 1,
625
628
  name_at=(
626
629
  source.count("\n", 0, named) + 1,
627
630
  named - (source.rfind("\n", 0, named) + 1),
@@ -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
+ ]
@@ -183,6 +183,11 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
183
183
  @asynccontextmanager
184
184
  async def connect(self) -> AsyncIterator[AsyncConnection]:
185
185
  """Open a connection and bind it, or reuse the one already bound."""
186
+ elsewhere = self._stand_in()
187
+ if elsewhere is not None:
188
+ async with elsewhere.connect() as connection:
189
+ yield connection
190
+ return
186
191
  reuse = self._scope_to_reuse()
187
192
  if reuse is not None:
188
193
  async with self._bound(await self._areused(reuse)) as connection:
@@ -225,6 +230,11 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
225
230
  @asynccontextmanager
226
231
  async def _autocommit(self) -> AsyncIterator[AsyncConnection]:
227
232
  """Open a connection in ``AUTOCOMMIT`` and bind it, or join the outer one."""
233
+ elsewhere = self._stand_in()
234
+ if elsewhere is not None:
235
+ async with elsewhere.autocommit() as connection:
236
+ yield connection
237
+ return
228
238
  outer = self._connection_to_join()
229
239
  if outer is not None:
230
240
  async with self._bound(outer, commit=True) as connection:
@@ -355,6 +365,11 @@ class Database(BaseDatabase[AsyncConnection, AsyncSession]):
355
365
  flush, as ``async_sessionmaker()`` does it. Inside another block it
356
366
  runs on the connection already bound.
357
367
  """
368
+ elsewhere = self._stand_in()
369
+ if elsewhere is not None:
370
+ async with elsewhere.session_factory() as session:
371
+ yield session
372
+ return
358
373
  reuse = self._scope_to_reuse()
359
374
  if reuse is not None:
360
375
  async with self._bound(await self._areused(reuse)):
@@ -512,6 +527,18 @@ class Transaction(
512
527
 
513
528
  async def __aenter__(self) -> AsyncConnection:
514
529
  stack = AsyncExitStack()
530
+ elsewhere = self.db._stand_in() # noqa: SLF001
531
+ if elsewhere is not None:
532
+ connection = await stack.enter_async_context(
533
+ elsewhere.transaction(
534
+ savepoint=self.savepoint,
535
+ join_nested=self.join_nested,
536
+ rollback=self.rollback,
537
+ commit_on_error=self.commit_on_error,
538
+ )
539
+ )
540
+ self._stacks.append(stack)
541
+ return connection
515
542
  try:
516
543
  outer, savepoint = self.db._plan( # noqa: SLF001
517
544
  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