xtr-http-kernel 1.4.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.
Files changed (39) hide show
  1. xtr_http_kernel/__init__.py +52 -0
  2. xtr_http_kernel/_kernel_middleware.py +76 -0
  3. xtr_http_kernel/_state.py +17 -0
  4. xtr_http_kernel/bundle/__init__.py +8 -0
  5. xtr_http_kernel/bundle/http_kernel_bundle.py +211 -0
  6. xtr_http_kernel/bundle/http_kernel_config.py +62 -0
  7. xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +35 -0
  8. xtr_http_kernel/command/__init__.py +9 -0
  9. xtr_http_kernel/command/_route_contexts.py +113 -0
  10. xtr_http_kernel/command/debug_router_command.py +52 -0
  11. xtr_http_kernel/command/route_description.py +81 -0
  12. xtr_http_kernel/command/router_command.py +69 -0
  13. xtr_http_kernel/command/router_match_command.py +96 -0
  14. xtr_http_kernel/event/__init__.py +35 -0
  15. xtr_http_kernel/event/exception_event.py +65 -0
  16. xtr_http_kernel/event/finish_request_event.py +40 -0
  17. xtr_http_kernel/event/request_event.py +56 -0
  18. xtr_http_kernel/event/response_event.py +64 -0
  19. xtr_http_kernel/event/terminate_event.py +43 -0
  20. xtr_http_kernel/event_listener/__init__.py +23 -0
  21. xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +31 -0
  22. xtr_http_kernel/event_listener/error_logging_listener.py +52 -0
  23. xtr_http_kernel/event_listener/log_unit_listener.py +39 -0
  24. xtr_http_kernel/event_listener/request_id_listener.py +72 -0
  25. xtr_http_kernel/exception/__init__.py +14 -0
  26. xtr_http_kernel/exception/http_kernel_error.py +15 -0
  27. xtr_http_kernel/exception/invalid_middleware_priority_error.py +39 -0
  28. xtr_http_kernel/kernel_events.py +51 -0
  29. xtr_http_kernel/middleware_stack.py +52 -0
  30. xtr_http_kernel/middleware_tag.py +19 -0
  31. xtr_http_kernel/py.typed +0 -0
  32. xtr_http_kernel/request_lifecycle_middleware.py +182 -0
  33. xtr_http_kernel/setup.py +96 -0
  34. xtr_http_kernel/testing.py +52 -0
  35. xtr_http_kernel-1.4.0.dist-info/METADATA +432 -0
  36. xtr_http_kernel-1.4.0.dist-info/RECORD +39 -0
  37. xtr_http_kernel-1.4.0.dist-info/WHEEL +4 -0
  38. xtr_http_kernel-1.4.0.dist-info/entry_points.txt +3 -0
  39. xtr_http_kernel-1.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,39 @@
1
+ """Each request framed as one unit of work for the logging package.
2
+
3
+ This module needs the optional logging extra: it is deliberately left out of
4
+ the package's eager re-exports, and the bundle registers the listener only
5
+ when the logging bundle is active.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, final
11
+
12
+ from xtr_logging import begin_unit, end_unit
13
+
14
+ if TYPE_CHECKING:
15
+ from xtr_http_kernel.event import RequestEvent, TerminateEvent
16
+
17
+ __all__ = ["LogUnitListener"]
18
+
19
+
20
+ @final
21
+ class LogUnitListener:
22
+ """Opens a unit of work when a request arrives, and closes it when all was sent.
23
+
24
+ State the logging package keeps per unit — the id tying a request's
25
+ records together, a fingers-crossed buffer — then lives exactly as long
26
+ as the request, and two requests handled at once keep their own.
27
+ """
28
+
29
+ __slots__ = ()
30
+
31
+ def on_request(self, event: RequestEvent) -> None:
32
+ """Open the request's unit, before anything else contributes."""
33
+ del event
34
+ begin_unit()
35
+
36
+ def on_terminate(self, event: TerminateEvent) -> None:
37
+ """Close the unit, after everything has been sent."""
38
+ del event
39
+ end_unit()
@@ -0,0 +1,72 @@
1
+ """Every request gets an id, and every response says which one it carried."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, Final, final
7
+ from uuid import uuid4
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable
11
+
12
+ from xtr_logging_contracts import Context
13
+
14
+ from xtr_http_kernel.event import RequestEvent, ResponseEvent
15
+
16
+ __all__ = ["RequestIdListener"]
17
+
18
+ _VALID_REQUEST_ID: Final = re.compile(r"[A-Za-z0-9._-]{1,200}")
19
+ """What an incoming id may look like before it is worth keeping."""
20
+
21
+ # Binding the id to the ambient log context is a courtesy to the logging
22
+ # package, which is an optional extra: without it there is nothing to bind to
23
+ # and the listener still does its job.
24
+ _bind_context: Callable[[Context], None] | None
25
+ try:
26
+ from xtr_logging import bind_context as _bind_context
27
+ except ImportError: # pragma: no cover — exercised only without the logging extra.
28
+ _bind_context = None
29
+
30
+
31
+ @final
32
+ class RequestIdListener:
33
+ """Keeps, or mints, one id per request, and echoes it on the response.
34
+
35
+ At the request: a well-formed incoming header is kept when trusted,
36
+ anything else is replaced with a fresh ``uuid4().hex``. The id is put on
37
+ ``request.state.request_id`` for whoever handles the request, and bound
38
+ to the ambient log context when the logging package is around, so every
39
+ record made while handling carries it.
40
+
41
+ At the response: the id goes out under the same header, so the caller
42
+ can quote it back.
43
+ """
44
+
45
+ __slots__ = ("_header", "_trust_incoming")
46
+
47
+ def __init__(self, header: str = "X-Request-Id", trust_incoming: bool = True) -> None:
48
+ """Read and write ``header``, keeping a valid incoming id when ``trust_incoming``."""
49
+ self._header = header
50
+ self._trust_incoming = trust_incoming
51
+
52
+ def on_request(self, event: RequestEvent) -> None:
53
+ """Settle the request's id before anything handles it."""
54
+ incoming = event.request.headers.get(self._header)
55
+ keep = (
56
+ self._trust_incoming and incoming is not None and _VALID_REQUEST_ID.fullmatch(incoming)
57
+ )
58
+ request_id = incoming if keep and incoming is not None else uuid4().hex
59
+ event.request.state.request_id = request_id
60
+ if _bind_context is not None:
61
+ _bind_context({"request_id": request_id})
62
+
63
+ def on_response(self, event: ResponseEvent) -> None:
64
+ """Echo the request's id on the outgoing head.
65
+
66
+ A listener answering at the request stops that event before this
67
+ listener's request half runs, so a response can start without an id
68
+ having been settled; such a response goes out unmarked.
69
+ """
70
+ request_id = getattr(event.request.state, "request_id", None)
71
+ if isinstance(request_id, str):
72
+ event.headers[self._header] = request_id
@@ -0,0 +1,14 @@
1
+ """Every error this library raises.
2
+
3
+ All of them derive from :class:`HttpKernelError`, so one ``except`` catches
4
+ anything the request lifecycle can go wrong with, and a narrower one handles
5
+ one cause. Each carries the data a caller needs as typed attributes rather
6
+ than forcing a message to be parsed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .http_kernel_error import HttpKernelError
12
+ from .invalid_middleware_priority_error import InvalidMiddlewarePriorityError
13
+
14
+ __all__ = ["HttpKernelError", "InvalidMiddlewarePriorityError"]
@@ -0,0 +1,15 @@
1
+ """The root every error in this library derives from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["HttpKernelError"]
6
+
7
+
8
+ class HttpKernelError(Exception):
9
+ """Base class for every error raised by this library.
10
+
11
+ Catch this to handle anything turning a request into a response can go
12
+ wrong with; catch a subclass to handle one cause. Every subclass carries
13
+ the data a caller needs as typed attributes and composes its own message
14
+ from them.
15
+ """
@@ -0,0 +1,39 @@
1
+ """Raised when a middleware tag carries a priority that is not an integer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .http_kernel_error import HttpKernelError
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Hashable
11
+
12
+ __all__ = ["InvalidMiddlewarePriorityError"]
13
+
14
+
15
+ class InvalidMiddlewarePriorityError(HttpKernelError):
16
+ """A ``http_kernel.middleware`` tag's ``priority`` attribute is not an integer.
17
+
18
+ Raised while the kernel is built, so a misdeclared bundle fails at
19
+ startup rather than serving an unordered chain.
20
+
21
+ Attributes:
22
+ key: The tagged service's ``(type, qualifier)`` key.
23
+ priority: What the tag carried instead of an integer.
24
+ """
25
+
26
+ key: tuple[type, Hashable | None]
27
+ priority: object
28
+
29
+ def __init__(self, key: tuple[type, Hashable | None], priority: object) -> None:
30
+ """Record the offending service key and what its tag carried."""
31
+ self.key = key
32
+ self.priority = priority
33
+ provided, qualifier = key
34
+ name = f"{provided.__module__}.{provided.__qualname__}"
35
+ service = name if qualifier is None else f"{name}[{qualifier!r}]"
36
+ super().__init__(
37
+ f"the http_kernel.middleware tag on {service} must carry an integer priority, "
38
+ f"not {priority!r}"
39
+ )
@@ -0,0 +1,51 @@
1
+ """The names the request lifecycle dispatches its events under."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final, final
6
+
7
+ from xtr_event_dispatcher_contracts import event_name_of
8
+
9
+ from .event import (
10
+ ExceptionEvent,
11
+ FinishRequestEvent,
12
+ RequestEvent,
13
+ ResponseEvent,
14
+ TerminateEvent,
15
+ )
16
+
17
+ __all__ = ["KernelEvents"]
18
+
19
+
20
+ @final
21
+ class KernelEvents:
22
+ """One name per lifecycle event, for listeners registered by name.
23
+
24
+ An event is keyed by the qualified name of its class, so a listener
25
+ declared on a typed parameter and one registered under the matching
26
+ constant here are registered for the same event. The constants exist for
27
+ the places a class cannot be written — a listener whose event is chosen
28
+ at runtime, a configuration file, a subscriber mapping names to methods:
29
+
30
+ ```python
31
+ dispatcher.add_listener(KernelEvents.RESPONSE, stamp_request_id, priority=100)
32
+ ```
33
+
34
+ Keeping them in one place also means the set of moments a request goes
35
+ through can be read at a glance, in the order it goes through them.
36
+ """
37
+
38
+ REQUEST: Final[str] = event_name_of(RequestEvent)
39
+ """The request arrived and nothing has handled it."""
40
+
41
+ RESPONSE: Final[str] = event_name_of(ResponseEvent)
42
+ """A response is about to start, and its head can still be changed."""
43
+
44
+ EXCEPTION: Final[str] = event_name_of(ExceptionEvent)
45
+ """Handling raised, and nothing has been sent."""
46
+
47
+ FINISH_REQUEST: Final[str] = event_name_of(FinishRequestEvent)
48
+ """Handling finished — dispatched on every path."""
49
+
50
+ TERMINATE: Final[str] = event_name_of(TerminateEvent)
51
+ """Everything has been sent, or the failure went past the lifecycle."""
@@ -0,0 +1,52 @@
1
+ """An ordered, immutable chain of middleware factories, ready to wrap an app.
2
+
3
+ The http_kernel bundle builds one when the kernel is compiled: every
4
+ definition tagged ``http_kernel.middleware`` contributes a factory, ordered
5
+ by the tag's ``priority`` — highest first, so it wraps outermost and sees
6
+ the request first. The setup call fetches the stack once per application
7
+ life; the middleware it added composes the stack over the application.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, final
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable, Iterable, Iterator
16
+
17
+ from starlette.types import ASGIApp
18
+
19
+ MiddlewareFactory = Callable[[ASGIApp], ASGIApp]
20
+
21
+ __all__ = ["MiddlewareStack"]
22
+
23
+
24
+ @final
25
+ class MiddlewareStack:
26
+ """The middleware factories a kernel contributed, the outermost first.
27
+
28
+ Immutable: built once when the kernel is compiled, composed as many
29
+ times as the serving code asks.
30
+ """
31
+
32
+ __slots__ = ("_factories",)
33
+
34
+ def __init__(self, factories: Iterable[MiddlewareFactory] = ()) -> None:
35
+ """Hold ``factories`` in the given order — the outermost first."""
36
+ self._factories = tuple(factories)
37
+
38
+ def wrap(self, app: ASGIApp) -> ASGIApp:
39
+ """Return ``app`` wrapped in every factory, the first one outermost."""
40
+ wrapped = app
41
+ # The first factory sits outermost, so it wraps last.
42
+ for factory in reversed(self._factories):
43
+ wrapped = factory(wrapped)
44
+ return wrapped
45
+
46
+ def __len__(self) -> int:
47
+ """Return how many factories the stack holds."""
48
+ return len(self._factories)
49
+
50
+ def __iter__(self) -> Iterator[MiddlewareFactory]:
51
+ """Yield the factories, the outermost first."""
52
+ return iter(self._factories)
@@ -0,0 +1,19 @@
1
+ """The tag a bundle puts on the middleware factories it contributes.
2
+
3
+ A definition tagged this way provides a middleware factory — a callable
4
+ taking the downstream ASGI app and returning the app wrapped. The
5
+ http_kernel bundle collects every tagged definition when the kernel is
6
+ built and orders them into the
7
+ :class:`~xtr_http_kernel.middleware_stack.MiddlewareStack` by the tag's
8
+ integer ``priority`` attribute (0 when absent): the highest priority sits
9
+ outermost, so it sees the request first.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Final
15
+
16
+ __all__ = ["MIDDLEWARE_TAG"]
17
+
18
+ MIDDLEWARE_TAG: Final = "http_kernel.middleware"
19
+ """The tag name bundles and the setup call agree on."""
File without changes
@@ -0,0 +1,182 @@
1
+ """Dispatches the request lifecycle events around whatever application it wraps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, cast, final, runtime_checkable
6
+
7
+ from starlette.datastructures import MutableHeaders
8
+ from starlette.requests import Request
9
+
10
+ from .event import (
11
+ ExceptionEvent,
12
+ FinishRequestEvent,
13
+ RequestEvent,
14
+ ResponseEvent,
15
+ TerminateEvent,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
20
+ from xtr_event_dispatcher_contracts import EventDispatcherInterface
21
+
22
+ __all__ = ["RequestLifecycleMiddleware"]
23
+
24
+ _SERVER_ERROR = 500
25
+
26
+
27
+ @runtime_checkable
28
+ class _UnitScopedDispatcher(Protocol):
29
+ """What a dispatcher recording per unit of work offers on top of dispatching."""
30
+
31
+ def begin_unit(self) -> None: ...
32
+
33
+ def end_unit(self) -> None: ...
34
+
35
+
36
+ @final
37
+ class _Lifecycle:
38
+ """One request's passage: a ``send`` that dispatches around what leaves.
39
+
40
+ The middleware and the responses a listener answers with all send through
41
+ this one wrapper, so the sending rules live in one place: the response
42
+ start is announced (and adjusted) before it leaves, handling is declared
43
+ finished before the last body chunk leaves, and each announcement happens
44
+ at most once however the request went.
45
+ """
46
+
47
+ __slots__ = ("_dispatcher", "_finished", "_request", "_send", "_status_sent")
48
+
49
+ def __init__(self, dispatcher: EventDispatcherInterface, request: Request, send: Send) -> None:
50
+ self._dispatcher = dispatcher
51
+ self._request = request
52
+ self._send = send
53
+ self._status_sent: int | None = None
54
+ self._finished = False
55
+
56
+ @property
57
+ def request(self) -> Request:
58
+ return self._request
59
+
60
+ @property
61
+ def response_started(self) -> bool:
62
+ return self._status_sent is not None
63
+
64
+ @property
65
+ def status_sent(self) -> int:
66
+ """The status that left, or 500 when the response never started."""
67
+ return self._status_sent if self._status_sent is not None else _SERVER_ERROR
68
+
69
+ async def send(self, message: Message) -> None:
70
+ message_type = cast("str", message["type"])
71
+ if message_type == "http.response.start" and self._status_sent is None:
72
+ event = ResponseEvent(
73
+ self._request,
74
+ cast("int", message["status"]),
75
+ # The message's own header list, so what a listener writes is
76
+ # what leaves.
77
+ MutableHeaders(scope=message),
78
+ )
79
+ _ = await self._dispatcher.dispatch(event)
80
+ message["status"] = event.status_code
81
+ self._status_sent = event.status_code
82
+ elif message_type == "http.response.body" and not cast(
83
+ "bool", message.get("more_body", False)
84
+ ):
85
+ await self.finish()
86
+ await self._send(message)
87
+
88
+ async def finish(self) -> None:
89
+ """Declare handling finished, the first time this is asked for."""
90
+ if not self._finished:
91
+ self._finished = True
92
+ _ = await self._dispatcher.dispatch(FinishRequestEvent(self._request))
93
+
94
+
95
+ @final
96
+ class RequestLifecycleMiddleware:
97
+ """Announces each moment of a request's life to whoever listens.
98
+
99
+ Sits between the server and the application as plain middleware — built
100
+ from an application and a dispatcher, nothing else — so a framework
101
+ registers it the way it registers any other:
102
+
103
+ ```python
104
+ app.add_middleware(RequestLifecycleMiddleware, dispatcher=dispatcher)
105
+ ```
106
+
107
+ Every request then goes through the same sequence, whatever becomes of
108
+ it: :class:`~xtr_http_kernel.event.request_event.RequestEvent` before the
109
+ application sees it (a listener answering there skips the application),
110
+ :class:`~xtr_http_kernel.event.response_event.ResponseEvent` before the
111
+ response start leaves,
112
+ :class:`~xtr_http_kernel.event.exception_event.ExceptionEvent` when
113
+ handling raised (a listener answering there swallows the failure, unless
114
+ the response already started),
115
+ :class:`~xtr_http_kernel.event.finish_request_event.FinishRequestEvent`
116
+ before the last body chunk leaves — or before an unanswered failure
117
+ propagates — and
118
+ :class:`~xtr_http_kernel.event.terminate_event.TerminateEvent` last, with
119
+ the status that was sent. Each event at most once per request.
120
+
121
+ A failure nobody answered propagates as it arrived: turning it into a
122
+ response is the application's business, not this middleware's. Only the
123
+ lifespan of the application and its websockets pass through untouched —
124
+ they are not requests.
125
+
126
+ When the dispatcher records per unit of work — it offers callable
127
+ ``begin_unit`` and ``end_unit`` — each request is framed as one unit, so
128
+ overlapping requests each trace only their own events.
129
+ """
130
+
131
+ __slots__ = ("_app", "_dispatcher")
132
+
133
+ def __init__(self, app: ASGIApp, *, dispatcher: EventDispatcherInterface) -> None:
134
+ """Wrap ``app``, announcing every request to ``dispatcher``'s listeners."""
135
+ self._app = app
136
+ self._dispatcher = dispatcher
137
+
138
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
139
+ """Carry one connection through, dispatching the lifecycle when it is a request."""
140
+ if cast("str", scope["type"]) != "http":
141
+ await self._app(scope, receive, send)
142
+ return
143
+
144
+ lifecycle = _Lifecycle(self._dispatcher, Request(scope, receive), send)
145
+ unit = (
146
+ self._dispatcher
147
+ if isinstance(self._dispatcher, _UnitScopedDispatcher)
148
+ and callable(self._dispatcher.begin_unit)
149
+ and callable(self._dispatcher.end_unit)
150
+ else None
151
+ )
152
+ if unit is not None:
153
+ unit.begin_unit()
154
+ try:
155
+ await self._handle(scope, receive, lifecycle)
156
+ finally:
157
+ try:
158
+ _ = await self._dispatcher.dispatch(
159
+ TerminateEvent(lifecycle.request, lifecycle.status_sent)
160
+ )
161
+ finally:
162
+ if unit is not None:
163
+ unit.end_unit()
164
+
165
+ async def _handle(self, scope: Scope, receive: Receive, lifecycle: _Lifecycle) -> None:
166
+ request_event = RequestEvent(lifecycle.request)
167
+ _ = await self._dispatcher.dispatch(request_event)
168
+ answer = request_event.response
169
+ if answer is not None:
170
+ await answer(scope, receive, lifecycle.send)
171
+ return
172
+ try:
173
+ await self._app(scope, receive, lifecycle.send)
174
+ except BaseException as error:
175
+ exception_event = ExceptionEvent(lifecycle.request, error)
176
+ _ = await self._dispatcher.dispatch(exception_event)
177
+ answer = exception_event.response
178
+ if answer is not None and not lifecycle.response_started:
179
+ await answer(scope, receive, lifecycle.send)
180
+ return
181
+ await lifecycle.finish()
182
+ raise
@@ -0,0 +1,96 @@
1
+ """Serving a FastAPI application from a kernel: the one public setup call.
2
+
3
+ ``setup(app, kernel)`` is everything an application calls. It wraps the
4
+ application's lifespan so every application life builds, boots and shuts
5
+ down its own kernel, and adds one middleware so every request runs inside
6
+ the scope its scoped services live in. Call it before the first request —
7
+ after the routes and the application's other middleware is fine. An
8
+ application that already started refuses new middleware; the framework's
9
+ own error surfaces then.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from contextlib import asynccontextmanager
15
+ from typing import TYPE_CHECKING, cast
16
+
17
+ from xtr_dependency_injection.integration.fastapi import attach, detach, request_scope
18
+ from xtr_dependency_injection.testing import apply_overrides
19
+
20
+ from ._kernel_middleware import KernelMiddleware
21
+ from ._state import OVERRIDES_KEY, STACK_KEY
22
+ from .middleware_stack import MiddlewareStack
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import AsyncGenerator, Hashable, Mapping
26
+
27
+ from fastapi import FastAPI
28
+ from starlette.types import Lifespan
29
+ from xtr_dependency_injection import Kernel
30
+ from xtr_dependency_injection.kernel.compiled_kernel import CompiledKernel
31
+
32
+ __all__ = ["setup"]
33
+
34
+
35
+ def setup(app: FastAPI, kernel: Kernel) -> None:
36
+ """Serve ``app`` from ``kernel``.
37
+
38
+ Nothing is built here: every start of the application's lifespan builds
39
+ the kernel afresh — a built container boots exactly once, and a test
40
+ starts the application many times — applies the overrides
41
+ :func:`xtr_http_kernel.testing.override_services` parked on the
42
+ application, boots, and attaches the kernel so the injection markers
43
+ resolve. The application's own lifespan runs inside, its state passing
44
+ through untouched. On the way out the kernel is detached and shut down.
45
+
46
+ Each life also fetches the :class:`~xtr_http_kernel.middleware_stack.MiddlewareStack`
47
+ the kernel's bundles contributed — ordered when the kernel was built,
48
+ highest tag ``priority`` outermost — as the chain every request runs
49
+ through, inside the one middleware this call adds, so the whole chain
50
+ sees the request's scope. A kernel without the http_kernel bundle
51
+ serves with an empty stack.
52
+
53
+ Args:
54
+ app: The application to serve, its routes and middleware already
55
+ registered.
56
+ kernel: The recipe to build each application life from.
57
+ """
58
+ app.router.lifespan_context = _serving_lifespan(app, kernel, app.router.lifespan_context)
59
+ app.add_middleware(KernelMiddleware, open_scope=request_scope)
60
+
61
+
62
+ def _serving_lifespan(app: FastAPI, kernel: Kernel, inner: Lifespan[FastAPI]) -> Lifespan[FastAPI]:
63
+ """Return ``inner`` wrapped in one kernel life per application life."""
64
+
65
+ @asynccontextmanager
66
+ async def serving(target: FastAPI) -> AsyncGenerator[Mapping[str, object] | None, None]:
67
+ compiled = kernel.build()
68
+ overrides = cast(
69
+ "Mapping[type | tuple[type, Hashable], object] | None",
70
+ getattr(app.state, OVERRIDES_KEY, None),
71
+ )
72
+ if overrides is not None:
73
+ apply_overrides(compiled, overrides)
74
+ async with compiled.lifespan(app):
75
+ attach(app, compiled)
76
+ try:
77
+ setattr(app.state, STACK_KEY, await _stack(compiled))
78
+ async with inner(target) as state:
79
+ yield state
80
+ finally:
81
+ if hasattr(app.state, STACK_KEY):
82
+ delattr(app.state, STACK_KEY)
83
+ detach(app)
84
+
85
+ # The wrapper yields whatever the application's own lifespan yields;
86
+ # the framework's lifespan type is a union of the two shapes, not a
87
+ # context manager of the union.
88
+ return cast("Lifespan[FastAPI]", serving)
89
+
90
+
91
+ async def _stack(compiled: CompiledKernel) -> MiddlewareStack:
92
+ """Return the kernel's middleware stack, empty when none was registered."""
93
+ container = compiled.container
94
+ if container.has(MiddlewareStack):
95
+ return await container.get(MiddlewareStack)
96
+ return MiddlewareStack()
@@ -0,0 +1,52 @@
1
+ """Swapping services in a served application, for the span of a test.
2
+
3
+ The setup call builds a kernel at every start of the application's
4
+ lifespan; :func:`override_services` parks a mapping on the application for
5
+ those builds to apply, so every boot hook and every route already sees the
6
+ replacements. Enter the block before starting the application's lifespan.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from contextlib import contextmanager
12
+ from typing import TYPE_CHECKING
13
+
14
+ from ._state import OVERRIDES_KEY
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Generator, Hashable, Mapping
18
+
19
+ from fastapi import FastAPI
20
+
21
+ __all__ = ["override_services"]
22
+
23
+
24
+ @contextmanager
25
+ def override_services(
26
+ app: FastAPI,
27
+ overrides: Mapping[type | tuple[type, Hashable], object],
28
+ /,
29
+ ) -> Generator[None, None, None]:
30
+ """Serve ``app`` with ``overrides`` while the block is entered.
31
+
32
+ A key is a type, or a ``(type, qualifier)`` pair for a qualified
33
+ service — the same keys ``apply_overrides`` takes. The mapping applies
34
+ to every kernel built while the block is entered; on exit the previous
35
+ overrides, if any, are restored::
36
+
37
+ with override_services(app, {Mailer: FakeMailer()}):
38
+ ... # every application life started here sees the fake
39
+
40
+ Args:
41
+ app: The application the setup call serves.
42
+ overrides: What to replace, keyed by type or ``(type, qualifier)``.
43
+ """
44
+ previous: object = getattr(app.state, OVERRIDES_KEY, None)
45
+ setattr(app.state, OVERRIDES_KEY, dict(overrides))
46
+ try:
47
+ yield
48
+ finally:
49
+ if previous is None:
50
+ delattr(app.state, OVERRIDES_KEY)
51
+ else:
52
+ setattr(app.state, OVERRIDES_KEY, previous)