python-neva 0.6.0.dev4__tar.gz → 1.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 (149) hide show
  1. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/.pre-commit-config.yaml +5 -0
  2. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/PKG-INFO +2 -1
  3. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/__init__.py +1 -0
  4. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/arch/__init__.py +1 -0
  5. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/arch/application.py +27 -4
  6. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/arch/facade.py +2 -1
  7. python_neva-1.0.0/neva/arch/faststream.py +76 -0
  8. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/arch/service_provider.py +10 -1
  9. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/config/__init__.py +1 -0
  10. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/config/base_providers.py +7 -1
  11. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/config/repository.py +1 -1
  12. python_neva-1.0.0/neva/database/__init__.py +16 -0
  13. python_neva-1.0.0/neva/database/connection.py +124 -0
  14. python_neva-1.0.0/neva/database/manager.py +44 -0
  15. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/database/provider.py +3 -0
  16. python_neva-1.0.0/neva/database/transaction.py +96 -0
  17. python_neva-1.0.0/neva/events/__init__.py +23 -0
  18. python_neva-1.0.0/neva/events/dispatcher.py +89 -0
  19. python_neva-1.0.0/neva/events/event.py +14 -0
  20. python_neva-1.0.0/neva/events/event_registry.py +44 -0
  21. python_neva-1.0.0/neva/events/listener.py +70 -0
  22. python_neva-1.0.0/neva/events/provider.py +27 -0
  23. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/obs/__init__.py +1 -0
  24. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/obs/logging/__init__.py +1 -0
  25. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/obs/logging/manager.py +0 -1
  26. python_neva-1.0.0/neva/obs/middleware/__init__.py +7 -0
  27. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/obs/middleware/profiler.py +4 -4
  28. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/__init__.py +2 -4
  29. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/encryption/__init__.py +1 -0
  30. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/encryption/encrypter.py +1 -4
  31. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/encryption/protocol.py +1 -0
  32. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/hashing/__init__.py +1 -0
  33. python_neva-1.0.0/neva/security/tokens/__init__.py +8 -0
  34. python_neva-1.0.0/neva/security/tokens/generate_token.py +16 -0
  35. python_neva-1.0.0/neva/security/tokens/hash_token.py +16 -0
  36. python_neva-0.6.0.dev4/neva/security/tokens/token_hash.py → python_neva-1.0.0/neva/security/tokens/verify_token.py +2 -14
  37. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/__init__.py +1 -0
  38. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/__init__.py +3 -0
  39. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/app.py +1 -1
  40. python_neva-1.0.0/neva/support/facade/db.py +16 -0
  41. python_neva-1.0.0/neva/support/facade/db.pyi +39 -0
  42. python_neva-1.0.0/neva/support/facade/event.py +15 -0
  43. python_neva-1.0.0/neva/support/facade/event.pyi +22 -0
  44. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/hash.py +1 -0
  45. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/hash.pyi +1 -0
  46. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/testing/__init__.py +1 -0
  47. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/testing/http.py +2 -1
  48. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/testing/test_case.py +1 -0
  49. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/pyproject.toml +2 -4
  50. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/ruff.toml +9 -1
  51. python_neva-1.0.0/tests/arch/__init__.py +1 -0
  52. {python_neva-0.6.0.dev4/tests → python_neva-1.0.0/tests/arch}/test_scope.py +1 -1
  53. python_neva-1.0.0/tests/config/__init__.py +1 -0
  54. python_neva-1.0.0/tests/config/test_loader.py +97 -0
  55. python_neva-1.0.0/tests/config/test_repository.py +193 -0
  56. python_neva-1.0.0/tests/database/__init__.py +1 -0
  57. python_neva-1.0.0/tests/database/conftest.py +30 -0
  58. python_neva-1.0.0/tests/database/test_connection_manager.py +84 -0
  59. python_neva-1.0.0/tests/database/test_database_manager.py +59 -0
  60. python_neva-1.0.0/tests/database/test_edge_cases.py +138 -0
  61. python_neva-1.0.0/tests/database/test_multi_connection.py +133 -0
  62. python_neva-1.0.0/tests/database/test_transaction.py +170 -0
  63. python_neva-1.0.0/tests/database/test_transaction_context.py +54 -0
  64. python_neva-1.0.0/tests/database/test_transaction_registry.py +42 -0
  65. python_neva-1.0.0/tests/events/__init__.py +1 -0
  66. python_neva-1.0.0/tests/events/conftest.py +59 -0
  67. python_neva-1.0.0/tests/events/test_deferred.py +109 -0
  68. python_neva-1.0.0/tests/events/test_dispatch.py +95 -0
  69. python_neva-1.0.0/tests/events/test_function_listener.py +117 -0
  70. python_neva-1.0.0/tests/events/test_immediate.py +45 -0
  71. python_neva-1.0.0/tests/obs/__init__.py +1 -0
  72. python_neva-1.0.0/tests/obs/test_correlation.py +123 -0
  73. python_neva-1.0.0/tests/obs/test_profiler.py +86 -0
  74. python_neva-1.0.0/tests/security/__init__.py +1 -0
  75. python_neva-1.0.0/tests/testing/__init__.py +1 -0
  76. {python_neva-0.6.0.dev4/tests → python_neva-1.0.0/tests/testing}/test_fixtures.py +0 -1
  77. {python_neva-0.6.0.dev4/tests → python_neva-1.0.0/tests/testing}/test_test_case.py +3 -2
  78. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/uv.lock +35 -1
  79. python_neva-0.6.0.dev4/neva/console/__init__.py +0 -1
  80. python_neva-0.6.0.dev4/neva/console/kernel.py +0 -47
  81. python_neva-0.6.0.dev4/neva/console/runner.py +0 -26
  82. python_neva-0.6.0.dev4/neva/database/__init__.py +0 -1
  83. python_neva-0.6.0.dev4/neva/database/manager.py +0 -15
  84. python_neva-0.6.0.dev4/neva/database/repository.py +0 -49
  85. python_neva-0.6.0.dev4/neva/events/__init__.py +0 -1
  86. python_neva-0.6.0.dev4/neva/events/dispatcher.py +0 -23
  87. python_neva-0.6.0.dev4/neva/events/event.py +0 -10
  88. python_neva-0.6.0.dev4/neva/events/event_registry.py +0 -20
  89. python_neva-0.6.0.dev4/neva/events/interface.py +0 -23
  90. python_neva-0.6.0.dev4/neva/events/listener.py +0 -21
  91. python_neva-0.6.0.dev4/neva/obs/middleware/__init__.py +0 -1
  92. python_neva-0.6.0.dev4/neva/security/tokens/__init__.py +0 -5
  93. python_neva-0.6.0.dev4/specifications/future_ideas.md +0 -21
  94. python_neva-0.6.0.dev4/specifications/security.md +0 -801
  95. python_neva-0.6.0.dev4/tests/test_example_usage.py +0 -175
  96. python_neva-0.6.0.dev4/wiki/architecture/01-overview.md +0 -54
  97. python_neva-0.6.0.dev4/wiki/architecture/02-dependency-injection.md +0 -133
  98. python_neva-0.6.0.dev4/wiki/architecture/03-service-providers.md +0 -217
  99. python_neva-0.6.0.dev4/wiki/architecture/04-facades.md +0 -199
  100. python_neva-0.6.0.dev4/wiki/architecture/05-application-lifecycle.md +0 -271
  101. python_neva-0.6.0.dev4/wiki/architecture/06-result-option.md +0 -277
  102. python_neva-0.6.0.dev4/wiki/configuration/01-overview.md +0 -59
  103. python_neva-0.6.0.dev4/wiki/configuration/02-configuration-files.md +0 -178
  104. python_neva-0.6.0.dev4/wiki/configuration/03-accessing-configuration.md +0 -129
  105. python_neva-0.6.0.dev4/wiki/configuration/04-config-repository.md +0 -119
  106. python_neva-0.6.0.dev4/wiki/configuration/05-loading-process.md +0 -154
  107. python_neva-0.6.0.dev4/wiki/configuration/06-configuration-in-providers.md +0 -174
  108. python_neva-0.6.0.dev4/wiki/testing/01-introduction.md +0 -68
  109. python_neva-0.6.0.dev4/wiki/testing/02-test-case.md +0 -87
  110. python_neva-0.6.0.dev4/wiki/testing/03-fixtures.md +0 -190
  111. python_neva-0.6.0.dev4/wiki/testing/04-http-testing.md +0 -253
  112. python_neva-0.6.0.dev4/wiki/testing/05-custom-configuration.md +0 -355
  113. python_neva-0.6.0.dev4/wiki/testing/06-test-isolation.md +0 -263
  114. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/.envrc +0 -0
  115. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/.gitignore +0 -0
  116. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/.python-version +0 -0
  117. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/README.md +0 -0
  118. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/arch/app.py +1 -1
  119. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/arch/config.py +0 -0
  120. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/config/loader.py +0 -0
  121. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/config/provider.py +1 -1
  122. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/database/config.py +0 -0
  123. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/obs/logging/provider.py +0 -0
  124. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/obs/middleware/correlation.py +1 -1
  125. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/py.typed +0 -0
  126. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/hashing/config.py +0 -0
  127. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/hashing/hash_manager.py +0 -0
  128. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/hashing/hashers/__init__.py +0 -0
  129. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/hashing/hashers/argon2.py +0 -0
  130. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  131. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/hashing/hashers/protocol.py +0 -0
  132. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/security/provider.py +0 -0
  133. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/accessors.py +0 -0
  134. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/app.pyi +0 -0
  135. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/config.py +0 -0
  136. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/config.pyi +0 -0
  137. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/crypt.py +0 -0
  138. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/crypt.pyi +0 -0
  139. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/log.py +0 -0
  140. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/facade/log.pyi +0 -0
  141. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/results.py +0 -0
  142. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/strategy.py +0 -0
  143. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/strconv.py +0 -0
  144. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/support/time.py +0 -0
  145. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/neva/testing/fixtures.py +0 -0
  146. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/tests/__init__.py +0 -0
  147. {python_neva-0.6.0.dev4 → python_neva-1.0.0}/tests/conftest.py +0 -0
  148. {python_neva-0.6.0.dev4/tests → python_neva-1.0.0/tests/security}/test_encrypter.py +0 -0
  149. {python_neva-0.6.0.dev4/tests → python_neva-1.0.0/tests/security}/test_hash_manager.py +0 -0
@@ -1,4 +1,9 @@
1
1
  repos:
2
+ - repo: https://github.com/gitleaks/gitleaks
3
+ rev: v8.24.2
4
+ hooks:
5
+ - id: gitleaks
6
+
2
7
  - repo: https://github.com/pre-commit/pre-commit-hooks
3
8
  rev: v6.0.0
4
9
  hooks:
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-neva
3
- Version: 0.6.0.dev4
3
+ Version: 1.0.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: cryptography>=46.0.3
7
7
  Requires-Dist: dishka>=1.7.2
8
8
  Requires-Dist: fastapi[all]>=0.124.0
9
+ Requires-Dist: faststream>=0.6.6
9
10
  Requires-Dist: flexmock>=0.13.0
10
11
  Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
11
12
  Requires-Dist: pyinstrument>=5.1.1
@@ -11,6 +11,7 @@ from neva.support import (
11
11
  from_optional,
12
12
  )
13
13
 
14
+
14
15
  __all__ = [
15
16
  "Err",
16
17
  "Nothing",
@@ -12,6 +12,7 @@ from neva.arch.service_provider import (
12
12
  ServiceProvider,
13
13
  )
14
14
 
15
+
15
16
  __all__ = [
16
17
  "App",
17
18
  "Application",
@@ -1,8 +1,8 @@
1
1
  """Base application for DI and facade injection."""
2
2
 
3
+ import os
3
4
  from collections.abc import AsyncIterator, Iterator
4
5
  from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
5
- import os
6
6
  from pathlib import Path
7
7
  from typing import Any, Callable, Self
8
8
 
@@ -10,8 +10,8 @@ import dishka
10
10
  from dishka.integrations.fastapi import FastapiProvider
11
11
 
12
12
  from neva import Err, Ok, Result
13
- from neva.arch.service_provider import Bootable, ServiceProvider
14
13
  from neva.arch.facade import Facade
14
+ from neva.arch.service_provider import Bootable, ServiceProvider
15
15
 
16
16
 
17
17
  class Application:
@@ -27,9 +27,9 @@ class Application:
27
27
  Raises:
28
28
  RuntimeError: If the application fails to initialize.
29
29
  """
30
- from neva.config.repository import ConfigRepository
31
- from neva.config.provider import ConfigServiceProvider
32
30
  from neva.config.base_providers import base_providers
31
+ from neva.config.provider import ConfigServiceProvider
32
+ from neva.config.repository import ConfigRepository
33
33
 
34
34
  self.providers: dict[type, ServiceProvider] = {}
35
35
  self.di_provider: dishka.Provider = dishka.Provider(scope=dishka.Scope.APP)
@@ -65,6 +65,7 @@ class Application:
65
65
  case Err(e):
66
66
  raise RuntimeError(f"Failed to load configuration during boot: {e}")
67
67
 
68
+ self._bind_event_listeners()
68
69
  self.container = dishka.make_container(self.di_provider)
69
70
 
70
71
  def bind_to_fastapi(self) -> None:
@@ -141,6 +142,28 @@ class Application:
141
142
  if isinstance(provider, Bootable):
142
143
  await stack.enter_async_context(provider.lifespan())
143
144
 
145
+ self._wire_event_listeners()
144
146
  yield
145
147
 
146
148
  Facade.reset_facade_application()
149
+
150
+ def _bind_event_listeners(self) -> None:
151
+ """Bind all listener classes declared in provider ``listen`` dicts."""
152
+ for provider in self.providers.values():
153
+ for listeners in provider.listen.values():
154
+ for listener_cls in listeners:
155
+ self.bind(listener_cls)
156
+
157
+ def _wire_event_listeners(self) -> None:
158
+ """Wire event-listener mappings from all providers onto the dispatcher."""
159
+ from neva.events.dispatcher import EventDispatcher
160
+
161
+ result = self.make(EventDispatcher)
162
+ if result.is_err:
163
+ return
164
+
165
+ dispatcher = result.unwrap()
166
+ for provider in self.providers.values():
167
+ for event_cls, listeners in provider.listen.items():
168
+ for listener_cls in listeners:
169
+ dispatcher.listen(event_cls, listener_cls)
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, ClassVar
11
11
  from neva import Option, Result, from_optional
12
12
  from neva.support.accessors import get_attr
13
13
 
14
+
14
15
  if TYPE_CHECKING:
15
16
  from neva.arch.application import Application
16
17
 
@@ -91,7 +92,7 @@ class FacadeMeta(ABCMeta):
91
92
  cls._get_app()
92
93
  .ok_or(
93
94
  f"A facade root (App instance) has not been set for {cls.__name__}. "
94
- + "Call Facade.set_facade_application(app) first."
95
+ "Call Facade.set_facade_application(app) first."
95
96
  )
96
97
  .and_then(
97
98
  lambda x: cls._resolve_facade_instance(
@@ -0,0 +1,76 @@
1
+ """FastStream wrapper for Neva."""
2
+
3
+ from collections.abc import AsyncGenerator, AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+ from typing import Any, Callable
6
+
7
+ import dishka
8
+ import faststream
9
+ from faststream._internal.broker import BrokerUsecase
10
+ from faststream._internal.configs import BrokerConfig
11
+ from starlette.types import StatelessLifespan
12
+
13
+ from neva import Result
14
+ from neva.arch import Application, ServiceProvider
15
+
16
+
17
+ class FastStream(faststream.FastStream):
18
+ """FastStream wrapper for Neva."""
19
+
20
+ def __init__(
21
+ self,
22
+ broker: BrokerUsecase[Any, Any, BrokerConfig],
23
+ config_path: str | None = None,
24
+ ) -> None:
25
+ """Initialize the FastStream wrapper."""
26
+ self.application: Application = Application(config_path=config_path)
27
+ super().__init__(broker, lifespan=self._create_lifespan())
28
+
29
+ def register(
30
+ self,
31
+ provider: type[ServiceProvider],
32
+ ) -> Result[ServiceProvider, str]:
33
+ """Registers a service provider with the application.
34
+
35
+ Returns:
36
+ Result containing the registered provider instance or an error message.
37
+ """
38
+ return self.application.register(provider=provider)
39
+
40
+ def bind(
41
+ self,
42
+ source: type | Callable[..., Any],
43
+ *,
44
+ interface: type | None = None,
45
+ scope: dishka.BaseScope | None = None,
46
+ ) -> None:
47
+ """Binds a source to the container."""
48
+ self.application.bind(
49
+ source=source,
50
+ interface=interface,
51
+ scope=scope,
52
+ )
53
+
54
+ def make[T](self, interface: type[T]) -> Result[T, str]:
55
+ """Resolve and instanciate a type from the container.
56
+
57
+ Returns:
58
+ Result containing the resolved type instance or an error message.
59
+ """
60
+ return self.application.make(interface=interface)
61
+
62
+ @asynccontextmanager
63
+ async def lifespan(self) -> AsyncGenerator[None, None]:
64
+ """Async context manager for the application lifespan."""
65
+ async with self.application.lifespan():
66
+ yield
67
+
68
+ def _create_lifespan(
69
+ self,
70
+ ) -> StatelessLifespan["FastStream"]:
71
+ @asynccontextmanager
72
+ async def composed_lifespan(app: faststream.FastStream) -> AsyncIterator[None]:
73
+ async with self.lifespan():
74
+ yield
75
+
76
+ return composed_lifespan
@@ -5,14 +5,19 @@ Service providers are responsible for binding services into the dependency injec
5
5
  container and optionally managing their lifecycle.
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
8
10
  import abc
9
11
  from contextlib import AbstractAsyncContextManager
10
- from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable
12
+ from typing import TYPE_CHECKING, Any, ClassVar, Protocol, Self, runtime_checkable
11
13
 
12
14
  from neva import Result
13
15
 
16
+
14
17
  if TYPE_CHECKING:
15
18
  from neva.arch.application import Application
19
+ from neva.events.event import Event
20
+ from neva.events.listener import EventListener
16
21
 
17
22
 
18
23
  @runtime_checkable
@@ -46,10 +51,14 @@ class ServiceProvider(abc.ABC):
46
51
 
47
52
  Attributes:
48
53
  app: The application instance.
54
+ listen: A mapping of event types to listener classes. Listeners
55
+ declared here are automatically bound into the container during
56
+ registration and wired to the event dispatcher during boot.
49
57
 
50
58
  """
51
59
 
52
60
  app: "Application"
61
+ listen: ClassVar[dict[type[Event], list[type[EventListener[Any]]]]] = {}
53
62
 
54
63
  def __init__(self, app: "Application") -> None:
55
64
  """Initialize the service provider.
@@ -3,6 +3,7 @@
3
3
  from neva.config.provider import ConfigServiceProvider
4
4
  from neva.config.repository import ConfigRepository
5
5
 
6
+
6
7
  __all__ = [
7
8
  "ConfigRepository",
8
9
  "ConfigServiceProvider",
@@ -6,6 +6,8 @@ to function properly.
6
6
  """
7
7
 
8
8
  from neva.arch import ServiceProvider
9
+ from neva.database import DatabaseServiceProvider
10
+ from neva.events.provider import EventServiceProvider
9
11
  from neva.obs import LogServiceProvider
10
12
 
11
13
 
@@ -22,4 +24,8 @@ def base_providers() -> set[type[ServiceProvider]]:
22
24
  Set of service provider classes to register.
23
25
 
24
26
  """
25
- return {LogServiceProvider}
27
+ return {
28
+ DatabaseServiceProvider,
29
+ EventServiceProvider,
30
+ LogServiceProvider,
31
+ }
@@ -53,7 +53,7 @@ class ConfigRepository:
53
53
  elif not isinstance(current[k], dict):
54
54
  return Err(
55
55
  f"Cannot set '{key}': '{k}' is not a dictionary "
56
- + f"(found {type(current[k]).__name__})"
56
+ f"(found {type(current[k]).__name__})"
57
57
  )
58
58
  current = current[k]
59
59
 
@@ -0,0 +1,16 @@
1
+ """Database module."""
2
+
3
+ from neva.database.config import DatabaseConfig
4
+ from neva.database.connection import TransactionContext
5
+ from neva.database.manager import DatabaseManager
6
+ from neva.database.provider import DatabaseServiceProvider
7
+ from neva.database.transaction import Transaction
8
+
9
+
10
+ __all__ = [
11
+ "DatabaseConfig",
12
+ "DatabaseManager",
13
+ "DatabaseServiceProvider",
14
+ "Transaction",
15
+ "TransactionContext",
16
+ ]
@@ -0,0 +1,124 @@
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 tortoise.transactions import in_transaction
10
+
11
+ from neva import Nothing, Option, Some, from_optional
12
+ from neva.database.transaction import 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
+ ) -> None:
77
+ self.name = name
78
+ self.tx_context = tx_context
79
+ self.logger = logger
80
+
81
+ @asynccontextmanager
82
+ async def transaction(
83
+ self,
84
+ ) -> AsyncIterator[Transaction]:
85
+ """Open a new transaction.
86
+
87
+ Yields:
88
+ Transaction: The new transaction.
89
+ """
90
+ parent = self.tx_context.current(self.name)
91
+ tx = Transaction(self.name)
92
+ tx.parent = parent
93
+ old_registry = _tx_registry.get()
94
+ token = (
95
+ _tx_registry.set(old_registry.extend(tx))
96
+ if old_registry is not None
97
+ else _tx_registry.set(TransactionRegistry())
98
+ )
99
+
100
+ try:
101
+ async with in_transaction(self.name):
102
+ yield tx
103
+ tx.state = TransactionState.COMMITTED
104
+ if tx.is_root:
105
+ for result in await tx.execute_on_commit_callbacks():
106
+ if result.is_err and self.logger is not None:
107
+ self.logger.error(
108
+ "commit callback failed",
109
+ error=result.err().unwrap(),
110
+ connection=self.name,
111
+ )
112
+ except BaseException:
113
+ tx.state = TransactionState.ROLLED_BACK
114
+ if tx.is_root:
115
+ for result in await tx.execute_on_rollback_callbacks():
116
+ if result.is_err and self.logger is not None:
117
+ self.logger.error(
118
+ "rollback callback failed",
119
+ error=result.err().unwrap(),
120
+ connection=self.name,
121
+ )
122
+ raise
123
+ finally:
124
+ _tx_registry.reset(token)
@@ -0,0 +1,44 @@
1
+ """Database manager."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+ from typing import final
6
+
7
+ from neva import Option
8
+ from neva.database.connection import ConnectionManager, TransactionContext
9
+ from neva.database.transaction import Transaction
10
+ from neva.obs import LogManager
11
+
12
+
13
+ @final
14
+ class DatabaseManager:
15
+ """Database manager."""
16
+
17
+ def __init__(self, tx_context: TransactionContext, logger: LogManager) -> None:
18
+ self._tx_context = tx_context
19
+ self._logger = logger
20
+ self._connections: dict[str, ConnectionManager] = {}
21
+
22
+ def connection(self, name: str) -> ConnectionManager:
23
+ """Returns a new connection manager."""
24
+ return self._connections.setdefault(
25
+ name, ConnectionManager(name, self._tx_context, self._logger)
26
+ )
27
+
28
+ def current(self, connection: str | None = None) -> Option[Transaction]:
29
+ """Returns the current transaction.
30
+
31
+ If no connection is specified, this will return the most recent transaction on
32
+ any connection. This is something to keep in mind.
33
+ """
34
+ return self._tx_context.current(connection)
35
+
36
+ @asynccontextmanager
37
+ async def transaction(self, name: str = "default") -> AsyncIterator[Transaction]:
38
+ """Returns a transaction to the default connection.
39
+
40
+ Yields:
41
+ Transaction: The transaction.
42
+ """
43
+ async with self.connection(name).transaction() as tx:
44
+ yield tx
@@ -5,8 +5,10 @@ from contextlib import asynccontextmanager
5
5
  from typing import Self, override
6
6
 
7
7
  from tortoise import Tortoise
8
+
8
9
  from neva import Err, Ok, Result
9
10
  from neva.arch import ServiceProvider
11
+ from neva.database.connection import TransactionContext
10
12
  from neva.database.manager import DatabaseManager
11
13
  from neva.support.facade import Config, Log
12
14
 
@@ -16,6 +18,7 @@ class DatabaseServiceProvider(ServiceProvider):
16
18
 
17
19
  @override
18
20
  def register(self) -> Result[Self, str]:
21
+ self.app.bind(TransactionContext)
19
22
  self.app.bind(DatabaseManager)
20
23
  return Ok(self)
21
24
 
@@ -0,0 +1,96 @@
1
+ """Transaction management systems."""
2
+
3
+ from collections.abc import Awaitable
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum, auto
6
+ from typing import Callable, Self
7
+
8
+ from neva import Err, Nothing, Option, Result, Some
9
+
10
+
11
+ type TransactionCallback = Callable[[], Awaitable[Result[None, str]]]
12
+
13
+
14
+ class TransactionState(Enum):
15
+ """Represents the state of a transaction."""
16
+
17
+ ACTIVE = auto()
18
+ COMMITTED = auto()
19
+ ROLLED_BACK = auto()
20
+
21
+
22
+ @dataclass
23
+ class Transaction:
24
+ """Represents a database transaction with callback support."""
25
+
26
+ conn_name: str
27
+ state: TransactionState = field(default=TransactionState.ACTIVE, init=False)
28
+ _on_commit: list[TransactionCallback] = field(default_factory=list, init=False)
29
+ _on_rollback: list[TransactionCallback] = field(default_factory=list, init=False)
30
+ parent: Option["Transaction"] = field(default_factory=Nothing, init=False)
31
+
32
+ @property
33
+ def is_active(self) -> bool:
34
+ """Determine if the transaction is active."""
35
+ return self.state == TransactionState.ACTIVE
36
+
37
+ @property
38
+ def is_root(self) -> bool:
39
+ """Determine if the transaction is the root transaction."""
40
+ return self.parent.is_nothing
41
+
42
+ def on_commit(self, callback: TransactionCallback) -> Self:
43
+ """Register a callback to be called when the root transaction is committed.
44
+
45
+ Returns:
46
+ Self: The current transaction, for chaining purposes.
47
+ """
48
+ match self.parent:
49
+ case Some(parent):
50
+ _ = parent.on_commit(callback)
51
+ case Nothing():
52
+ self._on_commit.append(callback)
53
+ return self
54
+
55
+ def on_rollback(self, callback: TransactionCallback) -> Self:
56
+ """Register a callback to be called when the root transaction is rolled back.
57
+
58
+ Returns:
59
+ Self: The current transaction, for chaining purposes.
60
+ """
61
+ match self.parent:
62
+ case Some(parent):
63
+ _ = parent.on_rollback(callback)
64
+ case Nothing():
65
+ self._on_rollback.append(callback)
66
+ return self
67
+
68
+ async def execute_on_commit_callbacks(self) -> list[Result[None, str]]:
69
+ """Execute all registered commit callbacks.
70
+
71
+ Returns:
72
+ A list of results from each callback.
73
+ """
74
+ results: list[Result[None, str]] = []
75
+ for callback in self._on_commit:
76
+ try:
77
+ results.append(await callback())
78
+ except Exception as e:
79
+ results.append(Err(f"Callback raised: {e}"))
80
+ self._on_commit.clear()
81
+ return results
82
+
83
+ async def execute_on_rollback_callbacks(self) -> list[Result[None, str]]:
84
+ """Execute all registered rollback callbacks.
85
+
86
+ Returns:
87
+ A list of results from each callback.
88
+ """
89
+ results: list[Result[None, str]] = []
90
+ for callback in self._on_rollback:
91
+ try:
92
+ results.append(await callback())
93
+ except Exception as e:
94
+ results.append(Err(f"Callback raised: {e}"))
95
+ self._on_rollback.clear()
96
+ return results
@@ -0,0 +1,23 @@
1
+ """Defines the events system.
2
+
3
+ This module defines the event system, which is used to dispatch events to listener.
4
+ Three components are of particular interest:
5
+ - The base Event class, which is used to define events.
6
+ - The @listener decorator, which is used to define listeners from handler functions.
7
+ - The EventDispatcher class, which is used to registers listeners and dispatch events.
8
+ """
9
+
10
+ from neva.events.dispatcher import EventDispatcher
11
+ from neva.events.event import Event
12
+ from neva.events.listener import EventListener, HandlingPolicy, listener
13
+ from neva.events.provider import EventServiceProvider
14
+
15
+
16
+ __all__ = [
17
+ "Event",
18
+ "EventDispatcher",
19
+ "EventListener",
20
+ "EventServiceProvider",
21
+ "HandlingPolicy",
22
+ "listener",
23
+ ]