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.
Files changed (49) hide show
  1. sqlalchemy_foundation_kit/__init__.py +119 -0
  2. sqlalchemy_foundation_kit/__version__.py +1 -0
  3. sqlalchemy_foundation_kit/_typing.py +28 -0
  4. sqlalchemy_foundation_kit/base/__init__.py +46 -0
  5. sqlalchemy_foundation_kit/base/_optional.py +37 -0
  6. sqlalchemy_foundation_kit/base/engine.py +256 -0
  7. sqlalchemy_foundation_kit/base/metadata.py +57 -0
  8. sqlalchemy_foundation_kit/base/models.py +101 -0
  9. sqlalchemy_foundation_kit/base/serialization.py +98 -0
  10. sqlalchemy_foundation_kit/base/types.py +72 -0
  11. sqlalchemy_foundation_kit/config/__init__.py +17 -0
  12. sqlalchemy_foundation_kit/config/postgres.py +177 -0
  13. sqlalchemy_foundation_kit/contrib/__init__.py +5 -0
  14. sqlalchemy_foundation_kit/contrib/_metrics_utils.py +19 -0
  15. sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +27 -0
  16. sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +27 -0
  17. sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +37 -0
  18. sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +196 -0
  19. sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +85 -0
  20. sqlalchemy_foundation_kit/contrib/di/__init__.py +21 -0
  21. sqlalchemy_foundation_kit/contrib/di/_base.py +27 -0
  22. sqlalchemy_foundation_kit/contrib/di/_deps.py +32 -0
  23. sqlalchemy_foundation_kit/contrib/di/database.py +169 -0
  24. sqlalchemy_foundation_kit/contrib/di/metrics.py +71 -0
  25. sqlalchemy_foundation_kit/contrib/metrics/__init__.py +7 -0
  26. sqlalchemy_foundation_kit/contrib/metrics/postgres.py +149 -0
  27. sqlalchemy_foundation_kit/contrib/settings/__init__.py +19 -0
  28. sqlalchemy_foundation_kit/contrib/settings/postgres.py +183 -0
  29. sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +17 -0
  30. sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +101 -0
  31. sqlalchemy_foundation_kit/contrib/telemetry/uow.py +227 -0
  32. sqlalchemy_foundation_kit/protocols/__init__.py +21 -0
  33. sqlalchemy_foundation_kit/protocols/metrics.py +77 -0
  34. sqlalchemy_foundation_kit/py.typed +0 -0
  35. sqlalchemy_foundation_kit/session/__init__.py +27 -0
  36. sqlalchemy_foundation_kit/session/builder.py +293 -0
  37. sqlalchemy_foundation_kit/session/connection.py +33 -0
  38. sqlalchemy_foundation_kit/session/factories.py +104 -0
  39. sqlalchemy_foundation_kit/session/locks.py +68 -0
  40. sqlalchemy_foundation_kit/session/manager.py +252 -0
  41. sqlalchemy_foundation_kit/session/retry.py +82 -0
  42. sqlalchemy_foundation_kit/uow/__init__.py +21 -0
  43. sqlalchemy_foundation_kit/uow/enums.py +18 -0
  44. sqlalchemy_foundation_kit/uow/protocols.py +80 -0
  45. sqlalchemy_foundation_kit/uow/sqlalchemy.py +406 -0
  46. sqlalchemy_foundation_kit-0.0.0.dist-info/METADATA +624 -0
  47. sqlalchemy_foundation_kit-0.0.0.dist-info/RECORD +49 -0
  48. sqlalchemy_foundation_kit-0.0.0.dist-info/WHEEL +4 -0
  49. 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,5 @@
1
+ """Optional contrib integrations for external dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
@@ -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
+ ]