sqlalchemy-foundation-kit 0.2.0__tar.gz → 0.2.1__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 (54) hide show
  1. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/CHANGELOG.md +7 -0
  2. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/PKG-INFO +16 -3
  3. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/README.md +14 -2
  4. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/pyproject.toml +5 -0
  5. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/__init__.py +1 -1
  6. sqlalchemy_foundation_kit-0.2.1/sqlalchemy_foundation_kit/__version__.py +1 -0
  7. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/_optional.py +2 -2
  8. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/serialization.py +2 -2
  9. sqlalchemy_foundation_kit-0.2.1/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +59 -0
  10. sqlalchemy_foundation_kit-0.2.1/sqlalchemy_foundation_kit/contrib/di/_deps.py +53 -0
  11. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/locks.py +29 -1
  12. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/manager.py +9 -6
  13. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/uow/sqlalchemy.py +43 -12
  14. sqlalchemy_foundation_kit-0.2.0/sqlalchemy_foundation_kit/__version__.py +0 -1
  15. sqlalchemy_foundation_kit-0.2.0/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +0 -37
  16. sqlalchemy_foundation_kit-0.2.0/sqlalchemy_foundation_kit/contrib/di/_deps.py +0 -32
  17. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/.gitignore +0 -0
  18. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/LICENSE +0 -0
  19. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/_typing.py +0 -0
  20. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/__init__.py +0 -0
  21. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/engine.py +0 -0
  22. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/metadata.py +0 -0
  23. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/models.py +0 -0
  24. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/types.py +0 -0
  25. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/config/__init__.py +0 -0
  26. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/config/postgres.py +0 -0
  27. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/__init__.py +0 -0
  28. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/_metrics_utils.py +0 -0
  29. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +0 -0
  30. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +0 -0
  31. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +0 -0
  32. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +0 -0
  33. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/di/__init__.py +0 -0
  34. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/di/_base.py +0 -0
  35. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/di/database.py +0 -0
  36. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/di/metrics.py +0 -0
  37. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/metrics/__init__.py +0 -0
  38. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/metrics/postgres.py +0 -0
  39. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/settings/__init__.py +0 -0
  40. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/settings/postgres.py +0 -0
  41. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +0 -0
  42. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +0 -0
  43. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/telemetry/uow.py +0 -0
  44. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/protocols/__init__.py +0 -0
  45. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/protocols/metrics.py +0 -0
  46. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/py.typed +0 -0
  47. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/__init__.py +0 -0
  48. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/builder.py +0 -0
  49. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/connection.py +0 -0
  50. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/factories.py +0 -0
  51. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/retry.py +0 -0
  52. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/uow/__init__.py +0 -0
  53. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/uow/enums.py +0 -0
  54. {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/uow/protocols.py +0 -0
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1](https://github.com/bedrock-python/sqlalchemy-foundation-kit/compare/sqlalchemy-foundation-kit-v0.2.0...sqlalchemy-foundation-kit-v0.2.1) (2026-09-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * the defects the agents page turned up ([#21](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/21)) ([9548c72](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/9548c72670696dd68e0eeec1f0ae4ad896fa62d6))
9
+
3
10
  ## [0.2.0](https://github.com/bedrock-python/sqlalchemy-foundation-kit/compare/sqlalchemy-foundation-kit-v0.1.0...sqlalchemy-foundation-kit-v0.2.0) (2026-08-28)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sqlalchemy-foundation-kit
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Foundation layer for SQLAlchemy-based services with UoW, session management, and observability
5
5
  Project-URL: Repository, https://github.com/bedrock-python/sqlalchemy-foundation-kit
6
6
  Project-URL: Documentation, https://bedrock-python.github.io/sqlalchemy-foundation-kit/
@@ -219,6 +219,7 @@ Classifier: Programming Language :: Python :: 3.12
219
219
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
220
220
  Classifier: Typing :: Typed
221
221
  Requires-Python: >=3.11
222
+ Requires-Dist: asyncpg<1.0.0,>=0.30.0
222
223
  Requires-Dist: pydantic<3.0.0,>=2.5.0
223
224
  Requires-Dist: sqlalchemy[asyncio]<3.0.0,>=2.0.35
224
225
  Provides-Extra: all
@@ -279,7 +280,15 @@ Description-Content-Type: text/markdown
279
280
  - **Observability** — Prometheus connection-pool metrics and OpenTelemetry tracing
280
281
  - **DI integration** — Ready-to-use providers for [`dishka`](https://github.com/reagento/dishka) and `dependency-injector`
281
282
 
282
- Only `sqlalchemy[asyncio]` and `pydantic` are required by default — everything else is an opt-in extra.
283
+ Only `sqlalchemy[asyncio]`, `pydantic` and `asyncpg` are required by default — everything else is an opt-in extra.
284
+
285
+ > [!TIP]
286
+ > **Building this with an AI assistant?** Hand it
287
+ > **[one page](https://bedrock-python.github.io/sqlalchemy-foundation-kit/agents/)** instead of the
288
+ > whole site: the public API surface, the session-lifetime and transaction-ownership rules that
289
+ > break code when they are broken, the mistakes models actually make with a Unit of Work, and a
290
+ > map of which page to fetch for the rest. Every docs page is also served as raw Markdown at its
291
+ > own URL, and a **Copy page** button at the top of each one hands it straight to a chat window.
283
292
 
284
293
  ## Key Features
285
294
 
@@ -425,12 +434,14 @@ async def main():
425
434
  user = UserDB(id=uuid4(), email="user@example.com", username="user")
426
435
  session.add(user)
427
436
  # Auto-commit on exit
437
+
438
+ await session_manager.aclose()
428
439
  ```
429
440
 
430
441
  ### 4. Unit of Work Pattern
431
442
 
432
443
  ```python
433
- from unit_of_work_kit import AsyncSQLAlchemyUnitOfWork, AsyncSQLAlchemyUowTransaction
444
+ from sqlalchemy_foundation_kit import AsyncSQLAlchemyUnitOfWork, AsyncSQLAlchemyUowTransaction
434
445
 
435
446
  # Define your transaction with repositories
436
447
  class MyTransaction(AsyncSQLAlchemyUowTransaction):
@@ -619,6 +630,8 @@ graph LR
619
630
 
620
631
  Full documentation is available at [https://bedrock-python.github.io/sqlalchemy-foundation-kit/](https://bedrock-python.github.io/sqlalchemy-foundation-kit/)
621
632
 
633
+ - [For AI agents](https://bedrock-python.github.io/sqlalchemy-foundation-kit/agents/) — the whole library on one page, for a coding assistant
634
+
622
635
  ## License
623
636
 
624
637
  This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
@@ -29,7 +29,15 @@
29
29
  - **Observability** — Prometheus connection-pool metrics and OpenTelemetry tracing
30
30
  - **DI integration** — Ready-to-use providers for [`dishka`](https://github.com/reagento/dishka) and `dependency-injector`
31
31
 
32
- Only `sqlalchemy[asyncio]` and `pydantic` are required by default — everything else is an opt-in extra.
32
+ Only `sqlalchemy[asyncio]`, `pydantic` and `asyncpg` are required by default — everything else is an opt-in extra.
33
+
34
+ > [!TIP]
35
+ > **Building this with an AI assistant?** Hand it
36
+ > **[one page](https://bedrock-python.github.io/sqlalchemy-foundation-kit/agents/)** instead of the
37
+ > whole site: the public API surface, the session-lifetime and transaction-ownership rules that
38
+ > break code when they are broken, the mistakes models actually make with a Unit of Work, and a
39
+ > map of which page to fetch for the rest. Every docs page is also served as raw Markdown at its
40
+ > own URL, and a **Copy page** button at the top of each one hands it straight to a chat window.
33
41
 
34
42
  ## Key Features
35
43
 
@@ -175,12 +183,14 @@ async def main():
175
183
  user = UserDB(id=uuid4(), email="user@example.com", username="user")
176
184
  session.add(user)
177
185
  # Auto-commit on exit
186
+
187
+ await session_manager.aclose()
178
188
  ```
179
189
 
180
190
  ### 4. Unit of Work Pattern
181
191
 
182
192
  ```python
183
- from unit_of_work_kit import AsyncSQLAlchemyUnitOfWork, AsyncSQLAlchemyUowTransaction
193
+ from sqlalchemy_foundation_kit import AsyncSQLAlchemyUnitOfWork, AsyncSQLAlchemyUowTransaction
184
194
 
185
195
  # Define your transaction with repositories
186
196
  class MyTransaction(AsyncSQLAlchemyUowTransaction):
@@ -369,6 +379,8 @@ graph LR
369
379
 
370
380
  Full documentation is available at [https://bedrock-python.github.io/sqlalchemy-foundation-kit/](https://bedrock-python.github.io/sqlalchemy-foundation-kit/)
371
381
 
382
+ - [For AI agents](https://bedrock-python.github.io/sqlalchemy-foundation-kit/agents/) — the whole library on one page, for a coding assistant
383
+
372
384
  ## License
373
385
 
374
386
  This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
@@ -31,6 +31,7 @@ classifiers = [
31
31
  dependencies = [
32
32
  "sqlalchemy[asyncio]>=2.0.35,<3.0.0",
33
33
  "pydantic>=2.5.0,<3.0.0",
34
+ "asyncpg>=0.30.0,<1.0.0",
34
35
  ]
35
36
 
36
37
  [project.urls]
@@ -130,6 +131,10 @@ warn_unreachable = true
130
131
  [tool.ruff]
131
132
  line-length = 120
132
133
  target-version = "py311"
134
+ # The Markdown guides hold fragments -- a keyword argument on its own, a body
135
+ # without its def -- that read as code to a formatter and come out as
136
+ # something else. ruff 0.16 formats fenced Python in Markdown by default.
137
+ extend-exclude = ["*.md"]
133
138
 
134
139
  [tool.ruff.lint]
135
140
  select = ["F", "E", "W", "I", "B", "N", "S", "C4", "DTZ", "SIM", "TRY", "PERF", "RUF", "UP", "ANN", "T20", "PTH", "PLC", "PLE", "PLW"]
@@ -64,7 +64,7 @@ from .uow import (
64
64
  try:
65
65
  __version__ = version("sqlalchemy-foundation-kit")
66
66
  except PackageNotFoundError: # pragma: no cover
67
- __version__ = "0.2.0"
67
+ __version__ = "0.2.1"
68
68
 
69
69
  __all__ = [ # noqa: RUF022
70
70
  # Base ORM
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -11,7 +11,7 @@ def require_optional(module_name: str, extra_name: str) -> types.ModuleType:
11
11
 
12
12
  Args:
13
13
  module_name: Name of the module to import (e.g., "orjson", "opentelemetry").
14
- extra_name: Name of the pip extra that provides this dependency (e.g., "json", "telemetry").
14
+ extra_name: Name of the pip extra that provides this dependency (e.g., "orjson", "telemetry").
15
15
 
16
16
  Returns:
17
17
  The imported module.
@@ -20,7 +20,7 @@ def require_optional(module_name: str, extra_name: str) -> types.ModuleType:
20
20
  ImportError: If the module is not installed, with installation instructions.
21
21
 
22
22
  Examples:
23
- >>> orjson = require_optional("orjson", "json")
23
+ >>> orjson = require_optional("orjson", "orjson")
24
24
  >>> from opentelemetry import trace
25
25
  # or
26
26
  >>> otel = require_optional("opentelemetry", "telemetry")
@@ -57,7 +57,7 @@ def _json_serializer(obj: object) -> str:
57
57
  >>> _json_serializer({"key": "value"})
58
58
  '{"key":"value"}'
59
59
  """
60
- orjson = require_optional("orjson", "json")
60
+ orjson = require_optional("orjson", "orjson")
61
61
 
62
62
  try:
63
63
  return orjson.dumps(obj, default=_default_json_encoder).decode("utf-8") # type: ignore[no-any-return]
@@ -82,7 +82,7 @@ def configure_orjson_serialization() -> dict[str, object]:
82
82
  >>> "json_deserializer" in config
83
83
  True
84
84
  """
85
- orjson = require_optional("orjson", "json")
85
+ orjson = require_optional("orjson", "orjson")
86
86
 
87
87
  return {
88
88
  "json_serializer": _json_serializer,
@@ -0,0 +1,59 @@
1
+ """Shared dependency-injector dependency helpers.
2
+
3
+ Centralizes the dependency-injector import boilerplate and availability check so each
4
+ module doesn't have to repeat it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, NoReturn
10
+
11
+
12
+ def check_dependency_injector() -> None:
13
+ """Raise ImportError if dependency-injector is not installed.
14
+
15
+ Raises:
16
+ ImportError: If dependency-injector is not available.
17
+ """
18
+ if not HAS_DEPENDENCY_INJECTOR:
19
+ raise ImportError(
20
+ "dependency-injector is required for containers. "
21
+ "Install it with: pip install 'sqlalchemy-foundation-kit[dependency-injector]'"
22
+ )
23
+
24
+
25
+ class _MissingDependencyInjector:
26
+ """Stand-in for a dependency-injector name, raising the intended ImportError on first use.
27
+
28
+ ``BaseDIContainer`` subclasses ``containers.DeclarativeContainer`` and the container
29
+ modules build ``providers.*`` in their class bodies, which runs before any check a
30
+ base class could perform. Without this, importing them without dependency-injector
31
+ installed fails with ``AttributeError: 'NoneType' object has no attribute
32
+ 'DeclarativeContainer'`` instead of the message that says what to install.
33
+ """
34
+
35
+ def __getattr__(self, name: str) -> NoReturn:
36
+ check_dependency_injector()
37
+ raise AttributeError(name) # pragma: no cover - unreachable, check raises
38
+
39
+ def __call__(self, *args: Any, **kwargs: Any) -> NoReturn:
40
+ check_dependency_injector()
41
+ raise TypeError("dependency-injector is not installed") # pragma: no cover - unreachable
42
+
43
+
44
+ try:
45
+ from dependency_injector import containers, providers
46
+
47
+ HAS_DEPENDENCY_INJECTOR = True
48
+ except ImportError:
49
+ HAS_DEPENDENCY_INJECTOR = False
50
+ containers = _MissingDependencyInjector() # type: ignore[misc,assignment]
51
+ providers = _MissingDependencyInjector() # type: ignore[misc,assignment]
52
+
53
+
54
+ __all__ = [
55
+ "HAS_DEPENDENCY_INJECTOR",
56
+ "check_dependency_injector",
57
+ "containers",
58
+ "providers",
59
+ ]
@@ -0,0 +1,53 @@
1
+ """Shared dishka dependency helpers.
2
+
3
+ Centralizes the dishka import boilerplate and availability check so each
4
+ provider module doesn't have to repeat it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, NoReturn
10
+
11
+
12
+ def check_dishka() -> None:
13
+ """Raise ImportError if dishka is not installed.
14
+
15
+ Raises:
16
+ ImportError: If dishka is not available.
17
+ """
18
+ if not HAS_DISHKA:
19
+ raise ImportError(
20
+ "dishka is required for providers. Install it with: pip install 'sqlalchemy-foundation-kit[dishka]'"
21
+ )
22
+
23
+
24
+ class _MissingDishka:
25
+ """Stand-in for a dishka name, raising the intended ImportError on first use.
26
+
27
+ Provider modules read ``Scope.APP`` and apply ``@provide`` in their class bodies,
28
+ which runs before any check a base class could perform. Without this, importing
29
+ them without dishka installed fails with ``AttributeError: 'NoneType' object has
30
+ no attribute 'APP'`` instead of the message that says what to install.
31
+ """
32
+
33
+ def __getattr__(self, name: str) -> NoReturn:
34
+ check_dishka()
35
+ raise AttributeError(name) # pragma: no cover - unreachable, check_dishka raises
36
+
37
+ def __call__(self, *args: Any, **kwargs: Any) -> NoReturn:
38
+ check_dishka()
39
+ raise TypeError("dishka is not installed") # pragma: no cover - unreachable
40
+
41
+
42
+ try:
43
+ from dishka import Provider, Scope, provide
44
+
45
+ HAS_DISHKA = True
46
+ except ImportError:
47
+ HAS_DISHKA = False
48
+ Provider = object # type: ignore[misc,assignment]
49
+ Scope = _MissingDishka() # type: ignore[misc,assignment]
50
+ provide = _MissingDishka() # type: ignore[misc,assignment]
51
+
52
+
53
+ __all__ = ["HAS_DISHKA", "Provider", "Scope", "check_dishka", "provide"]
@@ -1,5 +1,7 @@
1
1
  """PostgreSQL advisory locks (async)."""
2
2
 
3
+ import hashlib
4
+
3
5
  from sqlalchemy import text
4
6
  from sqlalchemy.ext.asyncio import AsyncSession
5
7
 
@@ -15,6 +17,10 @@ async def try_advisory_xact_lock(session: AsyncSession, key: str | int) -> bool:
15
17
  at transaction end. String keys are hashed to integers. The key is then
16
18
  truncated to signed 64-bit as Postgres expects.
17
19
 
20
+ A string key produces the same lock in every process, on every host and in
21
+ every release of the library, so two replicas of a service asking for
22
+ ``"nightly-rollup"`` contend for one lock.
23
+
18
24
  Args:
19
25
  session: SQLAlchemy AsyncSession within an active transaction.
20
26
  key: Lock identifier (string or integer). Strings are hashed to integers.
@@ -31,7 +37,7 @@ async def try_advisory_xact_lock(session: AsyncSession, key: str | int) -> bool:
31
37
  ... await session.commit()
32
38
  """
33
39
  # Convert string keys to integers via hashing
34
- int_key = hash(key) if isinstance(key, str) else key
40
+ int_key = _hash_lock_key(key) if isinstance(key, str) else key
35
41
 
36
42
  result = await session.execute(
37
43
  text("SELECT pg_try_advisory_xact_lock(:k)"),
@@ -40,6 +46,28 @@ async def try_advisory_xact_lock(session: AsyncSession, key: str | int) -> bool:
40
46
  return bool(result.scalar())
41
47
 
42
48
 
49
+ def _hash_lock_key(key: str) -> int:
50
+ """Hash a string lock key into the PostgreSQL bigint range, reproducibly.
51
+
52
+ Python's built-in ``hash()`` is unusable here: string hashing is salted per
53
+ interpreter, so the same key becomes a different lock in every process and two
54
+ replicas of the same service lock nothing against each other. BLAKE2b is
55
+ deterministic, so the key is stable across processes, hosts and restarts.
56
+
57
+ Args:
58
+ key: Lock identifier.
59
+
60
+ Returns:
61
+ A signed 64-bit integer suitable for ``pg_try_advisory_xact_lock``.
62
+
63
+ Examples:
64
+ >>> _hash_lock_key("nightly-rollup") == _hash_lock_key("nightly-rollup")
65
+ True
66
+ """
67
+ digest = hashlib.blake2b(key.encode("utf-8"), digest_size=8).digest()
68
+ return int.from_bytes(digest, byteorder="big", signed=True)
69
+
70
+
43
71
  def _to_signed64(key: int) -> int:
44
72
  """Wrap integer to PostgreSQL signed 64-bit bigint range.
45
73
 
@@ -238,15 +238,18 @@ class AsyncSessionManager(Generic[SessionT]):
238
238
  """Get a new database session with automatic transaction management.
239
239
 
240
240
  Args:
241
- isolation_level: Optional isolation level for the transaction.
241
+ isolation_level: Optional isolation level for the transaction, in the
242
+ PostgreSQL spelling (``"SERIALIZABLE"``, ``"REPEATABLE READ"``, …).
243
+ It is applied to the connection this transaction runs on, so it
244
+ affects only this transaction and not the engine.
242
245
 
243
246
  Yields:
244
247
  Managed async session with active transaction.
245
248
  """
246
249
  self._ensure_not_closed()
247
- options = {"isolation_level": isolation_level} if isolation_level else {}
248
- async with (
249
- self._session_maker(execution_options=options) as session,
250
- session.begin(),
251
- ):
250
+ async with self._session_maker() as session, session.begin():
251
+ if isolation_level is not None:
252
+ # begin() has not provisioned a connection yet, so this call is the one
253
+ # that checks it out -- the only moment the level can still be set.
254
+ await session.connection(execution_options={"isolation_level": isolation_level})
252
255
  yield session
@@ -74,10 +74,15 @@ async def apply_isolation_level(
74
74
  ) -> None:
75
75
  """Apply isolation level to an async session's connection.
76
76
 
77
- **Implementation Detail**:
78
- We use ``run_sync()`` because SQLAlchemy's ``execution_options()`` is a
79
- synchronous method that configures the underlying DBAPI connection object.
80
- We must bridge from async context to sync method via ``run_sync()``.
77
+ The level has to reach the connection *before* the connection begins its
78
+ transaction PostgreSQL cannot change the isolation level of a transaction that
79
+ is already running, and SQLAlchemy raises ``InvalidRequestError`` if you try. So
80
+ the level is handed to the ``session.connection()`` call that checks the
81
+ connection out, which applies it and only then begins.
82
+
83
+ Call this on a session that has not yet talked to the database. It starts the
84
+ session's transaction as a side effect, so a caller that wants to own the
85
+ transaction should join the one already open rather than call ``session.begin()``.
81
86
 
82
87
  This is a DRY utility to eliminate duplication of isolation level application
83
88
  logic across ``transaction()``, ``managed_session()``, and ``query()`` methods.
@@ -103,9 +108,32 @@ async def apply_isolation_level(
103
108
  """
104
109
  normalized = normalize_isolation_level(isolation_level)
105
110
  if normalized is not None:
106
- conn = await session.connection()
107
- # run_sync bridges async → sync for DBAPI-level configuration
108
- await conn.run_sync(lambda c: c.execution_options(isolation_level=normalized))
111
+ await session.connection(execution_options={"isolation_level": normalized})
112
+
113
+
114
+ @asynccontextmanager
115
+ async def _owned_transaction(session: AsyncSession) -> AsyncIterator[None]:
116
+ """Run a block inside a transaction, committing on success and rolling back on failure.
117
+
118
+ ``session.begin()`` refuses to start a second transaction, and applying an isolation
119
+ level has to check a connection out — which begins one. So the transaction is only
120
+ started here when the session does not already have one, and the commit/rollback is
121
+ driven explicitly instead of by the ``session.begin()`` context manager.
122
+ """
123
+ if not session.in_transaction():
124
+ await session.begin()
125
+
126
+ try:
127
+ yield
128
+ except BaseException:
129
+ await session.rollback()
130
+ raise
131
+
132
+ try:
133
+ await session.commit()
134
+ except BaseException:
135
+ await session.rollback()
136
+ raise
109
137
 
110
138
 
111
139
  class AsyncSQLAlchemyUowTransaction(AsyncUowTransaction):
@@ -181,13 +209,14 @@ class PostgresAdvisoryLockMixin:
181
209
 
182
210
  session: AsyncSession # Type annotation for protocol compliance
183
211
 
184
- async def try_advisory_lock(self, key: int) -> bool:
212
+ async def try_advisory_lock(self, key: str | int) -> bool:
185
213
  """Acquire a Postgres transaction-scoped advisory lock.
186
214
 
187
215
  Delegates to :func:`try_advisory_xact_lock` for actual locking logic.
188
216
 
189
217
  Args:
190
- key: Integer lock key.
218
+ key: Lock key. An integer is used as-is; a string is hashed to one
219
+ reproducibly, so the same string is the same lock in every process.
191
220
 
192
221
  Returns:
193
222
  True if lock was acquired, False if already held by another session.
@@ -301,7 +330,7 @@ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):
301
330
  if flush_before_commit is None:
302
331
  flush_before_commit = self._flush_before_commit
303
332
 
304
- async with self.open_session(isolation_level) as session, session.begin():
333
+ async with self.open_session(isolation_level) as session, _owned_transaction(session):
305
334
  uow = self._transaction_factory(session)
306
335
  yield uow
307
336
  if flush_before_commit:
@@ -390,8 +419,10 @@ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):
390
419
  rollback when the session closes.
391
420
  """
392
421
  async with self.open_session(isolation_level) as session:
393
- # Start transaction WITHOUT context manager - no auto-commit
394
- await session.begin()
422
+ # Start transaction WITHOUT context manager - no auto-commit.
423
+ # An isolation level, if one was asked for, has already begun it.
424
+ if not session.in_transaction():
425
+ await session.begin()
395
426
  try:
396
427
  uow = self._transaction_factory(session)
397
428
  yield uow, session
@@ -1 +0,0 @@
1
- __version__ = "0.2.0" # x-release-please-version
@@ -1,37 +0,0 @@
1
- """Shared dependency-injector dependency helpers.
2
-
3
- Centralizes the dependency-injector import boilerplate and availability check so each
4
- module doesn't have to repeat it.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- try:
10
- from dependency_injector import containers, providers
11
-
12
- HAS_DEPENDENCY_INJECTOR = True
13
- except ImportError:
14
- HAS_DEPENDENCY_INJECTOR = False
15
- containers = None # type: ignore[misc,assignment]
16
- providers = None # type: ignore[misc,assignment]
17
-
18
-
19
- def check_dependency_injector() -> None:
20
- """Raise ImportError if dependency-injector is not installed.
21
-
22
- Raises:
23
- ImportError: If dependency-injector is not available.
24
- """
25
- if not HAS_DEPENDENCY_INJECTOR:
26
- raise ImportError(
27
- "dependency-injector is required for containers. "
28
- "Install it with: pip install 'sqlalchemy-foundation-kit[dependency-injector]'"
29
- )
30
-
31
-
32
- __all__ = [
33
- "HAS_DEPENDENCY_INJECTOR",
34
- "check_dependency_injector",
35
- "containers",
36
- "providers",
37
- ]
@@ -1,32 +0,0 @@
1
- """Shared dishka dependency helpers.
2
-
3
- Centralizes the dishka import boilerplate and availability check so each
4
- provider module doesn't have to repeat it.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- try:
10
- from dishka import Provider, Scope, provide
11
-
12
- HAS_DISHKA = True
13
- except ImportError:
14
- HAS_DISHKA = False
15
- Provider = object # type: ignore[misc,assignment]
16
- Scope = None # type: ignore[misc,assignment]
17
- provide = None # type: ignore[misc,assignment]
18
-
19
-
20
- def check_dishka() -> None:
21
- """Raise ImportError if dishka is not installed.
22
-
23
- Raises:
24
- ImportError: If dishka is not available.
25
- """
26
- if not HAS_DISHKA:
27
- raise ImportError(
28
- "dishka is required for providers. Install it with: pip install 'sqlalchemy-foundation-kit[dishka]'"
29
- )
30
-
31
-
32
- __all__ = ["HAS_DISHKA", "Provider", "Scope", "check_dishka", "provide"]