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.
Files changed (128) hide show
  1. {python_neva-1.0.5 → python_neva-2.1.0}/PKG-INFO +4 -3
  2. {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/facade.py +83 -1
  3. {python_neva-1.0.5 → python_neva-2.1.0}/neva/database/__init__.py +4 -2
  4. python_neva-2.1.0/neva/database/config.py +20 -0
  5. python_neva-2.1.0/neva/database/connection.py +199 -0
  6. python_neva-2.1.0/neva/database/manager.py +111 -0
  7. {python_neva-1.0.5 → python_neva-2.1.0}/neva/database/provider.py +11 -10
  8. {python_neva-1.0.5 → python_neva-2.1.0}/neva/database/transaction.py +34 -0
  9. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/__init__.py +2 -0
  10. python_neva-2.1.0/neva/support/facade/db.pyi +69 -0
  11. python_neva-2.1.0/neva/support/facade/event.py +53 -0
  12. python_neva-2.1.0/neva/testing/__init__.py +7 -0
  13. python_neva-2.1.0/neva/testing/fakes.py +78 -0
  14. {python_neva-1.0.5 → python_neva-2.1.0}/neva/testing/test_case.py +39 -7
  15. {python_neva-1.0.5 → python_neva-2.1.0}/pyproject.toml +4 -3
  16. python_neva-2.1.0/tests/database/conftest.py +10 -0
  17. {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_connection_manager.py +14 -1
  18. {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_database_manager.py +39 -0
  19. {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_edge_cases.py +18 -50
  20. python_neva-2.1.0/tests/database/test_sqlalchemy_integration.py +179 -0
  21. {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_transaction.py +0 -1
  22. {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_transaction_context.py +44 -0
  23. {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/conftest.py +0 -19
  24. {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/test_deferred.py +67 -5
  25. {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/test_dispatch.py +65 -0
  26. python_neva-2.1.0/tests/events/test_event.py +40 -0
  27. python_neva-2.1.0/tests/events/test_event_registry.py +91 -0
  28. {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/test_function_listener.py +3 -3
  29. python_neva-2.1.0/tests/testing/test_event_fake.py +111 -0
  30. python_neva-2.1.0/tests/testing/test_facade_restore.py +57 -0
  31. python_neva-2.1.0/tests/testing/test_refresh_database.py +85 -0
  32. {python_neva-1.0.5 → python_neva-2.1.0}/uv.lock +197 -78
  33. python_neva-1.0.5/neva/database/config.py +0 -59
  34. python_neva-1.0.5/neva/database/connection.py +0 -124
  35. python_neva-1.0.5/neva/database/manager.py +0 -44
  36. python_neva-1.0.5/neva/support/facade/db.pyi +0 -39
  37. python_neva-1.0.5/neva/support/facade/event.py +0 -15
  38. python_neva-1.0.5/neva/testing/__init__.py +0 -6
  39. python_neva-1.0.5/tests/database/conftest.py +0 -30
  40. {python_neva-1.0.5 → python_neva-2.1.0}/.envrc +0 -0
  41. {python_neva-1.0.5 → python_neva-2.1.0}/.gitignore +0 -0
  42. {python_neva-1.0.5 → python_neva-2.1.0}/.pre-commit-config.yaml +0 -0
  43. {python_neva-1.0.5 → python_neva-2.1.0}/.python-version +0 -0
  44. {python_neva-1.0.5 → python_neva-2.1.0}/README.md +0 -0
  45. {python_neva-1.0.5 → python_neva-2.1.0}/neva/__init__.py +0 -0
  46. {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/__init__.py +0 -0
  47. {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/app.py +0 -0
  48. {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/application.py +0 -0
  49. {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/config.py +0 -0
  50. {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/faststream.py +0 -0
  51. {python_neva-1.0.5 → python_neva-2.1.0}/neva/arch/service_provider.py +0 -0
  52. {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/__init__.py +0 -0
  53. {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/base_providers.py +0 -0
  54. {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/loader.py +0 -0
  55. {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/provider.py +0 -0
  56. {python_neva-1.0.5 → python_neva-2.1.0}/neva/config/repository.py +0 -0
  57. {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/__init__.py +0 -0
  58. {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/dispatcher.py +0 -0
  59. {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/event.py +0 -0
  60. {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/event_registry.py +0 -0
  61. {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/listener.py +0 -0
  62. {python_neva-1.0.5 → python_neva-2.1.0}/neva/events/provider.py +0 -0
  63. {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/__init__.py +0 -0
  64. {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/logging/__init__.py +0 -0
  65. {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/logging/manager.py +0 -0
  66. {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/logging/provider.py +0 -0
  67. {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/middleware/__init__.py +0 -0
  68. {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/middleware/correlation.py +0 -0
  69. {python_neva-1.0.5 → python_neva-2.1.0}/neva/obs/middleware/profiler.py +0 -0
  70. {python_neva-1.0.5 → python_neva-2.1.0}/neva/py.typed +0 -0
  71. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/__init__.py +0 -0
  72. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/encryption/__init__.py +0 -0
  73. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/encryption/encrypter.py +0 -0
  74. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/encryption/protocol.py +0 -0
  75. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/__init__.py +0 -0
  76. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/config.py +0 -0
  77. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hash_manager.py +0 -0
  78. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hashers/__init__.py +0 -0
  79. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hashers/argon2.py +0 -0
  80. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  81. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/hashing/hashers/protocol.py +0 -0
  82. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/provider.py +0 -0
  83. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/tokens/__init__.py +0 -0
  84. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/tokens/generate_token.py +0 -0
  85. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/tokens/hash_token.py +0 -0
  86. {python_neva-1.0.5 → python_neva-2.1.0}/neva/security/tokens/verify_token.py +0 -0
  87. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/__init__.py +0 -0
  88. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/accessors.py +0 -0
  89. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/app.py +0 -0
  90. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/app.pyi +0 -0
  91. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/config.py +0 -0
  92. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/config.pyi +0 -0
  93. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/crypt.py +0 -0
  94. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/crypt.pyi +0 -0
  95. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/db.py +0 -0
  96. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/event.pyi +0 -0
  97. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/hash.py +0 -0
  98. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/hash.pyi +0 -0
  99. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/log.py +0 -0
  100. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/facade/log.pyi +0 -0
  101. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/results.py +0 -0
  102. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/strategy.py +0 -0
  103. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/strconv.py +0 -0
  104. {python_neva-1.0.5 → python_neva-2.1.0}/neva/support/time.py +0 -0
  105. {python_neva-1.0.5 → python_neva-2.1.0}/neva/testing/fixtures.py +0 -0
  106. {python_neva-1.0.5 → python_neva-2.1.0}/neva/testing/http.py +0 -0
  107. {python_neva-1.0.5 → python_neva-2.1.0}/ruff.toml +0 -0
  108. {python_neva-1.0.5 → python_neva-2.1.0}/tests/__init__.py +0 -0
  109. {python_neva-1.0.5 → python_neva-2.1.0}/tests/arch/__init__.py +0 -0
  110. {python_neva-1.0.5 → python_neva-2.1.0}/tests/arch/test_scope.py +0 -0
  111. {python_neva-1.0.5 → python_neva-2.1.0}/tests/config/__init__.py +0 -0
  112. {python_neva-1.0.5 → python_neva-2.1.0}/tests/config/test_loader.py +0 -0
  113. {python_neva-1.0.5 → python_neva-2.1.0}/tests/config/test_repository.py +0 -0
  114. {python_neva-1.0.5 → python_neva-2.1.0}/tests/conftest.py +0 -0
  115. {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/__init__.py +0 -0
  116. {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_multi_connection.py +0 -0
  117. {python_neva-1.0.5 → python_neva-2.1.0}/tests/database/test_transaction_registry.py +0 -0
  118. {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/__init__.py +0 -0
  119. {python_neva-1.0.5 → python_neva-2.1.0}/tests/events/test_immediate.py +0 -0
  120. {python_neva-1.0.5 → python_neva-2.1.0}/tests/obs/__init__.py +0 -0
  121. {python_neva-1.0.5 → python_neva-2.1.0}/tests/obs/test_correlation.py +0 -0
  122. {python_neva-1.0.5 → python_neva-2.1.0}/tests/obs/test_profiler.py +0 -0
  123. {python_neva-1.0.5 → python_neva-2.1.0}/tests/security/__init__.py +0 -0
  124. {python_neva-1.0.5 → python_neva-2.1.0}/tests/security/test_encrypter.py +0 -0
  125. {python_neva-1.0.5 → python_neva-2.1.0}/tests/security/test_hash_manager.py +0 -0
  126. {python_neva-1.0.5 → python_neva-2.1.0}/tests/testing/__init__.py +0 -0
  127. {python_neva-1.0.5 → python_neva-2.1.0}/tests/testing/test_fixtures.py +0 -0
  128. {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.5
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 tortoise import Tortoise
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
- create_db = config.pop("create_db", False)
34
- _ = await Tortoise.init(
35
- config=config,
36
- _create_db=create_db,
37
- _enable_global_fallback=True,
38
- )
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
 
@@ -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."""