python-neva 1.0.4__tar.gz → 2.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-1.0.4 → python_neva-2.0.0}/PKG-INFO +5 -4
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/database/__init__.py +4 -2
- python_neva-2.0.0/neva/database/config.py +20 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/database/connection.py +60 -13
- python_neva-2.0.0/neva/database/manager.py +111 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/database/provider.py +11 -10
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/database/transaction.py +29 -0
- python_neva-2.0.0/neva/support/facade/db.pyi +69 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/pyproject.toml +5 -4
- python_neva-2.0.0/tests/database/conftest.py +10 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_connection_manager.py +14 -1
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_database_manager.py +39 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_edge_cases.py +18 -50
- python_neva-2.0.0/tests/database/test_sqlalchemy_integration.py +179 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_transaction.py +0 -1
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_transaction_context.py +44 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/conftest.py +0 -19
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/test_deferred.py +67 -5
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/test_dispatch.py +65 -0
- python_neva-2.0.0/tests/events/test_event.py +40 -0
- python_neva-2.0.0/tests/events/test_event_registry.py +91 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/test_function_listener.py +3 -3
- {python_neva-1.0.4 → python_neva-2.0.0}/uv.lock +139 -84
- python_neva-1.0.4/neva/database/config.py +0 -59
- python_neva-1.0.4/neva/database/manager.py +0 -44
- python_neva-1.0.4/neva/support/facade/db.pyi +0 -39
- python_neva-1.0.4/tests/database/conftest.py +0 -30
- {python_neva-1.0.4 → python_neva-2.0.0}/.envrc +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/.gitignore +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/.pre-commit-config.yaml +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/.python-version +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/README.md +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/app.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/application.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/config.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/facade.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/faststream.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/service_provider.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/base_providers.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/loader.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/provider.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/repository.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/dispatcher.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/event.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/event_registry.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/listener.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/provider.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/logging/provider.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/middleware/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/middleware/correlation.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/middleware/profiler.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/py.typed +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/encryption/encrypter.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/config.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/provider.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/tokens/generate_token.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/accessors.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/app.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/config.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/db.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/event.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/hash.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/hash.pyi +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/log.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/results.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/strategy.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/strconv.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/time.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/testing/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/testing/fixtures.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/testing/http.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/neva/testing/test_case.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/ruff.toml +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/arch/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/arch/test_scope.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/config/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/config/test_loader.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/config/test_repository.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/conftest.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/test_immediate.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/obs/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/obs/test_correlation.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/obs/test_profiler.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/security/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/security/test_encrypter.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/security/test_hash_manager.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/testing/__init__.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.0}/tests/testing/test_fixtures.py +0 -0
- {python_neva-1.0.4 → python_neva-2.0.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:
|
|
3
|
+
Version: 2.0.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
|
-
Requires-Dist: fastapi[all]>=0.
|
|
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'
|
|
@@ -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]
|
|
@@ -6,10 +6,10 @@ from contextvars import ContextVar
|
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
from typing import final
|
|
8
8
|
|
|
9
|
-
from
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
10
10
|
|
|
11
11
|
from neva import Nothing, Option, Some, from_optional
|
|
12
|
-
from neva.database.transaction import Transaction, TransactionState
|
|
12
|
+
from neva.database.transaction import BoundTransaction, Transaction, TransactionState
|
|
13
13
|
from neva.obs import LogManager
|
|
14
14
|
|
|
15
15
|
|
|
@@ -73,33 +73,28 @@ class ConnectionManager:
|
|
|
73
73
|
name: str,
|
|
74
74
|
tx_context: TransactionContext,
|
|
75
75
|
logger: LogManager | None,
|
|
76
|
+
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
|
76
77
|
) -> None:
|
|
77
78
|
self.name = name
|
|
78
79
|
self.tx_context = tx_context
|
|
79
80
|
self.logger = logger
|
|
81
|
+
self.session_factory = session_factory
|
|
80
82
|
|
|
81
83
|
@asynccontextmanager
|
|
82
|
-
async def
|
|
83
|
-
|
|
84
|
-
) -> AsyncIterator[Transaction]:
|
|
85
|
-
"""Open a new transaction.
|
|
84
|
+
async def _scoped(self, tx: Transaction) -> AsyncIterator[None]:
|
|
85
|
+
"""Manage registry, state transitions, and callbacks for a transaction.
|
|
86
86
|
|
|
87
87
|
Yields:
|
|
88
|
-
|
|
88
|
+
None
|
|
89
89
|
"""
|
|
90
|
-
parent = self.tx_context.current(self.name)
|
|
91
|
-
tx = Transaction(self.name)
|
|
92
|
-
tx.parent = parent
|
|
93
90
|
old_registry = _tx_registry.get()
|
|
94
91
|
token = (
|
|
95
92
|
_tx_registry.set(old_registry.extend(tx))
|
|
96
93
|
if old_registry is not None
|
|
97
94
|
else _tx_registry.set(TransactionRegistry())
|
|
98
95
|
)
|
|
99
|
-
|
|
100
96
|
try:
|
|
101
|
-
|
|
102
|
-
yield tx
|
|
97
|
+
yield
|
|
103
98
|
tx.state = TransactionState.COMMITTED
|
|
104
99
|
if tx.is_root:
|
|
105
100
|
for result in await tx.execute_on_commit_callbacks():
|
|
@@ -122,3 +117,55 @@ class ConnectionManager:
|
|
|
122
117
|
raise
|
|
123
118
|
finally:
|
|
124
119
|
_tx_registry.reset(token)
|
|
120
|
+
|
|
121
|
+
@asynccontextmanager
|
|
122
|
+
async def transaction(self) -> AsyncIterator[Transaction]:
|
|
123
|
+
"""Open a new unbound transaction (no database session).
|
|
124
|
+
|
|
125
|
+
Intended for use in unit tests where transaction lifecycle, callbacks,
|
|
126
|
+
and context isolation need to be exercised without a real database.
|
|
127
|
+
|
|
128
|
+
Yields:
|
|
129
|
+
Transaction: An unbound transaction with no associated session.
|
|
130
|
+
"""
|
|
131
|
+
parent = self.tx_context.current(self.name)
|
|
132
|
+
tx = Transaction(self.name)
|
|
133
|
+
tx.parent = parent
|
|
134
|
+
async with self._scoped(tx):
|
|
135
|
+
yield tx
|
|
136
|
+
|
|
137
|
+
@asynccontextmanager
|
|
138
|
+
async def begin(self) -> AsyncIterator[BoundTransaction]:
|
|
139
|
+
"""Open a new bound transaction with an active database session.
|
|
140
|
+
|
|
141
|
+
For nested calls on the same connection, reuses the parent session
|
|
142
|
+
and begins a savepoint instead of a full transaction.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
RuntimeError: If no engine has been registered for this connection.
|
|
146
|
+
|
|
147
|
+
Yields:
|
|
148
|
+
BoundTransaction: A transaction with a guaranteed non-null session.
|
|
149
|
+
"""
|
|
150
|
+
if self.session_factory is None:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"No engine registered for connection '{self.name}'. "
|
|
153
|
+
+ "Call register_engine() before calling begin()."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
parent = self.tx_context.current(self.name)
|
|
157
|
+
|
|
158
|
+
match parent:
|
|
159
|
+
case Some(parent_tx) if isinstance(parent_tx, BoundTransaction):
|
|
160
|
+
tx = Transaction(self.name)
|
|
161
|
+
tx.parent = parent
|
|
162
|
+
bound = tx.begin(parent_tx.session)
|
|
163
|
+
async with self._scoped(bound), parent_tx.session.begin_nested():
|
|
164
|
+
yield bound
|
|
165
|
+
case _:
|
|
166
|
+
tx = Transaction(self.name)
|
|
167
|
+
tx.parent = Nothing()
|
|
168
|
+
async with self.session_factory() as session, session.begin():
|
|
169
|
+
bound = tx.begin(session)
|
|
170
|
+
async with self._scoped(bound):
|
|
171
|
+
yield bound
|
|
@@ -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
|
|
|
@@ -94,3 +96,30 @@ class Transaction:
|
|
|
94
96
|
results.append(Err(f"Callback raised: {e}"))
|
|
95
97
|
self._on_rollback.clear()
|
|
96
98
|
return results
|
|
99
|
+
|
|
100
|
+
def begin(self, session: "AsyncSession") -> "BoundTransaction":
|
|
101
|
+
"""Bind this transaction to a session, returning a BoundTransaction.
|
|
102
|
+
|
|
103
|
+
Transfers the parent relationship and all pending callbacks to the
|
|
104
|
+
returned BoundTransaction. The original transaction should not be
|
|
105
|
+
used after calling this method.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
session: The SQLAlchemy async session to bind to.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
BoundTransaction: A new transaction with the session attached and
|
|
112
|
+
all state transferred from this transaction.
|
|
113
|
+
"""
|
|
114
|
+
bound = BoundTransaction(self.conn_name, session=session)
|
|
115
|
+
bound.parent = self.parent
|
|
116
|
+
bound._on_commit = self._on_commit
|
|
117
|
+
bound._on_rollback = self._on_rollback
|
|
118
|
+
return bound
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class BoundTransaction(Transaction):
|
|
123
|
+
"""Transaction bound to an SQLAlchemy session."""
|
|
124
|
+
|
|
125
|
+
session: AsyncSession
|
|
@@ -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."""
|
|
@@ -7,20 +7,21 @@ packages = ["neva"]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "python-neva"
|
|
10
|
-
version = "
|
|
10
|
+
version = "2.0.0"
|
|
11
11
|
description = "Add your description here"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.12"
|
|
14
14
|
dependencies = [
|
|
15
15
|
"cryptography>=46.0.3",
|
|
16
16
|
"dishka>=1.7.2",
|
|
17
|
-
"fastapi[all]>=0.
|
|
17
|
+
"fastapi[all]>=0.129.0",
|
|
18
18
|
"faststream>=0.6.6",
|
|
19
|
-
"flexmock>=0.13.0",
|
|
20
19
|
"pwdlib[argon2,bcrypt]>=0.3.0",
|
|
21
20
|
"pyinstrument>=5.1.1",
|
|
22
21
|
"structlog>=25.5.0",
|
|
23
|
-
"
|
|
22
|
+
"sqlalchemy[asyncio]>=2.0.0",
|
|
23
|
+
"asyncpg>=0.30.0",
|
|
24
|
+
"aiosqlite>=0.20.0",
|
|
24
25
|
"typer>=0.21.1",
|
|
25
26
|
]
|
|
26
27
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
5
|
from neva.database.connection import ConnectionManager, TransactionContext
|
|
6
|
-
from neva.database.transaction import TransactionState
|
|
6
|
+
from neva.database.transaction import BoundTransaction, TransactionState
|
|
7
7
|
from neva.obs import LogManager
|
|
8
8
|
|
|
9
9
|
|
|
@@ -82,3 +82,16 @@ class TestConnectionManager:
|
|
|
82
82
|
raise RuntimeError(msg)
|
|
83
83
|
|
|
84
84
|
assert tx_context.current().is_nothing
|
|
85
|
+
|
|
86
|
+
async def test_transaction_yields_unbound_transaction(
|
|
87
|
+
self, manager: ConnectionManager
|
|
88
|
+
) -> None:
|
|
89
|
+
async with manager.transaction() as tx:
|
|
90
|
+
assert not isinstance(tx, BoundTransaction)
|
|
91
|
+
|
|
92
|
+
async def test_begin_raises_without_engine(
|
|
93
|
+
self, manager: ConnectionManager
|
|
94
|
+
) -> None:
|
|
95
|
+
with pytest.raises(RuntimeError, match="No engine registered"):
|
|
96
|
+
async with manager.begin():
|
|
97
|
+
pass
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Database manager tests."""
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
4
5
|
|
|
5
6
|
from neva.database.connection import ConnectionManager, TransactionContext
|
|
6
7
|
from neva.database.manager import DatabaseManager
|
|
@@ -57,3 +58,41 @@ class TestDatabaseManager:
|
|
|
57
58
|
assert default is not analytics
|
|
58
59
|
assert isinstance(default, ConnectionManager)
|
|
59
60
|
assert isinstance(analytics, ConnectionManager)
|
|
61
|
+
|
|
62
|
+
async def test_session_returns_nothing_outside_transaction(
|
|
63
|
+
self, db: DatabaseManager
|
|
64
|
+
) -> None:
|
|
65
|
+
assert db.session().is_nothing
|
|
66
|
+
|
|
67
|
+
async def test_session_returns_nothing_in_unbound_transaction(
|
|
68
|
+
self, db: DatabaseManager
|
|
69
|
+
) -> None:
|
|
70
|
+
async with db.transaction():
|
|
71
|
+
assert db.session().is_nothing
|
|
72
|
+
|
|
73
|
+
async def test_session_with_connection_name_returns_nothing_when_no_match(
|
|
74
|
+
self, db: DatabaseManager
|
|
75
|
+
) -> None:
|
|
76
|
+
async with db.transaction("default"):
|
|
77
|
+
assert db.session("other").is_nothing
|
|
78
|
+
|
|
79
|
+
async def test_begin_raises_without_engine(self, db: DatabaseManager) -> None:
|
|
80
|
+
with pytest.raises(RuntimeError, match="No engine registered"):
|
|
81
|
+
async with db.begin():
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestRegisterEngineCache:
|
|
86
|
+
def test_register_engine_invalidates_stale_connection_manager(self) -> None:
|
|
87
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
88
|
+
tx_context = TransactionContext()
|
|
89
|
+
db = DatabaseManager(tx_context, LogManager())
|
|
90
|
+
|
|
91
|
+
stale = db.connection("default")
|
|
92
|
+
assert stale.session_factory is None
|
|
93
|
+
|
|
94
|
+
db.register_engine("default", engine)
|
|
95
|
+
|
|
96
|
+
fresh = db.connection("default")
|
|
97
|
+
assert fresh is not stale
|
|
98
|
+
assert fresh.session_factory is not None
|
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
"""Transaction edge case tests."""
|
|
2
2
|
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
3
|
from neva import Err, Ok, Result, Some
|
|
6
|
-
from neva.database.
|
|
7
|
-
from neva.database.transaction import Transaction, TransactionState
|
|
8
|
-
from neva.obs import LogManager
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@pytest.fixture
|
|
12
|
-
def manager(tx_context: TransactionContext) -> ConnectionManager:
|
|
13
|
-
return ConnectionManager("default", tx_context, LogManager())
|
|
4
|
+
from neva.database.transaction import Transaction
|
|
14
5
|
|
|
15
6
|
|
|
16
7
|
class TestTransactionEdgeCases:
|
|
@@ -34,21 +25,26 @@ class TestTransactionEdgeCases:
|
|
|
34
25
|
assert callback_results[0].is_err
|
|
35
26
|
assert callback_results[1].is_ok
|
|
36
27
|
|
|
37
|
-
async def
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
async def test_deeply_nested_same_connection_callbacks_reach_root(self) -> None:
|
|
29
|
+
root = Transaction("default")
|
|
30
|
+
child = Transaction("default")
|
|
31
|
+
child.parent = Some(root)
|
|
32
|
+
grandchild = Transaction("default")
|
|
33
|
+
grandchild.parent = Some(child)
|
|
34
|
+
|
|
35
|
+
results: list[str] = []
|
|
41
36
|
|
|
42
37
|
async def callback() -> Result[None, str]:
|
|
43
|
-
|
|
44
|
-
called = True
|
|
38
|
+
results.append("reached_root")
|
|
45
39
|
return Ok(None)
|
|
46
40
|
|
|
47
|
-
|
|
41
|
+
grandchild.on_commit(callback)
|
|
48
42
|
|
|
49
|
-
assert
|
|
43
|
+
assert callback in root._on_commit
|
|
44
|
+
await root.execute_on_commit_callbacks()
|
|
45
|
+
assert results == ["reached_root"]
|
|
50
46
|
|
|
51
|
-
async def
|
|
47
|
+
async def test_deeply_nested_rollback_callbacks_reach_root(self) -> None:
|
|
52
48
|
root = Transaction("default")
|
|
53
49
|
child = Transaction("default")
|
|
54
50
|
child.parent = Some(root)
|
|
@@ -61,10 +57,10 @@ class TestTransactionEdgeCases:
|
|
|
61
57
|
results.append("reached_root")
|
|
62
58
|
return Ok(None)
|
|
63
59
|
|
|
64
|
-
grandchild.
|
|
60
|
+
grandchild.on_rollback(callback)
|
|
65
61
|
|
|
66
|
-
assert callback in root.
|
|
67
|
-
await root.
|
|
62
|
+
assert callback in root._on_rollback
|
|
63
|
+
await root.execute_on_rollback_callbacks()
|
|
68
64
|
assert results == ["reached_root"]
|
|
69
65
|
|
|
70
66
|
async def test_empty_transaction_no_callbacks(self) -> None:
|
|
@@ -76,34 +72,6 @@ class TestTransactionEdgeCases:
|
|
|
76
72
|
assert commit_results == []
|
|
77
73
|
assert rollback_results == []
|
|
78
74
|
|
|
79
|
-
async def test_current_after_all_transactions_closed(
|
|
80
|
-
self, manager: ConnectionManager, tx_context: TransactionContext
|
|
81
|
-
) -> None:
|
|
82
|
-
async with manager.transaction():
|
|
83
|
-
pass
|
|
84
|
-
|
|
85
|
-
async with manager.transaction():
|
|
86
|
-
pass
|
|
87
|
-
|
|
88
|
-
assert tx_context.current().is_nothing
|
|
89
|
-
|
|
90
|
-
async def test_multiple_callbacks_same_transaction(self) -> None:
|
|
91
|
-
tx = Transaction("default")
|
|
92
|
-
results: list[int] = []
|
|
93
|
-
|
|
94
|
-
for i in range(5):
|
|
95
|
-
|
|
96
|
-
async def callback(n: int = i) -> Result[None, str]:
|
|
97
|
-
results.append(n)
|
|
98
|
-
return Ok(None)
|
|
99
|
-
|
|
100
|
-
tx.on_commit(callback)
|
|
101
|
-
|
|
102
|
-
callback_results = await tx.execute_on_commit_callbacks()
|
|
103
|
-
|
|
104
|
-
assert results == [0, 1, 2, 3, 4]
|
|
105
|
-
assert all(r.is_ok for r in callback_results)
|
|
106
|
-
|
|
107
75
|
async def test_callback_registered_during_callback_execution(self) -> None:
|
|
108
76
|
tx = Transaction("default")
|
|
109
77
|
results: list[str] = []
|