pico-sqlalchemy 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.
- pico_sqlalchemy/__init__.py +20 -0
- pico_sqlalchemy/_version.py +1 -0
- pico_sqlalchemy/base.py +10 -0
- pico_sqlalchemy/config.py +23 -0
- pico_sqlalchemy/decorators.py +89 -0
- pico_sqlalchemy/factory.py +46 -0
- pico_sqlalchemy/interceptor.py +39 -0
- pico_sqlalchemy/session.py +205 -0
- pico_sqlalchemy-0.1.0.dist-info/METADATA +357 -0
- pico_sqlalchemy-0.1.0.dist-info/RECORD +13 -0
- pico_sqlalchemy-0.1.0.dist-info/WHEEL +5 -0
- pico_sqlalchemy-0.1.0.dist-info/licenses/LICENSE +21 -0
- pico_sqlalchemy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .config import DatabaseSettings, DatabaseConfigurer
|
|
2
|
+
from .decorators import transactional, repository
|
|
3
|
+
from .session import SessionManager, get_session
|
|
4
|
+
from .interceptor import TransactionalInterceptor
|
|
5
|
+
from .factory import SqlAlchemyFactory
|
|
6
|
+
from .base import AppBase, Mapped, mapped_column
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DatabaseSettings",
|
|
10
|
+
"DatabaseConfigurer",
|
|
11
|
+
"transactional",
|
|
12
|
+
"repository",
|
|
13
|
+
"SessionManager",
|
|
14
|
+
"get_session",
|
|
15
|
+
"TransactionalInterceptor",
|
|
16
|
+
"SqlAlchemyFactory",
|
|
17
|
+
"AppBase",
|
|
18
|
+
"Mapped",
|
|
19
|
+
"mapped_column",
|
|
20
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
pico_sqlalchemy/base.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Protocol, runtime_checkable
|
|
3
|
+
from pico_ioc import configured
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class DatabaseConfigurer(Protocol):
|
|
8
|
+
@property
|
|
9
|
+
def priority(self) -> int:
|
|
10
|
+
return 0
|
|
11
|
+
|
|
12
|
+
def configure(self, engine) -> None:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@configured(target="self", prefix="database", mapping="tree")
|
|
17
|
+
@dataclass
|
|
18
|
+
class DatabaseSettings:
|
|
19
|
+
url: str = "sqlite:///./app.db"
|
|
20
|
+
echo: bool = False
|
|
21
|
+
pool_size: int = 5
|
|
22
|
+
pool_pre_ping: bool = True
|
|
23
|
+
pool_recycle: int = 3600
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from typing import Any, Callable, Optional, ParamSpec, TypeVar, Set
|
|
2
|
+
from pico_ioc import component
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
from .session import get_default_session_manager
|
|
6
|
+
|
|
7
|
+
P = ParamSpec("P")
|
|
8
|
+
R = TypeVar("R")
|
|
9
|
+
|
|
10
|
+
TRANSACTIONAL_META = "_pico_sqlalchemy_transactional_meta"
|
|
11
|
+
REPOSITORY_META = "_pico_sqlalchemy_repository_meta"
|
|
12
|
+
REPOSITORIES: Set[type] = set()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def transactional(
|
|
16
|
+
*,
|
|
17
|
+
propagation: str = "REQUIRED",
|
|
18
|
+
read_only: bool = False,
|
|
19
|
+
isolation_level: Optional[str] = None,
|
|
20
|
+
rollback_for: tuple[type[BaseException], ...] = (Exception,),
|
|
21
|
+
no_rollback_for: tuple[type[BaseException], ...] = (),
|
|
22
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
23
|
+
valid = {
|
|
24
|
+
"REQUIRED",
|
|
25
|
+
"REQUIRES_NEW",
|
|
26
|
+
"SUPPORTS",
|
|
27
|
+
"MANDATORY",
|
|
28
|
+
"NOT_SUPPORTED",
|
|
29
|
+
"NEVER",
|
|
30
|
+
}
|
|
31
|
+
if propagation not in valid:
|
|
32
|
+
raise ValueError(f"Invalid propagation: {propagation}")
|
|
33
|
+
|
|
34
|
+
metadata = {
|
|
35
|
+
"propagation": propagation,
|
|
36
|
+
"read_only": read_only,
|
|
37
|
+
"isolation_level": isolation_level,
|
|
38
|
+
"rollback_for": rollback_for,
|
|
39
|
+
"no_rollback_for": no_rollback_for,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
43
|
+
setattr(func, TRANSACTIONAL_META, metadata)
|
|
44
|
+
|
|
45
|
+
if inspect.iscoroutinefunction(func):
|
|
46
|
+
|
|
47
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
48
|
+
manager = get_default_session_manager()
|
|
49
|
+
if manager is None:
|
|
50
|
+
return await func(*args, **kwargs)
|
|
51
|
+
async with manager.transaction(
|
|
52
|
+
propagation=propagation,
|
|
53
|
+
read_only=read_only,
|
|
54
|
+
isolation_level=isolation_level,
|
|
55
|
+
rollback_for=rollback_for,
|
|
56
|
+
no_rollback_for=no_rollback_for,
|
|
57
|
+
):
|
|
58
|
+
return await func(*args, **kwargs)
|
|
59
|
+
|
|
60
|
+
setattr(async_wrapper, TRANSACTIONAL_META, metadata)
|
|
61
|
+
return async_wrapper
|
|
62
|
+
|
|
63
|
+
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
64
|
+
raise TypeError(
|
|
65
|
+
f"Cannot apply @transactional to sync function '{func.__name__}' "
|
|
66
|
+
"when using an async SessionManager."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
setattr(sync_wrapper, TRANSACTIONAL_META, metadata)
|
|
70
|
+
return sync_wrapper
|
|
71
|
+
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def repository(
|
|
76
|
+
cls: Optional[type[Any]] = None,
|
|
77
|
+
*,
|
|
78
|
+
scope: str = "singleton",
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> Callable[[type[Any]], type[Any]] | type[Any]:
|
|
81
|
+
def decorate(c: type[Any]) -> type[Any]:
|
|
82
|
+
setattr(c, REPOSITORY_META, kwargs)
|
|
83
|
+
REPOSITORIES.add(c)
|
|
84
|
+
return component(c, scope=scope, **kwargs)
|
|
85
|
+
|
|
86
|
+
if cls is not None:
|
|
87
|
+
return decorate(cls)
|
|
88
|
+
|
|
89
|
+
return decorate
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from pico_ioc import configure, factory, provides, component
|
|
3
|
+
from .config import DatabaseConfigurer, DatabaseSettings
|
|
4
|
+
from .session import SessionManager, set_default_session_manager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _priority_of(obj):
|
|
8
|
+
try:
|
|
9
|
+
return int(getattr(obj, "priority", 0))
|
|
10
|
+
except Exception:
|
|
11
|
+
return 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@component
|
|
15
|
+
class PicoSqlAlchemyLifecycle:
|
|
16
|
+
@configure
|
|
17
|
+
def setup_database(
|
|
18
|
+
self,
|
|
19
|
+
session_manager: SessionManager,
|
|
20
|
+
configurers: List[DatabaseConfigurer],
|
|
21
|
+
) -> None:
|
|
22
|
+
valid = [
|
|
23
|
+
c
|
|
24
|
+
for c in configurers
|
|
25
|
+
if isinstance(c, DatabaseConfigurer)
|
|
26
|
+
and callable(getattr(c, "configure", None))
|
|
27
|
+
]
|
|
28
|
+
ordered = sorted(valid, key=_priority_of)
|
|
29
|
+
for cfg in ordered:
|
|
30
|
+
cfg.configure(session_manager.engine)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@factory
|
|
34
|
+
class SqlAlchemyFactory:
|
|
35
|
+
@provides(SessionManager, scope="singleton")
|
|
36
|
+
def create_session_manager(self, settings: DatabaseSettings) -> SessionManager:
|
|
37
|
+
manager = SessionManager(
|
|
38
|
+
url=settings.url,
|
|
39
|
+
echo=settings.echo,
|
|
40
|
+
pool_size=settings.pool_size,
|
|
41
|
+
pool_pre_ping=settings.pool_pre_ping,
|
|
42
|
+
pool_recycle=settings.pool_recycle,
|
|
43
|
+
)
|
|
44
|
+
set_default_session_manager(manager)
|
|
45
|
+
return manager
|
|
46
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
from pico_ioc import MethodCtx, MethodInterceptor, component
|
|
4
|
+
from .decorators import TRANSACTIONAL_META
|
|
5
|
+
from .session import SessionManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@component
|
|
9
|
+
class TransactionalInterceptor(MethodInterceptor):
|
|
10
|
+
def __init__(self, session_manager: SessionManager):
|
|
11
|
+
self.sm = session_manager
|
|
12
|
+
|
|
13
|
+
async def invoke(self, ctx: MethodCtx, call_next: Callable[[MethodCtx], Any]) -> Any:
|
|
14
|
+
func = getattr(ctx.cls, ctx.name, None)
|
|
15
|
+
meta = getattr(func, TRANSACTIONAL_META, None)
|
|
16
|
+
|
|
17
|
+
if not meta:
|
|
18
|
+
result = call_next(ctx)
|
|
19
|
+
if inspect.isawaitable(result):
|
|
20
|
+
return await result
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
propagation = meta["propagation"]
|
|
24
|
+
read_only = meta["read_only"]
|
|
25
|
+
isolation = meta["isolation_level"]
|
|
26
|
+
rollback_for = meta["rollback_for"]
|
|
27
|
+
no_rollback_for = meta["no_rollback_for"]
|
|
28
|
+
|
|
29
|
+
async with self.sm.transaction(
|
|
30
|
+
propagation=propagation,
|
|
31
|
+
read_only=read_only,
|
|
32
|
+
isolation_level=isolation,
|
|
33
|
+
rollback_for=rollback_for,
|
|
34
|
+
no_rollback_for=no_rollback_for,
|
|
35
|
+
):
|
|
36
|
+
result = call_next(ctx)
|
|
37
|
+
if inspect.isawaitable(result):
|
|
38
|
+
result = await result
|
|
39
|
+
return result
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import Generator, Optional, Dict, Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import Engine
|
|
6
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, AsyncSession
|
|
7
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
8
|
+
from pico_ioc import component
|
|
9
|
+
|
|
10
|
+
_tx_context: contextvars.ContextVar["TransactionContext | None"] = contextvars.ContextVar(
|
|
11
|
+
"pico_sqlalchemy_tx_context", default=None
|
|
12
|
+
)
|
|
13
|
+
_default_manager: Optional["SessionManager"] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_default_session_manager(manager: "SessionManager") -> None:
|
|
17
|
+
global _default_manager
|
|
18
|
+
_default_manager = manager
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_default_session_manager() -> Optional["SessionManager"]:
|
|
22
|
+
return _default_manager
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransactionContext:
|
|
26
|
+
__slots__ = ("session",)
|
|
27
|
+
|
|
28
|
+
def __init__(self, session: AsyncSession):
|
|
29
|
+
self.session = session
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@component(scope="singleton")
|
|
33
|
+
class SessionManager:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
url: str,
|
|
37
|
+
echo: bool = False,
|
|
38
|
+
pool_size: int = 5,
|
|
39
|
+
pool_pre_ping: bool = True,
|
|
40
|
+
pool_recycle: int = 3600,
|
|
41
|
+
):
|
|
42
|
+
engine_kwargs: Dict[str, Any] = {"echo": echo}
|
|
43
|
+
|
|
44
|
+
is_memory_sqlite = "sqlite" in url and ":memory:" in url
|
|
45
|
+
|
|
46
|
+
if not is_memory_sqlite:
|
|
47
|
+
engine_kwargs["pool_size"] = pool_size
|
|
48
|
+
engine_kwargs["pool_pre_ping"] = pool_pre_ping
|
|
49
|
+
engine_kwargs["pool_recycle"] = pool_recycle
|
|
50
|
+
|
|
51
|
+
self._engine: AsyncEngine = create_async_engine(
|
|
52
|
+
url, **engine_kwargs
|
|
53
|
+
)
|
|
54
|
+
self._session_factory = sessionmaker(
|
|
55
|
+
bind=self._engine,
|
|
56
|
+
class_=AsyncSession,
|
|
57
|
+
autoflush=False,
|
|
58
|
+
autocommit=False,
|
|
59
|
+
expire_on_commit=False,
|
|
60
|
+
)
|
|
61
|
+
set_default_session_manager(self)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def engine(self) -> AsyncEngine:
|
|
65
|
+
return self._engine
|
|
66
|
+
|
|
67
|
+
def create_session(self) -> AsyncSession:
|
|
68
|
+
return self._session_factory()
|
|
69
|
+
|
|
70
|
+
def get_current_session(self) -> Optional[AsyncSession]:
|
|
71
|
+
ctx = _tx_context.get()
|
|
72
|
+
return ctx.session if ctx is not None else None
|
|
73
|
+
|
|
74
|
+
@asynccontextmanager
|
|
75
|
+
async def transaction(
|
|
76
|
+
self,
|
|
77
|
+
propagation: str = "REQUIRED",
|
|
78
|
+
read_only: bool = False,
|
|
79
|
+
isolation_level: Optional[str] = None,
|
|
80
|
+
rollback_for: tuple[type[BaseException], ...] = (Exception,),
|
|
81
|
+
no_rollback_for: tuple[type[BaseException], ...] = (),
|
|
82
|
+
) -> Generator[AsyncSession, None, None]:
|
|
83
|
+
current = _tx_context.get()
|
|
84
|
+
|
|
85
|
+
if propagation == "MANDATORY":
|
|
86
|
+
if current is None:
|
|
87
|
+
raise RuntimeError("MANDATORY propagation requires active transaction")
|
|
88
|
+
yield current.session
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if propagation == "NEVER":
|
|
92
|
+
if current is not None:
|
|
93
|
+
raise RuntimeError("NEVER propagation forbids active transaction")
|
|
94
|
+
session = self.create_session()
|
|
95
|
+
try:
|
|
96
|
+
yield session
|
|
97
|
+
finally:
|
|
98
|
+
await session.close()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if propagation == "NOT_SUPPORTED":
|
|
102
|
+
if current is not None:
|
|
103
|
+
token = _tx_context.set(None)
|
|
104
|
+
try:
|
|
105
|
+
session = self.create_session()
|
|
106
|
+
try:
|
|
107
|
+
yield session
|
|
108
|
+
finally:
|
|
109
|
+
await session.close()
|
|
110
|
+
finally:
|
|
111
|
+
_tx_context.reset(token)
|
|
112
|
+
else:
|
|
113
|
+
session = self.create_session()
|
|
114
|
+
try:
|
|
115
|
+
yield session
|
|
116
|
+
finally:
|
|
117
|
+
await session.close()
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if propagation == "SUPPORTS":
|
|
121
|
+
if current is not None:
|
|
122
|
+
yield current.session
|
|
123
|
+
return
|
|
124
|
+
session = self.create_session()
|
|
125
|
+
try:
|
|
126
|
+
yield session
|
|
127
|
+
finally:
|
|
128
|
+
await session.close()
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if propagation == "REQUIRES_NEW":
|
|
132
|
+
if current is not None:
|
|
133
|
+
parent_token = _tx_context.set(None)
|
|
134
|
+
try:
|
|
135
|
+
async with self._start_transaction(
|
|
136
|
+
read_only=read_only,
|
|
137
|
+
isolation_level=isolation_level,
|
|
138
|
+
rollback_for=rollback_for,
|
|
139
|
+
no_rollback_for=no_rollback_for,
|
|
140
|
+
) as session:
|
|
141
|
+
yield session
|
|
142
|
+
finally:
|
|
143
|
+
_tx_context.reset(parent_token)
|
|
144
|
+
else:
|
|
145
|
+
async with self._start_transaction(
|
|
146
|
+
read_only=read_only,
|
|
147
|
+
isolation_level=isolation_level,
|
|
148
|
+
rollback_for=rollback_for,
|
|
149
|
+
no_rollback_for=no_rollback_for,
|
|
150
|
+
) as session:
|
|
151
|
+
yield session
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if propagation == "REQUIRED":
|
|
155
|
+
if current is not None:
|
|
156
|
+
yield current.session
|
|
157
|
+
return
|
|
158
|
+
async with self._start_transaction(
|
|
159
|
+
read_only=read_only,
|
|
160
|
+
isolation_level=isolation_level,
|
|
161
|
+
rollback_for=rollback_for,
|
|
162
|
+
no_rollback_for=no_rollback_for,
|
|
163
|
+
) as session:
|
|
164
|
+
yield session
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
raise ValueError(f"Unknown propagation: {propagation}")
|
|
168
|
+
|
|
169
|
+
@asynccontextmanager
|
|
170
|
+
async def _start_transaction(
|
|
171
|
+
self,
|
|
172
|
+
read_only: bool,
|
|
173
|
+
isolation_level: Optional[str],
|
|
174
|
+
rollback_for: tuple[type[BaseException], ...],
|
|
175
|
+
no_rollback_for: tuple[type[BaseException], ...],
|
|
176
|
+
) -> Generator[AsyncSession, None, None]:
|
|
177
|
+
session = self.create_session()
|
|
178
|
+
if isolation_level:
|
|
179
|
+
await session.connection(
|
|
180
|
+
execution_options={"isolation_level": isolation_level}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
ctx = TransactionContext(session)
|
|
184
|
+
token = _tx_context.set(ctx)
|
|
185
|
+
try:
|
|
186
|
+
yield session
|
|
187
|
+
if not read_only:
|
|
188
|
+
await session.commit()
|
|
189
|
+
except BaseException as e:
|
|
190
|
+
should_rollback = isinstance(e, rollback_for) and not isinstance(
|
|
191
|
+
e, no_rollback_for
|
|
192
|
+
)
|
|
193
|
+
if should_rollback:
|
|
194
|
+
await session.rollback()
|
|
195
|
+
raise
|
|
196
|
+
finally:
|
|
197
|
+
_tx_context.reset(token)
|
|
198
|
+
await session.close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_session(manager: SessionManager) -> AsyncSession:
|
|
202
|
+
session = manager.get_current_session()
|
|
203
|
+
if session is None:
|
|
204
|
+
raise RuntimeError("No active transaction")
|
|
205
|
+
return session
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pico-sqlalchemy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pico-ioc integration for SQLAlchemy. Adds Spring-style transactional support, configuration, and helpers.
|
|
5
|
+
Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 David Perez
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/dperezcabrera/pico-sqlalchemy
|
|
29
|
+
Project-URL: Repository, https://github.com/dperezcabrera/pico-sqlalchemy
|
|
30
|
+
Project-URL: Issue Tracker, https://github.com/dperezcabrera/pico-sqlalchemy/issues
|
|
31
|
+
Keywords: ioc,di,dependency injection,sqlalchemy,transaction,orm,inversion of control,asyncio
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
|
+
Classifier: Topic :: Database
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
42
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
43
|
+
Classifier: Operating System :: OS Independent
|
|
44
|
+
Requires-Python: >=3.10
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
License-File: LICENSE
|
|
47
|
+
Requires-Dist: pico-ioc>=2.0
|
|
48
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
49
|
+
Provides-Extra: async
|
|
50
|
+
Requires-Dist: asyncpg>=0.29.0; extra == "async"
|
|
51
|
+
Provides-Extra: test
|
|
52
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
53
|
+
Requires-Dist: pytest-asyncio>=0.23.5; extra == "test"
|
|
54
|
+
Requires-Dist: pytest-cov>=5; extra == "test"
|
|
55
|
+
Dynamic: license-file
|
|
56
|
+
|
|
57
|
+
# 📦 pico-sqlalchemy
|
|
58
|
+
|
|
59
|
+
[](https://pypi.org/project/pico-sqlalchemy/)
|
|
60
|
+
[](https://deepwiki.com/dperezcabrera/pico-sqlalchemy)
|
|
61
|
+
[](https://opensource.org/licenses/MIT)
|
|
62
|
+

|
|
63
|
+
[](https://codecov.io/gh/dperezcabrera/pico-sqlalchemy)
|
|
64
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
|
|
65
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
|
|
66
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
|
|
67
|
+
|
|
68
|
+
# Pico-SQLAlchemy
|
|
69
|
+
|
|
70
|
+
**Pico-SQLAlchemy** integrates **[Pico-IoC](https://github.com/dperezcabrera/pico-ioc)** with **SQLAlchemy**, providing real inversion of control for your persistence layer, with declarative repositories, transactional boundaries, and clean architectural isolation.
|
|
71
|
+
|
|
72
|
+
It brings constructor-based dependency injection, transparent transaction management, and a repository pattern inspired by the elegance of Spring Data — but using pure Python, Pico-IoC, and SQLAlchemy’s ORM.
|
|
73
|
+
|
|
74
|
+
> 🐍 Requires Python 3.10+
|
|
75
|
+
> 🚀 **Async-Native:** Built entirely on SQLAlchemy's async ORM (`AsyncSession`, `create_async_engine`).
|
|
76
|
+
> 🧩 Works with SQLAlchemy 2.0+ ORM
|
|
77
|
+
> 🔄 Automatic async transaction management
|
|
78
|
+
> 🧪 Fully testable without a running DB
|
|
79
|
+
|
|
80
|
+
With Pico-SQLAlchemy you get the expressive power of SQLAlchemy with proper IoC, clean layering, and annotation-driven transactions.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 🎯 Why pico-sqlalchemy
|
|
85
|
+
|
|
86
|
+
SQLAlchemy is powerful, but most applications end up with raw session handling, manual transaction scopes, or ad-hoc repository patterns.
|
|
87
|
+
|
|
88
|
+
Pico-SQLAlchemy provides:
|
|
89
|
+
|
|
90
|
+
* Constructor-injected repositories and services
|
|
91
|
+
* Declarative `@transactional` boundaries
|
|
92
|
+
* `REQUIRES_NEW`, `READ_ONLY`, `MANDATORY`, and all familiar propagation modes
|
|
93
|
+
* `SessionManager` that centralizes engine/session lifecycle
|
|
94
|
+
* Clean decoupling from frameworks (FastAPI, Flask, CLI, workers)
|
|
95
|
+
|
|
96
|
+
| Concern | SQLAlchemy Default | pico-sqlalchemy |
|
|
97
|
+
| :--- | :--- | :--- |
|
|
98
|
+
| Managing sessions | Manual `AsyncSession()` | Automatic |
|
|
99
|
+
| Transactions | Explicit `await commit()` / `await rollback()` | Declarative `@transactional` |
|
|
100
|
+
| Repository pattern | DIY, inconsistent | First-class `@repository` |
|
|
101
|
+
| Dependency injection | None | IoC-driven constructor injection |
|
|
102
|
+
| Testability | Manual setup | Container-managed + overrides |
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 🧱 Core Features
|
|
107
|
+
|
|
108
|
+
* Repository classes with `@repository`
|
|
109
|
+
* Declarative transactions via `@transactional`
|
|
110
|
+
* Full propagation semantics (`REQUIRED`, `REQUIRES_NEW`, `MANDATORY`, etc.)
|
|
111
|
+
* Automatic `AsyncSession` lifecycle
|
|
112
|
+
* Centralized `AsyncEngine` + session factory via `SessionManager`
|
|
113
|
+
* Transaction-aware `get_session()` for repository methods
|
|
114
|
+
* Plug-and-play integration with any Pico-IoC app (FastAPI, CLI tools, workers, event handlers)
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 📦 Installation
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install pico-sqlalchemy
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Also install `pico-ioc`, `sqlalchemy`, and an **async driver**:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install pico-ioc sqlalchemy
|
|
128
|
+
pip install aiosqlite # For SQLite
|
|
129
|
+
# pip install asyncpg # For PostgreSQL
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
-----
|
|
133
|
+
|
|
134
|
+
## 🚀 Quick Example
|
|
135
|
+
|
|
136
|
+
### Define your model:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from sqlalchemy import Integer, String
|
|
140
|
+
from pico_sqlalchemy import AppBase, Mapped, mapped_column
|
|
141
|
+
|
|
142
|
+
class User(AppBase):
|
|
143
|
+
__tablename__ = "users"
|
|
144
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
145
|
+
username: Mapped[str] = mapped_column(String(50))
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Define a repository:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from sqlalchemy.future import select
|
|
152
|
+
from pico_sqlalchemy import repository, transactional, get_session, SessionManager
|
|
153
|
+
|
|
154
|
+
@repository
|
|
155
|
+
class UserRepository:
|
|
156
|
+
def __init__(self, manager: SessionManager):
|
|
157
|
+
self.manager = manager
|
|
158
|
+
|
|
159
|
+
@transactional
|
|
160
|
+
async def save(self, user: User) -> User:
|
|
161
|
+
session = get_session(self.manager)
|
|
162
|
+
session.add(user)
|
|
163
|
+
return user
|
|
164
|
+
|
|
165
|
+
@transactional(read_only=True)
|
|
166
|
+
async def find_all(self) -> list[User]:
|
|
167
|
+
session = get_session(self.manager)
|
|
168
|
+
stmt = select(User).order_by(User.username)
|
|
169
|
+
result = await session.scalars(stmt)
|
|
170
|
+
return list(result.all())
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Define a service:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from pico_ioc import component
|
|
177
|
+
|
|
178
|
+
@component
|
|
179
|
+
class UserService:
|
|
180
|
+
def __init__(self, repo: UserRepository):
|
|
181
|
+
self.repo = repo
|
|
182
|
+
|
|
183
|
+
@transactional
|
|
184
|
+
async def create(self, name: str) -> User:
|
|
185
|
+
user = User(username=name)
|
|
186
|
+
user = await self.repo.save(user)
|
|
187
|
+
|
|
188
|
+
session = get_session(self.repo.manager)
|
|
189
|
+
await session.flush()
|
|
190
|
+
await session.refresh(user)
|
|
191
|
+
return user
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Initialize Pico-IoC and run:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
import asyncio
|
|
198
|
+
from pico_ioc import init, configuration, DictSource
|
|
199
|
+
|
|
200
|
+
config = configuration(DictSource({
|
|
201
|
+
"database": {
|
|
202
|
+
"url": "sqlite+aiosqlite:///:memory:", # Async URL
|
|
203
|
+
"echo": False
|
|
204
|
+
}
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
container = init(
|
|
208
|
+
modules=["services", "repositories", "pico_sqlalchemy"],
|
|
209
|
+
config=config,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def main():
|
|
213
|
+
# Use await container.aget() for async resolution
|
|
214
|
+
service = await container.aget(UserService)
|
|
215
|
+
|
|
216
|
+
# Await the async service method
|
|
217
|
+
user = await service.create("alice")
|
|
218
|
+
print(f"Created user: {user.id}")
|
|
219
|
+
|
|
220
|
+
# Clean up async resources
|
|
221
|
+
await container.cleanup_all_async()
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
asyncio.run(main())
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
-----
|
|
228
|
+
|
|
229
|
+
## 🔄 Transaction Propagation Modes
|
|
230
|
+
|
|
231
|
+
Pico-SQLAlchemy supports the core Spring-inspired semantics:
|
|
232
|
+
|
|
233
|
+
| Mode | Behavior |
|
|
234
|
+
| :--- | :--- |
|
|
235
|
+
| `REQUIRED` | Join existing tx or create new |
|
|
236
|
+
| `REQUIRES_NEW` | Suspend parent and start new tx |
|
|
237
|
+
| `SUPPORTS` | Join if exists, else run without tx |
|
|
238
|
+
| `MANDATORY` | Requires existing tx |
|
|
239
|
+
| `NOT_SUPPORTED`| Run without tx, suspending parent |
|
|
240
|
+
| `NEVER` | Fail if a tx exists |
|
|
241
|
+
|
|
242
|
+
Example:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
@transactional(propagation="REQUIRES_NEW")
|
|
246
|
+
async def write_audit(self, entry: AuditEntry):
|
|
247
|
+
...
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
-----
|
|
251
|
+
|
|
252
|
+
## 🧪 Testing with Pico-IoC
|
|
253
|
+
|
|
254
|
+
You can override repositories, engines, or services easily:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
import pytest
|
|
258
|
+
import pytest_asyncio
|
|
259
|
+
from pico_ioc import init, configuration, DictSource
|
|
260
|
+
from pico_sqlalchemy import SessionManager, AppBase
|
|
261
|
+
|
|
262
|
+
# In conftest.py
|
|
263
|
+
@pytest_asyncio.fixture
|
|
264
|
+
async def container():
|
|
265
|
+
cfg = configuration(DictSource({"database": {"url": "sqlite+aiosqlite:///:memory:"}}))
|
|
266
|
+
c = init(modules=["pico_sqlalchemy", "myapp"], config=cfg)
|
|
267
|
+
|
|
268
|
+
# Setup the in-memory database
|
|
269
|
+
sm = await c.aget(SessionManager)
|
|
270
|
+
async with sm.engine.begin() as conn:
|
|
271
|
+
await conn.run_sync(AppBase.metadata.create_all)
|
|
272
|
+
|
|
273
|
+
yield c
|
|
274
|
+
|
|
275
|
+
# Clean up all async components
|
|
276
|
+
await c.cleanup_all_async()
|
|
277
|
+
|
|
278
|
+
# In your test
|
|
279
|
+
@pytest.mark.asyncio
|
|
280
|
+
async def test_my_service(container):
|
|
281
|
+
service = await container.aget(UserService)
|
|
282
|
+
user = await service.create("test")
|
|
283
|
+
assert user.id is not None
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
-----
|
|
287
|
+
|
|
288
|
+
## 🧬 Example: Custom Database Configurer
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
from pico_sqlalchemy import DatabaseConfigurer, AppBase
|
|
292
|
+
from pico_ioc import component
|
|
293
|
+
import asyncio
|
|
294
|
+
|
|
295
|
+
@component
|
|
296
|
+
class TableCreationConfigurer(DatabaseConfigurer):
|
|
297
|
+
priority = 10
|
|
298
|
+
def __init__(self, base: AppBase):
|
|
299
|
+
self.base = base
|
|
300
|
+
|
|
301
|
+
def configure(self, engine):
|
|
302
|
+
# This configure method is called by the factory.
|
|
303
|
+
# We need to run the async setup.
|
|
304
|
+
async def setup():
|
|
305
|
+
async with engine.begin() as conn:
|
|
306
|
+
await conn.run_sync(self.base.metadata.create_all)
|
|
307
|
+
|
|
308
|
+
asyncio.run(setup())
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Pico-SQLAlchemy will detect it and call `configure` during initialization.
|
|
312
|
+
|
|
313
|
+
-----
|
|
314
|
+
|
|
315
|
+
## ⚙️ How It Works
|
|
316
|
+
|
|
317
|
+
* `SessionManager` is created by Pico-IoC (`SqlAlchemyFactory`)
|
|
318
|
+
* A global session context is established via `contextvars`
|
|
319
|
+
* `@transactional` automatically opens/closes async transactions
|
|
320
|
+
* `@repository` registers a class as a singleton component
|
|
321
|
+
* All dependencies (repositories, services, configurers) are resolved by Pico-IoC
|
|
322
|
+
|
|
323
|
+
No globals. No implicit singletons. No framework coupling.
|
|
324
|
+
|
|
325
|
+
-----
|
|
326
|
+
|
|
327
|
+
## 💡 Architecture Overview
|
|
328
|
+
|
|
329
|
+
```
|
|
330
|
+
┌─────────────────────────────┐
|
|
331
|
+
│ Your App │
|
|
332
|
+
└──────────────┬──────────────┘
|
|
333
|
+
│
|
|
334
|
+
Constructor Injection
|
|
335
|
+
│
|
|
336
|
+
┌──────────────▼───────────────┐
|
|
337
|
+
│ Pico-IoC │
|
|
338
|
+
└──────────────┬───────────────┘
|
|
339
|
+
│
|
|
340
|
+
SessionManager / SqlAlchemyFactory
|
|
341
|
+
│
|
|
342
|
+
┌──────────────▼───────────────┐
|
|
343
|
+
│ pico-sqlalchemy │
|
|
344
|
+
│ Transactional Decorators │
|
|
345
|
+
│ Repository Metadata │
|
|
346
|
+
└──────────────┬───────────────┘
|
|
347
|
+
│
|
|
348
|
+
SQLAlchemy
|
|
349
|
+
(Async ORM)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
-----
|
|
353
|
+
|
|
354
|
+
## 📝 License
|
|
355
|
+
|
|
356
|
+
MIT
|
|
357
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pico_sqlalchemy/__init__.py,sha256=xQ4q2D_-qjDUBwu5D5xIIk7cTa9oqrnAtCRWTmbS9Bg,546
|
|
2
|
+
pico_sqlalchemy/_version.py,sha256=IMjkMO3twhQzluVTo8Z6rE7Eg-9U79_LGKMcsWLKBkY,22
|
|
3
|
+
pico_sqlalchemy/base.py,sha256=YvwsUQiOdm7aKioV3Rpgkif_iBnQafz8cT20JUPSePI,221
|
|
4
|
+
pico_sqlalchemy/config.py,sha256=nqZjWnPQIJqecydENcZSyaBwGRrqMNiyco7hmjDIPis,549
|
|
5
|
+
pico_sqlalchemy/decorators.py,sha256=928rtYkhsCsrZk6zC77x38GglbD7k0N9YRvBERXkuE4,2724
|
|
6
|
+
pico_sqlalchemy/factory.py,sha256=dGE5n5P8n96-6Q2jONsN7-pw4isM0YSaFmqc6C6EDiI,1320
|
|
7
|
+
pico_sqlalchemy/interceptor.py,sha256=4f2FoWJk4XJFon3hdUC2UwrFRlVnQHvCX9X9baIykaw,1309
|
|
8
|
+
pico_sqlalchemy/session.py,sha256=UOW7saISQ9OLMQbqSxnOZZrqD39zmhPo1D4WbKUddwo,6582
|
|
9
|
+
pico_sqlalchemy-0.1.0.dist-info/licenses/LICENSE,sha256=j86ePhARUMlHapXj0uGGdmnRTholb6VMoORCv8jkGdU,1068
|
|
10
|
+
pico_sqlalchemy-0.1.0.dist-info/METADATA,sha256=OHjl9p3SAxwMakZNUIWTtXRMd7prPFd5wsFKeEF1CYo,12786
|
|
11
|
+
pico_sqlalchemy-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
pico_sqlalchemy-0.1.0.dist-info/top_level.txt,sha256=NYXrmd16JCyVKtyNLqsxfeoirlnos4dlPjSLgHuwHG4,16
|
|
13
|
+
pico_sqlalchemy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 David Perez
|
|
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 @@
|
|
|
1
|
+
pico_sqlalchemy
|