sqlakit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqlakit/_db.py ADDED
@@ -0,0 +1,540 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import itertools
5
+ import logging
6
+ import time
7
+ from contextlib import (
8
+ AbstractContextManager,
9
+ ContextDecorator,
10
+ ExitStack,
11
+ contextmanager,
12
+ )
13
+ from functools import cached_property
14
+ from typing import TYPE_CHECKING, Any, Self, TypeVar, cast, overload
15
+
16
+ import sqlalchemy as sa
17
+ import sqlalchemy.exc
18
+ from sqlalchemy.orm import Session, sessionmaker
19
+
20
+ from ._base import (
21
+ BaseDatabase,
22
+ BaseRetryingTransaction,
23
+ default_backoff,
24
+ fix_sqlite_transactions,
25
+ )
26
+ from .exceptions import TransactionRolledBackError
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable, Iterator, Sequence
30
+ from types import TracebackType
31
+
32
+ from sqlalchemy.engine import Engine
33
+
34
+ from ._base import RetryOn, _Scope
35
+ from .orm import Query
36
+ from .sql import SQL
37
+
38
+ ModelT = TypeVar("ModelT")
39
+ _FuncT = TypeVar("_FuncT", bound="Callable[..., Any]")
40
+
41
+ __all__ = ["Database", "RetryingTransaction", "Transaction"]
42
+
43
+ logger = logging.getLogger("sqlakit")
44
+
45
+
46
+ class Database(BaseDatabase[sa.Connection, Session]):
47
+ """A SQLAlchemy engine, with its connection and session kept in the context.
48
+
49
+ Connections opened by [`connect`][sqlakit.Database.connect] and
50
+ [`transaction`][sqlakit.Database.transaction] are reachable below the block as
51
+ [`connection`][sqlakit.Database.connection], and
52
+ [`session`][sqlakit.Database.session] opens a session on
53
+ the same connection. The engine is built on first use.
54
+
55
+ ``engine_args`` and ``session_args`` are merged over ``DEFAULT_ENGINE_ARGS``
56
+ and ``DEFAULT_SESSION_ARGS``; what you pass wins.
57
+ """
58
+
59
+ _engine: Engine | None = None
60
+ _sessionmaker: sessionmaker[Session] | None = None
61
+
62
+ @cached_property
63
+ def sql(self) -> SQL:
64
+ """The SQL templates of this database.
65
+
66
+ ```python
67
+ db = Database(DB_URL, templates="app/sql")
68
+
69
+ db.sql("reports/by_team.sql", since=since).typed(TeamReport).all()
70
+ ```
71
+
72
+ Templates need `sqlakit[sql]`, and nothing else here does, so the layer is
73
+ imported when it is first reached.
74
+ """
75
+ # Here rather than at the top, so that `import sqlakit` stays free of
76
+ # the optional layer and of what it imports.
77
+ from .sql import SQL # noqa: PLC0415
78
+
79
+ return SQL(self)
80
+
81
+ def query(self, model: type[ModelT]) -> Query[ModelT]:
82
+ """Build a query over a mapped class, on this database.
83
+
84
+ ```python
85
+ db.query(User).where(User.is_active).order_by("name").page(limit=20)
86
+ ```
87
+
88
+ Any declarative class works, with no model layer under it. A model
89
+ that has one reaches the same builder as `User.query`, on the database
90
+ the model belongs to.
91
+ """
92
+ # Here rather than at the top: `orm` imports this module.
93
+ from .orm import Query # noqa: PLC0415
94
+
95
+ return Query(model, self)
96
+
97
+ @property
98
+ def engine(self) -> Engine:
99
+ """The underlying engine, created on first access."""
100
+ if self._engine is None:
101
+ with self._engine_lock:
102
+ # Two threads reaching this at once would each build one, and
103
+ # the pool of the loser would never be disposed.
104
+ if self._engine is None:
105
+ self._engine = self._create_engine()
106
+ return self._engine
107
+
108
+ def _create_engine(self) -> Engine:
109
+ engine = sa.create_engine(self.url, **self.engine_args)
110
+ fix_sqlite_transactions(engine)
111
+ return engine
112
+
113
+ def _create_session(self, connection: sa.Connection) -> Session:
114
+ if self._sessionmaker is None:
115
+ self._sessionmaker = sessionmaker(**self.session_args)
116
+ return self._sessionmaker(
117
+ bind=connection,
118
+ **self._session_args_for(connection),
119
+ )
120
+
121
+ @contextmanager
122
+ def connect(self) -> Iterator[sa.Connection]:
123
+ """Open a connection and bind it, or reuse the one already bound."""
124
+ outer = self._connection_to_reuse()
125
+ if outer is not None:
126
+ with self._bound(outer) as connection:
127
+ yield connection
128
+ return
129
+ with self.engine.connect() as opened, self._bound(opened) as connection:
130
+ yield connection
131
+
132
+ @overload
133
+ def autocommit(self, func: _FuncT) -> _FuncT: ...
134
+
135
+ @overload
136
+ def autocommit(
137
+ self,
138
+ func: None = None,
139
+ ) -> AbstractContextManager[sa.Connection]: ...
140
+
141
+ def autocommit(
142
+ self,
143
+ func: _FuncT | None = None,
144
+ ) -> _FuncT | AbstractContextManager[sa.Connection]:
145
+ """Run in ``AUTOCOMMIT``, where every statement commits on its own.
146
+
147
+ For read-only work, which has nothing to commit, and for statements that
148
+ cannot run inside a transaction: ``VACUUM``, ``CREATE DATABASE``,
149
+ ``CREATE INDEX CONCURRENTLY``. Inside a transaction it joins that one, since
150
+ there is nothing else to commit into.
151
+
152
+ A context manager and a decorator, with or without parentheses.
153
+
154
+ Args:
155
+ func: The function to decorate, when used as a bare decorator.
156
+
157
+ """
158
+ autocommit = self._autocommit()
159
+ if func is not None:
160
+ return autocommit(func)
161
+ return autocommit
162
+
163
+ @contextmanager
164
+ def _autocommit(self) -> Iterator[sa.Connection]:
165
+ """Open a connection in ``AUTOCOMMIT`` and bind it, or join the outer one."""
166
+ outer = self._connection_to_join()
167
+ if outer is not None:
168
+ with self._bound(outer, commit=True) as connection:
169
+ yield connection
170
+ return
171
+ with self.engine.connect() as opened:
172
+ opened.execution_options(isolation_level="AUTOCOMMIT")
173
+ with self._set_outer(None), self._bound(opened, commit=True) as connection:
174
+ yield connection
175
+
176
+ @overload
177
+ def transaction(self, func: _FuncT) -> _FuncT: ...
178
+
179
+ @overload
180
+ def transaction(
181
+ self,
182
+ func: None = None,
183
+ *,
184
+ savepoint: bool = False,
185
+ join_nested: bool = True,
186
+ rollback: bool = False,
187
+ commit_on_error: type[BaseException]
188
+ | tuple[type[BaseException], ...]
189
+ | None = None,
190
+ retry_on: None = None,
191
+ max_retries: int = 3,
192
+ backoff: Callable[[int], float] = default_backoff,
193
+ ) -> Transaction: ...
194
+
195
+ @overload
196
+ def transaction(
197
+ self,
198
+ func: None = None,
199
+ *,
200
+ savepoint: bool = False,
201
+ join_nested: bool = True,
202
+ rollback: bool = False,
203
+ commit_on_error: type[BaseException]
204
+ | tuple[type[BaseException], ...]
205
+ | None = None,
206
+ retry_on: RetryOn,
207
+ max_retries: int = 3,
208
+ backoff: Callable[[int], float] = default_backoff,
209
+ ) -> RetryingTransaction: ...
210
+
211
+ def transaction( # noqa: PLR0913 (all keyword-only; this is the main API)
212
+ self,
213
+ func: _FuncT | None = None,
214
+ *,
215
+ savepoint: bool = False,
216
+ join_nested: bool = True,
217
+ rollback: bool = False,
218
+ commit_on_error: type[BaseException]
219
+ | tuple[type[BaseException], ...]
220
+ | None = None,
221
+ retry_on: RetryOn | None = None,
222
+ max_retries: int = 3,
223
+ backoff: Callable[[int], float] = default_backoff,
224
+ ) -> _FuncT | Transaction | RetryingTransaction:
225
+ """Run a transaction on a connection bound to the current context.
226
+
227
+ Commits when the block exits, rolls back if it raises. Inside another
228
+ transaction it takes part in that one rather than opening a second
229
+ connection: the outermost block commits.
230
+
231
+ A context manager and a decorator, the latter with or without parentheses:
232
+
233
+ ```python
234
+ with db.transaction():
235
+ ...
236
+
237
+
238
+ @db.transaction
239
+ def import_users() -> None: ...
240
+ ```
241
+
242
+ Args:
243
+ func: The function to decorate, when used as a bare decorator.
244
+ savepoint: Run as a savepoint when nested, so this block can fail and be
245
+ rolled back on its own, and the blocks below it with it. Off by
246
+ default: a savepoint costs a round trip.
247
+ join_nested: Whether blocks below reuse this connection. Turn it off to
248
+ let them reach the database on their own, seeing nothing of this
249
+ block and surviving its rollback.
250
+ rollback: Roll back on the way out rather than commit. Implies
251
+ ``savepoint``, and is what wraps a test.
252
+ commit_on_error: Exception types whose escape still commits. The
253
+ exception propagates; what was written before it stays.
254
+ retry_on: Exception types, or a predicate over the exception, worth
255
+ another attempt. Decorator only: retrying re-runs the block, so this
256
+ returns a [`RetryingTransaction`][sqlakit.RetryingTransaction] that type
257
+ checkers refuse to
258
+ enter. Only the block that owns the transaction retries.
259
+ max_retries: How many further attempts ``retry_on`` may buy.
260
+ backoff: Seconds to wait before attempt ``n``, counted from zero.
261
+
262
+ """
263
+
264
+ def new_transaction() -> Transaction:
265
+ return Transaction(
266
+ self,
267
+ savepoint=savepoint,
268
+ join_nested=join_nested,
269
+ rollback=rollback,
270
+ commit_on_error=commit_on_error,
271
+ )
272
+
273
+ transaction: Transaction | RetryingTransaction = new_transaction()
274
+ if retry_on is not None:
275
+ transaction = RetryingTransaction(
276
+ new_transaction,
277
+ retry_on=retry_on,
278
+ max_retries=max_retries,
279
+ backoff=backoff,
280
+ )
281
+ if func is not None:
282
+ return transaction(func)
283
+ return transaction
284
+
285
+ @contextmanager
286
+ def session_factory(self) -> Iterator[Session]:
287
+ """Open a new connection and a session on top of it, and bind both."""
288
+ with self.connect():
289
+ yield self.session
290
+
291
+ @contextmanager
292
+ def _bound(
293
+ self,
294
+ connection: sa.Connection,
295
+ *,
296
+ commit: bool = False,
297
+ ) -> Iterator[sa.Connection]:
298
+ """Bind ``connection`` for the block, ending the session it opened.
299
+
300
+ ``commit`` keeps that session's work. Closing a session that isolates
301
+ itself with a savepoint rolls back to it, discarding what the blocks
302
+ below committed.
303
+ """
304
+ with self._bind(connection) as scope:
305
+ done = False
306
+ try:
307
+ yield connection
308
+ done = True
309
+ finally:
310
+ if scope.session is not None:
311
+ if commit and done:
312
+ scope.session.commit()
313
+ scope.session.close()
314
+
315
+ @contextmanager
316
+ def provisioned_tables(
317
+ self,
318
+ metadata: sa.MetaData,
319
+ *,
320
+ tables: Sequence[sa.Table] | None = None,
321
+ ) -> Iterator[None]:
322
+ """Create these tables here, and drop them when the block ends.
323
+
324
+ What a test session opens once, around everything that needs a schema:
325
+
326
+ ```python
327
+ @pytest.fixture(scope="session")
328
+ def tables():
329
+ with db.provisioned_tables(Model.metadata):
330
+ yield
331
+ ```
332
+
333
+ Every table of the metadata unless ``tables`` names fewer, which is what a
334
+ second database wants.
335
+ """
336
+ with self.transaction() as connection:
337
+ metadata.create_all(connection, tables=tables)
338
+ try:
339
+ yield
340
+ finally:
341
+ with self.transaction() as connection:
342
+ metadata.drop_all(connection, tables=tables)
343
+
344
+ def ping(self) -> bool:
345
+ """Whether the database answers."""
346
+ try:
347
+ with self.engine.connect() as connection:
348
+ connection.execute(sa.text("SELECT 1"))
349
+ except sa.exc.SQLAlchemyError:
350
+ return False
351
+ return True
352
+
353
+ def dispose(self, *, close: bool = True) -> None:
354
+ """Dispose of the engine and its connection pool."""
355
+ with self._engine_lock:
356
+ if self._engine is not None:
357
+ self._engine.dispose(close=close)
358
+ self._engine = None
359
+
360
+ def __enter__(self) -> Self:
361
+ return self
362
+
363
+ def __exit__(self, *exc_info: object) -> None:
364
+ self.dispose()
365
+
366
+
367
+ class Transaction(ContextDecorator, AbstractContextManager["sa.Connection"]):
368
+ """What [`Database.transaction`][sqlakit.Database.transaction] returns.
369
+
370
+ A class rather than a generator, so that it works as a decorator and can be
371
+ entered more than once, which is what decorating a function does.
372
+ """
373
+
374
+ def __init__(
375
+ self,
376
+ db: Database,
377
+ *,
378
+ savepoint: bool = False,
379
+ join_nested: bool = True,
380
+ rollback: bool = False,
381
+ commit_on_error: type[BaseException]
382
+ | tuple[type[BaseException], ...]
383
+ | None = None,
384
+ ) -> None:
385
+ self.db = db
386
+ self.savepoint = savepoint
387
+ self.join_nested = join_nested
388
+ self.rollback = rollback
389
+ self.commit_on_error = commit_on_error
390
+ self._stacks: list[ExitStack] = []
391
+
392
+ def _recreate_cm(self) -> Transaction:
393
+ """Give every decorated call a transaction of its own.
394
+
395
+ `ContextDecorator` reuses one instance for all calls, and two of them
396
+ running at once would unwind each other's blocks.
397
+ """
398
+ return Transaction(
399
+ self.db,
400
+ savepoint=self.savepoint,
401
+ join_nested=self.join_nested,
402
+ rollback=self.rollback,
403
+ commit_on_error=self.commit_on_error,
404
+ )
405
+
406
+ def __enter__(self) -> sa.Connection:
407
+ stack = ExitStack()
408
+ try:
409
+ outer, savepoint = self.db._plan( # noqa: SLF001
410
+ savepoint=self.savepoint,
411
+ rollback=self.rollback,
412
+ )
413
+ if outer is not None:
414
+ connection = outer.connection
415
+ # Without a savepoint the block only takes part in the
416
+ # transaction around it, which commits it.
417
+ transaction = connection.begin_nested() if savepoint else None
418
+ # The block's savepoint isolates it; a session opened inside
419
+ # must not add a second one on the same connection.
420
+ session_savepoint = outer.session_savepoint and not savepoint
421
+ else:
422
+ connection = stack.enter_context(self.db.engine.connect())
423
+ transaction = connection.begin()
424
+ session_savepoint = savepoint
425
+ # Unwound in reverse: session, context, transaction, connection.
426
+ stack.push(self._finish(transaction))
427
+ bound = stack.enter_context(
428
+ self.db._set_outer( # noqa: SLF001
429
+ connection,
430
+ join_nested=self.join_nested,
431
+ savepoint=savepoint,
432
+ session_savepoint=session_savepoint,
433
+ )
434
+ )
435
+ scope = stack.enter_context(self.db._bind(connection)) # noqa: SLF001
436
+ if bound is not None:
437
+ bound.scope = scope
438
+ stack.push(self._close_session(scope))
439
+ except BaseException:
440
+ stack.close()
441
+ raise
442
+ self._stacks.append(stack)
443
+ return connection
444
+
445
+ def __exit__(
446
+ self,
447
+ exc_type: type[BaseException] | None,
448
+ exc: BaseException | None,
449
+ traceback: TracebackType | None,
450
+ ) -> None:
451
+ self._stacks.pop().__exit__(exc_type, exc, traceback)
452
+
453
+ def _close_session(
454
+ self,
455
+ scope: _Scope[sa.Connection, Session],
456
+ ) -> Callable[..., None]:
457
+ """Commit the block's session, if it opened one, then close it.
458
+
459
+ The block is the unit of work, so what its session holds belongs to the
460
+ transaction; closing first would roll it back.
461
+ """
462
+
463
+ def close_session(
464
+ _exc_type: object,
465
+ exc: BaseException | None,
466
+ _traceback: object,
467
+ ) -> None:
468
+ if scope.session is None:
469
+ return
470
+ if self._keeps(exc):
471
+ scope.session.commit()
472
+ scope.session.close()
473
+
474
+ return close_session
475
+
476
+ def _finish(self, transaction: sa.Transaction | None) -> Callable[..., None]:
477
+ """Commit or roll back, unless this block only takes part in another."""
478
+
479
+ def finish(
480
+ _exc_type: object,
481
+ exc: BaseException | None,
482
+ _traceback: object,
483
+ ) -> None:
484
+ if transaction is None:
485
+ return
486
+ if not transaction.is_active:
487
+ # Rolled back from inside the block. Say so, unless an
488
+ # exception is already on its way out with the reason.
489
+ if exc is None:
490
+ raise TransactionRolledBackError
491
+ return
492
+ if self._keeps(exc) and not self.rollback:
493
+ transaction.commit()
494
+ else:
495
+ transaction.rollback()
496
+
497
+ return finish
498
+
499
+ def _keeps(self, exc: BaseException | None) -> bool:
500
+ """Whether the block's work is kept rather than undone."""
501
+ if exc is None:
502
+ return True
503
+ return self.commit_on_error is not None and isinstance(
504
+ exc, self.commit_on_error
505
+ )
506
+
507
+
508
+ class RetryingTransaction(BaseRetryingTransaction):
509
+ """A transaction that runs its block again.
510
+
511
+ What [`Database.transaction`][sqlakit.Database.transaction] returns when it is
512
+ given ``retry_on``.
513
+ """
514
+
515
+ def __call__(self, func: _FuncT) -> _FuncT:
516
+ @functools.wraps(func)
517
+ def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
518
+ transaction = self.transaction()
519
+ if transaction.db.in_transaction():
520
+ # Only the block that owns the transaction can restart it: a
521
+ # retry inside would keep the snapshot that caused the
522
+ # conflict, and the outer transaction fails anyway.
523
+ logger.debug(
524
+ "%s runs inside another transaction; retrying is up to "
525
+ "whoever opened it.",
526
+ getattr(func, "__qualname__", func),
527
+ )
528
+ with transaction:
529
+ return func(*args, **kwargs)
530
+ for attempt in itertools.count():
531
+ try:
532
+ with self.transaction():
533
+ return func(*args, **kwargs)
534
+ except Exception as exc:
535
+ if not self._retry(exc, attempt=attempt):
536
+ raise
537
+ time.sleep(self.backoff(attempt))
538
+ raise AssertionError # pragma: no cover - the loop returns or raises
539
+
540
+ return cast("_FuncT", wrapper)
sqlakit/_discovery.py ADDED
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import pkgutil
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from .exceptions import UnknownImportPathError
8
+
9
+ if TYPE_CHECKING:
10
+ from types import ModuleType
11
+
12
+ __all__ = ["import_models", "import_string"]
13
+
14
+
15
+ def import_string(path: str) -> Any: # noqa: ANN401
16
+ """Return what a dotted path names, importing what it has to.
17
+
18
+ What settings hand over instead of the thing itself:
19
+
20
+ ```python
21
+ db.configure(
22
+ DB_URL,
23
+ routers=["app.db.reports_live_in_the_warehouse"],
24
+ )
25
+ ```
26
+
27
+ ``app.db.router`` and ``app.db:router`` both work, and the second is worth
28
+ using when the name could be read as a module.
29
+ """
30
+ module, _, name = path.replace(":", ".").rpartition(".")
31
+ if not module:
32
+ raise UnknownImportPathError(path)
33
+ try:
34
+ return getattr(importlib.import_module(module), name)
35
+ except (ImportError, AttributeError) as error:
36
+ raise UnknownImportPathError(path) from error
37
+
38
+
39
+ def import_models(package: str | ModuleType, *, name: str = "models") -> list[str]:
40
+ """Import every ``models`` module under a package, and return their names.
41
+
42
+ A model reaches the metadata when its module is imported, and not before, so
43
+ an application whose models live next to the features that own them has to
44
+ import them all somewhere. What the ones nobody imported cost:
45
+
46
+ - `alembic revision --autogenerate` reads a missing model as a table to
47
+ **drop**.
48
+ - `provisioned_tables()` comes up without those tables.
49
+ - A `relationship("Team")` cannot resolve a class nobody has defined.
50
+
51
+ Call it once, where the metadata is about to be used:
52
+
53
+ ```python
54
+ # migrations/env.py
55
+ import_models("app")
56
+ target_metadata = Model.metadata
57
+ ```
58
+
59
+ ``name`` is the module this looks for: ``app/billing/models.py`` and
60
+ everything inside ``app/users/models/``. An import that fails raises, here as
61
+ anywhere.
62
+ """
63
+ if isinstance(package, str):
64
+ package = importlib.import_module(package)
65
+ prefix = f"{package.__name__}."
66
+ suffix = f".{name}"
67
+ inside = f".{name}."
68
+
69
+ imported = []
70
+ for _, module, _ in pkgutil.walk_packages(package.__path__, prefix, _reraise):
71
+ if module.endswith(suffix) or inside in module:
72
+ importlib.import_module(module)
73
+ imported.append(module)
74
+ return imported
75
+
76
+
77
+ def _reraise(module: str) -> None: # noqa: ARG001
78
+ """Let an import error out, rather than walking past the module."""
79
+ raise # noqa: PLE0704