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.
Files changed (30) hide show
  1. {sqlakit-0.11.0 → sqlakit-0.12.0}/PKG-INFO +2 -1
  2. {sqlakit-0.11.0 → sqlakit-0.12.0}/pyproject.toml +8 -5
  3. {sqlakit-0.11.0 → sqlakit-0.12.0}/pyproject.toml.orig +5 -4
  4. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_base.py +53 -3
  5. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/exceptions.py +8 -0
  6. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/pytest_plugin.py +29 -1
  7. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/types.py +11 -5
  8. {sqlakit-0.11.0 → sqlakit-0.12.0}/LICENSE +0 -0
  9. {sqlakit-0.11.0 → sqlakit-0.12.0}/README.md +0 -0
  10. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/__init__.py +0 -0
  11. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_cli.py +0 -0
  12. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_db.py +0 -0
  13. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_debugserver.py +0 -0
  14. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_discovery.py +0 -0
  15. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_model.py +0 -0
  16. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_query.py +0 -0
  17. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_recording.py +0 -0
  18. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_registry.py +0 -0
  19. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_routing.py +0 -0
  20. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/_sql.py +0 -0
  21. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/__init__.py +0 -0
  22. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/_db.py +0 -0
  23. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/_registry.py +0 -0
  24. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/orm.py +0 -0
  25. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/asyncio/sql.py +0 -0
  26. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/debugserver.html +0 -0
  27. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/orm.py +0 -0
  28. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/py.typed +0 -0
  29. {sqlakit-0.11.0 → sqlakit-0.12.0}/sqlakit/sql.py +0 -0
  30. {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.11.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.11.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 = ["sqlalchemy>=2.0.22"]
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.75",
184
- "ruff>=0.16.5",
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.3",
214
+ "ruff>=0.16.6",
212
215
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.11.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.75",
64
- "ruff>=0.16.5",
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.3",
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
- return self._scope.get()
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) -> Self:
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, TypedDict
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