fastapi-toolsets 5.1.3__tar.gz → 5.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/PKG-INFO +1 -1
  2. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/pyproject.toml +1 -1
  3. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/pyproject.toml.orig +1 -1
  4. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/__init__.py +1 -1
  5. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/models/watched.py +127 -29
  6. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/LICENSE +0 -0
  7. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/README.md +0 -0
  8. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/_imports.py +0 -0
  9. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/__init__.py +0 -0
  10. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/app.py +0 -0
  11. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  12. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  13. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/config.py +0 -0
  14. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  15. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/utils.py +0 -0
  16. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/crud/__init__.py +0 -0
  17. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/crud/factory.py +0 -0
  18. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/crud/search.py +0 -0
  19. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/__init__.py +0 -0
  20. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/core.py +0 -0
  21. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/locks.py +0 -0
  22. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/m2m.py +0 -0
  23. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/testing.py +0 -0
  24. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/watch.py +0 -0
  25. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/dependencies.py +0 -0
  26. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  27. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  28. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  29. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  30. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  31. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  32. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  33. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/logger.py +0 -0
  34. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  35. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/metrics/handler.py +0 -0
  36. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/metrics/registry.py +0 -0
  37. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/models/__init__.py +0 -0
  38. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/models/columns.py +0 -0
  39. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/py.typed +0 -0
  40. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  41. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  42. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/pytest/utils.py +0 -0
  43. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/schemas.py +0 -0
  44. {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 5.1.3
3
+ Version: 5.1.4
4
4
  Summary: Production-ready utilities for FastAPI applications
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "5.1.3"
3
+ version = "5.1.4"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "5.1.3"
3
+ version = "5.1.4"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,4 +24,4 @@ Example usage:
24
24
  return Response(data={"user": user.username}, message="Success")
25
25
  """
26
26
 
27
- __version__ = "5.1.3"
27
+ __version__ = "5.1.4"
@@ -1,13 +1,15 @@
1
1
  """Field-change monitoring via SQLAlchemy session events."""
2
2
 
3
3
  import inspect
4
- from collections.abc import Callable
4
+ from collections.abc import Callable, Iterator
5
+ from contextlib import contextmanager
5
6
  from enum import Enum
6
7
  from typing import Any
7
8
 
8
9
  from sqlalchemy import event, select, tuple_
9
10
  from sqlalchemy import inspect as sa_inspect
10
- from sqlalchemy.ext.asyncio import AsyncSession
11
+ from sqlalchemy.ext.asyncio import AsyncSession, AsyncSessionTransaction
12
+ from sqlalchemy.ext.asyncio import async_session as _async_session
11
13
  from sqlalchemy.orm import selectinload
12
14
  from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
13
15
 
@@ -25,9 +27,11 @@ class ModelEvent(str, Enum):
25
27
 
26
28
 
27
29
  _CALLBACK_ERROR_MSG = "Event callback raised an unhandled exception"
30
+ _RELOAD_TRANSACTION_ERROR_MSG = "Closing the post-commit reload transaction failed"
28
31
  _SESSION_CREATES = "_ft_creates"
29
32
  _SESSION_DELETES = "_ft_deletes"
30
33
  _SESSION_UPDATES = "_ft_updates"
34
+ _SESSION_PRELOADED = "_ft_preloaded"
31
35
  _DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
32
36
  _EVENT_HANDLERS: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
33
37
  _HANDLER_CACHE: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
@@ -126,12 +130,24 @@ def _upsert_changes(
126
130
  pending[key] = (obj, changes)
127
131
 
128
132
 
133
+ def _dispatches(session: Any) -> bool:
134
+ """True when *session* is driven by an :class:`EventSession`."""
135
+ return isinstance(_async_session(session), EventSession)
136
+
137
+
129
138
  @event.listens_for(AsyncSession.sync_session_class, "after_flush")
130
139
  def _after_flush(session: Any, flush_context: Any) -> None:
140
+ if _dispatches(session):
141
+ _collect(session)
142
+
143
+
144
+ def _collect(session: Any) -> None:
145
+ """Record the flushed changes that the next commit will dispatch."""
131
146
  # New objects: capture reference. Attributes will be refreshed after commit.
132
147
  for obj in session.new:
133
148
  if _get_handlers(type(obj), ModelEvent.CREATE):
134
149
  session.info.setdefault(_SESSION_CREATES, []).append(obj)
150
+ _record_loaded_relationships(session, obj)
135
151
 
136
152
  # Deleted objects: snapshot now while attributes are still loaded.
137
153
  for obj in session.deleted:
@@ -170,6 +186,7 @@ def _after_flush(session: Any, flush_context: Any) -> None:
170
186
  obj,
171
187
  changes,
172
188
  )
189
+ _record_loaded_relationships(session, obj)
173
190
 
174
191
 
175
192
  @event.listens_for(AsyncSession.sync_session_class, "after_rollback")
@@ -179,6 +196,7 @@ def _after_rollback(session: Any) -> None:
179
196
  session.info.pop(_SESSION_CREATES, None)
180
197
  session.info.pop(_SESSION_DELETES, None)
181
198
  session.info.pop(_SESSION_UPDATES, None)
199
+ session.info.pop(_SESSION_PRELOADED, None)
182
200
 
183
201
 
184
202
  async def _invoke_callback(
@@ -208,7 +226,9 @@ async def _dispatch(
208
226
 
209
227
  def _loaded_relationships(obj: Any) -> set[str]:
210
228
  """Relationship keys currently loaded on *obj*."""
211
- state = sa_inspect(obj)
229
+ state = sa_inspect(obj, raiseerr=False)
230
+ if state is None:
231
+ return set()
212
232
  unloaded = state.unloaded
213
233
  return {
214
234
  rel.key
@@ -217,11 +237,38 @@ def _loaded_relationships(obj: Any) -> set[str]:
217
237
  }
218
238
 
219
239
 
240
+ def _record_loaded_relationships(session: Any, obj: Any) -> None:
241
+ """Merge the relationships loaded on *obj* into the session's record."""
242
+ store: dict[int, set[str]] = session.info.setdefault(_SESSION_PRELOADED, {})
243
+ store.setdefault(id(obj), set()).update(_loaded_relationships(obj))
244
+
245
+
220
246
  def _snapshot_loaded_relationships(session: Any) -> dict[int, set[str]]:
221
- """Record loaded relationships for the tracked objects, keyed by ``id``."""
247
+ """Loaded relationships for the tracked objects, keyed by ``id``."""
248
+ snapshot = {
249
+ key: set(value)
250
+ for key, value in session.info.get(_SESSION_PRELOADED, {}).items()
251
+ }
222
252
  objs = list(session.info.get(_SESSION_CREATES, []))
223
253
  objs += [obj for obj, _ in session.info.get(_SESSION_UPDATES, {}).values()]
224
- return {id(obj): _loaded_relationships(obj) for obj in objs}
254
+ for obj in objs:
255
+ snapshot.setdefault(id(obj), set()).update(_loaded_relationships(obj))
256
+ return snapshot
257
+
258
+
259
+ @contextmanager
260
+ def _suspended_trans_ctx(session: AsyncSession) -> Iterator[None]:
261
+ """Allow post-commit SQL while an outer ``session.begin()`` block is open."""
262
+ sync_session = session.sync_session
263
+ ctx = getattr(sync_session, "_trans_context_manager", None)
264
+ if ctx is None:
265
+ yield
266
+ return
267
+ sync_session._trans_context_manager = None
268
+ try:
269
+ yield
270
+ finally:
271
+ sync_session._trans_context_manager = ctx
225
272
 
226
273
 
227
274
  async def _batch_reload(
@@ -247,13 +294,47 @@ async def _batch_reload(
247
294
  await session.execute(q)
248
295
 
249
296
 
297
+ class _EventSessionTransaction(AsyncSessionTransaction):
298
+ """Transaction context manager that dispatches on a real commit."""
299
+
300
+ __slots__ = ()
301
+
302
+ async def __aexit__(self, type_: object, value: object, traceback: object) -> None:
303
+ session = self.session
304
+ commits = (
305
+ type_ is None
306
+ and not self.nested
307
+ and isinstance(session, EventSession)
308
+ and self.is_active
309
+ )
310
+ preloaded = _snapshot_loaded_relationships(session) if commits else {}
311
+ await super().__aexit__(type_, value, traceback)
312
+ if commits:
313
+ await session._dispatch_pending(preloaded)
314
+
315
+
250
316
  class EventSession(AsyncSession):
251
317
  """AsyncSession subclass that dispatches lifecycle callbacks after commit."""
252
318
 
319
+ def begin(self) -> AsyncSessionTransaction:
320
+ """Return a transaction context manager that dispatches on commit."""
321
+ return _EventSessionTransaction(self)
322
+
323
+ def begin_nested(self) -> AsyncSessionTransaction:
324
+ """Return a savepoint context manager; events wait for the real commit."""
325
+ return _EventSessionTransaction(self, nested=True)
326
+
253
327
  async def commit(self) -> None:
254
328
  preloaded = _snapshot_loaded_relationships(self)
255
329
  await super().commit()
256
-
330
+ await self._dispatch_pending(preloaded)
331
+
332
+ async def _dispatch_pending(self, preloaded: dict[int, set[str]]) -> None:
333
+ """Run the callbacks collected for the transaction that just committed."""
334
+ # The commit itself flushes, so objects first collected there are only
335
+ # recorded now; merge them into the pre-commit snapshot.
336
+ for key, value in self.info.pop(_SESSION_PRELOADED, {}).items():
337
+ preloaded.setdefault(key, set()).update(value)
257
338
  creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
258
339
  deletes: list[tuple[Any, dict[str, Any]]] = self.info.pop(_SESSION_DELETES, [])
259
340
  field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = self.info.pop(
@@ -310,32 +391,49 @@ class EventSession(AsyncSession):
310
391
  update_items.append((obj, changes))
311
392
  objs_by_type.setdefault(type(obj), []).append(obj)
312
393
 
313
- for model, objs in objs_by_type.items():
314
- try:
315
- await _batch_reload(self, model, objs, preloaded)
316
- except Exception as exc:
317
- _logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
318
-
319
- # Dispatch CREATE callbacks.
320
- for obj in create_items:
321
- await _dispatch(obj, ModelEvent.CREATE, None)
322
-
323
- # Dispatch DELETE callbacks (restore snapshot; row is gone).
324
- for obj, snapshot in deletes:
325
- try:
326
- for key, value in snapshot.items():
327
- _sa_set_committed_value(obj, key, value)
328
- except Exception as exc:
329
- _logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
330
- continue
331
- await _dispatch(obj, ModelEvent.DELETE, None)
332
-
333
- # Dispatch UPDATE callbacks.
334
- for obj, changes in update_items:
335
- await _dispatch(obj, ModelEvent.UPDATE, changes)
394
+ with _suspended_trans_ctx(self):
395
+ had_transaction = self.in_transaction()
396
+ for model, objs in objs_by_type.items():
397
+ try:
398
+ await _batch_reload(self, model, objs, preloaded)
399
+ except Exception as exc:
400
+ _logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
401
+ if not had_transaction and self.in_transaction():
402
+ await self._end_reload_transaction()
403
+
404
+ # Dispatch CREATE callbacks.
405
+ for obj in create_items:
406
+ await _dispatch(obj, ModelEvent.CREATE, None)
407
+
408
+ # Dispatch DELETE callbacks (restore snapshot; row is gone).
409
+ for obj, snapshot in deletes:
410
+ try:
411
+ for key, value in snapshot.items():
412
+ _sa_set_committed_value(obj, key, value)
413
+ except Exception as exc:
414
+ _logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
415
+ continue
416
+ await _dispatch(obj, ModelEvent.DELETE, None)
417
+
418
+ # Dispatch UPDATE callbacks.
419
+ for obj, changes in update_items:
420
+ await _dispatch(obj, ModelEvent.UPDATE, changes)
421
+
422
+ async def _end_reload_transaction(self) -> None:
423
+ """Commit the read-only transaction the reload opened, keeping state loaded."""
424
+ sync_session = self.sync_session
425
+ expire_on_commit = sync_session.expire_on_commit
426
+ sync_session.expire_on_commit = False
427
+ try:
428
+ await super().commit()
429
+ except Exception as exc:
430
+ _logger.error(_RELOAD_TRANSACTION_ERROR_MSG, exc_info=exc)
431
+ finally:
432
+ sync_session.expire_on_commit = expire_on_commit
336
433
 
337
434
  async def rollback(self) -> None:
338
435
  await super().rollback()
339
436
  self.info.pop(_SESSION_CREATES, None)
340
437
  self.info.pop(_SESSION_DELETES, None)
341
438
  self.info.pop(_SESSION_UPDATES, None)
439
+ self.info.pop(_SESSION_PRELOADED, None)