pico-sqlalchemy 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/publish-to-pypi.yml +12 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/AGENTS.md +4 -3
- {pico_sqlalchemy-0.3.0/docs → pico_sqlalchemy-0.4.0}/CHANGELOG.md +11 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/CLAUDE.md +2 -2
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/PKG-INFO +39 -20
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/README.md +37 -18
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0/docs}/CHANGELOG.md +11 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/architecture.md +26 -2
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/skills.md +2 -2
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/main.py +3 -9
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/repositories.py +2 -1
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/pyproject.toml +1 -1
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/__init__.py +2 -1
- pico_sqlalchemy-0.4.0/src/pico_sqlalchemy/_version.py +1 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/factory.py +3 -1
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/interceptor.py +36 -6
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/PKG-INFO +39 -20
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/SOURCES.txt +2 -1
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/requires.txt +1 -1
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/conftest.py +11 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_coverage_boost_v2.py +4 -4
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_interceptor.py +5 -5
- pico_sqlalchemy-0.4.0/tests/test_transaction_scope.py +133 -0
- pico_sqlalchemy-0.3.0/src/pico_sqlalchemy/_version.py +0 -1
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.coveragerc +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/dependabot.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/ci.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/codeql.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/docs.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/sync-keywords.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/CODE_OF_CONDUCT.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/CONTRIBUTING.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/LICENSE +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/MANIFEST.in +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/SECURITY.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/development/project-tooling.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/faq.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/hooks.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/how-to/alembic.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/how-to/multiple-databases.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/how-to/testing.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/index.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/javascripts/extra.js +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/migration.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/overview.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/quickstart.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/reference/configuration.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/reference/declarative-base.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/reference/repository.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/reference/transactions.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/requirements.txt +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/stylesheets/extra.css +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/README.md +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/__init__.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/models.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/services.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/config.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/requirements.txt +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/mkdocs.yml +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/setup.cfg +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/base.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/config.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/decorators.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/paging.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/py.typed +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/repository_interceptor.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/session.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/dependency_links.txt +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/entry_points.txt +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/top_level.txt +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_ioc_integration.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_pagination_sort.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_repository_interceptor_coverage.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_repository_query.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_session_propagation.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_transaction_manager.py +0 -0
- {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tox.ini +0 -0
|
@@ -27,6 +27,18 @@ jobs:
|
|
|
27
27
|
- name: Build package
|
|
28
28
|
run: python -m build
|
|
29
29
|
|
|
30
|
+
- name: Reject non-clean versions (.dev / .post)
|
|
31
|
+
run: |
|
|
32
|
+
python - <<'PY'
|
|
33
|
+
import glob, sys
|
|
34
|
+
whl = sorted(glob.glob('dist/*.whl'))[0]
|
|
35
|
+
ver = whl.split('-')[1]
|
|
36
|
+
print(f"Built version: {ver}")
|
|
37
|
+
if 'dev' in ver or 'post' in ver:
|
|
38
|
+
sys.exit(f"::error::Refusing to publish non-clean version '{ver}'. "
|
|
39
|
+
"Create the GitHub Release from an exact version tag (e.g. v0.4.0).")
|
|
40
|
+
PY
|
|
41
|
+
|
|
30
42
|
- name: Publish to PyPI
|
|
31
43
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
32
44
|
with:
|
|
@@ -35,9 +35,10 @@ src/pico_sqlalchemy/
|
|
|
35
35
|
- **Propagation modes**: REQUIRED, REQUIRES_NEW, MANDATORY, NEVER, NOT_SUPPORTED, SUPPORTS
|
|
36
36
|
- **Priority chain**: `@transactional` > `@query` (read-only) > `@repository` (read-write)
|
|
37
37
|
- **`SessionManager`**: Created by `SqlAlchemyFactory` (not `@component`). Manages engine, sessions, transactions
|
|
38
|
-
- **`_tx_context` ContextVar**: Holds `TransactionContext`
|
|
38
|
+
- **`_tx_context` ContextVar**: Holds `TransactionContext` (the session) for propagation across nested calls — the *session* side of a transaction
|
|
39
|
+
- **`"transaction"` DI scope**: `TransactionalInterceptor` activates pico-ioc's `"transaction"` scope (with `cleanup=True`) whenever a *new* transaction starts (REQUIRES_NEW, or REQUIRED with no enclosing tx). So a `scope="transaction"` component is one instance per transaction (Unit-of-Work / identity-map), released with its `@cleanup` hooks when the tx ends. The session ContextVar and the DI scope are two facets of one boundary
|
|
39
40
|
- **`get_session(manager)`**: Returns current session from active transaction context
|
|
40
|
-
- **Non-transactional paths** (NEVER, NOT_SUPPORTED, SUPPORTS without tx): Still set `TransactionContext` so `get_session()` works
|
|
41
|
+
- **Non-transactional paths** (NEVER, NOT_SUPPORTED, SUPPORTS without tx): Still set `TransactionContext` so `get_session()` works, but open no DI `"transaction"` scope (resolving a `scope="transaction"` component there raises `ScopeError`)
|
|
41
42
|
|
|
42
43
|
## Code Style
|
|
43
44
|
|
|
@@ -59,4 +60,4 @@ src/pico_sqlalchemy/
|
|
|
59
60
|
|
|
60
61
|
- Do not modify `_version.py`
|
|
61
62
|
- Do not add `@component` to `SessionManager` (factory creates it)
|
|
62
|
-
- `_tx_context`
|
|
63
|
+
- `_tx_context` (session propagation) and the pico-ioc `"transaction"` DI scope are bound to one boundary by `TransactionalInterceptor` — keep them coordinated; do not reintroduce a second, independent transaction concept
|
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.h
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-06-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Transaction-scoped DI**: `TransactionalInterceptor` now binds pico-ioc's `"transaction"` DI scope to the database transaction boundary. When a *new* transaction starts (`REQUIRES_NEW`, or `REQUIRED` with no enclosing transaction) it activates a fresh `"transaction"` scope (via `container.scope(..., cleanup=True)`) for the duration of the call, so components registered with `scope="transaction"` live exactly one transaction and run their `@cleanup` hooks when it ends. Joins reuse the enclosing scope. Requires **pico-ioc >= 2.2.6**.
|
|
14
|
+
- `tests/test_transaction_scope.py`: covers one-instance-per-transaction, sharing within a transaction, `REQUIRES_NEW` scope push/restore, fail-fast resolution outside a transaction, and `@cleanup` on transaction end.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Bumped `pico-ioc` dependency to `>= 2.2.6`.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
10
21
|
## [0.3.0] - 2026-02-20
|
|
11
22
|
|
|
12
23
|
### Changed
|
|
@@ -10,10 +10,10 @@ pico-sqlalchemy provides SQLAlchemy integration for pico-ioc. It uses:
|
|
|
10
10
|
|
|
11
11
|
## Key Reminders
|
|
12
12
|
|
|
13
|
-
- pico-ioc dependency: `>= 2.2.
|
|
13
|
+
- pico-ioc dependency: `>= 2.2.6` (needs `container.scope(..., cleanup=True)`)
|
|
14
14
|
- **NEVER change `version_scheme`** in pyproject.toml. It MUST remain `"post-release"`. Changing it to `"guess-next-dev"` causes `.dev0` versions to leak to PyPI. This was already fixed once — do not revert it.
|
|
15
15
|
- requires-python >= 3.11
|
|
16
16
|
- Commit messages: one line only
|
|
17
17
|
- `@transactional` works both with and without parentheses (like `@repository`)
|
|
18
18
|
- `SessionManager` has NO `@component` decorator - it's created by the factory
|
|
19
|
-
- `_tx_context` ContextVar is
|
|
19
|
+
- `_tx_context` ContextVar is the *session* side of a transaction (propagation across nested calls). `TransactionalInterceptor` binds pico-ioc's `"transaction"` DI scope to the *same* boundary: when a new transaction starts (REQUIRES_NEW, or REQUIRED with no enclosing tx) it activates a fresh `"transaction"` scope (with `cleanup=True`) for the call, so `scope="transaction"` components live exactly one transaction. They are two facets of one boundary, not separate mechanisms.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pico-sqlalchemy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Pico-ioc integration for SQLAlchemy. Adds Spring-style transactional support, configuration, and helpers.
|
|
5
5
|
Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -47,7 +47,7 @@ Classifier: Typing :: Typed
|
|
|
47
47
|
Requires-Python: >=3.11
|
|
48
48
|
Description-Content-Type: text/markdown
|
|
49
49
|
License-File: LICENSE
|
|
50
|
-
Requires-Dist: pico-ioc>=2.2.
|
|
50
|
+
Requires-Dist: pico-ioc>=2.2.6
|
|
51
51
|
Requires-Dist: sqlalchemy>=2.0
|
|
52
52
|
Provides-Extra: async
|
|
53
53
|
Requires-Dist: asyncpg>=0.29.0; extra == "async"
|
|
@@ -57,7 +57,7 @@ Requires-Dist: pytest-asyncio>=0.23.5; extra == "test"
|
|
|
57
57
|
Requires-Dist: pytest-cov>=5; extra == "test"
|
|
58
58
|
Dynamic: license-file
|
|
59
59
|
|
|
60
|
-
#
|
|
60
|
+
# pico-sqlalchemy
|
|
61
61
|
|
|
62
62
|
[](https://pypi.org/project/pico-sqlalchemy/)
|
|
63
63
|
[](https://deepwiki.com/dperezcabrera/pico-sqlalchemy)
|
|
@@ -68,6 +68,7 @@ Dynamic: license-file
|
|
|
68
68
|
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
|
|
69
69
|
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
|
|
70
70
|
[](https://dperezcabrera.github.io/pico-sqlalchemy/)
|
|
71
|
+
[](https://dperezcabrera.github.io/pico-learn/)
|
|
71
72
|
|
|
72
73
|
# Pico-SQLAlchemy
|
|
73
74
|
|
|
@@ -75,14 +76,14 @@ Dynamic: license-file
|
|
|
75
76
|
|
|
76
77
|
It brings constructor-based dependency injection, **implicit transaction management**, and powerful **declarative queries** using pure Python and SQLAlchemy’s Async ORM.
|
|
77
78
|
|
|
78
|
-
>
|
|
79
|
-
>
|
|
80
|
-
>
|
|
81
|
-
>
|
|
79
|
+
> **Requires Python 3.11+**
|
|
80
|
+
> **Async-Native:** Built entirely on `AsyncSession` and `create_async_engine`.
|
|
81
|
+
> **Zero-Boilerplate:** Repositories are transactional by default.
|
|
82
|
+
> **Declarative Queries:** Define SQL or expressions in decorators; the library executes them for you.
|
|
82
83
|
|
|
83
84
|
---
|
|
84
85
|
|
|
85
|
-
##
|
|
86
|
+
## Why pico-sqlalchemy?
|
|
86
87
|
|
|
87
88
|
Most Python apps suffer from manual session handling (`async with session...`), scattered transaction logic, and verbose repository patterns.
|
|
88
89
|
|
|
@@ -98,7 +99,7 @@ Most Python apps suffer from manual session handling (`async with session...`),
|
|
|
98
99
|
|
|
99
100
|
---
|
|
100
101
|
|
|
101
|
-
##
|
|
102
|
+
## Core Features
|
|
102
103
|
|
|
103
104
|
* **Implicit Transactions:** Methods inside `@repository` are automatically **Read-Write** transactional.
|
|
104
105
|
* **Declarative Queries:** Use `@query` to run SQL or Expressions automatically (defaults to **Read-Only**).
|
|
@@ -108,7 +109,7 @@ Most Python apps suffer from manual session handling (`async with session...`),
|
|
|
108
109
|
|
|
109
110
|
---
|
|
110
111
|
|
|
111
|
-
##
|
|
112
|
+
## Installation
|
|
112
113
|
|
|
113
114
|
```bash
|
|
114
115
|
pip install pico-sqlalchemy
|
|
@@ -123,7 +124,7 @@ pip install asyncpg # for PostgreSQL
|
|
|
123
124
|
|
|
124
125
|
-----
|
|
125
126
|
|
|
126
|
-
##
|
|
127
|
+
## Quick Example
|
|
127
128
|
|
|
128
129
|
### 1\. Define Model
|
|
129
130
|
|
|
@@ -218,7 +219,7 @@ if __name__ == "__main__":
|
|
|
218
219
|
|
|
219
220
|
-----
|
|
220
221
|
|
|
221
|
-
##
|
|
222
|
+
## Transaction Hierarchy & Rules
|
|
222
223
|
|
|
223
224
|
Pico-SQLAlchemy applies a "Best Effort" strategy to determine transaction configuration. The priority order (highest wins) is:
|
|
224
225
|
|
|
@@ -236,7 +237,7 @@ Pico-SQLAlchemy applies a "Best Effort" strategy to determine transaction config
|
|
|
236
237
|
async def update_user(self): ...
|
|
237
238
|
```
|
|
238
239
|
|
|
239
|
-
|
|
240
|
+
**Result:** Active Read-Write Transaction (Implicit from `@repository`).
|
|
240
241
|
|
|
241
242
|
2. **Query Method:**
|
|
242
243
|
|
|
@@ -245,7 +246,7 @@ Pico-SQLAlchemy applies a "Best Effort" strategy to determine transaction config
|
|
|
245
246
|
async def get_data(self): ...
|
|
246
247
|
```
|
|
247
248
|
|
|
248
|
-
|
|
249
|
+
**Result:** Active Read-Only Transaction (Implicit from `@query`).
|
|
249
250
|
|
|
250
251
|
3. **Manual Override:**
|
|
251
252
|
|
|
@@ -254,11 +255,29 @@ Pico-SQLAlchemy applies a "Best Effort" strategy to determine transaction config
|
|
|
254
255
|
async def complex_report(self): ...
|
|
255
256
|
```
|
|
256
257
|
|
|
257
|
-
|
|
258
|
+
**Result:** Active Read-Only Transaction (Explicit override).
|
|
259
|
+
|
|
260
|
+
### Transaction-scoped components *(v0.4.0+)*
|
|
261
|
+
|
|
262
|
+
Beyond managing the SQLAlchemy session, the interceptor binds pico-ioc's **`"transaction"` DI scope** to the same boundary. A component registered with `scope="transaction"` is instantiated **once per database transaction** and torn down (running its `@cleanup` hooks) when that transaction ends:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
@component(scope="transaction")
|
|
266
|
+
class UnitOfWorkAudit:
|
|
267
|
+
def __init__(self):
|
|
268
|
+
self.events: list[str] = []
|
|
269
|
+
|
|
270
|
+
@cleanup
|
|
271
|
+
def flush(self):
|
|
272
|
+
# runs exactly when the enclosing transaction ends
|
|
273
|
+
...
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
A **new** transaction (`REQUIRES_NEW`, or `REQUIRED` with no enclosing transaction) opens a fresh scope; **joins reuse** the enclosing one — so the session boundary and the DI lifetime are two facets of a single transaction. Requires **pico-ioc ≥ 2.2.6**.
|
|
258
277
|
|
|
259
278
|
-----
|
|
260
279
|
|
|
261
|
-
##
|
|
280
|
+
## Declarative Queries in Depth
|
|
262
281
|
|
|
263
282
|
The `@query` decorator eliminates boilerplate for common fetches.
|
|
264
283
|
|
|
@@ -293,7 +312,7 @@ async def find_active(self, page: PageRequest) -> Page[User]: ...
|
|
|
293
312
|
|
|
294
313
|
-----
|
|
295
314
|
|
|
296
|
-
##
|
|
315
|
+
## Testing
|
|
297
316
|
|
|
298
317
|
Testing is simple because you can override the configuration or the components easily using Pico-IoC.
|
|
299
318
|
|
|
@@ -311,7 +330,7 @@ async def test_service():
|
|
|
311
330
|
|
|
312
331
|
-----
|
|
313
332
|
|
|
314
|
-
##
|
|
333
|
+
## Architecture Overview
|
|
315
334
|
|
|
316
335
|
```
|
|
317
336
|
┌─────────────────────────────┐
|
|
@@ -349,7 +368,7 @@ curl -sL https://raw.githubusercontent.com/dperezcabrera/pico-skills/main/instal
|
|
|
349
368
|
|---------|-------------|
|
|
350
369
|
| `/add-repository` | Add SQLAlchemy entities and repositories with transactions |
|
|
351
370
|
| `/add-component` | Add components, factories, interceptors, settings |
|
|
352
|
-
| `/add-tests` | Generate tests for pico
|
|
371
|
+
| `/add-tests` | Generate tests for pico components |
|
|
353
372
|
|
|
354
373
|
All skills: `curl -sL https://raw.githubusercontent.com/dperezcabrera/pico-skills/main/install.sh | bash`
|
|
355
374
|
|
|
@@ -357,6 +376,6 @@ See [pico-skills](https://github.com/dperezcabrera/pico-skills) for details.
|
|
|
357
376
|
|
|
358
377
|
---
|
|
359
378
|
|
|
360
|
-
##
|
|
379
|
+
## License
|
|
361
380
|
|
|
362
381
|
MIT
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pico-sqlalchemy
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/pico-sqlalchemy/)
|
|
4
4
|
[](https://deepwiki.com/dperezcabrera/pico-sqlalchemy)
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
|
|
10
10
|
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-sqlalchemy)
|
|
11
11
|
[](https://dperezcabrera.github.io/pico-sqlalchemy/)
|
|
12
|
+
[](https://dperezcabrera.github.io/pico-learn/)
|
|
12
13
|
|
|
13
14
|
# Pico-SQLAlchemy
|
|
14
15
|
|
|
@@ -16,14 +17,14 @@
|
|
|
16
17
|
|
|
17
18
|
It brings constructor-based dependency injection, **implicit transaction management**, and powerful **declarative queries** using pure Python and SQLAlchemy’s Async ORM.
|
|
18
19
|
|
|
19
|
-
>
|
|
20
|
-
>
|
|
21
|
-
>
|
|
22
|
-
>
|
|
20
|
+
> **Requires Python 3.11+**
|
|
21
|
+
> **Async-Native:** Built entirely on `AsyncSession` and `create_async_engine`.
|
|
22
|
+
> **Zero-Boilerplate:** Repositories are transactional by default.
|
|
23
|
+
> **Declarative Queries:** Define SQL or expressions in decorators; the library executes them for you.
|
|
23
24
|
|
|
24
25
|
---
|
|
25
26
|
|
|
26
|
-
##
|
|
27
|
+
## Why pico-sqlalchemy?
|
|
27
28
|
|
|
28
29
|
Most Python apps suffer from manual session handling (`async with session...`), scattered transaction logic, and verbose repository patterns.
|
|
29
30
|
|
|
@@ -39,7 +40,7 @@ Most Python apps suffer from manual session handling (`async with session...`),
|
|
|
39
40
|
|
|
40
41
|
---
|
|
41
42
|
|
|
42
|
-
##
|
|
43
|
+
## Core Features
|
|
43
44
|
|
|
44
45
|
* **Implicit Transactions:** Methods inside `@repository` are automatically **Read-Write** transactional.
|
|
45
46
|
* **Declarative Queries:** Use `@query` to run SQL or Expressions automatically (defaults to **Read-Only**).
|
|
@@ -49,7 +50,7 @@ Most Python apps suffer from manual session handling (`async with session...`),
|
|
|
49
50
|
|
|
50
51
|
---
|
|
51
52
|
|
|
52
|
-
##
|
|
53
|
+
## Installation
|
|
53
54
|
|
|
54
55
|
```bash
|
|
55
56
|
pip install pico-sqlalchemy
|
|
@@ -64,7 +65,7 @@ pip install asyncpg # for PostgreSQL
|
|
|
64
65
|
|
|
65
66
|
-----
|
|
66
67
|
|
|
67
|
-
##
|
|
68
|
+
## Quick Example
|
|
68
69
|
|
|
69
70
|
### 1\. Define Model
|
|
70
71
|
|
|
@@ -159,7 +160,7 @@ if __name__ == "__main__":
|
|
|
159
160
|
|
|
160
161
|
-----
|
|
161
162
|
|
|
162
|
-
##
|
|
163
|
+
## Transaction Hierarchy & Rules
|
|
163
164
|
|
|
164
165
|
Pico-SQLAlchemy applies a "Best Effort" strategy to determine transaction configuration. The priority order (highest wins) is:
|
|
165
166
|
|
|
@@ -177,7 +178,7 @@ Pico-SQLAlchemy applies a "Best Effort" strategy to determine transaction config
|
|
|
177
178
|
async def update_user(self): ...
|
|
178
179
|
```
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
**Result:** Active Read-Write Transaction (Implicit from `@repository`).
|
|
181
182
|
|
|
182
183
|
2. **Query Method:**
|
|
183
184
|
|
|
@@ -186,7 +187,7 @@ Pico-SQLAlchemy applies a "Best Effort" strategy to determine transaction config
|
|
|
186
187
|
async def get_data(self): ...
|
|
187
188
|
```
|
|
188
189
|
|
|
189
|
-
|
|
190
|
+
**Result:** Active Read-Only Transaction (Implicit from `@query`).
|
|
190
191
|
|
|
191
192
|
3. **Manual Override:**
|
|
192
193
|
|
|
@@ -195,11 +196,29 @@ Pico-SQLAlchemy applies a "Best Effort" strategy to determine transaction config
|
|
|
195
196
|
async def complex_report(self): ...
|
|
196
197
|
```
|
|
197
198
|
|
|
198
|
-
|
|
199
|
+
**Result:** Active Read-Only Transaction (Explicit override).
|
|
200
|
+
|
|
201
|
+
### Transaction-scoped components *(v0.4.0+)*
|
|
202
|
+
|
|
203
|
+
Beyond managing the SQLAlchemy session, the interceptor binds pico-ioc's **`"transaction"` DI scope** to the same boundary. A component registered with `scope="transaction"` is instantiated **once per database transaction** and torn down (running its `@cleanup` hooks) when that transaction ends:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
@component(scope="transaction")
|
|
207
|
+
class UnitOfWorkAudit:
|
|
208
|
+
def __init__(self):
|
|
209
|
+
self.events: list[str] = []
|
|
210
|
+
|
|
211
|
+
@cleanup
|
|
212
|
+
def flush(self):
|
|
213
|
+
# runs exactly when the enclosing transaction ends
|
|
214
|
+
...
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A **new** transaction (`REQUIRES_NEW`, or `REQUIRED` with no enclosing transaction) opens a fresh scope; **joins reuse** the enclosing one — so the session boundary and the DI lifetime are two facets of a single transaction. Requires **pico-ioc ≥ 2.2.6**.
|
|
199
218
|
|
|
200
219
|
-----
|
|
201
220
|
|
|
202
|
-
##
|
|
221
|
+
## Declarative Queries in Depth
|
|
203
222
|
|
|
204
223
|
The `@query` decorator eliminates boilerplate for common fetches.
|
|
205
224
|
|
|
@@ -234,7 +253,7 @@ async def find_active(self, page: PageRequest) -> Page[User]: ...
|
|
|
234
253
|
|
|
235
254
|
-----
|
|
236
255
|
|
|
237
|
-
##
|
|
256
|
+
## Testing
|
|
238
257
|
|
|
239
258
|
Testing is simple because you can override the configuration or the components easily using Pico-IoC.
|
|
240
259
|
|
|
@@ -252,7 +271,7 @@ async def test_service():
|
|
|
252
271
|
|
|
253
272
|
-----
|
|
254
273
|
|
|
255
|
-
##
|
|
274
|
+
## Architecture Overview
|
|
256
275
|
|
|
257
276
|
```
|
|
258
277
|
┌─────────────────────────────┐
|
|
@@ -290,7 +309,7 @@ curl -sL https://raw.githubusercontent.com/dperezcabrera/pico-skills/main/instal
|
|
|
290
309
|
|---------|-------------|
|
|
291
310
|
| `/add-repository` | Add SQLAlchemy entities and repositories with transactions |
|
|
292
311
|
| `/add-component` | Add components, factories, interceptors, settings |
|
|
293
|
-
| `/add-tests` | Generate tests for pico
|
|
312
|
+
| `/add-tests` | Generate tests for pico components |
|
|
294
313
|
|
|
295
314
|
All skills: `curl -sL https://raw.githubusercontent.com/dperezcabrera/pico-skills/main/install.sh | bash`
|
|
296
315
|
|
|
@@ -298,6 +317,6 @@ See [pico-skills](https://github.com/dperezcabrera/pico-skills) for details.
|
|
|
298
317
|
|
|
299
318
|
---
|
|
300
319
|
|
|
301
|
-
##
|
|
320
|
+
## License
|
|
302
321
|
|
|
303
322
|
MIT
|
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.h
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-06-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Transaction-scoped DI**: `TransactionalInterceptor` now binds pico-ioc's `"transaction"` DI scope to the database transaction boundary. When a *new* transaction starts (`REQUIRES_NEW`, or `REQUIRED` with no enclosing transaction) it activates a fresh `"transaction"` scope (via `container.scope(..., cleanup=True)`) for the duration of the call, so components registered with `scope="transaction"` live exactly one transaction and run their `@cleanup` hooks when it ends. Joins reuse the enclosing scope. Requires **pico-ioc >= 2.2.6**.
|
|
14
|
+
- `tests/test_transaction_scope.py`: covers one-instance-per-transaction, sharing within a transaction, `REQUIRES_NEW` scope push/restore, fail-fast resolution outside a transaction, and `@cleanup` on transaction end.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Bumped `pico-ioc` dependency to `>= 2.2.6`.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
10
21
|
## [0.3.0] - 2026-02-20
|
|
11
22
|
|
|
12
23
|
### Changed
|
|
@@ -118,7 +118,7 @@ pico-sqlalchemy uses a `ContextVar` to propagate the active session across async
|
|
|
118
118
|
_tx_context: ContextVar[TransactionContext | None]
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
This is **
|
|
121
|
+
This is the **session** side of a transaction: a lightweight, per-async-task variable that stores the currently active `AsyncSession` wrapped in a `TransactionContext`. It is paired with the pico-ioc `"transaction"` DI scope (the **component** side — see below), bound to the same boundary by `TransactionalInterceptor`.
|
|
122
122
|
|
|
123
123
|
**How it works:**
|
|
124
124
|
|
|
@@ -140,7 +140,31 @@ Service.create_user() ← @transactional
|
|
|
140
140
|
_tx_context = None
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
**Why
|
|
143
|
+
**Why a ContextVar for the session?** Transaction propagation (REQUIRED, REQUIRES_NEW, etc.) needs to suspend and restore the active session (REQUIRES_NEW, NOT_SUPPORTED) — something a `ContextVar` handles naturally, save/restore via tokens.
|
|
144
|
+
|
|
145
|
+
### The paired `"transaction"` DI scope
|
|
146
|
+
|
|
147
|
+
`TransactionalInterceptor` also binds pico-ioc's `"transaction"` **DI scope** to the same boundary. Whenever a *new* transaction is born — `REQUIRES_NEW`, or `REQUIRED` with no enclosing transaction — it activates a fresh `"transaction"` scope (with `cleanup=True`) around the call:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
Service.create_user() ← @transactional REQUIRED (new tx)
|
|
151
|
+
│ activate "transaction" scope (id = T1) _tx_context = session_A
|
|
152
|
+
│
|
|
153
|
+
├─ container.get(AuditLog) → scope="transaction" ← created once, id T1
|
|
154
|
+
├─ container.get(AuditLog) → same instance ← joined: same T1
|
|
155
|
+
│
|
|
156
|
+
├─ Other.in_new_tx() ← @transactional REQUIRES_NEW
|
|
157
|
+
│ │ activate "transaction" scope (id = T2) _tx_context = session_B
|
|
158
|
+
│ └─ container.get(AuditLog) → NEW instance ← isolated in T2
|
|
159
|
+
│ deactivate T2 (+ @cleanup), restore T1, session_A
|
|
160
|
+
│
|
|
161
|
+
└─ commit / rollback
|
|
162
|
+
deactivate T1 (runs @cleanup on T1's components), _tx_context = None
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
So a `scope="transaction"` component is **one instance per transaction** — a Unit-of-Work / identity-map that is released (running its `@cleanup` hooks) when the transaction ends. Joins reuse the enclosing instance; `REQUIRES_NEW` gets its own and restores the outer one afterwards. Non-transactional paths (`NEVER`, `NOT_SUPPORTED`, `SUPPORTS` without a tx) open no scope — resolving a `scope="transaction"` component there raises `ScopeError`.
|
|
166
|
+
|
|
167
|
+
> The scope is bound at the interceptor (which wraps the call directly), not deep inside `SessionManager`: a `ContextVar` set inside the transaction async-generator does not reliably reach the method body across nesting. As with any AOP, **self-invocation bypasses it** — `REQUIRES_NEW` only opens a new transaction/scope when the method is called on another injected component, not via `self.method()`.
|
|
144
168
|
|
|
145
169
|
---
|
|
146
170
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
curl -sL https://raw.githubusercontent.com/dperezcabrera/pico-skills/main/install.sh | bash -s -- sqlalchemy
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Or install all pico
|
|
11
|
+
Or install all pico skills:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
curl -sL https://raw.githubusercontent.com/dperezcabrera/pico-skills/main/install.sh | bash
|
|
@@ -47,7 +47,7 @@ Creates a new pico-ioc component with dependency injection. Use when adding serv
|
|
|
47
47
|
|
|
48
48
|
### `/add-tests`
|
|
49
49
|
|
|
50
|
-
Generates tests for existing pico
|
|
50
|
+
Generates tests for existing pico components. Creates integration tests with in-memory database for repositories and unit tests for services.
|
|
51
51
|
|
|
52
52
|
```
|
|
53
53
|
/add-tests ProductRepository --integration
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
3
|
from pico_boot import init
|
|
4
|
-
from pico_ioc import
|
|
4
|
+
from pico_ioc import YamlTreeSource, configuration
|
|
5
5
|
|
|
6
6
|
from .services import UserService
|
|
7
7
|
|
|
@@ -9,14 +9,8 @@ from .services import UserService
|
|
|
9
9
|
async def main():
|
|
10
10
|
config = configuration(YamlTreeSource("config.yml"))
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"app.models",
|
|
15
|
-
"app.repositories",
|
|
16
|
-
"app.services",
|
|
17
|
-
],
|
|
18
|
-
config=config,
|
|
19
|
-
)
|
|
12
|
+
# pico-boot scans "app" recursively and auto-discovers pico-sqlalchemy
|
|
13
|
+
container = init(modules=["app"], config=config)
|
|
20
14
|
|
|
21
15
|
service = await container.aget(UserService)
|
|
22
16
|
|
|
@@ -18,7 +18,8 @@ Typical usage::
|
|
|
18
18
|
config = configuration(DictSource({
|
|
19
19
|
"database": {"url": "sqlite+aiosqlite:///:memory:"}
|
|
20
20
|
}))
|
|
21
|
-
|
|
21
|
+
# pico-boot auto-discovers pico-sqlalchemy — just list your app module
|
|
22
|
+
container = init(modules=["my_app"], config=config)
|
|
22
23
|
"""
|
|
23
24
|
|
|
24
25
|
from .base import AppBase, Mapped, mapped_column
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.4.0'
|
|
@@ -53,7 +53,9 @@ class PicoSqlAlchemyLifecycle:
|
|
|
53
53
|
discovered by the container (injected as a list).
|
|
54
54
|
"""
|
|
55
55
|
valid = [
|
|
56
|
-
c
|
|
56
|
+
c
|
|
57
|
+
for c in configurers
|
|
58
|
+
if isinstance(c, DatabaseConfigurer) and callable(getattr(c, "configure_database", None))
|
|
57
59
|
]
|
|
58
60
|
ordered = sorted(valid, key=_priority_of)
|
|
59
61
|
for cfg in ordered:
|
|
@@ -14,9 +14,11 @@ Priority resolution order (highest wins):
|
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
import inspect
|
|
17
|
+
from contextlib import nullcontext
|
|
17
18
|
from typing import Any, Callable
|
|
19
|
+
from uuid import uuid4
|
|
18
20
|
|
|
19
|
-
from pico_ioc import MethodCtx, MethodInterceptor, component
|
|
21
|
+
from pico_ioc import MethodCtx, MethodInterceptor, PicoContainer, component
|
|
20
22
|
|
|
21
23
|
from .decorators import QUERY_META, REPOSITORY_META, TRANSACTIONAL_META
|
|
22
24
|
from .session import SessionManager
|
|
@@ -41,13 +43,26 @@ class TransactionalInterceptor(MethodInterceptor):
|
|
|
41
43
|
If none of these markers are present, the method is invoked directly
|
|
42
44
|
without opening a transaction.
|
|
43
45
|
|
|
46
|
+
In addition to the SQLAlchemy transaction, this interceptor binds
|
|
47
|
+
pico-ioc's ``"transaction"`` DI scope to the same boundary: whenever a
|
|
48
|
+
*new* transaction is started (``REQUIRES_NEW``, or ``REQUIRED`` with no
|
|
49
|
+
enclosing transaction) it activates a fresh ``"transaction"`` scope for
|
|
50
|
+
the duration of the call, so ``scope="transaction"`` components live
|
|
51
|
+
exactly one transaction and are released (running their ``@cleanup``
|
|
52
|
+
hooks) when it ends. Joins reuse the enclosing scope.
|
|
53
|
+
|
|
44
54
|
Args:
|
|
45
55
|
session_manager: The ``SessionManager`` singleton used to open
|
|
46
56
|
or join transactions.
|
|
57
|
+
container: The pico-ioc container, used to activate the
|
|
58
|
+
``"transaction"`` DI scope around new transactions. Required —
|
|
59
|
+
auto-injected by pico-ioc; directly-constructed interceptors
|
|
60
|
+
(e.g. in a unit test) must pass a real container.
|
|
47
61
|
"""
|
|
48
62
|
|
|
49
|
-
def __init__(self, session_manager: SessionManager):
|
|
63
|
+
def __init__(self, session_manager: SessionManager, container: PicoContainer):
|
|
50
64
|
self.sm = session_manager
|
|
65
|
+
self.container = container
|
|
51
66
|
|
|
52
67
|
async def invoke(self, ctx: MethodCtx, call_next: Callable[[MethodCtx], Any]) -> Any:
|
|
53
68
|
"""Intercept the method call and wrap it in a transaction if needed.
|
|
@@ -98,6 +113,20 @@ class TransactionalInterceptor(MethodInterceptor):
|
|
|
98
113
|
rollback_for = meta["rollback_for"]
|
|
99
114
|
no_rollback_for = meta["no_rollback_for"]
|
|
100
115
|
|
|
116
|
+
# A *new* transaction is born when REQUIRES_NEW is requested, or
|
|
117
|
+
# when REQUIRED finds no enclosing transaction. Those are exactly
|
|
118
|
+
# the cases that should push a fresh DI "transaction" scope; every
|
|
119
|
+
# other mode (joins, MANDATORY, SUPPORTS-with-tx) reuses the
|
|
120
|
+
# enclosing scope, and the non-transactional modes open none. We
|
|
121
|
+
# bind the scope here, at the interceptor, rather than inside
|
|
122
|
+
# SessionManager: a ContextVar set deep in the transaction
|
|
123
|
+
# async-generator does not reliably reach the method body across
|
|
124
|
+
# nesting, whereas this frame wraps ``call_next`` directly.
|
|
125
|
+
opens_new_tx = propagation == "REQUIRES_NEW" or (
|
|
126
|
+
propagation == "REQUIRED" and self.sm.get_current_session() is None
|
|
127
|
+
)
|
|
128
|
+
scope_cm = self.container.scope("transaction", uuid4().hex, cleanup=True) if opens_new_tx else nullcontext()
|
|
129
|
+
|
|
101
130
|
async with self.sm.transaction(
|
|
102
131
|
propagation=propagation,
|
|
103
132
|
read_only=read_only,
|
|
@@ -105,7 +134,8 @@ class TransactionalInterceptor(MethodInterceptor):
|
|
|
105
134
|
rollback_for=rollback_for,
|
|
106
135
|
no_rollback_for=no_rollback_for,
|
|
107
136
|
):
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
137
|
+
with scope_cm:
|
|
138
|
+
result = call_next(ctx)
|
|
139
|
+
if inspect.isawaitable(result):
|
|
140
|
+
result = await result
|
|
141
|
+
return result
|