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.
- xtr_http_kernel/__init__.py +52 -0
- xtr_http_kernel/_kernel_middleware.py +76 -0
- xtr_http_kernel/_state.py +17 -0
- xtr_http_kernel/bundle/__init__.py +8 -0
- xtr_http_kernel/bundle/http_kernel_bundle.py +211 -0
- xtr_http_kernel/bundle/http_kernel_config.py +62 -0
- xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +35 -0
- xtr_http_kernel/command/__init__.py +9 -0
- xtr_http_kernel/command/_route_contexts.py +113 -0
- xtr_http_kernel/command/debug_router_command.py +52 -0
- xtr_http_kernel/command/route_description.py +81 -0
- xtr_http_kernel/command/router_command.py +69 -0
- xtr_http_kernel/command/router_match_command.py +96 -0
- xtr_http_kernel/event/__init__.py +35 -0
- xtr_http_kernel/event/exception_event.py +65 -0
- xtr_http_kernel/event/finish_request_event.py +40 -0
- xtr_http_kernel/event/request_event.py +56 -0
- xtr_http_kernel/event/response_event.py +64 -0
- xtr_http_kernel/event/terminate_event.py +43 -0
- xtr_http_kernel/event_listener/__init__.py +23 -0
- xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +31 -0
- xtr_http_kernel/event_listener/error_logging_listener.py +52 -0
- xtr_http_kernel/event_listener/log_unit_listener.py +39 -0
- xtr_http_kernel/event_listener/request_id_listener.py +72 -0
- xtr_http_kernel/exception/__init__.py +14 -0
- xtr_http_kernel/exception/http_kernel_error.py +15 -0
- xtr_http_kernel/exception/invalid_middleware_priority_error.py +39 -0
- xtr_http_kernel/kernel_events.py +51 -0
- xtr_http_kernel/middleware_stack.py +52 -0
- xtr_http_kernel/middleware_tag.py +19 -0
- xtr_http_kernel/py.typed +0 -0
- xtr_http_kernel/request_lifecycle_middleware.py +182 -0
- xtr_http_kernel/setup.py +96 -0
- xtr_http_kernel/testing.py +52 -0
- xtr_http_kernel-1.4.0.dist-info/METADATA +432 -0
- xtr_http_kernel-1.4.0.dist-info/RECORD +39 -0
- xtr_http_kernel-1.4.0.dist-info/WHEEL +4 -0
- xtr_http_kernel-1.4.0.dist-info/entry_points.txt +3 -0
- xtr_http_kernel-1.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""What the router commands report about one route of an application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from types import FunctionType, MethodType
|
|
7
|
+
from typing import TYPE_CHECKING, Final, final
|
|
8
|
+
|
|
9
|
+
from starlette.routing import Mount, WebSocketRoute
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from starlette.routing import BaseRoute
|
|
13
|
+
|
|
14
|
+
from ._route_contexts import RouteView
|
|
15
|
+
|
|
16
|
+
__all__ = ["RouteDescription"]
|
|
17
|
+
|
|
18
|
+
_ANY_METHOD: Final = "-"
|
|
19
|
+
"""Stands in for a route that answers whatever arrives, or has no method at all."""
|
|
20
|
+
|
|
21
|
+
_MOUNT: Final = "MOUNT"
|
|
22
|
+
_WEBSOCKET: Final = "WEBSOCKET"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@final
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class RouteDescription:
|
|
28
|
+
"""One line of what a router holds, ready to be printed.
|
|
29
|
+
|
|
30
|
+
Built from the routing layer's own view of a route, so the path is the
|
|
31
|
+
one requests are matched against — an included router's prefix already
|
|
32
|
+
applied — rather than the one written at the endpoint.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
methods: The methods the route answers, comma-separated;
|
|
36
|
+
``WEBSOCKET`` for a connection route, ``MOUNT`` for a mounted
|
|
37
|
+
application, ``-`` for a route taking whatever arrives.
|
|
38
|
+
path: The path as routing reads it.
|
|
39
|
+
name: The name the route answers to, empty when it has none.
|
|
40
|
+
endpoint: What the route hands the request to, as ``module:qualname``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
methods: str
|
|
44
|
+
path: str
|
|
45
|
+
name: str
|
|
46
|
+
endpoint: str
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def of(cls, view: RouteView) -> RouteDescription:
|
|
50
|
+
"""Describe the route ``view`` stands for."""
|
|
51
|
+
endpoint = view.endpoint
|
|
52
|
+
return cls(
|
|
53
|
+
methods=_methods(view),
|
|
54
|
+
path=view.path or "",
|
|
55
|
+
name=view.name or "",
|
|
56
|
+
endpoint=_qualified(endpoint if endpoint is not None else _carried(view.route)),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _methods(view: RouteView) -> str:
|
|
61
|
+
"""Name the methods the route answers, or else the kind of route it is."""
|
|
62
|
+
if view.methods:
|
|
63
|
+
return ", ".join(sorted(view.methods))
|
|
64
|
+
if isinstance(view.route, WebSocketRoute):
|
|
65
|
+
return _WEBSOCKET
|
|
66
|
+
if isinstance(view.route, Mount):
|
|
67
|
+
return _MOUNT
|
|
68
|
+
return _ANY_METHOD
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _carried(route: BaseRoute) -> object:
|
|
72
|
+
"""Return what a route with no endpoint of its own hands the request to."""
|
|
73
|
+
return route.app if isinstance(route, Mount) else route
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _qualified(target: object) -> str:
|
|
77
|
+
"""Name ``target`` as ``module:qualname`` — its type's when it has none of its own."""
|
|
78
|
+
if isinstance(target, (type, FunctionType, MethodType)):
|
|
79
|
+
return f"{target.__module__}:{target.__qualname__}"
|
|
80
|
+
kind = type(target)
|
|
81
|
+
return f"{kind.__module__}:{kind.__qualname__}"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""What both router commands share: finding the application to report on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, Final, cast
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from xtr_http_kernel.bundle.http_kernel_config import HttpKernelConfig
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from xtr_console import ConsoleStyle
|
|
14
|
+
|
|
15
|
+
__all__ = ["RouterCommand"]
|
|
16
|
+
|
|
17
|
+
_ZERO_CONFIG: Final = HttpKernelConfig()
|
|
18
|
+
"""What a console without a container builds these commands with."""
|
|
19
|
+
|
|
20
|
+
_NO_APPLICATION: Final = (
|
|
21
|
+
'no application to read: pass --app "package.module:app", or name one on the '
|
|
22
|
+
"http kernel's configuration"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RouterCommand:
|
|
27
|
+
"""A command reporting on an application, named on the command line or configured.
|
|
28
|
+
|
|
29
|
+
A container builds it with the http kernel's configuration, so an
|
|
30
|
+
application that names its own needs no option. Without a container the
|
|
31
|
+
console builds it bare, and ``--app`` is the only way to name one.
|
|
32
|
+
|
|
33
|
+
Nothing is imported until a command runs, and the application is only
|
|
34
|
+
read: no server starts, and no route is touched.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__: ClassVar[tuple[str, ...]] = ("_config",)
|
|
38
|
+
|
|
39
|
+
_config: HttpKernelConfig
|
|
40
|
+
|
|
41
|
+
def __init__(self, config: HttpKernelConfig = _ZERO_CONFIG) -> None:
|
|
42
|
+
"""Report on the application ``config`` names, unless an option overrides it."""
|
|
43
|
+
self._config = config
|
|
44
|
+
|
|
45
|
+
def _app_or_report(self, io: ConsoleStyle, app: str | None) -> FastAPI | None:
|
|
46
|
+
"""Import the application ``app`` names, or say on ``io`` why there is none.
|
|
47
|
+
|
|
48
|
+
``app`` is the command line's answer and wins; the configuration's
|
|
49
|
+
is used when it is ``None``.
|
|
50
|
+
"""
|
|
51
|
+
reference = app if app is not None else self._config.app
|
|
52
|
+
if reference is None:
|
|
53
|
+
io.error(_NO_APPLICATION)
|
|
54
|
+
return None
|
|
55
|
+
module_name, _, attribute = reference.partition(":")
|
|
56
|
+
if not module_name or not attribute:
|
|
57
|
+
io.error(f'an application reads "package.module:app", not {reference!r}')
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
module = import_module(module_name)
|
|
61
|
+
except ImportError as error:
|
|
62
|
+
io.error(f"cannot import {module_name!r}: {error}")
|
|
63
|
+
return None
|
|
64
|
+
# A module attribute is whatever the module put there: the check below decides.
|
|
65
|
+
loaded = cast("object", getattr(module, attribute, None))
|
|
66
|
+
if not isinstance(loaded, FastAPI):
|
|
67
|
+
io.error(f"{reference!r} does not name an application")
|
|
68
|
+
return None
|
|
69
|
+
return loaded
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""``router:match``: which route a path reaches, and which one nearly does."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast, final
|
|
6
|
+
|
|
7
|
+
from starlette.routing import Match
|
|
8
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
9
|
+
|
|
10
|
+
from ._route_contexts import route_contexts
|
|
11
|
+
from .route_description import RouteDescription
|
|
12
|
+
from .router_command import RouterCommand
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
from starlette.types import Scope
|
|
18
|
+
|
|
19
|
+
__all__ = ["RouterMatchCommand"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@as_command("router:match")
|
|
23
|
+
@final
|
|
24
|
+
class RouterMatchCommand(RouterCommand):
|
|
25
|
+
"""Names the route a path and a method reach, without serving anything.
|
|
26
|
+
|
|
27
|
+
The routes are tried against a request that is built and thrown away —
|
|
28
|
+
nothing is sent, no endpoint runs, and the application is left as it was.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ()
|
|
32
|
+
|
|
33
|
+
async def __call__(
|
|
34
|
+
self,
|
|
35
|
+
io: ConsoleStyle,
|
|
36
|
+
path: str,
|
|
37
|
+
*,
|
|
38
|
+
method: str = "GET",
|
|
39
|
+
app: str | None = None,
|
|
40
|
+
) -> int:
|
|
41
|
+
"""Report the route ``path`` reaches with ``method``.
|
|
42
|
+
|
|
43
|
+
Exits successfully on a route that answers. A route matching the
|
|
44
|
+
path but refusing the method is reported as the near miss it is, and
|
|
45
|
+
so is a path no route answers at all — both fail the run.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
io: Where the command writes.
|
|
49
|
+
path: The path to try, as it would arrive.
|
|
50
|
+
method: The method to try it with.
|
|
51
|
+
app: The application to read, as ``package.module:app``; the
|
|
52
|
+
configured one when left out.
|
|
53
|
+
"""
|
|
54
|
+
application = self._app_or_report(io, app)
|
|
55
|
+
if application is None:
|
|
56
|
+
return ExitCode.INVALID
|
|
57
|
+
wanted = method.upper()
|
|
58
|
+
scope: Scope = {"type": "http", "method": wanted, "path": path, "root_path": ""}
|
|
59
|
+
refused: RouteDescription | None = None
|
|
60
|
+
for view in route_contexts(application.routes):
|
|
61
|
+
match, child_scope = view.matches(scope)
|
|
62
|
+
if match is Match.FULL:
|
|
63
|
+
_report(io, RouteDescription.of(view), child_scope)
|
|
64
|
+
return ExitCode.SUCCESS
|
|
65
|
+
if match is Match.PARTIAL and refused is None:
|
|
66
|
+
refused = RouteDescription.of(view)
|
|
67
|
+
if refused is not None:
|
|
68
|
+
io.error(
|
|
69
|
+
f'"{escape(path)}" reaches the route "{escape(refused.name)}" '
|
|
70
|
+
f"({escape(refused.path)}), which does not answer {escape(wanted)}"
|
|
71
|
+
)
|
|
72
|
+
return ExitCode.FAILURE
|
|
73
|
+
io.error(f"no route matches {escape(wanted)} {escape(path)}")
|
|
74
|
+
return ExitCode.FAILURE
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _report(io: ConsoleStyle, route: RouteDescription, child_scope: Scope) -> None:
|
|
78
|
+
"""Say which route answered, and what it read out of the path."""
|
|
79
|
+
io.success(f'"{escape(route.path)}" is answered by {escape(route.endpoint)}')
|
|
80
|
+
io.table(
|
|
81
|
+
("Name", "Methods", "Path", "Endpoint"),
|
|
82
|
+
[
|
|
83
|
+
(
|
|
84
|
+
escape(route.name),
|
|
85
|
+
escape(route.methods),
|
|
86
|
+
escape(route.path),
|
|
87
|
+
escape(route.endpoint),
|
|
88
|
+
)
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
parameters = cast("Mapping[str, object]", child_scope.get("path_params", {}))
|
|
92
|
+
if parameters:
|
|
93
|
+
io.table(
|
|
94
|
+
("Parameter", "Value"),
|
|
95
|
+
[(escape(name), escape(str(value))) for name, value in sorted(parameters.items())],
|
|
96
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The events a request goes through, from arriving to being answered.
|
|
2
|
+
|
|
3
|
+
Every request dispatches the same sequence, and a listener joins it wherever
|
|
4
|
+
it has something to contribute:
|
|
5
|
+
|
|
6
|
+
1. :class:`RequestEvent` — it arrived, nothing has looked at it. A listener
|
|
7
|
+
may answer it here instead of the application.
|
|
8
|
+
2. :class:`ResponseEvent` — a response is about to start; its status and
|
|
9
|
+
headers can still be changed. **Or** :class:`ExceptionEvent` — handling
|
|
10
|
+
raised, and a listener may turn that into a response.
|
|
11
|
+
3. :class:`FinishRequestEvent` — handling finished, on every path.
|
|
12
|
+
4. :class:`TerminateEvent` — everything has been sent; whatever is done here
|
|
13
|
+
cannot reach the caller.
|
|
14
|
+
|
|
15
|
+
Events are keyed by the qualified name of their class, so listening to
|
|
16
|
+
``RequestEvent`` and listening to
|
|
17
|
+
:data:`~xtr_http_kernel.kernel_events.KernelEvents.REQUEST` name the same
|
|
18
|
+
event.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .exception_event import ExceptionEvent
|
|
24
|
+
from .finish_request_event import FinishRequestEvent
|
|
25
|
+
from .request_event import RequestEvent
|
|
26
|
+
from .response_event import ResponseEvent
|
|
27
|
+
from .terminate_event import TerminateEvent
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ExceptionEvent",
|
|
31
|
+
"FinishRequestEvent",
|
|
32
|
+
"RequestEvent",
|
|
33
|
+
"ResponseEvent",
|
|
34
|
+
"TerminateEvent",
|
|
35
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Dispatched when handling a request raised."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from xtr_event_dispatcher_contracts import Event
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import Response
|
|
12
|
+
|
|
13
|
+
__all__ = ["ExceptionEvent"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
class ExceptionEvent(Event):
|
|
18
|
+
"""Handling ``request`` raised, and nothing has been sent.
|
|
19
|
+
|
|
20
|
+
A listener that only wants to know — one writing the failure to a log,
|
|
21
|
+
say — reads :attr:`exception` and leaves the response alone: the
|
|
22
|
+
exception then carries on out of the lifecycle, to whatever the
|
|
23
|
+
application put in charge of turning it into a response.
|
|
24
|
+
|
|
25
|
+
A listener that wants to answer calls :meth:`set_response`, which stops
|
|
26
|
+
the event: the exception is swallowed, and that response leaves instead.
|
|
27
|
+
|
|
28
|
+
:attr:`exception` is a :class:`BaseException`, not an :class:`Exception`:
|
|
29
|
+
a cancellation or an interrupt is exactly the kind of failure a listener
|
|
30
|
+
logging what went wrong wants to see.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, request: Request, exception: BaseException) -> None:
|
|
34
|
+
"""Announce that handling ``request`` raised ``exception``."""
|
|
35
|
+
self._request = request
|
|
36
|
+
self._exception = exception
|
|
37
|
+
self._response: Response | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def request(self) -> Request:
|
|
41
|
+
"""Return the request whose handling raised."""
|
|
42
|
+
return self._request
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def exception(self) -> BaseException:
|
|
46
|
+
"""Return what was raised."""
|
|
47
|
+
return self._exception
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def response(self) -> Response | None:
|
|
51
|
+
"""Return the response a listener turned the exception into, if one did."""
|
|
52
|
+
return self._response
|
|
53
|
+
|
|
54
|
+
def has_response(self) -> bool:
|
|
55
|
+
"""Tell whether a listener turned the exception into a response."""
|
|
56
|
+
return self._response is not None
|
|
57
|
+
|
|
58
|
+
def set_response(self, response: Response) -> None:
|
|
59
|
+
"""Answer with ``response``, and skip the listeners after this one.
|
|
60
|
+
|
|
61
|
+
The exception stops here: ``response`` is what leaves, and the
|
|
62
|
+
listeners that have not run never hear about the failure.
|
|
63
|
+
"""
|
|
64
|
+
self._response = response
|
|
65
|
+
self.stop_propagation()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Dispatched when handling of a request finished, whatever came of it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from xtr_event_dispatcher_contracts import Event
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
|
|
12
|
+
__all__ = ["FinishRequestEvent"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class FinishRequestEvent(Event):
|
|
17
|
+
"""Handling of ``request`` finished — the one event dispatched on every path.
|
|
18
|
+
|
|
19
|
+
It runs whether the application answered, a listener short-circuited the
|
|
20
|
+
request, an exception was turned into a response, or an exception is
|
|
21
|
+
about to leave the lifecycle unhandled. That is what makes it the place
|
|
22
|
+
to put away anything a request set up: the per-request logging unit, a
|
|
23
|
+
trace, a scope closed by hand.
|
|
24
|
+
|
|
25
|
+
When there is a body, it is dispatched before the last chunk leaves, so a
|
|
26
|
+
listener can still do its work while the request is recognisably the one
|
|
27
|
+
being answered. It carries no response: by this point the head is gone,
|
|
28
|
+
and there is nothing left to change — see
|
|
29
|
+
:class:`~xtr_http_kernel.event.terminate_event.TerminateEvent` for what
|
|
30
|
+
was actually sent.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, request: Request) -> None:
|
|
34
|
+
"""Announce that handling ``request`` finished."""
|
|
35
|
+
self._request = request
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def request(self) -> Request:
|
|
39
|
+
"""Return the request whose handling finished."""
|
|
40
|
+
return self._request
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Dispatched first, before the application sees the request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from xtr_event_dispatcher_contracts import Event
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import Response
|
|
12
|
+
|
|
13
|
+
__all__ = ["RequestEvent"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
class RequestEvent(Event):
|
|
18
|
+
"""A request arrived, and nothing has looked at it yet.
|
|
19
|
+
|
|
20
|
+
This is the one moment a listener can answer instead of the application:
|
|
21
|
+
:meth:`set_response` short-circuits routing, so a cache, a maintenance
|
|
22
|
+
page or a redirect never reaches an endpoint. Answering stops the event,
|
|
23
|
+
because the listeners after it would be deciding about a request that has
|
|
24
|
+
already been dealt with.
|
|
25
|
+
|
|
26
|
+
A listener that only wants to read or annotate the request leaves the
|
|
27
|
+
response alone; the request then carries on to the router untouched.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, request: Request) -> None:
|
|
31
|
+
"""Announce ``request``, before anything has handled it."""
|
|
32
|
+
self._request = request
|
|
33
|
+
self._response: Response | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def request(self) -> Request:
|
|
37
|
+
"""Return the request that arrived."""
|
|
38
|
+
return self._request
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def response(self) -> Response | None:
|
|
42
|
+
"""Return the response a listener answered with, if one did."""
|
|
43
|
+
return self._response
|
|
44
|
+
|
|
45
|
+
def has_response(self) -> bool:
|
|
46
|
+
"""Tell whether a listener answered the request itself."""
|
|
47
|
+
return self._response is not None
|
|
48
|
+
|
|
49
|
+
def set_response(self, response: Response) -> None:
|
|
50
|
+
"""Answer the request with ``response``, and skip the listeners after this one.
|
|
51
|
+
|
|
52
|
+
The application never runs: ``response`` is what leaves, after the
|
|
53
|
+
rest of the lifecycle has had its say about it.
|
|
54
|
+
"""
|
|
55
|
+
self._response = response
|
|
56
|
+
self.stop_propagation()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Dispatched when a response is about to start leaving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from xtr_event_dispatcher_contracts import Event
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from starlette.datastructures import MutableHeaders
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
|
|
13
|
+
__all__ = ["ResponseEvent"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@final
|
|
17
|
+
class ResponseEvent(Event):
|
|
18
|
+
"""A response is about to start, and its head can still be changed.
|
|
19
|
+
|
|
20
|
+
Dispatched once the application has decided on a status and a set of
|
|
21
|
+
headers, and before either has left — a request id, a caching directive
|
|
22
|
+
or a header keeping the page out of a search index belongs here.
|
|
23
|
+
|
|
24
|
+
**The body is not here, and cannot be replaced.** A response is streamed:
|
|
25
|
+
by the time the first chunk is written the head is already gone, and the
|
|
26
|
+
body may be produced a chunk at a time by something that is still
|
|
27
|
+
running. This event owns exactly what an application can still change at
|
|
28
|
+
that point: :attr:`status_code`, which may be assigned, and
|
|
29
|
+
:attr:`headers`, which may be mutated. A listener wanting to answer with
|
|
30
|
+
a body of its own does so at
|
|
31
|
+
:class:`~xtr_http_kernel.event.request_event.RequestEvent` or
|
|
32
|
+
:class:`~xtr_http_kernel.event.exception_event.ExceptionEvent`, where
|
|
33
|
+
nothing has been sent yet.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, request: Request, status_code: int, headers: MutableHeaders) -> None:
|
|
37
|
+
"""Announce the response to ``request``, starting with ``status_code`` and ``headers``.
|
|
38
|
+
|
|
39
|
+
``headers`` is the outgoing list itself, not a copy: what a listener
|
|
40
|
+
writes into it is what leaves.
|
|
41
|
+
"""
|
|
42
|
+
self._request = request
|
|
43
|
+
self._status_code = status_code
|
|
44
|
+
self._headers = headers
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def request(self) -> Request:
|
|
48
|
+
"""Return the request being answered."""
|
|
49
|
+
return self._request
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def status_code(self) -> int:
|
|
53
|
+
"""Return the status the response will start with."""
|
|
54
|
+
return self._status_code
|
|
55
|
+
|
|
56
|
+
@status_code.setter
|
|
57
|
+
def status_code(self, status_code: int) -> None:
|
|
58
|
+
"""Start the response with ``status_code`` instead."""
|
|
59
|
+
self._status_code = status_code
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def headers(self) -> MutableHeaders:
|
|
63
|
+
"""Return the outgoing headers, to read or to change in place."""
|
|
64
|
+
return self._headers
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Dispatched last, once the response has been sent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from xtr_event_dispatcher_contracts import Event
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
|
|
12
|
+
__all__ = ["TerminateEvent"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class TerminateEvent(Event):
|
|
17
|
+
"""Everything was sent, or the failure went past the lifecycle.
|
|
18
|
+
|
|
19
|
+
Dispatched after the last chunk of the response left, so nothing a
|
|
20
|
+
listener does here can reach the client. That is the point: work worth
|
|
21
|
+
doing once the caller has their answer — a metric, a notification, a
|
|
22
|
+
write that would otherwise have held the response — belongs here.
|
|
23
|
+
|
|
24
|
+
:attr:`status_code` is what was actually sent. When nothing was sent at
|
|
25
|
+
all, because an exception left the lifecycle before the response started,
|
|
26
|
+
it is ``500``: that is what the caller ends up seeing, and a listener
|
|
27
|
+
counting statuses should count it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, request: Request, status_code: int) -> None:
|
|
31
|
+
"""Announce that ``request`` was answered with ``status_code``."""
|
|
32
|
+
self._request = request
|
|
33
|
+
self._status_code = status_code
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def request(self) -> Request:
|
|
37
|
+
"""Return the request that was answered."""
|
|
38
|
+
return self._request
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def status_code(self) -> int:
|
|
42
|
+
"""Return the status that was sent, or ``500`` when nothing was."""
|
|
43
|
+
return self._status_code
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The listeners this library ships for its own lifecycle.
|
|
2
|
+
|
|
3
|
+
Each contributes one of the things every application wants around an
|
|
4
|
+
endpoint: a request id, an uncaught exception in the log, a header keeping a
|
|
5
|
+
page out of a search index, a unit of work per request. The bundle registers
|
|
6
|
+
them; outside a container each is an ordinary object to register by hand.
|
|
7
|
+
|
|
8
|
+
:class:`~xtr_http_kernel.event_listener.log_unit_listener.LogUnitListener`
|
|
9
|
+
is not re-exported here: it needs the optional logging extra, so it is
|
|
10
|
+
imported from its own module by whoever knows that extra is installed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .disallow_robots_indexing_listener import DisallowRobotsIndexingListener
|
|
16
|
+
from .error_logging_listener import ErrorLoggingListener
|
|
17
|
+
from .request_id_listener import RequestIdListener
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DisallowRobotsIndexingListener",
|
|
21
|
+
"ErrorLoggingListener",
|
|
22
|
+
"RequestIdListener",
|
|
23
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Keeping every response out of search indexes, when the application asks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from xtr_http_kernel.event import ResponseEvent
|
|
9
|
+
|
|
10
|
+
__all__ = ["DisallowRobotsIndexingListener"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
class DisallowRobotsIndexingListener:
|
|
15
|
+
"""Marks every outgoing response ``X-Robots-Tag: noindex`` when enabled.
|
|
16
|
+
|
|
17
|
+
A staging deployment or an internal tool wants the whole application out
|
|
18
|
+
of search indexes, not one page: a response header set in one listener
|
|
19
|
+
beats a meta tag repeated in every template.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__slots__ = ("_enabled",)
|
|
23
|
+
|
|
24
|
+
def __init__(self, enabled: bool) -> None:
|
|
25
|
+
"""Mark responses when ``enabled``; stay silent otherwise."""
|
|
26
|
+
self._enabled = enabled
|
|
27
|
+
|
|
28
|
+
def on_response(self, event: ResponseEvent) -> None:
|
|
29
|
+
"""Stamp the outgoing head, when marking is on."""
|
|
30
|
+
if self._enabled:
|
|
31
|
+
event.headers["X-Robots-Tag"] = "noindex"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""An uncaught exception written to the log with the request that caused it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Final, final
|
|
6
|
+
|
|
7
|
+
from xtr_logging_contracts import EXCEPTION_KEY
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from xtr_logging_contracts import LoggerInterface
|
|
11
|
+
|
|
12
|
+
from xtr_http_kernel.event import ExceptionEvent
|
|
13
|
+
|
|
14
|
+
__all__ = ["ErrorLoggingListener"]
|
|
15
|
+
|
|
16
|
+
_SERVER_ERROR: Final = 500
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@final
|
|
20
|
+
class ErrorLoggingListener:
|
|
21
|
+
"""Writes every exception the lifecycle announces to one logger.
|
|
22
|
+
|
|
23
|
+
The level follows whose fault the failure is: an exception carrying an
|
|
24
|
+
integer ``status_code`` below 500 is the caller's, and logged ``error``;
|
|
25
|
+
a server status, or an exception saying nothing about status, is logged
|
|
26
|
+
``critical``. The exception itself travels under the logging contract's
|
|
27
|
+
exception key, so formatters print its class, origin and cause.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_logger",)
|
|
31
|
+
|
|
32
|
+
def __init__(self, logger: LoggerInterface) -> None:
|
|
33
|
+
"""Write to ``logger`` — the bundle hands in the request channel's."""
|
|
34
|
+
self._logger = logger
|
|
35
|
+
|
|
36
|
+
def on_exception(self, event: ExceptionEvent) -> None:
|
|
37
|
+
"""Log the failure, leaving the response to whoever answers it."""
|
|
38
|
+
status = getattr(event.exception, "status_code", None)
|
|
39
|
+
known = status if isinstance(status, int) else None
|
|
40
|
+
write = (
|
|
41
|
+
self._logger.error
|
|
42
|
+
if known is not None and known < _SERVER_ERROR
|
|
43
|
+
else self._logger.critical
|
|
44
|
+
)
|
|
45
|
+
write(
|
|
46
|
+
"handling {method} {path} raised",
|
|
47
|
+
{
|
|
48
|
+
"method": event.request.method,
|
|
49
|
+
"path": event.request.url.path,
|
|
50
|
+
EXCEPTION_KEY: event.exception,
|
|
51
|
+
},
|
|
52
|
+
)
|