python-neva 4.0.0__tar.gz → 5.0.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.
- {python_neva-4.0.0 → python_neva-5.0.0}/.gitlab-ci.yml +9 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/CHANGELOG.md +30 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/CLAUDE.md +12 -1
- {python_neva-4.0.0 → python_neva-5.0.0}/PKG-INFO +47 -6
- {python_neva-4.0.0 → python_neva-5.0.0}/README.md +44 -3
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/manager.py +0 -2
- python_neva-5.0.0/neva/guidelines/__init__.py +6 -0
- python_neva-5.0.0/neva/guidelines/fragments/configuration.md +108 -0
- python_neva-5.0.0/neva/guidelines/fragments/database-transactions.md +125 -0
- python_neva-5.0.0/neva/guidelines/fragments/events.md +150 -0
- python_neva-5.0.0/neva/guidelines/fragments/observability.md +116 -0
- python_neva-5.0.0/neva/guidelines/fragments/result-option.md +74 -0
- python_neva-5.0.0/neva/guidelines/fragments/service-providers.md +146 -0
- python_neva-5.0.0/neva/guidelines/fragments/testing.md +133 -0
- python_neva-5.0.0/neva/guidelines/manifest.py +26 -0
- python_neva-5.0.0/neva/obs/__init__.py +19 -0
- python_neva-5.0.0/neva/obs/config.py +37 -0
- python_neva-5.0.0/neva/obs/logging/__init__.py +22 -0
- python_neva-5.0.0/neva/obs/logging/channels.py +310 -0
- python_neva-5.0.0/neva/obs/logging/contracts.py +40 -0
- python_neva-5.0.0/neva/obs/logging/manager.py +196 -0
- python_neva-5.0.0/neva/obs/logging/provider.py +36 -0
- python_neva-5.0.0/neva/obs/logging/resolver.py +138 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/log.pyi +40 -0
- python_neva-5.0.0/neva/testing/py.typed +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/pyproject.toml +5 -3
- {python_neva-4.0.0 → python_neva-5.0.0}/ruff.toml +2 -2
- python_neva-5.0.0/tests/arch/test_register_resolution.py +108 -0
- python_neva-5.0.0/tests/obs/__init__.py +0 -0
- python_neva-5.0.0/tests/obs/conftest.py +133 -0
- python_neva-5.0.0/tests/obs/test_channels.py +213 -0
- python_neva-5.0.0/tests/obs/test_facade.py +90 -0
- python_neva-5.0.0/tests/obs/test_manager.py +160 -0
- python_neva-5.0.0/tests/obs/test_provider_hook.py +107 -0
- python_neva-5.0.0/tests/obs/test_resolver.py +127 -0
- python_neva-5.0.0/tests/support/__init__.py +0 -0
- python_neva-5.0.0/tests/support/test_results.py +310 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/uv.lock +20 -62
- python_neva-4.0.0/neva/obs/__init__.py +0 -9
- python_neva-4.0.0/neva/obs/instrumentation/__init__.py +0 -1
- python_neva-4.0.0/neva/obs/instrumentation/sqlalchemy.py +0 -15
- python_neva-4.0.0/neva/obs/logging/__init__.py +0 -10
- python_neva-4.0.0/neva/obs/logging/manager.py +0 -90
- python_neva-4.0.0/neva/obs/logging/provider.py +0 -26
- python_neva-4.0.0/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -41
- python_neva-4.0.0/wiki/architecture/configuration.md +0 -340
- python_neva-4.0.0/wiki/architecture/database-and-transactions.md +0 -407
- python_neva-4.0.0/wiki/architecture/dependency-injection.md +0 -584
- python_neva-4.0.0/wiki/architecture/events.md +0 -424
- python_neva-4.0.0/wiki/architecture/result-option.md +0 -296
- python_neva-4.0.0/wiki/architecture/testing.md +0 -425
- {python_neva-4.0.0 → python_neva-5.0.0}/.claude/settings.local.json +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/.envrc +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/.gitignore +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/.pre-commit-config.yaml +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/.python-version +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/application.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/config.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/facade.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/integrations/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/integrations/faststream.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/markers.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/py.typed +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/scopes.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/service_provider.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/base_providers.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/loader.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/py.typed +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/repository.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/config.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/connection.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/provider.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/py.typed +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/transaction.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/dispatcher.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/event.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/handler.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/listener.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/dispatcher.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/event.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/event_registry.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/listener.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/policy.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/provider.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/py.typed +0 -0
- {python_neva-4.0.0/neva/obs → python_neva-5.0.0/neva/guidelines}/py.typed +0 -0
- {python_neva-4.0.0/neva/polyfactory → python_neva-5.0.0/neva/obs}/py.typed +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/polyfactory/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/polyfactory/factories.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/polyfactory/persistence.py +0 -0
- {python_neva-4.0.0/neva/security → python_neva-5.0.0/neva/polyfactory}/py.typed +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/encryption/encrypter.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/config.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/provider.py +0 -0
- {python_neva-4.0.0/neva/support → python_neva-5.0.0/neva/security}/py.typed +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/tokens/generate_token.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/accessors.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/app.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/config.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/db.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/db.pyi +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/event.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/hash.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/hash.pyi +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/log.py +0 -0
- {python_neva-4.0.0/neva/testing → python_neva-5.0.0/neva/support}/py.typed +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/results.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/strategy.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/strconv.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/time.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/testing/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/testing/fakes.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/testing/fixtures.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/neva/testing/test_case.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/scripts/retag-with-changelog.sh +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_cache.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_config_shapes.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_context.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_extends.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_facade_root_nesting.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_lifetimes.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_registration.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_scope.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/config/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/config/test_config_path_resolution.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/config/test_loader.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/config/test_repository.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/conftest.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_connection_manager.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_database_manager.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_detached_lifespan.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_edge_cases.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_sqlalchemy_integration.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_transaction.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_transaction_callbacks.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_transaction_context.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/conftest.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_before_dispatch.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_binding.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_deferred.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_dispatch.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_event.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_function_listener.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_immediate.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_listen_on_parent_class.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_listener_wiring.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/security/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/security/test_encrypter.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/security/test_hash_manager.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/security/test_tokens.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/__init__.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_application_reuse.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_create_config_migration.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_event_fake.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_facade_restore.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_fixtures.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_refresh_database.py +0 -0
- {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_test_case.py +0 -0
|
@@ -48,6 +48,15 @@ pyrefly:
|
|
|
48
48
|
script:
|
|
49
49
|
- uv run --frozen pyrefly check
|
|
50
50
|
|
|
51
|
+
# The fragments shipped in the wheel cite the tests that prove their claims.
|
|
52
|
+
# Every cited path must still resolve, so a behaviour change that leaves the
|
|
53
|
+
# guidance behind fails here rather than reaching an agent as a wrong answer.
|
|
54
|
+
guidelines:
|
|
55
|
+
extends: [.uv_base, .default_rules]
|
|
56
|
+
stage: code_qa
|
|
57
|
+
script:
|
|
58
|
+
- uv run --frozen neva-boost check neva/guidelines/fragments
|
|
59
|
+
|
|
51
60
|
##### Test #####
|
|
52
61
|
pytest:
|
|
53
62
|
extends: [.uv_base, .default_rules]
|
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
## 5.0.0 (2026-09-01)
|
|
2
|
+
|
|
3
|
+
### 💥 Boom
|
|
4
|
+
|
|
5
|
+
- **obs**: drop OpenTelemetry and pyinstrument from the core
|
|
6
|
+
|
|
7
|
+
### ✨ Features
|
|
8
|
+
|
|
9
|
+
- **obs**: resolve logging through named channels
|
|
10
|
+
- **deps**: offer neva-boost through the boost extra
|
|
11
|
+
|
|
12
|
+
### 💚👷 CI & Build
|
|
13
|
+
|
|
14
|
+
- **guidelines**: fail when a fragment cites a test that is gone
|
|
15
|
+
|
|
16
|
+
### 📝💡 Documentation
|
|
17
|
+
|
|
18
|
+
- **guidelines**: rule out resolving from the container in register()
|
|
19
|
+
- **guidelines**: cover logging channels, and where OTel now lives
|
|
20
|
+
|
|
21
|
+
## 4.1.0 (2026-08-31)
|
|
22
|
+
|
|
23
|
+
### ✅🤡🧪 Tests
|
|
24
|
+
|
|
25
|
+
- **support**: add tests for results and options
|
|
26
|
+
|
|
27
|
+
### 📝💡 Documentation
|
|
28
|
+
|
|
29
|
+
- **guidelines**: ship agent guidelines in the wheel
|
|
30
|
+
|
|
1
31
|
## 4.0.0 (2026-08-28)
|
|
2
32
|
|
|
3
33
|
### 💥 Boom
|
|
@@ -138,4 +138,15 @@ rendered changelog. See `README.md` for the full flow and the plugin list.
|
|
|
138
138
|
|
|
139
139
|
## Documentation
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
Agent guidelines live in `neva/guidelines/fragments/` and ship inside the wheel, so a
|
|
142
|
+
consuming project gets the guidance for the version it actually installed. They are
|
|
143
|
+
discovered by `neva-boost` through the `neva.boost` entry point (`neva/guidelines/manifest.py`).
|
|
144
|
+
|
|
145
|
+
- One fragment per topic, with frontmatter: `id`, `title`, `requires` (a version
|
|
146
|
+
specifier), `triggers`, `priority`, `verified_by`.
|
|
147
|
+
- **`verified_by` lists the tests that prove the fragment's claims.** Every path must
|
|
148
|
+
resolve. When you change behaviour, update the fragment in the same commit as the code
|
|
149
|
+
and the test — that pairing is what keeps the guidance honest.
|
|
150
|
+
- Terse and rule-shaped, not narrative: this text is loaded into an agent's context.
|
|
151
|
+
- Nothing here may describe another package. `neva-fastapi` and friends ship their own
|
|
152
|
+
fragments; a cross-package claim made here cannot be verified or maintained.
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-neva
|
|
3
|
-
Version:
|
|
3
|
+
Version: 5.0.0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: aiosqlite>=0.20.0
|
|
7
7
|
Requires-Dist: asyncpg>=0.30.0
|
|
8
8
|
Requires-Dist: cryptography>=46.0.3
|
|
9
9
|
Requires-Dist: dishka>=1.10.0
|
|
10
|
-
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.62b0
|
|
11
10
|
Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
|
|
12
11
|
Requires-Dist: pydantic>=2.7
|
|
13
|
-
Requires-Dist: pyinstrument>=5.1.1
|
|
14
12
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
15
13
|
Requires-Dist: structlog>=25.5.0
|
|
16
14
|
Requires-Dist: typing-extensions>=4.13
|
|
17
15
|
Provides-Extra: asgi
|
|
18
16
|
Requires-Dist: neva-asgi>=0.1.1; extra == 'asgi'
|
|
17
|
+
Provides-Extra: boost
|
|
18
|
+
Requires-Dist: neva-boost>=0.2.1; extra == 'boost'
|
|
19
19
|
Provides-Extra: fastapi
|
|
20
20
|
Requires-Dist: neva-fastapi>=1.1.1; extra == 'fastapi'
|
|
21
21
|
Provides-Extra: faststream
|
|
@@ -47,17 +47,59 @@ independent repo, independently versioned and published.
|
|
|
47
47
|
|
|
48
48
|
| Package | Repo / directory | Role | Status |
|
|
49
49
|
| ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
|
|
50
|
-
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security,
|
|
50
|
+
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, structured logging, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
|
|
51
51
|
| `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
|
|
52
52
|
| `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
|
|
53
53
|
| `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
|
|
54
54
|
| `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
|
|
55
|
+
| `neva-otel` | `neva-otel/` | **OpenTelemetry** — SDK wiring, tracer and meter providers, exporters, samplers and instrumentors. Kept out of the core so nothing is committed to an observability backend. | planned |
|
|
56
|
+
| `neva-boost` | `neva-boost/` | **Agent guidelines** — composes each installed package's versioned guideline fragments into a project's agent configuration (Claude Code skills or `AGENTS.md`). Dev-time tooling; pulled in via the `python-neva[boost]` extra. | published ([PyPI](https://pypi.org/project/neva-boost/)) |
|
|
55
57
|
| `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
|
|
56
58
|
|
|
57
59
|
> When working across packages locally, each repo can be cloned as a sibling
|
|
58
60
|
> directory; integration packages depend on `python-neva` from PyPI (or an
|
|
59
61
|
> editable local path during development).
|
|
60
62
|
|
|
63
|
+
## Logging
|
|
64
|
+
|
|
65
|
+
Logging is a set of named channels, each with its own driver, level and output —
|
|
66
|
+
Laravel's `Log`, configured from one `config/obs.py` namespace.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
config = {
|
|
70
|
+
"logging": {
|
|
71
|
+
"default": "stdout",
|
|
72
|
+
"channels": {
|
|
73
|
+
"stdout": {"driver": "console", "level": "DEBUG"},
|
|
74
|
+
"json": {"driver": "json", "stream": "stderr", "level": "INFO"},
|
|
75
|
+
"audit": {"driver": "file", "path": "var/audit.log"},
|
|
76
|
+
"both": {"driver": "stack", "channels": ["json", "audit"]},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from neva.support.facade import Log
|
|
84
|
+
|
|
85
|
+
Log.info("order placed", order_id=42) # default channel
|
|
86
|
+
Log.channel("audit").warning("role granted", actor="root")
|
|
87
|
+
Log.bind(tenant="acme") # every channel, this context
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Drivers are `console`, `json`, `file`, `stack` and `null`. With no `config/obs.py`
|
|
91
|
+
at all, logging still works: a console channel on stdout at `DEBUG`.
|
|
92
|
+
|
|
93
|
+
Each channel carries its own structlog processor chain rather than going through
|
|
94
|
+
the process-global `structlog.configure()`, so one application's renderer cannot
|
|
95
|
+
leak into another in the same interpreter. `LogManager.processor(fn)` lets a
|
|
96
|
+
plugin add a processor to every chain — the seam `neva-otel` uses to put the
|
|
97
|
+
current span's ids on every record.
|
|
98
|
+
|
|
99
|
+
**Tracing and metrics are deliberately not here.** They live in `neva-otel`, so
|
|
100
|
+
the core carries no OpenTelemetry dependency and commits no application to an
|
|
101
|
+
observability backend.
|
|
102
|
+
|
|
61
103
|
## Develop
|
|
62
104
|
|
|
63
105
|
```bash
|
|
@@ -103,10 +145,9 @@ rewrite the new tag with the rendered changelog as its annotation.
|
|
|
103
145
|
## Feature ideas
|
|
104
146
|
|
|
105
147
|
- Feature-parity FastStream integration (now scaffolded as `neva-faststream`)
|
|
106
|
-
- Finish cleaning up the core package (moving any remaining ASGI-dependent code into the appropriate integration packages)
|
|
107
148
|
- Improved router registration (auto-discovery OR provider-based? both?)
|
|
108
149
|
- Improved security tooling (performance improvements, better defaults, etc.)
|
|
109
|
-
-
|
|
150
|
+
- OpenTelemetry as a plugin (`neva-otel`) — the core keeps logging and takes no OTel dependency
|
|
110
151
|
- Improved factory module (based on Polyfactory)
|
|
111
152
|
- Queue/Jobs system
|
|
112
153
|
- CLI integration
|
|
@@ -18,17 +18,59 @@ independent repo, independently versioned and published.
|
|
|
18
18
|
|
|
19
19
|
| Package | Repo / directory | Role | Status |
|
|
20
20
|
| ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
|
|
21
|
-
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security,
|
|
21
|
+
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, structured logging, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
|
|
22
22
|
| `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
|
|
23
23
|
| `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
|
|
24
24
|
| `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
|
|
25
25
|
| `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
|
|
26
|
+
| `neva-otel` | `neva-otel/` | **OpenTelemetry** — SDK wiring, tracer and meter providers, exporters, samplers and instrumentors. Kept out of the core so nothing is committed to an observability backend. | planned |
|
|
27
|
+
| `neva-boost` | `neva-boost/` | **Agent guidelines** — composes each installed package's versioned guideline fragments into a project's agent configuration (Claude Code skills or `AGENTS.md`). Dev-time tooling; pulled in via the `python-neva[boost]` extra. | published ([PyPI](https://pypi.org/project/neva-boost/)) |
|
|
26
28
|
| `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
|
|
27
29
|
|
|
28
30
|
> When working across packages locally, each repo can be cloned as a sibling
|
|
29
31
|
> directory; integration packages depend on `python-neva` from PyPI (or an
|
|
30
32
|
> editable local path during development).
|
|
31
33
|
|
|
34
|
+
## Logging
|
|
35
|
+
|
|
36
|
+
Logging is a set of named channels, each with its own driver, level and output —
|
|
37
|
+
Laravel's `Log`, configured from one `config/obs.py` namespace.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
config = {
|
|
41
|
+
"logging": {
|
|
42
|
+
"default": "stdout",
|
|
43
|
+
"channels": {
|
|
44
|
+
"stdout": {"driver": "console", "level": "DEBUG"},
|
|
45
|
+
"json": {"driver": "json", "stream": "stderr", "level": "INFO"},
|
|
46
|
+
"audit": {"driver": "file", "path": "var/audit.log"},
|
|
47
|
+
"both": {"driver": "stack", "channels": ["json", "audit"]},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from neva.support.facade import Log
|
|
55
|
+
|
|
56
|
+
Log.info("order placed", order_id=42) # default channel
|
|
57
|
+
Log.channel("audit").warning("role granted", actor="root")
|
|
58
|
+
Log.bind(tenant="acme") # every channel, this context
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Drivers are `console`, `json`, `file`, `stack` and `null`. With no `config/obs.py`
|
|
62
|
+
at all, logging still works: a console channel on stdout at `DEBUG`.
|
|
63
|
+
|
|
64
|
+
Each channel carries its own structlog processor chain rather than going through
|
|
65
|
+
the process-global `structlog.configure()`, so one application's renderer cannot
|
|
66
|
+
leak into another in the same interpreter. `LogManager.processor(fn)` lets a
|
|
67
|
+
plugin add a processor to every chain — the seam `neva-otel` uses to put the
|
|
68
|
+
current span's ids on every record.
|
|
69
|
+
|
|
70
|
+
**Tracing and metrics are deliberately not here.** They live in `neva-otel`, so
|
|
71
|
+
the core carries no OpenTelemetry dependency and commits no application to an
|
|
72
|
+
observability backend.
|
|
73
|
+
|
|
32
74
|
## Develop
|
|
33
75
|
|
|
34
76
|
```bash
|
|
@@ -74,10 +116,9 @@ rewrite the new tag with the rendered changelog as its annotation.
|
|
|
74
116
|
## Feature ideas
|
|
75
117
|
|
|
76
118
|
- Feature-parity FastStream integration (now scaffolded as `neva-faststream`)
|
|
77
|
-
- Finish cleaning up the core package (moving any remaining ASGI-dependent code into the appropriate integration packages)
|
|
78
119
|
- Improved router registration (auto-discovery OR provider-based? both?)
|
|
79
120
|
- Improved security tooling (performance improvements, better defaults, etc.)
|
|
80
|
-
-
|
|
121
|
+
- OpenTelemetry as a plugin (`neva-otel`) — the core keeps logging and takes no OTel dependency
|
|
81
122
|
- Improved factory module (based on Polyfactory)
|
|
82
123
|
- Queue/Jobs system
|
|
83
124
|
- CLI integration
|
|
@@ -10,7 +10,6 @@ from neva.database.config import ConnectionConfig
|
|
|
10
10
|
from neva.database.connection import ConnectionManager, TransactionContext
|
|
11
11
|
from neva.database.transaction import BoundTransaction
|
|
12
12
|
from neva.obs import LogManager
|
|
13
|
-
from neva.obs.instrumentation.sqlalchemy import instrument
|
|
14
13
|
from neva.support import Nothing, Option, Some, from_optional
|
|
15
14
|
|
|
16
15
|
|
|
@@ -36,7 +35,6 @@ class DatabaseManager:
|
|
|
36
35
|
name: The connection name.
|
|
37
36
|
engine: The async engine.
|
|
38
37
|
"""
|
|
39
|
-
instrument(engine)
|
|
40
38
|
self._connections[name] = ConnectionManager(
|
|
41
39
|
name,
|
|
42
40
|
self._tx_context,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: configuration
|
|
3
|
+
title: Configuration
|
|
4
|
+
requires: python-neva>=4.0
|
|
5
|
+
triggers: [reading a config value, adding a config file, Config.get, config namespace, environment settings, providers list]
|
|
6
|
+
priority: 50
|
|
7
|
+
verified_by: [tests/config/test_repository.py, tests/config/test_loader.py, tests/config/test_config_path_resolution.py, tests/arch/test_config_shapes.py]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Configuration
|
|
11
|
+
|
|
12
|
+
A flat directory of `.py` files, each exporting a module-level `config = {...}` dict. **The
|
|
13
|
+
filename is the namespace.** Values are read by dot-notation and come back as `Result`.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
src/config/app.py → "app"
|
|
17
|
+
src/config/database.py → "database"
|
|
18
|
+
src/config/providers.py → "providers"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Config files are real modules executed at boot, so they may compute values and import helpers.
|
|
22
|
+
Keep them pure — no I/O, no connections.
|
|
23
|
+
|
|
24
|
+
## Where the directory comes from
|
|
25
|
+
|
|
26
|
+
`config_path` argument, else `$NEVA_CONFIG_PATH`, else `./config` relative to the working
|
|
27
|
+
directory. Deliberately a dedicated subdirectory, never the project root: every `.py` in it is
|
|
28
|
+
imported to read its `config` dict, so a folder of ordinary modules would be executed.
|
|
29
|
+
|
|
30
|
+
The loader globs `*.py` except `__init__.py`, imports each file, and takes its `config`
|
|
31
|
+
attribute **only if it is a dict**. A file with no `config`, or a non-dict `config`, is skipped
|
|
32
|
+
silently — check that first when a namespace seems missing. A missing directory, a
|
|
33
|
+
`SyntaxError`, or any import-time exception aborts boot with `RuntimeError`.
|
|
34
|
+
|
|
35
|
+
There is no CLI, no generator, and no `ConfigServiceProvider`: loading is wired directly into
|
|
36
|
+
`Application.__init__`.
|
|
37
|
+
|
|
38
|
+
## Namespaces that drive boot
|
|
39
|
+
|
|
40
|
+
| Key | Meaning |
|
|
41
|
+
| --- | ------- |
|
|
42
|
+
| `providers.providers` | Providers to register. |
|
|
43
|
+
| `app.providers` | Further providers, appended after those. |
|
|
44
|
+
|
|
45
|
+
## Typed shapes
|
|
46
|
+
|
|
47
|
+
Each package declares a `TypedDict` for the keys **it** reads, so a shape never advertises keys
|
|
48
|
+
its own package ignores. Annotate your file to have a type checker catch typos before boot.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from neva.arch import AppConfig
|
|
52
|
+
config: AppConfig = {"key": "...", "providers": [ActorServiceProvider]}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`AppConfig` (`neva.arch`) covers `app.key`, `app.previous_keys`, `app.providers`, all optional.
|
|
56
|
+
`ProviderConfig` (`neva.arch`) covers `providers.providers`. `DatabaseConfig`
|
|
57
|
+
(`neva.database`) covers the `database` namespace, and its `connections` key is **required**.
|
|
58
|
+
The `app` namespace is shared — an integration package declares the keys it adds.
|
|
59
|
+
|
|
60
|
+
## Reading
|
|
61
|
+
|
|
62
|
+
Inject `ConfigRepository` inside services you unit-test; use the `Config` facade for
|
|
63
|
+
cross-cutting reads. Both resolve the same instance.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
Config.get("app.title").unwrap_or("Untitled")
|
|
67
|
+
Config.get("app.debug", type_=bool).unwrap_or(False)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`get(key, type_=None) -> Result[T, str]`. It never raises and has **no default parameter** — the
|
|
71
|
+
`Result` is the default mechanism.
|
|
72
|
+
|
|
73
|
+
**`type_` is a cast, not validation.** Nothing is coerced or checked:
|
|
74
|
+
`Config.get("app.debug", type_=bool)` over `debug = 0` returns the integer `0`, merely *typed*
|
|
75
|
+
as `bool`. Parse or validate separately, and wrap consumed config in a typed object.
|
|
76
|
+
|
|
77
|
+
`has(key) -> bool` is `get(key).is_ok`. `all() -> dict` returns a deep copy; mutating it never
|
|
78
|
+
reaches the repository, frozen or not.
|
|
79
|
+
|
|
80
|
+
## Writing
|
|
81
|
+
|
|
82
|
+
Mutable until frozen. This is the extension seam — a package registers defaults, an application
|
|
83
|
+
overrides leaves — and the seam tests use.
|
|
84
|
+
|
|
85
|
+
`set(key, value)` sets one dot-notation path, creating intermediate dicts, and errors if an
|
|
86
|
+
intermediate segment holds a non-dict.
|
|
87
|
+
|
|
88
|
+
**`merge(key, items)` is a recursive deep merge.** Nested dictionaries are combined rather than
|
|
89
|
+
replaced, so a leaf can be overridden without restating its siblings; only non-dict values are
|
|
90
|
+
replaced outright. Overriding one nested key needs nothing special:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
Config.merge("hashing", {"argon": {"time_cost": 2, "memory_cost": 1024}})
|
|
94
|
+
Config.merge("hashing", {"argon": {"time_cost": 4}})
|
|
95
|
+
# → argon == {"time_cost": 4, "memory_cost": 1024}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Boot uses exactly this: each loaded `{namespace: dict}` is merged in.
|
|
99
|
+
|
|
100
|
+
`freeze()` makes every later `set`/`merge` return `Err`. **The framework never calls it.** Config
|
|
101
|
+
stays writable at runtime, which is what keeps the extension seam and test overrides working.
|
|
102
|
+
Call it yourself if you want hard immutability after startup.
|
|
103
|
+
|
|
104
|
+
## In tests
|
|
105
|
+
|
|
106
|
+
`TestCase.create_config(tmp_path)` returns the directory the application is built from. Override
|
|
107
|
+
it as a **classmethod** — see the testing fragment. Because the repository is unfrozen you can
|
|
108
|
+
also adjust values mid-test with `Config.set` / `Config.merge`, or fake the facade.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: database-transactions
|
|
3
|
+
title: Database and transactions
|
|
4
|
+
requires: python-neva>=4.0
|
|
5
|
+
triggers: [database query, opening a transaction, DB.begin, session, savepoint, on_commit, rollback, SQLAlchemy]
|
|
6
|
+
priority: 40
|
|
7
|
+
verified_by: [tests/database/test_transaction.py, tests/database/test_transaction_callbacks.py, tests/database/test_transaction_context.py, tests/database/test_transaction_registry.py, tests/database/test_connection_manager.py, tests/database/test_database_manager.py, tests/database/test_multi_connection.py, tests/database/test_sqlalchemy_integration.py]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Database and transactions
|
|
11
|
+
|
|
12
|
+
A transaction-first wrapper over SQLAlchemy async. Never build an engine or session, never call
|
|
13
|
+
`session.commit()`. Open a unit of work, work through `tx.session`, and the block commits on
|
|
14
|
+
clean exit or rolls back on error.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
async with DB.begin() as tx:
|
|
18
|
+
tx.session.add(actor)
|
|
19
|
+
await tx.session.flush()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuring
|
|
23
|
+
|
|
24
|
+
One `database` namespace, mapping connection name to `ConnectionConfig`. `url` is required and
|
|
25
|
+
must use an **async** driver; `pool_size`, `max_overflow`, `pool_recycle`, `pool_pre_ping` and
|
|
26
|
+
`echo` are optional and pass straight to `create_async_engine`.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
config: DatabaseConfig = {
|
|
30
|
+
"connections": {"default": {"url": "postgresql+asyncpg://…", "echo": True}}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`DatabaseServiceProvider` binds `TransactionContext` and `DatabaseManager`, then registers each
|
|
35
|
+
configured connection during `lifespan()` and disposes the engines on shutdown. Session
|
|
36
|
+
factories use `expire_on_commit=False`, so ORM objects stay readable after commit.
|
|
37
|
+
|
|
38
|
+
**A missing or malformed `database` namespace does not fail boot.** The provider logs the error
|
|
39
|
+
and continues with zero connections; the failure then surfaces much later as
|
|
40
|
+
`RuntimeError: No connection registered with name default` from the first `DB.begin()`. If you
|
|
41
|
+
see that error, check the config namespace first.
|
|
42
|
+
|
|
43
|
+
## DatabaseManager
|
|
44
|
+
|
|
45
|
+
| Call | Returns | Purpose |
|
|
46
|
+
| ---- | ------- | ------- |
|
|
47
|
+
| `begin(name="default")` | ctx mgr of `BoundTransaction` | Open a unit of work. `RuntimeError` if the name is unregistered. |
|
|
48
|
+
| `session(connection=None)` | `Option[AsyncSession]` | Current session, only if this task may use it. |
|
|
49
|
+
| `current(connection=None)` | `Option[BoundTransaction]` | Innermost transaction (top of stack), or the one for a named connection. |
|
|
50
|
+
| `outermost()` | `Option[BoundTransaction]` | The transaction that commits **last**. What deferred events attach to. |
|
|
51
|
+
| `connection(name)` | `Option[ConnectionManager]` | The connection manager. |
|
|
52
|
+
| `register_connection(name, cfg)` | `None` | Build an engine from config. The normal path. |
|
|
53
|
+
| `register_engine(name, engine)` | `None` | Adopt an engine the caller built. For a schema on an in-memory database, where a second engine would be a second, empty database. |
|
|
54
|
+
| `close()` | `None` | Dispose every engine. |
|
|
55
|
+
|
|
56
|
+
Inject `DatabaseManager` in services you unit-test; use the `DB` facade for ambient access.
|
|
57
|
+
|
|
58
|
+
## The caller owns the transaction
|
|
59
|
+
|
|
60
|
+
An inner service reads the current session rather than opening its own:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
class CreateActor:
|
|
64
|
+
def __init__(self, db: DatabaseManager) -> None:
|
|
65
|
+
self._db = db
|
|
66
|
+
|
|
67
|
+
async def __call__(self, cmd: CreateActorCommand) -> Actor:
|
|
68
|
+
session = self._db.session().expect("CreateActor must run inside DB.begin()")
|
|
69
|
+
...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`session()` returns `Nothing` when no transaction is open **or** when the open one belongs to a
|
|
73
|
+
different asyncio task. Handle the `Option`; it is usually a wiring mistake.
|
|
74
|
+
|
|
75
|
+
## BoundTransaction
|
|
76
|
+
|
|
77
|
+
`session` (non-null inside the block), `on_commit(cb)`, `on_rollback(cb)`, `rollback()`,
|
|
78
|
+
`share()`, `is_root`, `is_active`, `parent`.
|
|
79
|
+
|
|
80
|
+
`await tx.rollback()` **marks** the transaction; the block then exits normally and nothing is
|
|
81
|
+
raised. Only an exception propagating out of the block rolls back *and* re-raises.
|
|
82
|
+
|
|
83
|
+
## Nesting is a savepoint
|
|
84
|
+
|
|
85
|
+
A second `begin()` on the same connection, from a task that can reach the parent, reuses the
|
|
86
|
+
parent session and opens a `SAVEPOINT`. An inner rollback undoes only the savepoint; the outer
|
|
87
|
+
transaction continues. Independent code paths can each wrap their work without knowing whether
|
|
88
|
+
they are outermost.
|
|
89
|
+
|
|
90
|
+
## Commit and rollback callbacks
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
tx.on_commit(lambda: send_confirmation_email(order.id))
|
|
94
|
+
tx.on_rollback(release_reserved_stock)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- **Sync or async, and `None` is a valid return.** The contract is
|
|
98
|
+
`Callable[[], Result[None, str] | None | Awaitable[...]]`. A bare side-effecting lambda is
|
|
99
|
+
fine; returning `None` is read as `Ok(None)`.
|
|
100
|
+
- **They bubble to the root** and fire only when the root settles — commit callbacks on commit,
|
|
101
|
+
rollback callbacks on rollback.
|
|
102
|
+
- **A raising callback is captured as `Err` and logged.** It never breaks the settle path or the
|
|
103
|
+
other callbacks, and it cannot abort a commit that already happened.
|
|
104
|
+
- **They run with no ambient transaction.** By the time one runs, the session is committed and
|
|
105
|
+
the transaction is off the registry, so `DB.current()` and `DB.session()` return `Nothing`.
|
|
106
|
+
This is what makes a callback the right place for an SMTP send or an HTTP call — none of it
|
|
107
|
+
pins the pooled connection or holds a lock. A callback needing the database opens its own
|
|
108
|
+
`DB.begin()`.
|
|
109
|
+
|
|
110
|
+
This is the machinery behind `DEFERRED` events.
|
|
111
|
+
|
|
112
|
+
## Task-bound sessions
|
|
113
|
+
|
|
114
|
+
A `BoundTransaction` records the asyncio task that opened it, and `AsyncSession` is not
|
|
115
|
+
concurrent-safe, so another task gets `Nothing` from `session()` and opens a fresh transaction
|
|
116
|
+
instead of reusing it. `tx.share()` lifts that for controlled **sequential** access only — its
|
|
117
|
+
canonical use is `RefreshDatabase`.
|
|
118
|
+
|
|
119
|
+
## Pitfalls
|
|
120
|
+
|
|
121
|
+
- Never call `session.commit()` / `session.rollback()`. The block owns settling.
|
|
122
|
+
- Never construct `AsyncEngine` / `AsyncSession` by hand.
|
|
123
|
+
- A nested rollback undoes the savepoint, not the transaction.
|
|
124
|
+
- `session()` and `current()` return `Option`. Handle `Nothing`.
|
|
125
|
+
- Use an async driver end to end.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: events
|
|
3
|
+
title: Events and handling policies
|
|
4
|
+
requires: python-neva>=4.0
|
|
5
|
+
triggers: [dispatching an event, writing a listener, DEFERRED vs IMMEDIATE, reacting to something that happened, side effect after commit]
|
|
6
|
+
priority: 30
|
|
7
|
+
verified_by: [tests/events/test_deferred.py, tests/events/test_immediate.py, tests/events/test_dispatch.py, tests/events/test_listen_on_parent_class.py, tests/events/test_function_listener.py, tests/events/test_before_dispatch.py, tests/events/test_listener_wiring.py]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Events and handling policies
|
|
11
|
+
|
|
12
|
+
A producer announces what happened; unrelated listeners react. The defining feature is
|
|
13
|
+
transaction awareness: a `DEFERRED` listener runs only after the surrounding database
|
|
14
|
+
transaction commits.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from neva.support.facade import Event
|
|
18
|
+
_ = await Event.dispatch(ActorCreated(actor_id=actor.id))
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Defining an event
|
|
22
|
+
|
|
23
|
+
Subclass `Event[T]`, where `T` types the `event_id`. The base supplies `event_id: T` and
|
|
24
|
+
`timestamp: datetime` (defaulted to `neva.support.time.utcnow`). Events are Pydantic models, so
|
|
25
|
+
they validate on construction and serialise.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
class ActorCreated(Event[uuid.UUID]):
|
|
29
|
+
actor_id: uuid.UUID
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Most applications define one project base event that fixes the id strategy
|
|
33
|
+
(`event_id: uuid.UUID = pydantic.Field(default_factory=uuid.uuid4)`) and let component events
|
|
34
|
+
declare payload only.
|
|
35
|
+
|
|
36
|
+
Naming: events are **past tense**.
|
|
37
|
+
|
|
38
|
+
## Writing a listener
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
class SendWelcomeEmail(EventListener[ActorCreated]):
|
|
42
|
+
policy = HandlingPolicy.DEFERRED # default is IMMEDIATE
|
|
43
|
+
|
|
44
|
+
def __init__(self, mailer: Mailer) -> None:
|
|
45
|
+
self._mailer = mailer
|
|
46
|
+
|
|
47
|
+
async def handle(self, event: ActorCreated) -> Result[None, str]:
|
|
48
|
+
await self._mailer.send_welcome(event.actor_id)
|
|
49
|
+
return Ok(None)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or, with no dependencies, decorate a handler — `@listener` synthesises a class named
|
|
53
|
+
`<Pascal>Listener` (`log_actor_created` → `LogActorCreatedListener`):
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
@listener(policy=HandlingPolicy.DEFERRED) # bare @listener() is IMMEDIATE
|
|
57
|
+
async def log_actor_created(event: ActorCreated) -> Result[None, str]:
|
|
58
|
+
Log.info("actor created", actor_id=str(event.actor_id))
|
|
59
|
+
return Ok(None)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Naming: class listeners are imperative (`SendWelcomeEmail`), function handlers snake_case.
|
|
63
|
+
|
|
64
|
+
**Always define `handle`.** The base class body is `...` and is not abstract, so an omitted
|
|
65
|
+
`handle` silently returns `None` where the contract promises `Result[None, str]`.
|
|
66
|
+
|
|
67
|
+
**Return `Err`, never raise.** A raised exception is captured as `Err(f"Listener raised: {e}")`
|
|
68
|
+
and never reaches the producer or the other listeners.
|
|
69
|
+
|
|
70
|
+
## Policy × transaction
|
|
71
|
+
|
|
72
|
+
| Policy | Inside an open transaction | Outside any transaction |
|
|
73
|
+
| ------ | -------------------------- | ----------------------- |
|
|
74
|
+
| `IMMEDIATE` | runs during `dispatch()` | runs during `dispatch()` |
|
|
75
|
+
| `DEFERRED` | runs after the transaction commits | runs during `dispatch()` |
|
|
76
|
+
|
|
77
|
+
`DEFERRED` is for effects that must not happen unless the data persisted — mail, indexing,
|
|
78
|
+
webhooks, outbound calls. On rollback they never fire. With no transaction open, `DEFERRED`
|
|
79
|
+
degrades to immediate: same call site, different timing by context.
|
|
80
|
+
|
|
81
|
+
## What dispatch does
|
|
82
|
+
|
|
83
|
+
1. Runs `before_dispatch` hooks.
|
|
84
|
+
2. Resolves listeners, walking the event's **MRO** — a listener registered against a base event
|
|
85
|
+
also fires for subclasses. A listener reached through several registries fires **once**;
|
|
86
|
+
repeated registrations against the *same* event are honoured and fire repeatedly.
|
|
87
|
+
3. Splits by policy.
|
|
88
|
+
4. Branches on `DatabaseManager.outermost()` — the transaction that commits **last**, not
|
|
89
|
+
`current()`. Deferred listeners attach to it via `on_commit`, so they never run for work an
|
|
90
|
+
enclosing transaction may still roll back. With no transaction, deferred listeners join the
|
|
91
|
+
immediate list.
|
|
92
|
+
5. Runs immediate listeners, resolving each from the container and awaiting `handle`.
|
|
93
|
+
6. Returns one `Result` per **immediate** listener.
|
|
94
|
+
|
|
95
|
+
The returned list therefore says nothing about deferred work. Never use it to confirm a
|
|
96
|
+
deferred listener ran.
|
|
97
|
+
|
|
98
|
+
## Resolution
|
|
99
|
+
|
|
100
|
+
`_resolve_listener` asks the container, then falls back to no-argument instantiation. A listener
|
|
101
|
+
with constructor dependencies **must** be bound — the `listen` ClassVar does that for you.
|
|
102
|
+
|
|
103
|
+
## Registering
|
|
104
|
+
|
|
105
|
+
Declarative, and preferred:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
class ActorServiceProvider(ServiceProvider):
|
|
109
|
+
listen = {ActorCreated: [SendWelcomeEmail, IndexActorForSearch]}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Listener classes are bound at `Scope.APP` onto the application's root provider during
|
|
113
|
+
registration, then wired onto the dispatcher during `lifespan()`. If `listen` mappings exist and
|
|
114
|
+
the dispatcher cannot be resolved, boot **raises `RuntimeError`** naming the count and the fix
|
|
115
|
+
rather than leaving every listener silently unwired.
|
|
116
|
+
|
|
117
|
+
Imperative registration also works. Note the type stubs currently declare only the single-class
|
|
118
|
+
form, so a list argument type-checks as an error even though the dispatcher accepts it:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
Event.listen(ActorCreated, SendWelcomeEmail)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`EventServiceProvider` is a base provider — always registered, never added by hand.
|
|
125
|
+
|
|
126
|
+
## before_dispatch hooks
|
|
127
|
+
|
|
128
|
+
Run once per dispatch, before any listener. The **hook** may be sync or async; the
|
|
129
|
+
**registration call is always awaited**, because `before_dispatch` is itself a coroutine
|
|
130
|
+
function. Forgetting the `await` registers nothing.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
await Event.before_dispatch(persist_to_event_store)
|
|
134
|
+
await Event.before_dispatch(lambda e: metrics.incr(type(e).__name__))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Testing
|
|
138
|
+
|
|
139
|
+
`Event.fake()` installs an `EventFake` that records events and fires **no** listeners — neither
|
|
140
|
+
immediate nor deferred — and whose `dispatch` always returns `[]`.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
fake = Event.fake()
|
|
144
|
+
fake.assert_dispatched(ActorCreated, times=1, match=lambda e: e.actor_id == expected)
|
|
145
|
+
fake.assert_not_dispatched(ActorDeleted)
|
|
146
|
+
events = fake.dispatched(ActorCreated)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`with Event.faking() as fake:` scopes it. `TestCase` restores facades after every test. To test
|
|
150
|
+
what a listener *does*, exercise the listener directly — the fake suppresses it.
|