python-neva 2.0.0__tar.gz → 2.2.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 (123) hide show
  1. {python_neva-2.0.0 → python_neva-2.2.0}/.pre-commit-config.yaml +5 -8
  2. {python_neva-2.0.0 → python_neva-2.2.0}/PKG-INFO +1 -1
  3. {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/app.py +3 -3
  4. {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/application.py +25 -31
  5. {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/facade.py +88 -2
  6. {python_neva-2.0.0 → python_neva-2.2.0}/neva/config/__init__.py +0 -2
  7. {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/connection.py +42 -14
  8. {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/transaction.py +5 -0
  9. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/encryption/encrypter.py +3 -1
  10. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/__init__.py +2 -0
  11. python_neva-2.2.0/neva/support/facade/event.py +53 -0
  12. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/results.py +4 -4
  13. python_neva-2.2.0/neva/testing/__init__.py +7 -0
  14. python_neva-2.2.0/neva/testing/fakes.py +78 -0
  15. {python_neva-2.0.0 → python_neva-2.2.0}/neva/testing/fixtures.py +2 -3
  16. {python_neva-2.0.0 → python_neva-2.2.0}/neva/testing/test_case.py +39 -7
  17. {python_neva-2.0.0 → python_neva-2.2.0}/pyproject.toml +8 -6
  18. python_neva-2.2.0/tests/testing/test_event_fake.py +111 -0
  19. python_neva-2.2.0/tests/testing/test_facade_restore.py +57 -0
  20. python_neva-2.2.0/tests/testing/test_refresh_database.py +111 -0
  21. {python_neva-2.0.0 → python_neva-2.2.0}/uv.lock +136 -51
  22. python_neva-2.0.0/neva/config/provider.py +0 -74
  23. python_neva-2.0.0/neva/support/facade/event.py +0 -15
  24. python_neva-2.0.0/neva/testing/__init__.py +0 -6
  25. {python_neva-2.0.0 → python_neva-2.2.0}/.envrc +0 -0
  26. {python_neva-2.0.0 → python_neva-2.2.0}/.gitignore +0 -0
  27. {python_neva-2.0.0 → python_neva-2.2.0}/.python-version +0 -0
  28. {python_neva-2.0.0 → python_neva-2.2.0}/README.md +0 -0
  29. {python_neva-2.0.0 → python_neva-2.2.0}/neva/__init__.py +0 -0
  30. {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/__init__.py +0 -0
  31. {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/config.py +0 -0
  32. {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/faststream.py +0 -0
  33. {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/service_provider.py +0 -0
  34. {python_neva-2.0.0 → python_neva-2.2.0}/neva/config/base_providers.py +0 -0
  35. {python_neva-2.0.0 → python_neva-2.2.0}/neva/config/loader.py +0 -0
  36. {python_neva-2.0.0 → python_neva-2.2.0}/neva/config/repository.py +0 -0
  37. {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/__init__.py +0 -0
  38. {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/config.py +0 -0
  39. {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/manager.py +0 -0
  40. {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/provider.py +0 -0
  41. {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/__init__.py +0 -0
  42. {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/dispatcher.py +0 -0
  43. {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/event.py +0 -0
  44. {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/event_registry.py +0 -0
  45. {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/listener.py +0 -0
  46. {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/provider.py +0 -0
  47. {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/__init__.py +0 -0
  48. {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/logging/__init__.py +0 -0
  49. {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/logging/manager.py +0 -0
  50. {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/logging/provider.py +0 -0
  51. {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/middleware/__init__.py +0 -0
  52. {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/middleware/correlation.py +0 -0
  53. {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/middleware/profiler.py +0 -0
  54. {python_neva-2.0.0 → python_neva-2.2.0}/neva/py.typed +0 -0
  55. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/__init__.py +0 -0
  56. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/encryption/__init__.py +0 -0
  57. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/encryption/protocol.py +0 -0
  58. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/__init__.py +0 -0
  59. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/config.py +0 -0
  60. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hash_manager.py +0 -0
  61. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hashers/__init__.py +0 -0
  62. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hashers/argon2.py +0 -0
  63. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  64. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hashers/protocol.py +0 -0
  65. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/provider.py +0 -0
  66. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/tokens/__init__.py +0 -0
  67. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/tokens/generate_token.py +0 -0
  68. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/tokens/hash_token.py +0 -0
  69. {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/tokens/verify_token.py +0 -0
  70. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/__init__.py +0 -0
  71. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/accessors.py +0 -0
  72. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/app.py +0 -0
  73. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/app.pyi +0 -0
  74. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/config.py +0 -0
  75. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/config.pyi +0 -0
  76. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/crypt.py +0 -0
  77. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/crypt.pyi +0 -0
  78. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/db.py +0 -0
  79. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/db.pyi +0 -0
  80. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/event.pyi +0 -0
  81. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/hash.py +0 -0
  82. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/hash.pyi +0 -0
  83. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/log.py +0 -0
  84. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/log.pyi +0 -0
  85. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/strategy.py +0 -0
  86. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/strconv.py +0 -0
  87. {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/time.py +0 -0
  88. {python_neva-2.0.0 → python_neva-2.2.0}/neva/testing/http.py +0 -0
  89. {python_neva-2.0.0 → python_neva-2.2.0}/ruff.toml +0 -0
  90. {python_neva-2.0.0 → python_neva-2.2.0}/tests/__init__.py +0 -0
  91. {python_neva-2.0.0 → python_neva-2.2.0}/tests/arch/__init__.py +0 -0
  92. {python_neva-2.0.0 → python_neva-2.2.0}/tests/arch/test_scope.py +0 -0
  93. {python_neva-2.0.0 → python_neva-2.2.0}/tests/config/__init__.py +0 -0
  94. {python_neva-2.0.0 → python_neva-2.2.0}/tests/config/test_loader.py +0 -0
  95. {python_neva-2.0.0 → python_neva-2.2.0}/tests/config/test_repository.py +0 -0
  96. {python_neva-2.0.0 → python_neva-2.2.0}/tests/conftest.py +0 -0
  97. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/__init__.py +0 -0
  98. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/conftest.py +0 -0
  99. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_connection_manager.py +0 -0
  100. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_database_manager.py +0 -0
  101. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_edge_cases.py +0 -0
  102. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_multi_connection.py +0 -0
  103. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  104. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_transaction.py +0 -0
  105. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_transaction_context.py +0 -0
  106. {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_transaction_registry.py +0 -0
  107. {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/__init__.py +0 -0
  108. {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/conftest.py +0 -0
  109. {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_deferred.py +0 -0
  110. {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_dispatch.py +0 -0
  111. {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_event.py +0 -0
  112. {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_event_registry.py +0 -0
  113. {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_function_listener.py +0 -0
  114. {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_immediate.py +0 -0
  115. {python_neva-2.0.0 → python_neva-2.2.0}/tests/obs/__init__.py +0 -0
  116. {python_neva-2.0.0 → python_neva-2.2.0}/tests/obs/test_correlation.py +0 -0
  117. {python_neva-2.0.0 → python_neva-2.2.0}/tests/obs/test_profiler.py +0 -0
  118. {python_neva-2.0.0 → python_neva-2.2.0}/tests/security/__init__.py +0 -0
  119. {python_neva-2.0.0 → python_neva-2.2.0}/tests/security/test_encrypter.py +0 -0
  120. {python_neva-2.0.0 → python_neva-2.2.0}/tests/security/test_hash_manager.py +0 -0
  121. {python_neva-2.0.0 → python_neva-2.2.0}/tests/testing/__init__.py +0 -0
  122. {python_neva-2.0.0 → python_neva-2.2.0}/tests/testing/test_fixtures.py +0 -0
  123. {python_neva-2.0.0 → python_neva-2.2.0}/tests/testing/test_test_case.py +0 -0
@@ -1,6 +1,6 @@
1
1
  repos:
2
2
  - repo: https://github.com/gitleaks/gitleaks
3
- rev: v8.24.2
3
+ rev: v8.30.1
4
4
  hooks:
5
5
  - id: gitleaks
6
6
 
@@ -16,16 +16,13 @@ repos:
16
16
  - id: debug-statements
17
17
 
18
18
  - repo: https://github.com/astral-sh/ruff-pre-commit
19
- rev: v0.13.3
19
+ rev: v0.15.6
20
20
  hooks:
21
21
  - id: ruff
22
22
  args: [--fix, --exit-non-zero-on-fix]
23
23
  - id: ruff-format
24
24
 
25
- - repo: local
25
+ - repo: https://github.com/pre-commit/mirrors-mypy
26
+ rev: v1.19.1
26
27
  hooks:
27
- - id: ty
28
- name: ty check
29
- entry: ty check . --project neva
30
- files: ^neva/
31
- language: system
28
+ - id: mypy
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-neva
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -13,6 +13,7 @@ from typing import Any, Callable
13
13
  import dishka
14
14
  import fastapi
15
15
  from dishka.integrations.fastapi import setup_dishka
16
+ from starlette.middleware import Middleware
16
17
  from starlette.routing import BaseRoute
17
18
  from starlette.types import StatefulLifespan, StatelessLifespan
18
19
 
@@ -28,7 +29,7 @@ class App(fastapi.FastAPI):
28
29
  self,
29
30
  *,
30
31
  routes: list[BaseRoute] | None = None,
31
- middlewares: Sequence[type] | None = None,
32
+ middlewares: Sequence[Middleware] | None = None,
32
33
  lifespan: StatelessLifespan["App"] | StatefulLifespan["App"] | None = None,
33
34
  config_path: str | Path | None = None,
34
35
  ) -> None:
@@ -58,9 +59,8 @@ class App(fastapi.FastAPI):
58
59
  docs_url=config.get("app.docs_url", default="/docs").unwrap(),
59
60
  redoc_url=config.get("app.redoc_url", default="/redoc").unwrap(),
60
61
  lifespan=self._create_lifespan(),
62
+ middleware=middlewares,
61
63
  )
62
- for middleware in middlewares or []:
63
- self.add_middleware(middleware)
64
64
 
65
65
  setup_dishka(self.application.container, app=self)
66
66
 
@@ -12,6 +12,7 @@ from dishka.integrations.fastapi import FastapiProvider
12
12
  from neva import Err, Ok, Result
13
13
  from neva.arch.facade import Facade
14
14
  from neva.arch.service_provider import Bootable, ServiceProvider
15
+ from neva.config.loader import ConfigLoader
15
16
 
16
17
 
17
18
  class Application:
@@ -28,45 +29,38 @@ class Application:
28
29
  RuntimeError: If the application fails to initialize.
29
30
  """
30
31
  from neva.config.base_providers import base_providers
31
- from neva.config.provider import ConfigServiceProvider
32
32
  from neva.config.repository import ConfigRepository
33
33
 
34
+ self.config: ConfigRepository = ConfigRepository()
34
35
  self.providers: dict[type, ServiceProvider] = {}
35
36
  self.di_provider: dishka.Provider = dishka.Provider(scope=dishka.Scope.APP)
36
37
 
37
- configuration_path = config_path or os.getenv("NEVA_CONFIG_PATH", default=None)
38
- config_provider = ConfigServiceProvider(
39
- app=self,
40
- config_path=configuration_path,
41
- ).register()
42
- if config_provider.is_err:
43
- raise RuntimeError(
44
- f"Failed to register config provider: {config_provider.unwrap_err()}"
45
- )
46
- self.providers[ConfigServiceProvider] = config_provider.unwrap()
47
-
48
- self.register_providers(base_providers())
49
- _ = self.di_provider.provide(source=lambda: self, provides=Application)
50
-
51
- self.container: dishka.Container = dishka.make_container(self.di_provider)
52
-
53
- config_result: Result[ConfigRepository, str] = self.make(
54
- interface=ConfigRepository
38
+ configuration_path = (
39
+ config_path
40
+ if config_path is not None
41
+ else os.getenv("NEVA_CONFIG_PATH", default=Path.cwd())
55
42
  )
56
- match config_result:
57
- case Ok(config):
58
- self.config: ConfigRepository = config
59
- providers_from_file = config.get("providers.providers").unwrap_or([])
60
- providers_from_app = config.get("app.providers").unwrap_or([])
61
- providers: set[type[ServiceProvider]] = set(providers_from_file).union(
62
- set(providers_from_app)
63
- )
64
- _ = self.register_providers(providers)
43
+ match ConfigLoader(configuration_path).load_all():
65
44
  case Err(e):
66
- raise RuntimeError(f"Failed to load configuration during boot: {e}")
67
-
45
+ raise RuntimeError(f"Failed to register config: {e}")
46
+ case Ok(configs):
47
+ if any(
48
+ self.config.merge(namespace, config).is_err
49
+ for namespace, config in configs.items()
50
+ ):
51
+ raise RuntimeError("Failed to register config")
52
+
53
+ self.bind(lambda: self.config, interface=ConfigRepository)
54
+
55
+ providers_from_file = self.config.get("providers.providers").unwrap_or([])
56
+ providers_from_app = self.config.get("app.providers").unwrap_or([])
57
+ providers: set[type[ServiceProvider]] = set(providers_from_file).union(
58
+ set(providers_from_app)
59
+ )
60
+ self.register_providers(base_providers().union(providers))
61
+ self.bind(source=lambda: self, interface=Application)
68
62
  self._bind_event_listeners()
69
- self.container = dishka.make_container(self.di_provider)
63
+ self.container: dishka.Container = dishka.make_container(self.di_provider)
70
64
 
71
65
  def bind_to_fastapi(self) -> None:
72
66
  """Setup the FastapiProvider for FastAPI integration."""
@@ -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 typing import TYPE_CHECKING, Any, ClassVar
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
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,69 @@ 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
+ real: object = (
154
+ cls._get_app()
155
+ .ok_or(f"Cannot spy on {cls.__name__}: no application set.")
156
+ .and_then(lambda a: a.make(cls.get_facade_accessor()))
157
+ .unwrap()
158
+ )
159
+ spy_obj = MagicMock(spec=type(real), wraps=real)
160
+ cls.swap(spy_obj)
161
+ return spy_obj
162
+
163
+ def restore(cls) -> None:
164
+ """Restore the facade to its real service, removing any fake or spy."""
165
+ FacadeMeta._fake_instances.pop(cls, None)
166
+
167
+ @contextmanager
168
+ def faking(cls) -> Iterator[AsyncMock]:
169
+ """Context manager that fakes the facade and restores it on exit.
170
+
171
+ Yields:
172
+ The AsyncMock installed as the facade root.
173
+
174
+ """
175
+ mock = cls.fake()
176
+ try:
177
+ yield mock
178
+ finally:
179
+ cls.restore()
180
+
114
181
  def _resolve_facade_instance[T](
115
182
  cls,
116
183
  app: "Application",
@@ -118,6 +185,9 @@ class FacadeMeta(ABCMeta):
118
185
  ) -> Result[T, str]:
119
186
  """Resolve the service instance from the container.
120
187
 
188
+ If a fake or swap has been registered for this facade, it is returned
189
+ directly without touching the container.
190
+
121
191
  Args:
122
192
  app: The application instance.
123
193
  interface: The interface being facaded.
@@ -126,6 +196,8 @@ class FacadeMeta(ABCMeta):
126
196
  Result containing the resolved service or an error message.
127
197
 
128
198
  """
199
+ if cls in FacadeMeta._fake_instances:
200
+ return Ok(cast(T, FacadeMeta._fake_instances[cls]))
129
201
  return app.make(interface)
130
202
 
131
203
 
@@ -174,3 +246,17 @@ class Facade(ABC, metaclass=FacadeMeta):
174
246
  rarely need to be called manually.
175
247
  """
176
248
  cls._app = None
249
+
250
+ @classmethod
251
+ def restore_all(cls) -> None:
252
+ """Restore all faked or swapped facades to their real services.
253
+
254
+ Intended for test teardown. Typical usage is an autouse fixture::
255
+
256
+ @pytest.fixture(autouse=True)
257
+ def restore_facades():
258
+ yield
259
+ Facade.restore_all()
260
+
261
+ """
262
+ FacadeMeta._fake_instances.clear()
@@ -1,10 +1,8 @@
1
1
  """Configuration system."""
2
2
 
3
- from neva.config.provider import ConfigServiceProvider
4
3
  from neva.config.repository import ConfigRepository
5
4
 
6
5
 
7
6
  __all__ = [
8
7
  "ConfigRepository",
9
- "ConfigServiceProvider",
10
8
  ]
@@ -95,15 +95,26 @@ class ConnectionManager:
95
95
  )
96
96
  try:
97
97
  yield
98
- tx.state = TransactionState.COMMITTED
99
- if tx.is_root:
100
- for result in await tx.execute_on_commit_callbacks():
101
- if result.is_err and self.logger is not None:
102
- self.logger.error(
103
- "commit callback failed",
104
- error=result.err().unwrap(),
105
- connection=self.name,
106
- )
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
+ )
107
118
  except BaseException:
108
119
  tx.state = TransactionState.ROLLED_BACK
109
120
  if tx.is_root:
@@ -160,12 +171,29 @@ class ConnectionManager:
160
171
  tx = Transaction(self.name)
161
172
  tx.parent = parent
162
173
  bound = tx.begin(parent_tx.session)
163
- async with self._scoped(bound), parent_tx.session.begin_nested():
164
- yield bound
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
165
185
  case _:
166
186
  tx = Transaction(self.name)
167
187
  tx.parent = Nothing()
168
- async with self.session_factory() as session, session.begin():
188
+ async with self.session_factory() as session:
169
189
  bound = tx.begin(session)
170
- async with self._scoped(bound):
171
- yield bound
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
@@ -27,6 +27,7 @@ class Transaction:
27
27
 
28
28
  conn_name: str
29
29
  state: TransactionState = field(default=TransactionState.ACTIVE, init=False)
30
+ rollback_requested: bool = field(default=False, init=False)
30
31
  _on_commit: list[TransactionCallback] = field(default_factory=list, init=False)
31
32
  _on_rollback: list[TransactionCallback] = field(default_factory=list, init=False)
32
33
  parent: Option["Transaction"] = field(default_factory=Nothing, init=False)
@@ -97,6 +98,10 @@ class Transaction:
97
98
  self._on_rollback.clear()
98
99
  return results
99
100
 
101
+ async def rollback(self) -> None:
102
+ """Mark this transaction for rollback on exit."""
103
+ self.rollback_requested = True
104
+
100
105
  def begin(self, session: "AsyncSession") -> "BoundTransaction":
101
106
  """Bind this transaction to a session, returning a BoundTransaction.
102
107
 
@@ -51,7 +51,9 @@ class AesEncrypter:
51
51
  Returns:
52
52
  Ok with base64-encoded encrypted payload, or Err with message.
53
53
  """
54
- wrapper = {"__str__": value} if isinstance(value, str) else {"__json__": value}
54
+ wrapper: dict[str, JsonValue] = (
55
+ {"__str__": value} if isinstance(value, str) else {"__json__": value}
56
+ )
55
57
 
56
58
  try:
57
59
  payload = json.dumps(wrapper)
@@ -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,53 @@
1
+ """Event facade."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from typing import TYPE_CHECKING, override
6
+
7
+ from neva.arch import Facade
8
+ from neva.events import EventDispatcher
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from neva.testing.fakes import EventFake
13
+
14
+
15
+ class Event(Facade):
16
+ """Event system facade."""
17
+
18
+ @classmethod
19
+ @override
20
+ def get_facade_accessor(cls) -> type:
21
+ """Return the service class backed by this facade.
22
+
23
+ Returns:
24
+ EventDispatcher class.
25
+ """
26
+ return EventDispatcher
27
+
28
+ @classmethod
29
+ def fake(cls) -> "EventFake":
30
+ """Replace the dispatcher with an EventFake and return it.
31
+
32
+ Returns:
33
+ The installed EventFake instance.
34
+ """
35
+ from neva.testing.fakes import EventFake
36
+
37
+ instance = EventFake()
38
+ cls.swap(instance)
39
+ return instance
40
+
41
+ @classmethod
42
+ @contextmanager
43
+ def faking(cls) -> "Iterator[EventFake]":
44
+ """Context manager that installs an EventFake and restores on exit.
45
+
46
+ Yields:
47
+ The installed EventFake instance.
48
+ """
49
+ instance = cls.fake()
50
+ try:
51
+ yield instance
52
+ finally:
53
+ cls.restore()
@@ -246,7 +246,7 @@ class OptionProtocol[T](ABC):
246
246
 
247
247
 
248
248
  @dataclass(eq=True, frozen=True)
249
- class Some(OptionProtocol[T]):
249
+ class Some[T](OptionProtocol[T]):
250
250
  """An Option containing a value.
251
251
 
252
252
  Represents the presence of a value in an Option type. All transformation
@@ -330,7 +330,7 @@ class Some(OptionProtocol[T]):
330
330
 
331
331
 
332
332
  @dataclass(eq=True, frozen=True)
333
- class Nothing(OptionProtocol[T]):
333
+ class Nothing[T](OptionProtocol[T]):
334
334
  """An Option containing no value.
335
335
 
336
336
  Represents the absence of a value in an Option type. All transformation
@@ -594,7 +594,7 @@ class ResultProtocol[T, E](ABC):
594
594
 
595
595
 
596
596
  @dataclass(eq=True, frozen=True)
597
- class Ok(ResultProtocol[T, E]):
597
+ class Ok[T, E](ResultProtocol[T, E]):
598
598
  """A Result containing a success value.
599
599
 
600
600
  Represents a successful operation in a Result type. All transformation
@@ -671,7 +671,7 @@ class Ok(ResultProtocol[T, E]):
671
671
 
672
672
 
673
673
  @dataclass(eq=True, frozen=True)
674
- class Err(ResultProtocol[T, E]):
674
+ class Err[T, E](ResultProtocol[T, E]):
675
675
  """A Result containing an error value.
676
676
 
677
677
  Represents a failed operation in a Result type. All transformation methods
@@ -0,0 +1,7 @@
1
+ """Testing utilities for Neva applications."""
2
+
3
+ from neva.testing.fakes import EventFake
4
+ from neva.testing.test_case import RefreshDatabase, TestCase
5
+
6
+
7
+ __all__ = ["EventFake", "RefreshDatabase", "TestCase"]
@@ -0,0 +1,78 @@
1
+ """Testing fakes for Neva facades."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from neva import Result
7
+ from neva.events.event import Event as BaseEvent
8
+
9
+
10
+ class EventFake:
11
+ """Recording fake for the Event facade. No listeners are dispatched."""
12
+
13
+ def __init__(self) -> None:
14
+ self._dispatched: list[BaseEvent[Any]] = []
15
+
16
+ async def dispatch(self, event: BaseEvent[Any]) -> list[Result[None, str]]:
17
+ """Record the event without invoking any listeners.
18
+
19
+ Returns:
20
+ An empty list — no listeners are fired.
21
+ """
22
+ self._dispatched.append(event)
23
+ return []
24
+
25
+ def listen[T: BaseEvent[Any]](self, event_cls: type[T], listener_cls: type) -> None:
26
+ """No-op."""
27
+
28
+ def dispatched[E: BaseEvent[Any]](self, event_cls: type[E]) -> list[E]:
29
+ """Return all recorded events of the given type.
30
+
31
+ Returns:
32
+ All dispatched events that are instances of event_cls.
33
+ """
34
+ return [e for e in self._dispatched if isinstance(e, event_cls)]
35
+
36
+ def assert_dispatched[E: BaseEvent[Any]](
37
+ self,
38
+ event_cls: type[E],
39
+ *,
40
+ times: int | None = None,
41
+ match: Callable[[E], bool] | None = None,
42
+ ) -> None:
43
+ """Assert that at least one event of the given type was dispatched.
44
+
45
+ Args:
46
+ event_cls: The event type to check.
47
+ times: If given, assert exactly this many were dispatched.
48
+ match: If given, assert at least one event satisfies the predicate.
49
+
50
+ Raises:
51
+ AssertionError: If the assertion fails.
52
+ """
53
+ matching = self.dispatched(event_cls)
54
+ if not matching:
55
+ raise AssertionError(f"{event_cls.__name__} was not dispatched")
56
+ if times is not None and len(matching) != times:
57
+ raise AssertionError(
58
+ f"Expected {times} dispatch(es) of {event_cls.__name__},"
59
+ + f" got {len(matching)}"
60
+ )
61
+ if match is not None and not any(match(e) for e in matching):
62
+ raise AssertionError(f"No {event_cls.__name__} matched the predicate")
63
+
64
+ def assert_not_dispatched[E: BaseEvent[Any]](self, event_cls: type[E]) -> None:
65
+ """Assert that no event of the given type was dispatched.
66
+
67
+ Args:
68
+ event_cls: The event type to check.
69
+
70
+ Raises:
71
+ AssertionError: If the assertion fails.
72
+ """
73
+ matching = self.dispatched(event_cls)
74
+ if matching:
75
+ raise AssertionError(
76
+ f"{event_cls.__name__} was dispatched"
77
+ + f" {len(matching)} time(s) unexpectedly"
78
+ )
@@ -1,9 +1,8 @@
1
1
  """Fixtures for testing."""
2
2
 
3
3
  from collections.abc import AsyncIterator, Callable
4
- from contextlib import asynccontextmanager
4
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
5
5
  from pathlib import Path
6
- from typing import AsyncContextManager
7
6
 
8
7
  import pytest
9
8
 
@@ -45,7 +44,7 @@ def webapp(test_config: Path) -> App:
45
44
 
46
45
 
47
46
  @pytest.fixture
48
- def app_factory() -> Callable[[Path], AsyncContextManager[Application]]:
47
+ def app_factory() -> Callable[[Path], AbstractAsyncContextManager[Application]]:
49
48
  """Factory fixture for creating applications with custom configs.
50
49
 
51
50
  Returns:
@@ -1,15 +1,12 @@
1
1
  """Base test case class."""
2
2
 
3
- from collections.abc import AsyncIterator
3
+ from collections.abc import AsyncIterator, Iterator
4
4
  from pathlib import Path
5
- from typing import TypeVar
6
5
 
7
6
  import pytest
8
7
 
9
- from neva.arch import Application
10
-
11
-
12
- T = TypeVar("T")
8
+ from neva.arch import Application, Facade
9
+ from neva.database.manager import DatabaseManager
13
10
 
14
11
 
15
12
  class TestCase:
@@ -64,6 +61,41 @@ class TestCase:
64
61
  yield app
65
62
 
66
63
  @pytest.fixture(autouse=True)
67
- async def _inject_app(self, _test_case_application: Application) -> None:
64
+ def _inject_app(self, _test_case_application: Application) -> None:
68
65
  """Auto-inject app fixture into self.app."""
69
66
  self.app = _test_case_application
67
+
68
+ @pytest.fixture(autouse=True)
69
+ def _restore_facades(self) -> Iterator[None]:
70
+ """Restore all faked facades after each test."""
71
+ yield
72
+ Facade.restore_all()
73
+
74
+
75
+ class RefreshDatabase:
76
+ """Mixin for TestCase that wraps each test in a transaction and rolls it back.
77
+
78
+ Must be used alongside TestCase. If engines are registered outside of app
79
+ lifespan, override ``_setup_database_engine`` to ensure the engine is ready
80
+ before the rollback transaction opens.
81
+ """
82
+
83
+ @pytest.fixture
84
+ async def _setup_database_engine(self) -> AsyncIterator[None]:
85
+ """Hook for registering DB engines before the rollback transaction opens.
86
+
87
+ Override in subclasses when engines are not registered during app lifespan.
88
+ """
89
+ yield
90
+
91
+ @pytest.fixture(autouse=True)
92
+ async def _rollback_database(
93
+ self,
94
+ _test_case_application: Application,
95
+ _setup_database_engine: None, # ordering dependency: runs after engine setup
96
+ ) -> AsyncIterator[None]:
97
+ """Wrap the test in a DB transaction and roll it back on completion."""
98
+ db = _test_case_application.make(DatabaseManager).unwrap()
99
+ async with db.begin() as tx:
100
+ yield
101
+ await tx.rollback()