python-neva 5.3.0__tar.gz → 5.4.1__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-5.3.0 → python_neva-5.4.1}/.gitignore +1 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/CHANGELOG.md +40 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/PKG-INFO +1 -1
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/application.py +106 -1
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/facade.py +5 -3
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/service_provider.py +9 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/config/base_providers.py +2 -8
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/config/repository.py +46 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/database/provider.py +11 -4
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/dispatcher.py +50 -5
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/listener.py +18 -3
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/configuration.md +76 -3
- python_neva-5.4.1/neva/guidelines/fragments/drivers.md +87 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/events.md +20 -3
- python_neva-5.4.1/neva/guidelines/fragments/result-option.md +137 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/security.md +8 -6
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/encryption/encrypter.py +36 -33
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/provider.py +3 -1
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/__init__.py +14 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/accessors.py +3 -2
- python_neva-5.4.1/neva/support/results/__init__.py +31 -0
- python_neva-5.4.1/neva/support/results/collect.py +88 -0
- python_neva-5.4.1/neva/support/results/convert.py +14 -0
- python_neva-5.4.1/neva/support/results/exceptions.py +6 -0
- python_neva-5.3.0/neva/support/results.py → python_neva-5.4.1/neva/support/results/option.py +14 -382
- python_neva-5.4.1/neva/support/results/pipeline.py +290 -0
- python_neva-5.4.1/neva/support/results/result.py +363 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/strategy.py +17 -7
- {python_neva-5.3.0 → python_neva-5.4.1}/pyproject.toml +1 -1
- python_neva-5.4.1/tests/arch/test_application_create.py +62 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_config_shapes.py +26 -1
- python_neva-5.4.1/tests/config/test_base_providers.py +68 -0
- python_neva-5.4.1/tests/config/test_provider_declarations.py +256 -0
- python_neva-5.4.1/tests/database/test_provider_lifespan.py +75 -0
- python_neva-5.4.1/tests/events/test_concurrent.py +178 -0
- python_neva-5.4.1/tests/events/test_unimplemented_handle.py +83 -0
- python_neva-5.4.1/tests/guidelines/test_manifest.py +71 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/security/test_encrypter.py +79 -40
- python_neva-5.4.1/tests/support/__init__.py +0 -0
- python_neva-5.4.1/tests/support/test_accessors.py +86 -0
- python_neva-5.4.1/tests/support/test_collect.py +149 -0
- python_neva-5.4.1/tests/support/test_pipeline.py +257 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/support/test_results.py +45 -0
- python_neva-5.4.1/tests/support/test_results_imports.py +118 -0
- python_neva-5.4.1/tests/support/test_strategy_imports.py +69 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/uv.lock +1 -1
- python_neva-5.3.0/.claude/settings.local.json +0 -18
- python_neva-5.3.0/neva/guidelines/fragments/result-option.md +0 -74
- {python_neva-5.3.0 → python_neva-5.4.1}/.envrc +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/.gitlab-ci.yml +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/.pre-commit-config.yaml +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/.python-version +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/CLAUDE.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/README.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/integrations/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/integrations/faststream.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/markers.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/arch/scopes.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/config/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/config/loader.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/config/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/database/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/database/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/database/connection.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/database/manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/database/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/database/transaction.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/contracts/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/contracts/dispatcher.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/contracts/event.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/contracts/handler.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/contracts/listener.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/event.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/event_registry.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/policy.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/provider.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/events/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/database-transactions.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/facades.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/factories.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/observability.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/service-providers.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/fragments/testing.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/manifest.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/guidelines/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/logging/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/logging/channels.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/logging/contracts.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/logging/manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/logging/provider.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/logging/resolver.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/obs/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/polyfactory/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/polyfactory/factories.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/polyfactory/persistence.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/polyfactory/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/encryption/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/encryption/protocol.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/hashing/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/hashing/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/tokens/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/tokens/generate_token.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/app.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/app.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/config.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/crypt.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/db.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/db.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/event.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/event.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/hash.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/hash.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/log.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/facade/log.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/strconv.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/support/time.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/testing/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/testing/fakes.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/testing/fixtures.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/testing/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/neva/testing/test_case.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/ruff.toml +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/scripts/retag-with-changelog.sh +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_cache.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_context.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_extends.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_facade_resolution.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_facade_root_nesting.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_lifetimes.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_register_resolution.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_registration.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/arch/test_scope.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/config/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/config/test_config_path_resolution.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/config/test_loader.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/config/test_repository.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/conftest.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_connection_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_database_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_detached_lifespan.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_edge_cases.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_multi_connection.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_sqlalchemy_integration.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_transaction.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_transaction_callbacks.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_transaction_context.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/conftest.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_before_dispatch.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_binding.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_deferred.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_dispatch.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_event.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_function_listener.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_immediate.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_listen_on_parent_class.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/events/test_listener_wiring.py +0 -0
- {python_neva-5.3.0/tests/obs → python_neva-5.4.1/tests/guidelines}/__init__.py +0 -0
- {python_neva-5.3.0/tests/polyfactory → python_neva-5.4.1/tests/obs}/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/obs/conftest.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/obs/test_channels.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/obs/test_facade.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/obs/test_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/obs/test_provider_hook.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/obs/test_resolver.py +0 -0
- {python_neva-5.3.0/tests/support → python_neva-5.4.1/tests/polyfactory}/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/polyfactory/test_model_factory.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/security/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/security/test_config_shapes.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/security/test_hash_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/security/test_tokens.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/support/test_strategy.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/test_application_reuse.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/test_boot_application.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/test_create_config_migration.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/test_event_fake.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/test_facade_restore.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/test_fixtures.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/test_refresh_database.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.1}/tests/testing/test_test_case.py +0 -0
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
## 5.4.1 (2026-09-09)
|
|
2
|
+
|
|
3
|
+
### 🐛🚑️ Fixes
|
|
4
|
+
|
|
5
|
+
- **config**: apply provider declarations on a late registration
|
|
6
|
+
- **guidelines**: render the drivers fragment for apps below 5.3
|
|
7
|
+
|
|
8
|
+
## 5.4.0 (2026-09-07)
|
|
9
|
+
|
|
10
|
+
### ✨ Features
|
|
11
|
+
|
|
12
|
+
- **config**: let a provider declare its config namespace and its secrets
|
|
13
|
+
- **events**: let a listener opt in to running concurrently
|
|
14
|
+
- **arch**: add Application.create returning a Result instead of raising
|
|
15
|
+
- **support**: export StrategyResolver as the driver-manager base
|
|
16
|
+
- **security**: register SecurityProvider as a base provider
|
|
17
|
+
- **support**: add collect, partition and collect_async for many results
|
|
18
|
+
- **support**: add ResultPipeline and OptionPipeline for async chaining
|
|
19
|
+
|
|
20
|
+
### 🐛🚑️ Fixes
|
|
21
|
+
|
|
22
|
+
- **support**: evaluate an attribute once in get_attr rather than twice
|
|
23
|
+
- **events**: return Err from the default listener handle instead of None
|
|
24
|
+
- **database**: stop reporting an absent database namespace as a failure
|
|
25
|
+
- **security**: surface key loading failures as Err rather than raising
|
|
26
|
+
|
|
27
|
+
### ♻️ Refactorings
|
|
28
|
+
|
|
29
|
+
- **arch**: build the facade root error only when it is needed
|
|
30
|
+
- **support**: split the results module into a package
|
|
31
|
+
|
|
32
|
+
### ✅🤡🧪 Tests
|
|
33
|
+
|
|
34
|
+
- **support**: cover falsy values and arbitrary iterables in collect
|
|
35
|
+
- **guidelines**: pin the boost manifest and the fragment requires floor
|
|
36
|
+
|
|
37
|
+
### 📝💡 Documentation
|
|
38
|
+
|
|
39
|
+
- **guidelines**: document composing the shared app config namespace
|
|
40
|
+
|
|
1
41
|
## 5.3.0 (2026-09-02)
|
|
2
42
|
|
|
3
43
|
### ✨ Features
|
|
@@ -9,13 +9,15 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, Self
|
|
10
10
|
|
|
11
11
|
import dishka
|
|
12
|
+
import pydantic
|
|
12
13
|
from dishka.provider import BaseProvider
|
|
14
|
+
from typing_extensions import TypeForm
|
|
13
15
|
|
|
14
16
|
from neva.arch.facade import Facade
|
|
15
17
|
from neva.arch.scopes import BaseScope, Scope
|
|
16
18
|
from neva.arch.service_provider import Bootable, ServiceProvider
|
|
17
19
|
from neva.config.loader import ConfigLoader
|
|
18
|
-
from neva.support import Err, Ok, Result, Some
|
|
20
|
+
from neva.support import Err, Ok, Result, Some, partition
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
_current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
|
|
@@ -66,6 +68,7 @@ class Application:
|
|
|
66
68
|
self._extra_providers: list[BaseProvider] = []
|
|
67
69
|
self._container: dishka.AsyncContainer | None = None
|
|
68
70
|
self._booted: bool = False
|
|
71
|
+
self._declarations_applied: bool = False
|
|
69
72
|
|
|
70
73
|
configuration_path = _resolve_config_path(config_path)
|
|
71
74
|
match ConfigLoader(configuration_path).load_all():
|
|
@@ -95,9 +98,32 @@ class Application:
|
|
|
95
98
|
)
|
|
96
99
|
_ = self.root_provider.provide(source=lambda: self, provides=Application)
|
|
97
100
|
self.register_providers(providers)
|
|
101
|
+
self._apply_provider_config_declarations()
|
|
98
102
|
self._bind_event_listeners()
|
|
99
103
|
self.build_container()
|
|
100
104
|
|
|
105
|
+
@classmethod
|
|
106
|
+
def create(cls, config_path: str | Path | None = None) -> Result[Self, str]:
|
|
107
|
+
"""Build an application, reporting a boot failure as a value.
|
|
108
|
+
|
|
109
|
+
The constructor raises, because a constructor cannot return a Result.
|
|
110
|
+
This is the same construction with the framework's own convention, and
|
|
111
|
+
is the form to prefer at a boundary that already handles Results.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
config_path: Path to the configuration directory. Defaults to
|
|
115
|
+
``$NEVA_CONFIG_PATH``, else ``./config`` relative to the
|
|
116
|
+
current working directory.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Ok with the application, or Err with the reason it could not boot.
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
return Ok(cls(config_path))
|
|
124
|
+
except RuntimeError as e:
|
|
125
|
+
return Err(str(e))
|
|
126
|
+
|
|
101
127
|
@property
|
|
102
128
|
def container(self) -> dishka.AsyncContainer:
|
|
103
129
|
"""The application container, built on first access.
|
|
@@ -139,6 +165,15 @@ class Application:
|
|
|
139
165
|
would orphan. Registering after boot is an error rather than a
|
|
140
166
|
silently ignored call.
|
|
141
167
|
|
|
168
|
+
A provider arriving after construction has its declared config
|
|
169
|
+
namespaces validated here, and its secrets marked once it registers, so
|
|
170
|
+
that it is held to the same shape as one named in the `providers`
|
|
171
|
+
config namespace. Validation runs before the provider is instantiated:
|
|
172
|
+
a malformed namespace leaves nothing registered and no secret marked.
|
|
173
|
+
Providers registered *during* construction are skipped here and
|
|
174
|
+
validated together by `_apply_provider_config_declarations`, which
|
|
175
|
+
reports every bad namespace in one message.
|
|
176
|
+
|
|
142
177
|
Returns:
|
|
143
178
|
Result containing the registered provider instance or an error message.
|
|
144
179
|
"""
|
|
@@ -152,15 +187,85 @@ class Application:
|
|
|
152
187
|
"or declare them in the 'providers' config namespace."
|
|
153
188
|
)
|
|
154
189
|
|
|
190
|
+
late = self._declarations_applied
|
|
191
|
+
if late and (failures := self._declaration_failures(provider)):
|
|
192
|
+
return Err("Invalid configuration: " + "; ".join(failures))
|
|
193
|
+
|
|
155
194
|
registered = (
|
|
156
195
|
provider(self)
|
|
157
196
|
.register()
|
|
158
197
|
.map(lambda p: self.providers.setdefault(provider, p))
|
|
159
198
|
)
|
|
160
199
|
if registered.is_ok:
|
|
200
|
+
if late:
|
|
201
|
+
self.config.mark_sensitive(*provider.sensitive)
|
|
161
202
|
self._container = None
|
|
162
203
|
return registered
|
|
163
204
|
|
|
205
|
+
def _apply_provider_config_declarations(self) -> None:
|
|
206
|
+
"""Validate declared config namespaces and mark declared secrets.
|
|
207
|
+
|
|
208
|
+
Runs once, over the providers registered during construction. A
|
|
209
|
+
namespace that is absent is skipped: not using a subsystem is not a
|
|
210
|
+
configuration error. Every failure is reported together, so a boot
|
|
211
|
+
naming three bad namespaces takes one run rather than three.
|
|
212
|
+
|
|
213
|
+
Providers registered after this point go through `register`, which
|
|
214
|
+
applies the same declarations one provider at a time.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
RuntimeError: If any declared namespace fails its shape.
|
|
218
|
+
"""
|
|
219
|
+
failures: list[str] = []
|
|
220
|
+
for provider in self.providers.values():
|
|
221
|
+
declaring = type(provider)
|
|
222
|
+
self.config.mark_sensitive(*declaring.sensitive)
|
|
223
|
+
failures.extend(self._declaration_failures(declaring))
|
|
224
|
+
|
|
225
|
+
if failures:
|
|
226
|
+
raise RuntimeError("Invalid configuration: " + "; ".join(failures))
|
|
227
|
+
|
|
228
|
+
self._declarations_applied = True
|
|
229
|
+
|
|
230
|
+
def _declaration_failures(self, declaring: type[ServiceProvider]) -> list[str]:
|
|
231
|
+
"""Check one provider's declared namespaces, applying nothing.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
declaring: The provider type whose `config_schema` to check.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
One message per malformed namespace, empty when every declared
|
|
238
|
+
namespace is absent or valid.
|
|
239
|
+
"""
|
|
240
|
+
_, failures = partition(
|
|
241
|
+
self._validate_namespace(namespace, schema)
|
|
242
|
+
for namespace, schema in declaring.config_schema.items()
|
|
243
|
+
)
|
|
244
|
+
return failures
|
|
245
|
+
|
|
246
|
+
def _validate_namespace(
|
|
247
|
+
self, namespace: str, schema: TypeForm[Any]
|
|
248
|
+
) -> Result[None, str]:
|
|
249
|
+
"""Check one config namespace against the shape a provider declared.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Ok when the namespace is absent or valid, otherwise Err naming the
|
|
253
|
+
namespace and the first offending field path.
|
|
254
|
+
"""
|
|
255
|
+
value = self.config.get(namespace)
|
|
256
|
+
if value.is_err:
|
|
257
|
+
return Ok(None)
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
_ = pydantic.TypeAdapter(schema).validate_python(value.unwrap())
|
|
261
|
+
except pydantic.ValidationError as e:
|
|
262
|
+
paths = ", ".join(
|
|
263
|
+
".".join(str(part) for part in error["loc"]) or "<root>"
|
|
264
|
+
for error in e.errors()
|
|
265
|
+
)
|
|
266
|
+
return Err(f"'{namespace}' is malformed ({paths})")
|
|
267
|
+
return Ok(None)
|
|
268
|
+
|
|
164
269
|
def register_providers(self, providers: Sequence[type[ServiceProvider]]) -> None:
|
|
165
270
|
"""Registers a set of providers."""
|
|
166
271
|
for provider in providers:
|
|
@@ -94,9 +94,11 @@ class FacadeMeta(ABCMeta):
|
|
|
94
94
|
"""
|
|
95
95
|
return (
|
|
96
96
|
cls._get_app()
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
.ok_or_else(
|
|
98
|
+
lambda: (
|
|
99
|
+
f"A facade root (App instance) has not been set for "
|
|
100
|
+
f"{cls.__name__}. Call Facade.set_facade_application(app) first."
|
|
101
|
+
)
|
|
100
102
|
)
|
|
101
103
|
.and_then(
|
|
102
104
|
lambda x: cls._resolve_facade_instance(
|
|
@@ -20,6 +20,7 @@ from typing import (
|
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
import dishka
|
|
23
|
+
from typing_extensions import TypeForm
|
|
23
24
|
|
|
24
25
|
from neva.arch.markers import BaseMarker, Marker
|
|
25
26
|
from neva.arch.scopes import BaseScope, Scope
|
|
@@ -66,12 +67,20 @@ class ServiceProvider(abc.ABC):
|
|
|
66
67
|
listen: A mapping of event types to listener classes. Listeners
|
|
67
68
|
declared here are automatically bound into the container during
|
|
68
69
|
registration and wired to the event dispatcher during boot.
|
|
70
|
+
config_schema: Config namespaces this provider owns, mapped to the
|
|
71
|
+
shape each must have. Validated once at boot; a namespace that
|
|
72
|
+
is absent is skipped, since an absent namespace means the app
|
|
73
|
+
does not use that subsystem.
|
|
74
|
+
sensitive: Dot-notated config keys holding secrets. Redacted by
|
|
75
|
+
`ConfigRepository.redacted()`, never by `get()`.
|
|
69
76
|
|
|
70
77
|
"""
|
|
71
78
|
|
|
72
79
|
app: "Application"
|
|
73
80
|
listen: ClassVar[dict[type[Event[Any]], list[type[EventListener[Any]]]]] = {}
|
|
74
81
|
when: ClassVar[Marker | None] = None
|
|
82
|
+
config_schema: ClassVar[dict[str, TypeForm[Any]]] = {}
|
|
83
|
+
sensitive: ClassVar[tuple[str, ...]] = ()
|
|
75
84
|
|
|
76
85
|
def __init__(self, app: "Application") -> None:
|
|
77
86
|
"""Initialize the service provider.
|
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
"""Base service providers.
|
|
2
2
|
|
|
3
3
|
This module defines the core providers that are automatically registered.
|
|
4
|
-
These providers are essential for the framework
|
|
5
|
-
to function properly.
|
|
6
4
|
"""
|
|
7
5
|
|
|
8
6
|
from neva.arch import ServiceProvider
|
|
9
7
|
from neva.database import DatabaseServiceProvider
|
|
10
8
|
from neva.events.provider import EventServiceProvider
|
|
11
9
|
from neva.obs import LogServiceProvider
|
|
10
|
+
from neva.security import SecurityProvider
|
|
12
11
|
|
|
13
12
|
|
|
14
13
|
def base_providers() -> list[type[ServiceProvider]]:
|
|
15
14
|
"""Return the list of base service providers.
|
|
16
15
|
|
|
17
|
-
These providers are automatically registered during application
|
|
18
|
-
initialization and provide core framework functionality.
|
|
19
|
-
|
|
20
|
-
Note: ConfigServiceProvider is registered separately in Application.__init__
|
|
21
|
-
to allow custom config_path configuration.
|
|
22
|
-
|
|
23
16
|
Returns:
|
|
24
17
|
Set of service provider classes to register.
|
|
25
18
|
|
|
@@ -28,4 +21,5 @@ def base_providers() -> list[type[ServiceProvider]]:
|
|
|
28
21
|
LogServiceProvider,
|
|
29
22
|
EventServiceProvider,
|
|
30
23
|
DatabaseServiceProvider,
|
|
24
|
+
SecurityProvider,
|
|
31
25
|
]
|
|
@@ -13,6 +13,9 @@ from typing_extensions import TypeForm
|
|
|
13
13
|
from neva.support import Err, Ok, Result
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
REDACTED = "********"
|
|
17
|
+
|
|
18
|
+
|
|
16
19
|
class ConfigRepository:
|
|
17
20
|
"""Central repository for application configuration with dot notation support.
|
|
18
21
|
|
|
@@ -30,6 +33,7 @@ class ConfigRepository:
|
|
|
30
33
|
"""Initialize an empty configuration repository."""
|
|
31
34
|
self._items: dict[str, Any] = {}
|
|
32
35
|
self._frozen: bool = False
|
|
36
|
+
self._sensitive: set[str] = set()
|
|
33
37
|
|
|
34
38
|
def set(self, key: str, value: object) -> Result[None, str]:
|
|
35
39
|
"""Set a configuration value using dot notation.
|
|
@@ -102,6 +106,48 @@ class ConfigRepository:
|
|
|
102
106
|
"""
|
|
103
107
|
return self.get(key).is_ok
|
|
104
108
|
|
|
109
|
+
def mark_sensitive(self, *keys: str) -> None:
|
|
110
|
+
"""Mark dot-notated keys as holding secrets.
|
|
111
|
+
|
|
112
|
+
Marking changes nothing about `get`, which always returns the real
|
|
113
|
+
value: a consumer asking for a secret by name wants the secret.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
keys: Dot-notated key paths whose values must never be displayed.
|
|
117
|
+
"""
|
|
118
|
+
self._sensitive.update(keys)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def sensitive(self) -> frozenset[str]:
|
|
122
|
+
"""The keys marked as holding secrets.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Every marked dot-notated key path.
|
|
126
|
+
"""
|
|
127
|
+
return frozenset(self._sensitive)
|
|
128
|
+
|
|
129
|
+
def redacted(self) -> dict[str, Any]:
|
|
130
|
+
"""Every value, with the marked ones replaced by a placeholder.
|
|
131
|
+
|
|
132
|
+
This is the form to display — a config dump, a debug endpoint, a
|
|
133
|
+
console command. `all()` is unchanged and still returns real values.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
A deep copy in which every marked key reads ``********``.
|
|
137
|
+
"""
|
|
138
|
+
items = copy.deepcopy(self._items)
|
|
139
|
+
for key in self._sensitive:
|
|
140
|
+
*parents, leaf = key.split(".")
|
|
141
|
+
current: Any = items
|
|
142
|
+
for parent in parents:
|
|
143
|
+
if not isinstance(current, dict) or parent not in current:
|
|
144
|
+
break
|
|
145
|
+
current = current[parent]
|
|
146
|
+
else:
|
|
147
|
+
if isinstance(current, dict) and leaf in current:
|
|
148
|
+
current[leaf] = REDACTED
|
|
149
|
+
return items
|
|
150
|
+
|
|
105
151
|
def all(self) -> dict[str, Any]:
|
|
106
152
|
"""Get all configuration items as a dictionary.
|
|
107
153
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from collections.abc import AsyncIterator
|
|
4
4
|
from contextlib import asynccontextmanager
|
|
5
|
-
from typing import Self, override
|
|
5
|
+
from typing import Any, ClassVar, Self, override
|
|
6
|
+
|
|
7
|
+
from typing_extensions import TypeForm
|
|
6
8
|
|
|
7
9
|
from neva.arch import ServiceProvider
|
|
8
10
|
from neva.database.config import ConnectionConfig, DatabaseConfig
|
|
@@ -15,6 +17,8 @@ from neva.support import Err, Ok, Result
|
|
|
15
17
|
class DatabaseServiceProvider(ServiceProvider):
|
|
16
18
|
"""Database service provider."""
|
|
17
19
|
|
|
20
|
+
config_schema: ClassVar[dict[str, TypeForm[Any]]] = {"database": DatabaseConfig}
|
|
21
|
+
|
|
18
22
|
@override
|
|
19
23
|
def register(self) -> Result[Self, str]:
|
|
20
24
|
_ = self.singleton(TransactionContext)
|
|
@@ -26,16 +30,19 @@ class DatabaseServiceProvider(ServiceProvider):
|
|
|
26
30
|
"""Initialize and cleanup database connections."""
|
|
27
31
|
logger: LogManager = (await self.app.make_async(LogManager)).unwrap()
|
|
28
32
|
db: DatabaseManager = (await self.app.make_async(DatabaseManager)).unwrap()
|
|
29
|
-
logger.info("Beginning SQLAlchemy initialization...")
|
|
30
33
|
match self.app.config.get("database", type_=DatabaseConfig):
|
|
31
34
|
case Ok(config):
|
|
35
|
+
logger.info("Beginning SQLAlchemy initialization...")
|
|
32
36
|
connections: dict[str, ConnectionConfig] = config.get("connections", {})
|
|
33
37
|
for name, conn_config in connections.items():
|
|
34
38
|
db.register_connection(name, conn_config)
|
|
35
39
|
logger.info(f"Registered engine for connection '{name}'.")
|
|
36
40
|
logger.info("SQLAlchemy initialization complete.")
|
|
37
|
-
case Err(
|
|
38
|
-
|
|
41
|
+
case Err():
|
|
42
|
+
# An absent namespace means the app has no database, which is
|
|
43
|
+
# not a failure. `get` errs only on a missing key, so a
|
|
44
|
+
# malformed namespace never reaches here.
|
|
45
|
+
logger.debug("No database configuration; skipping initialization.")
|
|
39
46
|
|
|
40
47
|
try:
|
|
41
48
|
yield
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Base implementation of the event dispatcher."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import inspect
|
|
4
5
|
from typing import override
|
|
5
6
|
|
|
@@ -11,6 +12,21 @@ from neva.events.event_registry import EventRegistry, flatten_listeners
|
|
|
11
12
|
from neva.support import Err, Nothing, Result, Some
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
def _runs_concurrently[T: contracts.Event](
|
|
16
|
+
listener_cls: type[contracts.EventListener[T]],
|
|
17
|
+
) -> bool:
|
|
18
|
+
"""Whether a listener has opted in to running alongside the others.
|
|
19
|
+
|
|
20
|
+
Read defensively: the opt-in lives on the concrete base class, not on the
|
|
21
|
+
protocol, so a listener implementing the protocol directly has no such
|
|
22
|
+
attribute and stays sequential.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
True when the listener declares itself concurrency-safe.
|
|
26
|
+
"""
|
|
27
|
+
return getattr(listener_cls, "concurrent", False) is True
|
|
28
|
+
|
|
29
|
+
|
|
14
30
|
class EventDispatcher(contracts.EventDispatcher):
|
|
15
31
|
"""Event dispatcher implementation."""
|
|
16
32
|
|
|
@@ -77,15 +93,44 @@ class EventDispatcher(contracts.EventDispatcher):
|
|
|
77
93
|
case Nothing():
|
|
78
94
|
immediate.extend(d for d in deferred if d not in immediate)
|
|
79
95
|
|
|
80
|
-
for
|
|
96
|
+
sequential = [c for c in immediate if not _runs_concurrently(c)]
|
|
97
|
+
concurrent = [c for c in immediate if _runs_concurrently(c)]
|
|
98
|
+
|
|
99
|
+
for listener_cls in sequential:
|
|
81
100
|
listener = self._resolve_listener(listener_cls)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
results.append(await self._run(listener, event))
|
|
102
|
+
|
|
103
|
+
if concurrent:
|
|
104
|
+
# Resolved before the group, so a resolution failure propagates the
|
|
105
|
+
# way it always has rather than arriving wrapped in an ExceptionGroup.
|
|
106
|
+
listeners = [self._resolve_listener(c) for c in concurrent]
|
|
107
|
+
async with asyncio.TaskGroup() as group:
|
|
108
|
+
tasks = [
|
|
109
|
+
group.create_task(self._run(listener, event))
|
|
110
|
+
for listener in listeners
|
|
111
|
+
]
|
|
112
|
+
results.extend(task.result() for task in tasks)
|
|
86
113
|
|
|
87
114
|
return results
|
|
88
115
|
|
|
116
|
+
async def _run[T: contracts.Event](
|
|
117
|
+
self,
|
|
118
|
+
listener: contracts.EventListener[T],
|
|
119
|
+
event: T,
|
|
120
|
+
) -> Result[None, str]:
|
|
121
|
+
"""Run a resolved listener, turning a raise into an Err.
|
|
122
|
+
|
|
123
|
+
Resolution stays outside, because a listener that cannot be built is a
|
|
124
|
+
wiring bug rather than a handling failure, and has always propagated.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The listener's result, or an Err describing what it raised.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
return await listener.handle(event)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
return Err(f"Listener raised: {e}")
|
|
133
|
+
|
|
89
134
|
@override
|
|
90
135
|
def listen[T: contracts.Event](
|
|
91
136
|
self,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""Event listener protocol."""
|
|
2
2
|
|
|
3
3
|
from types import new_class
|
|
4
|
-
from typing import Callable, override
|
|
4
|
+
from typing import Callable, ClassVar, override
|
|
5
5
|
|
|
6
6
|
from neva.events import contracts
|
|
7
7
|
from neva.events.policy import HandlingPolicy
|
|
8
|
-
from neva.support import Result
|
|
8
|
+
from neva.support import Err, Result
|
|
9
9
|
from neva.support.strconv import snake2pascal
|
|
10
10
|
|
|
11
11
|
|
|
@@ -13,9 +13,24 @@ class EventListener[T: contracts.Event](contracts.EventListener[T]):
|
|
|
13
13
|
"""Base event listener class."""
|
|
14
14
|
|
|
15
15
|
policy: HandlingPolicy = HandlingPolicy.IMMEDIATE
|
|
16
|
+
concurrent: ClassVar[bool] = False
|
|
17
|
+
"""Whether this listener may run alongside the others for one event."""
|
|
16
18
|
|
|
17
19
|
@override
|
|
18
|
-
async def handle(self, event: T) -> Result[None, str]:
|
|
20
|
+
async def handle(self, event: T) -> Result[None, str]:
|
|
21
|
+
"""Handle an event.
|
|
22
|
+
|
|
23
|
+
Subclasses override this. The default body handles nothing and says so,
|
|
24
|
+
rather than returning None where the contract promises a Result.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
An Err naming the listener that failed to implement handle.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
return Err(
|
|
31
|
+
f"{type(self).__name__} does not implement handle(), "
|
|
32
|
+
f"so {type(event).__name__} was received and discarded."
|
|
33
|
+
)
|
|
19
34
|
|
|
20
35
|
|
|
21
36
|
def listener[T: contracts.Event](
|
|
@@ -4,7 +4,7 @@ title: Configuration
|
|
|
4
4
|
requires: python-neva>=4.0
|
|
5
5
|
triggers: [reading a config value, adding a config file, Config.get, config namespace, environment settings, providers list]
|
|
6
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]
|
|
7
|
+
verified_by: [tests/config/test_provider_declarations.py, tests/config/test_repository.py, tests/config/test_loader.py, tests/config/test_config_path_resolution.py, tests/arch/test_config_shapes.py]
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Configuration
|
|
@@ -55,7 +55,32 @@ config: AppConfig = {"key": "...", "providers": [ActorServiceProvider]}
|
|
|
55
55
|
`AppConfig` (`neva.arch`) covers `app.key`, `app.previous_keys`, `app.providers`, all optional.
|
|
56
56
|
`ProviderConfig` (`neva.arch`) covers `providers.providers`. `DatabaseConfig`
|
|
57
57
|
(`neva.database`) covers the `database` namespace, and its `connections` key is **required**.
|
|
58
|
-
|
|
58
|
+
|
|
59
|
+
### The `app` namespace is shared — compose the shapes
|
|
60
|
+
|
|
61
|
+
`app` is the one namespace several packages read at once. The core owns `key`, `previous_keys`
|
|
62
|
+
and `providers`; every integration you install owns the keys it adds. A `TypedDict` is closed,
|
|
63
|
+
so **no single package's shape can annotate a whole `config/app.py`**. Inherit from each one you
|
|
64
|
+
install:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from neva.arch import AppConfig
|
|
68
|
+
from neva.<integration> import IntegrationAppConfig
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ServiceAppConfig(IntegrationAppConfig, AppConfig): ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
config: ServiceAppConfig = {"key": "...", "providers": [ActorServiceProvider]}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Annotating with one package's shape alone then rejects every key the others own — `AppConfig`
|
|
78
|
+
rejects a title, an integration's shape rejects `providers`. That is the ownership rule working,
|
|
79
|
+
not a defect in either shape; do not "fix" it by having one package's shape extend another's.
|
|
80
|
+
|
|
81
|
+
**A key two packages both read is declared by both, with the same type.** Nothing enforces that:
|
|
82
|
+
diverging types make the composed shape unannotatable, and no package can test it because none
|
|
83
|
+
installs its siblings.
|
|
59
84
|
|
|
60
85
|
## Reading
|
|
61
86
|
|
|
@@ -72,11 +97,59 @@ Config.get("app.debug", type_=bool).unwrap_or(False)
|
|
|
72
97
|
|
|
73
98
|
**`type_` is a cast, not validation.** Nothing is coerced or checked:
|
|
74
99
|
`Config.get("app.debug", type_=bool)` over `debug = 0` returns the integer `0`, merely *typed*
|
|
75
|
-
as `bool`.
|
|
100
|
+
as `bool`. Validate the namespace at boot instead — see below.
|
|
76
101
|
|
|
77
102
|
`has(key) -> bool` is `get(key).is_ok`. `all() -> dict` returns a deep copy; mutating it never
|
|
78
103
|
reaches the repository, frozen or not.
|
|
79
104
|
|
|
105
|
+
## Validating a namespace at boot
|
|
106
|
+
|
|
107
|
+
A provider declares the shape its namespace must have, and it is checked at boot (since 5.4).
|
|
108
|
+
The declaration reuses the `TypedDict` shapes above — nothing needs to become a pydantic
|
|
109
|
+
model.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
class DatabaseServiceProvider(ServiceProvider):
|
|
113
|
+
config_schema: ClassVar[dict[str, TypeForm[Any]]] = {"database": DatabaseConfig}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **An absent namespace is skipped, not rejected.** Not using a subsystem is not a
|
|
117
|
+
configuration error; a provider that genuinely requires its config errors in `lifespan`.
|
|
118
|
+
- Every failure is reported together: `Invalid configuration: 'database' is malformed
|
|
119
|
+
(connections.default.url)`.
|
|
120
|
+
- A provider that declares nothing is validated not at all — the behaviour before 5.4.
|
|
121
|
+
- The constructor raises, as it does for any config failure; `Application.create()` returns it
|
|
122
|
+
as an `Err` instead.
|
|
123
|
+
- **A provider registered after construction is checked too, and reports as an `Err` from
|
|
124
|
+
`Application.register` rather than raising** (since 5.4.1). That is the path
|
|
125
|
+
`App.register` on `neva-fastapi` and `neva-faststream` delegates to; before 5.4.1 it skipped
|
|
126
|
+
validation and marked no secrets. A rejected provider is not registered at all.
|
|
127
|
+
|
|
128
|
+
**`type_` is a cast, not validation, so the value a malformed key holds is still whatever the
|
|
129
|
+
file put there.** Validation only *rejects* it — nothing is coerced and written back, and
|
|
130
|
+
pydantic's default lax mode accepts `"false"` for a `bool`. A namespace whose values must be
|
|
131
|
+
exactly typed declares `__pydantic_config__ = ConfigDict(strict=True)` on its `TypedDict`.
|
|
132
|
+
|
|
133
|
+
## Secrets
|
|
134
|
+
|
|
135
|
+
A provider marks the keys that hold secrets, and they are replaced in the display form only.
|
|
136
|
+
Marking happens wherever the provider is registered — at construction, or through
|
|
137
|
+
`Application.register` (since 5.4.1).
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
class SecurityProvider(ServiceProvider):
|
|
141
|
+
sensitive: ClassVar[tuple[str, ...]] = ("app.key", "app.previous_keys")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| Call | Marked key reads |
|
|
145
|
+
| ---- | ---------------- |
|
|
146
|
+
| `get("app.key")` | the real value — asking by name means you want it |
|
|
147
|
+
| `all()` | the real value — unchanged from before 5.4 |
|
|
148
|
+
| `redacted()` | `********` |
|
|
149
|
+
|
|
150
|
+
Use `redacted()` for anything displayed: a config dump, a debug endpoint, a console command.
|
|
151
|
+
`all()` deliberately did **not** change, so no existing caller sees different data on upgrade.
|
|
152
|
+
|
|
80
153
|
## Writing
|
|
81
154
|
|
|
82
155
|
Mutable until frozen. This is the extension seam — a package registers defaults, an application
|