python-neva 3.5.1__tar.gz → 3.7.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 (164) hide show
  1. python_neva-3.7.0/.gitlab-ci.yml +79 -0
  2. python_neva-3.7.0/.pre-commit-config.yaml +33 -0
  3. python_neva-3.7.0/CHANGELOG.md +92 -0
  4. {python_neva-3.5.1 → python_neva-3.7.0}/CLAUDE.md +7 -4
  5. {python_neva-3.5.1 → python_neva-3.7.0}/PKG-INFO +10 -6
  6. {python_neva-3.5.1 → python_neva-3.7.0}/README.md +1 -1
  7. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/application.py +88 -22
  8. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/config.py +2 -2
  9. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/scopes.py +0 -1
  10. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/service_provider.py +82 -7
  11. {python_neva-3.5.1 → python_neva-3.7.0}/neva/config/loader.py +14 -2
  12. {python_neva-3.5.1 → python_neva-3.7.0}/neva/config/repository.py +24 -5
  13. {python_neva-3.5.1 → python_neva-3.7.0}/neva/database/connection.py +23 -7
  14. {python_neva-3.5.1 → python_neva-3.7.0}/neva/database/manager.py +11 -0
  15. {python_neva-3.5.1 → python_neva-3.7.0}/neva/database/provider.py +8 -6
  16. {python_neva-3.5.1 → python_neva-3.7.0}/neva/database/transaction.py +2 -3
  17. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/contracts/__init__.py +2 -0
  18. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/dispatcher.py +12 -9
  19. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/event_registry.py +40 -3
  20. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/listener.py +2 -3
  21. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/provider.py +3 -3
  22. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/logging/provider.py +1 -1
  23. {python_neva-3.5.1 → python_neva-3.7.0}/neva/polyfactory/__init__.py +2 -0
  24. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/hashing/hash_manager.py +49 -2
  25. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/hashing/hashers/bcrypt.py +1 -1
  26. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/provider.py +2 -2
  27. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/config.pyi +6 -3
  28. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/hash.pyi +45 -1
  29. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/strategy.py +2 -2
  30. {python_neva-3.5.1 → python_neva-3.7.0}/pyproject.toml +22 -12
  31. python_neva-3.7.0/ruff.toml +47 -0
  32. {python_neva-3.5.1 → python_neva-3.7.0}/tests/arch/test_context.py +0 -2
  33. {python_neva-3.5.1 → python_neva-3.7.0}/tests/arch/test_extends.py +1 -1
  34. python_neva-3.7.0/tests/arch/test_lifetimes.py +179 -0
  35. python_neva-3.7.0/tests/arch/test_registration.py +152 -0
  36. {python_neva-3.5.1 → python_neva-3.7.0}/tests/arch/test_scope.py +15 -0
  37. {python_neva-3.5.1 → python_neva-3.7.0}/tests/config/test_loader.py +36 -0
  38. {python_neva-3.5.1 → python_neva-3.7.0}/tests/config/test_repository.py +48 -0
  39. python_neva-3.7.0/tests/database/test_detached_lifespan.py +124 -0
  40. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/test_transaction_context.py +27 -1
  41. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/conftest.py +17 -1
  42. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/test_deferred.py +35 -1
  43. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/test_dispatch.py +1 -3
  44. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/test_listen_on_parent_class.py +40 -1
  45. {python_neva-3.5.1 → python_neva-3.7.0}/tests/security/test_encrypter.py +4 -4
  46. python_neva-3.7.0/tests/security/test_hash_manager.py +206 -0
  47. {python_neva-3.5.1 → python_neva-3.7.0}/tests/testing/test_event_fake.py +1 -0
  48. {python_neva-3.5.1 → python_neva-3.7.0}/tests/testing/test_fixtures.py +1 -1
  49. {python_neva-3.5.1 → python_neva-3.7.0}/tests/testing/test_refresh_database.py +2 -1
  50. {python_neva-3.5.1 → python_neva-3.7.0}/tests/testing/test_test_case.py +2 -0
  51. python_neva-3.7.0/uv.lock +1855 -0
  52. python_neva-3.7.0/wiki/architecture/KNOWN-DISCREPANCIES.md +23 -0
  53. python_neva-3.5.1/.pre-commit-config.yaml +0 -45
  54. python_neva-3.5.1/CHANGELOG.md +0 -48
  55. python_neva-3.5.1/ruff.toml +0 -43
  56. python_neva-3.5.1/tests/security/test_hash_manager.py +0 -92
  57. python_neva-3.5.1/uv.lock +0 -2436
  58. python_neva-3.5.1/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -41
  59. {python_neva-3.5.1 → python_neva-3.7.0}/.claude/settings.local.json +0 -0
  60. {python_neva-3.5.1 → python_neva-3.7.0}/.envrc +0 -0
  61. {python_neva-3.5.1 → python_neva-3.7.0}/.gitignore +0 -0
  62. {python_neva-3.5.1 → python_neva-3.7.0}/.python-version +0 -0
  63. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/__init__.py +0 -0
  64. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/facade.py +0 -0
  65. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/integrations/__init__.py +0 -0
  66. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/integrations/faststream.py +0 -0
  67. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/markers.py +0 -0
  68. {python_neva-3.5.1 → python_neva-3.7.0}/neva/arch/py.typed +0 -0
  69. {python_neva-3.5.1 → python_neva-3.7.0}/neva/config/__init__.py +0 -0
  70. {python_neva-3.5.1 → python_neva-3.7.0}/neva/config/base_providers.py +0 -0
  71. {python_neva-3.5.1 → python_neva-3.7.0}/neva/config/py.typed +0 -0
  72. {python_neva-3.5.1 → python_neva-3.7.0}/neva/database/__init__.py +0 -0
  73. {python_neva-3.5.1 → python_neva-3.7.0}/neva/database/config.py +0 -0
  74. {python_neva-3.5.1 → python_neva-3.7.0}/neva/database/py.typed +0 -0
  75. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/__init__.py +0 -0
  76. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/contracts/dispatcher.py +0 -0
  77. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/contracts/event.py +0 -0
  78. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/contracts/handler.py +0 -0
  79. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/contracts/listener.py +0 -0
  80. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/event.py +0 -0
  81. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/policy.py +0 -0
  82. {python_neva-3.5.1 → python_neva-3.7.0}/neva/events/py.typed +0 -0
  83. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/__init__.py +0 -0
  84. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/instrumentation/__init__.py +0 -0
  85. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/instrumentation/sqlalchemy.py +0 -0
  86. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/logging/__init__.py +0 -0
  87. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/logging/manager.py +0 -0
  88. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/middleware/__init__.py +0 -0
  89. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/middleware/correlation.py +0 -0
  90. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/middleware/profiler.py +0 -0
  91. {python_neva-3.5.1 → python_neva-3.7.0}/neva/obs/py.typed +0 -0
  92. {python_neva-3.5.1 → python_neva-3.7.0}/neva/polyfactory/factories.py +0 -0
  93. {python_neva-3.5.1 → python_neva-3.7.0}/neva/polyfactory/persistence.py +0 -0
  94. {python_neva-3.5.1 → python_neva-3.7.0}/neva/polyfactory/py.typed +0 -0
  95. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/__init__.py +0 -0
  96. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/encryption/__init__.py +0 -0
  97. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/encryption/encrypter.py +0 -0
  98. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/encryption/protocol.py +0 -0
  99. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/hashing/__init__.py +0 -0
  100. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/hashing/config.py +0 -0
  101. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/hashing/hashers/__init__.py +0 -0
  102. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/hashing/hashers/argon2.py +0 -0
  103. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/hashing/hashers/protocol.py +0 -0
  104. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/py.typed +0 -0
  105. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/tokens/__init__.py +0 -0
  106. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/tokens/generate_token.py +0 -0
  107. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/tokens/hash_token.py +0 -0
  108. {python_neva-3.5.1 → python_neva-3.7.0}/neva/security/tokens/verify_token.py +0 -0
  109. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/__init__.py +0 -0
  110. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/accessors.py +0 -0
  111. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/__init__.py +0 -0
  112. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/app.py +0 -0
  113. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/app.pyi +0 -0
  114. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/config.py +0 -0
  115. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/crypt.py +0 -0
  116. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/crypt.pyi +0 -0
  117. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/db.py +0 -0
  118. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/db.pyi +0 -0
  119. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/event.py +0 -0
  120. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/event.pyi +0 -0
  121. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/hash.py +0 -0
  122. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/log.py +0 -0
  123. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/facade/log.pyi +0 -0
  124. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/py.typed +0 -0
  125. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/results.py +0 -0
  126. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/strconv.py +0 -0
  127. {python_neva-3.5.1 → python_neva-3.7.0}/neva/support/time.py +0 -0
  128. {python_neva-3.5.1 → python_neva-3.7.0}/neva/testing/__init__.py +0 -0
  129. {python_neva-3.5.1 → python_neva-3.7.0}/neva/testing/fakes.py +0 -0
  130. {python_neva-3.5.1 → python_neva-3.7.0}/neva/testing/fixtures.py +0 -0
  131. {python_neva-3.5.1 → python_neva-3.7.0}/neva/testing/py.typed +0 -0
  132. {python_neva-3.5.1 → python_neva-3.7.0}/neva/testing/test_case.py +0 -0
  133. {python_neva-3.5.1 → python_neva-3.7.0}/scripts/retag-with-changelog.sh +0 -0
  134. {python_neva-3.5.1 → python_neva-3.7.0}/tests/__init__.py +0 -0
  135. {python_neva-3.5.1 → python_neva-3.7.0}/tests/arch/__init__.py +0 -0
  136. {python_neva-3.5.1 → python_neva-3.7.0}/tests/arch/test_cache.py +0 -0
  137. {python_neva-3.5.1 → python_neva-3.7.0}/tests/config/__init__.py +0 -0
  138. {python_neva-3.5.1 → python_neva-3.7.0}/tests/conftest.py +0 -0
  139. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/__init__.py +0 -0
  140. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/test_connection_manager.py +0 -0
  141. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/test_database_manager.py +0 -0
  142. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/test_edge_cases.py +0 -0
  143. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/test_multi_connection.py +0 -0
  144. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  145. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/test_transaction.py +0 -0
  146. {python_neva-3.5.1 → python_neva-3.7.0}/tests/database/test_transaction_registry.py +0 -0
  147. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/__init__.py +0 -0
  148. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/test_before_dispatch.py +0 -0
  149. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/test_binding.py +0 -0
  150. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/test_event.py +0 -0
  151. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/test_function_listener.py +0 -0
  152. {python_neva-3.5.1 → python_neva-3.7.0}/tests/events/test_immediate.py +0 -0
  153. {python_neva-3.5.1 → python_neva-3.7.0}/tests/obs/__init__.py +0 -0
  154. {python_neva-3.5.1 → python_neva-3.7.0}/tests/obs/test_correlation.py +0 -0
  155. {python_neva-3.5.1 → python_neva-3.7.0}/tests/obs/test_profiler.py +0 -0
  156. {python_neva-3.5.1 → python_neva-3.7.0}/tests/security/__init__.py +0 -0
  157. {python_neva-3.5.1 → python_neva-3.7.0}/tests/testing/__init__.py +0 -0
  158. {python_neva-3.5.1 → python_neva-3.7.0}/tests/testing/test_facade_restore.py +0 -0
  159. {python_neva-3.5.1 → python_neva-3.7.0}/wiki/architecture/configuration.md +0 -0
  160. {python_neva-3.5.1 → python_neva-3.7.0}/wiki/architecture/database-and-transactions.md +0 -0
  161. {python_neva-3.5.1 → python_neva-3.7.0}/wiki/architecture/dependency-injection.md +0 -0
  162. {python_neva-3.5.1 → python_neva-3.7.0}/wiki/architecture/events.md +0 -0
  163. {python_neva-3.5.1 → python_neva-3.7.0}/wiki/architecture/result-option.md +0 -0
  164. {python_neva-3.5.1 → python_neva-3.7.0}/wiki/architecture/testing.md +0 -0
@@ -0,0 +1,79 @@
1
+ ---
2
+ variables:
3
+ PYTHON_VERSION: "3.12"
4
+ UV_VERSION: "0.9.28"
5
+ BASE_LAYER: bookworm-slim
6
+ UV_LINK_MODE: copy
7
+ UV_CACHE_DIR: .cache/uv
8
+
9
+ stages:
10
+ - code_qa
11
+ - test
12
+ - deps
13
+
14
+ ##### Deps #####
15
+ .uv_base:
16
+ image: ghcr.io/astral-sh/uv:$UV_VERSION-python$PYTHON_VERSION-$BASE_LAYER
17
+ cache:
18
+ key:
19
+ files:
20
+ - uv.lock
21
+ paths:
22
+ - .cache/uv
23
+ - .venv
24
+ before_script:
25
+ - uv sync --frozen --all-extras
26
+
27
+ .default_rules:
28
+ rules:
29
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
30
+ - if: $CI_COMMIT_BRANCH == "main"
31
+
32
+ ##### Code QA #####
33
+ ruff:lint:
34
+ extends: [.uv_base, .default_rules]
35
+ stage: code_qa
36
+ script:
37
+ - uv run --frozen ruff check .
38
+
39
+ ruff:format:
40
+ extends: [.uv_base, .default_rules]
41
+ stage: code_qa
42
+ script:
43
+ - uv run --frozen ruff format --check .
44
+
45
+ pyrefly:
46
+ extends: [.uv_base, .default_rules]
47
+ stage: code_qa
48
+ script:
49
+ - uv run --frozen pyrefly check
50
+
51
+ ##### Test #####
52
+ pytest:
53
+ extends: [.uv_base, .default_rules]
54
+ stage: test
55
+ script:
56
+ - uv run --frozen pytest
57
+
58
+ ##### Deps contract #####
59
+ # Resolves every direct dependency at its declared floor, so a `>=` lower than
60
+ # what the code needs fails here instead of in a user's install. Three things
61
+ # keep this job honest, and dropping any one makes it pass without testing
62
+ # anything: it must not extend .uv_base, whose before_script installs the
63
+ # committed lock; --upgrade discards that lock as a preference set, without
64
+ # which uv reuses it and lowers nothing; --frozen on sync and run stops uv
65
+ # re-resolving at the default highest strategy and discarding the floors it
66
+ # just computed. Rewriting uv.lock here is expected and must never be
67
+ # committed.
68
+ deps:floors:
69
+ extends: .default_rules
70
+ image: ghcr.io/astral-sh/uv:$UV_VERSION-python$PYTHON_VERSION-$BASE_LAYER
71
+ stage: deps
72
+ cache:
73
+ key: floors
74
+ paths:
75
+ - .cache/uv
76
+ script:
77
+ - uv lock --upgrade --resolution lowest-direct
78
+ - uv sync --frozen --all-extras
79
+ - uv run --frozen pytest
@@ -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.16.5
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
@@ -0,0 +1,92 @@
1
+ ## 3.7.0 (2026-08-28)
2
+
3
+ ### ✨ Features
4
+
5
+ - **deps**: require only what the core imports
6
+
7
+ ### 🐛🚑️ Fixes
8
+
9
+ - **deps**: declare starlette directly
10
+
11
+ ### 💚👷 CI & Build
12
+
13
+ - add the pipeline with a lowest-direct floors gate
14
+
15
+ ### 📌➕⬇️➖⬆️ Dependencies
16
+
17
+ - upgrade all dependencies and adapt to ruff 0.16
18
+
19
+ ## 3.6.0 (2026-08-28)
20
+
21
+ ### ✨ Features
22
+
23
+ - **arch**: name the binding lifetimes, deprecate the implicit one
24
+ - **security**: add make_async and check_async
25
+ - **arch**: expose the container backing the current scope
26
+
27
+ ### 🐛🚑️ Fixes
28
+
29
+ - **events**: dispatch once per registration, and defer to the outermost transaction
30
+ - **config**: make freeze() actually freeze and merge() recursive
31
+ - **config**: stop config files from displacing real modules
32
+ - **arch**: run lifespan teardown even when the body raises
33
+ - **security**: stop requiring the optional hasher tuning config
34
+ - **arch**: make register() bind, or say why it cannot
35
+ - **database**: keep the transaction when the registry contextvar is unset
36
+
37
+ ### 📝💡 Documentation
38
+
39
+ - **general**: correct the Config stub, a lying docstring and a wrong annotation
40
+
41
+ ### 🔧🔨📦️ Configuration, Scripts, Packages
42
+
43
+ - **tooling**: replace mypy with pyrefly at strict
44
+
45
+ ## 3.5.1 (2026-08-24)
46
+
47
+ ### 🐛🚑️ Fixes
48
+
49
+ - **database**: run commit callbacks after the commit, not before
50
+
51
+ ### 📝💡 Documentation
52
+
53
+ - **general**: update docs
54
+ - **README**: very short section on contribution
55
+ - **docs**: extension ideas
56
+
57
+ ## 3.5.0 (2026-06-11)
58
+
59
+ ### ✨ Features
60
+
61
+ - **arch**: improve app facade stub
62
+ - **events**: make the event facade properly depend on the dispatcher contract
63
+ - **events**: new tests for events
64
+
65
+ ### 🐛🚑️ Fixes
66
+
67
+ - **events**: fixing handling of async before-dispatch hooks
68
+
69
+ ### 🏷️ Types
70
+
71
+ - **events**: improve event facade stub
72
+
73
+ ## 3.4.0 (2026-06-10)
74
+
75
+ ### ✨ Features
76
+
77
+ - **arch**: new from_context method
78
+ - **tooling**: placeholder commit
79
+
80
+ ### 💚👷 CI & Build
81
+
82
+ - **tooling**: default to base tag version for commitizen
83
+
84
+ ### 📝💡 Documentation
85
+
86
+ - **project**: update doc
87
+ - **core**: placeholde readme
88
+
89
+ ### 🔧🔨📦️ Configuration, Scripts, Packages
90
+
91
+ - **tooling**: update changelog script
92
+ - **project**: update versioning config
@@ -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,22 +1,26 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-neva
3
- Version: 3.5.1
3
+ Version: 3.7.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
7
7
  Requires-Dist: asyncpg>=0.30.0
8
8
  Requires-Dist: cryptography>=46.0.3
9
9
  Requires-Dist: dishka>=1.10.0
10
- Requires-Dist: fastapi[all]>=0.129.0
11
- Requires-Dist: faststream>=0.6.6
12
10
  Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.62b0
13
11
  Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
12
+ Requires-Dist: pydantic>=2.7
14
13
  Requires-Dist: pyinstrument>=5.1.1
15
14
  Requires-Dist: sqlalchemy[asyncio]>=2.0.0
15
+ Requires-Dist: starlette>=0.41
16
16
  Requires-Dist: structlog>=25.5.0
17
- Requires-Dist: typer>=0.21.1
17
+ Requires-Dist: typing-extensions>=4.13
18
18
  Provides-Extra: fastapi
19
- Requires-Dist: neva-fastapi>=1.0.0; extra == 'fastapi'
19
+ Requires-Dist: neva-fastapi>=1.1.1; extra == 'fastapi'
20
+ Provides-Extra: faststream
21
+ Requires-Dist: faststream>=0.6.6; extra == 'faststream'
22
+ Provides-Extra: polyfactory
23
+ Requires-Dist: polyfactory>=3.1.0; extra == 'polyfactory'
20
24
  Provides-Extra: testing
21
25
  Requires-Dist: pytest-asyncio>=0.25.3; extra == 'testing'
22
26
  Requires-Dist: pytest>=9.0.2; extra == 'testing'
@@ -58,7 +62,7 @@ independent repo, independently versioned and published.
58
62
  uv sync # install/refresh deps from uv.lock
59
63
  poe lint # ruff check
60
64
  poe fmt # ruff format
61
- poe tc # type-check (mypy)
65
+ poe tc # type-check (pyrefly)
62
66
  poe test # pytest
63
67
  poe test-cov # tests with coverage
64
68
 
@@ -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)