python-neva 2.0.0__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 (123) hide show
  1. {python_neva-2.0.0 → python_neva-2.1.0}/PKG-INFO +1 -1
  2. {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/facade.py +83 -1
  3. {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/connection.py +42 -14
  4. {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/transaction.py +5 -0
  5. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/__init__.py +2 -0
  6. python_neva-2.1.0/neva/support/facade/event.py +53 -0
  7. python_neva-2.1.0/neva/testing/__init__.py +7 -0
  8. python_neva-2.1.0/neva/testing/fakes.py +78 -0
  9. {python_neva-2.0.0 → python_neva-2.1.0}/neva/testing/test_case.py +39 -7
  10. {python_neva-2.0.0 → python_neva-2.1.0}/pyproject.toml +1 -1
  11. python_neva-2.1.0/tests/testing/test_event_fake.py +111 -0
  12. python_neva-2.1.0/tests/testing/test_facade_restore.py +57 -0
  13. python_neva-2.1.0/tests/testing/test_refresh_database.py +85 -0
  14. {python_neva-2.0.0 → python_neva-2.1.0}/uv.lock +1 -1
  15. python_neva-2.0.0/neva/support/facade/event.py +0 -15
  16. python_neva-2.0.0/neva/testing/__init__.py +0 -6
  17. {python_neva-2.0.0 → python_neva-2.1.0}/.envrc +0 -0
  18. {python_neva-2.0.0 → python_neva-2.1.0}/.gitignore +0 -0
  19. {python_neva-2.0.0 → python_neva-2.1.0}/.pre-commit-config.yaml +0 -0
  20. {python_neva-2.0.0 → python_neva-2.1.0}/.python-version +0 -0
  21. {python_neva-2.0.0 → python_neva-2.1.0}/README.md +0 -0
  22. {python_neva-2.0.0 → python_neva-2.1.0}/neva/__init__.py +0 -0
  23. {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/__init__.py +0 -0
  24. {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/app.py +0 -0
  25. {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/application.py +0 -0
  26. {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/config.py +0 -0
  27. {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/faststream.py +0 -0
  28. {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/service_provider.py +0 -0
  29. {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/__init__.py +0 -0
  30. {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/base_providers.py +0 -0
  31. {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/loader.py +0 -0
  32. {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/provider.py +0 -0
  33. {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/repository.py +0 -0
  34. {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/__init__.py +0 -0
  35. {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/config.py +0 -0
  36. {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/manager.py +0 -0
  37. {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/provider.py +0 -0
  38. {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/__init__.py +0 -0
  39. {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/dispatcher.py +0 -0
  40. {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/event.py +0 -0
  41. {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/event_registry.py +0 -0
  42. {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/listener.py +0 -0
  43. {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/provider.py +0 -0
  44. {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/__init__.py +0 -0
  45. {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/logging/__init__.py +0 -0
  46. {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/logging/manager.py +0 -0
  47. {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/logging/provider.py +0 -0
  48. {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/middleware/__init__.py +0 -0
  49. {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/middleware/correlation.py +0 -0
  50. {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/middleware/profiler.py +0 -0
  51. {python_neva-2.0.0 → python_neva-2.1.0}/neva/py.typed +0 -0
  52. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/__init__.py +0 -0
  53. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/encryption/__init__.py +0 -0
  54. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/encryption/encrypter.py +0 -0
  55. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/encryption/protocol.py +0 -0
  56. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/__init__.py +0 -0
  57. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/config.py +0 -0
  58. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hash_manager.py +0 -0
  59. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hashers/__init__.py +0 -0
  60. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hashers/argon2.py +0 -0
  61. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  62. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hashers/protocol.py +0 -0
  63. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/provider.py +0 -0
  64. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/tokens/__init__.py +0 -0
  65. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/tokens/generate_token.py +0 -0
  66. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/tokens/hash_token.py +0 -0
  67. {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/tokens/verify_token.py +0 -0
  68. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/__init__.py +0 -0
  69. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/accessors.py +0 -0
  70. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/app.py +0 -0
  71. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/app.pyi +0 -0
  72. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/config.py +0 -0
  73. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/config.pyi +0 -0
  74. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/crypt.py +0 -0
  75. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/crypt.pyi +0 -0
  76. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/db.py +0 -0
  77. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/db.pyi +0 -0
  78. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/event.pyi +0 -0
  79. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/hash.py +0 -0
  80. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/hash.pyi +0 -0
  81. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/log.py +0 -0
  82. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/log.pyi +0 -0
  83. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/results.py +0 -0
  84. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/strategy.py +0 -0
  85. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/strconv.py +0 -0
  86. {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/time.py +0 -0
  87. {python_neva-2.0.0 → python_neva-2.1.0}/neva/testing/fixtures.py +0 -0
  88. {python_neva-2.0.0 → python_neva-2.1.0}/neva/testing/http.py +0 -0
  89. {python_neva-2.0.0 → python_neva-2.1.0}/ruff.toml +0 -0
  90. {python_neva-2.0.0 → python_neva-2.1.0}/tests/__init__.py +0 -0
  91. {python_neva-2.0.0 → python_neva-2.1.0}/tests/arch/__init__.py +0 -0
  92. {python_neva-2.0.0 → python_neva-2.1.0}/tests/arch/test_scope.py +0 -0
  93. {python_neva-2.0.0 → python_neva-2.1.0}/tests/config/__init__.py +0 -0
  94. {python_neva-2.0.0 → python_neva-2.1.0}/tests/config/test_loader.py +0 -0
  95. {python_neva-2.0.0 → python_neva-2.1.0}/tests/config/test_repository.py +0 -0
  96. {python_neva-2.0.0 → python_neva-2.1.0}/tests/conftest.py +0 -0
  97. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/__init__.py +0 -0
  98. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/conftest.py +0 -0
  99. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_connection_manager.py +0 -0
  100. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_database_manager.py +0 -0
  101. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_edge_cases.py +0 -0
  102. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_multi_connection.py +0 -0
  103. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  104. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_transaction.py +0 -0
  105. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_transaction_context.py +0 -0
  106. {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_transaction_registry.py +0 -0
  107. {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/__init__.py +0 -0
  108. {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/conftest.py +0 -0
  109. {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_deferred.py +0 -0
  110. {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_dispatch.py +0 -0
  111. {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_event.py +0 -0
  112. {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_event_registry.py +0 -0
  113. {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_function_listener.py +0 -0
  114. {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_immediate.py +0 -0
  115. {python_neva-2.0.0 → python_neva-2.1.0}/tests/obs/__init__.py +0 -0
  116. {python_neva-2.0.0 → python_neva-2.1.0}/tests/obs/test_correlation.py +0 -0
  117. {python_neva-2.0.0 → python_neva-2.1.0}/tests/obs/test_profiler.py +0 -0
  118. {python_neva-2.0.0 → python_neva-2.1.0}/tests/security/__init__.py +0 -0
  119. {python_neva-2.0.0 → python_neva-2.1.0}/tests/security/test_encrypter.py +0 -0
  120. {python_neva-2.0.0 → python_neva-2.1.0}/tests/security/test_hash_manager.py +0 -0
  121. {python_neva-2.0.0 → python_neva-2.1.0}/tests/testing/__init__.py +0 -0
  122. {python_neva-2.0.0 → python_neva-2.1.0}/tests/testing/test_fixtures.py +0 -0
  123. {python_neva-2.0.0 → python_neva-2.1.0}/tests/testing/test_test_case.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-neva
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -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()
@@ -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
 
@@ -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()
@@ -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,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()
@@ -7,7 +7,7 @@ packages = ["neva"]
7
7
 
8
8
  [project]
9
9
  name = "python-neva"
10
- version = "2.0.0"
10
+ version = "2.1.0"
11
11
  description = "Add your description here"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.12"
@@ -0,0 +1,111 @@
1
+ """Tests for EventFake."""
2
+
3
+ import pytest
4
+
5
+ from neva.events.event import Event as BaseEvent
6
+ from neva.support.facade.event import Event as EventFacade
7
+ from neva.testing import EventFake, TestCase
8
+
9
+
10
+ class UserCreated(BaseEvent[int]):
11
+ user_id: int
12
+
13
+
14
+ class OrderPlaced(BaseEvent[int]):
15
+ order_id: int
16
+
17
+
18
+ class TestEventFakeDispatch:
19
+ async def test_dispatch_records_event(self) -> None:
20
+ fake = EventFake()
21
+ event = UserCreated(event_id=1, user_id=42)
22
+
23
+ _ = await fake.dispatch(event)
24
+
25
+ assert fake.dispatched(UserCreated) == [event]
26
+
27
+ async def test_dispatch_does_not_invoke_listeners(self) -> None:
28
+ fake = EventFake()
29
+ called = False
30
+
31
+ async def listener(_: UserCreated) -> None:
32
+ nonlocal called
33
+ called = True
34
+
35
+ _ = await fake.dispatch(UserCreated(event_id=1, user_id=1))
36
+ assert not called
37
+
38
+ async def test_listen_is_noop(self) -> None:
39
+ fake = EventFake()
40
+ fake.listen(UserCreated, object) # must not raise
41
+
42
+ async def test_dispatched_filters_by_type(self) -> None:
43
+ fake = EventFake()
44
+ user_event = UserCreated(event_id=1, user_id=1)
45
+ order_event = OrderPlaced(event_id=2, order_id=9)
46
+
47
+ _ = await fake.dispatch(user_event)
48
+ _ = await fake.dispatch(order_event)
49
+
50
+ assert fake.dispatched(UserCreated) == [user_event]
51
+ assert fake.dispatched(OrderPlaced) == [order_event]
52
+
53
+
54
+ class TestEventFakeAssertions:
55
+ async def test_assert_dispatched_passes(self) -> None:
56
+ fake = EventFake()
57
+ await fake.dispatch(UserCreated(event_id=1, user_id=42))
58
+ fake.assert_dispatched(UserCreated)
59
+
60
+ async def test_assert_dispatched_fails_when_not_dispatched(self) -> None:
61
+ fake = EventFake()
62
+ with pytest.raises(AssertionError, match="was not dispatched"):
63
+ fake.assert_dispatched(UserCreated)
64
+
65
+ async def test_assert_dispatched_times_passes(self) -> None:
66
+ fake = EventFake()
67
+ await fake.dispatch(UserCreated(event_id=1, user_id=1))
68
+ await fake.dispatch(UserCreated(event_id=2, user_id=2))
69
+ fake.assert_dispatched(UserCreated, times=2)
70
+
71
+ async def test_assert_dispatched_times_fails_on_mismatch(self) -> None:
72
+ fake = EventFake()
73
+ await fake.dispatch(UserCreated(event_id=1, user_id=1))
74
+ with pytest.raises(AssertionError, match="Expected 3"):
75
+ fake.assert_dispatched(UserCreated, times=3)
76
+
77
+ async def test_assert_dispatched_with_match_passes(self) -> None:
78
+ fake = EventFake()
79
+ await fake.dispatch(UserCreated(event_id=1, user_id=42))
80
+ fake.assert_dispatched(UserCreated, match=lambda e: e.user_id == 42)
81
+
82
+ async def test_assert_dispatched_with_match_fails(self) -> None:
83
+ fake = EventFake()
84
+ await fake.dispatch(UserCreated(event_id=1, user_id=1))
85
+ with pytest.raises(AssertionError, match="predicate"):
86
+ fake.assert_dispatched(UserCreated, match=lambda e: e.user_id == 99)
87
+
88
+ async def test_assert_not_dispatched_passes(self) -> None:
89
+ fake = EventFake()
90
+ fake.assert_not_dispatched(UserCreated)
91
+
92
+ async def test_assert_not_dispatched_fails_when_dispatched(self) -> None:
93
+ fake = EventFake()
94
+ await fake.dispatch(UserCreated(event_id=1, user_id=1))
95
+ with pytest.raises(AssertionError, match="unexpectedly"):
96
+ fake.assert_not_dispatched(UserCreated)
97
+
98
+
99
+ class TestEventFacadeFake(TestCase):
100
+ async def test_fake_returns_event_fake(self) -> None:
101
+ fake = EventFacade.fake()
102
+ assert isinstance(fake, EventFake)
103
+
104
+ async def test_faking_context_manager_returns_event_fake(self) -> None:
105
+ with EventFacade.faking() as fake:
106
+ assert isinstance(fake, EventFake)
107
+
108
+ async def test_faking_records_dispatches(self) -> None:
109
+ with EventFacade.faking() as fake:
110
+ await EventFacade.dispatch(UserCreated(event_id=1, user_id=7))
111
+ fake.assert_dispatched(UserCreated, match=lambda e: e.user_id == 7)
@@ -0,0 +1,57 @@
1
+ """Tests for facade auto-restore in TestCase."""
2
+
3
+ import pytest
4
+
5
+ from neva.arch.facade import FacadeMeta
6
+ from neva.support.facade.event import Event
7
+ from neva.testing import TestCase
8
+
9
+
10
+ class TestFacadeAutoRestore(TestCase):
11
+ """Verify fakes are cleared automatically between tests.
12
+
13
+ Tests are named with a/b prefix to enforce execution order.
14
+ """
15
+
16
+ async def test_a_fake_is_installed(self) -> None:
17
+ Event.fake()
18
+ assert Event in FacadeMeta._fake_instances
19
+
20
+ async def test_b_fake_is_cleared_after_previous_test(self) -> None:
21
+ assert Event not in FacadeMeta._fake_instances
22
+
23
+
24
+ class TestRestoreAll:
25
+ def test_restore_all_clears_all_fakes(self) -> None:
26
+ from neva.arch import Facade
27
+ from neva.support.facade import DB
28
+
29
+ Event.fake()
30
+ DB.fake()
31
+ assert len(FacadeMeta._fake_instances) == 2
32
+
33
+ Facade.restore_all()
34
+ assert len(FacadeMeta._fake_instances) == 0
35
+
36
+ def test_restore_clears_single_facade(self) -> None:
37
+ from neva.support.facade import DB
38
+
39
+ Event.fake()
40
+ DB.fake()
41
+ Event.restore()
42
+
43
+ assert Event not in FacadeMeta._fake_instances
44
+ assert DB in FacadeMeta._fake_instances
45
+
46
+ DB.restore()
47
+
48
+ def test_faking_context_manager_restores_on_exit(self) -> None:
49
+ with Event.faking():
50
+ assert Event in FacadeMeta._fake_instances
51
+ assert Event not in FacadeMeta._fake_instances
52
+
53
+ def test_faking_context_manager_restores_on_exception(self) -> None:
54
+ with pytest.raises(RuntimeError), Event.faking():
55
+ raise RuntimeError
56
+
57
+ assert Event not in FacadeMeta._fake_instances
@@ -0,0 +1,85 @@
1
+ """Tests for RefreshDatabase mixin."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from typing import final
5
+
6
+ import pytest
7
+ from sqlalchemy import Column, Integer, String, select
8
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
9
+ from sqlalchemy.orm import DeclarativeBase
10
+
11
+ from neva.arch import Application
12
+ from neva.database.manager import DatabaseManager
13
+ from neva.testing import RefreshDatabase, TestCase
14
+
15
+
16
+ class Base(DeclarativeBase):
17
+ pass
18
+
19
+
20
+ @final
21
+ class Item(Base):
22
+ __tablename__ = "items"
23
+ id = Column(Integer, primary_key=True)
24
+ name = Column(String, nullable=False)
25
+
26
+
27
+ class DatabaseTestCase(TestCase, RefreshDatabase):
28
+ """TestCase with a fresh in-memory SQLite engine registered per test."""
29
+
30
+ @pytest.fixture
31
+ async def _setup_database_engine(
32
+ self, _test_case_application: Application
33
+ ) -> AsyncIterator[None]:
34
+ engine: AsyncEngine = create_async_engine("sqlite+aiosqlite:///:memory:")
35
+ async with engine.begin() as conn:
36
+ await conn.run_sync(Base.metadata.create_all)
37
+
38
+ db = _test_case_application.make(DatabaseManager).unwrap()
39
+ db.register_engine("default", engine)
40
+
41
+ yield
42
+
43
+ await engine.dispose()
44
+
45
+
46
+ class TestRefreshDatabaseIsolation(DatabaseTestCase):
47
+ """Verify data written in one test is not visible in the next.
48
+
49
+ Tests are named with a/b prefix to enforce execution order.
50
+ """
51
+
52
+ async def test_a_insert_item(self) -> None:
53
+ db = self.app.make(DatabaseManager).unwrap()
54
+ session = db.session().unwrap()
55
+ session.add(Item(id=1, name="Alice"))
56
+ await session.flush()
57
+
58
+ result = await session.execute(select(Item).where(Item.id == 1))
59
+ assert result.scalar_one().name == "Alice"
60
+
61
+ async def test_b_item_is_gone_after_rollback(self) -> None:
62
+ db = self.app.make(DatabaseManager).unwrap()
63
+ async with db.begin() as tx:
64
+ result = await tx.session.execute(select(Item).where(Item.id == 1))
65
+ assert result.scalar_one_or_none() is None
66
+
67
+
68
+ class TestRefreshDatabaseMechanism(DatabaseTestCase):
69
+ async def test_nested_begin_shares_the_outer_session(self) -> None:
70
+ """Calls to db.begin() inside a test become savepoints."""
71
+ db = self.app.make(DatabaseManager).unwrap()
72
+ outer_session = db.session().unwrap()
73
+
74
+ async with db.begin() as inner:
75
+ assert inner.session is outer_session
76
+
77
+ async def test_data_visible_within_same_test(self) -> None:
78
+ db = self.app.make(DatabaseManager).unwrap()
79
+ session = db.session().unwrap()
80
+
81
+ session.add(Item(id=10, name="Visible"))
82
+ await session.flush()
83
+
84
+ result = await session.execute(select(Item).where(Item.id == 10))
85
+ assert result.scalar_one().name == "Visible"
@@ -1327,7 +1327,7 @@ wheels = [
1327
1327
 
1328
1328
  [[package]]
1329
1329
  name = "python-neva"
1330
- version = "2.0.0"
1330
+ version = "2.1.0"
1331
1331
  source = { editable = "." }
1332
1332
  dependencies = [
1333
1333
  { name = "aiosqlite" },
@@ -1,15 +0,0 @@
1
- """Event facade."""
2
-
3
- from typing import override
4
-
5
- from neva.arch import Facade
6
- from neva.events import EventDispatcher
7
-
8
-
9
- class Event(Facade):
10
- """Event system facade."""
11
-
12
- @classmethod
13
- @override
14
- def get_facade_accessor(cls) -> type:
15
- return EventDispatcher
@@ -1,6 +0,0 @@
1
- """Testing utilities for Neva applications."""
2
-
3
- from neva.testing.test_case import TestCase
4
-
5
-
6
- __all__ = ["TestCase"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes