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.
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/PKG-INFO +1 -1
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/pyproject.toml +1 -1
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/pyproject.toml.orig +1 -1
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/models/watched.py +127 -29
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/LICENSE +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/README.md +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/config.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/utils.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/crud/factory.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/crud/search.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/core.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/locks.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/m2m.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/testing.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/db/watch.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/dependencies.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/fixtures/registry.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/fixtures/utils.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/models/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/models/columns.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/pytest/plugin.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/pytest/utils.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/schemas.py +0 -0
- {fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/types.py +0 -0
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/cli/commands/fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/exceptions.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.3 → fastapi_toolsets-5.1.4}/src/fastapi_toolsets/exceptions/handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|