python-neva 5.3.0__tar.gz → 5.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_neva-5.3.0 → python_neva-5.4.0}/.gitignore +1 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/CHANGELOG.md +33 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/PKG-INFO +1 -1
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/application.py +73 -1
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/facade.py +5 -3
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/service_provider.py +9 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/config/base_providers.py +2 -8
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/config/repository.py +46 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/database/provider.py +11 -4
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/dispatcher.py +50 -5
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/listener.py +18 -3
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/configuration.md +65 -3
- python_neva-5.4.0/neva/guidelines/fragments/drivers.md +87 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/events.md +20 -3
- python_neva-5.4.0/neva/guidelines/fragments/result-option.md +137 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/security.md +8 -6
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/encryption/encrypter.py +36 -33
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/provider.py +3 -1
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/__init__.py +14 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/accessors.py +3 -2
- python_neva-5.4.0/neva/support/results/__init__.py +31 -0
- python_neva-5.4.0/neva/support/results/collect.py +88 -0
- python_neva-5.4.0/neva/support/results/convert.py +14 -0
- python_neva-5.4.0/neva/support/results/exceptions.py +6 -0
- python_neva-5.3.0/neva/support/results.py → python_neva-5.4.0/neva/support/results/option.py +14 -382
- python_neva-5.4.0/neva/support/results/pipeline.py +290 -0
- python_neva-5.4.0/neva/support/results/result.py +363 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/strategy.py +17 -7
- {python_neva-5.3.0 → python_neva-5.4.0}/pyproject.toml +1 -1
- python_neva-5.4.0/tests/arch/test_application_create.py +62 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_config_shapes.py +26 -1
- python_neva-5.4.0/tests/config/test_base_providers.py +68 -0
- python_neva-5.4.0/tests/config/test_provider_declarations.py +147 -0
- python_neva-5.4.0/tests/database/test_provider_lifespan.py +75 -0
- python_neva-5.4.0/tests/events/test_concurrent.py +178 -0
- python_neva-5.4.0/tests/events/test_unimplemented_handle.py +83 -0
- python_neva-5.4.0/tests/guidelines/test_manifest.py +71 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/security/test_encrypter.py +79 -40
- python_neva-5.4.0/tests/support/__init__.py +0 -0
- python_neva-5.4.0/tests/support/test_accessors.py +86 -0
- python_neva-5.4.0/tests/support/test_collect.py +149 -0
- python_neva-5.4.0/tests/support/test_pipeline.py +257 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/support/test_results.py +45 -0
- python_neva-5.4.0/tests/support/test_results_imports.py +118 -0
- python_neva-5.4.0/tests/support/test_strategy_imports.py +69 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/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.0}/.envrc +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/.gitlab-ci.yml +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/.pre-commit-config.yaml +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/.python-version +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/CLAUDE.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/README.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/integrations/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/integrations/faststream.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/markers.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/arch/scopes.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/config/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/config/loader.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/config/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/database/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/database/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/database/connection.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/database/manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/database/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/database/transaction.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/contracts/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/contracts/dispatcher.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/contracts/event.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/contracts/handler.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/contracts/listener.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/event.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/event_registry.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/policy.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/provider.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/events/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/database-transactions.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/facades.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/factories.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/observability.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/service-providers.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/fragments/testing.md +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/manifest.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/guidelines/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/logging/channels.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/logging/contracts.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/logging/provider.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/logging/resolver.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/obs/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/polyfactory/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/polyfactory/factories.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/polyfactory/persistence.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/polyfactory/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/hashing/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/tokens/generate_token.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/app.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/config.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/db.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/db.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/event.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/hash.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/hash.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/log.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/strconv.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/support/time.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/testing/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/testing/fakes.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/testing/fixtures.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/testing/py.typed +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/neva/testing/test_case.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/ruff.toml +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/scripts/retag-with-changelog.sh +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_cache.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_context.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_extends.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_facade_resolution.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_facade_root_nesting.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_lifetimes.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_register_resolution.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_registration.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/arch/test_scope.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/config/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/config/test_config_path_resolution.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/config/test_loader.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/config/test_repository.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/conftest.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_connection_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_database_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_detached_lifespan.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_edge_cases.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_sqlalchemy_integration.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_transaction.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_transaction_callbacks.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_transaction_context.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/conftest.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_before_dispatch.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_binding.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_deferred.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_dispatch.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_event.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_function_listener.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_immediate.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_listen_on_parent_class.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/events/test_listener_wiring.py +0 -0
- {python_neva-5.3.0/tests/obs → python_neva-5.4.0/tests/guidelines}/__init__.py +0 -0
- {python_neva-5.3.0/tests/polyfactory → python_neva-5.4.0/tests/obs}/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/obs/conftest.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/obs/test_channels.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/obs/test_facade.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/obs/test_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/obs/test_provider_hook.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/obs/test_resolver.py +0 -0
- {python_neva-5.3.0/tests/support → python_neva-5.4.0/tests/polyfactory}/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/polyfactory/test_model_factory.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/security/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/security/test_config_shapes.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/security/test_hash_manager.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/security/test_tokens.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/support/test_strategy.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/__init__.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/test_application_reuse.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/test_boot_application.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/test_create_config_migration.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/test_event_fake.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/test_facade_restore.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/test_fixtures.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/test_refresh_database.py +0 -0
- {python_neva-5.3.0 → python_neva-5.4.0}/tests/testing/test_test_case.py +0 -0
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
## 5.4.0 (2026-09-07)
|
|
2
|
+
|
|
3
|
+
### ✨ Features
|
|
4
|
+
|
|
5
|
+
- **config**: let a provider declare its config namespace and its secrets
|
|
6
|
+
- **events**: let a listener opt in to running concurrently
|
|
7
|
+
- **arch**: add Application.create returning a Result instead of raising
|
|
8
|
+
- **support**: export StrategyResolver as the driver-manager base
|
|
9
|
+
- **security**: register SecurityProvider as a base provider
|
|
10
|
+
- **support**: add collect, partition and collect_async for many results
|
|
11
|
+
- **support**: add ResultPipeline and OptionPipeline for async chaining
|
|
12
|
+
|
|
13
|
+
### 🐛🚑️ Fixes
|
|
14
|
+
|
|
15
|
+
- **support**: evaluate an attribute once in get_attr rather than twice
|
|
16
|
+
- **events**: return Err from the default listener handle instead of None
|
|
17
|
+
- **database**: stop reporting an absent database namespace as a failure
|
|
18
|
+
- **security**: surface key loading failures as Err rather than raising
|
|
19
|
+
|
|
20
|
+
### ♻️ Refactorings
|
|
21
|
+
|
|
22
|
+
- **arch**: build the facade root error only when it is needed
|
|
23
|
+
- **support**: split the results module into a package
|
|
24
|
+
|
|
25
|
+
### ✅🤡🧪 Tests
|
|
26
|
+
|
|
27
|
+
- **support**: cover falsy values and arbitrary iterables in collect
|
|
28
|
+
- **guidelines**: pin the boost manifest and the fragment requires floor
|
|
29
|
+
|
|
30
|
+
### 📝💡 Documentation
|
|
31
|
+
|
|
32
|
+
- **guidelines**: document composing the shared app config namespace
|
|
33
|
+
|
|
1
34
|
## 5.3.0 (2026-09-02)
|
|
2
35
|
|
|
3
36
|
### ✨ 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(
|
|
@@ -95,9 +97,32 @@ class Application:
|
|
|
95
97
|
)
|
|
96
98
|
_ = self.root_provider.provide(source=lambda: self, provides=Application)
|
|
97
99
|
self.register_providers(providers)
|
|
100
|
+
self._apply_provider_config_declarations()
|
|
98
101
|
self._bind_event_listeners()
|
|
99
102
|
self.build_container()
|
|
100
103
|
|
|
104
|
+
@classmethod
|
|
105
|
+
def create(cls, config_path: str | Path | None = None) -> Result[Self, str]:
|
|
106
|
+
"""Build an application, reporting a boot failure as a value.
|
|
107
|
+
|
|
108
|
+
The constructor raises, because a constructor cannot return a Result.
|
|
109
|
+
This is the same construction with the framework's own convention, and
|
|
110
|
+
is the form to prefer at a boundary that already handles Results.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
config_path: Path to the configuration directory. Defaults to
|
|
114
|
+
``$NEVA_CONFIG_PATH``, else ``./config`` relative to the
|
|
115
|
+
current working directory.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Ok with the application, or Err with the reason it could not boot.
|
|
119
|
+
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
return Ok(cls(config_path))
|
|
123
|
+
except RuntimeError as e:
|
|
124
|
+
return Err(str(e))
|
|
125
|
+
|
|
101
126
|
@property
|
|
102
127
|
def container(self) -> dishka.AsyncContainer:
|
|
103
128
|
"""The application container, built on first access.
|
|
@@ -161,6 +186,53 @@ class Application:
|
|
|
161
186
|
self._container = None
|
|
162
187
|
return registered
|
|
163
188
|
|
|
189
|
+
def _apply_provider_config_declarations(self) -> None:
|
|
190
|
+
"""Validate declared config namespaces and mark declared secrets.
|
|
191
|
+
|
|
192
|
+
Runs once, over the providers registered during construction. A
|
|
193
|
+
namespace that is absent is skipped: not using a subsystem is not a
|
|
194
|
+
configuration error. Every failure is reported together, so a boot
|
|
195
|
+
naming three bad namespaces takes one run rather than three.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
RuntimeError: If any declared namespace fails its shape.
|
|
199
|
+
"""
|
|
200
|
+
checks: list[Result[None, str]] = []
|
|
201
|
+
for provider in self.providers.values():
|
|
202
|
+
declaring = type(provider)
|
|
203
|
+
self.config.mark_sensitive(*declaring.sensitive)
|
|
204
|
+
checks.extend(
|
|
205
|
+
self._validate_namespace(namespace, schema)
|
|
206
|
+
for namespace, schema in declaring.config_schema.items()
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
_, failures = partition(checks)
|
|
210
|
+
if failures:
|
|
211
|
+
raise RuntimeError("Invalid configuration: " + "; ".join(failures))
|
|
212
|
+
|
|
213
|
+
def _validate_namespace(
|
|
214
|
+
self, namespace: str, schema: TypeForm[Any]
|
|
215
|
+
) -> Result[None, str]:
|
|
216
|
+
"""Check one config namespace against the shape a provider declared.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Ok when the namespace is absent or valid, otherwise Err naming the
|
|
220
|
+
namespace and the first offending field path.
|
|
221
|
+
"""
|
|
222
|
+
value = self.config.get(namespace)
|
|
223
|
+
if value.is_err:
|
|
224
|
+
return Ok(None)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
_ = pydantic.TypeAdapter(schema).validate_python(value.unwrap())
|
|
228
|
+
except pydantic.ValidationError as e:
|
|
229
|
+
paths = ", ".join(
|
|
230
|
+
".".join(str(part) for part in error["loc"]) or "<root>"
|
|
231
|
+
for error in e.errors()
|
|
232
|
+
)
|
|
233
|
+
return Err(f"'{namespace}' is malformed ({paths})")
|
|
234
|
+
return Ok(None)
|
|
235
|
+
|
|
164
236
|
def register_providers(self, providers: Sequence[type[ServiceProvider]]) -> None:
|
|
165
237
|
"""Registers a set of providers."""
|
|
166
238
|
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,48 @@ 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 once during
|
|
108
|
+
construction (since 5.4). The declaration reuses the `TypedDict` shapes above — nothing needs
|
|
109
|
+
to become a pydantic 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
|
+
|
|
124
|
+
## Secrets
|
|
125
|
+
|
|
126
|
+
A provider marks the keys that hold secrets, and they are replaced in the display form only.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
class SecurityProvider(ServiceProvider):
|
|
130
|
+
sensitive: ClassVar[tuple[str, ...]] = ("app.key", "app.previous_keys")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| Call | Marked key reads |
|
|
134
|
+
| ---- | ---------------- |
|
|
135
|
+
| `get("app.key")` | the real value — asking by name means you want it |
|
|
136
|
+
| `all()` | the real value — unchanged from before 5.4 |
|
|
137
|
+
| `redacted()` | `********` |
|
|
138
|
+
|
|
139
|
+
Use `redacted()` for anything displayed: a config dump, a debug endpoint, a console command.
|
|
140
|
+
`all()` deliberately did **not** change, so no existing caller sees different data on upgrade.
|
|
141
|
+
|
|
80
142
|
## Writing
|
|
81
143
|
|
|
82
144
|
Mutable until frozen. This is the extension seam — a package registers defaults, an application
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: drivers
|
|
3
|
+
title: Swappable drivers
|
|
4
|
+
requires: python-neva>=5.3
|
|
5
|
+
triggers: [swappable backend, driver manager, multiple implementations, choosing an implementation from config, registering a driver, plugin extension point]
|
|
6
|
+
priority: 70
|
|
7
|
+
verified_by: [tests/support/test_strategy.py, tests/support/test_strategy_imports.py]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Swappable drivers
|
|
11
|
+
|
|
12
|
+
A subsystem with interchangeable backends — cache stores, storage disks, queue transports,
|
|
13
|
+
hashers, log channels — subclasses `StrategyResolver`. **Never hand-roll a name-to-instance
|
|
14
|
+
dict**: the caching, the default lookup and the error shape are already decided here.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from neva.support import StrategyResolver # since 5.4
|
|
18
|
+
from neva.support.strategy import StrategyResolver # any version
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The contract
|
|
22
|
+
|
|
23
|
+
Subclass `StrategyResolver[T]` where `T` is the driver protocol, register factories in
|
|
24
|
+
`__init__`, and implement `default()` reading the name from config.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
class CacheManager(StrategyResolver[Store]):
|
|
28
|
+
def __init__(self, app: Application) -> None:
|
|
29
|
+
super().__init__(app)
|
|
30
|
+
_ = self.register("memory", self._create_memory_store)
|
|
31
|
+
_ = self.register("redis", self._create_redis_store)
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def default(self) -> Option[str]:
|
|
35
|
+
return self.app.config.get("cache.driver", type_=str).ok()
|
|
36
|
+
|
|
37
|
+
def _create_memory_store(self, manager: StrategyResolver[Store]) -> MemoryStore:
|
|
38
|
+
return MemoryStore()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A factory takes the resolver, not the app — reach config through `manager.app.config`. That is
|
|
42
|
+
what lets a plugin register a driver against a resolver it did not construct.
|
|
43
|
+
|
|
44
|
+
## Resolving
|
|
45
|
+
|
|
46
|
+
| Call | Does |
|
|
47
|
+
| ---- | ---- |
|
|
48
|
+
| `use()` | The default driver, created once and cached |
|
|
49
|
+
| `use("redis")` | That driver, created once and cached |
|
|
50
|
+
| `resolve(name)` | Builds a fresh instance, bypassing the cache |
|
|
51
|
+
| `register(name, factory)` | Adds a factory; returns `self` for chaining |
|
|
52
|
+
| `clear()` | Drops cached instances, keeps the factories |
|
|
53
|
+
| `strategies` | The instances built so far |
|
|
54
|
+
|
|
55
|
+
- `use` returns `Result[T, str]`. `Err` when no default is configured, when the name is
|
|
56
|
+
unregistered, or when the factory raised — the factory's exception is caught and becomes
|
|
57
|
+
`Err`, never propagates.
|
|
58
|
+
- `use` caches per name; `resolve` does not. A driver holding a connection wants `use`.
|
|
59
|
+
- `default()` returns `Option[str]`, so "no driver configured" is `Nothing`, not an exception.
|
|
60
|
+
Read it from config with `.ok()` on the `Result`.
|
|
61
|
+
|
|
62
|
+
## Registering a driver from a plugin
|
|
63
|
+
|
|
64
|
+
The resolver is a singleton in the container, so a provider's `register()` can add to it:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
class RedisCacheProvider(ServiceProvider):
|
|
68
|
+
def register(self) -> Result[Self, str]:
|
|
69
|
+
return self.app.make(CacheManager).map(
|
|
70
|
+
lambda cache: cache.register("redis", _create_redis_store)
|
|
71
|
+
).map(lambda _: self)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Rules
|
|
75
|
+
|
|
76
|
+
- One resolver per subsystem, bound as a **singleton** — the instance cache lives on it, so a
|
|
77
|
+
transient resolver caches nothing.
|
|
78
|
+
- The driver type `T` is a protocol, not a base class. Drivers are structural.
|
|
79
|
+
- Do not raise from a factory to signal a bad configuration; it becomes an opaque
|
|
80
|
+
`Err(f"Registered strategy '{name}' creation failed: {e}")`. Validate before constructing.
|
|
81
|
+
- `set_container(app)` exists for tests and re-binding. Ordinary code never calls it.
|
|
82
|
+
|
|
83
|
+
## Worked examples in the core
|
|
84
|
+
|
|
85
|
+
`HashManager` (`neva/security/hashing/hash_manager.py`) resolves `argon2` / `bcrypt` from
|
|
86
|
+
`hashing.driver`. `ChannelResolver` (`neva/obs/logging/resolver.py`) resolves log channels.
|
|
87
|
+
Copy either; they are the same shape.
|