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.
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/CHANGELOG.md +7 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/PKG-INFO +16 -3
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/README.md +14 -2
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/pyproject.toml +5 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/__init__.py +1 -1
- sqlalchemy_foundation_kit-0.2.1/sqlalchemy_foundation_kit/__version__.py +1 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/_optional.py +2 -2
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/serialization.py +2 -2
- sqlalchemy_foundation_kit-0.2.1/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +59 -0
- sqlalchemy_foundation_kit-0.2.1/sqlalchemy_foundation_kit/contrib/di/_deps.py +53 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/locks.py +29 -1
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/manager.py +9 -6
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/uow/sqlalchemy.py +43 -12
- sqlalchemy_foundation_kit-0.2.0/sqlalchemy_foundation_kit/__version__.py +0 -1
- sqlalchemy_foundation_kit-0.2.0/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +0 -37
- sqlalchemy_foundation_kit-0.2.0/sqlalchemy_foundation_kit/contrib/di/_deps.py +0 -32
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/.gitignore +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/LICENSE +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/_typing.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/engine.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/metadata.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/models.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/base/types.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/config/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/config/postgres.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/_metrics_utils.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/di/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/di/_base.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/di/database.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/di/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/metrics/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/metrics/postgres.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/settings/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/settings/postgres.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/contrib/telemetry/uow.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/protocols/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/protocols/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/py.typed +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/builder.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/connection.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/factories.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/session/retry.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/uow/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.2.1}/sqlalchemy_foundation_kit/uow/enums.py +0 -0
- {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.
|
|
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 `
|
|
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
|
|
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 `
|
|
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
|
|
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"]
|
|
@@ -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., "
|
|
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", "
|
|
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", "
|
|
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", "
|
|
85
|
+
orjson = require_optional("orjson", "orjson")
|
|
86
86
|
|
|
87
87
|
return {
|
|
88
88
|
"json_serializer": _json_serializer,
|
sqlalchemy_foundation_kit-0.2.1/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py
ADDED
|
@@ -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 =
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
sqlalchemy_foundation_kit-0.2.0/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py
DELETED
|
@@ -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"]
|
|
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
|
|
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
|