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,252 @@
1
+ """Async database session manager with connection pooling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from collections.abc import AsyncIterator, Callable
9
+ from contextlib import asynccontextmanager
10
+ from types import TracebackType
11
+ from typing import TYPE_CHECKING, Any, Generic, cast
12
+
13
+ from sqlalchemy import event
14
+ from sqlalchemy.exc import TimeoutError as SATimeoutError
15
+ from sqlalchemy.ext.asyncio import (
16
+ AsyncEngine,
17
+ AsyncSession,
18
+ async_sessionmaker,
19
+ create_async_engine,
20
+ )
21
+
22
+ from .._typing import SessionT
23
+ from ..base import build_engine_kwargs, resolve_pool_class
24
+
25
+ if TYPE_CHECKING:
26
+ from ..config import PoolSettingsProtocol
27
+ from ..protocols import PostgresMetricsProtocol
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ DEFAULT_DISPOSE_TIMEOUT_SECONDS: float = 30.0
32
+
33
+
34
+ def _safe_metric_call(func: Callable[[], None], error_msg: str) -> None:
35
+ """Call a metric-recording function and swallow exceptions.
36
+
37
+ Metrics must never break application logic — any failure inside the metric
38
+ callback is logged at exception level and discarded.
39
+
40
+ Args:
41
+ func: Zero-arg callable that records a metric.
42
+ error_msg: Message logged together with the traceback on failure.
43
+ """
44
+ try:
45
+ func()
46
+ except Exception:
47
+ logger.exception(error_msg)
48
+
49
+
50
+ def attach_metrics(engine: AsyncEngine, metrics: PostgresMetricsProtocol) -> None:
51
+ """Attach metrics event listeners to a SQLAlchemy engine.
52
+
53
+ Registers event handlers for connection checkout, checkin, and error events
54
+ to collect pool statistics and connection metrics.
55
+
56
+ Args:
57
+ engine: SQLAlchemy ``AsyncEngine`` to attach listeners to.
58
+ metrics: Metrics collector implementing ``PostgresMetricsProtocol``.
59
+ """
60
+ pool = engine.pool
61
+
62
+ def record_pool_stats() -> None:
63
+ _safe_metric_call(
64
+ lambda: metrics.record_pool_stats(
65
+ pool_size=pool.size() if hasattr(pool, "size") else 0,
66
+ pool_checked_out=(pool.checkedout() if hasattr(pool, "checkedout") else 0),
67
+ pool_overflow=pool.overflow() if hasattr(pool, "overflow") else 0,
68
+ ),
69
+ "Failed to record database pool stats",
70
+ )
71
+
72
+ def on_checkout(dbapi_connection: Any, connection_record: Any, connection_proxy: Any) -> None:
73
+ """Handle connection checkout event."""
74
+ record_pool_stats()
75
+ connection_record.info["checkout_start"] = time.perf_counter()
76
+
77
+ def on_checkin(dbapi_connection: Any, connection_record: Any) -> None:
78
+ """Handle connection checkin event."""
79
+ record_pool_stats()
80
+
81
+ if "checkout_start" in connection_record.info:
82
+ duration = time.perf_counter() - connection_record.info["checkout_start"]
83
+ _safe_metric_call(
84
+ lambda: metrics.record_checkout(duration=duration),
85
+ "Failed to record database checkout duration",
86
+ )
87
+
88
+ def on_error(exception_context: Any) -> None:
89
+ """Handle database error event."""
90
+ exc = exception_context.original_exception
91
+ error_type = type(exc).__name__
92
+ is_timeout = isinstance(exc, (TimeoutError, SATimeoutError))
93
+ _safe_metric_call(
94
+ lambda: metrics.record_error(error_type=error_type, is_timeout=is_timeout),
95
+ "Failed to record database error metric",
96
+ )
97
+
98
+ event.listen(pool, "checkout", on_checkout)
99
+ event.listen(pool, "checkin", on_checkin)
100
+ event.listen(engine.sync_engine, "handle_error", on_error)
101
+
102
+
103
+ class AsyncSessionManager(Generic[SessionT]):
104
+ """Manages async database sessions with configurable connection pooling.
105
+
106
+ Supports two initialization approaches:
107
+
108
+ 1. **Direct constructor** — all parameters in constructor with defaults.
109
+ 2. **Builder pattern** — use :class:`AsyncSessionManagerBuilder` for more
110
+ readable complex configurations.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ url: str,
116
+ echo: bool = False,
117
+ poolclass: str | type = "null",
118
+ session_class: type[SessionT] | None = None,
119
+ expire_on_commit: bool = False,
120
+ connect_args: dict[str, object] | None = None,
121
+ isolation_level: str | None = None,
122
+ pool_settings: PoolSettingsProtocol | None = None,
123
+ use_orjson: bool = False,
124
+ metrics: PostgresMetricsProtocol | None = None,
125
+ on_engine_created: Callable[[AsyncEngine], None] | None = None,
126
+ dispose_timeout: float = DEFAULT_DISPOSE_TIMEOUT_SECONDS,
127
+ **kwargs: object,
128
+ ) -> None:
129
+ """Initialize session manager with direct configuration.
130
+
131
+ Args:
132
+ url: Database connection URL (required).
133
+ echo: If True, SQLAlchemy will log all SQL statements (default: False).
134
+ poolclass: SQLAlchemy pool class or name (default: "null").
135
+ Use "queue" for production, "null" for testing.
136
+ session_class: Custom ``AsyncSession`` subclass (default: ``AsyncSession``).
137
+ expire_on_commit: If True, objects expire after commit (default: False).
138
+ connect_args: Arguments passed to the database driver (default: None).
139
+ isolation_level: Default transaction isolation level (default: None).
140
+ pool_settings: Pool configuration settings (default: None).
141
+ use_orjson: If True, use orjson for JSON serialization (default: False).
142
+ metrics: Optional metrics collector (default: None).
143
+ on_engine_created: Optional callback invoked with ``AsyncEngine`` after creation.
144
+ Use for OpenTelemetry instrumentation, custom event listeners, etc.
145
+ dispose_timeout: Maximum seconds to wait for engine disposal in :meth:`aclose`
146
+ (default: 30.0). Lower this in tests or short-lived environments; raise it
147
+ if you have long-running transactions that need more time to settle.
148
+ **kwargs: Additional keyword arguments for ``create_async_engine``.
149
+ """
150
+ self._closed = False
151
+ self._close_lock = asyncio.Lock()
152
+ self._dispose_timeout = dispose_timeout
153
+ resolved_poolclass = resolve_pool_class(poolclass)
154
+ engine_kwargs = build_engine_kwargs(
155
+ echo=echo,
156
+ poolclass=resolved_poolclass,
157
+ isolation_level=isolation_level,
158
+ pool_settings=pool_settings,
159
+ connect_args=connect_args,
160
+ extra_kwargs=kwargs,
161
+ use_orjson=use_orjson,
162
+ )
163
+
164
+ self._engine: AsyncEngine = create_async_engine(url, **engine_kwargs)
165
+ self._session_maker = cast(
166
+ async_sessionmaker[SessionT],
167
+ async_sessionmaker(
168
+ self._engine,
169
+ class_=session_class or AsyncSession,
170
+ expire_on_commit=expire_on_commit,
171
+ ),
172
+ )
173
+
174
+ if metrics:
175
+ attach_metrics(self._engine, metrics)
176
+
177
+ if on_engine_created is not None:
178
+ on_engine_created(self._engine)
179
+
180
+ async def aclose(self) -> None:
181
+ """Close the engine and all connections."""
182
+ async with self._close_lock:
183
+ if self._closed:
184
+ return
185
+
186
+ try:
187
+ # Use shield so disposal runs even if the task is cancelled; timeout avoids indefinite hang.
188
+ await asyncio.wait_for(
189
+ asyncio.shield(self._engine.dispose()),
190
+ timeout=self._dispose_timeout,
191
+ )
192
+ except TimeoutError:
193
+ logger.warning(
194
+ "Engine disposal timed out after %.1f seconds. Some connections may not have closed cleanly.",
195
+ self._dispose_timeout,
196
+ )
197
+ finally:
198
+ # Always mark as closed to prevent retry loops
199
+ self._closed = True
200
+
201
+ def _ensure_not_closed(self) -> None:
202
+ """Ensure that the manager is not closed."""
203
+ if self._closed:
204
+ raise RuntimeError("AsyncSessionManager is closed")
205
+
206
+ async def __aenter__(self) -> AsyncSessionManager[SessionT]:
207
+ """Support for async context manager."""
208
+ return self
209
+
210
+ async def __aexit__(
211
+ self,
212
+ exc_type: type[BaseException] | None,
213
+ exc_val: BaseException | None,
214
+ exc_tb: TracebackType | None,
215
+ ) -> None:
216
+ """Close the engine on exit."""
217
+ await self.aclose()
218
+
219
+ @property
220
+ def engine(self) -> AsyncEngine:
221
+ """Get the underlying engine."""
222
+ return self._engine
223
+
224
+ @property
225
+ def session_maker(self) -> async_sessionmaker[SessionT]:
226
+ """Get the session maker."""
227
+ return self._session_maker
228
+
229
+ @asynccontextmanager
230
+ async def get_session(self) -> AsyncIterator[SessionT]:
231
+ """Get a new database session."""
232
+ self._ensure_not_closed()
233
+ async with self._session_maker() as session:
234
+ yield session
235
+
236
+ @asynccontextmanager
237
+ async def get_transaction(self, isolation_level: str | None = None) -> AsyncIterator[SessionT]:
238
+ """Get a new database session with automatic transaction management.
239
+
240
+ Args:
241
+ isolation_level: Optional isolation level for the transaction.
242
+
243
+ Yields:
244
+ Managed async session with active transaction.
245
+ """
246
+ self._ensure_not_closed()
247
+ options = {"isolation_level": isolation_level} if isolation_level else {}
248
+ async with (
249
+ self._session_maker(execution_options=options) as session,
250
+ session.begin(),
251
+ ):
252
+ yield session
@@ -0,0 +1,82 @@
1
+ """Retry utilities for database connection startup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ DEFAULT_HEALTHCHECK_QUERY: str = "SELECT 1"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class RetryConfig:
17
+ """Configuration for retry behavior with exponential backoff.
18
+
19
+ Attributes:
20
+ max_retries: Maximum number of attempts before giving up.
21
+ retry_delay: Base delay in seconds; actual delay is ``retry_delay * 2 ** attempt``,
22
+ capped at ``max_backoff_delay``.
23
+ max_backoff_delay: Maximum delay between retries in seconds.
24
+ Prevents exponential backoff from growing indefinitely.
25
+ """
26
+
27
+ max_retries: int = 3
28
+ retry_delay: float = 1.0
29
+ max_backoff_delay: float = 60.0
30
+
31
+
32
+ DEFAULT_RETRY_CONFIG: RetryConfig = RetryConfig()
33
+
34
+
35
+ async def retry_async_connection(
36
+ connect_func: Callable[[], Awaitable[None]],
37
+ service_name: str,
38
+ config: RetryConfig = DEFAULT_RETRY_CONFIG,
39
+ ) -> None:
40
+ """Retry an async connection callable with exponential backoff.
41
+
42
+ Args:
43
+ connect_func: Callable that attempts to establish/test the connection.
44
+ service_name: Human-readable service name used in log messages.
45
+ config: Retry behavior configuration.
46
+
47
+ Raises:
48
+ ValueError: If config.max_retries is less than 1.
49
+ Exception: Re-raises the last exception when all attempts fail.
50
+ """
51
+ if config.max_retries < 1:
52
+ raise ValueError(f"max_retries must be >= 1, got {config.max_retries}")
53
+
54
+ for attempt in range(config.max_retries):
55
+ try:
56
+ await connect_func()
57
+ except Exception:
58
+ if attempt == config.max_retries - 1:
59
+ logger.exception(
60
+ "%s connection failed after %d attempts",
61
+ service_name,
62
+ config.max_retries,
63
+ )
64
+ raise
65
+ logger.warning(
66
+ "%s connection attempt %d failed, retrying...",
67
+ service_name,
68
+ attempt + 1,
69
+ )
70
+ delay = min(config.retry_delay * (2**attempt), config.max_backoff_delay)
71
+ await asyncio.sleep(delay)
72
+ else:
73
+ logger.info("%s connection successful", service_name)
74
+ return
75
+
76
+
77
+ __all__ = [
78
+ "DEFAULT_HEALTHCHECK_QUERY",
79
+ "DEFAULT_RETRY_CONFIG",
80
+ "RetryConfig",
81
+ "retry_async_connection",
82
+ ]
@@ -0,0 +1,21 @@
1
+ """Unit of Work pattern implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .enums import IsolationLevel
6
+ from .protocols import AsyncUnitOfWork, AsyncUowTransaction, SupportsAdvisoryLock
7
+ from .sqlalchemy import (
8
+ AsyncSQLAlchemyUnitOfWork,
9
+ AsyncSQLAlchemyUowTransaction,
10
+ PostgresAdvisoryLockMixin,
11
+ )
12
+
13
+ __all__ = [
14
+ "AsyncSQLAlchemyUnitOfWork",
15
+ "AsyncSQLAlchemyUowTransaction",
16
+ "AsyncUnitOfWork",
17
+ "AsyncUowTransaction",
18
+ "IsolationLevel",
19
+ "PostgresAdvisoryLockMixin",
20
+ "SupportsAdvisoryLock",
21
+ ]
@@ -0,0 +1,18 @@
1
+ """Enums for Unit of Work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+
8
+ class IsolationLevel(StrEnum):
9
+ """PostgreSQL transaction isolation levels.
10
+
11
+ Values match PostgreSQL's expected form (with spaces) for use with
12
+ SQLAlchemy execution_options(isolation_level=...).
13
+ """
14
+
15
+ READ_UNCOMMITTED = "READ UNCOMMITTED"
16
+ READ_COMMITTED = "READ COMMITTED"
17
+ REPEATABLE_READ = "REPEATABLE READ"
18
+ SERIALIZABLE = "SERIALIZABLE"
@@ -0,0 +1,80 @@
1
+ """Unit of Work protocols."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import AbstractAsyncContextManager
6
+ from typing import Any, Generic, Protocol
7
+
8
+ from .._typing import T_co
9
+
10
+
11
+ class AsyncUowTransaction(Protocol):
12
+ """Transaction-scoped repositories container.
13
+
14
+ Intended to be extended by concrete transaction types that expose
15
+ repository attributes for a specific bounded context.
16
+ """
17
+
18
+
19
+ class SupportsAdvisoryLock(Protocol):
20
+ """Capability protocol for transactions supporting advisory locks.
21
+
22
+ Use this when your transaction needs PostgreSQL advisory lock support.
23
+ Not all transactions require this capability (e.g., in-memory, read-only).
24
+ """
25
+
26
+ async def try_advisory_lock(self, key: int) -> bool:
27
+ """Try to acquire a transaction-scoped advisory lock identified by ``key``.
28
+
29
+ Returns ``True`` if the lock was acquired (and is held for the rest of the
30
+ transaction), ``False`` if another transaction already holds it.
31
+ """
32
+ ...
33
+
34
+
35
+ class AsyncUnitOfWork(Protocol, Generic[T_co]):
36
+ """Provides transactional context for repository operations.
37
+
38
+ Provides three modes of operation:
39
+ - ``transaction()``: For write operations with automatic commit/rollback.
40
+ - ``managed_session()``: For write operations with **manual** commit/rollback control.
41
+ - ``query()``: For read-only operations without transaction management.
42
+ """
43
+
44
+ def transaction(
45
+ self,
46
+ isolation_level: str | None = None,
47
+ flush_before_commit: bool | None = None,
48
+ ) -> AbstractAsyncContextManager[T_co]:
49
+ """Create a new transaction context with automatic commit/rollback.
50
+
51
+ Args:
52
+ isolation_level: Optional transaction isolation level.
53
+ flush_before_commit: If True, flush session before commit to surface
54
+ constraint violations within transaction. If ``None``, the implementation
55
+ applies its own default (typically configured at construction time).
56
+ """
57
+
58
+ def managed_session(
59
+ self,
60
+ isolation_level: str | None = None,
61
+ ) -> AbstractAsyncContextManager[tuple[T_co, Any]]:
62
+ """Create a session with manual transaction control.
63
+
64
+ Unlike :meth:`transaction`, this does **NOT** auto-commit on success. The caller
65
+ must explicitly call ``session.commit()`` or ``session.rollback()``. Useful for
66
+ complex transactional logic where the commit decision depends on multiple
67
+ conditions or external factors.
68
+
69
+ The second element of the yielded tuple is the underlying session object
70
+ (typed as ``Any`` in the protocol to avoid leaking SQLAlchemy types — concrete
71
+ implementations like ``AsyncSQLAlchemyUnitOfWork`` yield ``AsyncSession``).
72
+
73
+ On exception inside the context, the session is automatically rolled back.
74
+
75
+ Args:
76
+ isolation_level: Optional transaction isolation level.
77
+ """
78
+
79
+ def query(self, isolation_level: str | None = None) -> AbstractAsyncContextManager[T_co]:
80
+ """Create a read-only query context without transaction management."""