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.
@@ -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'
@@ -0,0 +1,10 @@
1
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
2
+ from pico_ioc import component
3
+
4
+
5
+ @component(scope="singleton")
6
+ class AppBase(DeclarativeBase):
7
+ pass
8
+
9
+
10
+ __all__ = ["AppBase", "Mapped", "mapped_column"]
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/pico-sqlalchemy.svg)](https://pypi.org/project/pico-sqlalchemy/)
60
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/dperezcabrera/pico-sqlalchemy)
61
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
62
+ ![CI (tox matrix)](https://github.com/dperezcabrera/pico-sqlalchemy/actions/workflows/ci.yml/badge.svg)
63
+ [![codecov](https://codecov.io/gh/dperezcabrera/pico-sqlalchemy/branch/main/graph/badge.svg)](https://codecov.io/gh/dperezcabrera/pico-sqlalchemy)
64
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dperezcabrera_pico-sqlalchemy\&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
65
+ [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=dperezcabrera_pico-sqlalchemy\&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
66
+ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=dperezcabrera_pico-sqlalchemy\&metric=sqale_rating)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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