sqlalchemy-foundation-kit 0.0.0__py3-none-any.whl
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/__init__.py +119 -0
- sqlalchemy_foundation_kit/__version__.py +1 -0
- sqlalchemy_foundation_kit/_typing.py +28 -0
- sqlalchemy_foundation_kit/base/__init__.py +46 -0
- sqlalchemy_foundation_kit/base/_optional.py +37 -0
- sqlalchemy_foundation_kit/base/engine.py +256 -0
- sqlalchemy_foundation_kit/base/metadata.py +57 -0
- sqlalchemy_foundation_kit/base/models.py +101 -0
- sqlalchemy_foundation_kit/base/serialization.py +98 -0
- sqlalchemy_foundation_kit/base/types.py +72 -0
- sqlalchemy_foundation_kit/config/__init__.py +17 -0
- sqlalchemy_foundation_kit/config/postgres.py +177 -0
- sqlalchemy_foundation_kit/contrib/__init__.py +5 -0
- sqlalchemy_foundation_kit/contrib/_metrics_utils.py +19 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +27 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +27 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +37 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +196 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +85 -0
- sqlalchemy_foundation_kit/contrib/di/__init__.py +21 -0
- sqlalchemy_foundation_kit/contrib/di/_base.py +27 -0
- sqlalchemy_foundation_kit/contrib/di/_deps.py +32 -0
- sqlalchemy_foundation_kit/contrib/di/database.py +169 -0
- sqlalchemy_foundation_kit/contrib/di/metrics.py +71 -0
- sqlalchemy_foundation_kit/contrib/metrics/__init__.py +7 -0
- sqlalchemy_foundation_kit/contrib/metrics/postgres.py +149 -0
- sqlalchemy_foundation_kit/contrib/settings/__init__.py +19 -0
- sqlalchemy_foundation_kit/contrib/settings/postgres.py +183 -0
- sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +17 -0
- sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +101 -0
- sqlalchemy_foundation_kit/contrib/telemetry/uow.py +227 -0
- sqlalchemy_foundation_kit/protocols/__init__.py +21 -0
- sqlalchemy_foundation_kit/protocols/metrics.py +77 -0
- sqlalchemy_foundation_kit/py.typed +0 -0
- sqlalchemy_foundation_kit/session/__init__.py +27 -0
- sqlalchemy_foundation_kit/session/builder.py +293 -0
- sqlalchemy_foundation_kit/session/connection.py +33 -0
- sqlalchemy_foundation_kit/session/factories.py +104 -0
- sqlalchemy_foundation_kit/session/locks.py +68 -0
- sqlalchemy_foundation_kit/session/manager.py +252 -0
- sqlalchemy_foundation_kit/session/retry.py +82 -0
- sqlalchemy_foundation_kit/uow/__init__.py +21 -0
- sqlalchemy_foundation_kit/uow/enums.py +18 -0
- sqlalchemy_foundation_kit/uow/protocols.py +80 -0
- sqlalchemy_foundation_kit/uow/sqlalchemy.py +406 -0
- sqlalchemy_foundation_kit-0.0.0.dist-info/METADATA +624 -0
- sqlalchemy_foundation_kit-0.0.0.dist-info/RECORD +49 -0
- sqlalchemy_foundation_kit-0.0.0.dist-info/WHEEL +4 -0
- sqlalchemy_foundation_kit-0.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""JSON serialization utilities for SQLAlchemy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import decimal
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from ._optional import require_optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_json_encoder(obj: object) -> str:
|
|
14
|
+
"""JSON encoder for types not supported by orjson.
|
|
15
|
+
|
|
16
|
+
Handles special types like decimal.Decimal that orjson doesn't support natively.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
obj: The object to encode.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
String representation of the object.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
TypeError: If the object type is not supported.
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
>>> from decimal import Decimal
|
|
29
|
+
>>> _default_json_encoder(Decimal("123.45"))
|
|
30
|
+
'123.45'
|
|
31
|
+
>>> _default_json_encoder(object())
|
|
32
|
+
Traceback (most recent call last):
|
|
33
|
+
...
|
|
34
|
+
TypeError
|
|
35
|
+
"""
|
|
36
|
+
if isinstance(obj, decimal.Decimal):
|
|
37
|
+
return str(obj)
|
|
38
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _json_serializer(obj: object) -> str:
|
|
42
|
+
"""High-performance JSON serializer using orjson.
|
|
43
|
+
|
|
44
|
+
Provides fast JSON serialization with fallback handling for types
|
|
45
|
+
like decimal.Decimal via the default encoder.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
obj: The object to serialize.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
UTF-8 encoded JSON string.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ImportError: If orjson is not installed.
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
>>> _json_serializer({"key": "value"})
|
|
58
|
+
'{"key":"value"}'
|
|
59
|
+
"""
|
|
60
|
+
orjson = require_optional("orjson", "json")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
return orjson.dumps(obj, default=_default_json_encoder).decode("utf-8") # type: ignore[no-any-return]
|
|
64
|
+
except (TypeError, ValueError) as e:
|
|
65
|
+
logger.exception("Failed to serialize %s to JSON", type(obj).__name__)
|
|
66
|
+
raise TypeError(f"Cannot serialize {type(obj).__name__} to JSON: {e}") from e
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def configure_orjson_serialization() -> dict[str, object]:
|
|
70
|
+
"""Configure orjson serialization for SQLAlchemy engine.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Dictionary with json_serializer and json_deserializer configured.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ImportError: If orjson is not installed.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
>>> config = configure_orjson_serialization()
|
|
80
|
+
>>> "json_serializer" in config
|
|
81
|
+
True
|
|
82
|
+
>>> "json_deserializer" in config
|
|
83
|
+
True
|
|
84
|
+
"""
|
|
85
|
+
orjson = require_optional("orjson", "json")
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"json_serializer": _json_serializer,
|
|
89
|
+
"json_deserializer": orjson.loads,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"configure_orjson_serialization",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# Note: _default_json_encoder and _json_serializer are private internal helpers
|
|
98
|
+
# used by configure_orjson_serialization(). They are not part of the public API.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Custom SQLAlchemy types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import TypeAdapter, ValidationError
|
|
9
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
10
|
+
from sqlalchemy.types import TypeDecorator
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Convenience alias for raw JSON dict columns.
|
|
17
|
+
# Use this when you don't need Pydantic validation — e.g. ``Mapped[GenericJSONDict]``.
|
|
18
|
+
GenericJSONDict = dict[str, Any]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PydanticJSONB(TypeDecorator):
|
|
22
|
+
"""SQLAlchemy TypeDecorator for Pydantic models stored as JSONB.
|
|
23
|
+
|
|
24
|
+
Validates and serializes values against the supplied Pydantic-compatible type
|
|
25
|
+
on both write and read paths. A ``model_type`` is **required** — if you need
|
|
26
|
+
raw dict storage without validation, use SQLAlchemy's built-in ``JSONB`` directly
|
|
27
|
+
(or the :data:`GenericJSONDict` alias).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
impl = JSONB
|
|
31
|
+
cache_ok = True
|
|
32
|
+
|
|
33
|
+
def __init__(self, model_type: type[T], *args: Any, **kwargs: Any) -> None:
|
|
34
|
+
"""Initialize the type decorator.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
model_type: Pydantic model class (or any type compatible with
|
|
38
|
+
``pydantic.TypeAdapter``) used to validate and serialize values.
|
|
39
|
+
"""
|
|
40
|
+
self.model_type = model_type
|
|
41
|
+
self.adapter: TypeAdapter[T] = TypeAdapter(model_type)
|
|
42
|
+
super().__init__(*args, **kwargs)
|
|
43
|
+
|
|
44
|
+
def process_bind_param(self, value: Any, dialect: Any) -> Any:
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Validate before dump to avoid Pydantic serialization warnings when value
|
|
49
|
+
# is a dict (e.g. from model_dump()). This ensures value matches the expected
|
|
50
|
+
# schema and converts it to a model instance if needed.
|
|
51
|
+
validated = self.adapter.validate_python(value)
|
|
52
|
+
return self.adapter.dump_python(validated, mode="json")
|
|
53
|
+
|
|
54
|
+
def process_result_value(self, value: Any, dialect: Any) -> Any:
|
|
55
|
+
if value is None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
return self.adapter.validate_python(value)
|
|
60
|
+
except ValidationError:
|
|
61
|
+
logger.warning(
|
|
62
|
+
"Validation error while loading %s from JSONB. Using raw data. "
|
|
63
|
+
"This may indicate legacy data that doesn't match current schema.",
|
|
64
|
+
self.model_type,
|
|
65
|
+
)
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"GenericJSONDict",
|
|
71
|
+
"PydanticJSONB",
|
|
72
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Database configuration module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .postgres import (
|
|
6
|
+
ConnectionSettingsProtocol,
|
|
7
|
+
PoolSettingsProtocol,
|
|
8
|
+
PostgresSettingsProtocol,
|
|
9
|
+
QuerySettingsProtocol,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ConnectionSettingsProtocol",
|
|
14
|
+
"PoolSettingsProtocol",
|
|
15
|
+
"PostgresSettingsProtocol",
|
|
16
|
+
"QuerySettingsProtocol",
|
|
17
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""PostgreSQL configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PasswordLike(Protocol):
|
|
9
|
+
"""Protocol for secret string types.
|
|
10
|
+
|
|
11
|
+
Allows ``ConnectionSettingsProtocol.password`` to accept either plain ``str``
|
|
12
|
+
or SecretStr-like objects without forcing pydantic dependency in core layer.
|
|
13
|
+
|
|
14
|
+
Implementations:
|
|
15
|
+
- pydantic.SecretStr
|
|
16
|
+
- pydantic_settings.SecretStr
|
|
17
|
+
- str (plain string)
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
Using with Pydantic SecretStr:
|
|
21
|
+
>>> from pydantic import SecretStr
|
|
22
|
+
>>> password: PasswordLike = SecretStr("secret123")
|
|
23
|
+
>>> password.get_secret_value()
|
|
24
|
+
'secret123'
|
|
25
|
+
|
|
26
|
+
Using with plain string (no get_secret_value method):
|
|
27
|
+
>>> password_plain: PasswordLike = "plain_password"
|
|
28
|
+
>>> # Note: plain str doesn't have get_secret_value()
|
|
29
|
+
>>> # Protocol usage requires runtime type checking
|
|
30
|
+
|
|
31
|
+
Security:
|
|
32
|
+
Always use SecretStr-like types in production to prevent accidental logging.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def get_secret_value(self) -> str:
|
|
36
|
+
"""Return the underlying secret value as plain text.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Decrypted/unwrapped secret value.
|
|
40
|
+
|
|
41
|
+
Security:
|
|
42
|
+
This method exposes the secret. Use carefully and never log the result.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> from pydantic import SecretStr
|
|
46
|
+
>>> secret = SecretStr("my-password")
|
|
47
|
+
>>> secret.get_secret_value()
|
|
48
|
+
'my-password'
|
|
49
|
+
"""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConnectionSettingsProtocol(Protocol):
|
|
54
|
+
"""Protocol for PostgreSQL connection settings.
|
|
55
|
+
|
|
56
|
+
Defines connection parameters required for establishing database connections.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
host: PostgreSQL server hostname or IP address.
|
|
60
|
+
port: PostgreSQL server port number.
|
|
61
|
+
user: Database username for authentication.
|
|
62
|
+
password: Database password — either a plain ``str`` or a SecretStr-like object
|
|
63
|
+
implementing ``get_secret_value()``.
|
|
64
|
+
database: Target database name.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
host: str
|
|
68
|
+
port: int
|
|
69
|
+
user: str
|
|
70
|
+
password: PasswordLike | str
|
|
71
|
+
database: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PoolSettingsProtocol(Protocol):
|
|
75
|
+
"""Protocol for PostgreSQL connection pool settings.
|
|
76
|
+
|
|
77
|
+
Defines pool configuration for SQLAlchemy engine connection management.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
kind: Connection pool implementation (queue, static, etc.).
|
|
81
|
+
size: Number of connections to maintain in pool.
|
|
82
|
+
max_overflow: Additional connections allowed when pool exhausted.
|
|
83
|
+
pre_ping: Test connection health before checkout.
|
|
84
|
+
recycle: Recycle connections after N seconds.
|
|
85
|
+
timeout: Timeout for acquiring connection from pool.
|
|
86
|
+
|
|
87
|
+
Examples:
|
|
88
|
+
>>> pool: PoolSettingsProtocol = ...
|
|
89
|
+
>>> if pool.size > 100:
|
|
90
|
+
... logger.warning("Large pool size detected")
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
kind: str | type
|
|
94
|
+
size: int | None
|
|
95
|
+
max_overflow: int | None
|
|
96
|
+
pre_ping: bool
|
|
97
|
+
recycle: int | None
|
|
98
|
+
timeout: float | None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class QuerySettingsProtocol(Protocol):
|
|
102
|
+
"""Protocol for PostgreSQL query execution settings.
|
|
103
|
+
|
|
104
|
+
Defines query behavior, caching, and transaction isolation configuration.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
echo: Enable SQL statement logging.
|
|
108
|
+
statement_cache_size: Prepared statement cache size.
|
|
109
|
+
prepared_statement_cache_size: Server-side prepared statement cache size.
|
|
110
|
+
isolation_level: Transaction isolation level.
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
>>> query: QuerySettingsProtocol = ...
|
|
114
|
+
>>> if query.echo:
|
|
115
|
+
... logger.info("SQL echo enabled")
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
echo: bool
|
|
119
|
+
statement_cache_size: int | None
|
|
120
|
+
prepared_statement_cache_size: int | None
|
|
121
|
+
isolation_level: str | None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PostgresSettingsProtocol(Protocol):
|
|
125
|
+
"""Protocol for PostgreSQL configuration.
|
|
126
|
+
|
|
127
|
+
Organized protocol with grouped settings for connection, pool, and query configuration.
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
connection: Connection parameters (host, port, user, database).
|
|
131
|
+
pool: Connection pool settings.
|
|
132
|
+
query: Query execution and transaction settings.
|
|
133
|
+
application_name: Application identifier for connections.
|
|
134
|
+
db_schema: Optional PostgreSQL schema name.
|
|
135
|
+
use_orjson_serialization: Enable orjson for JSON operations.
|
|
136
|
+
jit: JIT compilation setting (PgBouncer compatibility).
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
Implementing the protocol:
|
|
140
|
+
>>> class MyConfig:
|
|
141
|
+
... connection: ConnectionSettingsProtocol
|
|
142
|
+
... pool: PoolSettingsProtocol
|
|
143
|
+
... query: QuerySettingsProtocol
|
|
144
|
+
... application_name: str = "my-app"
|
|
145
|
+
... db_schema: str | None = None
|
|
146
|
+
... use_orjson_serialization: bool = True
|
|
147
|
+
... jit: str | None = "off"
|
|
148
|
+
...
|
|
149
|
+
... def to_dsn(self) -> str:
|
|
150
|
+
... return f"postgresql://{self.connection.user}@{self.connection.host}..."
|
|
151
|
+
|
|
152
|
+
Using the protocol:
|
|
153
|
+
>>> def create_engine(config: PostgresSettingsProtocol):
|
|
154
|
+
... dsn = config.to_dsn()
|
|
155
|
+
... pool_size = config.pool.pool_size
|
|
156
|
+
... echo = config.query.echo
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
connection: ConnectionSettingsProtocol
|
|
160
|
+
pool: PoolSettingsProtocol
|
|
161
|
+
query: QuerySettingsProtocol
|
|
162
|
+
application_name: str
|
|
163
|
+
db_schema: str | None
|
|
164
|
+
use_orjson_serialization: bool
|
|
165
|
+
jit: str | None
|
|
166
|
+
|
|
167
|
+
def to_dsn(self) -> str:
|
|
168
|
+
"""Convert config to DSN string.
|
|
169
|
+
|
|
170
|
+
Returns PostgreSQL connection string in format:
|
|
171
|
+
postgresql+asyncpg://user:password@host:port/database
|
|
172
|
+
|
|
173
|
+
Examples:
|
|
174
|
+
>>> config.to_dsn()
|
|
175
|
+
'postgresql+asyncpg://user:***@localhost:5432/mydb'
|
|
176
|
+
"""
|
|
177
|
+
...
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared helpers for metrics providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _infra_metrics_prefix(default_prefix: str | None) -> str | None:
|
|
7
|
+
"""Resolve prefix from the app (``get_default_prefix`` → ``str | None``).
|
|
8
|
+
|
|
9
|
+
``None`` or whitespace-only string means no prefix for underlying metrics classes.
|
|
10
|
+
This intentionally does **not** read ``PrometheusMetricsSettingsProtocol.prefix`` so
|
|
11
|
+
service-level ``METRICS__PREFIX`` can target business metrics only.
|
|
12
|
+
"""
|
|
13
|
+
if default_prefix is None:
|
|
14
|
+
return None
|
|
15
|
+
stripped = default_prefix.strip()
|
|
16
|
+
return stripped if stripped else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["_infra_metrics_prefix"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""dependency-injector containers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._base import BaseDIContainer
|
|
6
|
+
from .database import (
|
|
7
|
+
AsyncDatabaseResourceProvider,
|
|
8
|
+
DatabaseContainer,
|
|
9
|
+
RetryConfig,
|
|
10
|
+
retry_async_connection,
|
|
11
|
+
)
|
|
12
|
+
from .metrics import (
|
|
13
|
+
PostgresMetricsSettingsProtocol,
|
|
14
|
+
PrometheusMetricsContainer,
|
|
15
|
+
PrometheusMetricsSettingsProtocol,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AsyncDatabaseResourceProvider",
|
|
20
|
+
"BaseDIContainer",
|
|
21
|
+
"DatabaseContainer",
|
|
22
|
+
"PostgresMetricsSettingsProtocol",
|
|
23
|
+
"PrometheusMetricsContainer",
|
|
24
|
+
"PrometheusMetricsSettingsProtocol",
|
|
25
|
+
"RetryConfig",
|
|
26
|
+
"retry_async_connection",
|
|
27
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Base dependency-injector container with automatic dependency checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._deps import check_dependency_injector, containers
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseDIContainer(containers.DeclarativeContainer): # type: ignore[misc,name-defined]
|
|
9
|
+
"""Base container that checks dependency-injector availability on subclass creation.
|
|
10
|
+
|
|
11
|
+
All dependency-injector containers should inherit from this class instead of directly
|
|
12
|
+
from containers.DeclarativeContainer. This ensures consistent error messages when
|
|
13
|
+
dependency-injector is not installed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
17
|
+
"""Check dependency-injector availability when creating a subclass."""
|
|
18
|
+
super().__init_subclass__(**kwargs)
|
|
19
|
+
check_dependency_injector()
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
22
|
+
"""Check dependency-injector availability when instantiating."""
|
|
23
|
+
check_dependency_injector()
|
|
24
|
+
super().__init__(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["BaseDIContainer"]
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Database containers for dependency-injector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import text
|
|
9
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
11
|
+
|
|
12
|
+
from ...config import PostgresSettingsProtocol
|
|
13
|
+
from ...protocols import PostgresMetricsProtocol
|
|
14
|
+
from ...session import (
|
|
15
|
+
DEFAULT_HEALTHCHECK_QUERY,
|
|
16
|
+
DEFAULT_RETRY_CONFIG,
|
|
17
|
+
AsyncSessionManager,
|
|
18
|
+
RetryConfig,
|
|
19
|
+
create_async_session_manager,
|
|
20
|
+
retry_async_connection,
|
|
21
|
+
)
|
|
22
|
+
from ...uow import (
|
|
23
|
+
AsyncSQLAlchemyUnitOfWork,
|
|
24
|
+
AsyncSQLAlchemyUowTransaction,
|
|
25
|
+
AsyncUnitOfWork,
|
|
26
|
+
)
|
|
27
|
+
from ._base import BaseDIContainer
|
|
28
|
+
from ._deps import providers
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _create_session_manager_resource(
|
|
34
|
+
postgres_config: PostgresSettingsProtocol,
|
|
35
|
+
metrics: PostgresMetricsProtocol | None,
|
|
36
|
+
healthcheck_query: str | None,
|
|
37
|
+
retry_config: RetryConfig,
|
|
38
|
+
) -> AsyncIterator[AsyncSessionManager[AsyncSession]]:
|
|
39
|
+
"""Resource factory: build manager, run healthcheck, yield, then close."""
|
|
40
|
+
manager = create_async_session_manager(postgres_config, metrics=metrics)
|
|
41
|
+
|
|
42
|
+
if healthcheck_query is not None:
|
|
43
|
+
query = healthcheck_query
|
|
44
|
+
|
|
45
|
+
async def test_connection() -> None:
|
|
46
|
+
async with manager.session_maker() as session:
|
|
47
|
+
await session.execute(text(query))
|
|
48
|
+
|
|
49
|
+
await retry_async_connection(
|
|
50
|
+
connect_func=test_connection,
|
|
51
|
+
service_name="PostgreSQL",
|
|
52
|
+
config=retry_config,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
yield manager
|
|
57
|
+
finally:
|
|
58
|
+
try:
|
|
59
|
+
await manager.aclose()
|
|
60
|
+
logger.info("Database session manager closed successfully")
|
|
61
|
+
except SQLAlchemyError as e:
|
|
62
|
+
logger.warning("Error closing database session manager: %s", e)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_session_maker(
|
|
66
|
+
session_manager: AsyncSessionManager[AsyncSession],
|
|
67
|
+
) -> async_sessionmaker[AsyncSession]:
|
|
68
|
+
"""Factory: extract session_maker from session_manager."""
|
|
69
|
+
return session_manager.session_maker
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _create_uow(
|
|
73
|
+
session_maker: async_sessionmaker[AsyncSession],
|
|
74
|
+
) -> AsyncUnitOfWork[AsyncSQLAlchemyUowTransaction]:
|
|
75
|
+
"""Factory: create Unit of Work from session_maker."""
|
|
76
|
+
return AsyncSQLAlchemyUnitOfWork(session_maker, transaction_factory=AsyncSQLAlchemyUowTransaction)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DatabaseContainer(BaseDIContainer):
|
|
80
|
+
"""Container for database dependencies.
|
|
81
|
+
|
|
82
|
+
Provides:
|
|
83
|
+
- ``session_manager``: Manages database connections and engine lifecycle.
|
|
84
|
+
- ``session_maker``: Factory for creating database sessions.
|
|
85
|
+
- ``uow``: Unit of Work for database transactions.
|
|
86
|
+
|
|
87
|
+
Configuration:
|
|
88
|
+
- ``postgres_config``: PostgreSQL configuration (``PostgresSettingsProtocol``).
|
|
89
|
+
- ``metrics``: Optional metrics collector (``PostgresMetricsProtocol``).
|
|
90
|
+
- ``healthcheck_query``: SQL executed at startup (default: ``"SELECT 1"``, ``None`` to skip).
|
|
91
|
+
- ``retry_config``: Retry behavior for healthcheck (default: ``RetryConfig()``).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
# Configuration
|
|
95
|
+
postgres_config = providers.Dependency() # type: ignore[misc,var-annotated]
|
|
96
|
+
metrics = providers.Dependency(default=None) # type: ignore[misc,var-annotated]
|
|
97
|
+
|
|
98
|
+
# Healthcheck configuration
|
|
99
|
+
healthcheck_query = providers.Object(DEFAULT_HEALTHCHECK_QUERY) # type: ignore[misc,var-annotated]
|
|
100
|
+
retry_config = providers.Object(DEFAULT_RETRY_CONFIG) # type: ignore[misc,var-annotated]
|
|
101
|
+
|
|
102
|
+
# Session manager (resource: handles lifecycle)
|
|
103
|
+
session_manager = providers.Resource( # type: ignore[misc,var-annotated]
|
|
104
|
+
_create_session_manager_resource,
|
|
105
|
+
postgres_config=postgres_config,
|
|
106
|
+
metrics=metrics,
|
|
107
|
+
healthcheck_query=healthcheck_query,
|
|
108
|
+
retry_config=retry_config,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Session maker
|
|
112
|
+
session_maker = providers.Factory( # type: ignore[misc,var-annotated]
|
|
113
|
+
_get_session_maker,
|
|
114
|
+
session_manager=session_manager,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Unit of Work
|
|
118
|
+
uow = providers.Singleton( # type: ignore[misc,var-annotated]
|
|
119
|
+
_create_uow,
|
|
120
|
+
session_maker=session_maker,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AsyncDatabaseResourceProvider:
|
|
125
|
+
"""Helper class for managing database session manager lifecycle.
|
|
126
|
+
|
|
127
|
+
Use this when you need manual control over session manager lifecycle —
|
|
128
|
+
for example in tests or when not using dependency-injector containers.
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
>>> provider = AsyncDatabaseResourceProvider(config, metrics)
|
|
132
|
+
>>> manager = await provider.start()
|
|
133
|
+
>>> # Use manager...
|
|
134
|
+
>>> await provider.stop()
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
postgres_config: PostgresSettingsProtocol,
|
|
140
|
+
metrics: PostgresMetricsProtocol | None = None,
|
|
141
|
+
healthcheck_query: str | None = DEFAULT_HEALTHCHECK_QUERY,
|
|
142
|
+
retry_config: RetryConfig = DEFAULT_RETRY_CONFIG,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Initialize provider.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
postgres_config: PostgreSQL configuration.
|
|
148
|
+
metrics: Optional metrics collector.
|
|
149
|
+
healthcheck_query: SQL executed at startup to verify connectivity.
|
|
150
|
+
Pass ``None`` to skip the healthcheck entirely.
|
|
151
|
+
retry_config: Retry behavior for healthcheck.
|
|
152
|
+
"""
|
|
153
|
+
self._postgres_config = postgres_config
|
|
154
|
+
self._metrics = metrics
|
|
155
|
+
self._healthcheck_query = healthcheck_query
|
|
156
|
+
self._retry_config = retry_config
|
|
157
|
+
self._manager: AsyncSessionManager[AsyncSession] | None = None
|
|
158
|
+
|
|
159
|
+
async def start(self) -> AsyncSessionManager[AsyncSession]:
|
|
160
|
+
"""Start session manager and perform healthcheck."""
|
|
161
|
+
manager = create_async_session_manager(self._postgres_config, metrics=self._metrics)
|
|
162
|
+
|
|
163
|
+
if self._healthcheck_query is not None:
|
|
164
|
+
query = self._healthcheck_query
|
|
165
|
+
|
|
166
|
+
async def test_connection() -> None:
|
|
167
|
+
async with manager.session_maker() as session:
|
|
168
|
+
await session.execute(text(query))
|
|
169
|
+
|
|
170
|
+
await retry_async_connection(
|
|
171
|
+
connect_func=test_connection,
|
|
172
|
+
service_name="PostgreSQL",
|
|
173
|
+
config=self._retry_config,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
self._manager = manager
|
|
177
|
+
return manager
|
|
178
|
+
|
|
179
|
+
async def stop(self) -> None:
|
|
180
|
+
"""Stop session manager and close connections."""
|
|
181
|
+
if self._manager is not None:
|
|
182
|
+
try:
|
|
183
|
+
await self._manager.aclose()
|
|
184
|
+
logger.info("Database session manager closed successfully")
|
|
185
|
+
except SQLAlchemyError as e:
|
|
186
|
+
logger.warning("Error closing database session manager: %s", e)
|
|
187
|
+
finally:
|
|
188
|
+
self._manager = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = [
|
|
192
|
+
"AsyncDatabaseResourceProvider",
|
|
193
|
+
"DatabaseContainer",
|
|
194
|
+
"RetryConfig",
|
|
195
|
+
"retry_async_connection",
|
|
196
|
+
]
|