python-neva 3.7.0__tar.gz → 4.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. {python_neva-3.7.0 → python_neva-4.1.0}/CHANGELOG.md +42 -0
  2. {python_neva-3.7.0 → python_neva-4.1.0}/CLAUDE.md +13 -2
  3. {python_neva-3.7.0 → python_neva-4.1.0}/PKG-INFO +4 -2
  4. {python_neva-3.7.0 → python_neva-4.1.0}/README.md +1 -0
  5. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/__init__.py +3 -0
  6. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/application.py +64 -35
  7. python_neva-4.1.0/neva/arch/config.py +49 -0
  8. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/integrations/faststream.py +9 -6
  9. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/service_provider.py +11 -11
  10. {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/connection.py +7 -2
  11. {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/manager.py +7 -3
  12. {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/transaction.py +30 -12
  13. python_neva-4.1.0/neva/guidelines/__init__.py +6 -0
  14. python_neva-4.1.0/neva/guidelines/fragments/configuration.md +108 -0
  15. python_neva-4.1.0/neva/guidelines/fragments/database-transactions.md +125 -0
  16. python_neva-4.1.0/neva/guidelines/fragments/events.md +150 -0
  17. python_neva-4.1.0/neva/guidelines/fragments/result-option.md +74 -0
  18. python_neva-4.1.0/neva/guidelines/fragments/service-providers.md +114 -0
  19. python_neva-4.1.0/neva/guidelines/fragments/testing.md +133 -0
  20. python_neva-4.1.0/neva/guidelines/manifest.py +26 -0
  21. python_neva-4.1.0/neva/obs/__init__.py +9 -0
  22. python_neva-4.1.0/neva/security/tokens/generate_token.py +18 -0
  23. python_neva-4.1.0/neva/testing/__init__.py +7 -0
  24. python_neva-4.1.0/neva/testing/py.typed +0 -0
  25. python_neva-4.1.0/neva/testing/test_case.py +208 -0
  26. {python_neva-3.7.0 → python_neva-4.1.0}/pyproject.toml +5 -2
  27. {python_neva-3.7.0 → python_neva-4.1.0}/ruff.toml +2 -2
  28. {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_cache.py +2 -1
  29. python_neva-4.1.0/tests/arch/test_config_shapes.py +54 -0
  30. {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_context.py +2 -1
  31. {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_extends.py +2 -1
  32. python_neva-4.1.0/tests/arch/test_facade_root_nesting.py +46 -0
  33. {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_lifetimes.py +7 -10
  34. {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_scope.py +12 -6
  35. python_neva-4.1.0/tests/config/test_config_path_resolution.py +48 -0
  36. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_database_manager.py +0 -1
  37. python_neva-4.1.0/tests/database/test_transaction_callbacks.py +114 -0
  38. python_neva-4.1.0/tests/events/test_listener_wiring.py +108 -0
  39. {python_neva-3.7.0 → python_neva-4.1.0}/tests/security/test_encrypter.py +14 -7
  40. {python_neva-3.7.0 → python_neva-4.1.0}/tests/security/test_hash_manager.py +8 -4
  41. python_neva-4.1.0/tests/security/test_tokens.py +77 -0
  42. python_neva-4.1.0/tests/support/__init__.py +0 -0
  43. python_neva-4.1.0/tests/support/test_results.py +310 -0
  44. python_neva-4.1.0/tests/testing/test_application_reuse.py +67 -0
  45. python_neva-4.1.0/tests/testing/test_create_config_migration.py +76 -0
  46. {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_refresh_database.py +9 -3
  47. {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_test_case.py +8 -7
  48. {python_neva-3.7.0 → python_neva-4.1.0}/uv.lock +125 -79
  49. python_neva-3.7.0/neva/arch/config.py +0 -37
  50. python_neva-3.7.0/neva/obs/__init__.py +0 -13
  51. python_neva-3.7.0/neva/obs/middleware/__init__.py +0 -7
  52. python_neva-3.7.0/neva/obs/middleware/correlation.py +0 -68
  53. python_neva-3.7.0/neva/obs/middleware/profiler.py +0 -68
  54. python_neva-3.7.0/neva/security/tokens/generate_token.py +0 -16
  55. python_neva-3.7.0/neva/testing/__init__.py +0 -7
  56. python_neva-3.7.0/neva/testing/test_case.py +0 -101
  57. python_neva-3.7.0/tests/obs/__init__.py +0 -1
  58. python_neva-3.7.0/tests/obs/test_correlation.py +0 -127
  59. python_neva-3.7.0/tests/obs/test_profiler.py +0 -90
  60. python_neva-3.7.0/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -23
  61. python_neva-3.7.0/wiki/architecture/configuration.md +0 -315
  62. python_neva-3.7.0/wiki/architecture/database-and-transactions.md +0 -404
  63. python_neva-3.7.0/wiki/architecture/dependency-injection.md +0 -583
  64. python_neva-3.7.0/wiki/architecture/events.md +0 -424
  65. python_neva-3.7.0/wiki/architecture/result-option.md +0 -296
  66. python_neva-3.7.0/wiki/architecture/testing.md +0 -367
  67. {python_neva-3.7.0 → python_neva-4.1.0}/.claude/settings.local.json +0 -0
  68. {python_neva-3.7.0 → python_neva-4.1.0}/.envrc +0 -0
  69. {python_neva-3.7.0 → python_neva-4.1.0}/.gitignore +0 -0
  70. {python_neva-3.7.0 → python_neva-4.1.0}/.gitlab-ci.yml +0 -0
  71. {python_neva-3.7.0 → python_neva-4.1.0}/.pre-commit-config.yaml +0 -0
  72. {python_neva-3.7.0 → python_neva-4.1.0}/.python-version +0 -0
  73. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/facade.py +0 -0
  74. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/integrations/__init__.py +0 -0
  75. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/markers.py +0 -0
  76. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/py.typed +0 -0
  77. {python_neva-3.7.0 → python_neva-4.1.0}/neva/arch/scopes.py +0 -0
  78. {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/__init__.py +0 -0
  79. {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/base_providers.py +0 -0
  80. {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/loader.py +0 -0
  81. {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/py.typed +0 -0
  82. {python_neva-3.7.0 → python_neva-4.1.0}/neva/config/repository.py +0 -0
  83. {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/__init__.py +0 -0
  84. {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/config.py +0 -0
  85. {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/provider.py +0 -0
  86. {python_neva-3.7.0 → python_neva-4.1.0}/neva/database/py.typed +0 -0
  87. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/__init__.py +0 -0
  88. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/__init__.py +0 -0
  89. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/dispatcher.py +0 -0
  90. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/event.py +0 -0
  91. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/handler.py +0 -0
  92. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/contracts/listener.py +0 -0
  93. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/dispatcher.py +0 -0
  94. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/event.py +0 -0
  95. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/event_registry.py +0 -0
  96. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/listener.py +0 -0
  97. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/policy.py +0 -0
  98. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/provider.py +0 -0
  99. {python_neva-3.7.0 → python_neva-4.1.0}/neva/events/py.typed +0 -0
  100. {python_neva-3.7.0/neva/obs → python_neva-4.1.0/neva/guidelines}/py.typed +0 -0
  101. {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/instrumentation/__init__.py +0 -0
  102. {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/instrumentation/sqlalchemy.py +0 -0
  103. {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/logging/__init__.py +0 -0
  104. {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/logging/manager.py +0 -0
  105. {python_neva-3.7.0 → python_neva-4.1.0}/neva/obs/logging/provider.py +0 -0
  106. {python_neva-3.7.0/neva/polyfactory → python_neva-4.1.0/neva/obs}/py.typed +0 -0
  107. {python_neva-3.7.0 → python_neva-4.1.0}/neva/polyfactory/__init__.py +0 -0
  108. {python_neva-3.7.0 → python_neva-4.1.0}/neva/polyfactory/factories.py +0 -0
  109. {python_neva-3.7.0 → python_neva-4.1.0}/neva/polyfactory/persistence.py +0 -0
  110. {python_neva-3.7.0/neva/security → python_neva-4.1.0/neva/polyfactory}/py.typed +0 -0
  111. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/__init__.py +0 -0
  112. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/encryption/__init__.py +0 -0
  113. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/encryption/encrypter.py +0 -0
  114. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/encryption/protocol.py +0 -0
  115. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/__init__.py +0 -0
  116. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/config.py +0 -0
  117. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hash_manager.py +0 -0
  118. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hashers/__init__.py +0 -0
  119. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hashers/argon2.py +0 -0
  120. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  121. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/hashing/hashers/protocol.py +0 -0
  122. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/provider.py +0 -0
  123. {python_neva-3.7.0/neva/support → python_neva-4.1.0/neva/security}/py.typed +0 -0
  124. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/tokens/__init__.py +0 -0
  125. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/tokens/hash_token.py +0 -0
  126. {python_neva-3.7.0 → python_neva-4.1.0}/neva/security/tokens/verify_token.py +0 -0
  127. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/__init__.py +0 -0
  128. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/accessors.py +0 -0
  129. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/__init__.py +0 -0
  130. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/app.py +0 -0
  131. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/app.pyi +0 -0
  132. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/config.py +0 -0
  133. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/config.pyi +0 -0
  134. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/crypt.py +0 -0
  135. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/crypt.pyi +0 -0
  136. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/db.py +0 -0
  137. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/db.pyi +0 -0
  138. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/event.py +0 -0
  139. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/event.pyi +0 -0
  140. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/hash.py +0 -0
  141. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/hash.pyi +0 -0
  142. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/log.py +0 -0
  143. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/facade/log.pyi +0 -0
  144. {python_neva-3.7.0/neva/testing → python_neva-4.1.0/neva/support}/py.typed +0 -0
  145. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/results.py +0 -0
  146. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/strategy.py +0 -0
  147. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/strconv.py +0 -0
  148. {python_neva-3.7.0 → python_neva-4.1.0}/neva/support/time.py +0 -0
  149. {python_neva-3.7.0 → python_neva-4.1.0}/neva/testing/fakes.py +0 -0
  150. {python_neva-3.7.0 → python_neva-4.1.0}/neva/testing/fixtures.py +0 -0
  151. {python_neva-3.7.0 → python_neva-4.1.0}/scripts/retag-with-changelog.sh +0 -0
  152. {python_neva-3.7.0 → python_neva-4.1.0}/tests/__init__.py +0 -0
  153. {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/__init__.py +0 -0
  154. {python_neva-3.7.0 → python_neva-4.1.0}/tests/arch/test_registration.py +0 -0
  155. {python_neva-3.7.0 → python_neva-4.1.0}/tests/config/__init__.py +0 -0
  156. {python_neva-3.7.0 → python_neva-4.1.0}/tests/config/test_loader.py +0 -0
  157. {python_neva-3.7.0 → python_neva-4.1.0}/tests/config/test_repository.py +0 -0
  158. {python_neva-3.7.0 → python_neva-4.1.0}/tests/conftest.py +0 -0
  159. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/__init__.py +0 -0
  160. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_connection_manager.py +0 -0
  161. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_detached_lifespan.py +0 -0
  162. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_edge_cases.py +0 -0
  163. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_multi_connection.py +0 -0
  164. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  165. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_transaction.py +0 -0
  166. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_transaction_context.py +0 -0
  167. {python_neva-3.7.0 → python_neva-4.1.0}/tests/database/test_transaction_registry.py +0 -0
  168. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/__init__.py +0 -0
  169. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/conftest.py +0 -0
  170. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_before_dispatch.py +0 -0
  171. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_binding.py +0 -0
  172. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_deferred.py +0 -0
  173. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_dispatch.py +0 -0
  174. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_event.py +0 -0
  175. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_function_listener.py +0 -0
  176. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_immediate.py +0 -0
  177. {python_neva-3.7.0 → python_neva-4.1.0}/tests/events/test_listen_on_parent_class.py +0 -0
  178. {python_neva-3.7.0 → python_neva-4.1.0}/tests/security/__init__.py +0 -0
  179. {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/__init__.py +0 -0
  180. {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_event_fake.py +0 -0
  181. {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_facade_restore.py +0 -0
  182. {python_neva-3.7.0 → python_neva-4.1.0}/tests/testing/test_fixtures.py +0 -0
@@ -1,3 +1,45 @@
1
+ ## 4.1.0 (2026-08-31)
2
+
3
+ ### ✅🤡🧪 Tests
4
+
5
+ - **support**: add tests for results and options
6
+
7
+ ### 📝💡 Documentation
8
+
9
+ - **guidelines**: ship agent guidelines in the wheel
10
+
11
+ ## 4.0.0 (2026-08-28)
12
+
13
+ ### 💥 Boom
14
+
15
+ - **testing**: require create_config overrides to be classmethods
16
+ - **arch**: require an explicit lifetime, drop Application.bind
17
+
18
+ ### ✨ Features
19
+
20
+ - **deps**: offer the ASGI middleware through the asgi extra
21
+ - **config**: scope the core config shapes to what the core reads, export them
22
+
23
+ ### 🐛🚑️ Fixes
24
+
25
+ - **security**: name generate_token's argument nbytes, cover the token helpers
26
+ - **arch**: fail the boot when declared listeners cannot be wired
27
+ - **config**: default the config directory to ./config, not the project root
28
+ - **database**: accept sync transaction callbacks, never lose their failures
29
+
30
+ ### ♻️ Refactorings
31
+
32
+ - **obs**: move the ASGI middleware to neva-asgi
33
+ - **arch**: describe the FastStream lifespan without starlette
34
+
35
+ ### ⚡️ Performance
36
+
37
+ - **GED-16633**: build the TestCase application once per class
38
+
39
+ ### 📝💡 Documentation
40
+
41
+ - **database**: un-deprecate register_engine, say what it is for
42
+
1
43
  ## 3.7.0 (2026-08-28)
2
44
 
3
45
  ### ✨ Features
@@ -75,7 +75,7 @@ await Event.dispatch(UserCreated(event_id=1, user_id=42))
75
75
  **Service Providers** - Register services with lifecycle hooks. Bindings
76
76
  declare a lifetime by name — `singleton` (one per process), `scoped` (one per
77
77
  REQUEST scope), `transient` (one per resolution), `instance` (an object you
78
- already have). Bare `bind` without an explicit `scope`/`cache` is deprecated:
78
+ already have). Bare `bind` with neither `scope` nor `cache` raises `TypeError`:
79
79
  ```python
80
80
  class MyProvider(ServiceProvider):
81
81
  def register(self) -> Result[Self, str]:
@@ -138,4 +138,15 @@ rendered changelog. See `README.md` for the full flow and the plugin list.
138
138
 
139
139
  ## Documentation
140
140
 
141
- - Architecture guides: `wiki/architecture/`
141
+ Agent guidelines live in `neva/guidelines/fragments/` and ship inside the wheel, so a
142
+ consuming project gets the guidance for the version it actually installed. They are
143
+ discovered by `neva-boost` through the `neva.boost` entry point (`neva/guidelines/manifest.py`).
144
+
145
+ - One fragment per topic, with frontmatter: `id`, `title`, `requires` (a version
146
+ specifier), `triggers`, `priority`, `verified_by`.
147
+ - **`verified_by` lists the tests that prove the fragment's claims.** Every path must
148
+ resolve. When you change behaviour, update the fragment in the same commit as the code
149
+ and the test — that pairing is what keeps the guidance honest.
150
+ - Terse and rule-shaped, not narrative: this text is loaded into an agent's context.
151
+ - Nothing here may describe another package. `neva-fastapi` and friends ship their own
152
+ fragments; a cross-package claim made here cannot be verified or maintained.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-neva
3
- Version: 3.7.0
3
+ Version: 4.1.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -12,9 +12,10 @@ Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
12
12
  Requires-Dist: pydantic>=2.7
13
13
  Requires-Dist: pyinstrument>=5.1.1
14
14
  Requires-Dist: sqlalchemy[asyncio]>=2.0.0
15
- Requires-Dist: starlette>=0.41
16
15
  Requires-Dist: structlog>=25.5.0
17
16
  Requires-Dist: typing-extensions>=4.13
17
+ Provides-Extra: asgi
18
+ Requires-Dist: neva-asgi>=0.1.1; extra == 'asgi'
18
19
  Provides-Extra: fastapi
19
20
  Requires-Dist: neva-fastapi>=1.1.1; extra == 'fastapi'
20
21
  Provides-Extra: faststream
@@ -48,6 +49,7 @@ independent repo, independently versioned and published.
48
49
  | ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
49
50
  | `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
50
51
  | `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
52
+ | `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
51
53
  | `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
52
54
  | `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
53
55
  | `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
@@ -20,6 +20,7 @@ independent repo, independently versioned and published.
20
20
  | ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
21
21
  | `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
22
22
  | `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
23
+ | `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
23
24
  | `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
24
25
  | `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
25
26
  | `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
@@ -5,6 +5,7 @@ and facade implementations.
5
5
  """
6
6
 
7
7
  from neva.arch.application import Application
8
+ from neva.arch.config import AppConfig, ProviderConfig
8
9
  from neva.arch.facade import Facade
9
10
  from neva.arch.service_provider import (
10
11
  Bootable,
@@ -13,8 +14,10 @@ from neva.arch.service_provider import (
13
14
 
14
15
 
15
16
  __all__ = [
17
+ "AppConfig",
16
18
  "Application",
17
19
  "Bootable",
18
20
  "Facade",
21
+ "ProviderConfig",
19
22
  "ServiceProvider",
20
23
  ]
@@ -6,17 +6,16 @@ from collections.abc import AsyncGenerator, Sequence
6
6
  from contextlib import AsyncExitStack, asynccontextmanager
7
7
  from contextvars import ContextVar
8
8
  from pathlib import Path
9
- from typing import Any, Callable, Self
9
+ from typing import Any, Self
10
10
 
11
11
  import dishka
12
12
  from dishka.provider import BaseProvider
13
- from typing_extensions import deprecated
14
13
 
15
14
  from neva.arch.facade import Facade
16
15
  from neva.arch.scopes import BaseScope, Scope
17
16
  from neva.arch.service_provider import Bootable, ServiceProvider
18
17
  from neva.config.loader import ConfigLoader
19
- from neva.support import Err, Ok, Result
18
+ from neva.support import Err, Ok, Result, Some
20
19
 
21
20
 
22
21
  _current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
@@ -24,6 +23,26 @@ _current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
24
23
  )
25
24
 
26
25
 
26
+ def _resolve_config_path(config_path: str | Path | None) -> Path:
27
+ """Resolve the directory config files are loaded from.
28
+
29
+ The config directory is a dedicated folder, never the project root: every
30
+ ``.py`` in it is imported to read its ``config`` dict, so pointing this at
31
+ a directory holding ordinary modules would execute them.
32
+
33
+ Returns:
34
+ The explicit argument, else $NEVA_CONFIG_PATH, else ./config.
35
+ """
36
+ if config_path is not None:
37
+ return Path(config_path)
38
+
39
+ env_path = os.getenv("NEVA_CONFIG_PATH")
40
+ if env_path:
41
+ return Path(env_path)
42
+
43
+ return Path.cwd() / "config"
44
+
45
+
27
46
  class Application:
28
47
  """Base application for DI and facade injection."""
29
48
 
@@ -31,8 +50,9 @@ class Application:
31
50
  """Initialize the application and its bindings.
32
51
 
33
52
  Args:
34
- config_path: Path to the configuration directory. Defaults to "./config"
35
- relative to the current working directory.
53
+ config_path: Path to the configuration directory. Defaults to
54
+ ``$NEVA_CONFIG_PATH``, else ``./config`` relative to the
55
+ current working directory.
36
56
 
37
57
  Raises:
38
58
  RuntimeError: If the application fails to initialize.
@@ -47,11 +67,7 @@ class Application:
47
67
  self._container: dishka.AsyncContainer | None = None
48
68
  self._booted: bool = False
49
69
 
50
- configuration_path = (
51
- config_path
52
- if config_path is not None
53
- else os.getenv("NEVA_CONFIG_PATH", default=Path.cwd())
54
- )
70
+ configuration_path = _resolve_config_path(config_path)
55
71
  match ConfigLoader(configuration_path).load_all():
56
72
  case Err(e):
57
73
  raise RuntimeError(f"Failed to register config: {e}")
@@ -150,21 +166,6 @@ class Application:
150
166
  for provider in providers:
151
167
  _ = self.register(provider)
152
168
 
153
- @deprecated("Use the new bind() method directly with a Service Provider.")
154
- def bind(
155
- self,
156
- source: type | Callable[..., Any],
157
- *,
158
- interface: type | None = None,
159
- scope: BaseScope | None = None,
160
- ) -> None:
161
- """Binds a source to the container."""
162
- _ = self.root_provider.provide(
163
- source=source,
164
- scope=scope,
165
- provides=interface,
166
- )
167
-
168
169
  @property
169
170
  def current_container(self) -> dishka.AsyncContainer:
170
171
  """The container backing resolution in the current context.
@@ -226,6 +227,11 @@ class Application:
226
227
  @asynccontextmanager
227
228
  async def lifespan(self) -> AsyncGenerator[None]:
228
229
  """Wire the facades and providers."""
230
+ # The facade root is a single process-global slot, so a short-lived
231
+ # application nested inside a longer-lived one must hand it back rather
232
+ # than clear it -- otherwise it disarms every facade for whatever the
233
+ # outer application still has to do.
234
+ previous_facade_application = Facade.get_facade_application()
229
235
  Facade.set_facade_application(self)
230
236
  self._booted = True
231
237
 
@@ -243,27 +249,50 @@ class Application:
243
249
  if self._container is not None:
244
250
  await self._container.close()
245
251
  self._booted = False
246
- Facade.reset_facade_application()
252
+ match previous_facade_application:
253
+ case Some(application):
254
+ Facade.set_facade_application(application)
255
+ case _:
256
+ Facade.reset_facade_application()
247
257
 
248
258
  def _bind_event_listeners(self) -> None:
249
259
  """Bind all listener classes declared in provider ``listen`` dicts."""
250
260
  for provider in self.providers.values():
251
261
  for listeners in provider.listen.values():
252
262
  for listener_cls in listeners:
253
- # Straight onto the root provider: routing this through the
254
- # deprecated `bind` warned on every boot.
263
+ # Straight onto the root provider: these listeners are not
264
+ # a provider's own bindings, so they do not belong in one.
255
265
  _ = self.root_provider.provide(listener_cls, scope=Scope.APP)
256
266
 
257
267
  async def _wire_event_listeners(self) -> None:
258
- """Wire event-listener mappings from all providers onto the dispatcher."""
268
+ """Wire event-listener mappings from all providers onto the dispatcher.
269
+
270
+ Raises:
271
+ RuntimeError: If listeners are declared but the dispatcher cannot
272
+ be resolved. Booting on regardless would leave every one of
273
+ them unwired, so the events would simply never fire — with
274
+ nothing anywhere saying why.
275
+ """
259
276
  from neva.events.dispatcher import EventDispatcher
260
277
 
261
- result = await self.make_async(EventDispatcher)
262
- if result.is_err:
278
+ declared = [
279
+ (event_cls, listener_cls)
280
+ for provider in self.providers.values()
281
+ for event_cls, listeners in provider.listen.items()
282
+ for listener_cls in listeners
283
+ ]
284
+ if not declared:
263
285
  return
264
286
 
265
- dispatcher = result.unwrap()
266
- for provider in self.providers.values():
267
- for event_cls, listeners in provider.listen.items():
268
- for listener_cls in listeners:
287
+ match await self.make_async(EventDispatcher):
288
+ case Err(e):
289
+ msg = (
290
+ f"Cannot wire {len(declared)} declared event listener(s): "
291
+ f"the EventDispatcher could not be resolved ({e}). "
292
+ "Register EventServiceProvider, or remove the 'listen' "
293
+ "mappings that need it."
294
+ )
295
+ raise RuntimeError(msg)
296
+ case Ok(dispatcher):
297
+ for event_cls, listener_cls in declared:
269
298
  dispatcher.listen(event_cls, listener_cls)
@@ -0,0 +1,49 @@
1
+ """Config shapes for the namespaces the core itself reads.
2
+
3
+ Annotate a config file with one of these to have a type checker catch typos
4
+ and wrong value types before boot:
5
+
6
+ ```python
7
+ # src/config/app.py
8
+ from neva.arch import AppConfig
9
+
10
+ config: AppConfig = {"providers": [MyProvider]}
11
+ ```
12
+
13
+ Keys read by an integration package are declared by that package, not here —
14
+ `neva-fastapi` owns the OpenAPI and title keys of the `app` namespace, for
15
+ instance. The core only describes what the core consumes.
16
+ """
17
+
18
+ from typing import NotRequired, TypedDict
19
+
20
+ from neva.arch.service_provider import ServiceProvider
21
+
22
+
23
+ class AppConfig(TypedDict):
24
+ """The `app` namespace keys the core reads.
25
+
26
+ Attributes:
27
+ key: Base64-encoded 32-byte key for `Crypt`. Generate one with
28
+ `AesEncrypter.generate_key()`.
29
+ previous_keys: Superseded keys, still accepted when decrypting so a
30
+ key rotation does not orphan existing ciphertext.
31
+ providers: Service providers to register, appended after those from
32
+ the `providers` namespace.
33
+ """
34
+
35
+ key: NotRequired[str]
36
+ previous_keys: NotRequired[list[str]]
37
+ providers: NotRequired[list[type[ServiceProvider]]]
38
+
39
+
40
+ class ProviderConfig(TypedDict):
41
+ """The `providers` namespace.
42
+
43
+ A dedicated namespace for the provider list, read before `app.providers`.
44
+
45
+ Attributes:
46
+ providers: Service providers to register.
47
+ """
48
+
49
+ providers: NotRequired[list[type[ServiceProvider]]]
@@ -1,18 +1,23 @@
1
1
  """FastStream wrapper for Neva."""
2
2
 
3
- from collections.abc import AsyncGenerator, AsyncIterator
4
- from contextlib import asynccontextmanager
3
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable
4
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
5
5
  from typing import Any
6
6
 
7
7
  import faststream
8
8
  from faststream._internal.broker import BrokerUsecase
9
9
  from faststream._internal.configs import BrokerConfig
10
- from starlette.types import StatelessLifespan
11
10
 
12
11
  from neva.arch import Application, ServiceProvider
13
12
  from neva.support import Result
14
13
 
15
14
 
15
+ # Spelled out locally rather than borrowed from starlette.types: a messaging
16
+ # app's lifespan is not a web concern, and the core must not import an ASGI
17
+ # framework to describe one.
18
+ type FastStreamLifespan = Callable[["FastStream"], AbstractAsyncContextManager[None]]
19
+
20
+
16
21
  class FastStream(faststream.FastStream):
17
22
  """FastStream wrapper for Neva."""
18
23
 
@@ -42,9 +47,7 @@ class FastStream(faststream.FastStream):
42
47
  async with self.application.lifespan():
43
48
  yield
44
49
 
45
- def _create_lifespan(
46
- self,
47
- ) -> StatelessLifespan["FastStream"]:
50
+ def _create_lifespan(self) -> FastStreamLifespan:
48
51
  @asynccontextmanager
49
52
  async def composed_lifespan(_: faststream.FastStream) -> AsyncIterator[None]:
50
53
  async with self.lifespan():
@@ -8,7 +8,6 @@ container and optionally managing their lifecycle.
8
8
  from __future__ import annotations
9
9
 
10
10
  import abc
11
- import warnings
12
11
  from contextlib import AbstractAsyncContextManager
13
12
  from typing import (
14
13
  TYPE_CHECKING,
@@ -115,9 +114,9 @@ class ServiceProvider(abc.ABC):
115
114
  `instance` — which say what they mean. Use `bind` directly only to
116
115
  reach a scope those do not cover, and then pass `scope` explicitly.
117
116
 
118
- Calling this with neither `scope` nor `cache` is deprecated: the
119
- resulting binding is a process-wide singleton, which is the opposite of
120
- Laravel's transient `bind`, and nothing at the call site says so.
117
+ A lifetime is required. Without one the binding would be a
118
+ process-wide singleton the opposite of Laravel's transient `bind` —
119
+ with nothing at the call site saying so.
121
120
 
122
121
  Args:
123
122
  source: The class or factory to bind.
@@ -125,16 +124,17 @@ class ServiceProvider(abc.ABC):
125
124
  scope: The dishka scope. Defaults to the provider's (APP).
126
125
  when: Marker gating this binding.
127
126
  cache: Whether to reuse one instance per scope. Defaults to True.
127
+
128
+ Raises:
129
+ TypeError: If neither `scope` nor `cache` is given.
128
130
  """
129
131
  if scope is None and cache is None:
130
- warnings.warn(
131
- "bind() without an explicit lifetime is deprecated and will "
132
- "require one in 4.0. It currently creates a process-wide "
133
- "singleton: use singleton() for that, or scoped() / "
134
- "transient() / instance(), or pass scope= or cache= yourself.",
135
- DeprecationWarning,
136
- stacklevel=2,
132
+ msg = (
133
+ "bind() requires an explicit lifetime: use singleton() for a "
134
+ "process-wide instance, or scoped() / transient() / "
135
+ "instance(), or pass scope= or cache= yourself."
137
136
  )
137
+ raise TypeError(msg)
138
138
 
139
139
  _ = self.provider.provide(
140
140
  source=source,
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
7
7
  from typing import final
8
8
 
9
9
  from sqlalchemy.ext import asyncio
10
+ from structlog.stdlib import get_logger
10
11
 
11
12
  from neva.database.transaction import BoundTransaction, TransactionState
12
13
  from neva.obs import LogManager
@@ -147,9 +148,13 @@ class ConnectionManager:
147
148
  else await tx.execute_on_rollback_callbacks()
148
149
  )
149
150
  label = "commit" if committed else "rollback"
151
+ # A post-commit callback cannot un-commit the transaction, so its
152
+ # failure is reported rather than raised. It must never be lost: fall
153
+ # back to a module logger when the manager has none.
154
+ logger = self.logger if self.logger is not None else get_logger(__name__)
150
155
  for result in results:
151
- if result.is_err and self.logger is not None:
152
- self.logger.error(
156
+ if result.is_err:
157
+ logger.error(
153
158
  f"{label} callback failed",
154
159
  error=result.err().unwrap(),
155
160
  connection=self.name,
@@ -5,7 +5,6 @@ from contextlib import asynccontextmanager
5
5
  from typing import final
6
6
 
7
7
  from sqlalchemy.ext import asyncio
8
- from typing_extensions import deprecated
9
8
 
10
9
  from neva.database.config import ConnectionConfig
11
10
  from neva.database.connection import ConnectionManager, TransactionContext
@@ -24,9 +23,14 @@ class DatabaseManager:
24
23
  self._logger = logger
25
24
  self._connections: dict[str, ConnectionManager] = {}
26
25
 
27
- @deprecated("Please use DatabaseManager.register_connection instead.")
28
26
  def register_engine(self, name: str, engine: asyncio.AsyncEngine) -> None:
29
- """Register an engine and create a session factory for a connection.
27
+ """Register a connection around an engine you already built.
28
+
29
+ Prefer `register_connection`, which builds the engine from config. Use
30
+ this when the caller must own the engine — a test that creates the
31
+ schema on an in-memory database has to hand over that exact engine,
32
+ since a second engine to `sqlite:///:memory:` is a second, empty
33
+ database.
30
34
 
31
35
  Args:
32
36
  name: The connection name.
@@ -1,17 +1,41 @@
1
1
  """Transaction management systems."""
2
2
 
3
3
  import asyncio
4
- from collections.abc import Awaitable
4
+ import inspect
5
+ from collections.abc import Awaitable, Callable
5
6
  from dataclasses import dataclass, field
6
7
  from enum import Enum, auto
7
- from typing import Callable, Self
8
+ from typing import Self
8
9
 
9
10
  from sqlalchemy.ext.asyncio import AsyncSession
10
11
 
11
- from neva.support import Err, Nothing, Option, Result, Some
12
+ from neva.support import Err, Nothing, Ok, Option, Result, Some
12
13
 
13
14
 
14
- type TransactionCallback = Callable[[], Awaitable[Result[None, str]]]
15
+ type TransactionOutcome = Result[None, str] | None
16
+ type TransactionCallback = Callable[
17
+ [], TransactionOutcome | Awaitable[TransactionOutcome]
18
+ ]
19
+
20
+
21
+ async def _run_callback(callback: TransactionCallback) -> Result[None, str]:
22
+ """Invoke a transaction callback, whether it is sync or async.
23
+
24
+ A callback may return a ``Result`` to report its own failure, or ``None``
25
+ to say "done, nothing to report" — which is what a bare side-effecting
26
+ lambda does.
27
+
28
+ Returns:
29
+ The callback's own Result, Ok(None) if it returned None, or Err if it
30
+ raised.
31
+ """
32
+ try:
33
+ outcome = callback()
34
+ if inspect.isawaitable(outcome):
35
+ outcome = await outcome
36
+ except Exception as e:
37
+ return Err(f"Callback raised: {e}")
38
+ return Ok(None) if outcome is None else outcome
15
39
 
16
40
 
17
41
  class TransactionState(Enum):
@@ -77,10 +101,7 @@ class Transaction:
77
101
  """
78
102
  results: list[Result[None, str]] = []
79
103
  for callback in self._on_commit:
80
- try:
81
- results.append(await callback())
82
- except Exception as e:
83
- results.append(Err(f"Callback raised: {e}"))
104
+ results.append(await _run_callback(callback))
84
105
  self._on_commit.clear()
85
106
  return results
86
107
 
@@ -92,10 +113,7 @@ class Transaction:
92
113
  """
93
114
  results: list[Result[None, str]] = []
94
115
  for callback in self._on_rollback:
95
- try:
96
- results.append(await callback())
97
- except Exception as e:
98
- results.append(Err(f"Callback raised: {e}"))
116
+ results.append(await _run_callback(callback))
99
117
  self._on_rollback.clear()
100
118
  return results
101
119
 
@@ -0,0 +1,6 @@
1
+ """Guideline fragments and the manifest `neva-boost` reads to find them."""
2
+
3
+ from neva.guidelines.manifest import MANIFEST
4
+
5
+
6
+ __all__ = ["MANIFEST"]
@@ -0,0 +1,108 @@
1
+ ---
2
+ id: configuration
3
+ title: Configuration
4
+ requires: python-neva>=4.0
5
+ triggers: [reading a config value, adding a config file, Config.get, config namespace, environment settings, providers list]
6
+ priority: 50
7
+ verified_by: [tests/config/test_repository.py, tests/config/test_loader.py, tests/config/test_config_path_resolution.py, tests/arch/test_config_shapes.py]
8
+ ---
9
+
10
+ # Configuration
11
+
12
+ A flat directory of `.py` files, each exporting a module-level `config = {...}` dict. **The
13
+ filename is the namespace.** Values are read by dot-notation and come back as `Result`.
14
+
15
+ ```
16
+ src/config/app.py → "app"
17
+ src/config/database.py → "database"
18
+ src/config/providers.py → "providers"
19
+ ```
20
+
21
+ Config files are real modules executed at boot, so they may compute values and import helpers.
22
+ Keep them pure — no I/O, no connections.
23
+
24
+ ## Where the directory comes from
25
+
26
+ `config_path` argument, else `$NEVA_CONFIG_PATH`, else `./config` relative to the working
27
+ directory. Deliberately a dedicated subdirectory, never the project root: every `.py` in it is
28
+ imported to read its `config` dict, so a folder of ordinary modules would be executed.
29
+
30
+ The loader globs `*.py` except `__init__.py`, imports each file, and takes its `config`
31
+ attribute **only if it is a dict**. A file with no `config`, or a non-dict `config`, is skipped
32
+ silently — check that first when a namespace seems missing. A missing directory, a
33
+ `SyntaxError`, or any import-time exception aborts boot with `RuntimeError`.
34
+
35
+ There is no CLI, no generator, and no `ConfigServiceProvider`: loading is wired directly into
36
+ `Application.__init__`.
37
+
38
+ ## Namespaces that drive boot
39
+
40
+ | Key | Meaning |
41
+ | --- | ------- |
42
+ | `providers.providers` | Providers to register. |
43
+ | `app.providers` | Further providers, appended after those. |
44
+
45
+ ## Typed shapes
46
+
47
+ Each package declares a `TypedDict` for the keys **it** reads, so a shape never advertises keys
48
+ its own package ignores. Annotate your file to have a type checker catch typos before boot.
49
+
50
+ ```python
51
+ from neva.arch import AppConfig
52
+ config: AppConfig = {"key": "...", "providers": [ActorServiceProvider]}
53
+ ```
54
+
55
+ `AppConfig` (`neva.arch`) covers `app.key`, `app.previous_keys`, `app.providers`, all optional.
56
+ `ProviderConfig` (`neva.arch`) covers `providers.providers`. `DatabaseConfig`
57
+ (`neva.database`) covers the `database` namespace, and its `connections` key is **required**.
58
+ The `app` namespace is shared — an integration package declares the keys it adds.
59
+
60
+ ## Reading
61
+
62
+ Inject `ConfigRepository` inside services you unit-test; use the `Config` facade for
63
+ cross-cutting reads. Both resolve the same instance.
64
+
65
+ ```python
66
+ Config.get("app.title").unwrap_or("Untitled")
67
+ Config.get("app.debug", type_=bool).unwrap_or(False)
68
+ ```
69
+
70
+ `get(key, type_=None) -> Result[T, str]`. It never raises and has **no default parameter** — the
71
+ `Result` is the default mechanism.
72
+
73
+ **`type_` is a cast, not validation.** Nothing is coerced or checked:
74
+ `Config.get("app.debug", type_=bool)` over `debug = 0` returns the integer `0`, merely *typed*
75
+ as `bool`. Parse or validate separately, and wrap consumed config in a typed object.
76
+
77
+ `has(key) -> bool` is `get(key).is_ok`. `all() -> dict` returns a deep copy; mutating it never
78
+ reaches the repository, frozen or not.
79
+
80
+ ## Writing
81
+
82
+ Mutable until frozen. This is the extension seam — a package registers defaults, an application
83
+ overrides leaves — and the seam tests use.
84
+
85
+ `set(key, value)` sets one dot-notation path, creating intermediate dicts, and errors if an
86
+ intermediate segment holds a non-dict.
87
+
88
+ **`merge(key, items)` is a recursive deep merge.** Nested dictionaries are combined rather than
89
+ replaced, so a leaf can be overridden without restating its siblings; only non-dict values are
90
+ replaced outright. Overriding one nested key needs nothing special:
91
+
92
+ ```python
93
+ Config.merge("hashing", {"argon": {"time_cost": 2, "memory_cost": 1024}})
94
+ Config.merge("hashing", {"argon": {"time_cost": 4}})
95
+ # → argon == {"time_cost": 4, "memory_cost": 1024}
96
+ ```
97
+
98
+ Boot uses exactly this: each loaded `{namespace: dict}` is merged in.
99
+
100
+ `freeze()` makes every later `set`/`merge` return `Err`. **The framework never calls it.** Config
101
+ stays writable at runtime, which is what keeps the extension seam and test overrides working.
102
+ Call it yourself if you want hard immutability after startup.
103
+
104
+ ## In tests
105
+
106
+ `TestCase.create_config(tmp_path)` returns the directory the application is built from. Override
107
+ it as a **classmethod** — see the testing fragment. Because the repository is unfrozen you can
108
+ also adjust values mid-test with `Config.set` / `Config.merge`, or fake the facade.