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.
- fastapi_transactional/__init__.py +53 -0
- fastapi_transactional/_config.py +76 -0
- fastapi_transactional/async_session_context.py +28 -0
- fastapi_transactional/async_session_manager.py +77 -0
- fastapi_transactional/py.typed +0 -0
- fastapi_transactional/repository.py +33 -0
- fastapi_transactional/session_context.py +29 -0
- fastapi_transactional/session_manager.py +83 -0
- fastapi_transactional-0.1.0.dist-info/METADATA +256 -0
- fastapi_transactional-0.1.0.dist-info/RECORD +12 -0
- fastapi_transactional-0.1.0.dist-info/WHEEL +4 -0
- fastapi_transactional-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
[](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,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,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.
|