sqlakit 0.11.0__tar.gz → 0.12.0__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.
- {sqlakit-0.11.0 → sqlakit-0.12.0}/PKG-INFO +2 -1
- {sqlakit-0.11.0 → sqlakit-0.12.0}/pyproject.toml +8 -5
- {sqlakit-0.11.0 → sqlakit-0.12.0}/pyproject.toml.orig +5 -4
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_base.py +53 -3
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/exceptions.py +8 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/pytest_plugin.py +29 -1
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/types.py +11 -5
- {sqlakit-0.11.0 → sqlakit-0.12.0}/LICENSE +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/README.md +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/__init__.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_cli.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_db.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_debugserver.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_query.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_sql.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/debugserver.html +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/orm.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/py.typed +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/sql.py +0 -0
- {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlakit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: A toolkit for SQLAlchemy applications.
|
|
5
5
|
Keywords: sqlalchemy,database,orm,sql,asyncio
|
|
6
6
|
Author: Anton Ruhlov
|
|
@@ -19,6 +19,7 @@ Classifier: Topic :: Database
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Classifier: Typing :: Typed
|
|
21
21
|
Requires-Dist: sqlalchemy>=2.0.22
|
|
22
|
+
Requires-Dist: typing-extensions>=4.13
|
|
22
23
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.22 ; extra == 'asyncio'
|
|
23
24
|
Requires-Dist: sqlparse>=0.6.0 ; extra == 'debug'
|
|
24
25
|
Requires-Dist: jinja2sql>=0.11.0 ; extra == 'sql'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -26,7 +26,10 @@ classifiers = [
|
|
|
26
26
|
"Typing :: Typed",
|
|
27
27
|
]
|
|
28
28
|
requires-python = ">=3.11"
|
|
29
|
-
dependencies = [
|
|
29
|
+
dependencies = [
|
|
30
|
+
"sqlalchemy>=2.0.22",
|
|
31
|
+
"typing-extensions>=4.13",
|
|
32
|
+
]
|
|
30
33
|
|
|
31
34
|
[[project.authors]]
|
|
32
35
|
name = "Anton Ruhlov"
|
|
@@ -180,8 +183,8 @@ skip = "./.venv,./site,./uv.lock,./.git,./debugserver/node_modules,./debugserver
|
|
|
180
183
|
[dependency-groups]
|
|
181
184
|
dev = [
|
|
182
185
|
"pytest>=9.1.1",
|
|
183
|
-
"ty>=0.0.
|
|
184
|
-
"ruff>=0.16.
|
|
186
|
+
"ty>=0.0.79",
|
|
187
|
+
"ruff>=0.16.6",
|
|
185
188
|
"poethepoet>=0.48.0",
|
|
186
189
|
"anyio>=4.14.2",
|
|
187
190
|
"trio>=0.34.0",
|
|
@@ -208,5 +211,5 @@ docs = [
|
|
|
208
211
|
"mkdocs>=1.6.1,<2",
|
|
209
212
|
"mkdocs-material>=9.7.7",
|
|
210
213
|
"mkdocstrings[python]>=1.0.6",
|
|
211
|
-
"ruff>=0.16.
|
|
214
|
+
"ruff>=0.16.6",
|
|
212
215
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -23,6 +23,7 @@ classifiers = [
|
|
|
23
23
|
requires-python = ">=3.11"
|
|
24
24
|
dependencies = [
|
|
25
25
|
"sqlalchemy>=2.0.22",
|
|
26
|
+
"typing-extensions>=4.13",
|
|
26
27
|
]
|
|
27
28
|
|
|
28
29
|
[project.scripts]
|
|
@@ -60,8 +61,8 @@ default-groups = ["dev", "docs"]
|
|
|
60
61
|
[dependency-groups]
|
|
61
62
|
dev = [
|
|
62
63
|
"pytest>=9.1.1",
|
|
63
|
-
"ty>=0.0.
|
|
64
|
-
"ruff>=0.16.
|
|
64
|
+
"ty>=0.0.79",
|
|
65
|
+
"ruff>=0.16.6",
|
|
65
66
|
"poethepoet>=0.48.0",
|
|
66
67
|
"anyio>=4.14.2",
|
|
67
68
|
"trio>=0.34.0",
|
|
@@ -88,7 +89,7 @@ docs = [
|
|
|
88
89
|
"mkdocs>=1.6.1,<2",
|
|
89
90
|
"mkdocs-material>=9.7.7",
|
|
90
91
|
"mkdocstrings[python]>=1.0.6",
|
|
91
|
-
"ruff>=0.16.
|
|
92
|
+
"ruff>=0.16.6",
|
|
92
93
|
]
|
|
93
94
|
|
|
94
95
|
[tool.pytest.ini_options]
|
|
@@ -8,7 +8,7 @@ import time
|
|
|
8
8
|
from collections.abc import Callable, Mapping
|
|
9
9
|
from contextlib import ExitStack, contextmanager
|
|
10
10
|
from contextvars import ContextVar
|
|
11
|
-
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
12
|
from functools import cache
|
|
13
13
|
from typing import (
|
|
14
14
|
TYPE_CHECKING,
|
|
@@ -39,6 +39,7 @@ from ._recording import (
|
|
|
39
39
|
from ._routing import Router, as_router
|
|
40
40
|
from .exceptions import (
|
|
41
41
|
DEFAULT_ALIAS,
|
|
42
|
+
HIDDEN_BLOCK,
|
|
42
43
|
REGISTERED_DEFAULT,
|
|
43
44
|
AliasInUseError,
|
|
44
45
|
ConflictingDatabaseUrlError,
|
|
@@ -169,6 +170,9 @@ class _Scope(Generic[ConnectionT, SessionT]):
|
|
|
169
170
|
autocommit: bool = False
|
|
170
171
|
"""Whether the connection is in ``AUTOCOMMIT``, where no transaction runs."""
|
|
171
172
|
|
|
173
|
+
hidden: bool = False
|
|
174
|
+
"""Whether `unbound()` hides this scope from the code running under it."""
|
|
175
|
+
|
|
172
176
|
|
|
173
177
|
@dataclass(slots=True)
|
|
174
178
|
class _Outer(Generic[ConnectionT]):
|
|
@@ -279,9 +283,14 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
279
283
|
|
|
280
284
|
"""
|
|
281
285
|
try:
|
|
282
|
-
|
|
286
|
+
scope = self._scope.get()
|
|
283
287
|
except LookupError:
|
|
284
288
|
raise MissingConnectionError from None
|
|
289
|
+
if scope.hidden:
|
|
290
|
+
raise MissingConnectionError(
|
|
291
|
+
HIDDEN_BLOCK.format(what="connection")
|
|
292
|
+
) from None
|
|
293
|
+
return scope
|
|
285
294
|
|
|
286
295
|
@property
|
|
287
296
|
def session(self) -> SessionT:
|
|
@@ -299,6 +308,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
299
308
|
scope = self._scope.get()
|
|
300
309
|
except LookupError:
|
|
301
310
|
raise MissingSessionError from None
|
|
311
|
+
if scope.hidden:
|
|
312
|
+
raise MissingSessionError(HIDDEN_BLOCK.format(what="session")) from None
|
|
302
313
|
if scope.session is None:
|
|
303
314
|
if scope.connection is None and scope.checkout is not None:
|
|
304
315
|
scope.session = self._lazy_session(scope.checkout)
|
|
@@ -479,9 +490,12 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
479
490
|
"""
|
|
480
491
|
return (self._name,)
|
|
481
492
|
|
|
482
|
-
def __getitem__(self, alias: str) ->
|
|
493
|
+
def __getitem__(self, alias: str) -> Any: # noqa: ANN401
|
|
483
494
|
"""Return this database, under the name it carries.
|
|
484
495
|
|
|
496
|
+
`Any` rather than `Self`, so a registry can narrow it to the databases
|
|
497
|
+
it holds.
|
|
498
|
+
|
|
485
499
|
Raises:
|
|
486
500
|
UnknownDatabaseError: if the alias is another database's.
|
|
487
501
|
|
|
@@ -503,6 +517,34 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
503
517
|
scope = self._scope.get(None)
|
|
504
518
|
return scope is not None and scope.session is not None
|
|
505
519
|
|
|
520
|
+
@contextmanager
|
|
521
|
+
def unbound(self) -> Iterator[None]:
|
|
522
|
+
"""Hide the block open around this one, for the code inside to open its own.
|
|
523
|
+
|
|
524
|
+
A test opens a transaction for the whole test, so code that reaches for
|
|
525
|
+
`session` without a block of its own borrows one and passes, where in
|
|
526
|
+
production it raises `MissingSessionError`. Wrap the call under test:
|
|
527
|
+
|
|
528
|
+
```python
|
|
529
|
+
@pytest.mark.db
|
|
530
|
+
def test_the_handler_opens_a_block() -> None:
|
|
531
|
+
with db.unbound():
|
|
532
|
+
handle(event)
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
A block opened inside still joins the transaction around it, and rolls
|
|
536
|
+
back with it.
|
|
537
|
+
"""
|
|
538
|
+
scope = self._scope.get(None)
|
|
539
|
+
if scope is None:
|
|
540
|
+
yield
|
|
541
|
+
return
|
|
542
|
+
token = self._scope.set(replace(scope, session=None, hidden=True))
|
|
543
|
+
try:
|
|
544
|
+
yield
|
|
545
|
+
finally:
|
|
546
|
+
self._scope.reset(token)
|
|
547
|
+
|
|
506
548
|
@property
|
|
507
549
|
def engine(self) -> Any: # noqa: ANN401
|
|
508
550
|
"""The engine underneath, which the subclass makes."""
|
|
@@ -863,6 +905,14 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
863
905
|
if debugserver is not None:
|
|
864
906
|
send_recording(together, debugserver)
|
|
865
907
|
|
|
908
|
+
@contextmanager
|
|
909
|
+
def unbound(self) -> Iterator[None]:
|
|
910
|
+
"""Hide the block open on every database this registry holds."""
|
|
911
|
+
with ExitStack() as stack:
|
|
912
|
+
for alias in self.aliases:
|
|
913
|
+
stack.enter_context(BaseDatabase.unbound(self[alias]))
|
|
914
|
+
yield
|
|
915
|
+
|
|
866
916
|
def _recorded(
|
|
867
917
|
self, using: str | DatabaseT | Sequence[str | DatabaseT] | None
|
|
868
918
|
) -> tuple[Any, ...]:
|
|
@@ -85,6 +85,14 @@ class RetryNotSupportedError(SQLAKitError, TypeError):
|
|
|
85
85
|
super().__init__(message)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
HIDDEN_BLOCK = (
|
|
89
|
+
"No {what} is bound: the block around this one is hidden, so the code "
|
|
90
|
+
"under it opens its own. `with db.transaction():` to write, "
|
|
91
|
+
"`with db.connect():` to read. A test hides its block with "
|
|
92
|
+
"`sqlakit_unbound`, `db(unbound=True)` or `db.unbound()`."
|
|
93
|
+
)
|
|
94
|
+
"""What the two errors above say under a hidden block, which a test opens."""
|
|
95
|
+
|
|
88
96
|
REGISTERED_DEFAULT = (
|
|
89
97
|
"This registry did not build the default database, it was registered, so "
|
|
90
98
|
"the settings it was built from live on it. Read them from `db['default']`."
|
|
@@ -19,13 +19,18 @@ up in, or the one they were given in person. A project with no model layer
|
|
|
19
19
|
defines `sqlakit_db` and `sqlakit_metadata` instead.
|
|
20
20
|
|
|
21
21
|
A test marked `db` runs in a transaction that rolls back, on every database.
|
|
22
|
-
`using` narrows that to the ones a test works on
|
|
22
|
+
`using` narrows that to the ones a test works on, and `unbound` hides the
|
|
23
|
+
test's own block from the code it calls, as production has none:
|
|
23
24
|
|
|
24
25
|
```python
|
|
25
26
|
@pytest.mark.db(using="warehouse")
|
|
26
27
|
@pytest.mark.db(using=["default", "warehouse"])
|
|
28
|
+
@pytest.mark.db(unbound=True)
|
|
27
29
|
```
|
|
28
30
|
|
|
31
|
+
`sqlakit_unbound = true` says the last one for the whole suite, and
|
|
32
|
+
`db(unbound=False)` gives one test its block back.
|
|
33
|
+
|
|
29
34
|
Every other test connects to nothing, and reaching for a session there raises
|
|
30
35
|
`MissingSessionError` rather than opening one.
|
|
31
36
|
"""
|
|
@@ -66,6 +71,12 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
66
71
|
type="bool",
|
|
67
72
|
default=False,
|
|
68
73
|
)
|
|
74
|
+
parser.addini(
|
|
75
|
+
"sqlakit_unbound",
|
|
76
|
+
"hide the test's own block, so the code it calls opens one as in production",
|
|
77
|
+
type="bool",
|
|
78
|
+
default=False,
|
|
79
|
+
)
|
|
69
80
|
parser.addini(
|
|
70
81
|
"sqlakit_skip_queries_from",
|
|
71
82
|
"the queries these files run stay out of the report: a factory, a helper",
|
|
@@ -276,6 +287,8 @@ def _sqlakit_transaction(
|
|
|
276
287
|
with ExitStack() as stack:
|
|
277
288
|
for block in _rolled_back(sqlakit_db, _asked_for(request)):
|
|
278
289
|
stack.enter_context(block)
|
|
290
|
+
if _hidden(request):
|
|
291
|
+
stack.enter_context(sqlakit_db.unbound())
|
|
279
292
|
with _reported(request, sqlakit_db):
|
|
280
293
|
yield
|
|
281
294
|
|
|
@@ -289,6 +302,8 @@ async def _sqlakit_async_transaction(
|
|
|
289
302
|
async with AsyncExitStack() as stack:
|
|
290
303
|
for block in _rolled_back(sqlakit_db, _asked_for(request)):
|
|
291
304
|
await stack.enter_async_context(block)
|
|
305
|
+
if _hidden(request):
|
|
306
|
+
stack.enter_context(sqlakit_db.unbound())
|
|
292
307
|
with _reported(request, sqlakit_db):
|
|
293
308
|
yield
|
|
294
309
|
|
|
@@ -385,6 +400,19 @@ def _asked_for(request: pytest.FixtureRequest) -> tuple[Any, ...]:
|
|
|
385
400
|
return tuple(using)
|
|
386
401
|
|
|
387
402
|
|
|
403
|
+
def _hidden(request: pytest.FixtureRequest) -> bool:
|
|
404
|
+
"""Whether the test's own block is hidden from the code it calls.
|
|
405
|
+
|
|
406
|
+
`sqlakit_unbound` says it for the suite, and `db(unbound=...)` on a test
|
|
407
|
+
says it for that one.
|
|
408
|
+
"""
|
|
409
|
+
marker = request.node.get_closest_marker(MARKER)
|
|
410
|
+
asked = None if marker is None else marker.kwargs.get("unbound")
|
|
411
|
+
if asked is None:
|
|
412
|
+
return bool(request.config.getini("sqlakit_unbound"))
|
|
413
|
+
return bool(asked)
|
|
414
|
+
|
|
415
|
+
|
|
388
416
|
def _rolled_back(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
|
|
389
417
|
"""Return the blocks that undo what a test writes.
|
|
390
418
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
4
|
+
|
|
5
|
+
from typing_extensions import TypedDict
|
|
4
6
|
|
|
5
7
|
if TYPE_CHECKING:
|
|
6
8
|
from collections.abc import Callable, Mapping, Sequence
|
|
@@ -27,8 +29,11 @@ TemplatesLike: TypeAlias = "str | Path | Sequence[str | Path] | Templates"
|
|
|
27
29
|
"""Where a database's SQL templates are: a path, several, or the object."""
|
|
28
30
|
|
|
29
31
|
|
|
30
|
-
class EngineArgs(TypedDict, total=False):
|
|
31
|
-
"""Keyword arguments accepted by [`sqlalchemy.create_engine`](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine).
|
|
32
|
+
class EngineArgs(TypedDict, total=False, extra_items=Any):
|
|
33
|
+
"""Keyword arguments accepted by [`sqlalchemy.create_engine`](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine).
|
|
34
|
+
|
|
35
|
+
The portable ones are listed. A dialect keyword is allowed too.
|
|
36
|
+
"""
|
|
32
37
|
|
|
33
38
|
connect_args: dict[str, Any]
|
|
34
39
|
echo: bool | Literal["debug"]
|
|
@@ -61,10 +66,11 @@ class EngineArgs(TypedDict, total=False):
|
|
|
61
66
|
use_insertmanyvalues: bool
|
|
62
67
|
|
|
63
68
|
|
|
64
|
-
class SessionArgs(TypedDict, total=False):
|
|
69
|
+
class SessionArgs(TypedDict, total=False, extra_items=Any):
|
|
65
70
|
"""Keyword arguments accepted by `sqlalchemy.orm.sessionmaker`.
|
|
66
71
|
|
|
67
|
-
No ``bind``: sessions bind to the connection of the surrounding block.
|
|
72
|
+
No ``bind``: sessions bind to the connection of the surrounding block. A
|
|
73
|
+
keyword this list does not have is allowed too.
|
|
68
74
|
"""
|
|
69
75
|
|
|
70
76
|
autobegin: bool
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|