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.
- python_neva-3.6.0/.pre-commit-config.yaml +33 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/CHANGELOG.md +26 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/CLAUDE.md +7 -4
- {python_neva-3.5.1 → python_neva-3.6.0}/PKG-INFO +2 -2
- {python_neva-3.5.1 → python_neva-3.6.0}/README.md +1 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/application.py +88 -22
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/config.py +2 -2
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/scopes.py +0 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/service_provider.py +82 -7
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/loader.py +14 -2
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/repository.py +24 -5
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/connection.py +22 -6
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/manager.py +11 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/provider.py +8 -6
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/transaction.py +1 -2
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/__init__.py +2 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/dispatcher.py +12 -9
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/event_registry.py +40 -3
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/listener.py +2 -3
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/provider.py +3 -3
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/logging/provider.py +1 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/polyfactory/__init__.py +2 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hash_manager.py +49 -2
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hashers/bcrypt.py +1 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/provider.py +2 -2
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/config.pyi +6 -3
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/hash.pyi +45 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/pyproject.toml +14 -8
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/test_context.py +0 -2
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/test_extends.py +1 -1
- python_neva-3.6.0/tests/arch/test_lifetimes.py +179 -0
- python_neva-3.6.0/tests/arch/test_registration.py +156 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/test_scope.py +15 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/config/test_loader.py +40 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/config/test_repository.py +48 -0
- python_neva-3.6.0/tests/database/test_detached_lifespan.py +124 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_transaction_context.py +27 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/conftest.py +16 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_deferred.py +35 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_dispatch.py +1 -3
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_listen_on_parent_class.py +39 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/security/test_encrypter.py +3 -3
- python_neva-3.6.0/tests/security/test_hash_manager.py +206 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_event_fake.py +1 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_fixtures.py +1 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_refresh_database.py +2 -1
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_test_case.py +2 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/uv.lock +22 -165
- python_neva-3.6.0/wiki/architecture/KNOWN-DISCREPANCIES.md +23 -0
- python_neva-3.5.1/.pre-commit-config.yaml +0 -45
- python_neva-3.5.1/tests/security/test_hash_manager.py +0 -92
- python_neva-3.5.1/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -41
- {python_neva-3.5.1 → python_neva-3.6.0}/.claude/settings.local.json +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/.envrc +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/.gitignore +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/.python-version +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/facade.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/integrations/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/integrations/faststream.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/markers.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/arch/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/base_providers.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/config/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/config.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/database/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/dispatcher.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/event.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/handler.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/contracts/listener.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/event.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/policy.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/events/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/instrumentation/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/instrumentation/sqlalchemy.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/middleware/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/middleware/correlation.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/middleware/profiler.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/obs/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/polyfactory/factories.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/polyfactory/persistence.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/polyfactory/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/encryption/encrypter.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/config.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/tokens/generate_token.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/accessors.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/app.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/config.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/db.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/db.pyi +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/event.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/hash.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/log.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/results.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/strategy.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/strconv.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/support/time.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/fakes.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/fixtures.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/py.typed +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/neva/testing/test_case.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/ruff.toml +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/scripts/retag-with-changelog.sh +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/arch/test_cache.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/config/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/conftest.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_connection_manager.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_database_manager.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_edge_cases.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_sqlalchemy_integration.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_transaction.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_before_dispatch.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_binding.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_event.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_function_listener.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/events/test_immediate.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/obs/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/obs/test_correlation.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/obs/test_profiler.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/security/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/__init__.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/tests/testing/test_facade_restore.py +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/configuration.md +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/database-and-transactions.md +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/dependency-injection.md +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/events.md +0 -0
- {python_neva-3.5.1 → python_neva-3.6.0}/wiki/architecture/result-option.md +0 -0
- {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:**
|
|
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 #
|
|
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.
|
|
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.
|
|
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 (
|
|
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 (
|
|
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(
|
|
83
|
+
self.build_container()
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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]]]
|
|
@@ -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 =
|
|
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
|
|
202
|
+
"""Binds the source at REQUEST scope.
|
|
128
203
|
|
|
129
|
-
|
|
130
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
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]
|
|
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.
|
|
21
|
-
self.
|
|
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
|
|
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
|
-
|
|
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
|
|
26
|
+
class Transaction:
|
|
28
27
|
"""Represents a database transaction with callback support."""
|
|
29
28
|
|
|
30
29
|
conn_name: str
|