voltwire-fastapi-db-txs 0.0.1__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.
@@ -0,0 +1,221 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ # Local tool state
221
+ .omc/
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: voltwire-fastapi-db-txs
3
+ Version: 0.0.1
4
+ Summary: FastAPI middleware and dependencies for request-scoped DB transaction management
5
+ Author-email: Hermann Steidel <hsteidel.software@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: <4.0,>=3.13
8
+ Requires-Dist: fastapi>=0.100.0
9
+ Requires-Dist: starlette>=0.27.0
10
+ Requires-Dist: voltwire-db-session>=0.0.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ <img src="https://raw.githubusercontent.com/hsteidel/voltwire/main/assets/icons/fastapi-db-txs.svg" alt="" width="56" height="56" align="left">
14
+
15
+ # voltwire-fastapi-db-txs
16
+
17
+ FastAPI integration for `voltwire.db.session.TransactionContext` (from
18
+ [voltwire-db-session](https://pypi.org/project/voltwire-db-session/)): request-scoped read-write and
19
+ read-only database sessions with automatic commit/rollback, so route handlers never manage
20
+ session lifecycle themselves.
21
+
22
+ **No hidden global state, no DI framework coupling.** Every component here depends on
23
+ *resolvers* — anything callable as `(request: Request) -> T` — for `TransactionContext`
24
+ and the relevant session factory, rather than plain instances. This lets apps defer
25
+ resolution to `request.app.state`, a DI container, or anywhere else, which matters for
26
+ apps whose DB config isn't finalized until after this middleware/dependency is
27
+ constructed (e.g. test harnesses that substitute a test database after module import
28
+ time). Apps with one fixed instance for the process's lifetime just pass
29
+ `lambda request: my_instance`.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install voltwire-fastapi-db-txs
35
+ # or with Poetry:
36
+ poetry add voltwire-fastapi-db-txs
37
+ ```
38
+
39
+ ## Wiring it into your app
40
+
41
+ Your app should hold exactly one `TransactionContext` instance for the life of the process.
42
+ The easiest way to get one — along with its matching `DatabaseSessionFactory`/
43
+ `RODatabaseSessionFactory` — is `auto_configure_database`, from voltwire-db-session. If your DB
44
+ config is ready before any route module gets imported, resolve it once and close over it:
45
+
46
+ ```python
47
+ from voltwire.db.session import auto_configure_database, DatabaseAutoConfigurationProperties
48
+ from voltwire.fastapi.db_txs import TransactionMiddleware, make_read_only_transaction
49
+
50
+ db = auto_configure_database(db_settings, DatabaseAutoConfigurationProperties(build_read_replica=True))
51
+
52
+ app.add_middleware(
53
+ TransactionMiddleware,
54
+ transaction_context=lambda request: db.transaction_context,
55
+ session_factory=lambda request: db.session_factory,
56
+ )
57
+
58
+ ReadOnlyTransaction = make_read_only_transaction(
59
+ lambda request: db.ro_session_factory,
60
+ lambda request: db.transaction_context,
61
+ )
62
+ ```
63
+
64
+ If your app defers DB config until later (e.g. it's set on `request.app.state` during
65
+ startup, or substituted for a test database after these components are constructed),
66
+ resolve from the request instead. If your app uses voltwire-di-core's
67
+ `request.app.state.provide(cls)` convention, `AppStateResolver` implements the resolver
68
+ protocol for you — pass the class you want resolved:
69
+
70
+ ```python
71
+ from voltwire.fastapi.db_txs import AppStateResolver
72
+
73
+ app.add_middleware(
74
+ TransactionMiddleware,
75
+ transaction_context=AppStateResolver(TransactionContext),
76
+ session_factory=AppStateResolver(DatabaseSessionFactory),
77
+ )
78
+
79
+ ReadOnlyTransaction = make_read_only_transaction(
80
+ AppStateResolver(RODatabaseSessionFactory),
81
+ AppStateResolver(TransactionContext),
82
+ )
83
+ ```
84
+
85
+ Apps not using that convention can pass any other callable of the same shape —
86
+ `lambda request: request.app.state.my_custom_lookup(TransactionContext)`, a bound
87
+ method, or a small resolver class of their own.
88
+
89
+ If you'd rather assemble the session factories yourself (e.g. a custom
90
+ `DatabaseSessionFactory` subclass) instead of using `auto_configure_database`, build each
91
+ one directly — `TransactionMiddleware` and `make_read_only_transaction` only need
92
+ resolvers returning a `TransactionContext` and the relevant factory, however you got them:
93
+
94
+ ```python
95
+ from voltwire.db.session import TransactionContext, build_session_factory, build_ro_session_factory
96
+
97
+ transaction_context = TransactionContext()
98
+ session_factory = build_session_factory(db_settings)
99
+ ro_session_factory = build_ro_session_factory(db_settings)
100
+ ```
101
+
102
+ ## Using it in routes
103
+
104
+ Write routes (RW by default — the middleware opens a session for every request):
105
+
106
+ ```python
107
+ @router.post("/users")
108
+ def create_user(user_repo: UserRepositoryDI):
109
+ return user_repo.save(user) # commits automatically on a 2xx/3xx response
110
+ ```
111
+
112
+ Read-only routes (route or router-level, targets your read replica instead):
113
+
114
+ ```python
115
+ @router.get("/users", dependencies=[ReadOnlyTransaction])
116
+ def search_users(user_repo: UserRepositoryDI) -> list[UserResponse]:
117
+ ...
118
+ ```
119
+
120
+ Wherever your app resolves a `Session` for its repositories, call
121
+ `transaction_context.get_session()` — it returns the RO session if one is active for the
122
+ current request, otherwise the RW session opened by the middleware, otherwise `None`.
123
+
124
+ ## Scripts and background tasks
125
+
126
+ For code outside a FastAPI request (management scripts, workers), use the context managers
127
+ directly instead of the middleware/dependency — either via the `db` object from
128
+ `auto_configure_database`:
129
+
130
+ ```python
131
+ with db.transaction():
132
+ user_repo = UserRepository(db.transaction_context.get_session())
133
+ user_repo.save(user)
134
+ # commits on success, rolls back on exception
135
+
136
+ with db.read_transaction():
137
+ result = repo.find(...)
138
+ ```
139
+
140
+ or, if you built the pieces yourself, the same methods on `TransactionContext` directly:
141
+
142
+ ```python
143
+ with transaction_context.transaction(session_factory):
144
+ user_repo = UserRepository(transaction_context.get_session())
145
+ user_repo.save(user)
146
+
147
+ with transaction_context.read_transaction(ro_session_factory):
148
+ result = repo.find(...)
149
+ ```
150
+
151
+ If a single `DatabaseAutoConfiguration` is truly the only one in the process (no multiple
152
+ databases, no multiple apps sharing the interpreter — e.g. in a pytest session), voltwire-db-session
153
+ also has an opt-in `voltwire.db.session.default` module for a bare `with transaction():` — see
154
+ voltwire-db-session's docs for when that tradeoff is worth it.
155
+
156
+ ## Why resolvers instead of instances?
157
+
158
+ This library has no knowledge of any dependency-injection framework — `TransactionMiddleware`
159
+ and `make_read_only_transaction` never construct or own a `TransactionContext`/session
160
+ factory themselves. Depending on a resolver *protocol* instead of a plain instance means
161
+ your app decides *when* resolution happens: eagerly (a lambda that just returns a value
162
+ captured at wiring time) or lazily per request (e.g. `request.app.state.provide(...)`),
163
+ without this library needing to know which. `AppStateResolver` is the one piece that
164
+ assumes anything about `request.app.state` beyond FastAPI itself — it exists purely as a
165
+ convenience for voltwire-di-core's convention; everything else in this library only ever calls
166
+ the resolver, never reaches into `request.app.state` directly. The one thing that
167
+ matters regardless of which resolver you use: share the *same* `TransactionContext`
168
+ instance across the middleware, any read-only dependencies, and your app's own
169
+ session-resolution code — its ContextVars are what tie a request's session together.
@@ -0,0 +1,157 @@
1
+ <img src="https://raw.githubusercontent.com/hsteidel/voltwire/main/assets/icons/fastapi-db-txs.svg" alt="" width="56" height="56" align="left">
2
+
3
+ # voltwire-fastapi-db-txs
4
+
5
+ FastAPI integration for `voltwire.db.session.TransactionContext` (from
6
+ [voltwire-db-session](https://pypi.org/project/voltwire-db-session/)): request-scoped read-write and
7
+ read-only database sessions with automatic commit/rollback, so route handlers never manage
8
+ session lifecycle themselves.
9
+
10
+ **No hidden global state, no DI framework coupling.** Every component here depends on
11
+ *resolvers* — anything callable as `(request: Request) -> T` — for `TransactionContext`
12
+ and the relevant session factory, rather than plain instances. This lets apps defer
13
+ resolution to `request.app.state`, a DI container, or anywhere else, which matters for
14
+ apps whose DB config isn't finalized until after this middleware/dependency is
15
+ constructed (e.g. test harnesses that substitute a test database after module import
16
+ time). Apps with one fixed instance for the process's lifetime just pass
17
+ `lambda request: my_instance`.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install voltwire-fastapi-db-txs
23
+ # or with Poetry:
24
+ poetry add voltwire-fastapi-db-txs
25
+ ```
26
+
27
+ ## Wiring it into your app
28
+
29
+ Your app should hold exactly one `TransactionContext` instance for the life of the process.
30
+ The easiest way to get one — along with its matching `DatabaseSessionFactory`/
31
+ `RODatabaseSessionFactory` — is `auto_configure_database`, from voltwire-db-session. If your DB
32
+ config is ready before any route module gets imported, resolve it once and close over it:
33
+
34
+ ```python
35
+ from voltwire.db.session import auto_configure_database, DatabaseAutoConfigurationProperties
36
+ from voltwire.fastapi.db_txs import TransactionMiddleware, make_read_only_transaction
37
+
38
+ db = auto_configure_database(db_settings, DatabaseAutoConfigurationProperties(build_read_replica=True))
39
+
40
+ app.add_middleware(
41
+ TransactionMiddleware,
42
+ transaction_context=lambda request: db.transaction_context,
43
+ session_factory=lambda request: db.session_factory,
44
+ )
45
+
46
+ ReadOnlyTransaction = make_read_only_transaction(
47
+ lambda request: db.ro_session_factory,
48
+ lambda request: db.transaction_context,
49
+ )
50
+ ```
51
+
52
+ If your app defers DB config until later (e.g. it's set on `request.app.state` during
53
+ startup, or substituted for a test database after these components are constructed),
54
+ resolve from the request instead. If your app uses voltwire-di-core's
55
+ `request.app.state.provide(cls)` convention, `AppStateResolver` implements the resolver
56
+ protocol for you — pass the class you want resolved:
57
+
58
+ ```python
59
+ from voltwire.fastapi.db_txs import AppStateResolver
60
+
61
+ app.add_middleware(
62
+ TransactionMiddleware,
63
+ transaction_context=AppStateResolver(TransactionContext),
64
+ session_factory=AppStateResolver(DatabaseSessionFactory),
65
+ )
66
+
67
+ ReadOnlyTransaction = make_read_only_transaction(
68
+ AppStateResolver(RODatabaseSessionFactory),
69
+ AppStateResolver(TransactionContext),
70
+ )
71
+ ```
72
+
73
+ Apps not using that convention can pass any other callable of the same shape —
74
+ `lambda request: request.app.state.my_custom_lookup(TransactionContext)`, a bound
75
+ method, or a small resolver class of their own.
76
+
77
+ If you'd rather assemble the session factories yourself (e.g. a custom
78
+ `DatabaseSessionFactory` subclass) instead of using `auto_configure_database`, build each
79
+ one directly — `TransactionMiddleware` and `make_read_only_transaction` only need
80
+ resolvers returning a `TransactionContext` and the relevant factory, however you got them:
81
+
82
+ ```python
83
+ from voltwire.db.session import TransactionContext, build_session_factory, build_ro_session_factory
84
+
85
+ transaction_context = TransactionContext()
86
+ session_factory = build_session_factory(db_settings)
87
+ ro_session_factory = build_ro_session_factory(db_settings)
88
+ ```
89
+
90
+ ## Using it in routes
91
+
92
+ Write routes (RW by default — the middleware opens a session for every request):
93
+
94
+ ```python
95
+ @router.post("/users")
96
+ def create_user(user_repo: UserRepositoryDI):
97
+ return user_repo.save(user) # commits automatically on a 2xx/3xx response
98
+ ```
99
+
100
+ Read-only routes (route or router-level, targets your read replica instead):
101
+
102
+ ```python
103
+ @router.get("/users", dependencies=[ReadOnlyTransaction])
104
+ def search_users(user_repo: UserRepositoryDI) -> list[UserResponse]:
105
+ ...
106
+ ```
107
+
108
+ Wherever your app resolves a `Session` for its repositories, call
109
+ `transaction_context.get_session()` — it returns the RO session if one is active for the
110
+ current request, otherwise the RW session opened by the middleware, otherwise `None`.
111
+
112
+ ## Scripts and background tasks
113
+
114
+ For code outside a FastAPI request (management scripts, workers), use the context managers
115
+ directly instead of the middleware/dependency — either via the `db` object from
116
+ `auto_configure_database`:
117
+
118
+ ```python
119
+ with db.transaction():
120
+ user_repo = UserRepository(db.transaction_context.get_session())
121
+ user_repo.save(user)
122
+ # commits on success, rolls back on exception
123
+
124
+ with db.read_transaction():
125
+ result = repo.find(...)
126
+ ```
127
+
128
+ or, if you built the pieces yourself, the same methods on `TransactionContext` directly:
129
+
130
+ ```python
131
+ with transaction_context.transaction(session_factory):
132
+ user_repo = UserRepository(transaction_context.get_session())
133
+ user_repo.save(user)
134
+
135
+ with transaction_context.read_transaction(ro_session_factory):
136
+ result = repo.find(...)
137
+ ```
138
+
139
+ If a single `DatabaseAutoConfiguration` is truly the only one in the process (no multiple
140
+ databases, no multiple apps sharing the interpreter — e.g. in a pytest session), voltwire-db-session
141
+ also has an opt-in `voltwire.db.session.default` module for a bare `with transaction():` — see
142
+ voltwire-db-session's docs for when that tradeoff is worth it.
143
+
144
+ ## Why resolvers instead of instances?
145
+
146
+ This library has no knowledge of any dependency-injection framework — `TransactionMiddleware`
147
+ and `make_read_only_transaction` never construct or own a `TransactionContext`/session
148
+ factory themselves. Depending on a resolver *protocol* instead of a plain instance means
149
+ your app decides *when* resolution happens: eagerly (a lambda that just returns a value
150
+ captured at wiring time) or lazily per request (e.g. `request.app.state.provide(...)`),
151
+ without this library needing to know which. `AppStateResolver` is the one piece that
152
+ assumes anything about `request.app.state` beyond FastAPI itself — it exists purely as a
153
+ convenience for voltwire-di-core's convention; everything else in this library only ever calls
154
+ the resolver, never reaches into `request.app.state` directly. The one thing that
155
+ matters regardless of which resolver you use: share the *same* `TransactionContext`
156
+ instance across the middleware, any read-only dependencies, and your app's own
157
+ session-resolution code — its ContextVars are what tie a request's session together.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "voltwire-fastapi-db-txs"
3
+ version = "0.0.1"
4
+ description = "FastAPI middleware and dependencies for request-scoped DB transaction management"
5
+ authors = [{name = "Hermann Steidel", email = "hsteidel.software@gmail.com"}]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ requires-python = ">=3.13,<4.0"
9
+ dependencies = [
10
+ "fastapi>=0.100.0",
11
+ "starlette>=0.27.0",
12
+ "voltwire-db-session>=0.0.0",
13
+ ]
14
+
15
+ [dependency-groups]
16
+ dev = ["pytest>=8.0"]
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/voltwire"]
24
+
25
+ [tool.uv.sources]
26
+ voltwire-db-session = {workspace = true}
@@ -0,0 +1,19 @@
1
+ from voltwire.fastapi.db_txs.dependencies import make_read_only_transaction
2
+ from voltwire.fastapi.db_txs.middleware import TransactionMiddleware
3
+ from voltwire.fastapi.db_txs.resolvers import (
4
+ AppStateResolver,
5
+ RODatabaseSessionFactoryResolver,
6
+ SessionFactoryResolver,
7
+ TransactionContextResolver,
8
+ )
9
+
10
+ __version__ = "0.0.0"
11
+
12
+ __all__ = [
13
+ "TransactionMiddleware",
14
+ "make_read_only_transaction",
15
+ "AppStateResolver",
16
+ "RODatabaseSessionFactoryResolver",
17
+ "SessionFactoryResolver",
18
+ "TransactionContextResolver",
19
+ ]
@@ -0,0 +1,66 @@
1
+ """
2
+ Read-Replica FastAPI Dependency Builder
3
+
4
+ FastAPI's ``Depends()`` callables can't take extra constructor arguments, so the
5
+ read-only dependency is produced by a builder function instead: call
6
+ ``make_read_only_transaction`` once at app-wiring time with resolver callables for
7
+ your ``RODatabaseSessionFactory`` and the same ``TransactionContext`` instance used by
8
+ ``TransactionMiddleware``, then bind the result to a name in your app::
9
+
10
+ ReadOnlyTransaction = make_read_only_transaction(
11
+ lambda request: my_ro_session_factory,
12
+ lambda request: my_transaction_context,
13
+ )
14
+
15
+ @router.get("", dependencies=[ReadOnlyTransaction])
16
+ def search_items(service: MyServiceDI) -> MySearchResponse:
17
+ ...
18
+
19
+ Both resolvers are invoked per request with the ``Request`` rather than passed as
20
+ plain instances — this lets apps defer resolution to whatever they've stashed on
21
+ ``request.app.state`` (or a DI container), which matters for apps whose settings/
22
+ session factories aren't finalized until after this dependency is built (e.g. test
23
+ harnesses that substitute a test database via ``request.app.state`` after module
24
+ import time). Apps with a single fixed instance for the lifetime of the process can
25
+ pass ``lambda request: my_instance``.
26
+ """
27
+
28
+ from collections.abc import AsyncGenerator
29
+
30
+ from fastapi import Depends, Request
31
+ from fastapi.params import Depends as DependsType
32
+
33
+ from voltwire.fastapi.db_txs.resolvers import RODatabaseSessionFactoryResolver, TransactionContextResolver
34
+
35
+
36
+ def make_read_only_transaction(
37
+ ro_session_factory: RODatabaseSessionFactoryResolver,
38
+ transaction_context: TransactionContextResolver,
39
+ ) -> DependsType:
40
+ """
41
+ Build a FastAPI dependency that opens a read-only replica session for the
42
+ duration of a request.
43
+
44
+ Keeps the RO session open for the full duration of the request so that all
45
+ downstream session resolution within the route uses the replica.
46
+
47
+ Must be async so the ContextVar is set and reset within the async event-loop
48
+ context — mirroring ``TransactionMiddleware``. Sync generator dependencies run in
49
+ a thread pool where anyio snapshots the context, causing ContextVar tokens to be
50
+ bound to the thread's copied Context; the token would then be invalid when FastAPI
51
+ runs generator cleanup in a different context snapshot. As an async generator,
52
+ ``set()``/``reset()`` both execute in the same async Context, and the sync route
53
+ thread inherits the session via the context copy.
54
+ """
55
+
56
+ async def build_read_replica_context(request: Request) -> AsyncGenerator[None, None]:
57
+ session = ro_session_factory(request).get_session()
58
+ context = transaction_context(request)
59
+ token = context.set_ro_session(session)
60
+ try:
61
+ yield
62
+ finally:
63
+ context.reset_ro_session(token)
64
+ session.close()
65
+
66
+ return Depends(build_read_replica_context)
@@ -0,0 +1,98 @@
1
+ """
2
+ FastAPI Transaction Middleware
3
+
4
+ Provides transparent transaction management for web requests with automatic
5
+ commit/rollback behavior, built on top of ``voltwire.db.session.TransactionContext``.
6
+
7
+ This middleware:
8
+ - Opens a read-write session for each HTTP request
9
+ - Commits the transaction on successful response (2xx/3xx status codes)
10
+ - Rolls back the transaction on exceptions or error status codes
11
+ - Always closes the session for proper cleanup
12
+ - Works across FastAPI's thread boundaries via the injected ``TransactionContext``
13
+
14
+ Usage::
15
+
16
+ app.add_middleware(
17
+ TransactionMiddleware,
18
+ transaction_context=lambda request: my_transaction_context,
19
+ session_factory=lambda request: my_session_factory,
20
+ )
21
+
22
+ Both ``transaction_context`` and ``session_factory`` are resolver callables invoked
23
+ per request with the ``Request``, not plain instances — this lets apps defer
24
+ resolution to whatever they've stashed on ``request.app.state`` (or a DI container),
25
+ which matters for apps whose settings/session factories aren't finalized until after
26
+ this middleware is constructed (e.g. test harnesses that substitute a test database
27
+ via ``request.app.state`` after module import time). Apps with a single fixed
28
+ instance for the lifetime of the process can pass ``lambda request: my_instance``.
29
+ """
30
+
31
+ from collections.abc import Callable
32
+
33
+ from starlette.middleware.base import BaseHTTPMiddleware
34
+ from starlette.requests import Request
35
+ from starlette.responses import Response
36
+
37
+ from voltwire.fastapi.db_txs.resolvers import SessionFactoryResolver, TransactionContextResolver
38
+
39
+
40
+ class TransactionMiddleware(BaseHTTPMiddleware):
41
+ """
42
+ Middleware that provides transparent transaction management for HTTP requests.
43
+
44
+ Automatically manages database session lifecycle:
45
+ - Opens a session before request processing
46
+ - Commits on successful completion (2xx/3xx status codes)
47
+ - Rolls back on exceptions or error status codes
48
+ - Always cleans up resources
49
+
50
+ ``transaction_context`` and ``session_factory`` are resolver callables — invoked
51
+ with the current ``Request`` on every request — supplied by the caller. This
52
+ middleware has no knowledge of any DI framework or app-level session registry.
53
+ The resolved ``TransactionContext`` should be the same instance used by any
54
+ read-only dependency built with ``make_read_only_transaction`` and by the app's
55
+ own session-resolution code, since the ContextVars it owns are what tie a
56
+ request's session together across dependencies and route handlers.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ app,
62
+ transaction_context: TransactionContextResolver,
63
+ session_factory: SessionFactoryResolver,
64
+ ):
65
+ super().__init__(app)
66
+ self._resolve_transaction_context = transaction_context
67
+ self._resolve_session_factory = session_factory
68
+
69
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
70
+ """
71
+ Handle HTTP request with automatic transaction management.
72
+
73
+ Opens the RW session eagerly in the async context so that all thread-pool
74
+ workers dispatched by ``call_next`` inherit the same session via context copy.
75
+ Read-only routes shadow it via a dependency built from the same
76
+ ``transaction_context``.
77
+ """
78
+ transaction_context = self._resolve_transaction_context(request)
79
+ session_factory = self._resolve_session_factory(request)
80
+
81
+ session = session_factory.get_session()
82
+ transaction_context.set_rw_session(session)
83
+ try:
84
+ response = await call_next(request)
85
+
86
+ if 200 <= response.status_code < 399:
87
+ transaction_context.commit()
88
+ else:
89
+ transaction_context.rollback()
90
+
91
+ return response
92
+
93
+ except Exception:
94
+ transaction_context.rollback()
95
+ raise
96
+
97
+ finally:
98
+ transaction_context.close()
@@ -0,0 +1,64 @@
1
+ """
2
+ Resolvers
3
+
4
+ ``TransactionMiddleware`` and ``make_read_only_transaction`` depend on resolver
5
+ *protocols* — anything callable with ``(request: Request) -> T`` — rather than plain
6
+ instances, so resolution can be deferred to whatever the app has stashed on
7
+ ``request.app.state`` (or elsewhere), instead of requiring a value fixed at wiring
8
+ time. A plain function or lambda satisfies the protocol structurally; no subclassing
9
+ required::
10
+
11
+ TransactionMiddleware(
12
+ app,
13
+ transaction_context=lambda request: my_transaction_context,
14
+ session_factory=lambda request: my_session_factory,
15
+ )
16
+
17
+ For apps using voltwire-di-core's ``request.app.state.provide(cls)`` convention,
18
+ ``AppStateResolver`` implements the protocol for you — pass the class you want
19
+ resolved::
20
+
21
+ TransactionMiddleware(
22
+ app,
23
+ transaction_context=AppStateResolver(TransactionContext),
24
+ session_factory=AppStateResolver(DatabaseSessionFactory),
25
+ )
26
+
27
+ ``AppStateResolver`` is the only piece of this library that assumes anything about
28
+ ``request.app.state`` beyond FastAPI itself — apps not using that convention should
29
+ pass a plain callable instead.
30
+ """
31
+
32
+ from typing import Generic, Protocol, TypeVar
33
+
34
+ from voltwire.db.session import DatabaseSessionFactory, RODatabaseSessionFactory, TransactionContext
35
+ from starlette.requests import Request
36
+
37
+ T = TypeVar("T")
38
+
39
+
40
+ class TransactionContextResolver(Protocol):
41
+ def __call__(self, request: Request) -> TransactionContext: ...
42
+
43
+
44
+ class SessionFactoryResolver(Protocol):
45
+ def __call__(self, request: Request) -> DatabaseSessionFactory: ...
46
+
47
+
48
+ class RODatabaseSessionFactoryResolver(Protocol):
49
+ def __call__(self, request: Request) -> RODatabaseSessionFactory: ...
50
+
51
+
52
+ class AppStateResolver(Generic[T]):
53
+ """
54
+ Resolves ``cls`` via ``request.app.state.provide(cls)`` — the voltwire-di-core
55
+ convention for reaching a request's DI container. Satisfies
56
+ ``TransactionContextResolver``/``SessionFactoryResolver``/
57
+ ``RODatabaseSessionFactoryResolver`` for whichever ``cls`` is passed.
58
+ """
59
+
60
+ def __init__(self, cls: type[T]) -> None:
61
+ self._cls = cls
62
+
63
+ def __call__(self, request: Request) -> T:
64
+ return request.app.state.provide(self._cls)
@@ -0,0 +1,76 @@
1
+ import asyncio
2
+ from unittest.mock import MagicMock
3
+
4
+ from voltwire.db.session import RODatabaseSessionFactory, TransactionContext
5
+ from voltwire.fastapi.db_txs import make_read_only_transaction
6
+
7
+
8
+ def _ro_factory(session: MagicMock) -> MagicMock:
9
+ factory = MagicMock(spec=RODatabaseSessionFactory)
10
+ factory.get_session.return_value = session
11
+ return factory
12
+
13
+
14
+ async def _drain(dependency) -> None:
15
+ """Fully exercise a FastAPI Depends()-wrapped async generator, like FastAPI would."""
16
+ agen = dependency.dependency(MagicMock())
17
+ await agen.__anext__()
18
+ try:
19
+ await agen.__anext__()
20
+ except StopAsyncIteration:
21
+ pass
22
+
23
+
24
+ def test_ro_session_active_during_yield():
25
+ context = TransactionContext()
26
+ session = MagicMock()
27
+ ro_transaction = make_read_only_transaction(lambda _request: _ro_factory(session), lambda _request: context)
28
+
29
+ seen = {}
30
+
31
+ async def run():
32
+ agen = ro_transaction.dependency(MagicMock())
33
+ await agen.__anext__()
34
+ seen["session"] = context.get_session()
35
+ try:
36
+ await agen.__anext__()
37
+ except StopAsyncIteration:
38
+ pass
39
+
40
+ asyncio.run(run())
41
+
42
+ assert seen["session"] is session
43
+
44
+
45
+ def test_session_closed_and_context_cleared_after():
46
+ context = TransactionContext()
47
+ session = MagicMock()
48
+ ro_transaction = make_read_only_transaction(lambda _request: _ro_factory(session), lambda _request: context)
49
+
50
+ asyncio.run(_drain(ro_transaction))
51
+
52
+ session.close.assert_called_once()
53
+ assert context.get_ro_session() is None
54
+
55
+
56
+ def test_never_commits():
57
+ context = TransactionContext()
58
+ session = MagicMock()
59
+ ro_transaction = make_read_only_transaction(lambda _request: _ro_factory(session), lambda _request: context)
60
+
61
+ asyncio.run(_drain(ro_transaction))
62
+
63
+ session.commit.assert_not_called()
64
+
65
+
66
+ def test_restores_previous_ro_session_after_completion():
67
+ context = TransactionContext()
68
+ outer_session = MagicMock()
69
+ context.set_ro_session(outer_session)
70
+
71
+ inner_session = MagicMock()
72
+ ro_transaction = make_read_only_transaction(lambda _request: _ro_factory(inner_session), lambda _request: context)
73
+
74
+ asyncio.run(_drain(ro_transaction))
75
+
76
+ assert context.get_ro_session() is outer_session
@@ -0,0 +1,92 @@
1
+ import asyncio
2
+ from unittest.mock import AsyncMock, MagicMock
3
+
4
+ import pytest
5
+
6
+ from voltwire.db.session import DatabaseSessionFactory, TransactionContext
7
+ from voltwire.fastapi.db_txs import TransactionMiddleware
8
+
9
+
10
+ def _build_middleware(transaction_context: TransactionContext, session: MagicMock) -> TransactionMiddleware:
11
+ session_factory = MagicMock(spec=DatabaseSessionFactory)
12
+ session_factory.get_session.return_value = session
13
+ app = MagicMock()
14
+ return TransactionMiddleware(
15
+ app,
16
+ transaction_context=lambda _request: transaction_context,
17
+ session_factory=lambda _request: session_factory,
18
+ )
19
+
20
+
21
+ def _response(status_code: int) -> MagicMock:
22
+ response = MagicMock()
23
+ response.status_code = status_code
24
+ return response
25
+
26
+
27
+ def test_commits_on_success_status():
28
+ context = TransactionContext()
29
+ session = MagicMock()
30
+ middleware = _build_middleware(context, session)
31
+ call_next = AsyncMock(return_value=_response(200))
32
+
33
+ result = asyncio.run(middleware.dispatch(MagicMock(), call_next))
34
+
35
+ assert result.status_code == 200
36
+ session.commit.assert_called_once()
37
+ session.rollback.assert_not_called()
38
+ session.close.assert_called_once()
39
+
40
+
41
+ def test_rolls_back_on_error_status():
42
+ context = TransactionContext()
43
+ session = MagicMock()
44
+ middleware = _build_middleware(context, session)
45
+ call_next = AsyncMock(return_value=_response(500))
46
+
47
+ asyncio.run(middleware.dispatch(MagicMock(), call_next))
48
+
49
+ session.rollback.assert_called_once()
50
+ session.commit.assert_not_called()
51
+ session.close.assert_called_once()
52
+
53
+
54
+ def test_rolls_back_and_reraises_on_exception():
55
+ context = TransactionContext()
56
+ session = MagicMock()
57
+ middleware = _build_middleware(context, session)
58
+ call_next = AsyncMock(side_effect=ValueError("boom"))
59
+
60
+ with pytest.raises(ValueError, match="boom"):
61
+ asyncio.run(middleware.dispatch(MagicMock(), call_next))
62
+
63
+ session.rollback.assert_called_once()
64
+ session.commit.assert_not_called()
65
+ session.close.assert_called_once()
66
+
67
+
68
+ def test_session_visible_via_transaction_context_during_request():
69
+ context = TransactionContext()
70
+ session = MagicMock()
71
+ middleware = _build_middleware(context, session)
72
+
73
+ seen = {}
74
+
75
+ async def call_next(_request):
76
+ seen["session"] = context.get_session()
77
+ return _response(200)
78
+
79
+ asyncio.run(middleware.dispatch(MagicMock(), call_next))
80
+
81
+ assert seen["session"] is session
82
+
83
+
84
+ def test_session_cleared_after_request():
85
+ context = TransactionContext()
86
+ session = MagicMock()
87
+ middleware = _build_middleware(context, session)
88
+ call_next = AsyncMock(return_value=_response(200))
89
+
90
+ asyncio.run(middleware.dispatch(MagicMock(), call_next))
91
+
92
+ assert context.get_session() is None
@@ -0,0 +1,26 @@
1
+ from unittest.mock import MagicMock
2
+
3
+ from voltwire.db.session import TransactionContext
4
+ from voltwire.fastapi.db_txs import AppStateResolver
5
+
6
+
7
+ class TestAppStateResolver:
8
+ def test_calls_provide_with_the_given_class(self):
9
+ resolved = TransactionContext()
10
+ request = MagicMock()
11
+ request.app.state.provide.return_value = resolved
12
+
13
+ resolver = AppStateResolver(TransactionContext)
14
+ result = resolver(request)
15
+
16
+ request.app.state.provide.assert_called_once_with(TransactionContext)
17
+ assert result is resolved
18
+
19
+ def test_re_resolves_on_every_call(self):
20
+ request = MagicMock()
21
+ resolver = AppStateResolver(TransactionContext)
22
+
23
+ resolver(request)
24
+ resolver(request)
25
+
26
+ assert request.app.state.provide.call_count == 2