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,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
+ )