simple-module-db 0.0.32__tar.gz → 0.0.34__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 (29) hide show
  1. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/PKG-INFO +8 -6
  2. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/README.md +6 -4
  3. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/pyproject.toml +2 -2
  4. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/__init__.py +4 -1
  5. simple_module_db-0.0.34/simple_module_db/callbacks.py +47 -0
  6. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/deps.py +3 -3
  7. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/session.py +18 -2
  8. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/transaction.py +8 -0
  9. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_session.py +2 -1
  10. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_transaction.py +70 -8
  11. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/.gitignore +0 -0
  12. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/LICENSE +0 -0
  13. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/audit.py +0 -0
  14. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/base.py +0 -0
  15. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/listeners.py +0 -0
  16. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/migrations.py +0 -0
  17. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/mixins.py +0 -0
  18. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/provider.py +0 -0
  19. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/py.typed +0 -0
  20. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/search.py +0 -0
  21. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/_audit_models.py +0 -0
  22. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/_models.py +0 -0
  23. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/conftest.py +0 -0
  24. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_audit.py +0 -0
  25. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_base.py +0 -0
  26. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_db_logging.py +0 -0
  27. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_migrations.py +0 -0
  28. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_mixins.py +0 -0
  29. {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_multi_tenancy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_db
3
- Version: 0.0.32
3
+ Version: 0.0.34
4
4
  Summary: Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -25,7 +25,7 @@ Requires-Dist: aiosqlite>=0.20
25
25
  Requires-Dist: alembic>=1.14
26
26
  Requires-Dist: asyncpg>=0.30
27
27
  Requires-Dist: psycopg2-binary>=2.9
28
- Requires-Dist: simple-module-core==0.0.32
28
+ Requires-Dist: simple-module-core==0.0.34
29
29
  Requires-Dist: sqlalchemy[asyncio]>=2.0
30
30
  Requires-Dist: sqlmodel>=0.0.22
31
31
  Description-Content-Type: text/markdown
@@ -43,7 +43,8 @@ pip install simple_module_db
43
43
  ## What it provides
44
44
 
45
45
  - `create_module_base("<module_name>")` — a module-scoped declarative `Base` with its own `MetaData`. All modules share the host's single schema, so `__tablename__` should be prefixed with the module name (e.g. `users_user`) to avoid collisions.
46
- - Per-request async session (`get_db`) with an auto-commit-on-flush hook — `after_flush` commits if there are pending writes, rolls back otherwise.
46
+ - Per-request `RequestSession` (`get_db`) that commits pending writes before the response and rolls back read-only requests.
47
+ - `session.on_commit(callback)` for synchronous or asynchronous cache refreshes that must only run after a successful request commit.
47
48
  - Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
48
49
  - `DatabaseState` container used by the framework to avoid global mutable state.
49
50
 
@@ -66,12 +67,13 @@ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
66
67
  In a service:
67
68
 
68
69
  ```python
69
- from simple_module_db import get_db
70
+ from simple_module_db import RequestSession, get_db
70
71
 
71
- async def create_order(session = Depends(get_db), ...):
72
+ async def create_order(session: RequestSession = Depends(get_db), ...):
72
73
  order = Order(customer_id=..., total_cents=...)
73
74
  session.add(order)
74
- await session.flush() # assigns order.id; auto-commit happens after the request
75
+ await session.flush() # assigns order.id; commit happens before the response
76
+ session.on_commit(order_cache.refresh)
75
77
  return order
76
78
  ```
77
79
 
@@ -11,7 +11,8 @@ pip install simple_module_db
11
11
  ## What it provides
12
12
 
13
13
  - `create_module_base("<module_name>")` — a module-scoped declarative `Base` with its own `MetaData`. All modules share the host's single schema, so `__tablename__` should be prefixed with the module name (e.g. `users_user`) to avoid collisions.
14
- - Per-request async session (`get_db`) with an auto-commit-on-flush hook — `after_flush` commits if there are pending writes, rolls back otherwise.
14
+ - Per-request `RequestSession` (`get_db`) that commits pending writes before the response and rolls back read-only requests.
15
+ - `session.on_commit(callback)` for synchronous or asynchronous cache refreshes that must only run after a successful request commit.
15
16
  - Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
16
17
  - `DatabaseState` container used by the framework to avoid global mutable state.
17
18
 
@@ -34,12 +35,13 @@ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
34
35
  In a service:
35
36
 
36
37
  ```python
37
- from simple_module_db import get_db
38
+ from simple_module_db import RequestSession, get_db
38
39
 
39
- async def create_order(session = Depends(get_db), ...):
40
+ async def create_order(session: RequestSession = Depends(get_db), ...):
40
41
  order = Order(customer_id=..., total_cents=...)
41
42
  session.add(order)
42
- await session.flush() # assigns order.id; auto-commit happens after the request
43
+ await session.flush() # assigns order.id; commit happens before the response
44
+ session.on_commit(order_cache.refresh)
43
45
  return order
44
46
  ```
45
47
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_db"
3
- version = "0.0.32"
3
+ version = "0.0.34"
4
4
  description = "Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -28,7 +28,7 @@ dependencies = [
28
28
  # The async app code goes through asyncpg; migration env.py rewrites the
29
29
  # URL to +psycopg2 because Alembic's online migrations are sync-only.
30
30
  "psycopg2-binary>=2.9",
31
- "simple_module_core==0.0.32",
31
+ "simple_module_core==0.0.34",
32
32
  "sqlalchemy[asyncio]>=2.0",
33
33
  "sqlmodel>=0.0.22",
34
34
  ]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from simple_module_db.audit import AuditRecord
4
4
  from simple_module_db.base import create_module_base
5
+ from simple_module_db.callbacks import OnCommitCallback
5
6
  from simple_module_db.deps import get_db
6
7
  from simple_module_db.listeners import TenantIsolationError, current_tenant_id
7
8
  from simple_module_db.migrations import (
@@ -13,7 +14,7 @@ from simple_module_db.migrations import (
13
14
  from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
14
15
  from simple_module_db.provider import DatabaseProvider, detect_provider
15
16
  from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
16
- from simple_module_db.session import DatabaseState, init_db
17
+ from simple_module_db.session import DatabaseState, RequestSession, init_db
17
18
  from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
18
19
 
19
20
  __all__ = [
@@ -24,6 +25,8 @@ __all__ = [
24
25
  "DatabaseProvider",
25
26
  "DatabaseState",
26
27
  "MultiTenantMixin",
28
+ "OnCommitCallback",
29
+ "RequestSession",
27
30
  "SoftDeleteMixin",
28
31
  "TenantIsolationError",
29
32
  "VersionedMixin",
@@ -0,0 +1,47 @@
1
+ """Post-commit callbacks for framework-managed database sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ from collections.abc import Awaitable, Callable
8
+
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ type OnCommitCallback = Callable[[], Awaitable[None] | None]
12
+
13
+ _CALLBACKS_KEY = "sm_db_on_commit_callbacks"
14
+ logger = logging.getLogger("simple_module.db")
15
+
16
+
17
+ def register_on_commit(session: AsyncSession, callback: OnCommitCallback) -> None:
18
+ """Queue ``callback`` for this session's next successful finalization."""
19
+ if not callable(callback):
20
+ raise TypeError("on_commit callback must be callable")
21
+ callbacks = session.info.setdefault(_CALLBACKS_KEY, [])
22
+ callbacks.append(callback)
23
+
24
+
25
+ def discard_on_commit_callbacks(session: AsyncSession) -> None:
26
+ """Discard callbacks belonging to a transaction that did not commit."""
27
+ session.info.pop(_CALLBACKS_KEY, None)
28
+
29
+
30
+ async def run_on_commit_callbacks(session: AsyncSession) -> None:
31
+ """Run and remove queued callbacks after a successful commit.
32
+
33
+ The transaction is already durable, so callback failures are logged and do
34
+ not turn a successful database mutation into a misleading failed response.
35
+ Remaining callbacks still run.
36
+ """
37
+ callbacks: list[OnCommitCallback] = session.info.pop(_CALLBACKS_KEY, [])
38
+ for callback in callbacks:
39
+ try:
40
+ result = callback()
41
+ if inspect.isawaitable(result):
42
+ await result
43
+ except Exception:
44
+ logger.exception(
45
+ "db.session.on_commit_failed",
46
+ extra={"operation": "on_commit_failed"},
47
+ )
@@ -6,8 +6,8 @@ import time
6
6
  from collections.abc import AsyncGenerator
7
7
 
8
8
  from fastapi import Request
9
- from sqlalchemy.ext.asyncio import AsyncSession
10
9
 
10
+ from simple_module_db.session import RequestSession
11
11
  from simple_module_db.transaction import (
12
12
  SESSION_START_KEY,
13
13
  finalize_session,
@@ -16,7 +16,7 @@ from simple_module_db.transaction import (
16
16
  )
17
17
 
18
18
 
19
- async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
19
+ async def get_db(request: Request) -> AsyncGenerator[RequestSession, None]:
20
20
  """Yield an async database session, auto-closing on exit.
21
21
 
22
22
  Commits only when the session has pending writes (``new``, ``dirty``,
@@ -34,7 +34,7 @@ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
34
34
  Usage in FastAPI endpoints::
35
35
 
36
36
  @router.get("/items")
37
- async def list_items(db: AsyncSession = Depends(get_db)):
37
+ async def list_items(db: RequestSession = Depends(get_db)):
38
38
  ...
39
39
  """
40
40
  factory = request.app.state.sm.db.session_factory
@@ -13,15 +13,28 @@ from sqlalchemy.ext.asyncio import (
13
13
  )
14
14
  from sqlalchemy.orm import Session
15
15
 
16
+ from simple_module_db.callbacks import OnCommitCallback, register_on_commit
16
17
  from simple_module_db.provider import DatabaseProvider, detect_provider
17
18
 
18
19
 
20
+ class RequestSession(AsyncSession):
21
+ """Async session with hooks for the request-owned transaction."""
22
+
23
+ def on_commit(self, callback: OnCommitCallback) -> None:
24
+ """Run ``callback`` after this request's next successful commit.
25
+
26
+ Callbacks may be synchronous or asynchronous. They are discarded when
27
+ the request rolls back or has no writes.
28
+ """
29
+ register_on_commit(self, callback)
30
+
31
+
19
32
  @dataclass
20
33
  class DatabaseState:
21
34
  """Holds all database state for a single application instance."""
22
35
 
23
36
  engine: AsyncEngine
24
- session_factory: async_sessionmaker[AsyncSession]
37
+ session_factory: async_sessionmaker[RequestSession]
25
38
  sync_session_class: type[Session] = field(repr=False, default=Session)
26
39
  audit_callback: Callable | None = field(default=None, repr=False)
27
40
  _listeners_registered: bool = field(default=False, repr=False)
@@ -67,7 +80,10 @@ def init_db(
67
80
  # Scoped Session subclass so event listeners only fire for this engine's sessions
68
81
  scoped_session_class = type("ScopedSession", (Session,), {})
69
82
  session_factory = async_sessionmaker(
70
- engine, expire_on_commit=False, sync_session_class=scoped_session_class
83
+ engine,
84
+ class_=RequestSession,
85
+ expire_on_commit=False,
86
+ sync_session_class=scoped_session_class,
71
87
  )
72
88
 
73
89
  return DatabaseState(
@@ -31,6 +31,10 @@ import time
31
31
 
32
32
  from sqlalchemy.ext.asyncio import AsyncSession
33
33
 
34
+ from simple_module_db.callbacks import (
35
+ discard_on_commit_callbacks,
36
+ run_on_commit_callbacks,
37
+ )
34
38
  from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
35
39
 
36
40
  logger = logging.getLogger("simple_module.db")
@@ -97,6 +101,7 @@ async def finalize_session(session: AsyncSession) -> None:
97
101
  if session.info.get(_FINALIZED_KEY):
98
102
  return
99
103
  _settle(session)
104
+ discard_on_commit_callbacks(session)
100
105
  await session.rollback()
101
106
  logger.debug(
102
107
  "db.session.read_only",
@@ -106,6 +111,7 @@ async def finalize_session(session: AsyncSession) -> None:
106
111
  try:
107
112
  await session.commit()
108
113
  except Exception:
114
+ discard_on_commit_callbacks(session)
109
115
  await session.rollback()
110
116
  raise
111
117
  finally:
@@ -116,10 +122,12 @@ async def finalize_session(session: AsyncSession) -> None:
116
122
  "db.session.commit",
117
123
  extra={"operation": "commit", "db_duration_ms": _elapsed_ms(session)},
118
124
  )
125
+ await run_on_commit_callbacks(session)
119
126
 
120
127
 
121
128
  async def rollback_session(session: AsyncSession) -> None:
122
129
  """Roll ``session`` back and drop its pending work. Idempotent."""
130
+ discard_on_commit_callbacks(session)
123
131
  if session.info.get(_FINALIZED_KEY) and not _has_pending(session):
124
132
  return
125
133
  _settle(session)
@@ -6,7 +6,7 @@ import contextlib
6
6
  from unittest.mock import MagicMock
7
7
 
8
8
  from simple_module_db.deps import get_db
9
- from simple_module_db.session import DatabaseState, init_db
9
+ from simple_module_db.session import DatabaseState, RequestSession, init_db
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
11
11
 
12
12
 
@@ -58,6 +58,7 @@ class TestGetDbDependency:
58
58
  gen = get_db(mock_request)
59
59
  session = await gen.__anext__()
60
60
  assert isinstance(session, AsyncSession)
61
+ assert isinstance(session, RequestSession)
61
62
 
62
63
  with contextlib.suppress(StopAsyncIteration):
63
64
  await gen.__anext__()
@@ -20,6 +20,7 @@ import pytest
20
20
  from _models import _TxnBase, _TxnThing
21
21
  from fastapi import BackgroundTasks, Depends, FastAPI
22
22
  from fastapi.responses import StreamingResponse
23
+ from simple_module_db import OnCommitCallback, RequestSession
23
24
  from simple_module_db.deps import get_db
24
25
  from simple_module_db.listeners import register_listeners
25
26
  from simple_module_db.session import init_db
@@ -28,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
28
29
  from sqlmodel import select
29
30
 
30
31
 
31
- def _build_app(db_state, *, with_middleware: bool = True) -> FastAPI:
32
+ def _build_app(
33
+ db_state,
34
+ *,
35
+ with_middleware: bool = True,
36
+ on_commit: OnCommitCallback | None = None,
37
+ ) -> FastAPI:
32
38
  """A miniature host: create flushes only, read opens its own session."""
33
39
  app = FastAPI()
34
40
  if with_middleware:
@@ -36,12 +42,14 @@ def _build_app(db_state, *, with_middleware: bool = True) -> FastAPI:
36
42
  app.state.sm = SimpleNamespace(db=db_state)
37
43
 
38
44
  @app.post("/things", status_code=201)
39
- async def create(name: str, db: AsyncSession = Depends(get_db)):
45
+ async def create(name: str, db: RequestSession = Depends(get_db)):
40
46
  # Deliberately no commit() — get_db owns the unit of work, which is
41
47
  # exactly the pattern the framework documents for service code.
42
48
  thing = _TxnThing(name=name)
43
49
  db.add(thing)
44
50
  await db.flush()
51
+ if on_commit is not None:
52
+ db.on_commit(on_commit)
45
53
  return {"id": thing.id}
46
54
 
47
55
  @app.get("/things/{thing_id}")
@@ -54,9 +62,11 @@ def _build_app(db_state, *, with_middleware: bool = True) -> FastAPI:
54
62
  return {"found": True, "name": found.name}
55
63
 
56
64
  @app.post("/boom", status_code=201)
57
- async def boom(db: AsyncSession = Depends(get_db)):
65
+ async def boom(db: RequestSession = Depends(get_db)):
58
66
  db.add(_TxnThing(name="doomed"))
59
67
  await db.flush()
68
+ if on_commit is not None:
69
+ db.on_commit(on_commit)
60
70
  raise RuntimeError("endpoint blew up after writing")
61
71
 
62
72
  return app
@@ -131,11 +141,54 @@ class TestCommitBeforeResponse:
131
141
  "row was not committed by the time the response left the server"
132
142
  )
133
143
 
134
- async def test_endpoint_exception_still_rolls_back(self, db_state):
135
- """The middleware must not turn a failed request's writes into a commit."""
136
- async with await _client(_build_app(db_state)) as client:
144
+ async def test_on_commit_runs_after_the_write_is_durable(self, db_state):
145
+ """A cache refresh can only observe state committed in another session."""
146
+ observed: list[list[str]] = []
147
+
148
+ async def refresh_cache():
149
+ async with db_state.session_factory() as other:
150
+ rows = (await other.execute(select(_TxnThing))).scalars().all()
151
+ observed.append([row.name for row in rows])
152
+
153
+ async with await _client(_build_app(db_state, on_commit=refresh_cache)) as client:
154
+ assert (await client.post("/things", params={"name": "committed"})).status_code == 201
155
+
156
+ assert observed == [["committed"]]
157
+
158
+ async def test_on_commit_failure_does_not_skip_later_callbacks(self, db_state):
159
+ """The commit is durable, so callback errors are logged rather than returned as 500s."""
160
+ called: list[bool] = []
161
+ app = _build_app(db_state)
162
+
163
+ @app.post("/callback-errors", status_code=201)
164
+ async def callback_errors(db: RequestSession = Depends(get_db)):
165
+ db.add(_TxnThing(name="durable-despite-cache-error"))
166
+ await db.flush()
167
+
168
+ def fail_refresh():
169
+ raise RuntimeError("cache unavailable")
170
+
171
+ db.on_commit(fail_refresh)
172
+ db.on_commit(lambda: called.append(True))
173
+ return {"ok": True}
174
+
175
+ async with await _client(app) as client:
176
+ response = await client.post("/callback-errors")
177
+
178
+ assert response.status_code == 201
179
+ assert called == [True]
180
+ async with db_state.session_factory() as session:
181
+ names = [row.name for row in (await session.execute(select(_TxnThing))).scalars()]
182
+ assert names == ["durable-despite-cache-error"]
183
+
184
+ async def test_endpoint_exception_rolls_back_without_on_commit(self, db_state):
185
+ """Callbacks for work discarded by an endpoint failure never run."""
186
+ called: list[bool] = []
187
+ app = _build_app(db_state, on_commit=lambda: called.append(True))
188
+ async with await _client(app) as client:
137
189
  assert (await client.post("/boom")).status_code == 500
138
190
 
191
+ assert called == []
139
192
  async with db_state.session_factory() as session:
140
193
  assert (await session.execute(select(_TxnThing))).scalars().all() == []
141
194
 
@@ -154,11 +207,14 @@ class TestCommitBeforeResponse:
154
207
 
155
208
  monkeypatch.setattr(AsyncSession, "commit", explode)
156
209
 
157
- async with await _client(_build_app(db_state)) as client:
210
+ called: list[bool] = []
211
+ app = _build_app(db_state, on_commit=lambda: called.append(True))
212
+ async with await _client(app) as client:
158
213
  response = await client.post("/things", params={"name": "nope"})
159
214
 
160
215
  assert response.status_code == 500
161
216
  assert response.json() == {"detail": "Internal Server Error"}
217
+ assert called == []
162
218
 
163
219
  monkeypatch.undo()
164
220
  async with db_state.session_factory() as session:
@@ -216,10 +272,16 @@ class TestCommitBeforeResponse:
216
272
  async def test_still_commits_without_the_middleware(self, db_state):
217
273
  """get_db keeps its own fallback finalize, so the dependency works
218
274
  standalone — in a WebSocket handler, or a test that builds no stack."""
219
- app = _build_app(db_state, with_middleware=False)
275
+ called: list[bool] = []
276
+ app = _build_app(
277
+ db_state,
278
+ with_middleware=False,
279
+ on_commit=lambda: called.append(True),
280
+ )
220
281
  async with await _client(app) as client:
221
282
  assert (await client.post("/things", params={"name": "solo"})).status_code == 201
222
283
 
284
+ assert called == [True]
223
285
  async with db_state.session_factory() as session:
224
286
  names = [t.name for t in (await session.execute(select(_TxnThing))).scalars().all()]
225
287
  assert names == ["solo"]