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.
Files changed (26) hide show
  1. fastapi_transactional-0.1.0/.gitignore +56 -0
  2. fastapi_transactional-0.1.0/CHANGELOG.md +23 -0
  3. fastapi_transactional-0.1.0/LICENSE +21 -0
  4. fastapi_transactional-0.1.0/PKG-INFO +256 -0
  5. fastapi_transactional-0.1.0/README.md +190 -0
  6. fastapi_transactional-0.1.0/examples/README.md +27 -0
  7. fastapi_transactional-0.1.0/examples/async_fastapi.py +111 -0
  8. fastapi_transactional-0.1.0/examples/sync_fastapi.py +103 -0
  9. fastapi_transactional-0.1.0/pyproject.toml +115 -0
  10. fastapi_transactional-0.1.0/src/fastapi_transactional/__init__.py +53 -0
  11. fastapi_transactional-0.1.0/src/fastapi_transactional/_config.py +76 -0
  12. fastapi_transactional-0.1.0/src/fastapi_transactional/async_session_context.py +28 -0
  13. fastapi_transactional-0.1.0/src/fastapi_transactional/async_session_manager.py +77 -0
  14. fastapi_transactional-0.1.0/src/fastapi_transactional/py.typed +0 -0
  15. fastapi_transactional-0.1.0/src/fastapi_transactional/repository.py +33 -0
  16. fastapi_transactional-0.1.0/src/fastapi_transactional/session_context.py +29 -0
  17. fastapi_transactional-0.1.0/src/fastapi_transactional/session_manager.py +83 -0
  18. fastapi_transactional-0.1.0/tests/__init__.py +0 -0
  19. fastapi_transactional-0.1.0/tests/conftest.py +94 -0
  20. fastapi_transactional-0.1.0/tests/test_async_session_context.py +25 -0
  21. fastapi_transactional-0.1.0/tests/test_async_session_manager.py +121 -0
  22. fastapi_transactional-0.1.0/tests/test_config.py +52 -0
  23. fastapi_transactional-0.1.0/tests/test_integration.py +195 -0
  24. fastapi_transactional-0.1.0/tests/test_repository.py +39 -0
  25. fastapi_transactional-0.1.0/tests/test_session_context.py +35 -0
  26. 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
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-transactional.svg)](https://pypi.org/project/fastapi-transactional/)
70
+ [![Python versions](https://img.shields.io/pypi/pyversions/fastapi-transactional.svg)](https://pypi.org/project/fastapi-transactional/)
71
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
72
+ [![CI](https://github.com/oviladrosa/fastapi-transactional/actions/workflows/ci.yml/badge.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-transactional.svg)](https://pypi.org/project/fastapi-transactional/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/fastapi-transactional.svg)](https://pypi.org/project/fastapi-transactional/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+ [![CI](https://github.com/oviladrosa/fastapi-transactional/actions/workflows/ci.yml/badge.svg)](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)