python-neva 3.7.0__tar.gz → 4.1.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-3.7.0 → python_neva-4.1.0}/CHANGELOG.md +42 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/CLAUDE.md +13 -2
- {python_neva-3.7.0 → python_neva-4.1.0}/PKG-INFO +4 -2
- {python_neva-3.7.0 → python_neva-4.1.0}/README.md +1 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/__init__.py +3 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/application.py +64 -35
- python_neva-4.1.0/neva/arch/config.py +49 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/integrations/faststream.py +9 -6
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/service_provider.py +11 -11
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/connection.py +7 -2
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/manager.py +7 -3
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/transaction.py +30 -12
- python_neva-4.1.0/neva/guidelines/__init__.py +6 -0
- python_neva-4.1.0/neva/guidelines/fragments/configuration.md +108 -0
- python_neva-4.1.0/neva/guidelines/fragments/database-transactions.md +125 -0
- python_neva-4.1.0/neva/guidelines/fragments/events.md +150 -0
- python_neva-4.1.0/neva/guidelines/fragments/result-option.md +74 -0
- python_neva-4.1.0/neva/guidelines/fragments/service-providers.md +114 -0
- python_neva-4.1.0/neva/guidelines/fragments/testing.md +133 -0
- python_neva-4.1.0/neva/guidelines/manifest.py +26 -0
- python_neva-4.1.0/neva/obs/__init__.py +9 -0
- python_neva-4.1.0/neva/security/tokens/generate_token.py +18 -0
- python_neva-4.1.0/neva/testing/__init__.py +7 -0
- python_neva-4.1.0/neva/testing/py.typed +0 -0
- python_neva-4.1.0/neva/testing/test_case.py +208 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/pyproject.toml +5 -2
- {python_neva-3.7.0 → python_neva-4.1.0}/ruff.toml +2 -2
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_cache.py +2 -1
- python_neva-4.1.0/tests/arch/test_config_shapes.py +54 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_context.py +2 -1
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_extends.py +2 -1
- python_neva-4.1.0/tests/arch/test_facade_root_nesting.py +46 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_lifetimes.py +7 -10
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_scope.py +12 -6
- python_neva-4.1.0/tests/config/test_config_path_resolution.py +48 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_database_manager.py +0 -1
- python_neva-4.1.0/tests/database/test_transaction_callbacks.py +114 -0
- python_neva-4.1.0/tests/events/test_listener_wiring.py +108 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/security/test_encrypter.py +14 -7
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/security/test_hash_manager.py +8 -4
- python_neva-4.1.0/tests/security/test_tokens.py +77 -0
- python_neva-4.1.0/tests/support/__init__.py +0 -0
- python_neva-4.1.0/tests/support/test_results.py +310 -0
- python_neva-4.1.0/tests/testing/test_application_reuse.py +67 -0
- python_neva-4.1.0/tests/testing/test_create_config_migration.py +76 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_refresh_database.py +9 -3
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_test_case.py +8 -7
- {python_neva-3.7.0 → python_neva-4.1.0}/uv.lock +125 -79
- python_neva-3.7.0/neva/arch/config.py +0 -37
- python_neva-3.7.0/neva/obs/__init__.py +0 -13
- python_neva-3.7.0/neva/obs/middleware/__init__.py +0 -7
- python_neva-3.7.0/neva/obs/middleware/correlation.py +0 -68
- python_neva-3.7.0/neva/obs/middleware/profiler.py +0 -68
- python_neva-3.7.0/neva/security/tokens/generate_token.py +0 -16
- python_neva-3.7.0/neva/testing/__init__.py +0 -7
- python_neva-3.7.0/neva/testing/test_case.py +0 -101
- python_neva-3.7.0/tests/obs/__init__.py +0 -1
- python_neva-3.7.0/tests/obs/test_correlation.py +0 -127
- python_neva-3.7.0/tests/obs/test_profiler.py +0 -90
- python_neva-3.7.0/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -23
- python_neva-3.7.0/wiki/architecture/configuration.md +0 -315
- python_neva-3.7.0/wiki/architecture/database-and-transactions.md +0 -404
- python_neva-3.7.0/wiki/architecture/dependency-injection.md +0 -583
- python_neva-3.7.0/wiki/architecture/events.md +0 -424
- python_neva-3.7.0/wiki/architecture/result-option.md +0 -296
- python_neva-3.7.0/wiki/architecture/testing.md +0 -367
- {python_neva-3.7.0 → python_neva-4.1.0}/.claude/settings.local.json +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/.envrc +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/.gitignore +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/.gitlab-ci.yml +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/.pre-commit-config.yaml +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/.python-version +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/facade.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/integrations/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/markers.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/py.typed +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/scopes.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/base_providers.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/loader.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/py.typed +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/repository.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/config.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/provider.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/py.typed +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/dispatcher.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/event.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/handler.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/listener.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/dispatcher.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/event.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/event_registry.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/listener.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/policy.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/provider.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/py.typed +0 -0
- {python_neva-3.7.0/neva/obs → python_neva-4.1.0/neva/guidelines}/py.typed +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/instrumentation/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/instrumentation/sqlalchemy.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/logging/provider.py +0 -0
- {python_neva-3.7.0/neva/polyfactory → python_neva-4.1.0/neva/obs}/py.typed +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/polyfactory/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/polyfactory/factories.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/polyfactory/persistence.py +0 -0
- {python_neva-3.7.0/neva/security → python_neva-4.1.0/neva/polyfactory}/py.typed +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/encryption/encrypter.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/config.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/provider.py +0 -0
- {python_neva-3.7.0/neva/support → python_neva-4.1.0/neva/security}/py.typed +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/accessors.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/app.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/config.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/db.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/db.pyi +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/event.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/hash.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/hash.pyi +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/log.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-3.7.0/neva/testing → python_neva-4.1.0/neva/support}/py.typed +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/results.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/strategy.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/strconv.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/time.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/testing/fakes.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/neva/testing/fixtures.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/scripts/retag-with-changelog.sh +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_registration.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/config/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/config/test_loader.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/config/test_repository.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/conftest.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_connection_manager.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_detached_lifespan.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_edge_cases.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_sqlalchemy_integration.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_transaction.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_transaction_context.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/conftest.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_before_dispatch.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_binding.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_deferred.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_dispatch.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_event.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_function_listener.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_immediate.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_listen_on_parent_class.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/security/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/__init__.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_event_fake.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_facade_restore.py +0 -0
- {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_fixtures.py +0 -0
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
## 4.1.0 (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### ✅🤡🧪 Tests
|
|
4
|
+
|
|
5
|
+
- **support**: add tests for results and options
|
|
6
|
+
|
|
7
|
+
### 📝💡 Documentation
|
|
8
|
+
|
|
9
|
+
- **guidelines**: ship agent guidelines in the wheel
|
|
10
|
+
|
|
11
|
+
## 4.0.0 (2026-08-28)
|
|
12
|
+
|
|
13
|
+
### 💥 Boom
|
|
14
|
+
|
|
15
|
+
- **testing**: require create_config overrides to be classmethods
|
|
16
|
+
- **arch**: require an explicit lifetime, drop Application.bind
|
|
17
|
+
|
|
18
|
+
### ✨ Features
|
|
19
|
+
|
|
20
|
+
- **deps**: offer the ASGI middleware through the asgi extra
|
|
21
|
+
- **config**: scope the core config shapes to what the core reads, export them
|
|
22
|
+
|
|
23
|
+
### 🐛🚑️ Fixes
|
|
24
|
+
|
|
25
|
+
- **security**: name generate_token's argument nbytes, cover the token helpers
|
|
26
|
+
- **arch**: fail the boot when declared listeners cannot be wired
|
|
27
|
+
- **config**: default the config directory to ./config, not the project root
|
|
28
|
+
- **database**: accept sync transaction callbacks, never lose their failures
|
|
29
|
+
|
|
30
|
+
### ♻️ Refactorings
|
|
31
|
+
|
|
32
|
+
- **obs**: move the ASGI middleware to neva-asgi
|
|
33
|
+
- **arch**: describe the FastStream lifespan without starlette
|
|
34
|
+
|
|
35
|
+
### ⚡️ Performance
|
|
36
|
+
|
|
37
|
+
- **GED-16633**: build the TestCase application once per class
|
|
38
|
+
|
|
39
|
+
### 📝💡 Documentation
|
|
40
|
+
|
|
41
|
+
- **database**: un-deprecate register_engine, say what it is for
|
|
42
|
+
|
|
1
43
|
## 3.7.0 (2026-08-28)
|
|
2
44
|
|
|
3
45
|
### ✨ Features
|
|
@@ -75,7 +75,7 @@ await Event.dispatch(UserCreated(event_id=1, user_id=42))
|
|
|
75
75
|
**Service Providers** - Register services with lifecycle hooks. Bindings
|
|
76
76
|
declare a lifetime by name — `singleton` (one per process), `scoped` (one per
|
|
77
77
|
REQUEST scope), `transient` (one per resolution), `instance` (an object you
|
|
78
|
-
already have). Bare `bind`
|
|
78
|
+
already have). Bare `bind` with neither `scope` nor `cache` raises `TypeError`:
|
|
79
79
|
```python
|
|
80
80
|
class MyProvider(ServiceProvider):
|
|
81
81
|
def register(self) -> Result[Self, str]:
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-neva
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.1.0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: aiosqlite>=0.20.0
|
|
@@ -12,9 +12,10 @@ Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
|
|
|
12
12
|
Requires-Dist: pydantic>=2.7
|
|
13
13
|
Requires-Dist: pyinstrument>=5.1.1
|
|
14
14
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
15
|
-
Requires-Dist: starlette>=0.41
|
|
16
15
|
Requires-Dist: structlog>=25.5.0
|
|
17
16
|
Requires-Dist: typing-extensions>=4.13
|
|
17
|
+
Provides-Extra: asgi
|
|
18
|
+
Requires-Dist: neva-asgi>=0.1.1; extra == 'asgi'
|
|
18
19
|
Provides-Extra: fastapi
|
|
19
20
|
Requires-Dist: neva-fastapi>=1.1.1; extra == 'fastapi'
|
|
20
21
|
Provides-Extra: faststream
|
|
@@ -48,6 +49,7 @@ independent repo, independently versioned and published.
|
|
|
48
49
|
| ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
|
|
49
50
|
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
|
|
50
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
|
+
| `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/)) |
|
|
51
53
|
| `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
|
|
52
54
|
| `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
|
|
53
55
|
| `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
|
|
@@ -20,6 +20,7 @@ independent repo, independently versioned and published.
|
|
|
20
20
|
| ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
|
|
21
21
|
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, 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
|
+
| `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/)) |
|
|
23
24
|
| `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
|
|
24
25
|
| `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
|
|
25
26
|
| `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
|
|
@@ -5,6 +5,7 @@ and facade implementations.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from neva.arch.application import Application
|
|
8
|
+
from neva.arch.config import AppConfig, ProviderConfig
|
|
8
9
|
from neva.arch.facade import Facade
|
|
9
10
|
from neva.arch.service_provider import (
|
|
10
11
|
Bootable,
|
|
@@ -13,8 +14,10 @@ from neva.arch.service_provider import (
|
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
17
|
+
"AppConfig",
|
|
16
18
|
"Application",
|
|
17
19
|
"Bootable",
|
|
18
20
|
"Facade",
|
|
21
|
+
"ProviderConfig",
|
|
19
22
|
"ServiceProvider",
|
|
20
23
|
]
|
|
@@ -6,17 +6,16 @@ from collections.abc import AsyncGenerator, Sequence
|
|
|
6
6
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
7
7
|
from contextvars import ContextVar
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any,
|
|
9
|
+
from typing import Any, Self
|
|
10
10
|
|
|
11
11
|
import dishka
|
|
12
12
|
from dishka.provider import BaseProvider
|
|
13
|
-
from typing_extensions import deprecated
|
|
14
13
|
|
|
15
14
|
from neva.arch.facade import Facade
|
|
16
15
|
from neva.arch.scopes import BaseScope, Scope
|
|
17
16
|
from neva.arch.service_provider import Bootable, ServiceProvider
|
|
18
17
|
from neva.config.loader import ConfigLoader
|
|
19
|
-
from neva.support import Err, Ok, Result
|
|
18
|
+
from neva.support import Err, Ok, Result, Some
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
_current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
|
|
@@ -24,6 +23,26 @@ _current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
|
|
|
24
23
|
)
|
|
25
24
|
|
|
26
25
|
|
|
26
|
+
def _resolve_config_path(config_path: str | Path | None) -> Path:
|
|
27
|
+
"""Resolve the directory config files are loaded from.
|
|
28
|
+
|
|
29
|
+
The config directory is a dedicated folder, never the project root: every
|
|
30
|
+
``.py`` in it is imported to read its ``config`` dict, so pointing this at
|
|
31
|
+
a directory holding ordinary modules would execute them.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The explicit argument, else $NEVA_CONFIG_PATH, else ./config.
|
|
35
|
+
"""
|
|
36
|
+
if config_path is not None:
|
|
37
|
+
return Path(config_path)
|
|
38
|
+
|
|
39
|
+
env_path = os.getenv("NEVA_CONFIG_PATH")
|
|
40
|
+
if env_path:
|
|
41
|
+
return Path(env_path)
|
|
42
|
+
|
|
43
|
+
return Path.cwd() / "config"
|
|
44
|
+
|
|
45
|
+
|
|
27
46
|
class Application:
|
|
28
47
|
"""Base application for DI and facade injection."""
|
|
29
48
|
|
|
@@ -31,8 +50,9 @@ class Application:
|
|
|
31
50
|
"""Initialize the application and its bindings.
|
|
32
51
|
|
|
33
52
|
Args:
|
|
34
|
-
config_path: Path to the configuration directory. Defaults to
|
|
35
|
-
relative to the
|
|
53
|
+
config_path: Path to the configuration directory. Defaults to
|
|
54
|
+
``$NEVA_CONFIG_PATH``, else ``./config`` relative to the
|
|
55
|
+
current working directory.
|
|
36
56
|
|
|
37
57
|
Raises:
|
|
38
58
|
RuntimeError: If the application fails to initialize.
|
|
@@ -47,11 +67,7 @@ class Application:
|
|
|
47
67
|
self._container: dishka.AsyncContainer | None = None
|
|
48
68
|
self._booted: bool = False
|
|
49
69
|
|
|
50
|
-
configuration_path = (
|
|
51
|
-
config_path
|
|
52
|
-
if config_path is not None
|
|
53
|
-
else os.getenv("NEVA_CONFIG_PATH", default=Path.cwd())
|
|
54
|
-
)
|
|
70
|
+
configuration_path = _resolve_config_path(config_path)
|
|
55
71
|
match ConfigLoader(configuration_path).load_all():
|
|
56
72
|
case Err(e):
|
|
57
73
|
raise RuntimeError(f"Failed to register config: {e}")
|
|
@@ -150,21 +166,6 @@ class Application:
|
|
|
150
166
|
for provider in providers:
|
|
151
167
|
_ = self.register(provider)
|
|
152
168
|
|
|
153
|
-
@deprecated("Use the new bind() method directly with a Service Provider.")
|
|
154
|
-
def bind(
|
|
155
|
-
self,
|
|
156
|
-
source: type | Callable[..., Any],
|
|
157
|
-
*,
|
|
158
|
-
interface: type | None = None,
|
|
159
|
-
scope: BaseScope | None = None,
|
|
160
|
-
) -> None:
|
|
161
|
-
"""Binds a source to the container."""
|
|
162
|
-
_ = self.root_provider.provide(
|
|
163
|
-
source=source,
|
|
164
|
-
scope=scope,
|
|
165
|
-
provides=interface,
|
|
166
|
-
)
|
|
167
|
-
|
|
168
169
|
@property
|
|
169
170
|
def current_container(self) -> dishka.AsyncContainer:
|
|
170
171
|
"""The container backing resolution in the current context.
|
|
@@ -226,6 +227,11 @@ class Application:
|
|
|
226
227
|
@asynccontextmanager
|
|
227
228
|
async def lifespan(self) -> AsyncGenerator[None]:
|
|
228
229
|
"""Wire the facades and providers."""
|
|
230
|
+
# The facade root is a single process-global slot, so a short-lived
|
|
231
|
+
# application nested inside a longer-lived one must hand it back rather
|
|
232
|
+
# than clear it -- otherwise it disarms every facade for whatever the
|
|
233
|
+
# outer application still has to do.
|
|
234
|
+
previous_facade_application = Facade.get_facade_application()
|
|
229
235
|
Facade.set_facade_application(self)
|
|
230
236
|
self._booted = True
|
|
231
237
|
|
|
@@ -243,27 +249,50 @@ class Application:
|
|
|
243
249
|
if self._container is not None:
|
|
244
250
|
await self._container.close()
|
|
245
251
|
self._booted = False
|
|
246
|
-
|
|
252
|
+
match previous_facade_application:
|
|
253
|
+
case Some(application):
|
|
254
|
+
Facade.set_facade_application(application)
|
|
255
|
+
case _:
|
|
256
|
+
Facade.reset_facade_application()
|
|
247
257
|
|
|
248
258
|
def _bind_event_listeners(self) -> None:
|
|
249
259
|
"""Bind all listener classes declared in provider ``listen`` dicts."""
|
|
250
260
|
for provider in self.providers.values():
|
|
251
261
|
for listeners in provider.listen.values():
|
|
252
262
|
for listener_cls in listeners:
|
|
253
|
-
# Straight onto the root provider:
|
|
254
|
-
#
|
|
263
|
+
# Straight onto the root provider: these listeners are not
|
|
264
|
+
# a provider's own bindings, so they do not belong in one.
|
|
255
265
|
_ = self.root_provider.provide(listener_cls, scope=Scope.APP)
|
|
256
266
|
|
|
257
267
|
async def _wire_event_listeners(self) -> None:
|
|
258
|
-
"""Wire event-listener mappings from all providers onto the dispatcher.
|
|
268
|
+
"""Wire event-listener mappings from all providers onto the dispatcher.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
RuntimeError: If listeners are declared but the dispatcher cannot
|
|
272
|
+
be resolved. Booting on regardless would leave every one of
|
|
273
|
+
them unwired, so the events would simply never fire — with
|
|
274
|
+
nothing anywhere saying why.
|
|
275
|
+
"""
|
|
259
276
|
from neva.events.dispatcher import EventDispatcher
|
|
260
277
|
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
declared = [
|
|
279
|
+
(event_cls, listener_cls)
|
|
280
|
+
for provider in self.providers.values()
|
|
281
|
+
for event_cls, listeners in provider.listen.items()
|
|
282
|
+
for listener_cls in listeners
|
|
283
|
+
]
|
|
284
|
+
if not declared:
|
|
263
285
|
return
|
|
264
286
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
match await self.make_async(EventDispatcher):
|
|
288
|
+
case Err(e):
|
|
289
|
+
msg = (
|
|
290
|
+
f"Cannot wire {len(declared)} declared event listener(s): "
|
|
291
|
+
f"the EventDispatcher could not be resolved ({e}). "
|
|
292
|
+
"Register EventServiceProvider, or remove the 'listen' "
|
|
293
|
+
"mappings that need it."
|
|
294
|
+
)
|
|
295
|
+
raise RuntimeError(msg)
|
|
296
|
+
case Ok(dispatcher):
|
|
297
|
+
for event_cls, listener_cls in declared:
|
|
269
298
|
dispatcher.listen(event_cls, listener_cls)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Config shapes for the namespaces the core itself reads.
|
|
2
|
+
|
|
3
|
+
Annotate a config file with one of these to have a type checker catch typos
|
|
4
|
+
and wrong value types before boot:
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
# src/config/app.py
|
|
8
|
+
from neva.arch import AppConfig
|
|
9
|
+
|
|
10
|
+
config: AppConfig = {"providers": [MyProvider]}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Keys read by an integration package are declared by that package, not here —
|
|
14
|
+
`neva-fastapi` owns the OpenAPI and title keys of the `app` namespace, for
|
|
15
|
+
instance. The core only describes what the core consumes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import NotRequired, TypedDict
|
|
19
|
+
|
|
20
|
+
from neva.arch.service_provider import ServiceProvider
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AppConfig(TypedDict):
|
|
24
|
+
"""The `app` namespace keys the core reads.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
key: Base64-encoded 32-byte key for `Crypt`. Generate one with
|
|
28
|
+
`AesEncrypter.generate_key()`.
|
|
29
|
+
previous_keys: Superseded keys, still accepted when decrypting so a
|
|
30
|
+
key rotation does not orphan existing ciphertext.
|
|
31
|
+
providers: Service providers to register, appended after those from
|
|
32
|
+
the `providers` namespace.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
key: NotRequired[str]
|
|
36
|
+
previous_keys: NotRequired[list[str]]
|
|
37
|
+
providers: NotRequired[list[type[ServiceProvider]]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProviderConfig(TypedDict):
|
|
41
|
+
"""The `providers` namespace.
|
|
42
|
+
|
|
43
|
+
A dedicated namespace for the provider list, read before `app.providers`.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
providers: Service providers to register.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
providers: NotRequired[list[type[ServiceProvider]]]
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
"""FastStream wrapper for Neva."""
|
|
2
2
|
|
|
3
|
-
from collections.abc import AsyncGenerator, AsyncIterator
|
|
4
|
-
from contextlib import asynccontextmanager
|
|
3
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
|
4
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
import faststream
|
|
8
8
|
from faststream._internal.broker import BrokerUsecase
|
|
9
9
|
from faststream._internal.configs import BrokerConfig
|
|
10
|
-
from starlette.types import StatelessLifespan
|
|
11
10
|
|
|
12
11
|
from neva.arch import Application, ServiceProvider
|
|
13
12
|
from neva.support import Result
|
|
14
13
|
|
|
15
14
|
|
|
15
|
+
# Spelled out locally rather than borrowed from starlette.types: a messaging
|
|
16
|
+
# app's lifespan is not a web concern, and the core must not import an ASGI
|
|
17
|
+
# framework to describe one.
|
|
18
|
+
type FastStreamLifespan = Callable[["FastStream"], AbstractAsyncContextManager[None]]
|
|
19
|
+
|
|
20
|
+
|
|
16
21
|
class FastStream(faststream.FastStream):
|
|
17
22
|
"""FastStream wrapper for Neva."""
|
|
18
23
|
|
|
@@ -42,9 +47,7 @@ class FastStream(faststream.FastStream):
|
|
|
42
47
|
async with self.application.lifespan():
|
|
43
48
|
yield
|
|
44
49
|
|
|
45
|
-
def _create_lifespan(
|
|
46
|
-
self,
|
|
47
|
-
) -> StatelessLifespan["FastStream"]:
|
|
50
|
+
def _create_lifespan(self) -> FastStreamLifespan:
|
|
48
51
|
@asynccontextmanager
|
|
49
52
|
async def composed_lifespan(_: faststream.FastStream) -> AsyncIterator[None]:
|
|
50
53
|
async with self.lifespan():
|
|
@@ -8,7 +8,6 @@ container and optionally managing their lifecycle.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import abc
|
|
11
|
-
import warnings
|
|
12
11
|
from contextlib import AbstractAsyncContextManager
|
|
13
12
|
from typing import (
|
|
14
13
|
TYPE_CHECKING,
|
|
@@ -115,9 +114,9 @@ class ServiceProvider(abc.ABC):
|
|
|
115
114
|
`instance` — which say what they mean. Use `bind` directly only to
|
|
116
115
|
reach a scope those do not cover, and then pass `scope` explicitly.
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
A lifetime is required. Without one the binding would be a
|
|
118
|
+
process-wide singleton — the opposite of Laravel's transient `bind` —
|
|
119
|
+
with nothing at the call site saying so.
|
|
121
120
|
|
|
122
121
|
Args:
|
|
123
122
|
source: The class or factory to bind.
|
|
@@ -125,16 +124,17 @@ class ServiceProvider(abc.ABC):
|
|
|
125
124
|
scope: The dishka scope. Defaults to the provider's (APP).
|
|
126
125
|
when: Marker gating this binding.
|
|
127
126
|
cache: Whether to reuse one instance per scope. Defaults to True.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
TypeError: If neither `scope` nor `cache` is given.
|
|
128
130
|
"""
|
|
129
131
|
if scope is None and cache is None:
|
|
130
|
-
|
|
131
|
-
"bind()
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"transient() / instance(), or pass scope= or cache= yourself.",
|
|
135
|
-
DeprecationWarning,
|
|
136
|
-
stacklevel=2,
|
|
132
|
+
msg = (
|
|
133
|
+
"bind() requires an explicit lifetime: use singleton() for a "
|
|
134
|
+
"process-wide instance, or scoped() / transient() / "
|
|
135
|
+
"instance(), or pass scope= or cache= yourself."
|
|
137
136
|
)
|
|
137
|
+
raise TypeError(msg)
|
|
138
138
|
|
|
139
139
|
_ = self.provider.provide(
|
|
140
140
|
source=source,
|
|
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
|
|
|
7
7
|
from typing import final
|
|
8
8
|
|
|
9
9
|
from sqlalchemy.ext import asyncio
|
|
10
|
+
from structlog.stdlib import get_logger
|
|
10
11
|
|
|
11
12
|
from neva.database.transaction import BoundTransaction, TransactionState
|
|
12
13
|
from neva.obs import LogManager
|
|
@@ -147,9 +148,13 @@ class ConnectionManager:
|
|
|
147
148
|
else await tx.execute_on_rollback_callbacks()
|
|
148
149
|
)
|
|
149
150
|
label = "commit" if committed else "rollback"
|
|
151
|
+
# A post-commit callback cannot un-commit the transaction, so its
|
|
152
|
+
# failure is reported rather than raised. It must never be lost: fall
|
|
153
|
+
# back to a module logger when the manager has none.
|
|
154
|
+
logger = self.logger if self.logger is not None else get_logger(__name__)
|
|
150
155
|
for result in results:
|
|
151
|
-
if result.is_err
|
|
152
|
-
|
|
156
|
+
if result.is_err:
|
|
157
|
+
logger.error(
|
|
153
158
|
f"{label} callback failed",
|
|
154
159
|
error=result.err().unwrap(),
|
|
155
160
|
connection=self.name,
|
|
@@ -5,7 +5,6 @@ from contextlib import asynccontextmanager
|
|
|
5
5
|
from typing import final
|
|
6
6
|
|
|
7
7
|
from sqlalchemy.ext import asyncio
|
|
8
|
-
from typing_extensions import deprecated
|
|
9
8
|
|
|
10
9
|
from neva.database.config import ConnectionConfig
|
|
11
10
|
from neva.database.connection import ConnectionManager, TransactionContext
|
|
@@ -24,9 +23,14 @@ class DatabaseManager:
|
|
|
24
23
|
self._logger = logger
|
|
25
24
|
self._connections: dict[str, ConnectionManager] = {}
|
|
26
25
|
|
|
27
|
-
@deprecated("Please use DatabaseManager.register_connection instead.")
|
|
28
26
|
def register_engine(self, name: str, engine: asyncio.AsyncEngine) -> None:
|
|
29
|
-
"""Register
|
|
27
|
+
"""Register a connection around an engine you already built.
|
|
28
|
+
|
|
29
|
+
Prefer `register_connection`, which builds the engine from config. Use
|
|
30
|
+
this when the caller must own the engine — a test that creates the
|
|
31
|
+
schema on an in-memory database has to hand over that exact engine,
|
|
32
|
+
since a second engine to `sqlite:///:memory:` is a second, empty
|
|
33
|
+
database.
|
|
30
34
|
|
|
31
35
|
Args:
|
|
32
36
|
name: The connection name.
|
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
"""Transaction management systems."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from enum import Enum, auto
|
|
7
|
-
from typing import
|
|
8
|
+
from typing import Self
|
|
8
9
|
|
|
9
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
11
|
|
|
11
|
-
from neva.support import Err, Nothing, Option, Result, Some
|
|
12
|
+
from neva.support import Err, Nothing, Ok, Option, Result, Some
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
type
|
|
15
|
+
type TransactionOutcome = Result[None, str] | None
|
|
16
|
+
type TransactionCallback = Callable[
|
|
17
|
+
[], TransactionOutcome | Awaitable[TransactionOutcome]
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _run_callback(callback: TransactionCallback) -> Result[None, str]:
|
|
22
|
+
"""Invoke a transaction callback, whether it is sync or async.
|
|
23
|
+
|
|
24
|
+
A callback may return a ``Result`` to report its own failure, or ``None``
|
|
25
|
+
to say "done, nothing to report" — which is what a bare side-effecting
|
|
26
|
+
lambda does.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The callback's own Result, Ok(None) if it returned None, or Err if it
|
|
30
|
+
raised.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
outcome = callback()
|
|
34
|
+
if inspect.isawaitable(outcome):
|
|
35
|
+
outcome = await outcome
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return Err(f"Callback raised: {e}")
|
|
38
|
+
return Ok(None) if outcome is None else outcome
|
|
15
39
|
|
|
16
40
|
|
|
17
41
|
class TransactionState(Enum):
|
|
@@ -77,10 +101,7 @@ class Transaction:
|
|
|
77
101
|
"""
|
|
78
102
|
results: list[Result[None, str]] = []
|
|
79
103
|
for callback in self._on_commit:
|
|
80
|
-
|
|
81
|
-
results.append(await callback())
|
|
82
|
-
except Exception as e:
|
|
83
|
-
results.append(Err(f"Callback raised: {e}"))
|
|
104
|
+
results.append(await _run_callback(callback))
|
|
84
105
|
self._on_commit.clear()
|
|
85
106
|
return results
|
|
86
107
|
|
|
@@ -92,10 +113,7 @@ class Transaction:
|
|
|
92
113
|
"""
|
|
93
114
|
results: list[Result[None, str]] = []
|
|
94
115
|
for callback in self._on_rollback:
|
|
95
|
-
|
|
96
|
-
results.append(await callback())
|
|
97
|
-
except Exception as e:
|
|
98
|
-
results.append(Err(f"Callback raised: {e}"))
|
|
116
|
+
results.append(await _run_callback(callback))
|
|
99
117
|
self._on_rollback.clear()
|
|
100
118
|
return results
|
|
101
119
|
|
|
@@ -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.
|