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,406 @@
1
+ """Unit of Work implementation (async SQLAlchemy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import AsyncIterator, Callable
8
+ from contextlib import asynccontextmanager
9
+ from typing import Generic
10
+
11
+ from sqlalchemy.exc import SQLAlchemyError
12
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
13
+
14
+ from .._typing import T
15
+ from ..session.locks import try_advisory_xact_lock
16
+ from .enums import IsolationLevel
17
+ from .protocols import AsyncUnitOfWork, AsyncUowTransaction
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Cache valid isolation levels for performance (avoid recreating set on each call)
22
+ _VALID_ISOLATION_LEVELS: frozenset[str] = frozenset(item.value for item in IsolationLevel)
23
+
24
+
25
+ def normalize_isolation_level(
26
+ isolation_level: IsolationLevel | str | None,
27
+ ) -> str | None:
28
+ """Normalize isolation_level to a valid PostgreSQL string or None.
29
+
30
+ Accepts both enum members and strings. Strings may use either underscores
31
+ or spaces (e.g., "READ_COMMITTED" or "READ COMMITTED") for convenience.
32
+
33
+ Args:
34
+ isolation_level: Enum member, string, or None.
35
+
36
+ Returns:
37
+ PostgreSQL-form string (with spaces) valid for execution_options, or None.
38
+
39
+ Raises:
40
+ ValueError: If isolation_level is not supported.
41
+
42
+ Examples:
43
+ >>> normalize_isolation_level(None)
44
+ None
45
+ >>> normalize_isolation_level(IsolationLevel.READ_COMMITTED)
46
+ 'READ COMMITTED'
47
+ >>> normalize_isolation_level("READ_COMMITTED")
48
+ 'READ COMMITTED'
49
+ >>> normalize_isolation_level("read committed")
50
+ 'READ COMMITTED'
51
+ """
52
+ # Explicit None check (PEP 8: explicit is better than implicit)
53
+ if isolation_level is None:
54
+ return None
55
+
56
+ # Fast path for enum members
57
+ if isinstance(isolation_level, IsolationLevel):
58
+ return isolation_level.value
59
+
60
+ # Normalize string input: uppercase and replace underscores with spaces
61
+ normalized = str(isolation_level).upper().replace("_", " ")
62
+
63
+ # Validate against cached valid levels
64
+ if normalized not in _VALID_ISOLATION_LEVELS:
65
+ supported = ", ".join(sorted(_VALID_ISOLATION_LEVELS))
66
+ raise ValueError(f"Invalid isolation level: {isolation_level!r}. Supported values: {supported}")
67
+
68
+ return normalized
69
+
70
+
71
+ async def apply_isolation_level(
72
+ session: AsyncSession,
73
+ isolation_level: IsolationLevel | str | None,
74
+ ) -> None:
75
+ """Apply isolation level to an async session's connection.
76
+
77
+ **Implementation Detail**:
78
+ We use ``run_sync()`` because SQLAlchemy's ``execution_options()`` is a
79
+ synchronous method that configures the underlying DBAPI connection object.
80
+ We must bridge from async context to sync method via ``run_sync()``.
81
+
82
+ This is a DRY utility to eliminate duplication of isolation level application
83
+ logic across ``transaction()``, ``managed_session()``, and ``query()`` methods.
84
+
85
+ Args:
86
+ session: SQLAlchemy AsyncSession to configure.
87
+ isolation_level: Desired isolation level (enum, string, or None).
88
+
89
+ Raises:
90
+ ValueError: If isolation_level is not supported (raised by normalize_isolation_level).
91
+
92
+ Examples:
93
+ >>> async with session_maker() as session:
94
+ ... await apply_isolation_level(session, IsolationLevel.SERIALIZABLE)
95
+ ... result = await session.execute(select(User))
96
+ ... # Query runs with SERIALIZABLE isolation level
97
+
98
+ Using string isolation level:
99
+ >>> await apply_isolation_level(session, "READ COMMITTED")
100
+
101
+ No-op when None:
102
+ >>> await apply_isolation_level(session, None) # Does nothing
103
+ """
104
+ normalized = normalize_isolation_level(isolation_level)
105
+ if normalized is not None:
106
+ conn = await session.connection()
107
+ # run_sync bridges async → sync for DBAPI-level configuration
108
+ await conn.run_sync(lambda c: c.execution_options(isolation_level=normalized))
109
+
110
+
111
+ class AsyncSQLAlchemyUowTransaction(AsyncUowTransaction):
112
+ """Base async SQLAlchemy transaction-scoped repositories.
113
+
114
+ This class provides access to the underlying SQLAlchemy session and is
115
+ intended to be subclassed by services to expose specific repositories.
116
+
117
+ Example:
118
+ class IdentityTransaction(AsyncSQLAlchemyUowTransaction):
119
+ @property
120
+ def users(self) -> UserRepository:
121
+ return PostgresUserRepository(self.session)
122
+ """
123
+
124
+ def __init__(self, session: AsyncSession) -> None:
125
+ self._session = session
126
+
127
+ @property
128
+ def session(self) -> AsyncSession:
129
+ """Get the underlying SQLAlchemy async session."""
130
+ return self._session
131
+
132
+
133
+ class PostgresAdvisoryLockMixin:
134
+ """Mixin providing PostgreSQL advisory lock support for UoW transactions.
135
+
136
+ Requires the class to have a `session` property returning AsyncSession.
137
+
138
+ Example:
139
+ class IdentityTransaction(AsyncSQLAlchemyUowTransaction, PostgresAdvisoryLockMixin):
140
+ @property
141
+ def users(self) -> UserRepository:
142
+ return PostgresUserRepository(self.session)
143
+
144
+ # Now has access to try_advisory_lock method
145
+ async with uow.transaction() as tx:
146
+ if await tx.try_advisory_lock(12345):
147
+ # Protected operation
148
+ ...
149
+ """
150
+
151
+ session: AsyncSession # Type annotation for protocol compliance
152
+
153
+ async def try_advisory_lock(self, key: int) -> bool:
154
+ """Acquire a Postgres transaction-scoped advisory lock.
155
+
156
+ Delegates to :func:`try_advisory_xact_lock` for actual locking logic.
157
+
158
+ Args:
159
+ key: Integer lock key.
160
+
161
+ Returns:
162
+ True if lock was acquired, False if already held by another session.
163
+ """
164
+ return await try_advisory_xact_lock(self.session, key)
165
+
166
+
167
+ class AsyncSQLAlchemyUnitOfWork(AsyncUnitOfWork[T], Generic[T]):
168
+ """Base async SQLAlchemy Unit of Work.
169
+
170
+ Provides transactional context for repository operations using SQLAlchemy AsyncSession.
171
+
172
+ Methods:
173
+ transaction(): For write operations with automatic commit/rollback.
174
+ query(): For read-only operations without transaction management.
175
+ """
176
+
177
+ def __init__(
178
+ self,
179
+ session_maker: async_sessionmaker[AsyncSession],
180
+ transaction_factory: Callable[[AsyncSession], T],
181
+ *,
182
+ flush_before_commit: bool = True,
183
+ ) -> None:
184
+ """Initialize the unit of work.
185
+
186
+ Args:
187
+ session_maker: Async session factory.
188
+ transaction_factory: Callable producing the transaction object exposed to callers.
189
+ flush_before_commit: Default ``flush_before_commit`` policy applied when
190
+ :meth:`transaction` is called without an explicit override.
191
+ Set to ``False`` here once if your service prefers SQLAlchemy's default
192
+ "flush on commit" semantics instead of an early flush.
193
+ """
194
+ self._session_maker = session_maker
195
+ self._transaction_factory = transaction_factory
196
+ self._flush_before_commit = flush_before_commit
197
+
198
+ @asynccontextmanager
199
+ async def open_session(
200
+ self,
201
+ isolation_level: IsolationLevel | str | None = None,
202
+ ) -> AsyncIterator[AsyncSession]:
203
+ """Open a session with optional isolation level applied.
204
+
205
+ This is the extension point for subclasses that need custom session setup
206
+ (e.g., RLS context, session-level GUCs, custom statement timeouts).
207
+ Override to wrap or augment session creation while preserving isolation handling.
208
+
209
+ Used internally by :meth:`transaction` and :meth:`query`.
210
+
211
+ Args:
212
+ isolation_level: Optional transaction isolation level.
213
+
214
+ Yields:
215
+ Configured AsyncSession instance.
216
+
217
+ Raises:
218
+ ValueError: If isolation_level is not supported.
219
+
220
+ Examples:
221
+ Subclass that sets a session-level GUC for every transaction:
222
+
223
+ class TenantUnitOfWork(AsyncSQLAlchemyUnitOfWork):
224
+ def __init__(self, session_maker, tx_factory, tenant_id):
225
+ super().__init__(session_maker, tx_factory)
226
+ self._tenant_id = tenant_id
227
+
228
+ @asynccontextmanager
229
+ async def open_session(self, isolation_level=None):
230
+ async with super().open_session(isolation_level) as session:
231
+ await session.execute(
232
+ text("SET app.tenant_id = :tid"),
233
+ {"tid": self._tenant_id},
234
+ )
235
+ yield session
236
+ """
237
+ async with self._session_maker() as session:
238
+ # Apply isolation level if specified (DRY: using utility function)
239
+ await apply_isolation_level(session, isolation_level)
240
+ yield session
241
+
242
+ @asynccontextmanager
243
+ async def transaction(
244
+ self,
245
+ isolation_level: IsolationLevel | str | None = None,
246
+ flush_before_commit: bool | None = None,
247
+ ) -> AsyncIterator[T]:
248
+ """Create a new transaction context with automatic commit/rollback.
249
+
250
+ The Unit of Work automatically commits the transaction on successful exit
251
+ and rolls back on exception. This ensures atomic operations.
252
+
253
+ Args:
254
+ isolation_level: Optional transaction isolation level.
255
+ Can be an IsolationLevel enum member or a string value.
256
+ Supported values: "READ_COMMITTED", "REPEATABLE_READ", "SERIALIZABLE", "READ_UNCOMMITTED".
257
+ flush_before_commit: If True, flush the session before commit to surface
258
+ constraint violations while still inside the transaction. If ``None`` (default),
259
+ falls back to the value passed to the constructor (``True`` unless overridden).
260
+
261
+ Raises:
262
+ ValueError: If isolation_level is not supported.
263
+
264
+ Examples:
265
+ Write operation with automatic commit:
266
+ async with uow.transaction() as tx:
267
+ await tx.users.create(...)
268
+ # Auto-commit on exit, rollback on exception
269
+ """
270
+ if flush_before_commit is None:
271
+ flush_before_commit = self._flush_before_commit
272
+
273
+ async with self.open_session(isolation_level) as session, session.begin():
274
+ uow = self._transaction_factory(session)
275
+ yield uow
276
+ if flush_before_commit:
277
+ # Flush changes before commit to catch constraint violations early
278
+ # while still inside the transaction context.
279
+ try:
280
+ await session.flush()
281
+ except SQLAlchemyError as e:
282
+ logger.warning("Database flush failed", extra={"error": str(e)})
283
+ raise
284
+
285
+ @asynccontextmanager
286
+ async def managed_session(
287
+ self,
288
+ isolation_level: IsolationLevel | str | None = None,
289
+ ) -> AsyncIterator[tuple[T, AsyncSession]]:
290
+ """Create a session with manual transaction control.
291
+
292
+ Unlike transaction(), this does NOT auto-commit. The caller must
293
+ explicitly call session.commit() or session.rollback(). This is useful
294
+ for complex transactional logic where commit decision depends on multiple
295
+ conditions or external factors.
296
+
297
+ A transaction is started automatically, but you have full control over
298
+ when to commit or rollback. If you exit without calling either, SQLAlchemy
299
+ will automatically rollback on session close.
300
+
301
+ Args:
302
+ isolation_level: Optional transaction isolation level.
303
+ Can be an IsolationLevel enum member or a string value.
304
+ Supported values: "READ_COMMITTED", "REPEATABLE_READ", "SERIALIZABLE", "READ_UNCOMMITTED".
305
+
306
+ Yields:
307
+ Tuple of (transaction object, session) for manual control.
308
+
309
+ Raises:
310
+ ValueError: If isolation_level is not supported.
311
+
312
+ Examples:
313
+ Manual transaction control:
314
+ async with uow.managed_session() as (tx, session):
315
+ await tx.users.create(...)
316
+
317
+ # Manually decide when to commit
318
+ if should_commit:
319
+ await session.commit()
320
+ else:
321
+ await session.rollback()
322
+
323
+ Conditional commit based on external service:
324
+ async with uow.managed_session() as (tx, session):
325
+ user = await tx.users.create(...)
326
+
327
+ # Call external service
328
+ result = await external_api.validate(user)
329
+
330
+ if result.success:
331
+ await session.commit()
332
+ else:
333
+ await session.rollback()
334
+
335
+ Multiple operations with intermediate decision:
336
+ async with uow.managed_session() as (tx, session):
337
+ user = await tx.users.create(...)
338
+
339
+ # First checkpoint
340
+ await session.flush()
341
+
342
+ # More operations
343
+ await tx.profiles.create(user_id=user.id)
344
+
345
+ # Final decision
346
+ await session.commit()
347
+
348
+ Note:
349
+ Prefer :meth:`transaction` for the vast majority of use cases — it commits
350
+ automatically and enforces the UoW pattern. This method is an advanced escape
351
+ hatch for scenarios where the commit/rollback decision depends on conditions
352
+ that can only be evaluated after data is written (e.g., external service
353
+ validation). ``session.commit()`` calls belong exclusively at the use-case
354
+ boundary via this method, never inside repository implementations.
355
+
356
+ Warning:
357
+ You MUST explicitly call session.commit() or session.rollback().
358
+ Exiting the context without calling either will result in automatic
359
+ rollback when the session closes.
360
+ """
361
+ async with self.open_session(isolation_level) as session:
362
+ # Start transaction WITHOUT context manager - no auto-commit
363
+ await session.begin()
364
+ try:
365
+ uow = self._transaction_factory(session)
366
+ yield uow, session
367
+ except (Exception, asyncio.CancelledError):
368
+ # Auto-rollback on exception OR cancellation
369
+ await session.rollback()
370
+ raise
371
+ # User must call session.commit() or session.rollback() explicitly
372
+
373
+ @asynccontextmanager
374
+ async def query(
375
+ self,
376
+ isolation_level: IsolationLevel | str | None = None,
377
+ ) -> AsyncIterator[T]:
378
+ """Create a read-only query context without transaction management.
379
+
380
+ This method is designed for read-only operations and does not start a transaction
381
+ or perform any commit/rollback. It's semantically clearer than managed_session()
382
+ for read operations.
383
+
384
+ Args:
385
+ isolation_level: Optional transaction isolation level.
386
+ Can be an IsolationLevel enum member or a string value.
387
+ Supported values: "READ_COMMITTED", "REPEATABLE_READ", "SERIALIZABLE", "READ_UNCOMMITTED".
388
+
389
+ Raises:
390
+ ValueError: If isolation_level is not supported.
391
+
392
+ Examples:
393
+ Read-only query:
394
+ async with uow.query() as qx:
395
+ users = await qx.users.list_all()
396
+ user = await qx.users.get_by_id(user_id)
397
+ # No commit/rollback - just closes session
398
+
399
+ Note:
400
+ While this method is intended for read-only operations, SQLAlchemy does not enforce
401
+ this at the session level. It's up to the caller to ensure only read operations are performed.
402
+ """
403
+ async with self.open_session(isolation_level) as session:
404
+ # No transaction begin/commit - just yield the session
405
+ uow = self._transaction_factory(session)
406
+ yield uow