python-neva 4.0.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 (171) hide show
  1. {python_neva-4.0.0 → python_neva-4.1.0}/CHANGELOG.md +10 -0
  2. {python_neva-4.0.0 → python_neva-4.1.0}/CLAUDE.md +12 -1
  3. {python_neva-4.0.0 → python_neva-4.1.0}/PKG-INFO +1 -1
  4. python_neva-4.1.0/neva/guidelines/__init__.py +6 -0
  5. python_neva-4.1.0/neva/guidelines/fragments/configuration.md +108 -0
  6. python_neva-4.1.0/neva/guidelines/fragments/database-transactions.md +125 -0
  7. python_neva-4.1.0/neva/guidelines/fragments/events.md +150 -0
  8. python_neva-4.1.0/neva/guidelines/fragments/result-option.md +74 -0
  9. python_neva-4.1.0/neva/guidelines/fragments/service-providers.md +114 -0
  10. python_neva-4.1.0/neva/guidelines/fragments/testing.md +133 -0
  11. python_neva-4.1.0/neva/guidelines/manifest.py +26 -0
  12. python_neva-4.1.0/neva/testing/py.typed +0 -0
  13. {python_neva-4.0.0 → python_neva-4.1.0}/pyproject.toml +4 -1
  14. {python_neva-4.0.0 → python_neva-4.1.0}/ruff.toml +2 -2
  15. python_neva-4.1.0/tests/support/__init__.py +0 -0
  16. python_neva-4.1.0/tests/support/test_results.py +310 -0
  17. {python_neva-4.0.0 → python_neva-4.1.0}/uv.lock +1 -1
  18. python_neva-4.0.0/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -41
  19. python_neva-4.0.0/wiki/architecture/configuration.md +0 -340
  20. python_neva-4.0.0/wiki/architecture/database-and-transactions.md +0 -407
  21. python_neva-4.0.0/wiki/architecture/dependency-injection.md +0 -584
  22. python_neva-4.0.0/wiki/architecture/events.md +0 -424
  23. python_neva-4.0.0/wiki/architecture/result-option.md +0 -296
  24. python_neva-4.0.0/wiki/architecture/testing.md +0 -425
  25. {python_neva-4.0.0 → python_neva-4.1.0}/.claude/settings.local.json +0 -0
  26. {python_neva-4.0.0 → python_neva-4.1.0}/.envrc +0 -0
  27. {python_neva-4.0.0 → python_neva-4.1.0}/.gitignore +0 -0
  28. {python_neva-4.0.0 → python_neva-4.1.0}/.gitlab-ci.yml +0 -0
  29. {python_neva-4.0.0 → python_neva-4.1.0}/.pre-commit-config.yaml +0 -0
  30. {python_neva-4.0.0 → python_neva-4.1.0}/.python-version +0 -0
  31. {python_neva-4.0.0 → python_neva-4.1.0}/README.md +0 -0
  32. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/__init__.py +0 -0
  33. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/application.py +0 -0
  34. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/config.py +0 -0
  35. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/facade.py +0 -0
  36. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/integrations/__init__.py +0 -0
  37. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/integrations/faststream.py +0 -0
  38. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/markers.py +0 -0
  39. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/py.typed +0 -0
  40. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/scopes.py +0 -0
  41. {python_neva-4.0.0 → python_neva-4.1.0}/neva/arch/service_provider.py +0 -0
  42. {python_neva-4.0.0 → python_neva-4.1.0}/neva/config/__init__.py +0 -0
  43. {python_neva-4.0.0 → python_neva-4.1.0}/neva/config/base_providers.py +0 -0
  44. {python_neva-4.0.0 → python_neva-4.1.0}/neva/config/loader.py +0 -0
  45. {python_neva-4.0.0 → python_neva-4.1.0}/neva/config/py.typed +0 -0
  46. {python_neva-4.0.0 → python_neva-4.1.0}/neva/config/repository.py +0 -0
  47. {python_neva-4.0.0 → python_neva-4.1.0}/neva/database/__init__.py +0 -0
  48. {python_neva-4.0.0 → python_neva-4.1.0}/neva/database/config.py +0 -0
  49. {python_neva-4.0.0 → python_neva-4.1.0}/neva/database/connection.py +0 -0
  50. {python_neva-4.0.0 → python_neva-4.1.0}/neva/database/manager.py +0 -0
  51. {python_neva-4.0.0 → python_neva-4.1.0}/neva/database/provider.py +0 -0
  52. {python_neva-4.0.0 → python_neva-4.1.0}/neva/database/py.typed +0 -0
  53. {python_neva-4.0.0 → python_neva-4.1.0}/neva/database/transaction.py +0 -0
  54. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/__init__.py +0 -0
  55. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/contracts/__init__.py +0 -0
  56. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/contracts/dispatcher.py +0 -0
  57. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/contracts/event.py +0 -0
  58. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/contracts/handler.py +0 -0
  59. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/contracts/listener.py +0 -0
  60. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/dispatcher.py +0 -0
  61. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/event.py +0 -0
  62. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/event_registry.py +0 -0
  63. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/listener.py +0 -0
  64. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/policy.py +0 -0
  65. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/provider.py +0 -0
  66. {python_neva-4.0.0 → python_neva-4.1.0}/neva/events/py.typed +0 -0
  67. {python_neva-4.0.0/neva/obs → python_neva-4.1.0/neva/guidelines}/py.typed +0 -0
  68. {python_neva-4.0.0 → python_neva-4.1.0}/neva/obs/__init__.py +0 -0
  69. {python_neva-4.0.0 → python_neva-4.1.0}/neva/obs/instrumentation/__init__.py +0 -0
  70. {python_neva-4.0.0 → python_neva-4.1.0}/neva/obs/instrumentation/sqlalchemy.py +0 -0
  71. {python_neva-4.0.0 → python_neva-4.1.0}/neva/obs/logging/__init__.py +0 -0
  72. {python_neva-4.0.0 → python_neva-4.1.0}/neva/obs/logging/manager.py +0 -0
  73. {python_neva-4.0.0 → python_neva-4.1.0}/neva/obs/logging/provider.py +0 -0
  74. {python_neva-4.0.0/neva/polyfactory → python_neva-4.1.0/neva/obs}/py.typed +0 -0
  75. {python_neva-4.0.0 → python_neva-4.1.0}/neva/polyfactory/__init__.py +0 -0
  76. {python_neva-4.0.0 → python_neva-4.1.0}/neva/polyfactory/factories.py +0 -0
  77. {python_neva-4.0.0 → python_neva-4.1.0}/neva/polyfactory/persistence.py +0 -0
  78. {python_neva-4.0.0/neva/security → python_neva-4.1.0/neva/polyfactory}/py.typed +0 -0
  79. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/__init__.py +0 -0
  80. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/encryption/__init__.py +0 -0
  81. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/encryption/encrypter.py +0 -0
  82. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/encryption/protocol.py +0 -0
  83. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/hashing/__init__.py +0 -0
  84. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/hashing/config.py +0 -0
  85. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/hashing/hash_manager.py +0 -0
  86. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/hashing/hashers/__init__.py +0 -0
  87. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/hashing/hashers/argon2.py +0 -0
  88. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  89. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/hashing/hashers/protocol.py +0 -0
  90. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/provider.py +0 -0
  91. {python_neva-4.0.0/neva/support → python_neva-4.1.0/neva/security}/py.typed +0 -0
  92. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/tokens/__init__.py +0 -0
  93. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/tokens/generate_token.py +0 -0
  94. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/tokens/hash_token.py +0 -0
  95. {python_neva-4.0.0 → python_neva-4.1.0}/neva/security/tokens/verify_token.py +0 -0
  96. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/__init__.py +0 -0
  97. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/accessors.py +0 -0
  98. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/__init__.py +0 -0
  99. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/app.py +0 -0
  100. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/app.pyi +0 -0
  101. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/config.py +0 -0
  102. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/config.pyi +0 -0
  103. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/crypt.py +0 -0
  104. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/crypt.pyi +0 -0
  105. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/db.py +0 -0
  106. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/db.pyi +0 -0
  107. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/event.py +0 -0
  108. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/event.pyi +0 -0
  109. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/hash.py +0 -0
  110. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/hash.pyi +0 -0
  111. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/log.py +0 -0
  112. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/facade/log.pyi +0 -0
  113. {python_neva-4.0.0/neva/testing → python_neva-4.1.0/neva/support}/py.typed +0 -0
  114. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/results.py +0 -0
  115. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/strategy.py +0 -0
  116. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/strconv.py +0 -0
  117. {python_neva-4.0.0 → python_neva-4.1.0}/neva/support/time.py +0 -0
  118. {python_neva-4.0.0 → python_neva-4.1.0}/neva/testing/__init__.py +0 -0
  119. {python_neva-4.0.0 → python_neva-4.1.0}/neva/testing/fakes.py +0 -0
  120. {python_neva-4.0.0 → python_neva-4.1.0}/neva/testing/fixtures.py +0 -0
  121. {python_neva-4.0.0 → python_neva-4.1.0}/neva/testing/test_case.py +0 -0
  122. {python_neva-4.0.0 → python_neva-4.1.0}/scripts/retag-with-changelog.sh +0 -0
  123. {python_neva-4.0.0 → python_neva-4.1.0}/tests/__init__.py +0 -0
  124. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/__init__.py +0 -0
  125. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/test_cache.py +0 -0
  126. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/test_config_shapes.py +0 -0
  127. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/test_context.py +0 -0
  128. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/test_extends.py +0 -0
  129. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/test_facade_root_nesting.py +0 -0
  130. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/test_lifetimes.py +0 -0
  131. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/test_registration.py +0 -0
  132. {python_neva-4.0.0 → python_neva-4.1.0}/tests/arch/test_scope.py +0 -0
  133. {python_neva-4.0.0 → python_neva-4.1.0}/tests/config/__init__.py +0 -0
  134. {python_neva-4.0.0 → python_neva-4.1.0}/tests/config/test_config_path_resolution.py +0 -0
  135. {python_neva-4.0.0 → python_neva-4.1.0}/tests/config/test_loader.py +0 -0
  136. {python_neva-4.0.0 → python_neva-4.1.0}/tests/config/test_repository.py +0 -0
  137. {python_neva-4.0.0 → python_neva-4.1.0}/tests/conftest.py +0 -0
  138. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/__init__.py +0 -0
  139. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_connection_manager.py +0 -0
  140. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_database_manager.py +0 -0
  141. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_detached_lifespan.py +0 -0
  142. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_edge_cases.py +0 -0
  143. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_multi_connection.py +0 -0
  144. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  145. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_transaction.py +0 -0
  146. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_transaction_callbacks.py +0 -0
  147. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_transaction_context.py +0 -0
  148. {python_neva-4.0.0 → python_neva-4.1.0}/tests/database/test_transaction_registry.py +0 -0
  149. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/__init__.py +0 -0
  150. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/conftest.py +0 -0
  151. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_before_dispatch.py +0 -0
  152. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_binding.py +0 -0
  153. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_deferred.py +0 -0
  154. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_dispatch.py +0 -0
  155. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_event.py +0 -0
  156. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_function_listener.py +0 -0
  157. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_immediate.py +0 -0
  158. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_listen_on_parent_class.py +0 -0
  159. {python_neva-4.0.0 → python_neva-4.1.0}/tests/events/test_listener_wiring.py +0 -0
  160. {python_neva-4.0.0 → python_neva-4.1.0}/tests/security/__init__.py +0 -0
  161. {python_neva-4.0.0 → python_neva-4.1.0}/tests/security/test_encrypter.py +0 -0
  162. {python_neva-4.0.0 → python_neva-4.1.0}/tests/security/test_hash_manager.py +0 -0
  163. {python_neva-4.0.0 → python_neva-4.1.0}/tests/security/test_tokens.py +0 -0
  164. {python_neva-4.0.0 → python_neva-4.1.0}/tests/testing/__init__.py +0 -0
  165. {python_neva-4.0.0 → python_neva-4.1.0}/tests/testing/test_application_reuse.py +0 -0
  166. {python_neva-4.0.0 → python_neva-4.1.0}/tests/testing/test_create_config_migration.py +0 -0
  167. {python_neva-4.0.0 → python_neva-4.1.0}/tests/testing/test_event_fake.py +0 -0
  168. {python_neva-4.0.0 → python_neva-4.1.0}/tests/testing/test_facade_restore.py +0 -0
  169. {python_neva-4.0.0 → python_neva-4.1.0}/tests/testing/test_fixtures.py +0 -0
  170. {python_neva-4.0.0 → python_neva-4.1.0}/tests/testing/test_refresh_database.py +0 -0
  171. {python_neva-4.0.0 → python_neva-4.1.0}/tests/testing/test_test_case.py +0 -0
@@ -1,3 +1,13 @@
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
+
1
11
  ## 4.0.0 (2026-08-28)
2
12
 
3
13
  ### 💥 Boom
@@ -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: 4.0.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
@@ -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.
@@ -0,0 +1,125 @@
1
+ ---
2
+ id: database-transactions
3
+ title: Database and transactions
4
+ requires: python-neva>=4.0
5
+ triggers: [database query, opening a transaction, DB.begin, session, savepoint, on_commit, rollback, SQLAlchemy]
6
+ priority: 40
7
+ verified_by: [tests/database/test_transaction.py, tests/database/test_transaction_callbacks.py, tests/database/test_transaction_context.py, tests/database/test_transaction_registry.py, tests/database/test_connection_manager.py, tests/database/test_database_manager.py, tests/database/test_multi_connection.py, tests/database/test_sqlalchemy_integration.py]
8
+ ---
9
+
10
+ # Database and transactions
11
+
12
+ A transaction-first wrapper over SQLAlchemy async. Never build an engine or session, never call
13
+ `session.commit()`. Open a unit of work, work through `tx.session`, and the block commits on
14
+ clean exit or rolls back on error.
15
+
16
+ ```python
17
+ async with DB.begin() as tx:
18
+ tx.session.add(actor)
19
+ await tx.session.flush()
20
+ ```
21
+
22
+ ## Configuring
23
+
24
+ One `database` namespace, mapping connection name to `ConnectionConfig`. `url` is required and
25
+ must use an **async** driver; `pool_size`, `max_overflow`, `pool_recycle`, `pool_pre_ping` and
26
+ `echo` are optional and pass straight to `create_async_engine`.
27
+
28
+ ```python
29
+ config: DatabaseConfig = {
30
+ "connections": {"default": {"url": "postgresql+asyncpg://…", "echo": True}}
31
+ }
32
+ ```
33
+
34
+ `DatabaseServiceProvider` binds `TransactionContext` and `DatabaseManager`, then registers each
35
+ configured connection during `lifespan()` and disposes the engines on shutdown. Session
36
+ factories use `expire_on_commit=False`, so ORM objects stay readable after commit.
37
+
38
+ **A missing or malformed `database` namespace does not fail boot.** The provider logs the error
39
+ and continues with zero connections; the failure then surfaces much later as
40
+ `RuntimeError: No connection registered with name default` from the first `DB.begin()`. If you
41
+ see that error, check the config namespace first.
42
+
43
+ ## DatabaseManager
44
+
45
+ | Call | Returns | Purpose |
46
+ | ---- | ------- | ------- |
47
+ | `begin(name="default")` | ctx mgr of `BoundTransaction` | Open a unit of work. `RuntimeError` if the name is unregistered. |
48
+ | `session(connection=None)` | `Option[AsyncSession]` | Current session, only if this task may use it. |
49
+ | `current(connection=None)` | `Option[BoundTransaction]` | Innermost transaction (top of stack), or the one for a named connection. |
50
+ | `outermost()` | `Option[BoundTransaction]` | The transaction that commits **last**. What deferred events attach to. |
51
+ | `connection(name)` | `Option[ConnectionManager]` | The connection manager. |
52
+ | `register_connection(name, cfg)` | `None` | Build an engine from config. The normal path. |
53
+ | `register_engine(name, engine)` | `None` | Adopt an engine the caller built. For a schema on an in-memory database, where a second engine would be a second, empty database. |
54
+ | `close()` | `None` | Dispose every engine. |
55
+
56
+ Inject `DatabaseManager` in services you unit-test; use the `DB` facade for ambient access.
57
+
58
+ ## The caller owns the transaction
59
+
60
+ An inner service reads the current session rather than opening its own:
61
+
62
+ ```python
63
+ class CreateActor:
64
+ def __init__(self, db: DatabaseManager) -> None:
65
+ self._db = db
66
+
67
+ async def __call__(self, cmd: CreateActorCommand) -> Actor:
68
+ session = self._db.session().expect("CreateActor must run inside DB.begin()")
69
+ ...
70
+ ```
71
+
72
+ `session()` returns `Nothing` when no transaction is open **or** when the open one belongs to a
73
+ different asyncio task. Handle the `Option`; it is usually a wiring mistake.
74
+
75
+ ## BoundTransaction
76
+
77
+ `session` (non-null inside the block), `on_commit(cb)`, `on_rollback(cb)`, `rollback()`,
78
+ `share()`, `is_root`, `is_active`, `parent`.
79
+
80
+ `await tx.rollback()` **marks** the transaction; the block then exits normally and nothing is
81
+ raised. Only an exception propagating out of the block rolls back *and* re-raises.
82
+
83
+ ## Nesting is a savepoint
84
+
85
+ A second `begin()` on the same connection, from a task that can reach the parent, reuses the
86
+ parent session and opens a `SAVEPOINT`. An inner rollback undoes only the savepoint; the outer
87
+ transaction continues. Independent code paths can each wrap their work without knowing whether
88
+ they are outermost.
89
+
90
+ ## Commit and rollback callbacks
91
+
92
+ ```python
93
+ tx.on_commit(lambda: send_confirmation_email(order.id))
94
+ tx.on_rollback(release_reserved_stock)
95
+ ```
96
+
97
+ - **Sync or async, and `None` is a valid return.** The contract is
98
+ `Callable[[], Result[None, str] | None | Awaitable[...]]`. A bare side-effecting lambda is
99
+ fine; returning `None` is read as `Ok(None)`.
100
+ - **They bubble to the root** and fire only when the root settles — commit callbacks on commit,
101
+ rollback callbacks on rollback.
102
+ - **A raising callback is captured as `Err` and logged.** It never breaks the settle path or the
103
+ other callbacks, and it cannot abort a commit that already happened.
104
+ - **They run with no ambient transaction.** By the time one runs, the session is committed and
105
+ the transaction is off the registry, so `DB.current()` and `DB.session()` return `Nothing`.
106
+ This is what makes a callback the right place for an SMTP send or an HTTP call — none of it
107
+ pins the pooled connection or holds a lock. A callback needing the database opens its own
108
+ `DB.begin()`.
109
+
110
+ This is the machinery behind `DEFERRED` events.
111
+
112
+ ## Task-bound sessions
113
+
114
+ A `BoundTransaction` records the asyncio task that opened it, and `AsyncSession` is not
115
+ concurrent-safe, so another task gets `Nothing` from `session()` and opens a fresh transaction
116
+ instead of reusing it. `tx.share()` lifts that for controlled **sequential** access only — its
117
+ canonical use is `RefreshDatabase`.
118
+
119
+ ## Pitfalls
120
+
121
+ - Never call `session.commit()` / `session.rollback()`. The block owns settling.
122
+ - Never construct `AsyncEngine` / `AsyncSession` by hand.
123
+ - A nested rollback undoes the savepoint, not the transaction.
124
+ - `session()` and `current()` return `Option`. Handle `Nothing`.
125
+ - Use an async driver end to end.
@@ -0,0 +1,150 @@
1
+ ---
2
+ id: events
3
+ title: Events and handling policies
4
+ requires: python-neva>=4.0
5
+ triggers: [dispatching an event, writing a listener, DEFERRED vs IMMEDIATE, reacting to something that happened, side effect after commit]
6
+ priority: 30
7
+ verified_by: [tests/events/test_deferred.py, tests/events/test_immediate.py, tests/events/test_dispatch.py, tests/events/test_listen_on_parent_class.py, tests/events/test_function_listener.py, tests/events/test_before_dispatch.py, tests/events/test_listener_wiring.py]
8
+ ---
9
+
10
+ # Events and handling policies
11
+
12
+ A producer announces what happened; unrelated listeners react. The defining feature is
13
+ transaction awareness: a `DEFERRED` listener runs only after the surrounding database
14
+ transaction commits.
15
+
16
+ ```python
17
+ from neva.support.facade import Event
18
+ _ = await Event.dispatch(ActorCreated(actor_id=actor.id))
19
+ ```
20
+
21
+ ## Defining an event
22
+
23
+ Subclass `Event[T]`, where `T` types the `event_id`. The base supplies `event_id: T` and
24
+ `timestamp: datetime` (defaulted to `neva.support.time.utcnow`). Events are Pydantic models, so
25
+ they validate on construction and serialise.
26
+
27
+ ```python
28
+ class ActorCreated(Event[uuid.UUID]):
29
+ actor_id: uuid.UUID
30
+ ```
31
+
32
+ Most applications define one project base event that fixes the id strategy
33
+ (`event_id: uuid.UUID = pydantic.Field(default_factory=uuid.uuid4)`) and let component events
34
+ declare payload only.
35
+
36
+ Naming: events are **past tense**.
37
+
38
+ ## Writing a listener
39
+
40
+ ```python
41
+ class SendWelcomeEmail(EventListener[ActorCreated]):
42
+ policy = HandlingPolicy.DEFERRED # default is IMMEDIATE
43
+
44
+ def __init__(self, mailer: Mailer) -> None:
45
+ self._mailer = mailer
46
+
47
+ async def handle(self, event: ActorCreated) -> Result[None, str]:
48
+ await self._mailer.send_welcome(event.actor_id)
49
+ return Ok(None)
50
+ ```
51
+
52
+ Or, with no dependencies, decorate a handler — `@listener` synthesises a class named
53
+ `<Pascal>Listener` (`log_actor_created` → `LogActorCreatedListener`):
54
+
55
+ ```python
56
+ @listener(policy=HandlingPolicy.DEFERRED) # bare @listener() is IMMEDIATE
57
+ async def log_actor_created(event: ActorCreated) -> Result[None, str]:
58
+ Log.info("actor created", actor_id=str(event.actor_id))
59
+ return Ok(None)
60
+ ```
61
+
62
+ Naming: class listeners are imperative (`SendWelcomeEmail`), function handlers snake_case.
63
+
64
+ **Always define `handle`.** The base class body is `...` and is not abstract, so an omitted
65
+ `handle` silently returns `None` where the contract promises `Result[None, str]`.
66
+
67
+ **Return `Err`, never raise.** A raised exception is captured as `Err(f"Listener raised: {e}")`
68
+ and never reaches the producer or the other listeners.
69
+
70
+ ## Policy × transaction
71
+
72
+ | Policy | Inside an open transaction | Outside any transaction |
73
+ | ------ | -------------------------- | ----------------------- |
74
+ | `IMMEDIATE` | runs during `dispatch()` | runs during `dispatch()` |
75
+ | `DEFERRED` | runs after the transaction commits | runs during `dispatch()` |
76
+
77
+ `DEFERRED` is for effects that must not happen unless the data persisted — mail, indexing,
78
+ webhooks, outbound calls. On rollback they never fire. With no transaction open, `DEFERRED`
79
+ degrades to immediate: same call site, different timing by context.
80
+
81
+ ## What dispatch does
82
+
83
+ 1. Runs `before_dispatch` hooks.
84
+ 2. Resolves listeners, walking the event's **MRO** — a listener registered against a base event
85
+ also fires for subclasses. A listener reached through several registries fires **once**;
86
+ repeated registrations against the *same* event are honoured and fire repeatedly.
87
+ 3. Splits by policy.
88
+ 4. Branches on `DatabaseManager.outermost()` — the transaction that commits **last**, not
89
+ `current()`. Deferred listeners attach to it via `on_commit`, so they never run for work an
90
+ enclosing transaction may still roll back. With no transaction, deferred listeners join the
91
+ immediate list.
92
+ 5. Runs immediate listeners, resolving each from the container and awaiting `handle`.
93
+ 6. Returns one `Result` per **immediate** listener.
94
+
95
+ The returned list therefore says nothing about deferred work. Never use it to confirm a
96
+ deferred listener ran.
97
+
98
+ ## Resolution
99
+
100
+ `_resolve_listener` asks the container, then falls back to no-argument instantiation. A listener
101
+ with constructor dependencies **must** be bound — the `listen` ClassVar does that for you.
102
+
103
+ ## Registering
104
+
105
+ Declarative, and preferred:
106
+
107
+ ```python
108
+ class ActorServiceProvider(ServiceProvider):
109
+ listen = {ActorCreated: [SendWelcomeEmail, IndexActorForSearch]}
110
+ ```
111
+
112
+ Listener classes are bound at `Scope.APP` onto the application's root provider during
113
+ registration, then wired onto the dispatcher during `lifespan()`. If `listen` mappings exist and
114
+ the dispatcher cannot be resolved, boot **raises `RuntimeError`** naming the count and the fix
115
+ rather than leaving every listener silently unwired.
116
+
117
+ Imperative registration also works. Note the type stubs currently declare only the single-class
118
+ form, so a list argument type-checks as an error even though the dispatcher accepts it:
119
+
120
+ ```python
121
+ Event.listen(ActorCreated, SendWelcomeEmail)
122
+ ```
123
+
124
+ `EventServiceProvider` is a base provider — always registered, never added by hand.
125
+
126
+ ## before_dispatch hooks
127
+
128
+ Run once per dispatch, before any listener. The **hook** may be sync or async; the
129
+ **registration call is always awaited**, because `before_dispatch` is itself a coroutine
130
+ function. Forgetting the `await` registers nothing.
131
+
132
+ ```python
133
+ await Event.before_dispatch(persist_to_event_store)
134
+ await Event.before_dispatch(lambda e: metrics.incr(type(e).__name__))
135
+ ```
136
+
137
+ ## Testing
138
+
139
+ `Event.fake()` installs an `EventFake` that records events and fires **no** listeners — neither
140
+ immediate nor deferred — and whose `dispatch` always returns `[]`.
141
+
142
+ ```python
143
+ fake = Event.fake()
144
+ fake.assert_dispatched(ActorCreated, times=1, match=lambda e: e.actor_id == expected)
145
+ fake.assert_not_dispatched(ActorDeleted)
146
+ events = fake.dispatched(ActorCreated)
147
+ ```
148
+
149
+ `with Event.faking() as fake:` scopes it. `TestCase` restores facades after every test. To test
150
+ what a listener *does*, exercise the listener directly — the fake suppresses it.
@@ -0,0 +1,74 @@
1
+ ---
2
+ id: result-option
3
+ title: Result and Option
4
+ requires: python-neva>=4.0
5
+ triggers: [returning an error, handling a failure, unwrapping, a function that can fail, T | None]
6
+ priority: 10
7
+ verified_by: [tests/support/test_results.py]
8
+ ---
9
+
10
+ # Result and Option
11
+
12
+ Fallible operations return `Result[T, str]`. Absent values are `Option[T]`. Exceptions are for
13
+ unrecoverable conditions only.
14
+
15
+ ```python
16
+ from neva.support import Result, Ok, Err, Option, Some, Nothing, from_optional, UnwrapError
17
+ ```
18
+
19
+ `Result` is the alias `Ok[T, E] | Err[T, E]`; `Option` is `Nothing[T] | Some[T]`. Construct the
20
+ variants directly, annotate with the alias.
21
+
22
+ ## Rules
23
+
24
+ - `is_ok`, `is_err`, `is_some`, `is_nothing` are **properties**. `if r.is_ok():` is always
25
+ truthy — it is a bound method.
26
+ - Errors are `str` across the framework. Any type works as `E`; stay with strings.
27
+ - `unwrap()` asserts "this cannot fail" and raises `UnwrapError`. Correct in tests and where an
28
+ `Err` is a bug; at a boundary use `unwrap_or` / `map` / `and_then` / `match`.
29
+ - `map(f)` when `f` returns a plain value; `and_then(f)` when `f` returns a `Result`/`Option`.
30
+ `and_then` short-circuits on the first failure.
31
+ - `ok_or` / `ok_or_else` exist **only on `Option`**. A `Result` cannot be `ok_or`'d — to reshape
32
+ its error use `map_err`, to supply a fallback use `unwrap_or`.
33
+
34
+ ## Converting
35
+
36
+ | From | To | Call |
37
+ | ---- | -- | ---- |
38
+ | `Option` | `Result` | `ok_or(err)`, `ok_or_else(f)` |
39
+ | `Result` | `Option` | `ok()` keeps the value, `err()` keeps the error |
40
+ | `T \| None` | `Option` | `from_optional(x)` |
41
+
42
+ ```python
43
+ Config.get("app.title").unwrap_or("Untitled") # Result: fallback via unwrap_or
44
+ Config.get("app.title").map_err(lambda e: f"config: {e}")
45
+ find_user(uid).ok_or("user not found") # Option: absence becomes an error
46
+ ```
47
+
48
+ ## Pattern matching
49
+
50
+ `Ok`/`Err`/`Some` are single-field frozen dataclasses, so positional `match` binds through
51
+ `__match_args__`. Never name the private `_value` / `_err` fields.
52
+
53
+ ```python
54
+ match ConfigLoader(path).load_all():
55
+ case Err(message):
56
+ raise RuntimeError(f"Failed to register config: {message}")
57
+ case Ok(configs):
58
+ ...
59
+ ```
60
+
61
+ ## Equality and hashing
62
+
63
+ Value equality, and hashable. A variant never equals a different variant or a non-`Result`:
64
+ `Ok(1) == Ok(1)`, `Ok(1) != Err(1)`, `Ok(1) != 1`. `Some`/`Ok` define `__eq__` explicitly;
65
+ `Nothing`/`Err` use the dataclass one, so `Err(1) == Ok(1)` resolves through `Ok`'s reflected
66
+ comparison.
67
+
68
+ ## Method reference
69
+
70
+ Both: `map`, `map_or`, `map_or_else`, `and_then`, `unwrap`, `unwrap_or`, `unwrap_or_else`,
71
+ `expect`.
72
+ `Result` only: `map_err`, `unwrap_err`, `expect_err`, `ok`, `err`.
73
+ `Option` only: `filter`, `or_else`, `flatten` (collapses one level), `zip`, `ok_or`,
74
+ `ok_or_else`, `in` via `__contains__`.
@@ -0,0 +1,114 @@
1
+ ---
2
+ id: service-providers
3
+ title: Service providers and bindings
4
+ requires: python-neva>=4.0
5
+ triggers: [registering a service, binding a dependency, provider.py, wiring a component, DI container, resolving a type]
6
+ priority: 20
7
+ verified_by: [tests/arch/test_lifetimes.py, tests/arch/test_registration.py, tests/arch/test_cache.py, tests/arch/test_scope.py, tests/arch/test_extends.py]
8
+ ---
9
+
10
+ # Service providers and bindings
11
+
12
+ Every component owns one `ServiceProvider` in its `provider.py`, listed in the `providers`
13
+ config namespace. Registration happens in `register()`; nothing else binds.
14
+
15
+ ```python
16
+ from typing import Self, override
17
+ from neva.arch import ServiceProvider
18
+ from neva.support import Ok, Result
19
+
20
+ class ActorServiceProvider(ServiceProvider):
21
+ @override
22
+ def register(self) -> Result[Self, str]:
23
+ _ = self.singleton(CreateActor)
24
+ _ = self.singleton(SqlAlchemyActorRepository, interface=ActorRepository)
25
+ return Ok(self)
26
+ ```
27
+
28
+ `register()` returns `Ok(self)`, or `Err("...")` to abort boot with a message. Bind only — no
29
+ I/O, no connections. Startup work goes in `lifespan()`.
30
+
31
+ ## A lifetime is mandatory
32
+
33
+ Bare `bind()` with neither `scope` nor `cache` **raises `TypeError`** since 4.0. Use the named
34
+ lifetimes; they say what they mean and return `self`.
35
+
36
+ | Call | Scope | Cached | Use for |
37
+ | ---- | ----- | ------ | ------- |
38
+ | `singleton(src)` | APP | yes | One instance per process. The default choice. |
39
+ | `scoped(src)` | REQUEST | yes, per scope | Per-request state — request context, unit of work. |
40
+ | `transient(src)` | provider's (APP) | never | Stateless helpers, anything that must not be shared. |
41
+ | `instance(value)` | APP | n/a | An object you already built. `interface` defaults to `type(value)`. |
42
+
43
+ `bind()` itself returns `None` and is the escape hatch for a scope the four do not cover — then
44
+ pass `scope=` explicitly.
45
+
46
+ ```python
47
+ _ = self.singleton(Mailer)
48
+ _ = self.scoped(UnitOfWork, interface=UoW)
49
+ _ = self.transient(Clock)
50
+ _ = self.instance(settings_object)
51
+ self.bind(Thing, scope=Scope.SESSION) # only when a named lifetime cannot express it
52
+ ```
53
+
54
+ Every helper takes `interface=` to register under a protocol or base type, and `when=` to gate
55
+ the binding behind a marker.
56
+
57
+ ## Other seams
58
+
59
+ - `extend(fn, interface=T)` — `dishka.decorate`: wrap or replace a binding declared elsewhere,
60
+ including in another package. The cross-package customisation seam.
61
+ - `from_context(T, scope=...)` — the value is pushed in at scope entry, not constructed.
62
+ Integrations feed the HTTP request in this way.
63
+ - `activator(predicate, *markers)` — decides whether marker-gated bindings activate. The
64
+ variadic arguments are **markers** (`neva.arch.markers`), never service classes.
65
+ - `when: ClassVar[Marker | None]` on the provider gates all of its bindings at once.
66
+ - `listen: ClassVar[dict[type[Event], list[type[EventListener]]]]` — see the events fragment.
67
+
68
+ ## Startup and shutdown
69
+
70
+ Implement `lifespan()` and the provider is `Bootable` — a runtime-checkable Protocol, so no
71
+ inheritance is needed.
72
+
73
+ ```python
74
+ @asynccontextmanager
75
+ async def lifespan(self) -> AsyncIterator[None]:
76
+ cache = (await self.app.make_async(Cache)).unwrap()
77
+ await cache.connect()
78
+ try:
79
+ yield
80
+ finally:
81
+ await cache.disconnect()
82
+ ```
83
+
84
+ Lifespans enter in registration order and exit in reverse. Base providers — `LogServiceProvider`,
85
+ `EventServiceProvider`, `DatabaseServiceProvider` — are always registered, first.
86
+
87
+ ## Resolving
88
+
89
+ `make(T)` and `make_async(T)` return `Result[T, str]` and never raise. Use `make_async` when the
90
+ construction path involves an async provider. Both read the innermost container entered in the
91
+ current context, which is what makes scoped resolution work.
92
+
93
+ `current_container` is the container backing resolution here — an integration handing a
94
+ request-scoped container to another framework wants that, not `container`.
95
+
96
+ ## Registration is closed after boot
97
+
98
+ `register()` is idempotent for an already-registered class, but **returns `Err` once the
99
+ application has booted**: dishka validates the graph at build time, and the booted container
100
+ holds APP-scoped instances a rebuild would orphan. Declare providers in config, or register
101
+ before entering `lifespan()`.
102
+
103
+ ## Typed config shapes
104
+
105
+ `AppConfig` and `ProviderConfig` (from `neva.arch`) annotate a config file so a type checker
106
+ catches typos before boot. Each package declares the shape for the keys it reads.
107
+
108
+ ## Pitfalls
109
+
110
+ - `Application.bind()` was removed in 4.0. Bind through a provider.
111
+ - A provider absent from the `providers` config never boots, and nothing says so.
112
+ - Resolution is by **type**. There are no string keys; for two implementations of one interface
113
+ use distinct types or markers.
114
+ - `register()` must be pure binding. Connections belong in `lifespan()`.