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.
Files changed (80) hide show
  1. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/publish-to-pypi.yml +12 -0
  2. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/AGENTS.md +4 -3
  3. {pico_sqlalchemy-0.3.0/docs → pico_sqlalchemy-0.4.0}/CHANGELOG.md +11 -0
  4. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/CLAUDE.md +2 -2
  5. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/PKG-INFO +39 -20
  6. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/README.md +37 -18
  7. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0/docs}/CHANGELOG.md +11 -0
  8. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/architecture.md +26 -2
  9. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/skills.md +2 -2
  10. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/main.py +3 -9
  11. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/repositories.py +2 -1
  12. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/pyproject.toml +1 -1
  13. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/__init__.py +2 -1
  14. pico_sqlalchemy-0.4.0/src/pico_sqlalchemy/_version.py +1 -0
  15. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/factory.py +3 -1
  16. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/interceptor.py +36 -6
  17. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/PKG-INFO +39 -20
  18. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/SOURCES.txt +2 -1
  19. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/requires.txt +1 -1
  20. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/conftest.py +11 -0
  21. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_coverage_boost_v2.py +4 -4
  22. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_interceptor.py +5 -5
  23. pico_sqlalchemy-0.4.0/tests/test_transaction_scope.py +133 -0
  24. pico_sqlalchemy-0.3.0/src/pico_sqlalchemy/_version.py +0 -1
  25. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.coveragerc +0 -0
  26. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  27. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  28. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  29. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/dependabot.yml +0 -0
  30. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/ci.yml +0 -0
  31. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/codeql.yml +0 -0
  32. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/docs.yml +0 -0
  33. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/.github/workflows/sync-keywords.yml +0 -0
  34. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/CODE_OF_CONDUCT.md +0 -0
  35. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/CONTRIBUTING.md +0 -0
  36. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/LICENSE +0 -0
  37. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/MANIFEST.in +0 -0
  38. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/SECURITY.md +0 -0
  39. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/development/project-tooling.md +0 -0
  40. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/faq.md +0 -0
  41. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/hooks.py +0 -0
  42. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/how-to/alembic.md +0 -0
  43. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/how-to/multiple-databases.md +0 -0
  44. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/how-to/testing.md +0 -0
  45. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/index.md +0 -0
  46. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/javascripts/extra.js +0 -0
  47. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/migration.md +0 -0
  48. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/overview.md +0 -0
  49. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/quickstart.md +0 -0
  50. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/reference/configuration.md +0 -0
  51. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/reference/declarative-base.md +0 -0
  52. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/reference/repository.md +0 -0
  53. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/reference/transactions.md +0 -0
  54. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/requirements.txt +0 -0
  55. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/docs/stylesheets/extra.css +0 -0
  56. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/README.md +0 -0
  57. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/__init__.py +0 -0
  58. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/models.py +0 -0
  59. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/app/services.py +0 -0
  60. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/config.yml +0 -0
  61. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/examples/crud-async/requirements.txt +0 -0
  62. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/mkdocs.yml +0 -0
  63. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/setup.cfg +0 -0
  64. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/base.py +0 -0
  65. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/config.py +0 -0
  66. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/decorators.py +0 -0
  67. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/paging.py +0 -0
  68. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/py.typed +0 -0
  69. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/repository_interceptor.py +0 -0
  70. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy/session.py +0 -0
  71. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/dependency_links.txt +0 -0
  72. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/entry_points.txt +0 -0
  73. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/src/pico_sqlalchemy.egg-info/top_level.txt +0 -0
  74. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_ioc_integration.py +0 -0
  75. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_pagination_sort.py +0 -0
  76. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_repository_interceptor_coverage.py +0 -0
  77. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_repository_query.py +0 -0
  78. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_session_propagation.py +0 -0
  79. {pico_sqlalchemy-0.3.0 → pico_sqlalchemy-0.4.0}/tests/test_transaction_manager.py +0 -0
  80. {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` for session propagation. Separate from pico-ioc's "transaction" scope (which is for DI caching)
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` ContextVar is intentional and separate from pico-ioc scope system
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.0`
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 for session propagation, NOT the same as pico-ioc's "transaction" scope
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.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.0
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
- # 📦 pico-sqlalchemy
60
+ # pico-sqlalchemy
61
61
 
62
62
  [![PyPI](https://img.shields.io/pypi/v/pico-sqlalchemy.svg)](https://pypi.org/project/pico-sqlalchemy/)
63
63
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/dperezcabrera/pico-sqlalchemy)
@@ -68,6 +68,7 @@ Dynamic: license-file
68
68
  [![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)
69
69
  [![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)
70
70
  [![Docs](https://img.shields.io/badge/Docs-pico--sqlalchemy-blue?style=flat&logo=readthedocs&logoColor=white)](https://dperezcabrera.github.io/pico-sqlalchemy/)
71
+ [![Interactive Lab](https://img.shields.io/badge/Learn-online-green?style=flat&logo=python&logoColor=white)](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
- > 🐍 **Requires Python 3.11+**
79
- > 🚀 **Async-Native:** Built entirely on `AsyncSession` and `create_async_engine`.
80
- > **Zero-Boilerplate:** Repositories are transactional by default.
81
- > 🔍 **Declarative Queries:** Define SQL or expressions in decorators; the library executes them for you.
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
- ## 🎯 Why pico-sqlalchemy?
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
- ## 🧱 Core Features
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
- ## 📦 Installation
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
- ## 🚀 Quick Example
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
- ## Transaction Hierarchy & Rules
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
- 👉 **Result:** Active Read-Write Transaction (Implicit from `@repository`).
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
- 👉 **Result:** Active Read-Only Transaction (Implicit from `@query`).
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
- 👉 **Result:** Active Read-Only Transaction (Explicit override).
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
- ## 🔍 Declarative Queries in Depth
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
- ## 🧪 Testing
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
- ## 💡 Architecture Overview
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-framework components |
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
- ## 📝 License
379
+ ## License
361
380
 
362
381
  MIT
@@ -1,4 +1,4 @@
1
- # 📦 pico-sqlalchemy
1
+ # pico-sqlalchemy
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/pico-sqlalchemy.svg)](https://pypi.org/project/pico-sqlalchemy/)
4
4
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/dperezcabrera/pico-sqlalchemy)
@@ -9,6 +9,7 @@
9
9
  [![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)
10
10
  [![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)
11
11
  [![Docs](https://img.shields.io/badge/Docs-pico--sqlalchemy-blue?style=flat&logo=readthedocs&logoColor=white)](https://dperezcabrera.github.io/pico-sqlalchemy/)
12
+ [![Interactive Lab](https://img.shields.io/badge/Learn-online-green?style=flat&logo=python&logoColor=white)](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
- > 🐍 **Requires Python 3.11+**
20
- > 🚀 **Async-Native:** Built entirely on `AsyncSession` and `create_async_engine`.
21
- > **Zero-Boilerplate:** Repositories are transactional by default.
22
- > 🔍 **Declarative Queries:** Define SQL or expressions in decorators; the library executes them for you.
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
- ## 🎯 Why pico-sqlalchemy?
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
- ## 🧱 Core Features
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
- ## 📦 Installation
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
- ## 🚀 Quick Example
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
- ## Transaction Hierarchy & Rules
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
- 👉 **Result:** Active Read-Write Transaction (Implicit from `@repository`).
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
- 👉 **Result:** Active Read-Only Transaction (Implicit from `@query`).
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
- 👉 **Result:** Active Read-Only Transaction (Explicit override).
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
- ## 🔍 Declarative Queries in Depth
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
- ## 🧪 Testing
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
- ## 💡 Architecture Overview
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-framework components |
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
- ## 📝 License
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 **separate from pico-ioc's scope system**. It is a lightweight, per-async-task variable that stores the currently active `AsyncSession` wrapped in a `TransactionContext`.
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 not pico-ioc scopes?** The `_tx_context` ContextVar provides transaction propagation semantics (REQUIRED, REQUIRES_NEW, etc.) that don't map to pico-ioc's scope lifecycle. A transaction may be suspended and restored (REQUIRES_NEW, NOT_SUPPORTED), which requires explicit save/restore of the context — something ContextVar handles naturally.
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-framework skills:
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-framework components. Creates integration tests with in-memory database for repositories and unit tests for services.
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 configuration, YamlTreeSource
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
- container = init(
13
- modules=[
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
 
@@ -1,5 +1,6 @@
1
1
  from pico_ioc import component
2
- from pico_sqlalchemy import transactional, repository
2
+
3
+ from pico_sqlalchemy import repository, transactional
3
4
 
4
5
  from .models import User
5
6
 
@@ -37,7 +37,7 @@ classifiers = [
37
37
  ]
38
38
 
39
39
  dependencies = [
40
- "pico-ioc >= 2.2.0",
40
+ "pico-ioc >= 2.2.6",
41
41
  "sqlalchemy >= 2.0",
42
42
  ]
43
43
 
@@ -18,7 +18,8 @@ Typical usage::
18
18
  config = configuration(DictSource({
19
19
  "database": {"url": "sqlite+aiosqlite:///:memory:"}
20
20
  }))
21
- container = init(modules=["pico_sqlalchemy", "my_app"], config=config)
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 for c in configurers if isinstance(c, DatabaseConfigurer) and callable(getattr(c, "configure_database", None))
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
- result = call_next(ctx)
109
- if inspect.isawaitable(result):
110
- result = await result
111
- return result
137
+ with scope_cm:
138
+ result = call_next(ctx)
139
+ if inspect.isawaitable(result):
140
+ result = await result
141
+ return result