python-neva 3.6.0__tar.gz → 4.0.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.
- python_neva-4.0.0/.gitlab-ci.yml +79 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/.pre-commit-config.yaml +1 -1
- {python_neva-3.6.0 → python_neva-4.0.0}/CHANGELOG.md +50 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/CLAUDE.md +1 -1
- {python_neva-3.6.0 → python_neva-4.0.0}/PKG-INFO +11 -5
- {python_neva-3.6.0 → python_neva-4.0.0}/README.md +1 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/__init__.py +3 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/application.py +64 -35
- python_neva-4.0.0/neva/arch/config.py +49 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/integrations/faststream.py +9 -6
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/service_provider.py +11 -11
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/database/connection.py +8 -3
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/database/manager.py +7 -3
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/database/transaction.py +31 -13
- python_neva-4.0.0/neva/obs/__init__.py +9 -0
- python_neva-4.0.0/neva/security/tokens/generate_token.py +18 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/strategy.py +2 -2
- python_neva-4.0.0/neva/testing/__init__.py +7 -0
- python_neva-4.0.0/neva/testing/test_case.py +208 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/pyproject.toml +9 -5
- python_neva-4.0.0/ruff.toml +47 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/arch/test_cache.py +2 -1
- python_neva-4.0.0/tests/arch/test_config_shapes.py +54 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/arch/test_context.py +2 -1
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/arch/test_extends.py +2 -1
- python_neva-4.0.0/tests/arch/test_facade_root_nesting.py +46 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/arch/test_lifetimes.py +7 -10
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/arch/test_registration.py +2 -6
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/arch/test_scope.py +12 -6
- python_neva-4.0.0/tests/config/test_config_path_resolution.py +48 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/config/test_loader.py +2 -6
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_database_manager.py +0 -1
- python_neva-4.0.0/tests/database/test_transaction_callbacks.py +114 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/conftest.py +1 -1
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/test_listen_on_parent_class.py +1 -1
- python_neva-4.0.0/tests/events/test_listener_wiring.py +108 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/security/test_encrypter.py +15 -8
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/security/test_hash_manager.py +16 -12
- python_neva-4.0.0/tests/security/test_tokens.py +77 -0
- python_neva-4.0.0/tests/testing/test_application_reuse.py +67 -0
- python_neva-4.0.0/tests/testing/test_create_config_migration.py +76 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/testing/test_refresh_database.py +9 -3
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/testing/test_test_case.py +8 -7
- python_neva-4.0.0/uv.lock +1901 -0
- python_neva-4.0.0/wiki/architecture/KNOWN-DISCREPANCIES.md +41 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/wiki/architecture/configuration.md +32 -7
- {python_neva-3.6.0 → python_neva-4.0.0}/wiki/architecture/database-and-transactions.md +4 -1
- {python_neva-3.6.0 → python_neva-4.0.0}/wiki/architecture/dependency-injection.md +6 -5
- {python_neva-3.6.0 → python_neva-4.0.0}/wiki/architecture/testing.md +72 -14
- python_neva-3.6.0/neva/arch/config.py +0 -37
- python_neva-3.6.0/neva/obs/__init__.py +0 -13
- python_neva-3.6.0/neva/obs/middleware/__init__.py +0 -7
- python_neva-3.6.0/neva/obs/middleware/correlation.py +0 -68
- python_neva-3.6.0/neva/obs/middleware/profiler.py +0 -68
- python_neva-3.6.0/neva/security/tokens/generate_token.py +0 -16
- python_neva-3.6.0/neva/testing/__init__.py +0 -7
- python_neva-3.6.0/neva/testing/test_case.py +0 -101
- python_neva-3.6.0/ruff.toml +0 -43
- python_neva-3.6.0/tests/obs/__init__.py +0 -1
- python_neva-3.6.0/tests/obs/test_correlation.py +0 -127
- python_neva-3.6.0/tests/obs/test_profiler.py +0 -90
- python_neva-3.6.0/uv.lock +0 -2293
- python_neva-3.6.0/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -23
- {python_neva-3.6.0 → python_neva-4.0.0}/.claude/settings.local.json +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/.envrc +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/.gitignore +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/.python-version +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/facade.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/integrations/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/markers.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/arch/scopes.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/config/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/config/base_providers.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/config/loader.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/config/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/config/repository.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/database/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/database/config.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/database/provider.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/database/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/contracts/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/contracts/dispatcher.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/contracts/event.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/contracts/handler.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/contracts/listener.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/dispatcher.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/event.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/event_registry.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/listener.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/policy.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/provider.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/events/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/obs/instrumentation/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/obs/instrumentation/sqlalchemy.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/obs/logging/provider.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/obs/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/polyfactory/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/polyfactory/factories.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/polyfactory/persistence.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/polyfactory/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/encryption/encrypter.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/hashing/config.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/provider.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/accessors.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/app.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/config.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/db.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/db.pyi +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/event.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/hash.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/hash.pyi +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/log.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/results.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/strconv.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/support/time.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/testing/fakes.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/testing/fixtures.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/neva/testing/py.typed +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/scripts/retag-with-changelog.sh +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/arch/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/config/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/config/test_repository.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/conftest.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_connection_manager.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_detached_lifespan.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_edge_cases.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_sqlalchemy_integration.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_transaction.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_transaction_context.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/test_before_dispatch.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/test_binding.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/test_deferred.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/test_dispatch.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/test_event.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/test_function_listener.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/events/test_immediate.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/security/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/testing/__init__.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/testing/test_event_fake.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/testing/test_facade_restore.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/tests/testing/test_fixtures.py +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/wiki/architecture/events.md +0 -0
- {python_neva-3.6.0 → python_neva-4.0.0}/wiki/architecture/result-option.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
|
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
## 4.0.0 (2026-08-28)
|
|
2
|
+
|
|
3
|
+
### 💥 Boom
|
|
4
|
+
|
|
5
|
+
- **testing**: require create_config overrides to be classmethods
|
|
6
|
+
- **arch**: require an explicit lifetime, drop Application.bind
|
|
7
|
+
|
|
8
|
+
### ✨ Features
|
|
9
|
+
|
|
10
|
+
- **deps**: offer the ASGI middleware through the asgi extra
|
|
11
|
+
- **config**: scope the core config shapes to what the core reads, export them
|
|
12
|
+
|
|
13
|
+
### 🐛🚑️ Fixes
|
|
14
|
+
|
|
15
|
+
- **security**: name generate_token's argument nbytes, cover the token helpers
|
|
16
|
+
- **arch**: fail the boot when declared listeners cannot be wired
|
|
17
|
+
- **config**: default the config directory to ./config, not the project root
|
|
18
|
+
- **database**: accept sync transaction callbacks, never lose their failures
|
|
19
|
+
|
|
20
|
+
### ♻️ Refactorings
|
|
21
|
+
|
|
22
|
+
- **obs**: move the ASGI middleware to neva-asgi
|
|
23
|
+
- **arch**: describe the FastStream lifespan without starlette
|
|
24
|
+
|
|
25
|
+
### ⚡️ Performance
|
|
26
|
+
|
|
27
|
+
- **GED-16633**: build the TestCase application once per class
|
|
28
|
+
|
|
29
|
+
### 📝💡 Documentation
|
|
30
|
+
|
|
31
|
+
- **database**: un-deprecate register_engine, say what it is for
|
|
32
|
+
|
|
33
|
+
## 3.7.0 (2026-08-28)
|
|
34
|
+
|
|
35
|
+
### ✨ Features
|
|
36
|
+
|
|
37
|
+
- **deps**: require only what the core imports
|
|
38
|
+
|
|
39
|
+
### 🐛🚑️ Fixes
|
|
40
|
+
|
|
41
|
+
- **deps**: declare starlette directly
|
|
42
|
+
|
|
43
|
+
### 💚👷 CI & Build
|
|
44
|
+
|
|
45
|
+
- add the pipeline with a lowest-direct floors gate
|
|
46
|
+
|
|
47
|
+
### 📌➕⬇️➖⬆️ Dependencies
|
|
48
|
+
|
|
49
|
+
- upgrade all dependencies and adapt to ruff 0.16
|
|
50
|
+
|
|
1
51
|
## 3.6.0 (2026-08-28)
|
|
2
52
|
|
|
3
53
|
### ✨ Features
|
|
@@ -75,7 +75,7 @@ await Event.dispatch(UserCreated(event_id=1, user_id=42))
|
|
|
75
75
|
**Service Providers** - Register services with lifecycle hooks. Bindings
|
|
76
76
|
declare a lifetime by name — `singleton` (one per process), `scoped` (one per
|
|
77
77
|
REQUEST scope), `transient` (one per resolution), `instance` (an object you
|
|
78
|
-
already have). Bare `bind`
|
|
78
|
+
already have). Bare `bind` with neither `scope` nor `cache` raises `TypeError`:
|
|
79
79
|
```python
|
|
80
80
|
class MyProvider(ServiceProvider):
|
|
81
81
|
def register(self) -> Result[Self, str]:
|
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-neva
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.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
|
|
16
15
|
Requires-Dist: structlog>=25.5.0
|
|
17
|
-
Requires-Dist:
|
|
16
|
+
Requires-Dist: typing-extensions>=4.13
|
|
17
|
+
Provides-Extra: asgi
|
|
18
|
+
Requires-Dist: neva-asgi>=0.1.1; extra == 'asgi'
|
|
18
19
|
Provides-Extra: fastapi
|
|
19
|
-
Requires-Dist: neva-fastapi>=1.
|
|
20
|
+
Requires-Dist: neva-fastapi>=1.1.1; extra == 'fastapi'
|
|
21
|
+
Provides-Extra: faststream
|
|
22
|
+
Requires-Dist: faststream>=0.6.6; extra == 'faststream'
|
|
23
|
+
Provides-Extra: polyfactory
|
|
24
|
+
Requires-Dist: polyfactory>=3.1.0; extra == 'polyfactory'
|
|
20
25
|
Provides-Extra: testing
|
|
21
26
|
Requires-Dist: pytest-asyncio>=0.25.3; extra == 'testing'
|
|
22
27
|
Requires-Dist: pytest>=9.0.2; extra == 'testing'
|
|
@@ -44,6 +49,7 @@ independent repo, independently versioned and published.
|
|
|
44
49
|
| ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
|
|
45
50
|
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
|
|
46
51
|
| `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
|
|
52
|
+
| `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
|
|
47
53
|
| `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
|
|
48
54
|
| `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
|
|
49
55
|
| `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
|
|
@@ -20,6 +20,7 @@ independent repo, independently versioned and published.
|
|
|
20
20
|
| ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
|
|
21
21
|
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
|
|
22
22
|
| `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
|
|
23
|
+
| `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
|
|
23
24
|
| `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
|
|
24
25
|
| `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
|
|
25
26
|
| `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
|
|
@@ -5,6 +5,7 @@ and facade implementations.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from neva.arch.application import Application
|
|
8
|
+
from neva.arch.config import AppConfig, ProviderConfig
|
|
8
9
|
from neva.arch.facade import Facade
|
|
9
10
|
from neva.arch.service_provider import (
|
|
10
11
|
Bootable,
|
|
@@ -13,8 +14,10 @@ from neva.arch.service_provider import (
|
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
17
|
+
"AppConfig",
|
|
16
18
|
"Application",
|
|
17
19
|
"Bootable",
|
|
18
20
|
"Facade",
|
|
21
|
+
"ProviderConfig",
|
|
19
22
|
"ServiceProvider",
|
|
20
23
|
]
|
|
@@ -6,17 +6,16 @@ from collections.abc import AsyncGenerator, Sequence
|
|
|
6
6
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
7
7
|
from contextvars import ContextVar
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any,
|
|
9
|
+
from typing import Any, Self
|
|
10
10
|
|
|
11
11
|
import dishka
|
|
12
12
|
from dishka.provider import BaseProvider
|
|
13
|
-
from typing_extensions import deprecated
|
|
14
13
|
|
|
15
14
|
from neva.arch.facade import Facade
|
|
16
15
|
from neva.arch.scopes import BaseScope, Scope
|
|
17
16
|
from neva.arch.service_provider import Bootable, ServiceProvider
|
|
18
17
|
from neva.config.loader import ConfigLoader
|
|
19
|
-
from neva.support import Err, Ok, Result
|
|
18
|
+
from neva.support import Err, Ok, Result, Some
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
_current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
|
|
@@ -24,6 +23,26 @@ _current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
|
|
|
24
23
|
)
|
|
25
24
|
|
|
26
25
|
|
|
26
|
+
def _resolve_config_path(config_path: str | Path | None) -> Path:
|
|
27
|
+
"""Resolve the directory config files are loaded from.
|
|
28
|
+
|
|
29
|
+
The config directory is a dedicated folder, never the project root: every
|
|
30
|
+
``.py`` in it is imported to read its ``config`` dict, so pointing this at
|
|
31
|
+
a directory holding ordinary modules would execute them.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The explicit argument, else $NEVA_CONFIG_PATH, else ./config.
|
|
35
|
+
"""
|
|
36
|
+
if config_path is not None:
|
|
37
|
+
return Path(config_path)
|
|
38
|
+
|
|
39
|
+
env_path = os.getenv("NEVA_CONFIG_PATH")
|
|
40
|
+
if env_path:
|
|
41
|
+
return Path(env_path)
|
|
42
|
+
|
|
43
|
+
return Path.cwd() / "config"
|
|
44
|
+
|
|
45
|
+
|
|
27
46
|
class Application:
|
|
28
47
|
"""Base application for DI and facade injection."""
|
|
29
48
|
|
|
@@ -31,8 +50,9 @@ class Application:
|
|
|
31
50
|
"""Initialize the application and its bindings.
|
|
32
51
|
|
|
33
52
|
Args:
|
|
34
|
-
config_path: Path to the configuration directory. Defaults to
|
|
35
|
-
relative to the
|
|
53
|
+
config_path: Path to the configuration directory. Defaults to
|
|
54
|
+
``$NEVA_CONFIG_PATH``, else ``./config`` relative to the
|
|
55
|
+
current working directory.
|
|
36
56
|
|
|
37
57
|
Raises:
|
|
38
58
|
RuntimeError: If the application fails to initialize.
|
|
@@ -47,11 +67,7 @@ class Application:
|
|
|
47
67
|
self._container: dishka.AsyncContainer | None = None
|
|
48
68
|
self._booted: bool = False
|
|
49
69
|
|
|
50
|
-
configuration_path = (
|
|
51
|
-
config_path
|
|
52
|
-
if config_path is not None
|
|
53
|
-
else os.getenv("NEVA_CONFIG_PATH", default=Path.cwd())
|
|
54
|
-
)
|
|
70
|
+
configuration_path = _resolve_config_path(config_path)
|
|
55
71
|
match ConfigLoader(configuration_path).load_all():
|
|
56
72
|
case Err(e):
|
|
57
73
|
raise RuntimeError(f"Failed to register config: {e}")
|
|
@@ -150,21 +166,6 @@ class Application:
|
|
|
150
166
|
for provider in providers:
|
|
151
167
|
_ = self.register(provider)
|
|
152
168
|
|
|
153
|
-
@deprecated("Use the new bind() method directly with a Service Provider.")
|
|
154
|
-
def bind(
|
|
155
|
-
self,
|
|
156
|
-
source: type | Callable[..., Any],
|
|
157
|
-
*,
|
|
158
|
-
interface: type | None = None,
|
|
159
|
-
scope: BaseScope | None = None,
|
|
160
|
-
) -> None:
|
|
161
|
-
"""Binds a source to the container."""
|
|
162
|
-
_ = self.root_provider.provide(
|
|
163
|
-
source=source,
|
|
164
|
-
scope=scope,
|
|
165
|
-
provides=interface,
|
|
166
|
-
)
|
|
167
|
-
|
|
168
169
|
@property
|
|
169
170
|
def current_container(self) -> dishka.AsyncContainer:
|
|
170
171
|
"""The container backing resolution in the current context.
|
|
@@ -226,6 +227,11 @@ class Application:
|
|
|
226
227
|
@asynccontextmanager
|
|
227
228
|
async def lifespan(self) -> AsyncGenerator[None]:
|
|
228
229
|
"""Wire the facades and providers."""
|
|
230
|
+
# The facade root is a single process-global slot, so a short-lived
|
|
231
|
+
# application nested inside a longer-lived one must hand it back rather
|
|
232
|
+
# than clear it -- otherwise it disarms every facade for whatever the
|
|
233
|
+
# outer application still has to do.
|
|
234
|
+
previous_facade_application = Facade.get_facade_application()
|
|
229
235
|
Facade.set_facade_application(self)
|
|
230
236
|
self._booted = True
|
|
231
237
|
|
|
@@ -243,27 +249,50 @@ class Application:
|
|
|
243
249
|
if self._container is not None:
|
|
244
250
|
await self._container.close()
|
|
245
251
|
self._booted = False
|
|
246
|
-
|
|
252
|
+
match previous_facade_application:
|
|
253
|
+
case Some(application):
|
|
254
|
+
Facade.set_facade_application(application)
|
|
255
|
+
case _:
|
|
256
|
+
Facade.reset_facade_application()
|
|
247
257
|
|
|
248
258
|
def _bind_event_listeners(self) -> None:
|
|
249
259
|
"""Bind all listener classes declared in provider ``listen`` dicts."""
|
|
250
260
|
for provider in self.providers.values():
|
|
251
261
|
for listeners in provider.listen.values():
|
|
252
262
|
for listener_cls in listeners:
|
|
253
|
-
# Straight onto the root provider:
|
|
254
|
-
#
|
|
263
|
+
# Straight onto the root provider: these listeners are not
|
|
264
|
+
# a provider's own bindings, so they do not belong in one.
|
|
255
265
|
_ = self.root_provider.provide(listener_cls, scope=Scope.APP)
|
|
256
266
|
|
|
257
267
|
async def _wire_event_listeners(self) -> None:
|
|
258
|
-
"""Wire event-listener mappings from all providers onto the dispatcher.
|
|
268
|
+
"""Wire event-listener mappings from all providers onto the dispatcher.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
RuntimeError: If listeners are declared but the dispatcher cannot
|
|
272
|
+
be resolved. Booting on regardless would leave every one of
|
|
273
|
+
them unwired, so the events would simply never fire — with
|
|
274
|
+
nothing anywhere saying why.
|
|
275
|
+
"""
|
|
259
276
|
from neva.events.dispatcher import EventDispatcher
|
|
260
277
|
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
declared = [
|
|
279
|
+
(event_cls, listener_cls)
|
|
280
|
+
for provider in self.providers.values()
|
|
281
|
+
for event_cls, listeners in provider.listen.items()
|
|
282
|
+
for listener_cls in listeners
|
|
283
|
+
]
|
|
284
|
+
if not declared:
|
|
263
285
|
return
|
|
264
286
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
match await self.make_async(EventDispatcher):
|
|
288
|
+
case Err(e):
|
|
289
|
+
msg = (
|
|
290
|
+
f"Cannot wire {len(declared)} declared event listener(s): "
|
|
291
|
+
f"the EventDispatcher could not be resolved ({e}). "
|
|
292
|
+
"Register EventServiceProvider, or remove the 'listen' "
|
|
293
|
+
"mappings that need it."
|
|
294
|
+
)
|
|
295
|
+
raise RuntimeError(msg)
|
|
296
|
+
case Ok(dispatcher):
|
|
297
|
+
for event_cls, listener_cls in declared:
|
|
269
298
|
dispatcher.listen(event_cls, listener_cls)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Config shapes for the namespaces the core itself reads.
|
|
2
|
+
|
|
3
|
+
Annotate a config file with one of these to have a type checker catch typos
|
|
4
|
+
and wrong value types before boot:
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
# src/config/app.py
|
|
8
|
+
from neva.arch import AppConfig
|
|
9
|
+
|
|
10
|
+
config: AppConfig = {"providers": [MyProvider]}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Keys read by an integration package are declared by that package, not here —
|
|
14
|
+
`neva-fastapi` owns the OpenAPI and title keys of the `app` namespace, for
|
|
15
|
+
instance. The core only describes what the core consumes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import NotRequired, TypedDict
|
|
19
|
+
|
|
20
|
+
from neva.arch.service_provider import ServiceProvider
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AppConfig(TypedDict):
|
|
24
|
+
"""The `app` namespace keys the core reads.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
key: Base64-encoded 32-byte key for `Crypt`. Generate one with
|
|
28
|
+
`AesEncrypter.generate_key()`.
|
|
29
|
+
previous_keys: Superseded keys, still accepted when decrypting so a
|
|
30
|
+
key rotation does not orphan existing ciphertext.
|
|
31
|
+
providers: Service providers to register, appended after those from
|
|
32
|
+
the `providers` namespace.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
key: NotRequired[str]
|
|
36
|
+
previous_keys: NotRequired[list[str]]
|
|
37
|
+
providers: NotRequired[list[type[ServiceProvider]]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProviderConfig(TypedDict):
|
|
41
|
+
"""The `providers` namespace.
|
|
42
|
+
|
|
43
|
+
A dedicated namespace for the provider list, read before `app.providers`.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
providers: Service providers to register.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
providers: NotRequired[list[type[ServiceProvider]]]
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
"""FastStream wrapper for Neva."""
|
|
2
2
|
|
|
3
|
-
from collections.abc import AsyncGenerator, AsyncIterator
|
|
4
|
-
from contextlib import asynccontextmanager
|
|
3
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
|
4
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
import faststream
|
|
8
8
|
from faststream._internal.broker import BrokerUsecase
|
|
9
9
|
from faststream._internal.configs import BrokerConfig
|
|
10
|
-
from starlette.types import StatelessLifespan
|
|
11
10
|
|
|
12
11
|
from neva.arch import Application, ServiceProvider
|
|
13
12
|
from neva.support import Result
|
|
14
13
|
|
|
15
14
|
|
|
15
|
+
# Spelled out locally rather than borrowed from starlette.types: a messaging
|
|
16
|
+
# app's lifespan is not a web concern, and the core must not import an ASGI
|
|
17
|
+
# framework to describe one.
|
|
18
|
+
type FastStreamLifespan = Callable[["FastStream"], AbstractAsyncContextManager[None]]
|
|
19
|
+
|
|
20
|
+
|
|
16
21
|
class FastStream(faststream.FastStream):
|
|
17
22
|
"""FastStream wrapper for Neva."""
|
|
18
23
|
|
|
@@ -42,9 +47,7 @@ class FastStream(faststream.FastStream):
|
|
|
42
47
|
async with self.application.lifespan():
|
|
43
48
|
yield
|
|
44
49
|
|
|
45
|
-
def _create_lifespan(
|
|
46
|
-
self,
|
|
47
|
-
) -> StatelessLifespan["FastStream"]:
|
|
50
|
+
def _create_lifespan(self) -> FastStreamLifespan:
|
|
48
51
|
@asynccontextmanager
|
|
49
52
|
async def composed_lifespan(_: faststream.FastStream) -> AsyncIterator[None]:
|
|
50
53
|
async with self.lifespan():
|
|
@@ -8,7 +8,6 @@ container and optionally managing their lifecycle.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import abc
|
|
11
|
-
import warnings
|
|
12
11
|
from contextlib import AbstractAsyncContextManager
|
|
13
12
|
from typing import (
|
|
14
13
|
TYPE_CHECKING,
|
|
@@ -115,9 +114,9 @@ class ServiceProvider(abc.ABC):
|
|
|
115
114
|
`instance` — which say what they mean. Use `bind` directly only to
|
|
116
115
|
reach a scope those do not cover, and then pass `scope` explicitly.
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
A lifetime is required. Without one the binding would be a
|
|
118
|
+
process-wide singleton — the opposite of Laravel's transient `bind` —
|
|
119
|
+
with nothing at the call site saying so.
|
|
121
120
|
|
|
122
121
|
Args:
|
|
123
122
|
source: The class or factory to bind.
|
|
@@ -125,16 +124,17 @@ class ServiceProvider(abc.ABC):
|
|
|
125
124
|
scope: The dishka scope. Defaults to the provider's (APP).
|
|
126
125
|
when: Marker gating this binding.
|
|
127
126
|
cache: Whether to reuse one instance per scope. Defaults to True.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
TypeError: If neither `scope` nor `cache` is given.
|
|
128
130
|
"""
|
|
129
131
|
if scope is None and cache is None:
|
|
130
|
-
|
|
131
|
-
"bind()
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"transient() / instance(), or pass scope= or cache= yourself.",
|
|
135
|
-
DeprecationWarning,
|
|
136
|
-
stacklevel=2,
|
|
132
|
+
msg = (
|
|
133
|
+
"bind() requires an explicit lifetime: use singleton() for a "
|
|
134
|
+
"process-wide instance, or scoped() / transient() / "
|
|
135
|
+
"instance(), or pass scope= or cache= yourself."
|
|
137
136
|
)
|
|
137
|
+
raise TypeError(msg)
|
|
138
138
|
|
|
139
139
|
_ = self.provider.provide(
|
|
140
140
|
source=source,
|
|
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
|
|
|
7
7
|
from typing import final
|
|
8
8
|
|
|
9
9
|
from sqlalchemy.ext import asyncio
|
|
10
|
+
from structlog.stdlib import get_logger
|
|
10
11
|
|
|
11
12
|
from neva.database.transaction import BoundTransaction, TransactionState
|
|
12
13
|
from neva.obs import LogManager
|
|
@@ -100,7 +101,7 @@ class ConnectionManager:
|
|
|
100
101
|
|
|
101
102
|
@property
|
|
102
103
|
def engine(self) -> asyncio.AsyncEngine:
|
|
103
|
-
"""
|
|
104
|
+
"""The underlying engine."""
|
|
104
105
|
return self._engine
|
|
105
106
|
|
|
106
107
|
async def dispose(self) -> None:
|
|
@@ -147,9 +148,13 @@ class ConnectionManager:
|
|
|
147
148
|
else await tx.execute_on_rollback_callbacks()
|
|
148
149
|
)
|
|
149
150
|
label = "commit" if committed else "rollback"
|
|
151
|
+
# A post-commit callback cannot un-commit the transaction, so its
|
|
152
|
+
# failure is reported rather than raised. It must never be lost: fall
|
|
153
|
+
# back to a module logger when the manager has none.
|
|
154
|
+
logger = self.logger if self.logger is not None else get_logger(__name__)
|
|
150
155
|
for result in results:
|
|
151
|
-
if result.is_err
|
|
152
|
-
|
|
156
|
+
if result.is_err:
|
|
157
|
+
logger.error(
|
|
153
158
|
f"{label} callback failed",
|
|
154
159
|
error=result.err().unwrap(),
|
|
155
160
|
connection=self.name,
|
|
@@ -5,7 +5,6 @@ from contextlib import asynccontextmanager
|
|
|
5
5
|
from typing import final
|
|
6
6
|
|
|
7
7
|
from sqlalchemy.ext import asyncio
|
|
8
|
-
from typing_extensions import deprecated
|
|
9
8
|
|
|
10
9
|
from neva.database.config import ConnectionConfig
|
|
11
10
|
from neva.database.connection import ConnectionManager, TransactionContext
|
|
@@ -24,9 +23,14 @@ class DatabaseManager:
|
|
|
24
23
|
self._logger = logger
|
|
25
24
|
self._connections: dict[str, ConnectionManager] = {}
|
|
26
25
|
|
|
27
|
-
@deprecated("Please use DatabaseManager.register_connection instead.")
|
|
28
26
|
def register_engine(self, name: str, engine: asyncio.AsyncEngine) -> None:
|
|
29
|
-
"""Register
|
|
27
|
+
"""Register a connection around an engine you already built.
|
|
28
|
+
|
|
29
|
+
Prefer `register_connection`, which builds the engine from config. Use
|
|
30
|
+
this when the caller must own the engine — a test that creates the
|
|
31
|
+
schema on an in-memory database has to hand over that exact engine,
|
|
32
|
+
since a second engine to `sqlite:///:memory:` is a second, empty
|
|
33
|
+
database.
|
|
30
34
|
|
|
31
35
|
Args:
|
|
32
36
|
name: The connection name.
|
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
"""Transaction management systems."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from enum import Enum, auto
|
|
7
|
-
from typing import
|
|
8
|
+
from typing import Self
|
|
8
9
|
|
|
9
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
11
|
|
|
11
|
-
from neva.support import Err, Nothing, Option, Result, Some
|
|
12
|
+
from neva.support import Err, Nothing, Ok, Option, Result, Some
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
type
|
|
15
|
+
type TransactionOutcome = Result[None, str] | None
|
|
16
|
+
type TransactionCallback = Callable[
|
|
17
|
+
[], TransactionOutcome | Awaitable[TransactionOutcome]
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _run_callback(callback: TransactionCallback) -> Result[None, str]:
|
|
22
|
+
"""Invoke a transaction callback, whether it is sync or async.
|
|
23
|
+
|
|
24
|
+
A callback may return a ``Result`` to report its own failure, or ``None``
|
|
25
|
+
to say "done, nothing to report" — which is what a bare side-effecting
|
|
26
|
+
lambda does.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The callback's own Result, Ok(None) if it returned None, or Err if it
|
|
30
|
+
raised.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
outcome = callback()
|
|
34
|
+
if inspect.isawaitable(outcome):
|
|
35
|
+
outcome = await outcome
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return Err(f"Callback raised: {e}")
|
|
38
|
+
return Ok(None) if outcome is None else outcome
|
|
15
39
|
|
|
16
40
|
|
|
17
41
|
class TransactionState(Enum):
|
|
@@ -77,10 +101,7 @@ class Transaction:
|
|
|
77
101
|
"""
|
|
78
102
|
results: list[Result[None, str]] = []
|
|
79
103
|
for callback in self._on_commit:
|
|
80
|
-
|
|
81
|
-
results.append(await callback())
|
|
82
|
-
except Exception as e:
|
|
83
|
-
results.append(Err(f"Callback raised: {e}"))
|
|
104
|
+
results.append(await _run_callback(callback))
|
|
84
105
|
self._on_commit.clear()
|
|
85
106
|
return results
|
|
86
107
|
|
|
@@ -92,10 +113,7 @@ class Transaction:
|
|
|
92
113
|
"""
|
|
93
114
|
results: list[Result[None, str]] = []
|
|
94
115
|
for callback in self._on_rollback:
|
|
95
|
-
|
|
96
|
-
results.append(await callback())
|
|
97
|
-
except Exception as e:
|
|
98
|
-
results.append(Err(f"Callback raised: {e}"))
|
|
116
|
+
results.append(await _run_callback(callback))
|
|
99
117
|
self._on_rollback.clear()
|
|
100
118
|
return results
|
|
101
119
|
|
|
@@ -134,7 +152,7 @@ class BoundTransaction(Transaction):
|
|
|
134
152
|
|
|
135
153
|
@property
|
|
136
154
|
def is_accessible_from_current_task(self) -> bool:
|
|
137
|
-
"""
|
|
155
|
+
"""Whether the current asyncio task may use this transaction's session.
|
|
138
156
|
|
|
139
157
|
Returns:
|
|
140
158
|
True if the transaction was created by the current task, or if
|