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.
Files changed (121) hide show
  1. {python_neva-1.0.4 → python_neva-2.0.0}/PKG-INFO +5 -4
  2. {python_neva-1.0.4 → python_neva-2.0.0}/neva/database/__init__.py +4 -2
  3. python_neva-2.0.0/neva/database/config.py +20 -0
  4. {python_neva-1.0.4 → python_neva-2.0.0}/neva/database/connection.py +60 -13
  5. python_neva-2.0.0/neva/database/manager.py +111 -0
  6. {python_neva-1.0.4 → python_neva-2.0.0}/neva/database/provider.py +11 -10
  7. {python_neva-1.0.4 → python_neva-2.0.0}/neva/database/transaction.py +29 -0
  8. python_neva-2.0.0/neva/support/facade/db.pyi +69 -0
  9. {python_neva-1.0.4 → python_neva-2.0.0}/pyproject.toml +5 -4
  10. python_neva-2.0.0/tests/database/conftest.py +10 -0
  11. {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_connection_manager.py +14 -1
  12. {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_database_manager.py +39 -0
  13. {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_edge_cases.py +18 -50
  14. python_neva-2.0.0/tests/database/test_sqlalchemy_integration.py +179 -0
  15. {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_transaction.py +0 -1
  16. {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_transaction_context.py +44 -0
  17. {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/conftest.py +0 -19
  18. {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/test_deferred.py +67 -5
  19. {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/test_dispatch.py +65 -0
  20. python_neva-2.0.0/tests/events/test_event.py +40 -0
  21. python_neva-2.0.0/tests/events/test_event_registry.py +91 -0
  22. {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/test_function_listener.py +3 -3
  23. {python_neva-1.0.4 → python_neva-2.0.0}/uv.lock +139 -84
  24. python_neva-1.0.4/neva/database/config.py +0 -59
  25. python_neva-1.0.4/neva/database/manager.py +0 -44
  26. python_neva-1.0.4/neva/support/facade/db.pyi +0 -39
  27. python_neva-1.0.4/tests/database/conftest.py +0 -30
  28. {python_neva-1.0.4 → python_neva-2.0.0}/.envrc +0 -0
  29. {python_neva-1.0.4 → python_neva-2.0.0}/.gitignore +0 -0
  30. {python_neva-1.0.4 → python_neva-2.0.0}/.pre-commit-config.yaml +0 -0
  31. {python_neva-1.0.4 → python_neva-2.0.0}/.python-version +0 -0
  32. {python_neva-1.0.4 → python_neva-2.0.0}/README.md +0 -0
  33. {python_neva-1.0.4 → python_neva-2.0.0}/neva/__init__.py +0 -0
  34. {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/__init__.py +0 -0
  35. {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/app.py +0 -0
  36. {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/application.py +0 -0
  37. {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/config.py +0 -0
  38. {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/facade.py +0 -0
  39. {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/faststream.py +0 -0
  40. {python_neva-1.0.4 → python_neva-2.0.0}/neva/arch/service_provider.py +0 -0
  41. {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/__init__.py +0 -0
  42. {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/base_providers.py +0 -0
  43. {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/loader.py +0 -0
  44. {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/provider.py +0 -0
  45. {python_neva-1.0.4 → python_neva-2.0.0}/neva/config/repository.py +0 -0
  46. {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/__init__.py +0 -0
  47. {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/dispatcher.py +0 -0
  48. {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/event.py +0 -0
  49. {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/event_registry.py +0 -0
  50. {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/listener.py +0 -0
  51. {python_neva-1.0.4 → python_neva-2.0.0}/neva/events/provider.py +0 -0
  52. {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/__init__.py +0 -0
  53. {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/logging/__init__.py +0 -0
  54. {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/logging/manager.py +0 -0
  55. {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/logging/provider.py +0 -0
  56. {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/middleware/__init__.py +0 -0
  57. {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/middleware/correlation.py +0 -0
  58. {python_neva-1.0.4 → python_neva-2.0.0}/neva/obs/middleware/profiler.py +0 -0
  59. {python_neva-1.0.4 → python_neva-2.0.0}/neva/py.typed +0 -0
  60. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/__init__.py +0 -0
  61. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/encryption/__init__.py +0 -0
  62. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/encryption/encrypter.py +0 -0
  63. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/encryption/protocol.py +0 -0
  64. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/__init__.py +0 -0
  65. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/config.py +0 -0
  66. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hash_manager.py +0 -0
  67. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hashers/__init__.py +0 -0
  68. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hashers/argon2.py +0 -0
  69. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  70. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/hashing/hashers/protocol.py +0 -0
  71. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/provider.py +0 -0
  72. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/tokens/__init__.py +0 -0
  73. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/tokens/generate_token.py +0 -0
  74. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/tokens/hash_token.py +0 -0
  75. {python_neva-1.0.4 → python_neva-2.0.0}/neva/security/tokens/verify_token.py +0 -0
  76. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/__init__.py +0 -0
  77. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/accessors.py +0 -0
  78. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/__init__.py +0 -0
  79. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/app.py +0 -0
  80. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/app.pyi +0 -0
  81. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/config.py +0 -0
  82. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/config.pyi +0 -0
  83. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/crypt.py +0 -0
  84. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/crypt.pyi +0 -0
  85. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/db.py +0 -0
  86. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/event.py +0 -0
  87. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/event.pyi +0 -0
  88. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/hash.py +0 -0
  89. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/hash.pyi +0 -0
  90. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/log.py +0 -0
  91. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/facade/log.pyi +0 -0
  92. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/results.py +0 -0
  93. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/strategy.py +0 -0
  94. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/strconv.py +0 -0
  95. {python_neva-1.0.4 → python_neva-2.0.0}/neva/support/time.py +0 -0
  96. {python_neva-1.0.4 → python_neva-2.0.0}/neva/testing/__init__.py +0 -0
  97. {python_neva-1.0.4 → python_neva-2.0.0}/neva/testing/fixtures.py +0 -0
  98. {python_neva-1.0.4 → python_neva-2.0.0}/neva/testing/http.py +0 -0
  99. {python_neva-1.0.4 → python_neva-2.0.0}/neva/testing/test_case.py +0 -0
  100. {python_neva-1.0.4 → python_neva-2.0.0}/ruff.toml +0 -0
  101. {python_neva-1.0.4 → python_neva-2.0.0}/tests/__init__.py +0 -0
  102. {python_neva-1.0.4 → python_neva-2.0.0}/tests/arch/__init__.py +0 -0
  103. {python_neva-1.0.4 → python_neva-2.0.0}/tests/arch/test_scope.py +0 -0
  104. {python_neva-1.0.4 → python_neva-2.0.0}/tests/config/__init__.py +0 -0
  105. {python_neva-1.0.4 → python_neva-2.0.0}/tests/config/test_loader.py +0 -0
  106. {python_neva-1.0.4 → python_neva-2.0.0}/tests/config/test_repository.py +0 -0
  107. {python_neva-1.0.4 → python_neva-2.0.0}/tests/conftest.py +0 -0
  108. {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/__init__.py +0 -0
  109. {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_multi_connection.py +0 -0
  110. {python_neva-1.0.4 → python_neva-2.0.0}/tests/database/test_transaction_registry.py +0 -0
  111. {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/__init__.py +0 -0
  112. {python_neva-1.0.4 → python_neva-2.0.0}/tests/events/test_immediate.py +0 -0
  113. {python_neva-1.0.4 → python_neva-2.0.0}/tests/obs/__init__.py +0 -0
  114. {python_neva-1.0.4 → python_neva-2.0.0}/tests/obs/test_correlation.py +0 -0
  115. {python_neva-1.0.4 → python_neva-2.0.0}/tests/obs/test_profiler.py +0 -0
  116. {python_neva-1.0.4 → python_neva-2.0.0}/tests/security/__init__.py +0 -0
  117. {python_neva-1.0.4 → python_neva-2.0.0}/tests/security/test_encrypter.py +0 -0
  118. {python_neva-1.0.4 → python_neva-2.0.0}/tests/security/test_hash_manager.py +0 -0
  119. {python_neva-1.0.4 → python_neva-2.0.0}/tests/testing/__init__.py +0 -0
  120. {python_neva-1.0.4 → python_neva-2.0.0}/tests/testing/test_fixtures.py +0 -0
  121. {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: 1.0.4
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.124.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 tortoise.transactions import in_transaction
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 transaction(
83
- self,
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
- Transaction: The new transaction.
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
- async with in_transaction(self.name):
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 tortoise import Tortoise, exceptions
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
- logger.info("Beginning Tortoise initialization...")
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
- try:
34
- _ = await Tortoise.init(
35
- config=config, _create_db=True, _enable_global_fallback=True
36
- )
37
- except exceptions.OperationalError:
38
- _ = await Tortoise.init(config=config, _enable_global_fallback=True)
39
- logger.info("Tortoise initialization complete.")
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 Tortoise.close_connections()
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 = "1.0.4"
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.124.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
- "tortoise-orm[accel]>=1.1.4",
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
 
@@ -0,0 +1,10 @@
1
+ """Shared fixtures for database tests."""
2
+
3
+ import pytest
4
+
5
+ from neva.database.connection import TransactionContext
6
+
7
+
8
+ @pytest.fixture
9
+ def tx_context() -> TransactionContext:
10
+ return TransactionContext()
@@ -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.connection import ConnectionManager, TransactionContext
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 test_registering_callback_on_committed_transaction(self) -> None:
38
- tx = Transaction("default")
39
- tx.state = TransactionState.COMMITTED
40
- called = False
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
- nonlocal called
44
- called = True
38
+ results.append("reached_root")
45
39
  return Ok(None)
46
40
 
47
- tx.on_commit(callback)
41
+ grandchild.on_commit(callback)
48
42
 
49
- assert not called
43
+ assert callback in root._on_commit
44
+ await root.execute_on_commit_callbacks()
45
+ assert results == ["reached_root"]
50
46
 
51
- async def test_deeply_nested_same_connection_callbacks_reach_root(self) -> None:
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.on_commit(callback)
60
+ grandchild.on_rollback(callback)
65
61
 
66
- assert callback in root._on_commit
67
- await root.execute_on_commit_callbacks()
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] = []