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.
- {python_neva-2.0.0 → python_neva-2.2.0}/.pre-commit-config.yaml +5 -8
- {python_neva-2.0.0 → python_neva-2.2.0}/PKG-INFO +1 -1
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/app.py +3 -3
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/application.py +25 -31
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/facade.py +88 -2
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/config/__init__.py +0 -2
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/connection.py +42 -14
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/transaction.py +5 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/encryption/encrypter.py +3 -1
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/__init__.py +2 -0
- python_neva-2.2.0/neva/support/facade/event.py +53 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/results.py +4 -4
- python_neva-2.2.0/neva/testing/__init__.py +7 -0
- python_neva-2.2.0/neva/testing/fakes.py +78 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/testing/fixtures.py +2 -3
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/testing/test_case.py +39 -7
- {python_neva-2.0.0 → python_neva-2.2.0}/pyproject.toml +8 -6
- python_neva-2.2.0/tests/testing/test_event_fake.py +111 -0
- python_neva-2.2.0/tests/testing/test_facade_restore.py +57 -0
- python_neva-2.2.0/tests/testing/test_refresh_database.py +111 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/uv.lock +136 -51
- python_neva-2.0.0/neva/config/provider.py +0 -74
- 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.2.0}/.envrc +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/.gitignore +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/.python-version +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/README.md +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/config.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/faststream.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/arch/service_provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/config/base_providers.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/config/loader.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/config/repository.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/config.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/database/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/dispatcher.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/event.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/event_registry.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/listener.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/events/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/logging/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/middleware/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/middleware/correlation.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/obs/middleware/profiler.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/py.typed +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/encryption/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/encryption/protocol.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/config.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hash_manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hashers/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hashers/argon2.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/hashing/hashers/protocol.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/provider.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/tokens/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/tokens/generate_token.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/tokens/hash_token.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/security/tokens/verify_token.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/accessors.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/app.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/config.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/crypt.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/crypt.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/db.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/db.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/event.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/hash.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/hash.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/log.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/strategy.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/strconv.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/support/time.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/neva/testing/http.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/ruff.toml +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/arch/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/arch/test_scope.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/config/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/config/test_loader.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/config/test_repository.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/conftest.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/conftest.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_connection_manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_database_manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_edge_cases.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_multi_connection.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_sqlalchemy_integration.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_transaction.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_transaction_context.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/database/test_transaction_registry.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/conftest.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_deferred.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_dispatch.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_event.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_event_registry.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_function_listener.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/events/test_immediate.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/obs/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/obs/test_correlation.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/obs/test_profiler.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/security/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/security/test_encrypter.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/security/test_hash_manager.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/testing/__init__.py +0 -0
- {python_neva-2.0.0 → python_neva-2.2.0}/tests/testing/test_fixtures.py +0 -0
- {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.
|
|
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.
|
|
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:
|
|
25
|
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
26
|
+
rev: v1.19.1
|
|
26
27
|
hooks:
|
|
27
|
-
- id:
|
|
28
|
-
name: ty check
|
|
29
|
-
entry: ty check . --project neva
|
|
30
|
-
files: ^neva/
|
|
31
|
-
language: system
|
|
28
|
+
- id: mypy
|
|
@@ -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[
|
|
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 =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
@@ -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
|
|
|
@@ -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
|
|
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,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],
|
|
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
|
-
|
|
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()
|