fastapi-transactional 0.1.0__py3-none-any.whl

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,53 @@
1
+ """fastapi-transactional — transactional session management for SQLAlchemy.
2
+
3
+ Public API:
4
+ Configuration
5
+ configure, reset
6
+
7
+ Sync
8
+ session_manager, with_transaction
9
+ get_current_session, set_current_session, clear_current_session
10
+ DatabaseRepository
11
+
12
+ Async
13
+ async_session_manager, with_async_transaction
14
+ get_current_async_session, set_current_async_session, clear_current_async_session
15
+ AsyncDatabaseRepository
16
+ """
17
+
18
+ from ._config import configure, reset
19
+ from .async_session_context import (
20
+ clear_current_async_session,
21
+ get_current_async_session,
22
+ set_current_async_session,
23
+ )
24
+ from .async_session_manager import async_session_manager, with_async_transaction
25
+ from .repository import AsyncDatabaseRepository, DatabaseRepository
26
+ from .session_context import (
27
+ clear_current_session,
28
+ get_current_session,
29
+ set_current_session,
30
+ )
31
+ from .session_manager import session_manager, with_transaction
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "__version__",
37
+ "configure",
38
+ "reset",
39
+ # Sync
40
+ "session_manager",
41
+ "with_transaction",
42
+ "get_current_session",
43
+ "set_current_session",
44
+ "clear_current_session",
45
+ "DatabaseRepository",
46
+ # Async
47
+ "async_session_manager",
48
+ "with_async_transaction",
49
+ "get_current_async_session",
50
+ "set_current_async_session",
51
+ "clear_current_async_session",
52
+ "AsyncDatabaseRepository",
53
+ ]
@@ -0,0 +1,76 @@
1
+ """Module-level configuration for session factories.
2
+
3
+ Users call ``configure()`` once at application startup to wire in the
4
+ SQLAlchemy session factory the rest of the library should use. The library
5
+ keeps no global state beyond what is set here, and configuration can be
6
+ reset (useful in tests).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
15
+ from sqlalchemy.orm import Session, sessionmaker
16
+
17
+
18
+ class _Config:
19
+ __slots__ = ("session_factory", "async_session_factory")
20
+
21
+ def __init__(self) -> None:
22
+ self.session_factory: sessionmaker[Session] | None = None
23
+ self.async_session_factory: async_sessionmaker[AsyncSession] | None = None
24
+
25
+
26
+ _config = _Config()
27
+
28
+
29
+ def configure(
30
+ *,
31
+ session_factory: sessionmaker[Session] | None = None,
32
+ async_session_factory: async_sessionmaker[AsyncSession] | None = None,
33
+ ) -> None:
34
+ """Register the session factory (sync, async, or both).
35
+
36
+ Call this once at startup, after creating your SQLAlchemy engine(s).
37
+
38
+ Example:
39
+ from sqlalchemy import create_engine
40
+ from sqlalchemy.orm import sessionmaker
41
+ from fastapi_transactional import configure
42
+
43
+ engine = create_engine("postgresql://...")
44
+ SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
45
+ configure(session_factory=SessionLocal)
46
+ """
47
+ if session_factory is None and async_session_factory is None:
48
+ raise ValueError("configure() requires at least one of session_factory or async_session_factory.")
49
+ if session_factory is not None:
50
+ _config.session_factory = session_factory
51
+ if async_session_factory is not None:
52
+ _config.async_session_factory = async_session_factory
53
+
54
+
55
+ def reset() -> None:
56
+ """Clear the registered session factories. Mostly useful in tests."""
57
+ _config.session_factory = None
58
+ _config.async_session_factory = None
59
+
60
+
61
+ def _get_session_factory() -> sessionmaker[Session]:
62
+ if _config.session_factory is None:
63
+ raise RuntimeError(
64
+ "fastapi_transactional is not configured for sync sessions. "
65
+ "Call configure(session_factory=...) at application startup."
66
+ )
67
+ return _config.session_factory
68
+
69
+
70
+ def _get_async_session_factory() -> async_sessionmaker[AsyncSession]:
71
+ if _config.async_session_factory is None:
72
+ raise RuntimeError(
73
+ "fastapi_transactional is not configured for async sessions. "
74
+ "Call configure(async_session_factory=...) at application startup."
75
+ )
76
+ return _config.async_session_factory
@@ -0,0 +1,28 @@
1
+ """Per-context storage for the active async SQLAlchemy session.
2
+
3
+ Separate ``ContextVar`` from the sync one so that an application using both
4
+ sync and async sessions never accidentally cross-wires them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextvars import ContextVar
10
+
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ _current_async_session: ContextVar[AsyncSession | None] = ContextVar("current_async_session", default=None)
14
+
15
+
16
+ def get_current_async_session() -> AsyncSession | None:
17
+ """Return the async session bound to the current context, or ``None``."""
18
+ return _current_async_session.get()
19
+
20
+
21
+ def set_current_async_session(session: AsyncSession) -> None:
22
+ """Bind ``session`` to the current context."""
23
+ _current_async_session.set(session)
24
+
25
+
26
+ def clear_current_async_session() -> None:
27
+ """Remove any async session bound to the current context."""
28
+ _current_async_session.set(None)
@@ -0,0 +1,77 @@
1
+ """Async transactional scope: ``async_session_manager`` + ``@with_async_transaction``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Awaitable, Callable
6
+ from contextlib import asynccontextmanager
7
+ from functools import wraps
8
+ from typing import Any, TypeVar
9
+
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from ._config import _get_async_session_factory
13
+ from .async_session_context import (
14
+ clear_current_async_session,
15
+ get_current_async_session,
16
+ set_current_async_session,
17
+ )
18
+ from .repository import AsyncDatabaseRepository
19
+
20
+ F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
21
+
22
+
23
+ @asynccontextmanager
24
+ async def async_session_manager() -> AsyncIterator[AsyncSession]:
25
+ """Async counterpart to :func:`session_manager`.
26
+
27
+ Yields an :class:`AsyncSession`, commits on clean exit, rolls back on
28
+ exception, and clears the context on the way out. Nested calls reuse the
29
+ outer session.
30
+ """
31
+ existing_session = get_current_async_session()
32
+ if existing_session is not None:
33
+ yield existing_session
34
+ return
35
+
36
+ session_factory = _get_async_session_factory()
37
+ session: AsyncSession = session_factory()
38
+ try:
39
+ set_current_async_session(session)
40
+ yield session
41
+ await session.commit()
42
+ except Exception:
43
+ await session.rollback()
44
+ raise
45
+ finally:
46
+ clear_current_async_session()
47
+ await session.close()
48
+
49
+
50
+ def _inject_async_session_into_instance(instance: Any, session: AsyncSession) -> None:
51
+ """Set ``session`` on every :class:`AsyncDatabaseRepository` attribute of ``instance``."""
52
+ for attr_name in dir(instance):
53
+ if attr_name.startswith("__"):
54
+ continue
55
+ try:
56
+ attr = getattr(instance, attr_name)
57
+ except AttributeError:
58
+ continue
59
+ if isinstance(attr, AsyncDatabaseRepository):
60
+ attr.set_session(session)
61
+
62
+
63
+ def with_async_transaction(func: F) -> F:
64
+ """Async counterpart to :func:`with_transaction`.
65
+
66
+ Wrap a coroutine method so it runs inside :func:`async_session_manager`,
67
+ injecting the active session into every :class:`AsyncDatabaseRepository`
68
+ attribute of ``self`` before the method body runs.
69
+ """
70
+
71
+ @wraps(func)
72
+ async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
73
+ async with async_session_manager() as session:
74
+ _inject_async_session_into_instance(self, session)
75
+ return await func(self, *args, **kwargs)
76
+
77
+ return wrapper # type: ignore[return-value]
File without changes
@@ -0,0 +1,33 @@
1
+ """Base classes for repositories that participate in a managed transaction.
2
+
3
+ A repository inheriting from :class:`DatabaseRepository` (sync) or
4
+ :class:`AsyncDatabaseRepository` (async) exposes a ``self.db`` session
5
+ slot. When the repository instance is an attribute of an object whose
6
+ method is decorated with ``@with_transaction`` / ``@with_async_transaction``,
7
+ the active session is injected into ``self.db`` for the duration of the call.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+ from sqlalchemy.orm import Session
14
+
15
+
16
+ class DatabaseRepository:
17
+ """Base class for sync repositories backed by a SQLAlchemy session."""
18
+
19
+ def __init__(self) -> None:
20
+ self.db: Session | None = None
21
+
22
+ def set_session(self, db: Session) -> None:
23
+ self.db = db
24
+
25
+
26
+ class AsyncDatabaseRepository:
27
+ """Base class for async repositories backed by an AsyncSession."""
28
+
29
+ def __init__(self) -> None:
30
+ self.db: AsyncSession | None = None
31
+
32
+ def set_session(self, db: AsyncSession) -> None:
33
+ self.db = db
@@ -0,0 +1,29 @@
1
+ """Per-context storage for the active sync SQLAlchemy session.
2
+
3
+ Backed by ``ContextVar`` so each thread / asyncio task sees its own value,
4
+ which makes the session implicitly shared across repositories that run
5
+ inside the same transactional scope without having to pass it around.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextvars import ContextVar
11
+
12
+ from sqlalchemy.orm import Session
13
+
14
+ _current_session: ContextVar[Session | None] = ContextVar("current_session", default=None)
15
+
16
+
17
+ def get_current_session() -> Session | None:
18
+ """Return the session bound to the current context, or ``None``."""
19
+ return _current_session.get()
20
+
21
+
22
+ def set_current_session(session: Session) -> None:
23
+ """Bind ``session`` to the current context."""
24
+ _current_session.set(session)
25
+
26
+
27
+ def clear_current_session() -> None:
28
+ """Remove any session bound to the current context."""
29
+ _current_session.set(None)
@@ -0,0 +1,83 @@
1
+ """Sync transactional scope: ``session_manager`` and ``@with_transaction``.
2
+
3
+ Open a new SQLAlchemy session, bind it to the context, commit on clean exit,
4
+ roll back on exception, and clear the context on the way out. Nested calls
5
+ reuse the outer session instead of opening a new one, which is what enables
6
+ composition between use cases.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Iterator
12
+ from contextlib import contextmanager
13
+ from functools import wraps
14
+ from typing import Any, TypeVar
15
+
16
+ from sqlalchemy.orm import Session
17
+
18
+ from ._config import _get_session_factory
19
+ from .repository import DatabaseRepository
20
+ from .session_context import (
21
+ clear_current_session,
22
+ get_current_session,
23
+ set_current_session,
24
+ )
25
+
26
+ F = TypeVar("F", bound=Callable[..., Any])
27
+
28
+
29
+ @contextmanager
30
+ def session_manager() -> Iterator[Session]:
31
+ """Provide a transactional scope around a series of operations.
32
+
33
+ If a session is already bound to the current context (i.e. we are nested
34
+ inside another ``session_manager`` block), that session is yielded as-is
35
+ and the outer block stays responsible for commit / rollback / close.
36
+ """
37
+ existing_session = get_current_session()
38
+ if existing_session is not None:
39
+ yield existing_session
40
+ return
41
+
42
+ session_factory = _get_session_factory()
43
+ session: Session = session_factory()
44
+ try:
45
+ set_current_session(session)
46
+ yield session
47
+ session.commit()
48
+ except Exception:
49
+ session.rollback()
50
+ raise
51
+ finally:
52
+ clear_current_session()
53
+ session.close()
54
+
55
+
56
+ def _inject_session_into_instance(instance: Any, session: Session) -> None:
57
+ """Set ``session`` on every :class:`DatabaseRepository` attribute of ``instance``."""
58
+ for attr_name in dir(instance):
59
+ if attr_name.startswith("__"):
60
+ continue
61
+ try:
62
+ attr = getattr(instance, attr_name)
63
+ except AttributeError:
64
+ continue
65
+ if isinstance(attr, DatabaseRepository):
66
+ attr.set_session(session)
67
+
68
+
69
+ def with_transaction(func: F) -> F:
70
+ """Wrap a method so it runs inside :func:`session_manager`.
71
+
72
+ Before the wrapped method runs, every :class:`DatabaseRepository`
73
+ attribute on ``self`` has its session set to the active one, so
74
+ repositories can do ``self.db.add(...)`` etc. without any plumbing.
75
+ """
76
+
77
+ @wraps(func)
78
+ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
79
+ with session_manager() as session:
80
+ _inject_session_into_instance(self, session)
81
+ return func(self, *args, **kwargs)
82
+
83
+ return wrapper # type: ignore[return-value]
@@ -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,12 @@
1
+ fastapi_transactional/__init__.py,sha256=3O0-4lCF_QPa-kg1naq9k4Ynct3fzoN-1KUl46VYNDo,1428
2
+ fastapi_transactional/_config.py,sha256=YQcG2b8CqptWzm3uodxbWdx3I5SGg33u7TnOAc1OlSc,2653
3
+ fastapi_transactional/async_session_context.py,sha256=FJlJ7jPkMAK_l3WyJYCUNn64HGhdhIdT2fvGIdSybbA,914
4
+ fastapi_transactional/async_session_manager.py,sha256=z58rW-uUdi5Uqsc87Rr2nguFLG8Qzg7mVVeAhFeM_3g,2544
5
+ fastapi_transactional/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ fastapi_transactional/repository.py,sha256=pnjaFjslPv9tH_YQ6P30gKw3sK2UKc-y_ndVk5gUizE,1066
7
+ fastapi_transactional/session_context.py,sha256=aVlBULylyROO6BLoBk9PP6iwor0NRc2TtaqFKAruUfU,904
8
+ fastapi_transactional/session_manager.py,sha256=9ne5TmCXrzBfhYrpfd4o58gkzeG-sNOGCIwOCJsAwNQ,2666
9
+ fastapi_transactional-0.1.0.dist-info/METADATA,sha256=z5woCpjhXRkaF3AD0nvxJwbdAeJgrY658xNku7MzE-8,10441
10
+ fastapi_transactional-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ fastapi_transactional-0.1.0.dist-info/licenses/LICENSE,sha256=KQoUMac4dYeGcakqJzLVWfNcwm_sYN8KcAzv1uHvSfM,1063
12
+ fastapi_transactional-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.