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.
- {python_neva-2.0.0 → python_neva-2.1.0}/PKG-INFO +1 -1
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/facade.py +83 -1
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/connection.py +42 -14
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/transaction.py +5 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/__init__.py +2 -0
- python_neva-2.1.0/neva/support/facade/event.py +53 -0
- python_neva-2.1.0/neva/testing/__init__.py +7 -0
- python_neva-2.1.0/neva/testing/fakes.py +78 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/testing/test_case.py +39 -7
- {python_neva-2.0.0 → python_neva-2.1.0}/pyproject.toml +1 -1
- python_neva-2.1.0/tests/testing/test_event_fake.py +111 -0
- python_neva-2.1.0/tests/testing/test_facade_restore.py +57 -0
- python_neva-2.1.0/tests/testing/test_refresh_database.py +85 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/uv.lock +1 -1
- python_neva-2.0.0/neva/support/facade/event.py +0 -15
- python_neva-2.0.0/neva/testing/__init__.py +0 -6
- {python_neva-2.0.0 → python_neva-2.1.0}/.envrc +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/.gitignore +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/.pre-commit-config.yaml +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/.python-version +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/README.md +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/app.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/application.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/config.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/faststream.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/arch/service_provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/base_providers.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/loader.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/config/repository.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/config.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/database/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/dispatcher.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/event.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/event_registry.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/listener.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/events/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/logging/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/middleware/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/middleware/correlation.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/obs/middleware/profiler.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/py.typed +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/encryption/encrypter.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/config.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/tokens/generate_token.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/accessors.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/app.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/config.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/db.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/db.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/hash.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/hash.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/log.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/results.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/strategy.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/strconv.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/support/time.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/testing/fixtures.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/neva/testing/http.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/ruff.toml +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/arch/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/arch/test_scope.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/config/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/config/test_loader.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/config/test_repository.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/conftest.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/conftest.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_connection_manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_database_manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_edge_cases.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_sqlalchemy_integration.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_transaction.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_transaction_context.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/conftest.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_deferred.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_dispatch.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_event.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_event_registry.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_function_listener.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/events/test_immediate.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/obs/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/obs/test_correlation.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/obs/test_profiler.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/security/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/security/test_encrypter.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/security/test_hash_manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/testing/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/testing/test_fixtures.py +0 -0
- {python_neva-2.0.0 → python_neva-2.1.0}/tests/testing/test_test_case.py +0 -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.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
self.logger
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
188
|
+
async with self.session_factory() as session:
|
|
169
189
|
bound = tx.begin(session)
|
|
170
|
-
|
|
171
|
-
|
|
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,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
|
-
|
|
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()
|
|
@@ -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"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|