fastapi-transactional 0.1.0__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_transactional-0.1.0/.gitignore +56 -0
- fastapi_transactional-0.1.0/CHANGELOG.md +23 -0
- fastapi_transactional-0.1.0/LICENSE +21 -0
- fastapi_transactional-0.1.0/PKG-INFO +256 -0
- fastapi_transactional-0.1.0/README.md +190 -0
- fastapi_transactional-0.1.0/examples/README.md +27 -0
- fastapi_transactional-0.1.0/examples/async_fastapi.py +111 -0
- fastapi_transactional-0.1.0/examples/sync_fastapi.py +103 -0
- fastapi_transactional-0.1.0/pyproject.toml +115 -0
- fastapi_transactional-0.1.0/src/fastapi_transactional/__init__.py +53 -0
- fastapi_transactional-0.1.0/src/fastapi_transactional/_config.py +76 -0
- fastapi_transactional-0.1.0/src/fastapi_transactional/async_session_context.py +28 -0
- fastapi_transactional-0.1.0/src/fastapi_transactional/async_session_manager.py +77 -0
- fastapi_transactional-0.1.0/src/fastapi_transactional/py.typed +0 -0
- fastapi_transactional-0.1.0/src/fastapi_transactional/repository.py +33 -0
- fastapi_transactional-0.1.0/src/fastapi_transactional/session_context.py +29 -0
- fastapi_transactional-0.1.0/src/fastapi_transactional/session_manager.py +83 -0
- fastapi_transactional-0.1.0/tests/__init__.py +0 -0
- fastapi_transactional-0.1.0/tests/conftest.py +94 -0
- fastapi_transactional-0.1.0/tests/test_async_session_context.py +25 -0
- fastapi_transactional-0.1.0/tests/test_async_session_manager.py +121 -0
- fastapi_transactional-0.1.0/tests/test_config.py +52 -0
- fastapi_transactional-0.1.0/tests/test_integration.py +195 -0
- fastapi_transactional-0.1.0/tests/test_repository.py +39 -0
- fastapi_transactional-0.1.0/tests/test_session_context.py +35 -0
- fastapi_transactional-0.1.0/tests/test_session_manager.py +123 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Byte-compiled / optimized
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
dist/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
*.egg
|
|
15
|
+
MANIFEST
|
|
16
|
+
|
|
17
|
+
# Installer logs
|
|
18
|
+
pip-log.txt
|
|
19
|
+
pip-delete-this-directory.txt
|
|
20
|
+
|
|
21
|
+
# Unit test / coverage
|
|
22
|
+
.tox/
|
|
23
|
+
.nox/
|
|
24
|
+
.coverage
|
|
25
|
+
.coverage.*
|
|
26
|
+
.cache
|
|
27
|
+
htmlcov/
|
|
28
|
+
coverage.xml
|
|
29
|
+
*.cover
|
|
30
|
+
*.py,cover
|
|
31
|
+
.hypothesis/
|
|
32
|
+
.pytest_cache/
|
|
33
|
+
|
|
34
|
+
# mypy / ruff
|
|
35
|
+
.mypy_cache/
|
|
36
|
+
.ruff_cache/
|
|
37
|
+
|
|
38
|
+
# Environments
|
|
39
|
+
.env
|
|
40
|
+
.venv
|
|
41
|
+
env/
|
|
42
|
+
venv/
|
|
43
|
+
ENV/
|
|
44
|
+
|
|
45
|
+
# IDE
|
|
46
|
+
.vscode/
|
|
47
|
+
.idea/
|
|
48
|
+
*.swp
|
|
49
|
+
*.swo
|
|
50
|
+
.DS_Store
|
|
51
|
+
|
|
52
|
+
# uv
|
|
53
|
+
uv.lock
|
|
54
|
+
|
|
55
|
+
# Local
|
|
56
|
+
*.local
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] — 2026-05-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial public release.
|
|
15
|
+
- Sync transactional API: `session_manager`, `with_transaction`, `DatabaseRepository`.
|
|
16
|
+
- Async transactional API: `async_session_manager`, `with_async_transaction`, `AsyncDatabaseRepository`.
|
|
17
|
+
- `ContextVar`-based session storage with `get/set/clear_current_session` and the async equivalents.
|
|
18
|
+
- `configure(session_factory=..., async_session_factory=...)` to wire SQLAlchemy session factories at startup.
|
|
19
|
+
- Nested-transaction support: nested use cases reuse the outermost session.
|
|
20
|
+
- PEP 561 type information (`py.typed`).
|
|
21
|
+
|
|
22
|
+
[Unreleased]: https://github.com/oviladrosa/fastapi-transactional/compare/v0.1.0...HEAD
|
|
23
|
+
[0.1.0]: https://github.com/oviladrosa/fastapi-transactional/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 uri_vg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-transactional
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transactional session management for SQLAlchemy use cases (sync + async).
|
|
5
|
+
Project-URL: Homepage, https://github.com/oviladrosa/fastapi-transactional
|
|
6
|
+
Project-URL: Repository, https://github.com/oviladrosa/fastapi-transactional
|
|
7
|
+
Project-URL: Issues, https://github.com/oviladrosa/fastapi-transactional/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/oviladrosa/fastapi-transactional/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Oriol Viladrosa <oviladrosa@gmail.com>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 uri_vg
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: async,context-manager,decorator,fastapi,hexagonal-architecture,sqlalchemy,transactions
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Framework :: AsyncIO
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Topic :: Database
|
|
44
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
45
|
+
Classifier: Typing :: Typed
|
|
46
|
+
Requires-Python: >=3.10
|
|
47
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: aiosqlite>=0.19; extra == 'dev'
|
|
50
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
51
|
+
Requires-Dist: greenlet>=3; extra == 'dev'
|
|
52
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
53
|
+
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
54
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
55
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
56
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
57
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
58
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
59
|
+
Provides-Extra: test
|
|
60
|
+
Requires-Dist: aiosqlite>=0.19; extra == 'test'
|
|
61
|
+
Requires-Dist: greenlet>=3; extra == 'test'
|
|
62
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
63
|
+
Requires-Dist: pytest-cov>=4; extra == 'test'
|
|
64
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
65
|
+
Description-Content-Type: text/markdown
|
|
66
|
+
|
|
67
|
+
# fastapi-transactional
|
|
68
|
+
|
|
69
|
+
[](https://pypi.org/project/fastapi-transactional/)
|
|
70
|
+
[](https://pypi.org/project/fastapi-transactional/)
|
|
71
|
+
[](LICENSE)
|
|
72
|
+
[](https://github.com/oviladrosa/fastapi-transactional/actions/workflows/ci.yml)
|
|
73
|
+
|
|
74
|
+
Transactional session management for SQLAlchemy use cases — sync and async. Built for FastAPI and other apps that follow hexagonal / clean architecture, where a single use case orchestrates multiple repositories and they all need to share one transaction.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
@with_transaction
|
|
78
|
+
def execute(self, policy_id: str) -> None:
|
|
79
|
+
policy = self.policy_repo.find_by_id(policy_id)
|
|
80
|
+
policy.status = "ACTIVE"
|
|
81
|
+
self.policy_repo.save(policy)
|
|
82
|
+
self.notification_repo.save(Notification(policy_id=policy_id))
|
|
83
|
+
# Commit on success. Rollback on any exception. Both repos share the same session.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Why
|
|
87
|
+
|
|
88
|
+
In a hexagonal architecture, a use case typically depends on multiple repositories:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
class ActivatePolicyUseCase:
|
|
92
|
+
def __init__(self):
|
|
93
|
+
self.policy_repo = PolicyRepository()
|
|
94
|
+
self.notification_repo = NotificationRepository()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If each repository opens its own session, you lose transactional guarantees — a failure in `notification_repo.save()` won't undo the change in `policy_repo.save()`. The usual fix is to pass a session around explicitly, which leaks infrastructure into the domain layer.
|
|
98
|
+
|
|
99
|
+
`fastapi-transactional` solves this with a `ContextVar`-based session and a one-line decorator. Every repository inheriting from `DatabaseRepository` automatically receives the active session before the use case runs.
|
|
100
|
+
|
|
101
|
+
## Install
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install fastapi-transactional
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Requires Python 3.10+ and SQLAlchemy 2.0+.
|
|
108
|
+
|
|
109
|
+
## Quickstart — sync
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from sqlalchemy import create_engine
|
|
113
|
+
from sqlalchemy.orm import sessionmaker
|
|
114
|
+
from fastapi_transactional import (
|
|
115
|
+
DatabaseRepository, configure, with_transaction,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# 1. Configure once at startup.
|
|
119
|
+
engine = create_engine("postgresql+psycopg://user:pass@host/db")
|
|
120
|
+
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
|
121
|
+
configure(session_factory=SessionLocal)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# 2. Inherit from DatabaseRepository — `self.db` is injected automatically.
|
|
125
|
+
class PolicyRepository(DatabaseRepository):
|
|
126
|
+
def find_by_id(self, policy_id):
|
|
127
|
+
return self.db.get(Policy, policy_id)
|
|
128
|
+
|
|
129
|
+
def save(self, policy):
|
|
130
|
+
self.db.add(policy)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# 3. Decorate the use case method.
|
|
134
|
+
class ActivatePolicyUseCase:
|
|
135
|
+
def __init__(self):
|
|
136
|
+
self.policy_repo = PolicyRepository()
|
|
137
|
+
|
|
138
|
+
@with_transaction
|
|
139
|
+
def execute(self, policy_id: str) -> None:
|
|
140
|
+
policy = self.policy_repo.find_by_id(policy_id)
|
|
141
|
+
policy.status = "ACTIVE"
|
|
142
|
+
self.policy_repo.save(policy)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Quickstart — async
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
149
|
+
from fastapi_transactional import (
|
|
150
|
+
AsyncDatabaseRepository, configure, with_async_transaction,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@host/db")
|
|
154
|
+
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
155
|
+
configure(async_session_factory=AsyncSessionLocal)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class PolicyRepository(AsyncDatabaseRepository):
|
|
159
|
+
async def find_by_id(self, policy_id):
|
|
160
|
+
return await self.db.get(Policy, policy_id)
|
|
161
|
+
|
|
162
|
+
async def save(self, policy):
|
|
163
|
+
self.db.add(policy)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ActivatePolicyUseCase:
|
|
167
|
+
def __init__(self):
|
|
168
|
+
self.policy_repo = PolicyRepository()
|
|
169
|
+
|
|
170
|
+
@with_async_transaction
|
|
171
|
+
async def execute(self, policy_id: str) -> None:
|
|
172
|
+
policy = await self.policy_repo.find_by_id(policy_id)
|
|
173
|
+
policy.status = "ACTIVE"
|
|
174
|
+
await self.policy_repo.save(policy)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
You can configure both sync and async factories at the same time — they live in separate `ContextVar`s and don't interfere.
|
|
178
|
+
|
|
179
|
+
## How it works
|
|
180
|
+
|
|
181
|
+
1. The decorator opens a `session_manager()` (sync) or `async_session_manager()` (async) context.
|
|
182
|
+
2. The manager creates a session from the factory you registered with `configure(...)` and stores it in a `ContextVar`.
|
|
183
|
+
3. The decorator scans `self` for attributes that are `DatabaseRepository` / `AsyncDatabaseRepository` instances and sets their `.db` to the active session.
|
|
184
|
+
4. Your method runs. On clean exit the session commits; on any exception it rolls back.
|
|
185
|
+
5. The session is always closed and removed from the `ContextVar`.
|
|
186
|
+
|
|
187
|
+
`ContextVar` is thread-safe and asyncio-safe — each request handler gets its own slot, so concurrent requests never see each other's sessions.
|
|
188
|
+
|
|
189
|
+
## Nested use cases
|
|
190
|
+
|
|
191
|
+
A use case can call another use case without worrying about double transactions:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
class Outer:
|
|
195
|
+
def __init__(self):
|
|
196
|
+
self.inner = Inner()
|
|
197
|
+
|
|
198
|
+
@with_transaction
|
|
199
|
+
def execute(self):
|
|
200
|
+
self.inner.execute() # Reuses the outer session.
|
|
201
|
+
self.repo.save(thing) # Same transaction.
|
|
202
|
+
# Single commit when execute() returns.
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
When `session_manager()` sees an existing session bound to the context, it yields that session instead of opening a new one — so the outermost call owns the transaction lifecycle.
|
|
206
|
+
|
|
207
|
+
## Advanced — accessing the session directly
|
|
208
|
+
|
|
209
|
+
For code that isn't a repository (e.g. a service that needs to run a raw query inside the active transaction):
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from fastapi_transactional import get_current_session
|
|
213
|
+
|
|
214
|
+
def some_service():
|
|
215
|
+
session = get_current_session()
|
|
216
|
+
if session is None:
|
|
217
|
+
raise RuntimeError("Must be called inside a transactional scope")
|
|
218
|
+
session.execute(...)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The async equivalents are `get_current_async_session`, `set_current_async_session`, `clear_current_async_session`.
|
|
222
|
+
|
|
223
|
+
You can also use the context manager directly without the decorator:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
with session_manager() as session:
|
|
227
|
+
session.execute(...)
|
|
228
|
+
# Commit happens here. Rollback on exception.
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## API reference
|
|
232
|
+
|
|
233
|
+
| Symbol | Purpose |
|
|
234
|
+
|---|---|
|
|
235
|
+
| `configure(session_factory=..., async_session_factory=...)` | Register session factories (call once at startup). |
|
|
236
|
+
| `reset()` | Clear the registered factories (useful in tests). |
|
|
237
|
+
| `session_manager()` / `async_session_manager()` | Context manager that opens a transactional scope. |
|
|
238
|
+
| `with_transaction` / `with_async_transaction` | Decorator wrapping a method in a transactional scope and injecting sessions into repositories. |
|
|
239
|
+
| `DatabaseRepository` / `AsyncDatabaseRepository` | Base class — gives the repo a `self.db` slot. |
|
|
240
|
+
| `get_current_session` / `set_current_session` / `clear_current_session` | Direct access to the sync ContextVar. |
|
|
241
|
+
| `get_current_async_session` / `set_current_async_session` / `clear_current_async_session` | Direct access to the async ContextVar. |
|
|
242
|
+
|
|
243
|
+
## Tests
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
pip install -e ".[test]"
|
|
247
|
+
pytest --cov=fastapi_transactional
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Contributing
|
|
251
|
+
|
|
252
|
+
Issues and pull requests welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# fastapi-transactional
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/fastapi-transactional/)
|
|
4
|
+
[](https://pypi.org/project/fastapi-transactional/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/oviladrosa/fastapi-transactional/actions/workflows/ci.yml)
|
|
7
|
+
|
|
8
|
+
Transactional session management for SQLAlchemy use cases — sync and async. Built for FastAPI and other apps that follow hexagonal / clean architecture, where a single use case orchestrates multiple repositories and they all need to share one transaction.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
@with_transaction
|
|
12
|
+
def execute(self, policy_id: str) -> None:
|
|
13
|
+
policy = self.policy_repo.find_by_id(policy_id)
|
|
14
|
+
policy.status = "ACTIVE"
|
|
15
|
+
self.policy_repo.save(policy)
|
|
16
|
+
self.notification_repo.save(Notification(policy_id=policy_id))
|
|
17
|
+
# Commit on success. Rollback on any exception. Both repos share the same session.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Why
|
|
21
|
+
|
|
22
|
+
In a hexagonal architecture, a use case typically depends on multiple repositories:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
class ActivatePolicyUseCase:
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self.policy_repo = PolicyRepository()
|
|
28
|
+
self.notification_repo = NotificationRepository()
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If each repository opens its own session, you lose transactional guarantees — a failure in `notification_repo.save()` won't undo the change in `policy_repo.save()`. The usual fix is to pass a session around explicitly, which leaks infrastructure into the domain layer.
|
|
32
|
+
|
|
33
|
+
`fastapi-transactional` solves this with a `ContextVar`-based session and a one-line decorator. Every repository inheriting from `DatabaseRepository` automatically receives the active session before the use case runs.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install fastapi-transactional
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python 3.10+ and SQLAlchemy 2.0+.
|
|
42
|
+
|
|
43
|
+
## Quickstart — sync
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from sqlalchemy import create_engine
|
|
47
|
+
from sqlalchemy.orm import sessionmaker
|
|
48
|
+
from fastapi_transactional import (
|
|
49
|
+
DatabaseRepository, configure, with_transaction,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# 1. Configure once at startup.
|
|
53
|
+
engine = create_engine("postgresql+psycopg://user:pass@host/db")
|
|
54
|
+
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
|
55
|
+
configure(session_factory=SessionLocal)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# 2. Inherit from DatabaseRepository — `self.db` is injected automatically.
|
|
59
|
+
class PolicyRepository(DatabaseRepository):
|
|
60
|
+
def find_by_id(self, policy_id):
|
|
61
|
+
return self.db.get(Policy, policy_id)
|
|
62
|
+
|
|
63
|
+
def save(self, policy):
|
|
64
|
+
self.db.add(policy)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# 3. Decorate the use case method.
|
|
68
|
+
class ActivatePolicyUseCase:
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self.policy_repo = PolicyRepository()
|
|
71
|
+
|
|
72
|
+
@with_transaction
|
|
73
|
+
def execute(self, policy_id: str) -> None:
|
|
74
|
+
policy = self.policy_repo.find_by_id(policy_id)
|
|
75
|
+
policy.status = "ACTIVE"
|
|
76
|
+
self.policy_repo.save(policy)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Quickstart — async
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
83
|
+
from fastapi_transactional import (
|
|
84
|
+
AsyncDatabaseRepository, configure, with_async_transaction,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@host/db")
|
|
88
|
+
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
89
|
+
configure(async_session_factory=AsyncSessionLocal)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PolicyRepository(AsyncDatabaseRepository):
|
|
93
|
+
async def find_by_id(self, policy_id):
|
|
94
|
+
return await self.db.get(Policy, policy_id)
|
|
95
|
+
|
|
96
|
+
async def save(self, policy):
|
|
97
|
+
self.db.add(policy)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ActivatePolicyUseCase:
|
|
101
|
+
def __init__(self):
|
|
102
|
+
self.policy_repo = PolicyRepository()
|
|
103
|
+
|
|
104
|
+
@with_async_transaction
|
|
105
|
+
async def execute(self, policy_id: str) -> None:
|
|
106
|
+
policy = await self.policy_repo.find_by_id(policy_id)
|
|
107
|
+
policy.status = "ACTIVE"
|
|
108
|
+
await self.policy_repo.save(policy)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
You can configure both sync and async factories at the same time — they live in separate `ContextVar`s and don't interfere.
|
|
112
|
+
|
|
113
|
+
## How it works
|
|
114
|
+
|
|
115
|
+
1. The decorator opens a `session_manager()` (sync) or `async_session_manager()` (async) context.
|
|
116
|
+
2. The manager creates a session from the factory you registered with `configure(...)` and stores it in a `ContextVar`.
|
|
117
|
+
3. The decorator scans `self` for attributes that are `DatabaseRepository` / `AsyncDatabaseRepository` instances and sets their `.db` to the active session.
|
|
118
|
+
4. Your method runs. On clean exit the session commits; on any exception it rolls back.
|
|
119
|
+
5. The session is always closed and removed from the `ContextVar`.
|
|
120
|
+
|
|
121
|
+
`ContextVar` is thread-safe and asyncio-safe — each request handler gets its own slot, so concurrent requests never see each other's sessions.
|
|
122
|
+
|
|
123
|
+
## Nested use cases
|
|
124
|
+
|
|
125
|
+
A use case can call another use case without worrying about double transactions:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
class Outer:
|
|
129
|
+
def __init__(self):
|
|
130
|
+
self.inner = Inner()
|
|
131
|
+
|
|
132
|
+
@with_transaction
|
|
133
|
+
def execute(self):
|
|
134
|
+
self.inner.execute() # Reuses the outer session.
|
|
135
|
+
self.repo.save(thing) # Same transaction.
|
|
136
|
+
# Single commit when execute() returns.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
When `session_manager()` sees an existing session bound to the context, it yields that session instead of opening a new one — so the outermost call owns the transaction lifecycle.
|
|
140
|
+
|
|
141
|
+
## Advanced — accessing the session directly
|
|
142
|
+
|
|
143
|
+
For code that isn't a repository (e.g. a service that needs to run a raw query inside the active transaction):
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from fastapi_transactional import get_current_session
|
|
147
|
+
|
|
148
|
+
def some_service():
|
|
149
|
+
session = get_current_session()
|
|
150
|
+
if session is None:
|
|
151
|
+
raise RuntimeError("Must be called inside a transactional scope")
|
|
152
|
+
session.execute(...)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The async equivalents are `get_current_async_session`, `set_current_async_session`, `clear_current_async_session`.
|
|
156
|
+
|
|
157
|
+
You can also use the context manager directly without the decorator:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
with session_manager() as session:
|
|
161
|
+
session.execute(...)
|
|
162
|
+
# Commit happens here. Rollback on exception.
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## API reference
|
|
166
|
+
|
|
167
|
+
| Symbol | Purpose |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `configure(session_factory=..., async_session_factory=...)` | Register session factories (call once at startup). |
|
|
170
|
+
| `reset()` | Clear the registered factories (useful in tests). |
|
|
171
|
+
| `session_manager()` / `async_session_manager()` | Context manager that opens a transactional scope. |
|
|
172
|
+
| `with_transaction` / `with_async_transaction` | Decorator wrapping a method in a transactional scope and injecting sessions into repositories. |
|
|
173
|
+
| `DatabaseRepository` / `AsyncDatabaseRepository` | Base class — gives the repo a `self.db` slot. |
|
|
174
|
+
| `get_current_session` / `set_current_session` / `clear_current_session` | Direct access to the sync ContextVar. |
|
|
175
|
+
| `get_current_async_session` / `set_current_async_session` / `clear_current_async_session` | Direct access to the async ContextVar. |
|
|
176
|
+
|
|
177
|
+
## Tests
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pip install -e ".[test]"
|
|
181
|
+
pytest --cov=fastapi_transactional
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Contributing
|
|
185
|
+
|
|
186
|
+
Issues and pull requests welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
Runnable mini-apps showing `fastapi-transactional` end to end.
|
|
4
|
+
|
|
5
|
+
## Sync — `sync_fastapi.py`
|
|
6
|
+
|
|
7
|
+
A FastAPI app backed by SQLite that creates `Widget` rows inside a transaction.
|
|
8
|
+
The `/widgets/fail` endpoint demonstrates that an exception rolls back the
|
|
9
|
+
whole transaction even after a successful `repo.save()` call.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install fastapi uvicorn fastapi-transactional
|
|
13
|
+
python examples/sync_fastapi.py
|
|
14
|
+
# Then in another terminal:
|
|
15
|
+
curl -X POST localhost:8000/widgets -d '{"name":"alpha"}' -H content-type:application/json
|
|
16
|
+
curl -X POST localhost:8000/widgets/fail -d '{"name":"ghost"}' -H content-type:application/json
|
|
17
|
+
curl localhost:8000/widgets
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Async — `async_fastapi.py`
|
|
21
|
+
|
|
22
|
+
Same app, async stack (`AsyncSession` + aiosqlite).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install fastapi uvicorn aiosqlite fastapi-transactional
|
|
26
|
+
python examples/async_fastapi.py
|
|
27
|
+
```
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""FastAPI app using fastapi-transactional with an async SQLAlchemy session.
|
|
2
|
+
|
|
3
|
+
Run with:
|
|
4
|
+
pip install fastapi uvicorn aiosqlite fastapi-transactional
|
|
5
|
+
python examples/async_fastapi.py
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
from sqlalchemy import Column, Integer, String, select
|
|
15
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
17
|
+
|
|
18
|
+
from fastapi_transactional import (
|
|
19
|
+
AsyncDatabaseRepository,
|
|
20
|
+
async_session_manager,
|
|
21
|
+
configure,
|
|
22
|
+
with_async_transaction,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Base(DeclarativeBase):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Widget(Base):
|
|
31
|
+
__tablename__ = "widgets"
|
|
32
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
33
|
+
name = Column(String, nullable=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
engine = create_async_engine("sqlite+aiosqlite:///./widgets_async.db")
|
|
37
|
+
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
38
|
+
configure(async_session_factory=AsyncSessionLocal)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WidgetRepository(AsyncDatabaseRepository):
|
|
42
|
+
async def save(self, widget: Widget) -> None:
|
|
43
|
+
assert self.db is not None
|
|
44
|
+
self.db.add(widget)
|
|
45
|
+
|
|
46
|
+
async def list_all(self) -> list[Widget]:
|
|
47
|
+
assert self.db is not None
|
|
48
|
+
result = await self.db.scalars(select(Widget))
|
|
49
|
+
return list(result.all())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CreateWidget:
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self.repo = WidgetRepository()
|
|
55
|
+
|
|
56
|
+
@with_async_transaction
|
|
57
|
+
async def execute(self, name: str) -> None:
|
|
58
|
+
await self.repo.save(Widget(name=name))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CreateWidgetThenFail:
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self.repo = WidgetRepository()
|
|
64
|
+
|
|
65
|
+
@with_async_transaction
|
|
66
|
+
async def execute(self, name: str) -> None:
|
|
67
|
+
await self.repo.save(Widget(name=name))
|
|
68
|
+
raise RuntimeError("intentional failure")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def lifespan(app: FastAPI):
|
|
73
|
+
async with engine.begin() as conn:
|
|
74
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
75
|
+
yield
|
|
76
|
+
await engine.dispose()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
app = FastAPI(lifespan=lifespan)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WidgetIn(BaseModel):
|
|
83
|
+
name: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.post("/widgets")
|
|
87
|
+
async def create(payload: WidgetIn) -> dict[str, str]:
|
|
88
|
+
await CreateWidget().execute(payload.name)
|
|
89
|
+
return {"status": "ok"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.post("/widgets/fail")
|
|
93
|
+
async def create_and_fail(payload: WidgetIn) -> dict[str, str]:
|
|
94
|
+
try:
|
|
95
|
+
await CreateWidgetThenFail().execute(payload.name)
|
|
96
|
+
except RuntimeError as exc:
|
|
97
|
+
return {"status": "rolled-back", "error": str(exc)}
|
|
98
|
+
return {"status": "ok"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.get("/widgets")
|
|
102
|
+
async def list_widgets() -> list[dict[str, object]]:
|
|
103
|
+
async with async_session_manager() as session:
|
|
104
|
+
result = await session.scalars(select(Widget))
|
|
105
|
+
return [{"id": w.id, "name": w.name} for w in result.all()]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
import uvicorn
|
|
110
|
+
|
|
111
|
+
uvicorn.run(app, host="127.0.0.1", port=8000)
|