sqlalchemy-foundation-kit 0.2.0__tar.gz → 0.3.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.
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/CHANGELOG.md +14 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/PKG-INFO +18 -5
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/README.md +16 -4
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/pyproject.toml +5 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/__init__.py +1 -1
- sqlalchemy_foundation_kit-0.3.0/sqlalchemy_foundation_kit/__version__.py +1 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/base/_optional.py +2 -2
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/base/serialization.py +2 -2
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/config/postgres.py +3 -3
- sqlalchemy_foundation_kit-0.3.0/sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +59 -0
- sqlalchemy_foundation_kit-0.3.0/sqlalchemy_foundation_kit/contrib/di/_deps.py +53 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/settings/postgres.py +13 -4
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/session/builder.py +18 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/session/factories.py +12 -3
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/session/locks.py +29 -1
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/session/manager.py +49 -7
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/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.3.0}/.gitignore +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/LICENSE +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/_typing.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/base/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/base/engine.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/base/metadata.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/base/models.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/base/types.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/config/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/_metrics_utils.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/di/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/di/_base.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/di/database.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/di/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/metrics/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/metrics/postgres.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/settings/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/contrib/telemetry/uow.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/protocols/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/protocols/metrics.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/py.typed +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/session/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/session/connection.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/session/retry.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/uow/__init__.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/uow/enums.py +0 -0
- {sqlalchemy_foundation_kit-0.2.0 → sqlalchemy_foundation_kit-0.3.0}/sqlalchemy_foundation_kit/uow/protocols.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/bedrock-python/sqlalchemy-foundation-kit/compare/sqlalchemy-foundation-kit-v0.2.1...sqlalchemy-foundation-kit-v0.3.0) (2026-09-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* stop sending jit and search_path as startup parameters ([#24](https://github.com/bedrock-python/sqlalchemy-foundation-kit/issues/24)) ([704c343](https://github.com/bedrock-python/sqlalchemy-foundation-kit/commit/704c343b932b15f0c6ee434f2fc80ab4732b0f80))
|
|
9
|
+
|
|
10
|
+
## [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)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* 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))
|
|
16
|
+
|
|
3
17
|
## [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
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlalchemy-foundation-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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,14 +280,22 @@ 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
|
|
|
286
295
|
✅ **Single dependency** — All foundation pieces in one place
|
|
287
296
|
✅ **Unit of Work pattern** — Transactional consistency with automatic commit/rollback
|
|
288
297
|
✅ **Connection pool management** — `AsyncSessionManager` with metrics and health checks
|
|
289
|
-
✅ **
|
|
298
|
+
✅ **PgBouncer compatible** — A startup packet PgBouncer accepts, `search_path` per transaction, unique statement names
|
|
290
299
|
✅ **Observability built-in** — Prometheus metrics + OpenTelemetry tracing
|
|
291
300
|
✅ **Type-safe configuration** — Pydantic settings with validation
|
|
292
301
|
✅ **Base ORM models** — Pre-configured `Base` with naming conventions and mixins
|
|
@@ -389,7 +398,7 @@ class PostgresConfig:
|
|
|
389
398
|
application_name: str = "my-service"
|
|
390
399
|
db_schema: str | None = None
|
|
391
400
|
use_orjson_serialization: bool = True
|
|
392
|
-
jit: str | None =
|
|
401
|
+
jit: str | None = None
|
|
393
402
|
|
|
394
403
|
def to_dsn(self) -> str:
|
|
395
404
|
return f"postgresql+asyncpg://{self.connection.user}@{self.connection.host}:{self.connection.port}/{self.connection.database}"
|
|
@@ -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,14 +29,22 @@
|
|
|
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
|
|
|
36
44
|
✅ **Single dependency** — All foundation pieces in one place
|
|
37
45
|
✅ **Unit of Work pattern** — Transactional consistency with automatic commit/rollback
|
|
38
46
|
✅ **Connection pool management** — `AsyncSessionManager` with metrics and health checks
|
|
39
|
-
✅ **
|
|
47
|
+
✅ **PgBouncer compatible** — A startup packet PgBouncer accepts, `search_path` per transaction, unique statement names
|
|
40
48
|
✅ **Observability built-in** — Prometheus metrics + OpenTelemetry tracing
|
|
41
49
|
✅ **Type-safe configuration** — Pydantic settings with validation
|
|
42
50
|
✅ **Base ORM models** — Pre-configured `Base` with naming conventions and mixins
|
|
@@ -139,7 +147,7 @@ class PostgresConfig:
|
|
|
139
147
|
application_name: str = "my-service"
|
|
140
148
|
db_schema: str | None = None
|
|
141
149
|
use_orjson_serialization: bool = True
|
|
142
|
-
jit: str | None =
|
|
150
|
+
jit: str | None = None
|
|
143
151
|
|
|
144
152
|
def to_dsn(self) -> str:
|
|
145
153
|
return f"postgresql+asyncpg://{self.connection.user}@{self.connection.host}:{self.connection.port}/{self.connection.database}"
|
|
@@ -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.3.0"
|
|
@@ -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,
|
|
@@ -131,9 +131,9 @@ class PostgresSettingsProtocol(Protocol):
|
|
|
131
131
|
pool: Connection pool settings.
|
|
132
132
|
query: Query execution and transaction settings.
|
|
133
133
|
application_name: Application identifier for connections.
|
|
134
|
-
db_schema: Optional PostgreSQL
|
|
134
|
+
db_schema: Optional PostgreSQL ``search_path``, applied to every transaction.
|
|
135
135
|
use_orjson_serialization: Enable orjson for JSON operations.
|
|
136
|
-
jit: JIT compilation setting
|
|
136
|
+
jit: JIT compilation setting, sent as a startup parameter only when not ``None``.
|
|
137
137
|
|
|
138
138
|
Examples:
|
|
139
139
|
Implementing the protocol:
|
|
@@ -144,7 +144,7 @@ class PostgresSettingsProtocol(Protocol):
|
|
|
144
144
|
... application_name: str = "my-app"
|
|
145
145
|
... db_schema: str | None = None
|
|
146
146
|
... use_orjson_serialization: bool = True
|
|
147
|
-
... jit: str | None =
|
|
147
|
+
... jit: str | None = None
|
|
148
148
|
...
|
|
149
149
|
... def to_dsn(self) -> str:
|
|
150
150
|
... return f"postgresql://{self.connection.user}@{self.connection.host}..."
|
sqlalchemy_foundation_kit-0.3.0/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"]
|
|
@@ -98,9 +98,12 @@ class BasePostgresConfig(BaseSettings):
|
|
|
98
98
|
pool: Connection pool configuration (size, overflow, timeouts).
|
|
99
99
|
query: Query execution settings (echo, caching, isolation level).
|
|
100
100
|
application_name: Application name for connection identification.
|
|
101
|
-
db_schema: Optional PostgreSQL schema
|
|
101
|
+
db_schema: Optional PostgreSQL ``search_path`` — a schema, or a comma-separated
|
|
102
|
+
list — applied to every transaction with ``SET LOCAL`` semantics.
|
|
102
103
|
use_orjson_serialization: Use orjson for JSON serialization (requires orjson).
|
|
103
|
-
jit: JIT compilation setting (off/on)
|
|
104
|
+
jit: JIT compilation setting (off/on), sent as a startup parameter only when set.
|
|
105
|
+
``None`` (the default) sends nothing and leaves the server's own setting; a
|
|
106
|
+
transaction-mode PgBouncer rejects the parameter unless it tracks it.
|
|
104
107
|
metrics_enabled: Enable connection pool metrics collection.
|
|
105
108
|
|
|
106
109
|
Examples:
|
|
@@ -125,12 +128,18 @@ class BasePostgresConfig(BaseSettings):
|
|
|
125
128
|
|
|
126
129
|
# Top-level settings
|
|
127
130
|
application_name: str = Field(description="Application name for PostgreSQL")
|
|
128
|
-
db_schema: str | None = Field(
|
|
131
|
+
db_schema: str | None = Field(
|
|
132
|
+
default=None,
|
|
133
|
+
description="PostgreSQL search_path (a schema or a comma-separated list), applied to every transaction",
|
|
134
|
+
)
|
|
129
135
|
use_orjson_serialization: bool = Field(
|
|
130
136
|
default=True,
|
|
131
137
|
description="Use orjson for JSON serialization (requires orjson installed)",
|
|
132
138
|
)
|
|
133
|
-
jit: PostgresJit | None = Field(
|
|
139
|
+
jit: PostgresJit | None = Field(
|
|
140
|
+
default=None,
|
|
141
|
+
description="JIT setting (off/on), sent as a startup parameter only when set",
|
|
142
|
+
)
|
|
134
143
|
metrics_enabled: bool = Field(default=False, description="Enable PostgreSQL metrics")
|
|
135
144
|
|
|
136
145
|
def __repr__(self) -> str:
|
|
@@ -77,6 +77,7 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
|
|
|
77
77
|
self._metrics: PostgresMetricsProtocol | None = None
|
|
78
78
|
self._on_engine_created: Callable[[AsyncEngine], None] | None = None
|
|
79
79
|
self._dispose_timeout: float | None = None
|
|
80
|
+
self._search_path: str | None = None
|
|
80
81
|
self._extra_kwargs: dict[str, object] = {}
|
|
81
82
|
|
|
82
83
|
def with_echo(self, echo: bool = True) -> AsyncSessionManagerBuilder[SessionT]:
|
|
@@ -258,6 +259,22 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
|
|
|
258
259
|
self._dispose_timeout = timeout
|
|
259
260
|
return self
|
|
260
261
|
|
|
262
|
+
def with_search_path(self, search_path: str) -> AsyncSessionManagerBuilder[SessionT]:
|
|
263
|
+
"""Apply a PostgreSQL ``search_path`` to every transaction.
|
|
264
|
+
|
|
265
|
+
Issued with ``SET LOCAL`` semantics at the start of each transaction — the one
|
|
266
|
+
scope a transaction-mode pooler such as PgBouncer honours. See
|
|
267
|
+
:func:`~sqlalchemy_foundation_kit.session.manager.attach_search_path`.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
search_path: A schema, or a comma-separated list such as ``"tenant_7, public"``.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Self for method chaining.
|
|
274
|
+
"""
|
|
275
|
+
self._search_path = search_path
|
|
276
|
+
return self
|
|
277
|
+
|
|
261
278
|
def build(self) -> AsyncSessionManager[SessionT]:
|
|
262
279
|
"""Build AsyncSessionManager instance with configured parameters.
|
|
263
280
|
|
|
@@ -284,6 +301,7 @@ class AsyncSessionManagerBuilder(Generic[SessionT]):
|
|
|
284
301
|
"use_orjson": self._use_orjson,
|
|
285
302
|
"metrics": self._metrics,
|
|
286
303
|
"on_engine_created": self._on_engine_created,
|
|
304
|
+
"search_path": self._search_path,
|
|
287
305
|
}
|
|
288
306
|
if self._dispose_timeout is not None:
|
|
289
307
|
kwargs["dispose_timeout"] = self._dispose_timeout
|
|
@@ -31,6 +31,13 @@ def create_async_session_manager(
|
|
|
31
31
|
) -> AsyncSessionManager[AsyncSession]:
|
|
32
32
|
"""Create async session manager with PostgreSQL-specific configuration.
|
|
33
33
|
|
|
34
|
+
The startup packet carries ``application_name`` and, only when the config sets it,
|
|
35
|
+
``jit``; nothing else, because a transaction-mode pooler such as PgBouncer rejects
|
|
36
|
+
startup parameters it does not track (``unsupported startup parameter: jit``) or,
|
|
37
|
+
with ``ignore_startup_parameters``, silently drops them. ``db_schema`` therefore
|
|
38
|
+
travels as the manager's ``search_path`` and is applied with ``SET LOCAL`` semantics
|
|
39
|
+
at the start of every transaction, which is the one scope such a pooler honours.
|
|
40
|
+
|
|
34
41
|
Args:
|
|
35
42
|
postgres_config: PostgreSQL configuration implementing PostgresSettingsProtocol.
|
|
36
43
|
application_name: Optional custom application name. If None, uses postgres_config.application_name.
|
|
@@ -41,7 +48,8 @@ def create_async_session_manager(
|
|
|
41
48
|
which provides pgbouncer transaction-mode compatibility.
|
|
42
49
|
extra_server_settings: Additional PostgreSQL ``server_settings`` to merge with defaults
|
|
43
50
|
(e.g., ``{"statement_timeout": "30000", "timezone": "UTC"}``). User-provided keys
|
|
44
|
-
override library defaults.
|
|
51
|
+
override library defaults. These are startup parameters: through PgBouncer only
|
|
52
|
+
the ones it tracks arrive (``track_extra_parameters``).
|
|
45
53
|
extra_connect_args: Additional asyncpg ``connect_args`` to merge with defaults
|
|
46
54
|
(e.g., ``{"command_timeout": 60}``). User-provided keys override library defaults.
|
|
47
55
|
**kwargs: Additional keyword arguments passed to AsyncSessionManager.
|
|
@@ -73,11 +81,11 @@ def create_async_session_manager(
|
|
|
73
81
|
"""
|
|
74
82
|
app_name = application_name or postgres_config.application_name
|
|
75
83
|
|
|
76
|
-
# Build server settings with optional overrides
|
|
84
|
+
# Build server settings with optional overrides. These are startup parameters, so
|
|
85
|
+
# search_path is deliberately not among them -- see the docstring.
|
|
77
86
|
server_settings: dict[str, str] = {
|
|
78
87
|
"application_name": app_name,
|
|
79
88
|
**({"jit": postgres_config.jit} if postgres_config.jit is not None else {}),
|
|
80
|
-
**({"search_path": postgres_config.db_schema} if postgres_config.db_schema is not None else {}),
|
|
81
89
|
**(extra_server_settings or {}),
|
|
82
90
|
}
|
|
83
91
|
|
|
@@ -100,5 +108,6 @@ def create_async_session_manager(
|
|
|
100
108
|
use_orjson=postgres_config.use_orjson_serialization,
|
|
101
109
|
metrics=metrics,
|
|
102
110
|
on_engine_created=on_engine_created,
|
|
111
|
+
search_path=postgres_config.db_schema,
|
|
103
112
|
**kwargs,
|
|
104
113
|
)
|
|
@@ -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
|
|
|
@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
|
|
|
10
10
|
from types import TracebackType
|
|
11
11
|
from typing import TYPE_CHECKING, Any, Generic, cast
|
|
12
12
|
|
|
13
|
-
from sqlalchemy import event
|
|
13
|
+
from sqlalchemy import event, text
|
|
14
14
|
from sqlalchemy.exc import TimeoutError as SATimeoutError
|
|
15
15
|
from sqlalchemy.ext.asyncio import (
|
|
16
16
|
AsyncEngine,
|
|
@@ -23,6 +23,8 @@ from .._typing import SessionT
|
|
|
23
23
|
from ..base import build_engine_kwargs, resolve_pool_class
|
|
24
24
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
|
+
from sqlalchemy.engine import Connection
|
|
27
|
+
|
|
26
28
|
from ..config import PoolSettingsProtocol
|
|
27
29
|
from ..protocols import PostgresMetricsProtocol
|
|
28
30
|
|
|
@@ -100,6 +102,35 @@ def attach_metrics(engine: AsyncEngine, metrics: PostgresMetricsProtocol) -> Non
|
|
|
100
102
|
event.listen(engine.sync_engine, "handle_error", on_error)
|
|
101
103
|
|
|
102
104
|
|
|
105
|
+
def attach_search_path(engine: AsyncEngine, search_path: str) -> None:
|
|
106
|
+
"""Apply ``search_path`` to every transaction the engine begins.
|
|
107
|
+
|
|
108
|
+
Registers a ``begin`` listener that issues ``set_config('search_path', …, true)`` —
|
|
109
|
+
the function form of ``SET LOCAL``, with the value as a bind parameter — as the first
|
|
110
|
+
statement of each transaction, so the setting lives exactly as long as the transaction
|
|
111
|
+
does. That is the one scope a transaction-mode pooler such as PgBouncer honours: a
|
|
112
|
+
startup parameter is rejected or dropped before it reaches PostgreSQL, and a plain
|
|
113
|
+
``SET`` on a server connection leaks to whichever client is handed it next.
|
|
114
|
+
|
|
115
|
+
Under the asyncpg adapter ``BEGIN`` is sent lazily with the first statement, so the
|
|
116
|
+
``set_config`` call lands inside the transaction rather than ahead of it. Every
|
|
117
|
+
transaction is covered — a session's autobegin, ``session.begin()``, a raw
|
|
118
|
+
``engine.connect()``, the one that follows a ``commit()`` — but a statement run with
|
|
119
|
+
``isolation_level="AUTOCOMMIT"`` begins none and runs with the server's default.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
engine: SQLAlchemy ``AsyncEngine`` to attach the listener to.
|
|
123
|
+
search_path: Value for ``search_path`` — a schema, or a comma-separated list such
|
|
124
|
+
as ``"tenant_7, public"``.
|
|
125
|
+
"""
|
|
126
|
+
statement = text("SELECT set_config('search_path', :search_path, true)")
|
|
127
|
+
|
|
128
|
+
def on_begin(conn: Connection) -> None:
|
|
129
|
+
conn.execute(statement, {"search_path": search_path})
|
|
130
|
+
|
|
131
|
+
event.listen(engine.sync_engine, "begin", on_begin)
|
|
132
|
+
|
|
133
|
+
|
|
103
134
|
class AsyncSessionManager(Generic[SessionT]):
|
|
104
135
|
"""Manages async database sessions with configurable connection pooling.
|
|
105
136
|
|
|
@@ -124,6 +155,7 @@ class AsyncSessionManager(Generic[SessionT]):
|
|
|
124
155
|
metrics: PostgresMetricsProtocol | None = None,
|
|
125
156
|
on_engine_created: Callable[[AsyncEngine], None] | None = None,
|
|
126
157
|
dispose_timeout: float = DEFAULT_DISPOSE_TIMEOUT_SECONDS,
|
|
158
|
+
search_path: str | None = None,
|
|
127
159
|
**kwargs: object,
|
|
128
160
|
) -> None:
|
|
129
161
|
"""Initialize session manager with direct configuration.
|
|
@@ -145,6 +177,10 @@ class AsyncSessionManager(Generic[SessionT]):
|
|
|
145
177
|
dispose_timeout: Maximum seconds to wait for engine disposal in :meth:`aclose`
|
|
146
178
|
(default: 30.0). Lower this in tests or short-lived environments; raise it
|
|
147
179
|
if you have long-running transactions that need more time to settle.
|
|
180
|
+
search_path: PostgreSQL ``search_path`` applied to every transaction with
|
|
181
|
+
``SET LOCAL`` semantics, which is what survives a transaction-mode pooler
|
|
182
|
+
(default: None — nothing is sent, the server default applies). See
|
|
183
|
+
:func:`attach_search_path`.
|
|
148
184
|
**kwargs: Additional keyword arguments for ``create_async_engine``.
|
|
149
185
|
"""
|
|
150
186
|
self._closed = False
|
|
@@ -174,6 +210,9 @@ class AsyncSessionManager(Generic[SessionT]):
|
|
|
174
210
|
if metrics:
|
|
175
211
|
attach_metrics(self._engine, metrics)
|
|
176
212
|
|
|
213
|
+
if search_path is not None:
|
|
214
|
+
attach_search_path(self._engine, search_path)
|
|
215
|
+
|
|
177
216
|
if on_engine_created is not None:
|
|
178
217
|
on_engine_created(self._engine)
|
|
179
218
|
|
|
@@ -238,15 +277,18 @@ class AsyncSessionManager(Generic[SessionT]):
|
|
|
238
277
|
"""Get a new database session with automatic transaction management.
|
|
239
278
|
|
|
240
279
|
Args:
|
|
241
|
-
isolation_level: Optional isolation level for the transaction
|
|
280
|
+
isolation_level: Optional isolation level for the transaction, in the
|
|
281
|
+
PostgreSQL spelling (``"SERIALIZABLE"``, ``"REPEATABLE READ"``, …).
|
|
282
|
+
It is applied to the connection this transaction runs on, so it
|
|
283
|
+
affects only this transaction and not the engine.
|
|
242
284
|
|
|
243
285
|
Yields:
|
|
244
286
|
Managed async session with active transaction.
|
|
245
287
|
"""
|
|
246
288
|
self._ensure_not_closed()
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
289
|
+
async with self._session_maker() as session, session.begin():
|
|
290
|
+
if isolation_level is not None:
|
|
291
|
+
# begin() has not provisioned a connection yet, so this call is the one
|
|
292
|
+
# that checks it out -- the only moment the level can still be set.
|
|
293
|
+
await session.connection(execution_options={"isolation_level": isolation_level})
|
|
252
294
|
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
|