python-neva 1.0.5__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_neva-1.0.5 → python_neva-2.1.0}/PKG-INFO +4 -3
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/facade.py +83 -1
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/database/__init__.py +4 -2
- python_neva-2.1.0/neva/database/config.py +20 -0
- python_neva-2.1.0/neva/database/connection.py +199 -0
- python_neva-2.1.0/neva/database/manager.py +111 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/database/provider.py +11 -10
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/database/transaction.py +34 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/__init__.py +2 -0
- python_neva-2.1.0/neva/support/facade/db.pyi +69 -0
- python_neva-2.1.0/neva/support/facade/event.py +53 -0
- python_neva-2.1.0/neva/testing/__init__.py +7 -0
- python_neva-2.1.0/neva/testing/fakes.py +78 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/testing/test_case.py +39 -7
- {python_neva-1.0.5 → python_neva-2.1.0}/pyproject.toml +4 -3
- python_neva-2.1.0/tests/database/conftest.py +10 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_connection_manager.py +14 -1
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_database_manager.py +39 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_edge_cases.py +18 -50
- python_neva-2.1.0/tests/database/test_sqlalchemy_integration.py +179 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_transaction.py +0 -1
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_transaction_context.py +44 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/conftest.py +0 -19
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/test_deferred.py +67 -5
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/test_dispatch.py +65 -0
- python_neva-2.1.0/tests/events/test_event.py +40 -0
- python_neva-2.1.0/tests/events/test_event_registry.py +91 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/test_function_listener.py +3 -3
- python_neva-2.1.0/tests/testing/test_event_fake.py +111 -0
- python_neva-2.1.0/tests/testing/test_facade_restore.py +57 -0
- python_neva-2.1.0/tests/testing/test_refresh_database.py +85 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/uv.lock +197 -78
- python_neva-1.0.5/neva/database/config.py +0 -59
- python_neva-1.0.5/neva/database/connection.py +0 -124
- python_neva-1.0.5/neva/database/manager.py +0 -44
- python_neva-1.0.5/neva/support/facade/db.pyi +0 -39
- python_neva-1.0.5/neva/support/facade/event.py +0 -15
- python_neva-1.0.5/neva/testing/__init__.py +0 -6
- python_neva-1.0.5/tests/database/conftest.py +0 -30
- {python_neva-1.0.5 → python_neva-2.1.0}/.envrc +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/.gitignore +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/.pre-commit-config.yaml +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/.python-version +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/README.md +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/app.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/application.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/config.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/faststream.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/service_provider.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/base_providers.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/loader.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/provider.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/repository.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/dispatcher.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/event.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/event_registry.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/listener.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/provider.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/logging/provider.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/middleware/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/middleware/correlation.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/middleware/profiler.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/py.typed +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/encryption/encrypter.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/config.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/provider.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/tokens/generate_token.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/accessors.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/app.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/config.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/db.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/hash.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/hash.pyi +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/log.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/results.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/strategy.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/strconv.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/time.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/testing/fixtures.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/neva/testing/http.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/ruff.toml +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/arch/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/arch/test_scope.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/config/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/config/test_loader.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/config/test_repository.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/conftest.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/test_immediate.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/obs/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/obs/test_correlation.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/obs/test_profiler.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/security/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/security/test_encrypter.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/security/test_hash_manager.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/testing/__init__.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/testing/test_fixtures.py +0 -0
- {python_neva-1.0.5 → python_neva-2.1.0}/tests/testing/test_test_case.py +0 -0
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-neva
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: aiosqlite>=0.20.0
|
|
7
|
+
Requires-Dist: asyncpg>=0.30.0
|
|
6
8
|
Requires-Dist: cryptography>=46.0.3
|
|
7
9
|
Requires-Dist: dishka>=1.7.2
|
|
8
10
|
Requires-Dist: fastapi[all]>=0.129.0
|
|
9
11
|
Requires-Dist: faststream>=0.6.6
|
|
10
|
-
Requires-Dist: flexmock>=0.13.0
|
|
11
12
|
Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
|
|
12
13
|
Requires-Dist: pyinstrument>=5.1.1
|
|
14
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
13
15
|
Requires-Dist: structlog>=25.5.0
|
|
14
|
-
Requires-Dist: tortoise-orm[accel]>=1.1.4
|
|
15
16
|
Requires-Dist: typer>=0.21.1
|
|
16
17
|
Provides-Extra: testing
|
|
17
18
|
Requires-Dist: pytest-asyncio>=0.25.3; extra == 'testing'
|
|
@@ -6,9 +6,12 @@ injection container, enabling convenient access without explicit dependency inje
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from abc import ABC, ABCMeta, abstractmethod
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
9
11
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
12
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
10
13
|
|
|
11
|
-
from neva import Option, Result, from_optional
|
|
14
|
+
from neva import Ok, Option, Result, from_optional
|
|
12
15
|
from neva.support.accessors import get_attr
|
|
13
16
|
|
|
14
17
|
|
|
@@ -25,6 +28,7 @@ class FacadeMeta(ABCMeta):
|
|
|
25
28
|
"""
|
|
26
29
|
|
|
27
30
|
_app: ClassVar["Application | None"] = None
|
|
31
|
+
_fake_instances: ClassVar[dict[type, object]] = {}
|
|
28
32
|
|
|
29
33
|
def __getattr__(cls, name: str) -> object:
|
|
30
34
|
"""Intercept attribute access and forward to the resolved service.
|
|
@@ -111,6 +115,65 @@ class FacadeMeta(ABCMeta):
|
|
|
111
115
|
"""
|
|
112
116
|
return from_optional(cls._app)
|
|
113
117
|
|
|
118
|
+
def swap(cls, instance: object) -> None:
|
|
119
|
+
"""Replace the resolved instance with a specific object for testing.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
instance: The object to use in place of the real service.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
FacadeMeta._fake_instances[cls] = instance
|
|
126
|
+
|
|
127
|
+
def fake(cls) -> AsyncMock:
|
|
128
|
+
"""Replace the resolved instance with an AsyncMock for testing.
|
|
129
|
+
|
|
130
|
+
The mock is spec'd against the real service class so only valid
|
|
131
|
+
attributes are accessible. Returns the mock for setting up
|
|
132
|
+
expectations or assertions.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The AsyncMock instance installed as the facade root.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
mock = AsyncMock(spec=cls.get_facade_accessor())
|
|
139
|
+
cls.swap(mock)
|
|
140
|
+
return mock
|
|
141
|
+
|
|
142
|
+
def spy(cls) -> MagicMock:
|
|
143
|
+
"""Wrap the real service instance in a spy for testing.
|
|
144
|
+
|
|
145
|
+
The spy records all calls while delegating to the real implementation.
|
|
146
|
+
Async methods on the real service are automatically wrapped with
|
|
147
|
+
AsyncMock semantics.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The MagicMock spy installed as the facade root.
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
app = cls._get_app().ok_or(f"Cannot spy on {cls.__name__}: no application set.")
|
|
154
|
+
real = app.and_then(lambda a: a.make(cls.get_facade_accessor())).unwrap()
|
|
155
|
+
spy_obj = MagicMock(spec=type(real), wraps=real)
|
|
156
|
+
cls.swap(spy_obj)
|
|
157
|
+
return spy_obj
|
|
158
|
+
|
|
159
|
+
def restore(cls) -> None:
|
|
160
|
+
"""Restore the facade to its real service, removing any fake or spy."""
|
|
161
|
+
FacadeMeta._fake_instances.pop(cls, None)
|
|
162
|
+
|
|
163
|
+
@contextmanager
|
|
164
|
+
def faking(cls) -> Iterator[AsyncMock]:
|
|
165
|
+
"""Context manager that fakes the facade and restores it on exit.
|
|
166
|
+
|
|
167
|
+
Yields:
|
|
168
|
+
The AsyncMock installed as the facade root.
|
|
169
|
+
|
|
170
|
+
"""
|
|
171
|
+
mock = cls.fake()
|
|
172
|
+
try:
|
|
173
|
+
yield mock
|
|
174
|
+
finally:
|
|
175
|
+
cls.restore()
|
|
176
|
+
|
|
114
177
|
def _resolve_facade_instance[T](
|
|
115
178
|
cls,
|
|
116
179
|
app: "Application",
|
|
@@ -118,6 +181,9 @@ class FacadeMeta(ABCMeta):
|
|
|
118
181
|
) -> Result[T, str]:
|
|
119
182
|
"""Resolve the service instance from the container.
|
|
120
183
|
|
|
184
|
+
If a fake or swap has been registered for this facade, it is returned
|
|
185
|
+
directly without touching the container.
|
|
186
|
+
|
|
121
187
|
Args:
|
|
122
188
|
app: The application instance.
|
|
123
189
|
interface: The interface being facaded.
|
|
@@ -126,6 +192,8 @@ class FacadeMeta(ABCMeta):
|
|
|
126
192
|
Result containing the resolved service or an error message.
|
|
127
193
|
|
|
128
194
|
"""
|
|
195
|
+
if cls in FacadeMeta._fake_instances:
|
|
196
|
+
return Ok(FacadeMeta._fake_instances[cls])
|
|
129
197
|
return app.make(interface)
|
|
130
198
|
|
|
131
199
|
|
|
@@ -174,3 +242,17 @@ class Facade(ABC, metaclass=FacadeMeta):
|
|
|
174
242
|
rarely need to be called manually.
|
|
175
243
|
"""
|
|
176
244
|
cls._app = None
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def restore_all(cls) -> None:
|
|
248
|
+
"""Restore all faked or swapped facades to their real services.
|
|
249
|
+
|
|
250
|
+
Intended for test teardown. Typical usage is an autouse fixture::
|
|
251
|
+
|
|
252
|
+
@pytest.fixture(autouse=True)
|
|
253
|
+
def restore_facades():
|
|
254
|
+
yield
|
|
255
|
+
Facade.restore_all()
|
|
256
|
+
|
|
257
|
+
"""
|
|
258
|
+
FacadeMeta._fake_instances.clear()
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""Database module."""
|
|
2
2
|
|
|
3
|
-
from neva.database.config import DatabaseConfig
|
|
3
|
+
from neva.database.config import ConnectionConfig, DatabaseConfig
|
|
4
4
|
from neva.database.connection import TransactionContext
|
|
5
5
|
from neva.database.manager import DatabaseManager
|
|
6
6
|
from neva.database.provider import DatabaseServiceProvider
|
|
7
|
-
from neva.database.transaction import Transaction
|
|
7
|
+
from neva.database.transaction import BoundTransaction, Transaction
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
__all__ = [
|
|
11
|
+
"BoundTransaction",
|
|
12
|
+
"ConnectionConfig",
|
|
11
13
|
"DatabaseConfig",
|
|
12
14
|
"DatabaseManager",
|
|
13
15
|
"DatabaseServiceProvider",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Database configs."""
|
|
2
|
+
|
|
3
|
+
from typing import NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConnectionConfig(TypedDict):
|
|
7
|
+
"""Configuration for a single database connection."""
|
|
8
|
+
|
|
9
|
+
url: str
|
|
10
|
+
pool_size: NotRequired[int]
|
|
11
|
+
max_overflow: NotRequired[int]
|
|
12
|
+
pool_recycle: NotRequired[int]
|
|
13
|
+
pool_pre_ping: NotRequired[bool]
|
|
14
|
+
echo: NotRequired[bool]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DatabaseConfig(TypedDict):
|
|
18
|
+
"""Database config."""
|
|
19
|
+
|
|
20
|
+
connections: dict[str, ConnectionConfig]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Connection manager."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import final
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
10
|
+
|
|
11
|
+
from neva import Nothing, Option, Some, from_optional
|
|
12
|
+
from neva.database.transaction import BoundTransaction, Transaction, TransactionState
|
|
13
|
+
from neva.obs import LogManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TransactionRegistry:
|
|
18
|
+
"""A registry of ongoing transactions."""
|
|
19
|
+
|
|
20
|
+
by_connection: dict[str, Transaction] = field(default_factory=dict)
|
|
21
|
+
stack: list[Transaction] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
def extend(self, transaction: Transaction) -> "TransactionRegistry":
|
|
24
|
+
"""Adds a new transaction to the registry.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A new registry with the new transaction added.
|
|
28
|
+
"""
|
|
29
|
+
return TransactionRegistry(
|
|
30
|
+
by_connection={**self.by_connection, transaction.conn_name: transaction},
|
|
31
|
+
stack=[*self.stack, transaction],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_tx_registry: ContextVar[TransactionRegistry | None] = ContextVar(
|
|
36
|
+
"_tx_registry",
|
|
37
|
+
default=None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TransactionContext:
|
|
42
|
+
"""Transaction context."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
if _tx_registry.get() is None:
|
|
46
|
+
_ = _tx_registry.set(TransactionRegistry())
|
|
47
|
+
|
|
48
|
+
def current(self, connection: str | None = None) -> Option[Transaction]:
|
|
49
|
+
"""Get the current transaction.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The current transaction, if any.
|
|
53
|
+
"""
|
|
54
|
+
registry = _tx_registry.get()
|
|
55
|
+
if registry is None:
|
|
56
|
+
return Nothing()
|
|
57
|
+
|
|
58
|
+
if connection is not None:
|
|
59
|
+
tx = registry.by_connection.get(connection)
|
|
60
|
+
return from_optional(tx)
|
|
61
|
+
|
|
62
|
+
if registry.stack:
|
|
63
|
+
return Some(registry.stack[-1])
|
|
64
|
+
return Nothing()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@final
|
|
68
|
+
class ConnectionManager:
|
|
69
|
+
"""Connection manager."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
name: str,
|
|
74
|
+
tx_context: TransactionContext,
|
|
75
|
+
logger: LogManager | None,
|
|
76
|
+
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.name = name
|
|
79
|
+
self.tx_context = tx_context
|
|
80
|
+
self.logger = logger
|
|
81
|
+
self.session_factory = session_factory
|
|
82
|
+
|
|
83
|
+
@asynccontextmanager
|
|
84
|
+
async def _scoped(self, tx: Transaction) -> AsyncIterator[None]:
|
|
85
|
+
"""Manage registry, state transitions, and callbacks for a transaction.
|
|
86
|
+
|
|
87
|
+
Yields:
|
|
88
|
+
None
|
|
89
|
+
"""
|
|
90
|
+
old_registry = _tx_registry.get()
|
|
91
|
+
token = (
|
|
92
|
+
_tx_registry.set(old_registry.extend(tx))
|
|
93
|
+
if old_registry is not None
|
|
94
|
+
else _tx_registry.set(TransactionRegistry())
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
yield
|
|
98
|
+
if tx.rollback_requested:
|
|
99
|
+
tx.state = TransactionState.ROLLED_BACK
|
|
100
|
+
if tx.is_root:
|
|
101
|
+
for result in await tx.execute_on_rollback_callbacks():
|
|
102
|
+
if result.is_err and self.logger is not None:
|
|
103
|
+
self.logger.error(
|
|
104
|
+
"rollback callback failed",
|
|
105
|
+
error=result.err().unwrap(),
|
|
106
|
+
connection=self.name,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
tx.state = TransactionState.COMMITTED
|
|
110
|
+
if tx.is_root:
|
|
111
|
+
for result in await tx.execute_on_commit_callbacks():
|
|
112
|
+
if result.is_err and self.logger is not None:
|
|
113
|
+
self.logger.error(
|
|
114
|
+
"commit callback failed",
|
|
115
|
+
error=result.err().unwrap(),
|
|
116
|
+
connection=self.name,
|
|
117
|
+
)
|
|
118
|
+
except BaseException:
|
|
119
|
+
tx.state = TransactionState.ROLLED_BACK
|
|
120
|
+
if tx.is_root:
|
|
121
|
+
for result in await tx.execute_on_rollback_callbacks():
|
|
122
|
+
if result.is_err and self.logger is not None:
|
|
123
|
+
self.logger.error(
|
|
124
|
+
"rollback callback failed",
|
|
125
|
+
error=result.err().unwrap(),
|
|
126
|
+
connection=self.name,
|
|
127
|
+
)
|
|
128
|
+
raise
|
|
129
|
+
finally:
|
|
130
|
+
_tx_registry.reset(token)
|
|
131
|
+
|
|
132
|
+
@asynccontextmanager
|
|
133
|
+
async def transaction(self) -> AsyncIterator[Transaction]:
|
|
134
|
+
"""Open a new unbound transaction (no database session).
|
|
135
|
+
|
|
136
|
+
Intended for use in unit tests where transaction lifecycle, callbacks,
|
|
137
|
+
and context isolation need to be exercised without a real database.
|
|
138
|
+
|
|
139
|
+
Yields:
|
|
140
|
+
Transaction: An unbound transaction with no associated session.
|
|
141
|
+
"""
|
|
142
|
+
parent = self.tx_context.current(self.name)
|
|
143
|
+
tx = Transaction(self.name)
|
|
144
|
+
tx.parent = parent
|
|
145
|
+
async with self._scoped(tx):
|
|
146
|
+
yield tx
|
|
147
|
+
|
|
148
|
+
@asynccontextmanager
|
|
149
|
+
async def begin(self) -> AsyncIterator[BoundTransaction]:
|
|
150
|
+
"""Open a new bound transaction with an active database session.
|
|
151
|
+
|
|
152
|
+
For nested calls on the same connection, reuses the parent session
|
|
153
|
+
and begins a savepoint instead of a full transaction.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
RuntimeError: If no engine has been registered for this connection.
|
|
157
|
+
|
|
158
|
+
Yields:
|
|
159
|
+
BoundTransaction: A transaction with a guaranteed non-null session.
|
|
160
|
+
"""
|
|
161
|
+
if self.session_factory is None:
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"No engine registered for connection '{self.name}'. "
|
|
164
|
+
+ "Call register_engine() before calling begin()."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
parent = self.tx_context.current(self.name)
|
|
168
|
+
|
|
169
|
+
match parent:
|
|
170
|
+
case Some(parent_tx) if isinstance(parent_tx, BoundTransaction):
|
|
171
|
+
tx = Transaction(self.name)
|
|
172
|
+
tx.parent = parent
|
|
173
|
+
bound = tx.begin(parent_tx.session)
|
|
174
|
+
sp = await parent_tx.session.begin_nested()
|
|
175
|
+
try:
|
|
176
|
+
async with self._scoped(bound):
|
|
177
|
+
yield bound
|
|
178
|
+
if bound.rollback_requested:
|
|
179
|
+
await sp.rollback()
|
|
180
|
+
else:
|
|
181
|
+
await sp.commit()
|
|
182
|
+
except BaseException:
|
|
183
|
+
await sp.rollback()
|
|
184
|
+
raise
|
|
185
|
+
case _:
|
|
186
|
+
tx = Transaction(self.name)
|
|
187
|
+
tx.parent = Nothing()
|
|
188
|
+
async with self.session_factory() as session:
|
|
189
|
+
bound = tx.begin(session)
|
|
190
|
+
try:
|
|
191
|
+
async with self._scoped(bound):
|
|
192
|
+
yield bound
|
|
193
|
+
if bound.rollback_requested:
|
|
194
|
+
await session.rollback()
|
|
195
|
+
else:
|
|
196
|
+
await session.commit()
|
|
197
|
+
except BaseException:
|
|
198
|
+
await session.rollback()
|
|
199
|
+
raise
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Database manager."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
8
|
+
|
|
9
|
+
from neva import Nothing, Option, Some
|
|
10
|
+
from neva.database.connection import ConnectionManager, TransactionContext
|
|
11
|
+
from neva.database.transaction import BoundTransaction, Transaction
|
|
12
|
+
from neva.obs import LogManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class DatabaseManager:
|
|
17
|
+
"""Database manager."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, tx_context: TransactionContext, logger: LogManager) -> None:
|
|
20
|
+
self._tx_context = tx_context
|
|
21
|
+
self._logger = logger
|
|
22
|
+
self._connections: dict[str, ConnectionManager] = {}
|
|
23
|
+
self._engines: dict[str, AsyncEngine] = {}
|
|
24
|
+
self._session_factories: dict[str, async_sessionmaker[AsyncSession]] = {}
|
|
25
|
+
|
|
26
|
+
def register_engine(self, name: str, engine: AsyncEngine) -> None:
|
|
27
|
+
"""Register an engine and create a session factory for a connection.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
name: The connection name.
|
|
31
|
+
engine: The async engine.
|
|
32
|
+
"""
|
|
33
|
+
self._engines[name] = engine
|
|
34
|
+
self._session_factories[name] = async_sessionmaker(
|
|
35
|
+
bind=engine,
|
|
36
|
+
expire_on_commit=False,
|
|
37
|
+
)
|
|
38
|
+
_ = self._connections.pop(name, None)
|
|
39
|
+
|
|
40
|
+
def connection(self, name: str) -> ConnectionManager:
|
|
41
|
+
"""Returns a connection manager for the given name."""
|
|
42
|
+
return self._connections.setdefault(
|
|
43
|
+
name,
|
|
44
|
+
ConnectionManager(
|
|
45
|
+
name,
|
|
46
|
+
self._tx_context,
|
|
47
|
+
self._logger,
|
|
48
|
+
self._session_factories.get(name),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def current(self, connection: str | None = None) -> Option[Transaction]:
|
|
53
|
+
"""Returns the current transaction.
|
|
54
|
+
|
|
55
|
+
If no connection is specified, this will return the most recent transaction on
|
|
56
|
+
any connection. This is something to keep in mind.
|
|
57
|
+
"""
|
|
58
|
+
return self._tx_context.current(connection)
|
|
59
|
+
|
|
60
|
+
def session(self, connection: str | None = None) -> Option[AsyncSession]:
|
|
61
|
+
"""Returns the current session for a connection.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
connection: The connection name. Defaults to "default".
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The current session, if any. Returns Nothing if there is no active
|
|
68
|
+
bound transaction on the given connection.
|
|
69
|
+
"""
|
|
70
|
+
match self.current(connection):
|
|
71
|
+
case Some(tx) if isinstance(tx, BoundTransaction):
|
|
72
|
+
return Some(tx.session)
|
|
73
|
+
case _:
|
|
74
|
+
return Nothing()
|
|
75
|
+
|
|
76
|
+
@asynccontextmanager
|
|
77
|
+
async def transaction(self, name: str = "default") -> AsyncIterator[Transaction]:
|
|
78
|
+
"""Open an unbound transaction on the named connection.
|
|
79
|
+
|
|
80
|
+
Intended for unit tests that exercise transaction lifecycle and callbacks
|
|
81
|
+
without a real database engine.
|
|
82
|
+
|
|
83
|
+
Yields:
|
|
84
|
+
Transaction: An unbound transaction with no associated session.
|
|
85
|
+
"""
|
|
86
|
+
async with self.connection(name).transaction() as tx:
|
|
87
|
+
yield tx
|
|
88
|
+
|
|
89
|
+
@asynccontextmanager
|
|
90
|
+
async def begin(self, name: str = "default") -> AsyncIterator[BoundTransaction]:
|
|
91
|
+
"""Open a bound transaction on the named connection.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
name: The connection name.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
RuntimeError: If no engine has been registered for the connection.
|
|
98
|
+
|
|
99
|
+
Yields:
|
|
100
|
+
BoundTransaction: A transaction with a guaranteed non-null session.
|
|
101
|
+
""" # noqa: DOC502 explicit documentation of the possible exception raised by 'begin'
|
|
102
|
+
async with self.connection(name).begin() as tx:
|
|
103
|
+
yield tx
|
|
104
|
+
|
|
105
|
+
async def close(self) -> None:
|
|
106
|
+
"""Dispose all engines and clear caches."""
|
|
107
|
+
for engine in self._engines.values():
|
|
108
|
+
await engine.dispose()
|
|
109
|
+
self._engines.clear()
|
|
110
|
+
self._session_factories.clear()
|
|
111
|
+
self._connections.clear()
|
|
@@ -4,7 +4,7 @@ from collections.abc import AsyncIterator
|
|
|
4
4
|
from contextlib import asynccontextmanager
|
|
5
5
|
from typing import Self, override
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
8
8
|
|
|
9
9
|
from neva import Err, Ok, Result
|
|
10
10
|
from neva.arch import ServiceProvider
|
|
@@ -27,18 +27,19 @@ class DatabaseServiceProvider(ServiceProvider):
|
|
|
27
27
|
async def lifespan(self) -> AsyncIterator[None]:
|
|
28
28
|
"""Initialize and cleanup database connections."""
|
|
29
29
|
logger: LogManager = self.app.make(LogManager).unwrap()
|
|
30
|
-
|
|
30
|
+
db: DatabaseManager = self.app.make(DatabaseManager).unwrap()
|
|
31
|
+
logger.info("Beginning SQLAlchemy initialization...")
|
|
31
32
|
match self.app.make(ConfigRepository).unwrap().get("database"):
|
|
32
33
|
case Ok(config):
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
logger.info("
|
|
34
|
+
connections: dict = config.get("connections", {})
|
|
35
|
+
for name, conn_config in connections.items():
|
|
36
|
+
url = conn_config.pop("url")
|
|
37
|
+
engine = create_async_engine(url, **conn_config)
|
|
38
|
+
db.register_engine(name, engine)
|
|
39
|
+
logger.info(f"Registered engine for connection '{name}'.")
|
|
40
|
+
logger.info("SQLAlchemy initialization complete.")
|
|
40
41
|
yield
|
|
41
|
-
await
|
|
42
|
+
await db.close()
|
|
42
43
|
case Err(err):
|
|
43
44
|
logger.error(f"Failed to load database configuration: {err}")
|
|
44
45
|
yield
|
|
@@ -5,6 +5,8 @@ from dataclasses import dataclass, field
|
|
|
5
5
|
from enum import Enum, auto
|
|
6
6
|
from typing import Callable, Self
|
|
7
7
|
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
8
10
|
from neva import Err, Nothing, Option, Result, Some
|
|
9
11
|
|
|
10
12
|
|
|
@@ -25,6 +27,7 @@ class Transaction:
|
|
|
25
27
|
|
|
26
28
|
conn_name: str
|
|
27
29
|
state: TransactionState = field(default=TransactionState.ACTIVE, init=False)
|
|
30
|
+
rollback_requested: bool = field(default=False, init=False)
|
|
28
31
|
_on_commit: list[TransactionCallback] = field(default_factory=list, init=False)
|
|
29
32
|
_on_rollback: list[TransactionCallback] = field(default_factory=list, init=False)
|
|
30
33
|
parent: Option["Transaction"] = field(default_factory=Nothing, init=False)
|
|
@@ -94,3 +97,34 @@ class Transaction:
|
|
|
94
97
|
results.append(Err(f"Callback raised: {e}"))
|
|
95
98
|
self._on_rollback.clear()
|
|
96
99
|
return results
|
|
100
|
+
|
|
101
|
+
async def rollback(self) -> None:
|
|
102
|
+
"""Mark this transaction for rollback on exit."""
|
|
103
|
+
self.rollback_requested = True
|
|
104
|
+
|
|
105
|
+
def begin(self, session: "AsyncSession") -> "BoundTransaction":
|
|
106
|
+
"""Bind this transaction to a session, returning a BoundTransaction.
|
|
107
|
+
|
|
108
|
+
Transfers the parent relationship and all pending callbacks to the
|
|
109
|
+
returned BoundTransaction. The original transaction should not be
|
|
110
|
+
used after calling this method.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
session: The SQLAlchemy async session to bind to.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
BoundTransaction: A new transaction with the session attached and
|
|
117
|
+
all state transferred from this transaction.
|
|
118
|
+
"""
|
|
119
|
+
bound = BoundTransaction(self.conn_name, session=session)
|
|
120
|
+
bound.parent = self.parent
|
|
121
|
+
bound._on_commit = self._on_commit
|
|
122
|
+
bound._on_rollback = self._on_rollback
|
|
123
|
+
return bound
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class BoundTransaction(Transaction):
|
|
128
|
+
"""Transaction bound to an SQLAlchemy session."""
|
|
129
|
+
|
|
130
|
+
session: AsyncSession
|
|
@@ -4,6 +4,7 @@ from neva.support.facade.app import App
|
|
|
4
4
|
from neva.support.facade.config import Config
|
|
5
5
|
from neva.support.facade.crypt import Crypt
|
|
6
6
|
from neva.support.facade.db import DB
|
|
7
|
+
from neva.support.facade.event import Event
|
|
7
8
|
from neva.support.facade.hash import Hash
|
|
8
9
|
from neva.support.facade.log import Log
|
|
9
10
|
|
|
@@ -13,6 +14,7 @@ __all__ = [
|
|
|
13
14
|
"App",
|
|
14
15
|
"Config",
|
|
15
16
|
"Crypt",
|
|
17
|
+
"Event",
|
|
16
18
|
"Hash",
|
|
17
19
|
"Log",
|
|
18
20
|
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Type stub for DB facade."""
|
|
2
|
+
|
|
3
|
+
from contextlib import AbstractAsyncContextManager
|
|
4
|
+
from typing import override
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from neva import Option
|
|
9
|
+
from neva.arch import Facade
|
|
10
|
+
from neva.database import BoundTransaction, Transaction
|
|
11
|
+
from neva.database.connection import ConnectionManager
|
|
12
|
+
|
|
13
|
+
class DB(Facade):
|
|
14
|
+
@classmethod
|
|
15
|
+
@override
|
|
16
|
+
def get_facade_accessor(cls) -> type: ...
|
|
17
|
+
@classmethod
|
|
18
|
+
def connection(cls, name: str) -> ConnectionManager:
|
|
19
|
+
"""Returns a connection manager for the given connection name."""
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def current(cls, connection: str | None = None) -> Option[Transaction]:
|
|
23
|
+
"""Returns the current transaction.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
connection: If provided, returns the transaction for that specific
|
|
27
|
+
connection. If None, returns the innermost transaction.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def transaction(
|
|
32
|
+
cls, name: str = "default"
|
|
33
|
+
) -> AbstractAsyncContextManager[Transaction]:
|
|
34
|
+
"""Open an unbound transaction on the specified connection.
|
|
35
|
+
|
|
36
|
+
Intended for unit tests that exercise transaction lifecycle and callbacks
|
|
37
|
+
without a real database engine.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
name: The connection name.
|
|
41
|
+
|
|
42
|
+
Yields:
|
|
43
|
+
Transaction: An unbound transaction with no associated session.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def begin(
|
|
48
|
+
cls, name: str = "default"
|
|
49
|
+
) -> AbstractAsyncContextManager[BoundTransaction]:
|
|
50
|
+
"""Open a bound transaction on the specified connection.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
name: The connection name.
|
|
54
|
+
|
|
55
|
+
Yields:
|
|
56
|
+
BoundTransaction: A transaction with a guaranteed non-null session.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def session(cls, connection: str | None = None) -> Option[AsyncSession]:
|
|
61
|
+
"""Returns the current session for a connection.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
connection: The connection name. Defaults to "default".
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
async def close(cls) -> None:
|
|
69
|
+
"""Dispose all engines and clear caches."""
|