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 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, Callable, Self
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 "./config"
35
- relative to the current working directory.
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
- Facade.reset_facade_application()
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: routing this through the
254
- # deprecated `bind` warned on every boot.
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
- result = await self.make_async(EventDispatcher)
262
- if result.is_err:
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
- dispatcher = result.unwrap()
266
- for provider in self.providers.values():
267
- for event_cls, listeners in provider.listen.items():
268
- for listener_cls in listeners:
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
- """Helper TypedDict for application configuration."""
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
- """Main application config.
24
+ """The `app` namespace keys the core reads.
10
25
 
11
26
  Attributes:
12
- title: The application title.
13
- debug: Whether the application is in debug mode.
14
- version: The application version.
15
- openapi_url: The URL for the OpenAPI specification.
16
- docs_url: The URL for the Swagger UI documentation.
17
- redoc_url: The URL for the ReDoc documentation.
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
- title: NotRequired[str]
22
- debug: NotRequired[bool]
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
- """Application service provider config.
41
+ """The `providers` namespace.
42
+
43
+ A dedicated namespace for the provider list, read before `app.providers`.
32
44
 
33
45
  Attributes:
34
- providers: List of service providers to register.
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():
@@ -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
- Calling this with neither `scope` nor `cache` is deprecated: the
119
- resulting binding is a process-wide singleton, which is the opposite of
120
- Laravel's transient `bind`, and nothing at the call site says so.
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
- warnings.warn(
131
- "bind() without an explicit lifetime is deprecated and will "
132
- "require one in 4.0. It currently creates a process-wide "
133
- "singleton: use singleton() for that, or scoped() / "
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,
@@ -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 and self.logger is not None:
152
- self.logger.error(
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 an engine and create a session factory for a connection.
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.
@@ -1,17 +1,41 @@
1
1
  """Transaction management systems."""
2
2
 
3
3
  import asyncio
4
- from collections.abc import Awaitable
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 Callable, Self
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 TransactionCallback = Callable[[], Awaitable[Result[None, str]]]
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
- try:
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
- try:
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(length: int = 32) -> str:
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
- length (int, optional): The length of the token. Defaults to 32.
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
- str: The generated token.
16
+ A URL-safe token carrying `nbytes` bytes of entropy.
14
17
  """
15
- token = secrets.token_urlsafe(length)
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
- def create_config(self, tmp_path: Path) -> Path:
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: Pytest's temporary directory fixture.
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
- def _test_case_config(self, tmp_path: Path) -> Path:
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 self.create_config(tmp_path)
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
- @pytest.fixture
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
- self, _test_case_config: Path
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
- @pytest.fixture
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
- @pytest.fixture(autouse=True)
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.7.0
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=1ukTl5Ynz5PrGa5e-tFSMaNgVkfhQRrYQGPUqUeXMMg,429
2
- neva/arch/application.py,sha256=PQ5Zb29kwt6srDArnDrxwOjc62MYutQTmhlUCQ5wSlQ,9968
3
- neva/arch/config.py,sha256=k8Nf5kBfw0We-gFxPqDEi78T8eFHBhZe1qTpCi2Q0kA,1054
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=BRHMHuG3SRbVzQUipNaKgNQmn9GVUk5VO3Nart1JmnI,8630
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=AOwfpfdL3O5HLAx80SwUGQb_hKEeAkFOUyIXtjODaHM,1696
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=pBv29IygCnRGex4hiUx5TSb3TtmPHNBhOS4VTNUfQNc,6972
19
- neva/database/manager.py,sha256=yPbDMcVlsn5_8ISPVhyajAM42i34r2gJgI9o-TVp59c,4617
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=hhEZg34RsITM-MJHQEcWEgArG8AOYr16p1WjldVVpQ0,5140
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=dVzgljk9Hvo44LI34RcwbDyT42_z4nSnFVmL4GLbKD0,331
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=xqlC80IfCvkwmQR_TAHmEntiJdEV2yEPHX6qBnv5cBg,334
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=vt5zxG1N84vn_YwP44jhYPWYnyb02-cha3m-1l-qoEw,207
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=zJBZJeKI-sv-EOaXNuSJ0V2Zp_6hdk64W4dOh0eP8RI,3290
94
- python_neva-3.7.0.dist-info/METADATA,sha256=FShZGkR90WatQd_me7ozxBOpKNKUg5RVVe-owWdELqo,5198
95
- python_neva-3.7.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
96
- python_neva-3.7.0.dist-info/RECORD,,
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,,
@@ -1,7 +0,0 @@
1
- """Observability middlewares."""
2
-
3
- from .correlation import CorrelationMiddleware
4
- from .profiler import ProfilerMiddleware
5
-
6
-
7
- __all__ = ["CorrelationMiddleware", "ProfilerMiddleware"]
@@ -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
@@ -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)