python-neva 5.1.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.
Files changed (205) hide show
  1. {python_neva-5.1.0 → python_neva-5.4.0}/.gitignore +1 -0
  2. {python_neva-5.1.0 → python_neva-5.4.0}/CHANGELOG.md +49 -0
  3. {python_neva-5.1.0 → python_neva-5.4.0}/PKG-INFO +1 -1
  4. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/application.py +73 -1
  5. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/facade.py +5 -3
  6. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/service_provider.py +9 -0
  7. {python_neva-5.1.0 → python_neva-5.4.0}/neva/config/base_providers.py +2 -8
  8. {python_neva-5.1.0 → python_neva-5.4.0}/neva/config/repository.py +46 -0
  9. {python_neva-5.1.0 → python_neva-5.4.0}/neva/database/provider.py +11 -4
  10. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/dispatcher.py +50 -5
  11. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/listener.py +18 -3
  12. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/configuration.md +65 -3
  13. python_neva-5.4.0/neva/guidelines/fragments/drivers.md +87 -0
  14. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/events.md +20 -3
  15. python_neva-5.4.0/neva/guidelines/fragments/result-option.md +137 -0
  16. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/security.md +8 -6
  17. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/service-providers.md +9 -4
  18. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/testing.md +30 -3
  19. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/encryption/encrypter.py +36 -33
  20. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/provider.py +3 -1
  21. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/__init__.py +14 -0
  22. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/accessors.py +3 -2
  23. python_neva-5.4.0/neva/support/results/__init__.py +31 -0
  24. python_neva-5.4.0/neva/support/results/collect.py +88 -0
  25. python_neva-5.4.0/neva/support/results/convert.py +14 -0
  26. python_neva-5.4.0/neva/support/results/exceptions.py +6 -0
  27. python_neva-5.1.0/neva/support/results.py → python_neva-5.4.0/neva/support/results/option.py +14 -382
  28. python_neva-5.4.0/neva/support/results/pipeline.py +290 -0
  29. python_neva-5.4.0/neva/support/results/result.py +363 -0
  30. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/strategy.py +17 -7
  31. {python_neva-5.1.0 → python_neva-5.4.0}/neva/testing/test_case.py +40 -4
  32. {python_neva-5.1.0 → python_neva-5.4.0}/pyproject.toml +1 -1
  33. python_neva-5.4.0/tests/arch/test_application_create.py +62 -0
  34. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_config_shapes.py +26 -1
  35. python_neva-5.4.0/tests/config/test_base_providers.py +68 -0
  36. python_neva-5.4.0/tests/config/test_provider_declarations.py +147 -0
  37. python_neva-5.4.0/tests/database/test_provider_lifespan.py +75 -0
  38. python_neva-5.4.0/tests/events/test_concurrent.py +178 -0
  39. python_neva-5.4.0/tests/events/test_unimplemented_handle.py +83 -0
  40. python_neva-5.4.0/tests/guidelines/test_manifest.py +71 -0
  41. {python_neva-5.1.0 → python_neva-5.4.0}/tests/security/test_encrypter.py +79 -40
  42. python_neva-5.4.0/tests/support/__init__.py +0 -0
  43. python_neva-5.4.0/tests/support/test_accessors.py +86 -0
  44. python_neva-5.4.0/tests/support/test_collect.py +149 -0
  45. python_neva-5.4.0/tests/support/test_pipeline.py +257 -0
  46. {python_neva-5.1.0 → python_neva-5.4.0}/tests/support/test_results.py +45 -0
  47. python_neva-5.4.0/tests/support/test_results_imports.py +118 -0
  48. python_neva-5.4.0/tests/support/test_strategy_imports.py +69 -0
  49. python_neva-5.4.0/tests/testing/test_boot_application.py +85 -0
  50. {python_neva-5.1.0 → python_neva-5.4.0}/uv.lock +1 -1
  51. python_neva-5.1.0/.claude/settings.local.json +0 -18
  52. python_neva-5.1.0/neva/guidelines/fragments/result-option.md +0 -74
  53. {python_neva-5.1.0 → python_neva-5.4.0}/.envrc +0 -0
  54. {python_neva-5.1.0 → python_neva-5.4.0}/.gitlab-ci.yml +0 -0
  55. {python_neva-5.1.0 → python_neva-5.4.0}/.pre-commit-config.yaml +0 -0
  56. {python_neva-5.1.0 → python_neva-5.4.0}/.python-version +0 -0
  57. {python_neva-5.1.0 → python_neva-5.4.0}/CLAUDE.md +0 -0
  58. {python_neva-5.1.0 → python_neva-5.4.0}/README.md +0 -0
  59. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/__init__.py +0 -0
  60. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/config.py +0 -0
  61. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/integrations/__init__.py +0 -0
  62. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/integrations/faststream.py +0 -0
  63. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/markers.py +0 -0
  64. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/py.typed +0 -0
  65. {python_neva-5.1.0 → python_neva-5.4.0}/neva/arch/scopes.py +0 -0
  66. {python_neva-5.1.0 → python_neva-5.4.0}/neva/config/__init__.py +0 -0
  67. {python_neva-5.1.0 → python_neva-5.4.0}/neva/config/loader.py +0 -0
  68. {python_neva-5.1.0 → python_neva-5.4.0}/neva/config/py.typed +0 -0
  69. {python_neva-5.1.0 → python_neva-5.4.0}/neva/database/__init__.py +0 -0
  70. {python_neva-5.1.0 → python_neva-5.4.0}/neva/database/config.py +0 -0
  71. {python_neva-5.1.0 → python_neva-5.4.0}/neva/database/connection.py +0 -0
  72. {python_neva-5.1.0 → python_neva-5.4.0}/neva/database/manager.py +0 -0
  73. {python_neva-5.1.0 → python_neva-5.4.0}/neva/database/py.typed +0 -0
  74. {python_neva-5.1.0 → python_neva-5.4.0}/neva/database/transaction.py +0 -0
  75. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/__init__.py +0 -0
  76. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/contracts/__init__.py +0 -0
  77. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/contracts/dispatcher.py +0 -0
  78. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/contracts/event.py +0 -0
  79. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/contracts/handler.py +0 -0
  80. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/contracts/listener.py +0 -0
  81. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/event.py +0 -0
  82. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/event_registry.py +0 -0
  83. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/policy.py +0 -0
  84. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/provider.py +0 -0
  85. {python_neva-5.1.0 → python_neva-5.4.0}/neva/events/py.typed +0 -0
  86. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/__init__.py +0 -0
  87. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/database-transactions.md +0 -0
  88. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/facades.md +0 -0
  89. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/factories.md +0 -0
  90. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/fragments/observability.md +0 -0
  91. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/manifest.py +0 -0
  92. {python_neva-5.1.0 → python_neva-5.4.0}/neva/guidelines/py.typed +0 -0
  93. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/__init__.py +0 -0
  94. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/config.py +0 -0
  95. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/logging/__init__.py +0 -0
  96. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/logging/channels.py +0 -0
  97. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/logging/contracts.py +0 -0
  98. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/logging/manager.py +0 -0
  99. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/logging/provider.py +0 -0
  100. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/logging/resolver.py +0 -0
  101. {python_neva-5.1.0 → python_neva-5.4.0}/neva/obs/py.typed +0 -0
  102. {python_neva-5.1.0 → python_neva-5.4.0}/neva/polyfactory/__init__.py +0 -0
  103. {python_neva-5.1.0 → python_neva-5.4.0}/neva/polyfactory/factories.py +0 -0
  104. {python_neva-5.1.0 → python_neva-5.4.0}/neva/polyfactory/persistence.py +0 -0
  105. {python_neva-5.1.0 → python_neva-5.4.0}/neva/polyfactory/py.typed +0 -0
  106. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/__init__.py +0 -0
  107. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/encryption/__init__.py +0 -0
  108. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/encryption/protocol.py +0 -0
  109. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/hashing/__init__.py +0 -0
  110. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/hashing/config.py +0 -0
  111. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/hashing/hash_manager.py +0 -0
  112. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/hashing/hashers/__init__.py +0 -0
  113. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/hashing/hashers/argon2.py +0 -0
  114. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  115. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/hashing/hashers/protocol.py +0 -0
  116. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/py.typed +0 -0
  117. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/tokens/__init__.py +0 -0
  118. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/tokens/generate_token.py +0 -0
  119. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/tokens/hash_token.py +0 -0
  120. {python_neva-5.1.0 → python_neva-5.4.0}/neva/security/tokens/verify_token.py +0 -0
  121. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/__init__.py +0 -0
  122. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/app.py +0 -0
  123. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/app.pyi +0 -0
  124. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/config.py +0 -0
  125. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/config.pyi +0 -0
  126. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/crypt.py +0 -0
  127. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/crypt.pyi +0 -0
  128. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/db.py +0 -0
  129. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/db.pyi +0 -0
  130. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/event.py +0 -0
  131. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/event.pyi +0 -0
  132. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/hash.py +0 -0
  133. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/hash.pyi +0 -0
  134. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/log.py +0 -0
  135. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/facade/log.pyi +0 -0
  136. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/py.typed +0 -0
  137. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/strconv.py +0 -0
  138. {python_neva-5.1.0 → python_neva-5.4.0}/neva/support/time.py +0 -0
  139. {python_neva-5.1.0 → python_neva-5.4.0}/neva/testing/__init__.py +0 -0
  140. {python_neva-5.1.0 → python_neva-5.4.0}/neva/testing/fakes.py +0 -0
  141. {python_neva-5.1.0 → python_neva-5.4.0}/neva/testing/fixtures.py +0 -0
  142. {python_neva-5.1.0 → python_neva-5.4.0}/neva/testing/py.typed +0 -0
  143. {python_neva-5.1.0 → python_neva-5.4.0}/ruff.toml +0 -0
  144. {python_neva-5.1.0 → python_neva-5.4.0}/scripts/retag-with-changelog.sh +0 -0
  145. {python_neva-5.1.0 → python_neva-5.4.0}/tests/__init__.py +0 -0
  146. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/__init__.py +0 -0
  147. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_cache.py +0 -0
  148. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_context.py +0 -0
  149. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_extends.py +0 -0
  150. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_facade_resolution.py +0 -0
  151. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_facade_root_nesting.py +0 -0
  152. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_lifetimes.py +0 -0
  153. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_register_resolution.py +0 -0
  154. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_registration.py +0 -0
  155. {python_neva-5.1.0 → python_neva-5.4.0}/tests/arch/test_scope.py +0 -0
  156. {python_neva-5.1.0 → python_neva-5.4.0}/tests/config/__init__.py +0 -0
  157. {python_neva-5.1.0 → python_neva-5.4.0}/tests/config/test_config_path_resolution.py +0 -0
  158. {python_neva-5.1.0 → python_neva-5.4.0}/tests/config/test_loader.py +0 -0
  159. {python_neva-5.1.0 → python_neva-5.4.0}/tests/config/test_repository.py +0 -0
  160. {python_neva-5.1.0 → python_neva-5.4.0}/tests/conftest.py +0 -0
  161. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/__init__.py +0 -0
  162. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_connection_manager.py +0 -0
  163. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_database_manager.py +0 -0
  164. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_detached_lifespan.py +0 -0
  165. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_edge_cases.py +0 -0
  166. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_multi_connection.py +0 -0
  167. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  168. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_transaction.py +0 -0
  169. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_transaction_callbacks.py +0 -0
  170. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_transaction_context.py +0 -0
  171. {python_neva-5.1.0 → python_neva-5.4.0}/tests/database/test_transaction_registry.py +0 -0
  172. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/__init__.py +0 -0
  173. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/conftest.py +0 -0
  174. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_before_dispatch.py +0 -0
  175. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_binding.py +0 -0
  176. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_deferred.py +0 -0
  177. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_dispatch.py +0 -0
  178. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_event.py +0 -0
  179. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_function_listener.py +0 -0
  180. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_immediate.py +0 -0
  181. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_listen_on_parent_class.py +0 -0
  182. {python_neva-5.1.0 → python_neva-5.4.0}/tests/events/test_listener_wiring.py +0 -0
  183. {python_neva-5.1.0/tests/obs → python_neva-5.4.0/tests/guidelines}/__init__.py +0 -0
  184. {python_neva-5.1.0/tests/polyfactory → python_neva-5.4.0/tests/obs}/__init__.py +0 -0
  185. {python_neva-5.1.0 → python_neva-5.4.0}/tests/obs/conftest.py +0 -0
  186. {python_neva-5.1.0 → python_neva-5.4.0}/tests/obs/test_channels.py +0 -0
  187. {python_neva-5.1.0 → python_neva-5.4.0}/tests/obs/test_facade.py +0 -0
  188. {python_neva-5.1.0 → python_neva-5.4.0}/tests/obs/test_manager.py +0 -0
  189. {python_neva-5.1.0 → python_neva-5.4.0}/tests/obs/test_provider_hook.py +0 -0
  190. {python_neva-5.1.0 → python_neva-5.4.0}/tests/obs/test_resolver.py +0 -0
  191. {python_neva-5.1.0/tests/support → python_neva-5.4.0/tests/polyfactory}/__init__.py +0 -0
  192. {python_neva-5.1.0 → python_neva-5.4.0}/tests/polyfactory/test_model_factory.py +0 -0
  193. {python_neva-5.1.0 → python_neva-5.4.0}/tests/security/__init__.py +0 -0
  194. {python_neva-5.1.0 → python_neva-5.4.0}/tests/security/test_config_shapes.py +0 -0
  195. {python_neva-5.1.0 → python_neva-5.4.0}/tests/security/test_hash_manager.py +0 -0
  196. {python_neva-5.1.0 → python_neva-5.4.0}/tests/security/test_tokens.py +0 -0
  197. {python_neva-5.1.0 → python_neva-5.4.0}/tests/support/test_strategy.py +0 -0
  198. {python_neva-5.1.0 → python_neva-5.4.0}/tests/testing/__init__.py +0 -0
  199. {python_neva-5.1.0 → python_neva-5.4.0}/tests/testing/test_application_reuse.py +0 -0
  200. {python_neva-5.1.0 → python_neva-5.4.0}/tests/testing/test_create_config_migration.py +0 -0
  201. {python_neva-5.1.0 → python_neva-5.4.0}/tests/testing/test_event_fake.py +0 -0
  202. {python_neva-5.1.0 → python_neva-5.4.0}/tests/testing/test_facade_restore.py +0 -0
  203. {python_neva-5.1.0 → python_neva-5.4.0}/tests/testing/test_fixtures.py +0 -0
  204. {python_neva-5.1.0 → python_neva-5.4.0}/tests/testing/test_refresh_database.py +0 -0
  205. {python_neva-5.1.0 → python_neva-5.4.0}/tests/testing/test_test_case.py +0 -0
@@ -18,3 +18,4 @@ profiles/
18
18
 
19
19
  # LLM Contexts
20
20
  llm-context.md
21
+ .claude/
@@ -1,3 +1,52 @@
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
+
34
+ ## 5.3.0 (2026-09-02)
35
+
36
+ ### ✨ Features
37
+
38
+ - **testing**: promote the application hook TestCase builds through
39
+
40
+ ### 📝💡 Documentation
41
+
42
+ - **guidelines**: say which of register's two checks is read first
43
+
44
+ ## 5.2.0 (2026-09-02)
45
+
46
+ ### ✨ Features
47
+
48
+ - **testing**: promote the application hook TestCase builds through
49
+
1
50
  ## 5.1.0 (2026-09-02)
2
51
 
3
52
  ### ✨ Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-neva
3
- Version: 5.1.0
3
+ Version: 5.4.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -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
- .ok_or(
98
- f"A facade root (App instance) has not been set for {cls.__name__}. "
99
- "Call Facade.set_facade_application(app) first."
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(err):
38
- logger.error(f"Failed to load database configuration: {err}")
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 listener_cls in immediate:
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
- try:
83
- results.append(await listener.handle(event))
84
- except Exception as e:
85
- results.append(Err(f"Listener raised: {e}"))
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
- The `app` namespace is shared — an integration package declares the keys it adds.
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`. Parse or validate separately, and wrap consumed config in a typed object.
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.