python-neva 3.7.0__py3-none-any.whl → 4.0.0__py3-none-any.whl
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.
- neva/arch/__init__.py +3 -0
- neva/arch/application.py +64 -35
- neva/arch/config.py +30 -18
- neva/arch/integrations/faststream.py +9 -6
- neva/arch/service_provider.py +11 -11
- neva/database/connection.py +7 -2
- neva/database/manager.py +7 -3
- neva/database/transaction.py +30 -12
- neva/obs/__init__.py +0 -4
- neva/security/tokens/generate_token.py +8 -6
- neva/testing/__init__.py +2 -2
- neva/testing/test_case.py +118 -11
- {python_neva-3.7.0.dist-info → python_neva-4.0.0.dist-info}/METADATA +4 -2
- {python_neva-3.7.0.dist-info → python_neva-4.0.0.dist-info}/RECORD +15 -18
- neva/obs/middleware/__init__.py +0 -7
- neva/obs/middleware/correlation.py +0 -68
- neva/obs/middleware/profiler.py +0 -68
- {python_neva-3.7.0.dist-info → python_neva-4.0.0.dist-info}/WHEEL +0 -0
neva/arch/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ and facade implementations.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from neva.arch.application import Application
|
|
8
|
+
from neva.arch.config import AppConfig, ProviderConfig
|
|
8
9
|
from neva.arch.facade import Facade
|
|
9
10
|
from neva.arch.service_provider import (
|
|
10
11
|
Bootable,
|
|
@@ -13,8 +14,10 @@ from neva.arch.service_provider import (
|
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
17
|
+
"AppConfig",
|
|
16
18
|
"Application",
|
|
17
19
|
"Bootable",
|
|
18
20
|
"Facade",
|
|
21
|
+
"ProviderConfig",
|
|
19
22
|
"ServiceProvider",
|
|
20
23
|
]
|
neva/arch/application.py
CHANGED
|
@@ -6,17 +6,16 @@ from collections.abc import AsyncGenerator, Sequence
|
|
|
6
6
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
7
7
|
from contextvars import ContextVar
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any,
|
|
9
|
+
from typing import Any, Self
|
|
10
10
|
|
|
11
11
|
import dishka
|
|
12
12
|
from dishka.provider import BaseProvider
|
|
13
|
-
from typing_extensions import deprecated
|
|
14
13
|
|
|
15
14
|
from neva.arch.facade import Facade
|
|
16
15
|
from neva.arch.scopes import BaseScope, Scope
|
|
17
16
|
from neva.arch.service_provider import Bootable, ServiceProvider
|
|
18
17
|
from neva.config.loader import ConfigLoader
|
|
19
|
-
from neva.support import Err, Ok, Result
|
|
18
|
+
from neva.support import Err, Ok, Result, Some
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
_current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
|
|
@@ -24,6 +23,26 @@ _current_container: ContextVar["dishka.AsyncContainer"] = ContextVar(
|
|
|
24
23
|
)
|
|
25
24
|
|
|
26
25
|
|
|
26
|
+
def _resolve_config_path(config_path: str | Path | None) -> Path:
|
|
27
|
+
"""Resolve the directory config files are loaded from.
|
|
28
|
+
|
|
29
|
+
The config directory is a dedicated folder, never the project root: every
|
|
30
|
+
``.py`` in it is imported to read its ``config`` dict, so pointing this at
|
|
31
|
+
a directory holding ordinary modules would execute them.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The explicit argument, else $NEVA_CONFIG_PATH, else ./config.
|
|
35
|
+
"""
|
|
36
|
+
if config_path is not None:
|
|
37
|
+
return Path(config_path)
|
|
38
|
+
|
|
39
|
+
env_path = os.getenv("NEVA_CONFIG_PATH")
|
|
40
|
+
if env_path:
|
|
41
|
+
return Path(env_path)
|
|
42
|
+
|
|
43
|
+
return Path.cwd() / "config"
|
|
44
|
+
|
|
45
|
+
|
|
27
46
|
class Application:
|
|
28
47
|
"""Base application for DI and facade injection."""
|
|
29
48
|
|
|
@@ -31,8 +50,9 @@ class Application:
|
|
|
31
50
|
"""Initialize the application and its bindings.
|
|
32
51
|
|
|
33
52
|
Args:
|
|
34
|
-
config_path: Path to the configuration directory. Defaults to
|
|
35
|
-
relative to the
|
|
53
|
+
config_path: Path to the configuration directory. Defaults to
|
|
54
|
+
``$NEVA_CONFIG_PATH``, else ``./config`` relative to the
|
|
55
|
+
current working directory.
|
|
36
56
|
|
|
37
57
|
Raises:
|
|
38
58
|
RuntimeError: If the application fails to initialize.
|
|
@@ -47,11 +67,7 @@ class Application:
|
|
|
47
67
|
self._container: dishka.AsyncContainer | None = None
|
|
48
68
|
self._booted: bool = False
|
|
49
69
|
|
|
50
|
-
configuration_path = (
|
|
51
|
-
config_path
|
|
52
|
-
if config_path is not None
|
|
53
|
-
else os.getenv("NEVA_CONFIG_PATH", default=Path.cwd())
|
|
54
|
-
)
|
|
70
|
+
configuration_path = _resolve_config_path(config_path)
|
|
55
71
|
match ConfigLoader(configuration_path).load_all():
|
|
56
72
|
case Err(e):
|
|
57
73
|
raise RuntimeError(f"Failed to register config: {e}")
|
|
@@ -150,21 +166,6 @@ class Application:
|
|
|
150
166
|
for provider in providers:
|
|
151
167
|
_ = self.register(provider)
|
|
152
168
|
|
|
153
|
-
@deprecated("Use the new bind() method directly with a Service Provider.")
|
|
154
|
-
def bind(
|
|
155
|
-
self,
|
|
156
|
-
source: type | Callable[..., Any],
|
|
157
|
-
*,
|
|
158
|
-
interface: type | None = None,
|
|
159
|
-
scope: BaseScope | None = None,
|
|
160
|
-
) -> None:
|
|
161
|
-
"""Binds a source to the container."""
|
|
162
|
-
_ = self.root_provider.provide(
|
|
163
|
-
source=source,
|
|
164
|
-
scope=scope,
|
|
165
|
-
provides=interface,
|
|
166
|
-
)
|
|
167
|
-
|
|
168
169
|
@property
|
|
169
170
|
def current_container(self) -> dishka.AsyncContainer:
|
|
170
171
|
"""The container backing resolution in the current context.
|
|
@@ -226,6 +227,11 @@ class Application:
|
|
|
226
227
|
@asynccontextmanager
|
|
227
228
|
async def lifespan(self) -> AsyncGenerator[None]:
|
|
228
229
|
"""Wire the facades and providers."""
|
|
230
|
+
# The facade root is a single process-global slot, so a short-lived
|
|
231
|
+
# application nested inside a longer-lived one must hand it back rather
|
|
232
|
+
# than clear it -- otherwise it disarms every facade for whatever the
|
|
233
|
+
# outer application still has to do.
|
|
234
|
+
previous_facade_application = Facade.get_facade_application()
|
|
229
235
|
Facade.set_facade_application(self)
|
|
230
236
|
self._booted = True
|
|
231
237
|
|
|
@@ -243,27 +249,50 @@ class Application:
|
|
|
243
249
|
if self._container is not None:
|
|
244
250
|
await self._container.close()
|
|
245
251
|
self._booted = False
|
|
246
|
-
|
|
252
|
+
match previous_facade_application:
|
|
253
|
+
case Some(application):
|
|
254
|
+
Facade.set_facade_application(application)
|
|
255
|
+
case _:
|
|
256
|
+
Facade.reset_facade_application()
|
|
247
257
|
|
|
248
258
|
def _bind_event_listeners(self) -> None:
|
|
249
259
|
"""Bind all listener classes declared in provider ``listen`` dicts."""
|
|
250
260
|
for provider in self.providers.values():
|
|
251
261
|
for listeners in provider.listen.values():
|
|
252
262
|
for listener_cls in listeners:
|
|
253
|
-
# Straight onto the root provider:
|
|
254
|
-
#
|
|
263
|
+
# Straight onto the root provider: these listeners are not
|
|
264
|
+
# a provider's own bindings, so they do not belong in one.
|
|
255
265
|
_ = self.root_provider.provide(listener_cls, scope=Scope.APP)
|
|
256
266
|
|
|
257
267
|
async def _wire_event_listeners(self) -> None:
|
|
258
|
-
"""Wire event-listener mappings from all providers onto the dispatcher.
|
|
268
|
+
"""Wire event-listener mappings from all providers onto the dispatcher.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
RuntimeError: If listeners are declared but the dispatcher cannot
|
|
272
|
+
be resolved. Booting on regardless would leave every one of
|
|
273
|
+
them unwired, so the events would simply never fire — with
|
|
274
|
+
nothing anywhere saying why.
|
|
275
|
+
"""
|
|
259
276
|
from neva.events.dispatcher import EventDispatcher
|
|
260
277
|
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
declared = [
|
|
279
|
+
(event_cls, listener_cls)
|
|
280
|
+
for provider in self.providers.values()
|
|
281
|
+
for event_cls, listeners in provider.listen.items()
|
|
282
|
+
for listener_cls in listeners
|
|
283
|
+
]
|
|
284
|
+
if not declared:
|
|
263
285
|
return
|
|
264
286
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
match await self.make_async(EventDispatcher):
|
|
288
|
+
case Err(e):
|
|
289
|
+
msg = (
|
|
290
|
+
f"Cannot wire {len(declared)} declared event listener(s): "
|
|
291
|
+
f"the EventDispatcher could not be resolved ({e}). "
|
|
292
|
+
"Register EventServiceProvider, or remove the 'listen' "
|
|
293
|
+
"mappings that need it."
|
|
294
|
+
)
|
|
295
|
+
raise RuntimeError(msg)
|
|
296
|
+
case Ok(dispatcher):
|
|
297
|
+
for event_cls, listener_cls in declared:
|
|
269
298
|
dispatcher.listen(event_cls, listener_cls)
|
neva/arch/config.py
CHANGED
|
@@ -1,37 +1,49 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Config shapes for the namespaces the core itself reads.
|
|
2
|
+
|
|
3
|
+
Annotate a config file with one of these to have a type checker catch typos
|
|
4
|
+
and wrong value types before boot:
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
# src/config/app.py
|
|
8
|
+
from neva.arch import AppConfig
|
|
9
|
+
|
|
10
|
+
config: AppConfig = {"providers": [MyProvider]}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Keys read by an integration package are declared by that package, not here —
|
|
14
|
+
`neva-fastapi` owns the OpenAPI and title keys of the `app` namespace, for
|
|
15
|
+
instance. The core only describes what the core consumes.
|
|
16
|
+
"""
|
|
2
17
|
|
|
3
18
|
from typing import NotRequired, TypedDict
|
|
4
19
|
|
|
5
|
-
from neva.arch import ServiceProvider
|
|
20
|
+
from neva.arch.service_provider import ServiceProvider
|
|
6
21
|
|
|
7
22
|
|
|
8
23
|
class AppConfig(TypedDict):
|
|
9
|
-
"""
|
|
24
|
+
"""The `app` namespace keys the core reads.
|
|
10
25
|
|
|
11
26
|
Attributes:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
providers: List of service providers to register.
|
|
27
|
+
key: Base64-encoded 32-byte key for `Crypt`. Generate one with
|
|
28
|
+
`AesEncrypter.generate_key()`.
|
|
29
|
+
previous_keys: Superseded keys, still accepted when decrypting so a
|
|
30
|
+
key rotation does not orphan existing ciphertext.
|
|
31
|
+
providers: Service providers to register, appended after those from
|
|
32
|
+
the `providers` namespace.
|
|
19
33
|
"""
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
version: NotRequired[str]
|
|
24
|
-
openapi_url: NotRequired[str]
|
|
25
|
-
docs_url: NotRequired[str]
|
|
26
|
-
redoc_url: NotRequired[str]
|
|
35
|
+
key: NotRequired[str]
|
|
36
|
+
previous_keys: NotRequired[list[str]]
|
|
27
37
|
providers: NotRequired[list[type[ServiceProvider]]]
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
class ProviderConfig(TypedDict):
|
|
31
|
-
"""
|
|
41
|
+
"""The `providers` namespace.
|
|
42
|
+
|
|
43
|
+
A dedicated namespace for the provider list, read before `app.providers`.
|
|
32
44
|
|
|
33
45
|
Attributes:
|
|
34
|
-
providers:
|
|
46
|
+
providers: Service providers to register.
|
|
35
47
|
"""
|
|
36
48
|
|
|
37
49
|
providers: NotRequired[list[type[ServiceProvider]]]
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
"""FastStream wrapper for Neva."""
|
|
2
2
|
|
|
3
|
-
from collections.abc import AsyncGenerator, AsyncIterator
|
|
4
|
-
from contextlib import asynccontextmanager
|
|
3
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
|
4
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
import faststream
|
|
8
8
|
from faststream._internal.broker import BrokerUsecase
|
|
9
9
|
from faststream._internal.configs import BrokerConfig
|
|
10
|
-
from starlette.types import StatelessLifespan
|
|
11
10
|
|
|
12
11
|
from neva.arch import Application, ServiceProvider
|
|
13
12
|
from neva.support import Result
|
|
14
13
|
|
|
15
14
|
|
|
15
|
+
# Spelled out locally rather than borrowed from starlette.types: a messaging
|
|
16
|
+
# app's lifespan is not a web concern, and the core must not import an ASGI
|
|
17
|
+
# framework to describe one.
|
|
18
|
+
type FastStreamLifespan = Callable[["FastStream"], AbstractAsyncContextManager[None]]
|
|
19
|
+
|
|
20
|
+
|
|
16
21
|
class FastStream(faststream.FastStream):
|
|
17
22
|
"""FastStream wrapper for Neva."""
|
|
18
23
|
|
|
@@ -42,9 +47,7 @@ class FastStream(faststream.FastStream):
|
|
|
42
47
|
async with self.application.lifespan():
|
|
43
48
|
yield
|
|
44
49
|
|
|
45
|
-
def _create_lifespan(
|
|
46
|
-
self,
|
|
47
|
-
) -> StatelessLifespan["FastStream"]:
|
|
50
|
+
def _create_lifespan(self) -> FastStreamLifespan:
|
|
48
51
|
@asynccontextmanager
|
|
49
52
|
async def composed_lifespan(_: faststream.FastStream) -> AsyncIterator[None]:
|
|
50
53
|
async with self.lifespan():
|
neva/arch/service_provider.py
CHANGED
|
@@ -8,7 +8,6 @@ container and optionally managing their lifecycle.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import abc
|
|
11
|
-
import warnings
|
|
12
11
|
from contextlib import AbstractAsyncContextManager
|
|
13
12
|
from typing import (
|
|
14
13
|
TYPE_CHECKING,
|
|
@@ -115,9 +114,9 @@ class ServiceProvider(abc.ABC):
|
|
|
115
114
|
`instance` — which say what they mean. Use `bind` directly only to
|
|
116
115
|
reach a scope those do not cover, and then pass `scope` explicitly.
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
A lifetime is required. Without one the binding would be a
|
|
118
|
+
process-wide singleton — the opposite of Laravel's transient `bind` —
|
|
119
|
+
with nothing at the call site saying so.
|
|
121
120
|
|
|
122
121
|
Args:
|
|
123
122
|
source: The class or factory to bind.
|
|
@@ -125,16 +124,17 @@ class ServiceProvider(abc.ABC):
|
|
|
125
124
|
scope: The dishka scope. Defaults to the provider's (APP).
|
|
126
125
|
when: Marker gating this binding.
|
|
127
126
|
cache: Whether to reuse one instance per scope. Defaults to True.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
TypeError: If neither `scope` nor `cache` is given.
|
|
128
130
|
"""
|
|
129
131
|
if scope is None and cache is None:
|
|
130
|
-
|
|
131
|
-
"bind()
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"transient() / instance(), or pass scope= or cache= yourself.",
|
|
135
|
-
DeprecationWarning,
|
|
136
|
-
stacklevel=2,
|
|
132
|
+
msg = (
|
|
133
|
+
"bind() requires an explicit lifetime: use singleton() for a "
|
|
134
|
+
"process-wide instance, or scoped() / transient() / "
|
|
135
|
+
"instance(), or pass scope= or cache= yourself."
|
|
137
136
|
)
|
|
137
|
+
raise TypeError(msg)
|
|
138
138
|
|
|
139
139
|
_ = self.provider.provide(
|
|
140
140
|
source=source,
|
neva/database/connection.py
CHANGED
|
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
|
|
|
7
7
|
from typing import final
|
|
8
8
|
|
|
9
9
|
from sqlalchemy.ext import asyncio
|
|
10
|
+
from structlog.stdlib import get_logger
|
|
10
11
|
|
|
11
12
|
from neva.database.transaction import BoundTransaction, TransactionState
|
|
12
13
|
from neva.obs import LogManager
|
|
@@ -147,9 +148,13 @@ class ConnectionManager:
|
|
|
147
148
|
else await tx.execute_on_rollback_callbacks()
|
|
148
149
|
)
|
|
149
150
|
label = "commit" if committed else "rollback"
|
|
151
|
+
# A post-commit callback cannot un-commit the transaction, so its
|
|
152
|
+
# failure is reported rather than raised. It must never be lost: fall
|
|
153
|
+
# back to a module logger when the manager has none.
|
|
154
|
+
logger = self.logger if self.logger is not None else get_logger(__name__)
|
|
150
155
|
for result in results:
|
|
151
|
-
if result.is_err
|
|
152
|
-
|
|
156
|
+
if result.is_err:
|
|
157
|
+
logger.error(
|
|
153
158
|
f"{label} callback failed",
|
|
154
159
|
error=result.err().unwrap(),
|
|
155
160
|
connection=self.name,
|
neva/database/manager.py
CHANGED
|
@@ -5,7 +5,6 @@ from contextlib import asynccontextmanager
|
|
|
5
5
|
from typing import final
|
|
6
6
|
|
|
7
7
|
from sqlalchemy.ext import asyncio
|
|
8
|
-
from typing_extensions import deprecated
|
|
9
8
|
|
|
10
9
|
from neva.database.config import ConnectionConfig
|
|
11
10
|
from neva.database.connection import ConnectionManager, TransactionContext
|
|
@@ -24,9 +23,14 @@ class DatabaseManager:
|
|
|
24
23
|
self._logger = logger
|
|
25
24
|
self._connections: dict[str, ConnectionManager] = {}
|
|
26
25
|
|
|
27
|
-
@deprecated("Please use DatabaseManager.register_connection instead.")
|
|
28
26
|
def register_engine(self, name: str, engine: asyncio.AsyncEngine) -> None:
|
|
29
|
-
"""Register
|
|
27
|
+
"""Register a connection around an engine you already built.
|
|
28
|
+
|
|
29
|
+
Prefer `register_connection`, which builds the engine from config. Use
|
|
30
|
+
this when the caller must own the engine — a test that creates the
|
|
31
|
+
schema on an in-memory database has to hand over that exact engine,
|
|
32
|
+
since a second engine to `sqlite:///:memory:` is a second, empty
|
|
33
|
+
database.
|
|
30
34
|
|
|
31
35
|
Args:
|
|
32
36
|
name: The connection name.
|
neva/database/transaction.py
CHANGED
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
"""Transaction management systems."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from enum import Enum, auto
|
|
7
|
-
from typing import
|
|
8
|
+
from typing import Self
|
|
8
9
|
|
|
9
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
11
|
|
|
11
|
-
from neva.support import Err, Nothing, Option, Result, Some
|
|
12
|
+
from neva.support import Err, Nothing, Ok, Option, Result, Some
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
type
|
|
15
|
+
type TransactionOutcome = Result[None, str] | None
|
|
16
|
+
type TransactionCallback = Callable[
|
|
17
|
+
[], TransactionOutcome | Awaitable[TransactionOutcome]
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _run_callback(callback: TransactionCallback) -> Result[None, str]:
|
|
22
|
+
"""Invoke a transaction callback, whether it is sync or async.
|
|
23
|
+
|
|
24
|
+
A callback may return a ``Result`` to report its own failure, or ``None``
|
|
25
|
+
to say "done, nothing to report" — which is what a bare side-effecting
|
|
26
|
+
lambda does.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The callback's own Result, Ok(None) if it returned None, or Err if it
|
|
30
|
+
raised.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
outcome = callback()
|
|
34
|
+
if inspect.isawaitable(outcome):
|
|
35
|
+
outcome = await outcome
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return Err(f"Callback raised: {e}")
|
|
38
|
+
return Ok(None) if outcome is None else outcome
|
|
15
39
|
|
|
16
40
|
|
|
17
41
|
class TransactionState(Enum):
|
|
@@ -77,10 +101,7 @@ class Transaction:
|
|
|
77
101
|
"""
|
|
78
102
|
results: list[Result[None, str]] = []
|
|
79
103
|
for callback in self._on_commit:
|
|
80
|
-
|
|
81
|
-
results.append(await callback())
|
|
82
|
-
except Exception as e:
|
|
83
|
-
results.append(Err(f"Callback raised: {e}"))
|
|
104
|
+
results.append(await _run_callback(callback))
|
|
84
105
|
self._on_commit.clear()
|
|
85
106
|
return results
|
|
86
107
|
|
|
@@ -92,10 +113,7 @@ class Transaction:
|
|
|
92
113
|
"""
|
|
93
114
|
results: list[Result[None, str]] = []
|
|
94
115
|
for callback in self._on_rollback:
|
|
95
|
-
|
|
96
|
-
results.append(await callback())
|
|
97
|
-
except Exception as e:
|
|
98
|
-
results.append(Err(f"Callback raised: {e}"))
|
|
116
|
+
results.append(await _run_callback(callback))
|
|
99
117
|
self._on_rollback.clear()
|
|
100
118
|
return results
|
|
101
119
|
|
neva/obs/__init__.py
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
"""Observability tooling."""
|
|
2
2
|
|
|
3
3
|
from neva.obs.logging import LogManager, LogServiceProvider
|
|
4
|
-
from neva.obs.middleware.correlation import CorrelationMiddleware
|
|
5
|
-
from neva.obs.middleware.profiler import ProfilerMiddleware
|
|
6
4
|
|
|
7
5
|
|
|
8
6
|
__all__ = [
|
|
9
|
-
"CorrelationMiddleware",
|
|
10
7
|
"LogManager",
|
|
11
8
|
"LogServiceProvider",
|
|
12
|
-
"ProfilerMiddleware",
|
|
13
9
|
]
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
import secrets
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def generate_token(
|
|
7
|
-
"""Generate a secure random token.
|
|
6
|
+
def generate_token(nbytes: int = 32) -> str:
|
|
7
|
+
"""Generate a secure random URL-safe token.
|
|
8
8
|
|
|
9
9
|
Args:
|
|
10
|
-
|
|
10
|
+
nbytes: Bytes of entropy to draw. This is not the length of the
|
|
11
|
+
returned string: base64url encoding makes it roughly
|
|
12
|
+
``4 * nbytes / 3`` characters, so the default 32 bytes yields a
|
|
13
|
+
43-character token.
|
|
11
14
|
|
|
12
15
|
Returns:
|
|
13
|
-
|
|
16
|
+
A URL-safe token carrying `nbytes` bytes of entropy.
|
|
14
17
|
"""
|
|
15
|
-
|
|
16
|
-
return token
|
|
18
|
+
return secrets.token_urlsafe(nbytes)
|
neva/testing/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Testing utilities for Neva applications."""
|
|
2
2
|
|
|
3
3
|
from neva.testing.fakes import EventFake
|
|
4
|
-
from neva.testing.test_case import RefreshDatabase, TestCase
|
|
4
|
+
from neva.testing.test_case import FreshApplication, RefreshDatabase, TestCase
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
__all__ = ["EventFake", "RefreshDatabase", "TestCase"]
|
|
7
|
+
__all__ = ["EventFake", "FreshApplication", "RefreshDatabase", "TestCase"]
|
neva/testing/test_case.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Base test case class."""
|
|
2
2
|
|
|
3
|
+
import inspect
|
|
3
4
|
from collections.abc import AsyncIterator, Iterator
|
|
4
5
|
from pathlib import Path
|
|
6
|
+
from typing import override
|
|
5
7
|
|
|
6
8
|
import pytest
|
|
9
|
+
import pytest_asyncio
|
|
7
10
|
|
|
8
11
|
from neva.arch import Application, Facade
|
|
9
12
|
from neva.database.manager import DatabaseManager
|
|
@@ -15,17 +18,43 @@ class TestCase:
|
|
|
15
18
|
Subclasses can override `create_config` to provide custom configuration
|
|
16
19
|
for their tests. The application will be automatically created with this
|
|
17
20
|
configuration and injected into `self.app`.
|
|
21
|
+
|
|
22
|
+
The application is built **once per class**, not once per test: construction
|
|
23
|
+
costs tens of milliseconds (config loading re-executes every module in the
|
|
24
|
+
config directory, then every provider registers and a DI container is built)
|
|
25
|
+
and nothing in it is per-test. `RefreshDatabase` isolates database state by
|
|
26
|
+
rolling the transaction back, and `_restore_facades` undoes fakes.
|
|
27
|
+
|
|
28
|
+
Per class rather than per session is deliberate. `create_config` is a
|
|
29
|
+
per-subclass override point, so one application for the whole session would
|
|
30
|
+
serve every subclass the config of whichever class happened to run first.
|
|
31
|
+
|
|
32
|
+
Two consequences for subclasses:
|
|
33
|
+
|
|
34
|
+
* State written onto `self.app` outlives the test that wrote it, for the rest
|
|
35
|
+
of its class. Database state does not -- that is what `RefreshDatabase` is
|
|
36
|
+
for.
|
|
37
|
+
* Every test in a class shares one event loop, which is what lets the
|
|
38
|
+
application's engines and connections outlive a single test.
|
|
39
|
+
|
|
40
|
+
A class that genuinely needs a fresh application per test -- one varying the
|
|
41
|
+
*shape* of its config rather than reading it -- inherits `FreshApplication`.
|
|
18
42
|
"""
|
|
19
43
|
|
|
44
|
+
pytestmark = pytest.mark.asyncio(loop_scope="class")
|
|
45
|
+
|
|
20
46
|
app: Application
|
|
21
47
|
|
|
22
|
-
|
|
48
|
+
@classmethod
|
|
49
|
+
def create_config(cls, tmp_path: Path) -> Path:
|
|
23
50
|
"""Create the configuration directory for tests.
|
|
24
51
|
|
|
25
|
-
Override this method to provide custom configuration.
|
|
52
|
+
Override this method to provide custom configuration. It is a classmethod
|
|
53
|
+
because the fixture that calls it is class-scoped: no instance exists yet
|
|
54
|
+
when the application is built.
|
|
26
55
|
|
|
27
56
|
Args:
|
|
28
|
-
tmp_path:
|
|
57
|
+
tmp_path: A temporary directory belonging to this class.
|
|
29
58
|
|
|
30
59
|
Returns:
|
|
31
60
|
Path to the configuration directory.
|
|
@@ -38,20 +67,48 @@ class TestCase:
|
|
|
38
67
|
_ = (config_dir / "providers.py").write_text("""config = { "providers": []}""")
|
|
39
68
|
return config_dir
|
|
40
69
|
|
|
41
|
-
@pytest.fixture
|
|
42
|
-
|
|
70
|
+
@pytest.fixture(scope="class")
|
|
71
|
+
@classmethod
|
|
72
|
+
def _test_case_config(cls, tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
43
73
|
"""Config fixture that uses the create_config method.
|
|
44
74
|
|
|
45
75
|
Returns:
|
|
46
76
|
Path to the configuration directory.
|
|
47
77
|
"""
|
|
48
|
-
return
|
|
78
|
+
return cls._build_config(tmp_path_factory.mktemp("neva"))
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def _build_config(cls, tmp_path: Path) -> Path:
|
|
82
|
+
"""Call `create_config`, rejecting an override left as an instance method.
|
|
49
83
|
|
|
50
|
-
|
|
84
|
+
`create_config` became a classmethod when the application fixture became
|
|
85
|
+
class-scoped. An override still declared as `def create_config(self, ...)`
|
|
86
|
+
would otherwise fail with a bare "missing 1 required positional
|
|
87
|
+
argument", which says nothing about what to change.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Path to the configuration directory.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
TypeError: If the override is an instance method.
|
|
94
|
+
"""
|
|
95
|
+
create_config = inspect.getattr_static(cls, "create_config")
|
|
96
|
+
if isinstance(create_config, classmethod):
|
|
97
|
+
return cls.create_config(tmp_path)
|
|
98
|
+
|
|
99
|
+
msg = (
|
|
100
|
+
f"{cls.__name__}.create_config is an instance method. The application"
|
|
101
|
+
" fixture is class-scoped, so create_config must be a @classmethod"
|
|
102
|
+
" taking (cls, tmp_path)."
|
|
103
|
+
)
|
|
104
|
+
raise TypeError(msg)
|
|
105
|
+
|
|
106
|
+
@pytest_asyncio.fixture(scope="class", loop_scope="class")
|
|
107
|
+
@classmethod
|
|
51
108
|
async def _test_case_application(
|
|
52
|
-
|
|
109
|
+
cls, _test_case_config: Path
|
|
53
110
|
) -> AsyncIterator[Application]:
|
|
54
|
-
"""Application fixture for TestCase subclasses.
|
|
111
|
+
"""Application fixture for TestCase subclasses, built once per class.
|
|
55
112
|
|
|
56
113
|
Yields:
|
|
57
114
|
Application instance with lifespan managed.
|
|
@@ -65,6 +122,17 @@ class TestCase:
|
|
|
65
122
|
"""Auto-inject app fixture into self.app."""
|
|
66
123
|
self.app = _test_case_application
|
|
67
124
|
|
|
125
|
+
@pytest.fixture(autouse=True)
|
|
126
|
+
def _own_the_facade(self, _test_case_application: Application) -> None:
|
|
127
|
+
"""Point the facade root at this class's application, for every test.
|
|
128
|
+
|
|
129
|
+
`Application.lifespan` already does this on entry, but the root is a single
|
|
130
|
+
process-global slot: another application booting and tearing down inside a
|
|
131
|
+
test would leave it pointing elsewhere. Re-asserting it per test costs one
|
|
132
|
+
attribute write and makes the ordering of unrelated tests irrelevant.
|
|
133
|
+
"""
|
|
134
|
+
Facade.set_facade_application(_test_case_application)
|
|
135
|
+
|
|
68
136
|
@pytest.fixture(autouse=True)
|
|
69
137
|
def _restore_facades(self) -> Iterator[None]:
|
|
70
138
|
"""Restore all faked facades after each test."""
|
|
@@ -72,6 +140,45 @@ class TestCase:
|
|
|
72
140
|
Facade.restore_all()
|
|
73
141
|
|
|
74
142
|
|
|
143
|
+
class FreshApplication(TestCase):
|
|
144
|
+
"""Opt-out base: build a new application for every test.
|
|
145
|
+
|
|
146
|
+
`TestCase` shares one application per class. Subclass this instead when a class
|
|
147
|
+
needs a genuinely fresh application per test -- typically because its tests vary
|
|
148
|
+
the *shape* of the configuration, so the application under test differs from one
|
|
149
|
+
test to the next rather than merely being read differently.
|
|
150
|
+
|
|
151
|
+
It pays the full construction cost per test, so reach for it only when the
|
|
152
|
+
shared application is actually wrong::
|
|
153
|
+
|
|
154
|
+
class TestConfigVariations(FreshApplication):
|
|
155
|
+
...
|
|
156
|
+
|
|
157
|
+
It composes with `RefreshDatabase` the same way `TestCase` does::
|
|
158
|
+
|
|
159
|
+
class TestConfigVariations(FreshApplication, RefreshDatabase):
|
|
160
|
+
...
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
# A fixture override cannot keep its parent's signature here: the shared
|
|
164
|
+
# application is class-scoped and therefore a classmethod, while a per-test one
|
|
165
|
+
# needs the instance. Overriding a fixture by name is the pytest-sanctioned way
|
|
166
|
+
# to do this, and no type checker can model it.
|
|
167
|
+
@pytest_asyncio.fixture(loop_scope="class")
|
|
168
|
+
@override
|
|
169
|
+
async def _test_case_application( # pyrefly: ignore[bad-override]
|
|
170
|
+
self, tmp_path: Path
|
|
171
|
+
) -> AsyncIterator[Application]:
|
|
172
|
+
"""Override `TestCase`'s class-scoped application with a per-test one.
|
|
173
|
+
|
|
174
|
+
Yields:
|
|
175
|
+
A freshly built Application, torn down at the end of the test.
|
|
176
|
+
"""
|
|
177
|
+
app = Application(config_path=self._build_config(tmp_path))
|
|
178
|
+
async with app.lifespan():
|
|
179
|
+
yield app
|
|
180
|
+
|
|
181
|
+
|
|
75
182
|
class RefreshDatabase(TestCase):
|
|
76
183
|
"""Mixin for TestCase that wraps each test in a transaction and rolls it back.
|
|
77
184
|
|
|
@@ -80,7 +187,7 @@ class RefreshDatabase(TestCase):
|
|
|
80
187
|
before the rollback transaction opens.
|
|
81
188
|
"""
|
|
82
189
|
|
|
83
|
-
@
|
|
190
|
+
@pytest_asyncio.fixture(loop_scope="class")
|
|
84
191
|
async def _setup_database_engine(self) -> AsyncIterator[None]:
|
|
85
192
|
"""Hook for registering DB engines before the rollback transaction opens.
|
|
86
193
|
|
|
@@ -88,7 +195,7 @@ class RefreshDatabase(TestCase):
|
|
|
88
195
|
"""
|
|
89
196
|
yield
|
|
90
197
|
|
|
91
|
-
@
|
|
198
|
+
@pytest_asyncio.fixture(autouse=True, loop_scope="class")
|
|
92
199
|
async def _rollback_database(
|
|
93
200
|
self,
|
|
94
201
|
_setup_database_engine: None, # ordering dependency: runs after engine setup
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-neva
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: aiosqlite>=0.20.0
|
|
@@ -12,9 +12,10 @@ Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
|
|
|
12
12
|
Requires-Dist: pydantic>=2.7
|
|
13
13
|
Requires-Dist: pyinstrument>=5.1.1
|
|
14
14
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
15
|
-
Requires-Dist: starlette>=0.41
|
|
16
15
|
Requires-Dist: structlog>=25.5.0
|
|
17
16
|
Requires-Dist: typing-extensions>=4.13
|
|
17
|
+
Provides-Extra: asgi
|
|
18
|
+
Requires-Dist: neva-asgi>=0.1.1; extra == 'asgi'
|
|
18
19
|
Provides-Extra: fastapi
|
|
19
20
|
Requires-Dist: neva-fastapi>=1.1.1; extra == 'fastapi'
|
|
20
21
|
Provides-Extra: faststream
|
|
@@ -48,6 +49,7 @@ independent repo, independently versioned and published.
|
|
|
48
49
|
| ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
|
|
49
50
|
| `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
|
|
50
51
|
| `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
|
|
52
|
+
| `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
|
|
51
53
|
| `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
|
|
52
54
|
| `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
|
|
53
55
|
| `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
neva/arch/__init__.py,sha256=
|
|
2
|
-
neva/arch/application.py,sha256=
|
|
3
|
-
neva/arch/config.py,sha256=
|
|
1
|
+
neva/arch/__init__.py,sha256=S9I9HUL9G-ylfzMdIfhLS8ux1heyFSti1auXpKvZrEY,523
|
|
2
|
+
neva/arch/application.py,sha256=saXl8KdCp_EEwi0tgXqI_iuEzqtrw_CXixjkqSLlhNo,11401
|
|
3
|
+
neva/arch/config.py,sha256=F_4TNIYJpqnAbQrfeYsKKHK9Py6L0ew1UkW0P9YkFro,1478
|
|
4
4
|
neva/arch/facade.py,sha256=1kYVhyYto8zSyOZV6sAYJ3xWzQDJRzoVV-e_EarTp0Q,7927
|
|
5
5
|
neva/arch/markers.py,sha256=STdhmW88qV8uogIa9kSD9gs-uMRaglZI8HOw3PrhX1Q,103
|
|
6
6
|
neva/arch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
neva/arch/scopes.py,sha256=9sc1nIVeODQzAtNwvSUWiij6IIssNyOaKW2CNknBgTo,86
|
|
8
|
-
neva/arch/service_provider.py,sha256=
|
|
8
|
+
neva/arch/service_provider.py,sha256=eA6ZGYOYnZTgfQu-ry5qTmViX7B-KJFjC6NXTSxlbaE,8537
|
|
9
9
|
neva/arch/integrations/__init__.py,sha256=cXuqC4JfZdGYLlh6POgYUVXUT2NQ84276eC-RYTyFxk,75
|
|
10
|
-
neva/arch/integrations/faststream.py,sha256=
|
|
10
|
+
neva/arch/integrations/faststream.py,sha256=_SnOg8Z-v_XqX_ReeLLTyz-oPm4-9tj9SblX6sDIZao,1931
|
|
11
11
|
neva/config/__init__.py,sha256=Ygfnh-MRRKYDdf54_LeMSHwmck5VpNHTdnnMtGEFLtU,121
|
|
12
12
|
neva/config/base_providers.py,sha256=vXv3kGWnrWQGBMo4WvEmYnryCKLkOdFFTjrDFDnhXFI,909
|
|
13
13
|
neva/config/loader.py,sha256=E81a6nEboRNNMBEFU26UvEgvAjPIN3Aqn6mmVpa5cf4,4534
|
|
@@ -15,11 +15,11 @@ neva/config/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
15
15
|
neva/config/repository.py,sha256=p8PjTfjV23ioy_iyb5b6oYPnYq1bVe6q6gVRSegJr7Q,5505
|
|
16
16
|
neva/database/__init__.py,sha256=3yYnEe8HQM86tURyAhTbMtE7AXubIEMN3GuwxkkT-bk,508
|
|
17
17
|
neva/database/config.py,sha256=bVKUqlrYDFrURqg_FbntPXyVkbOUPSVXQJTKjyucjL0,451
|
|
18
|
-
neva/database/connection.py,sha256=
|
|
19
|
-
neva/database/manager.py,sha256=
|
|
18
|
+
neva/database/connection.py,sha256=PkpC--RyLh0K2AR26a1ZlpHN0p4uFFlcbwSpPdSj8Y8,7274
|
|
19
|
+
neva/database/manager.py,sha256=uZkCSvOgzjdQbuw4YKsNbA4T97nmbksKuAlp0yktAUE,4817
|
|
20
20
|
neva/database/provider.py,sha256=XCi8-mfJ5KDVpVO10YlNX4jt0a9ESSeTyn8o3ohOe9c,1701
|
|
21
21
|
neva/database/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
-
neva/database/transaction.py,sha256=
|
|
22
|
+
neva/database/transaction.py,sha256=wC0BW44c16wxiFBXvLC921lXQ7ESxV-rZCpqTPx8Kvs,5703
|
|
23
23
|
neva/events/__init__.py,sha256=Gpi5q-pNlIi-q23U2ZOsNf8Y9rB6AQtsBbbBFU0tkFc,776
|
|
24
24
|
neva/events/dispatcher.py,sha256=O_sDpbQBRMFg34hoXEtIfWhYVX-_QUjYB0Nk4QXOGsM,4453
|
|
25
25
|
neva/events/event.py,sha256=q-KyyxNkU3AN2YSaujuNPIoXQMXhGOwClTap5y8fU5I,282
|
|
@@ -33,16 +33,13 @@ neva/events/contracts/dispatcher.py,sha256=pzCv_ZLS5ju2IKVMxL5f-fFipEqQZc6zPiIVt
|
|
|
33
33
|
neva/events/contracts/event.py,sha256=kUUECex_AlmaPJ-fXRg32eMkiqNS75CHldOr7bbpFqo,89
|
|
34
34
|
neva/events/contracts/handler.py,sha256=SwLuuRtXhCzGkcBBLdH3MjUAkjEr0_rhqgE6dkWVJxI,327
|
|
35
35
|
neva/events/contracts/listener.py,sha256=0xwPrjprkMzXu9vY1Z-EjSLFdvOUpomiE65hH16nfxA,465
|
|
36
|
-
neva/obs/__init__.py,sha256=
|
|
36
|
+
neva/obs/__init__.py,sha256=uVD8uKaRTLviBNQ681lG6mVgVynQ47-qwzehbTNUPpE,150
|
|
37
37
|
neva/obs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
38
38
|
neva/obs/instrumentation/__init__.py,sha256=SfIAvuCs-L0kEzOxUJ4aOIkhbydCe7qBwWtGeDELVA0,36
|
|
39
39
|
neva/obs/instrumentation/sqlalchemy.py,sha256=kwk43NthFi4mPMhDJI55rxt9YPOY_mwOkr2E-cD64ZY,497
|
|
40
40
|
neva/obs/logging/__init__.py,sha256=gIYBAzMK7-wPGEpA_kYKvJPT3hYN99OcCc9UuHFeiHk,251
|
|
41
41
|
neva/obs/logging/manager.py,sha256=ktd3muBGU3e1KuBK6tSU5faRJRliPwmknuqJ2EYeFBk,2422
|
|
42
42
|
neva/obs/logging/provider.py,sha256=-AIZb60PmTpvO9PtsJwZ5FJeTC_Yn6ik8DdJCHULpV0,651
|
|
43
|
-
neva/obs/middleware/__init__.py,sha256=WbZ-Pr_fVl6T8LfQRyC_YXMlN7WaQKEwX8KUvGiLOBc,182
|
|
44
|
-
neva/obs/middleware/correlation.py,sha256=x_3HtlBV1Qe8JRgGpEwSTXvKkeCgWal15b0hrFjNAJs,2034
|
|
45
|
-
neva/obs/middleware/profiler.py,sha256=ovx3s-39WpNtu2KhzVMWyIJa6OLrSVDuL56xyDA-vQ4,1942
|
|
46
43
|
neva/polyfactory/__init__.py,sha256=t7uAXaleAyshSyBdGXNzvgY0nNpb5AqFU4zjE8lyKyk,153
|
|
47
44
|
neva/polyfactory/factories.py,sha256=8VoZJlY0-_ZOM8rmfUcvEbt-KOZsaDj8f6R5EQF62u8,600
|
|
48
45
|
neva/polyfactory/persistence.py,sha256=1qBg8L_xkiRFq6J2XoZFez43oxNWDaUM8SrlNAeOkQA,605
|
|
@@ -61,7 +58,7 @@ neva/security/hashing/hashers/argon2.py,sha256=pJqTl3_oCJCjJarnQwNSFRznlaijjbjdD
|
|
|
61
58
|
neva/security/hashing/hashers/bcrypt.py,sha256=-VsCtbQvEFDoKnwQxUwYFGSI_1qyWfP3R5TAhNvZW0Q,1083
|
|
62
59
|
neva/security/hashing/hashers/protocol.py,sha256=aVGMO2q3H-u8uyGPt6mIy1Dq7pW0k4WjtHi0nnKsgCQ,611
|
|
63
60
|
neva/security/tokens/__init__.py,sha256=H1SWLWa4btJqc0wBUFTdKUYmBgXYZYnCitG1BsbLtqI,235
|
|
64
|
-
neva/security/tokens/generate_token.py,sha256=
|
|
61
|
+
neva/security/tokens/generate_token.py,sha256=K3Wy7DGVlBvPflDh6C8-FWZWCSPc3Fwmw9LqJdIViN4,524
|
|
65
62
|
neva/security/tokens/hash_token.py,sha256=KMzYXNqi6SbmgNqntGnsE9FMIoDIEVEsCumLYzkQssc,351
|
|
66
63
|
neva/security/tokens/verify_token.py,sha256=kPs3HQwu0N8Syp7V_hY_YmOWfmq-Cd9ACUWsxwjniZk,460
|
|
67
64
|
neva/support/__init__.py,sha256=EOdAwB2qA_CwPnoace-Q1CEOvp0B4PdlPxRUGsWABD4,447
|
|
@@ -86,11 +83,11 @@ neva/support/facade/hash.py,sha256=tGhsvfGovt-mcRXSjDKf4jt1n7ib0eDPXFvRUfVxLUc,2
|
|
|
86
83
|
neva/support/facade/hash.pyi,sha256=Uy0CkV67MImPjHc6BmmLQPd7qF1QNDtO1oDFyyRr-1A,2950
|
|
87
84
|
neva/support/facade/log.py,sha256=_uLoHB9tkpHkiqEJXazbaqfiKUOtv3NS71ZCZAprzOI,347
|
|
88
85
|
neva/support/facade/log.pyi,sha256=Xi7gIzw9qRqmD4D3PcW06_HUtgLWEOdsHqs1B0jh1-k,1647
|
|
89
|
-
neva/testing/__init__.py,sha256=
|
|
86
|
+
neva/testing/__init__.py,sha256=QW9inJP1lC_HeoJbKjD_kyHCy4cFV6qt9Rp-EjsKmOY,245
|
|
90
87
|
neva/testing/fakes.py,sha256=w2dnFaO6x14l0_si7iBNELMCxaFi8VsVOSQ6PmTlclE,2936
|
|
91
88
|
neva/testing/fixtures.py,sha256=oiPa5ntUkW-SbySDvwEHXtti8NqSwI-R0CT1_ezTx_Y,1445
|
|
92
89
|
neva/testing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
93
|
-
neva/testing/test_case.py,sha256=
|
|
94
|
-
python_neva-
|
|
95
|
-
python_neva-
|
|
96
|
-
python_neva-
|
|
90
|
+
neva/testing/test_case.py,sha256=quVjECLWw5ZOnf7kwvtRsyD-_mkyxv5yRuvNSOAJYUI,8145
|
|
91
|
+
python_neva-4.0.0.dist-info/METADATA,sha256=ILm9awKSo2HaeSc5IgvKFCDl2SPBd9Yd3zEmTnRrG9I,5523
|
|
92
|
+
python_neva-4.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
93
|
+
python_neva-4.0.0.dist-info/RECORD,,
|
neva/obs/middleware/__init__.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""Correlation middleware."""
|
|
2
|
-
|
|
3
|
-
import uuid
|
|
4
|
-
from collections.abc import Callable
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
|
-
from typing import Literal
|
|
7
|
-
|
|
8
|
-
from starlette.datastructures import MutableHeaders
|
|
9
|
-
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def is_valid_uuid(uuid_: str, version: Literal[1, 2, 3, 4] = 4) -> bool:
|
|
13
|
-
"""Check if a string is a valid UUID.
|
|
14
|
-
|
|
15
|
-
Args:
|
|
16
|
-
uuid_: The string to check.
|
|
17
|
-
version: The UUID version to check.
|
|
18
|
-
|
|
19
|
-
Returns:
|
|
20
|
-
True if the string is a valid UUID, False otherwise.
|
|
21
|
-
"""
|
|
22
|
-
try:
|
|
23
|
-
return uuid.UUID(uuid_).version == version
|
|
24
|
-
except ValueError:
|
|
25
|
-
return False
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
CorrelationHeader = Literal["X-Request-ID", "X-Correlation-ID"]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@dataclass
|
|
32
|
-
class CorrelationMiddleware:
|
|
33
|
-
"""Middleware to add a correlation ID to outgoing requests."""
|
|
34
|
-
|
|
35
|
-
app: ASGIApp
|
|
36
|
-
|
|
37
|
-
header_name: CorrelationHeader = "X-Request-ID"
|
|
38
|
-
generator: Callable[[], str] = field(default=lambda: uuid.uuid4().hex)
|
|
39
|
-
validator: Callable[[str], bool] = field(default=is_valid_uuid)
|
|
40
|
-
|
|
41
|
-
async def __call__(
|
|
42
|
-
self,
|
|
43
|
-
scope: Scope,
|
|
44
|
-
receive: Receive,
|
|
45
|
-
send: Send,
|
|
46
|
-
) -> None:
|
|
47
|
-
"""Middleware entry point."""
|
|
48
|
-
if scope["type"] not in ("http", "websocket"):
|
|
49
|
-
await self.app(scope, receive, send)
|
|
50
|
-
return
|
|
51
|
-
|
|
52
|
-
headers = MutableHeaders(scope=scope)
|
|
53
|
-
header_value = headers.get("X-Request-ID".lower()) or headers.get(
|
|
54
|
-
"X-Correlation-ID".lower()
|
|
55
|
-
)
|
|
56
|
-
if header_value is None or not self.validator(header_value):
|
|
57
|
-
header_value = self.generator()
|
|
58
|
-
|
|
59
|
-
scope["state"]["correlation_id"] = header_value
|
|
60
|
-
|
|
61
|
-
async def handle_outgoing_request(message: Message) -> None:
|
|
62
|
-
if message["type"] == "http.response.start":
|
|
63
|
-
headers = MutableHeaders(scope=message)
|
|
64
|
-
headers.append(self.header_name, header_value)
|
|
65
|
-
await send(message)
|
|
66
|
-
|
|
67
|
-
await self.app(scope, receive, handle_outgoing_request)
|
|
68
|
-
return
|
neva/obs/middleware/profiler.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""Profiler middleware.
|
|
2
|
-
|
|
3
|
-
This module provides the ProfilerMiddleware class, which wraps an ASGI
|
|
4
|
-
application and profiles its execution. It generates a HTML report of the
|
|
5
|
-
profiling data and writes it to a file.
|
|
6
|
-
|
|
7
|
-
Uses the Pyinstrument library for profiling.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
-
|
|
12
|
-
from pyinstrument import Profiler
|
|
13
|
-
from pyinstrument.profiler import AsyncMode
|
|
14
|
-
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
15
|
-
|
|
16
|
-
from neva.support import time
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@dataclass
|
|
20
|
-
class ProfilerMiddleware:
|
|
21
|
-
"""Middleware for profiling ASGI applications.
|
|
22
|
-
|
|
23
|
-
This middleware wraps an ASGI application and profiles its execution.
|
|
24
|
-
It generates a HTML report of the profiling data and writes it to a file.
|
|
25
|
-
"""
|
|
26
|
-
|
|
27
|
-
app: ASGIApp
|
|
28
|
-
|
|
29
|
-
interval: float = 0.001
|
|
30
|
-
async_mode: AsyncMode = "enabled"
|
|
31
|
-
use_timing_thread: bool = False
|
|
32
|
-
path: str = "./profiles"
|
|
33
|
-
|
|
34
|
-
async def __call__(
|
|
35
|
-
self,
|
|
36
|
-
scope: Scope,
|
|
37
|
-
receive: Receive,
|
|
38
|
-
send: Send,
|
|
39
|
-
) -> None:
|
|
40
|
-
"""Wrap the application with profiling.
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
scope: The ASGI scope.
|
|
44
|
-
receive: The ASGI receive function.
|
|
45
|
-
send: The ASGI send function.
|
|
46
|
-
"""
|
|
47
|
-
if scope["type"] != "http":
|
|
48
|
-
await self.app(scope, receive, send)
|
|
49
|
-
return
|
|
50
|
-
correlation_id = scope["state"].get("correlation_id", None)
|
|
51
|
-
filename = (
|
|
52
|
-
f"profile_{correlation_id}"
|
|
53
|
-
if correlation_id is not None
|
|
54
|
-
else f"profile_{time.utcnow_ts()}"
|
|
55
|
-
)
|
|
56
|
-
self.profiler = Profiler(
|
|
57
|
-
interval=self.interval,
|
|
58
|
-
async_mode=self.async_mode,
|
|
59
|
-
use_timing_thread=self.use_timing_thread,
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
self.profiler.start()
|
|
63
|
-
try:
|
|
64
|
-
await self.app(scope, receive, send)
|
|
65
|
-
finally:
|
|
66
|
-
_ = self.profiler.stop()
|
|
67
|
-
file_path = f"{self.path}/{filename}.html"
|
|
68
|
-
self.profiler.write_html(file_path)
|
|
File without changes
|