python-neva 3.5.1__tar.gz → 3.6.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 (160) hide show
  1. python_neva-3.6.0/.pre-commit-config.yaml +33 -0
  2. {python_neva-3.5.1 → python_neva-3.6.0}/CHANGELOG.md +26 -0
  3. {python_neva-3.5.1 → python_neva-3.6.0}/CLAUDE.md +7 -4
  4. {python_neva-3.5.1 → python_neva-3.6.0}/PKG-INFO +2 -2
  5. {python_neva-3.5.1 → python_neva-3.6.0}/README.md +1 -1
  6. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/application.py +88 -22
  7. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/config.py +2 -2
  8. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/scopes.py +0 -1
  9. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/service_provider.py +82 -7
  10. {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/loader.py +14 -2
  11. {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/repository.py +24 -5
  12. {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/connection.py +22 -6
  13. {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/manager.py +11 -0
  14. {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/provider.py +8 -6
  15. {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/transaction.py +1 -2
  16. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/__init__.py +2 -0
  17. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/dispatcher.py +12 -9
  18. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/event_registry.py +40 -3
  19. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/listener.py +2 -3
  20. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/provider.py +3 -3
  21. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/logging/provider.py +1 -1
  22. {python_neva-3.5.1 → python_neva-3.6.0}/neva/polyfactory/__init__.py +2 -0
  23. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hash_manager.py +49 -2
  24. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hashers/bcrypt.py +1 -1
  25. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/provider.py +2 -2
  26. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/config.pyi +6 -3
  27. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/hash.pyi +45 -1
  28. {python_neva-3.5.1 → python_neva-3.6.0}/pyproject.toml +14 -8
  29. {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/test_context.py +0 -2
  30. {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/test_extends.py +1 -1
  31. python_neva-3.6.0/tests/arch/test_lifetimes.py +179 -0
  32. python_neva-3.6.0/tests/arch/test_registration.py +156 -0
  33. {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/test_scope.py +15 -0
  34. {python_neva-3.5.1 → python_neva-3.6.0}/tests/config/test_loader.py +40 -0
  35. {python_neva-3.5.1 → python_neva-3.6.0}/tests/config/test_repository.py +48 -0
  36. python_neva-3.6.0/tests/database/test_detached_lifespan.py +124 -0
  37. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_transaction_context.py +27 -1
  38. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/conftest.py +16 -0
  39. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_deferred.py +35 -1
  40. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_dispatch.py +1 -3
  41. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_listen_on_parent_class.py +39 -0
  42. {python_neva-3.5.1 → python_neva-3.6.0}/tests/security/test_encrypter.py +3 -3
  43. python_neva-3.6.0/tests/security/test_hash_manager.py +206 -0
  44. {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_event_fake.py +1 -0
  45. {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_fixtures.py +1 -1
  46. {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_refresh_database.py +2 -1
  47. {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_test_case.py +2 -0
  48. {python_neva-3.5.1 → python_neva-3.6.0}/uv.lock +22 -165
  49. python_neva-3.6.0/wiki/architecture/KNOWN-DISCREPANCIES.md +23 -0
  50. python_neva-3.5.1/.pre-commit-config.yaml +0 -45
  51. python_neva-3.5.1/tests/security/test_hash_manager.py +0 -92
  52. python_neva-3.5.1/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -41
  53. {python_neva-3.5.1 → python_neva-3.6.0}/.claude/settings.local.json +0 -0
  54. {python_neva-3.5.1 → python_neva-3.6.0}/.envrc +0 -0
  55. {python_neva-3.5.1 → python_neva-3.6.0}/.gitignore +0 -0
  56. {python_neva-3.5.1 → python_neva-3.6.0}/.python-version +0 -0
  57. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/__init__.py +0 -0
  58. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/facade.py +0 -0
  59. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/integrations/__init__.py +0 -0
  60. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/integrations/faststream.py +0 -0
  61. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/markers.py +0 -0
  62. {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/py.typed +0 -0
  63. {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/__init__.py +0 -0
  64. {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/base_providers.py +0 -0
  65. {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/py.typed +0 -0
  66. {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/__init__.py +0 -0
  67. {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/config.py +0 -0
  68. {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/py.typed +0 -0
  69. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/__init__.py +0 -0
  70. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/dispatcher.py +0 -0
  71. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/event.py +0 -0
  72. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/handler.py +0 -0
  73. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/listener.py +0 -0
  74. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/event.py +0 -0
  75. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/policy.py +0 -0
  76. {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/py.typed +0 -0
  77. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/__init__.py +0 -0
  78. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/instrumentation/__init__.py +0 -0
  79. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/instrumentation/sqlalchemy.py +0 -0
  80. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/logging/__init__.py +0 -0
  81. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/logging/manager.py +0 -0
  82. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/middleware/__init__.py +0 -0
  83. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/middleware/correlation.py +0 -0
  84. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/middleware/profiler.py +0 -0
  85. {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/py.typed +0 -0
  86. {python_neva-3.5.1 → python_neva-3.6.0}/neva/polyfactory/factories.py +0 -0
  87. {python_neva-3.5.1 → python_neva-3.6.0}/neva/polyfactory/persistence.py +0 -0
  88. {python_neva-3.5.1 → python_neva-3.6.0}/neva/polyfactory/py.typed +0 -0
  89. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/__init__.py +0 -0
  90. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/encryption/__init__.py +0 -0
  91. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/encryption/encrypter.py +0 -0
  92. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/encryption/protocol.py +0 -0
  93. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/__init__.py +0 -0
  94. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/config.py +0 -0
  95. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hashers/__init__.py +0 -0
  96. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hashers/argon2.py +0 -0
  97. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hashers/protocol.py +0 -0
  98. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/py.typed +0 -0
  99. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/tokens/__init__.py +0 -0
  100. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/tokens/generate_token.py +0 -0
  101. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/tokens/hash_token.py +0 -0
  102. {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/tokens/verify_token.py +0 -0
  103. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/__init__.py +0 -0
  104. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/accessors.py +0 -0
  105. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/__init__.py +0 -0
  106. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/app.py +0 -0
  107. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/app.pyi +0 -0
  108. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/config.py +0 -0
  109. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/crypt.py +0 -0
  110. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/crypt.pyi +0 -0
  111. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/db.py +0 -0
  112. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/db.pyi +0 -0
  113. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/event.py +0 -0
  114. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/event.pyi +0 -0
  115. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/hash.py +0 -0
  116. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/log.py +0 -0
  117. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/log.pyi +0 -0
  118. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/py.typed +0 -0
  119. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/results.py +0 -0
  120. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/strategy.py +0 -0
  121. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/strconv.py +0 -0
  122. {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/time.py +0 -0
  123. {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/__init__.py +0 -0
  124. {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/fakes.py +0 -0
  125. {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/fixtures.py +0 -0
  126. {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/py.typed +0 -0
  127. {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/test_case.py +0 -0
  128. {python_neva-3.5.1 → python_neva-3.6.0}/ruff.toml +0 -0
  129. {python_neva-3.5.1 → python_neva-3.6.0}/scripts/retag-with-changelog.sh +0 -0
  130. {python_neva-3.5.1 → python_neva-3.6.0}/tests/__init__.py +0 -0
  131. {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/__init__.py +0 -0
  132. {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/test_cache.py +0 -0
  133. {python_neva-3.5.1 → python_neva-3.6.0}/tests/config/__init__.py +0 -0
  134. {python_neva-3.5.1 → python_neva-3.6.0}/tests/conftest.py +0 -0
  135. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/__init__.py +0 -0
  136. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_connection_manager.py +0 -0
  137. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_database_manager.py +0 -0
  138. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_edge_cases.py +0 -0
  139. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_multi_connection.py +0 -0
  140. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  141. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_transaction.py +0 -0
  142. {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_transaction_registry.py +0 -0
  143. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/__init__.py +0 -0
  144. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_before_dispatch.py +0 -0
  145. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_binding.py +0 -0
  146. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_event.py +0 -0
  147. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_function_listener.py +0 -0
  148. {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_immediate.py +0 -0
  149. {python_neva-3.5.1 → python_neva-3.6.0}/tests/obs/__init__.py +0 -0
  150. {python_neva-3.5.1 → python_neva-3.6.0}/tests/obs/test_correlation.py +0 -0
  151. {python_neva-3.5.1 → python_neva-3.6.0}/tests/obs/test_profiler.py +0 -0
  152. {python_neva-3.5.1 → python_neva-3.6.0}/tests/security/__init__.py +0 -0
  153. {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/__init__.py +0 -0
  154. {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_facade_restore.py +0 -0
  155. {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/configuration.md +0 -0
  156. {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/database-and-transactions.md +0 -0
  157. {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/dependency-injection.md +0 -0
  158. {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/events.md +0 -0
  159. {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/result-option.md +0 -0
  160. {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/testing.md +0 -0
@@ -0,0 +1,33 @@
1
+ repos:
2
+ - repo: https://github.com/gitleaks/gitleaks
3
+ rev: v8.30.1
4
+ hooks:
5
+ - id: gitleaks
6
+
7
+ - repo: https://github.com/pre-commit/pre-commit-hooks
8
+ rev: v6.0.0
9
+ hooks:
10
+ - id: trailing-whitespace
11
+ - id: end-of-file-fixer
12
+ - id: check-yaml
13
+ - id: check-added-large-files
14
+ - id: check-case-conflict
15
+ - id: check-merge-conflict
16
+ - id: debug-statements
17
+
18
+ - repo: https://github.com/astral-sh/ruff-pre-commit
19
+ rev: v0.15.6
20
+ hooks:
21
+ - id: ruff
22
+ args: [--fix, --exit-non-zero-on-fix]
23
+ - id: ruff-format
24
+
25
+ - repo: local
26
+ hooks:
27
+ - id: pyrefly
28
+ name: Pyrefly (type checking)
29
+ entry: uv run pyrefly check
30
+ language: system
31
+ types_or: [python, pyi]
32
+ pass_filenames: false
33
+ require_serial: true
@@ -1,3 +1,29 @@
1
+ ## 3.6.0 (2026-08-28)
2
+
3
+ ### ✨ Features
4
+
5
+ - **arch**: name the binding lifetimes, deprecate the implicit one
6
+ - **security**: add make_async and check_async
7
+ - **arch**: expose the container backing the current scope
8
+
9
+ ### 🐛🚑️ Fixes
10
+
11
+ - **events**: dispatch once per registration, and defer to the outermost transaction
12
+ - **config**: make freeze() actually freeze and merge() recursive
13
+ - **config**: stop config files from displacing real modules
14
+ - **arch**: run lifespan teardown even when the body raises
15
+ - **security**: stop requiring the optional hasher tuning config
16
+ - **arch**: make register() bind, or say why it cannot
17
+ - **database**: keep the transaction when the registry contextvar is unset
18
+
19
+ ### 📝💡 Documentation
20
+
21
+ - **general**: correct the Config stub, a lying docstring and a wrong annotation
22
+
23
+ ### 🔧🔨📦️ Configuration, Scripts, Packages
24
+
25
+ - **tooling**: replace mypy with pyrefly at strict
26
+
1
27
  ## 3.5.1 (2026-08-24)
2
28
 
3
29
  ### 🐛🚑️ Fixes
@@ -18,7 +18,7 @@ ecosystem is listed in `README.md`.
18
18
 
19
19
  **Python Version:** 3.12+
20
20
  **Build System:** uv + hatchling
21
- **Type checker:** mypy (with the `pydantic.mypy` plugin and the `TypeForm` incomplete feature)
21
+ **Type checker:** pyrefly (`preset = "strict"`, configured in `[tool.pyrefly]`)
22
22
 
23
23
  ## Commands
24
24
 
@@ -26,7 +26,7 @@ ecosystem is listed in `README.md`.
26
26
  # Development (poe task aliases)
27
27
  poe lint # ruff check
28
28
  poe fmt # ruff format
29
- poe tc # mypy .
29
+ poe tc # pyrefly check
30
30
  poe test # pytest
31
31
  poe test-cov # tests with coverage (--cov-report=term-missing)
32
32
  poe test-full # coverage over the neva package
@@ -72,11 +72,14 @@ config = Config.get("app.name").unwrap_or("MyApp")
72
72
  await Event.dispatch(UserCreated(event_id=1, user_id=42))
73
73
  ```
74
74
 
75
- **Service Providers** - Register services with lifecycle hooks:
75
+ **Service Providers** - Register services with lifecycle hooks. Bindings
76
+ declare a lifetime by name — `singleton` (one per process), `scoped` (one per
77
+ REQUEST scope), `transient` (one per resolution), `instance` (an object you
78
+ already have). Bare `bind` without an explicit `scope`/`cache` is deprecated:
76
79
  ```python
77
80
  class MyProvider(ServiceProvider):
78
81
  def register(self) -> Result[Self, str]:
79
- self.bind(MyService)
82
+ _ = self.singleton(MyService)
80
83
  return Ok(self)
81
84
 
82
85
  @asynccontextmanager
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-neva
3
- Version: 3.5.1
3
+ Version: 3.6.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -58,7 +58,7 @@ independent repo, independently versioned and published.
58
58
  uv sync # install/refresh deps from uv.lock
59
59
  poe lint # ruff check
60
60
  poe fmt # ruff format
61
- poe tc # type-check (mypy)
61
+ poe tc # type-check (pyrefly)
62
62
  poe test # pytest
63
63
  poe test-cov # tests with coverage
64
64
 
@@ -34,7 +34,7 @@ independent repo, independently versioned and published.
34
34
  uv sync # install/refresh deps from uv.lock
35
35
  poe lint # ruff check
36
36
  poe fmt # ruff format
37
- poe tc # type-check (mypy)
37
+ poe tc # type-check (pyrefly)
38
38
  poe test # pytest
39
39
  poe test-cov # tests with coverage
40
40
 
@@ -43,6 +43,9 @@ class Application:
43
43
  self.config: ConfigRepository = ConfigRepository()
44
44
  self.providers: OrderedDict[type, ServiceProvider] = OrderedDict()
45
45
  self.root_provider: dishka.Provider = dishka.Provider(scope=Scope.APP)
46
+ self._extra_providers: list[BaseProvider] = []
47
+ self._container: dishka.AsyncContainer | None = None
48
+ self._booted: bool = False
46
49
 
47
50
  configuration_path = (
48
51
  config_path
@@ -77,30 +80,70 @@ class Application:
77
80
  _ = self.root_provider.provide(source=lambda: self, provides=Application)
78
81
  self.register_providers(providers)
79
82
  self._bind_event_listeners()
80
- self.build_container(self.root_provider)
83
+ self.build_container()
81
84
 
82
- def build_container(self, *providers: BaseProvider) -> None:
83
- """Build the application container."""
84
- self.container: dishka.AsyncContainer = dishka.make_async_container(
85
+ @property
86
+ def container(self) -> dishka.AsyncContainer:
87
+ """The application container, built on first access.
88
+
89
+ Registering a provider discards the built container so the next access
90
+ rebuilds the graph with that provider's bindings in it.
91
+
92
+ Returns:
93
+ The application container.
94
+ """
95
+ if self._container is not None:
96
+ return self._container
97
+ return self.build_container()
98
+
99
+ def build_container(self, *providers: BaseProvider) -> dishka.AsyncContainer:
100
+ """Build the application container.
101
+
102
+ Args:
103
+ providers: Extra dishka providers to compose into the graph. They
104
+ are remembered, so a later rebuild keeps them.
105
+
106
+ Returns:
107
+ The freshly built container.
108
+ """
109
+ self._extra_providers.extend(providers)
110
+ self._container = dishka.make_async_container(
85
111
  self.root_provider,
86
112
  *[p.provider for p in self.providers.values()],
87
- *providers,
113
+ *self._extra_providers,
88
114
  )
115
+ return self._container
89
116
 
90
117
  def register(self, provider: type[ServiceProvider]) -> Result[ServiceProvider, str]:
91
118
  """Registers a service provider with the application.
92
119
 
120
+ Providers must be registered before the application boots: dishka
121
+ validates the dependency graph when the container is built, and the
122
+ booted container already holds APP-scoped instances that a rebuild
123
+ would orphan. Registering after boot is an error rather than a
124
+ silently ignored call.
125
+
93
126
  Returns:
94
127
  Result containing the registered provider instance or an error message.
95
128
  """
96
129
  if provider in self.providers:
97
130
  return Ok(self.providers[provider])
98
131
 
99
- return (
132
+ if self._booted:
133
+ return Err(
134
+ f"Cannot register '{provider.__name__}': the application is "
135
+ "already booted. Register providers before entering lifespan(), "
136
+ "or declare them in the 'providers' config namespace."
137
+ )
138
+
139
+ registered = (
100
140
  provider(self)
101
141
  .register()
102
142
  .map(lambda p: self.providers.setdefault(provider, p))
103
143
  )
144
+ if registered.is_ok:
145
+ self._container = None
146
+ return registered
104
147
 
105
148
  def register_providers(self, providers: Sequence[type[ServiceProvider]]) -> None:
106
149
  """Registers a set of providers."""
@@ -122,15 +165,28 @@ class Application:
122
165
  provides=interface,
123
166
  )
124
167
 
168
+ @property
169
+ def current_container(self) -> dishka.AsyncContainer:
170
+ """The container backing resolution in the current context.
171
+
172
+ Inside ``scope()`` this is the child container for that scope; outside
173
+ it, the application container. Integrations that hand a container to
174
+ another framework — a request-scoped container to a route layer, say —
175
+ want this one, not ``container``.
176
+
177
+ Returns:
178
+ The innermost container entered in this context.
179
+ """
180
+ return _current_container.get(self.container)
181
+
125
182
  async def make_async[T](self, interface: type[T]) -> Result[T, str]:
126
183
  """Resolve and instanciate a type from the container.
127
184
 
128
185
  Returns:
129
186
  Result containing the resolved type instance or an error message.
130
187
  """
131
- container = _current_container.get(self.container)
132
188
  try:
133
- return Ok(await container.get(interface))
189
+ return Ok(await self.current_container.get(interface))
134
190
  except Exception as e:
135
191
  return Err(f"Failed to resolve service '{interface.__name__}': {e}")
136
192
 
@@ -140,7 +196,7 @@ class Application:
140
196
  Returns:
141
197
  Result containing the resolved type instance or an error message.
142
198
  """
143
- container = _current_container.get(self.container)
199
+ container = self.current_container
144
200
  try:
145
201
  return Ok(container.get_sync(interface))
146
202
  except Exception as e:
@@ -154,11 +210,13 @@ class Application:
154
210
  ) -> AsyncGenerator[Self]:
155
211
  """Enter a new scope.
156
212
 
213
+ The application is yielded, not the child container; read
214
+ ``current_container`` inside the block to reach it.
215
+
157
216
  Yields:
158
217
  The application instance with the new scope.
159
218
  """
160
- parent = _current_container.get(self.container)
161
- async with parent(scope=scope, context=context) as container:
219
+ async with self.current_container(scope=scope, context=context) as container:
162
220
  token = _current_container.set(container)
163
221
  try:
164
222
  yield self
@@ -169,24 +227,32 @@ class Application:
169
227
  async def lifespan(self) -> AsyncGenerator[None]:
170
228
  """Wire the facades and providers."""
171
229
  Facade.set_facade_application(self)
230
+ self._booted = True
172
231
 
173
- async with AsyncExitStack() as stack:
174
- for provider in self.providers.values():
175
- if isinstance(provider, Bootable):
176
- await stack.enter_async_context(provider.lifespan())
177
-
178
- await self._wire_event_listeners()
179
- yield
180
-
181
- await self.container.close()
182
- Facade.reset_facade_application()
232
+ try:
233
+ async with AsyncExitStack() as stack:
234
+ for provider in self.providers.values():
235
+ if isinstance(provider, Bootable):
236
+ await stack.enter_async_context(provider.lifespan())
237
+
238
+ await self._wire_event_listeners()
239
+ yield
240
+ finally:
241
+ # Read `_container` rather than the property: the container may
242
+ # never have been built, and closing is no reason to build one.
243
+ if self._container is not None:
244
+ await self._container.close()
245
+ self._booted = False
246
+ Facade.reset_facade_application()
183
247
 
184
248
  def _bind_event_listeners(self) -> None:
185
249
  """Bind all listener classes declared in provider ``listen`` dicts."""
186
250
  for provider in self.providers.values():
187
251
  for listeners in provider.listen.values():
188
252
  for listener_cls in listeners:
189
- self.bind(listener_cls)
253
+ # Straight onto the root provider: routing this through the
254
+ # deprecated `bind` warned on every boot.
255
+ _ = self.root_provider.provide(listener_cls, scope=Scope.APP)
190
256
 
191
257
  async def _wire_event_listeners(self) -> None:
192
258
  """Wire event-listener mappings from all providers onto the dispatcher."""
@@ -24,7 +24,7 @@ class AppConfig(TypedDict):
24
24
  openapi_url: NotRequired[str]
25
25
  docs_url: NotRequired[str]
26
26
  redoc_url: NotRequired[str]
27
- providers: NotRequired[list[ServiceProvider]]
27
+ providers: NotRequired[list[type[ServiceProvider]]]
28
28
 
29
29
 
30
30
  class ProviderConfig(TypedDict):
@@ -34,4 +34,4 @@ class ProviderConfig(TypedDict):
34
34
  providers: List of service providers to register.
35
35
  """
36
36
 
37
- providers: NotRequired[list[ServiceProvider]]
37
+ providers: NotRequired[list[type[ServiceProvider]]]
@@ -2,4 +2,3 @@ from dishka.entities.scope import BaseScope, Scope
2
2
 
3
3
 
4
4
  __all__ = ["BaseScope", "Scope"]
5
-
@@ -8,6 +8,7 @@ container and optionally managing their lifecycle.
8
8
  from __future__ import annotations
9
9
 
10
10
  import abc
11
+ import warnings
11
12
  from contextlib import AbstractAsyncContextManager
12
13
  from typing import (
13
14
  TYPE_CHECKING,
@@ -70,7 +71,7 @@ class ServiceProvider(abc.ABC):
70
71
  """
71
72
 
72
73
  app: "Application"
73
- listen: ClassVar[dict[type[Event], list[type[EventListener[Any]]]]] = {}
74
+ listen: ClassVar[dict[type[Event[Any]], list[type[EventListener[Any]]]]] = {}
74
75
  when: ClassVar[Marker | None] = None
75
76
 
76
77
  def __init__(self, app: "Application") -> None:
@@ -106,17 +107,91 @@ class ServiceProvider(abc.ABC):
106
107
  interface: type | None = None,
107
108
  scope: BaseScope | None = None,
108
109
  when: BaseMarker | None = None,
109
- cache: bool = True,
110
+ cache: bool | None = None,
110
111
  ) -> None:
111
- """Binds a source to the container."""
112
+ """Binds a source to the container.
113
+
114
+ Prefer the named lifetimes — `singleton`, `scoped`, `transient`,
115
+ `instance` — which say what they mean. Use `bind` directly only to
116
+ reach a scope those do not cover, and then pass `scope` explicitly.
117
+
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.
121
+
122
+ Args:
123
+ source: The class or factory to bind.
124
+ interface: The type to resolve it as. Defaults to `source`.
125
+ scope: The dishka scope. Defaults to the provider's (APP).
126
+ when: Marker gating this binding.
127
+ cache: Whether to reuse one instance per scope. Defaults to True.
128
+ """
129
+ 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,
137
+ )
138
+
112
139
  _ = self.provider.provide(
113
140
  source=source,
114
141
  scope=scope,
115
142
  provides=interface,
116
- cache=cache,
143
+ cache=True if cache is None else cache,
117
144
  when=when,
118
145
  )
119
146
 
147
+ def singleton(
148
+ self,
149
+ source: type | Callable[..., Any],
150
+ *,
151
+ interface: type | None = None,
152
+ when: BaseMarker | None = None,
153
+ ) -> Self:
154
+ """Binds the source at APP scope, as one instance for the process.
155
+
156
+ This is what a bare `bind` has always done; naming it makes the
157
+ lifetime visible at the call site.
158
+
159
+ Returns:
160
+ the provider itself for chaining purposes
161
+ """
162
+ self.bind(source, interface=interface, scope=Scope.APP, when=when, cache=True)
163
+ return self
164
+
165
+ def instance(
166
+ self,
167
+ value: object,
168
+ *,
169
+ interface: type | None = None,
170
+ when: BaseMarker | None = None,
171
+ ) -> Self:
172
+ """Binds an object that already exists.
173
+
174
+ Nothing is constructed: the value handed in is what every resolution
175
+ returns. Use it for configuration objects, clients built during boot,
176
+ and test doubles.
177
+
178
+ Args:
179
+ value: The object to hand out.
180
+ interface: The type to resolve it as. Defaults to `type(value)`.
181
+ when: Marker gating this binding.
182
+
183
+ Returns:
184
+ the provider itself for chaining purposes
185
+ """
186
+ self.bind(
187
+ lambda: value,
188
+ interface=interface if interface is not None else type(value),
189
+ scope=Scope.APP,
190
+ when=when,
191
+ cache=True,
192
+ )
193
+ return self
194
+
120
195
  def scoped(
121
196
  self,
122
197
  source: type | Callable[..., Any],
@@ -124,10 +199,10 @@ class ServiceProvider(abc.ABC):
124
199
  interface: type | None = None,
125
200
  when: BaseMarker | None = None,
126
201
  ) -> Self:
127
- """Binds the source to the container.
202
+ """Binds the source at REQUEST scope.
128
203
 
129
- Scope is REQUEST by default but a custom scope be provided.
130
- Dependency declared with this are cached no matter what.
204
+ One instance is created per request scope and cached for its duration.
205
+ For any other scope, call `bind` with an explicit `scope`.
131
206
 
132
207
  Returns:
133
208
  the provider itself for chaining purposes
@@ -14,6 +14,11 @@ from neva.support import Err, Ok, Option, Result
14
14
  from neva.support.accessors import get_attr
15
15
 
16
16
 
17
+ # Deliberately not "neva.config": a config file named repository.py or
18
+ # loader.py would otherwise displace the real module of that name.
19
+ _CONFIG_MODULE_NAMESPACE = "neva._app_config"
20
+
21
+
17
22
  class ConfigLoader:
18
23
  """Loads configuration files from a directory.
19
24
 
@@ -90,15 +95,22 @@ class ConfigLoader:
90
95
  """
91
96
  try:
92
97
  spec = importlib.util.spec_from_file_location(
93
- f"neva.config.{module_name}", file_path
98
+ f"{_CONFIG_MODULE_NAMESPACE}.{module_name}", file_path
94
99
  )
95
100
 
96
101
  if spec is None or spec.loader is None:
97
102
  return Err(f"Could not load module spec for {file_path}")
98
103
 
99
104
  module = importlib.util.module_from_spec(spec)
105
+ # Registered so dataclasses, pickle and typing lookups inside the
106
+ # config file can resolve it, then removed: these are data files,
107
+ # not importable modules, and leaving them in place would let a
108
+ # second config directory shadow the first.
100
109
  sys.modules[spec.name] = module
101
- spec.loader.exec_module(module)
110
+ try:
111
+ spec.loader.exec_module(module)
112
+ finally:
113
+ _ = sys.modules.pop(spec.name, None)
102
114
 
103
115
  config_dict = self._extract_config(module)
104
116
  return Ok(config_dict)
@@ -5,6 +5,7 @@ store for all application configuration values. It supports dot notation for
5
5
  nested access and can be frozen to prevent modifications after initialization.
6
6
  """
7
7
 
8
+ import copy
8
9
  from typing import Any, cast
9
10
 
10
11
  from typing_extensions import TypeForm
@@ -105,10 +106,11 @@ class ConfigRepository:
105
106
  """Get all configuration items as a dictionary.
106
107
 
107
108
  Returns:
108
- A copy of the entire configuration dictionary.
109
+ A deep copy of the entire configuration dictionary. Mutating it
110
+ never reaches the repository, frozen or not.
109
111
 
110
112
  """
111
- return self._items.copy()
113
+ return copy.deepcopy(self._items)
112
114
 
113
115
  def freeze(self) -> None:
114
116
  """Freeze the configuration to prevent further changes.
@@ -130,8 +132,10 @@ class ConfigRepository:
130
132
  def merge(self, key: str, items: dict[str, Any]) -> Result[None, str]:
131
133
  """Merge items into a configuration namespace.
132
134
 
133
- Updates the dictionary at the given key with the provided items.
134
- Creates the namespace if it doesn't exist.
135
+ Merges recursively: nested dictionaries are combined rather than
136
+ replaced, so a package can register defaults and an application can
137
+ override individual leaves without restating the whole namespace.
138
+ Non-dict values are replaced outright.
135
139
 
136
140
  Args:
137
141
  key: The namespace key to merge into.
@@ -153,5 +157,20 @@ class ConfigRepository:
153
157
  f"existing value is {type(self._items[key]).__name__}, not dict"
154
158
  )
155
159
 
156
- self._items[key].update(items)
160
+ _deep_merge(self._items[key], items)
157
161
  return Ok(None)
162
+
163
+
164
+ def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
165
+ """Merge source into target in place, recursing into nested dictionaries.
166
+
167
+ Args:
168
+ target: The dictionary to merge into. Modified in place.
169
+ source: The dictionary to read from.
170
+ """
171
+ for key, value in source.items():
172
+ existing = target.get(key)
173
+ if isinstance(existing, dict) and isinstance(value, dict):
174
+ _deep_merge(existing, value)
175
+ else:
176
+ target[key] = value
@@ -63,6 +63,20 @@ class TransactionContext:
63
63
  return Some(registry.stack[-1])
64
64
  return Nothing()
65
65
 
66
+ def outermost(self) -> Option[BoundTransaction]:
67
+ """Get the outermost open transaction, across all connections.
68
+
69
+ This is the last one to commit, so work that must not outlive *any*
70
+ open transaction — a deferred event listener — belongs on it.
71
+
72
+ Returns:
73
+ The outermost transaction, if any.
74
+ """
75
+ registry = _tx_registry.get()
76
+ if registry is None or not registry.stack:
77
+ return Nothing()
78
+ return Some(registry.stack[0])
79
+
66
80
 
67
81
  @final
68
82
  class ConnectionManager:
@@ -97,15 +111,17 @@ class ConnectionManager:
97
111
  async def _scoped(self, tx: BoundTransaction) -> AsyncIterator[None]:
98
112
  """Expose the transaction on the registry for the duration of the block.
99
113
 
114
+ The registry may be unset in the current context: ``TransactionContext``
115
+ seeds it when it is constructed, and a task that did not inherit that
116
+ context — an ASGI request, when the lifespan ran on its own task — sees
117
+ the ``None`` default. Seed a fresh registry in that case, but still put
118
+ the transaction in it.
119
+
100
120
  Yields:
101
121
  None
102
122
  """
103
- old_registry = _tx_registry.get()
104
- token = (
105
- _tx_registry.set(old_registry.extend(tx))
106
- if old_registry is not None
107
- else _tx_registry.set(TransactionRegistry())
108
- )
123
+ old_registry = _tx_registry.get() or TransactionRegistry()
124
+ token = _tx_registry.set(old_registry.extend(tx))
109
125
  try:
110
126
  yield
111
127
  finally:
@@ -68,6 +68,17 @@ class DatabaseManager:
68
68
  """
69
69
  return self._tx_context.current(connection)
70
70
 
71
+ def outermost(self) -> Option[BoundTransaction]:
72
+ """Returns the outermost open transaction, across all connections.
73
+
74
+ Unlike `current`, which returns the most recently opened transaction,
75
+ this returns the one that commits last.
76
+
77
+ Returns:
78
+ The outermost transaction, if any.
79
+ """
80
+ return self._tx_context.outermost()
81
+
71
82
  def session(self, connection: str | None = None) -> Option[asyncio.AsyncSession]:
72
83
  """Returns the current session for a connection.
73
84
 
@@ -17,8 +17,8 @@ class DatabaseServiceProvider(ServiceProvider):
17
17
 
18
18
  @override
19
19
  def register(self) -> Result[Self, str]:
20
- self.bind(TransactionContext)
21
- self.bind(DatabaseManager)
20
+ _ = self.singleton(TransactionContext)
21
+ _ = self.singleton(DatabaseManager)
22
22
  return Ok(self)
23
23
 
24
24
  @asynccontextmanager
@@ -29,13 +29,15 @@ class DatabaseServiceProvider(ServiceProvider):
29
29
  logger.info("Beginning SQLAlchemy initialization...")
30
30
  match self.app.config.get("database", type_=DatabaseConfig):
31
31
  case Ok(config):
32
- connections: dict[str, ConnectionConfig] = config["connections"]
32
+ connections: dict[str, ConnectionConfig] = config.get("connections", {})
33
33
  for name, conn_config in connections.items():
34
34
  db.register_connection(name, conn_config)
35
35
  logger.info(f"Registered engine for connection '{name}'.")
36
36
  logger.info("SQLAlchemy initialization complete.")
37
- yield
38
- await db.close()
39
37
  case Err(err):
40
38
  logger.error(f"Failed to load database configuration: {err}")
41
- yield
39
+
40
+ try:
41
+ yield
42
+ finally:
43
+ await db.close()
@@ -1,6 +1,5 @@
1
1
  """Transaction management systems."""
2
2
 
3
- import abc
4
3
  import asyncio
5
4
  from collections.abc import Awaitable
6
5
  from dataclasses import dataclass, field
@@ -24,7 +23,7 @@ class TransactionState(Enum):
24
23
 
25
24
 
26
25
  @dataclass
27
- class Transaction(abc.ABC):
26
+ class Transaction:
28
27
  """Represents a database transaction with callback support."""
29
28
 
30
29
  conn_name: str
@@ -1,3 +1,5 @@
1
+ """Protocols describing the event system's public contracts."""
2
+
1
3
  from neva.events.contracts.dispatcher import (
2
4
  AsyncBeforeDispatchHook,
3
5
  BeforeDispatchHook,