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,52 @@
1
+ """Requests turned into responses through events: a request lifecycle for web applications.
2
+
3
+ A web framework routes a request to the function that answers it. Everything
4
+ an application wants around that function — a request id on every response,
5
+ an uncaught exception written to the log with the request that caused it, a
6
+ header that keeps a page out of a search index — is not the endpoint's
7
+ business, and does not belong in each of them.
8
+
9
+ This library puts those between the framework and the endpoint as *events*:
10
+ a request announces itself, the response it produced announces itself, and
11
+ whoever listens contributes. Listeners come from the container, so they are
12
+ services like any other.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from importlib.metadata import PackageNotFoundError, version
18
+
19
+ from .event import (
20
+ ExceptionEvent,
21
+ FinishRequestEvent,
22
+ RequestEvent,
23
+ ResponseEvent,
24
+ TerminateEvent,
25
+ )
26
+ from .exception import HttpKernelError
27
+ from .kernel_events import KernelEvents
28
+ from .middleware_stack import MiddlewareStack
29
+ from .middleware_tag import MIDDLEWARE_TAG
30
+ from .request_lifecycle_middleware import RequestLifecycleMiddleware
31
+ from .setup import setup
32
+
33
+ try:
34
+ __version__ = version("xtr-http-kernel")
35
+ except PackageNotFoundError: # pragma: no cover
36
+ # Running from a source tree with no installed metadata to read.
37
+ __version__ = "0+unknown"
38
+
39
+ __all__ = [
40
+ "MIDDLEWARE_TAG",
41
+ "ExceptionEvent",
42
+ "FinishRequestEvent",
43
+ "HttpKernelError",
44
+ "KernelEvents",
45
+ "MiddlewareStack",
46
+ "RequestEvent",
47
+ "RequestLifecycleMiddleware",
48
+ "ResponseEvent",
49
+ "TerminateEvent",
50
+ "__version__",
51
+ "setup",
52
+ ]
@@ -0,0 +1,76 @@
1
+ """The one middleware the setup call adds: a container scope around each request.
2
+
3
+ Plain ASGI on purpose — no base class dispatch, no response buffering — so
4
+ the scope opens before anything downstream runs and closes only when the
5
+ downstream returned, which is after the response's last chunk left. The
6
+ scope opener arrives through the constructor rather than an import, so this
7
+ module stays free of the container layer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Final, cast, final
13
+
14
+ from ._state import STACK_KEY
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Callable
18
+ from contextlib import AbstractAsyncContextManager
19
+
20
+ from starlette.applications import Starlette
21
+ from starlette.types import ASGIApp, Receive, Scope, Send
22
+
23
+ from .middleware_stack import MiddlewareStack
24
+
25
+ ScopeOpener = Callable[[Starlette], AbstractAsyncContextManager[None]]
26
+
27
+ __all__ = ["KernelMiddleware"]
28
+
29
+ _SCOPED_TYPES: Final = frozenset({"http", "websocket"})
30
+
31
+
32
+ @final
33
+ class KernelMiddleware:
34
+ """Runs each connection inside the scope its scoped services live in.
35
+
36
+ ``http`` and ``websocket`` connections run through the middleware stack
37
+ the application's current life parked on its state — composed over the
38
+ downstream app — with the scope open around the whole of it, so a
39
+ scoped service lives until the response has been sent. The scope closes
40
+ on every path, a failing downstream included; the failure still
41
+ propagates. The ``lifespan`` connection passes through untouched.
42
+ """
43
+
44
+ __slots__ = ("_app", "_chain", "_open_scope", "_stack")
45
+
46
+ def __init__(self, app: ASGIApp, *, open_scope: ScopeOpener) -> None:
47
+ """Wrap ``app``, opening scopes through ``open_scope``."""
48
+ self._app = app
49
+ self._open_scope = open_scope
50
+ self._stack: MiddlewareStack | None = None
51
+ self._chain: ASGIApp = app
52
+
53
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
54
+ """Carry one connection through, scoped when it is a request."""
55
+ if cast("str", scope["type"]) not in _SCOPED_TYPES:
56
+ await self._app(scope, receive, send)
57
+ return
58
+ application = cast("Starlette", scope["app"])
59
+ chain = self._composed(application)
60
+ async with self._open_scope(application):
61
+ await chain(scope, receive, send)
62
+
63
+ def _composed(self, application: Starlette) -> ASGIApp:
64
+ """Return the chain for the application's current life, composed once.
65
+
66
+ The stack is frozen per application life, so its identity tells
67
+ whether the cached chain still stands.
68
+ """
69
+ stack = cast(
70
+ "MiddlewareStack | None",
71
+ getattr(application.state, STACK_KEY, None),
72
+ )
73
+ if stack is not self._stack:
74
+ self._stack = stack
75
+ self._chain = self._app if stack is None else stack.wrap(self._app)
76
+ return self._chain
@@ -0,0 +1,17 @@
1
+ """Where the setup call, its middleware and the test seam meet: the app's state.
2
+
3
+ Kept apart so the middleware module shares these keys with the setup and
4
+ testing modules without importing either.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Final
10
+
11
+ __all__ = ["OVERRIDES_KEY", "STACK_KEY"]
12
+
13
+ STACK_KEY: Final = "_xtr_http_kernel_middleware"
14
+ """Where each application life parks the kernel's middleware stack."""
15
+
16
+ OVERRIDES_KEY: Final = "_xtr_http_kernel_overrides"
17
+ """Where :func:`xtr_http_kernel.testing.override_services` parks its mapping."""
@@ -0,0 +1,8 @@
1
+ """The xtr-dependency-injection bundle for xtr-http-kernel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .http_kernel_bundle import HttpKernelBundle
6
+ from .http_kernel_config import HttpKernelConfig
7
+
8
+ __all__ = ["HttpKernelBundle", "HttpKernelConfig"]
@@ -0,0 +1,211 @@
1
+ """The xtr-http-kernel bundle: the request lifecycle, under the container.
2
+
3
+ The bundle is the integration, never the library: an application drives the
4
+ lifecycle itself with :func:`xtr_http_kernel.setup`, and lists this bundle so
5
+ the container owns the pieces the lifecycle reaches for — the middleware
6
+ factory the setup call composes, and the listeners that give every request
7
+ an id, a log record on failure and a unit of work of its own.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Final, cast, final
13
+
14
+ from typing_extensions import override
15
+
16
+ # ServiceKey stays a runtime import: it annotates a factory parameter the
17
+ # container evaluates when the definitions are emitted.
18
+ from xtr_dependency_injection import (
19
+ Bundle,
20
+ ServiceKey,
21
+ as_bundle,
22
+ bundle_active,
23
+ required_bundle,
24
+ )
25
+ from xtr_event_dispatcher.bundle import EventDispatcherBundle
26
+
27
+ # Read at runtime: the container fills factory parameters from annotations.
28
+ from xtr_logging_contracts import LoggerInterface
29
+ from xtr_service_contracts import ContainerInterface # noqa: TC002 — read at runtime, as above
30
+
31
+ from xtr_http_kernel.event import ExceptionEvent, RequestEvent, ResponseEvent, TerminateEvent
32
+ from xtr_http_kernel.event_listener import (
33
+ DisallowRobotsIndexingListener,
34
+ ErrorLoggingListener,
35
+ RequestIdListener,
36
+ )
37
+ from xtr_http_kernel.exception import InvalidMiddlewarePriorityError
38
+ from xtr_http_kernel.middleware_stack import MiddlewareStack
39
+ from xtr_http_kernel.middleware_tag import MIDDLEWARE_TAG
40
+
41
+ from .http_kernel_config import HttpKernelConfig
42
+ from .request_lifecycle_middleware_factory import RequestLifecycleMiddlewareFactory
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Callable, Coroutine
46
+
47
+ from xtr_dependency_injection import ContainerBuilder, ServiceConfigurator
48
+
49
+ from xtr_http_kernel.middleware_stack import MiddlewareFactory
50
+
51
+ __all__ = ["HttpKernelBundle"]
52
+
53
+ _LISTENER_TAG: Final = "event_dispatcher.listener"
54
+ """The tag the event dispatcher bundle reads listeners from."""
55
+
56
+ # The unit of work opens before anything else contributes and closes after
57
+ # everything else has heard the terminate; the request id settles right after
58
+ # the unit opens, so every record made while handling carries it.
59
+ _UNIT_OPEN_PRIORITY: Final = 8192
60
+ _REQUEST_ID_PRIORITY: Final = 4096
61
+ _UNIT_CLOSE_PRIORITY: Final = -8192
62
+
63
+
64
+ @final
65
+ @required_bundle(EventDispatcherBundle)
66
+ @required_bundle("xtr_logging.bundle:LoggingBundle", ignore_on_invalid=True)
67
+ @required_bundle("xtr_console.bundle:ConsoleBundle", ignore_on_invalid=True)
68
+ @as_bundle("http_kernel", config=HttpKernelConfig)
69
+ class HttpKernelBundle(Bundle[HttpKernelConfig]):
70
+ """Puts the request lifecycle's services under the container."""
71
+
72
+ @override
73
+ def prepend_extension(self, builder: ContainerBuilder) -> None:
74
+ """When ``logging`` is active, declare the default request channel on its config.
75
+
76
+ The channel name here is the config default: this bundle's own config
77
+ resolves after logging's, so a renamed channel is declared by the
78
+ application in its logging configuration instead.
79
+ """
80
+ if bundle_active(builder, "logging"):
81
+ builder.prepend_extension_config("logging", _add_request_channel)
82
+
83
+ @override
84
+ def load_extension(
85
+ self,
86
+ config: HttpKernelConfig,
87
+ services: ServiceConfigurator,
88
+ builder: ContainerBuilder,
89
+ ) -> None:
90
+ """Register the middleware factory, the listeners and the commands from ``config``.
91
+
92
+ The listeners that write to a log join only when the logging bundle
93
+ is active, and the router commands only when a console bundle is —
94
+ loading their module late keeps the console dependency out of the
95
+ graph of an application that never runs one.
96
+ """
97
+ _ = services.set(RequestLifecycleMiddlewareFactory).add_tag(
98
+ MIDDLEWARE_TAG, priority=config.middleware_priority
99
+ )
100
+ _ = services.set(_middleware_stack).set_argument("keys", ())
101
+ _ = (
102
+ services.set(RequestIdListener)
103
+ .set_argument("header", config.request_id_header)
104
+ .set_argument("trust_incoming", config.trust_request_id)
105
+ .add_tag(
106
+ _LISTENER_TAG,
107
+ event=RequestEvent,
108
+ method="on_request",
109
+ priority=_REQUEST_ID_PRIORITY,
110
+ )
111
+ .add_tag(_LISTENER_TAG, event=ResponseEvent, method="on_response")
112
+ )
113
+ _ = (
114
+ services.set(DisallowRobotsIndexingListener)
115
+ .set_argument("enabled", config.disallow_search_indexing)
116
+ .add_tag(_LISTENER_TAG, event=ResponseEvent, method="on_response")
117
+ )
118
+ if bundle_active(builder, "logging"):
119
+ # Needs the optional logging extra, which being here proves installed.
120
+ from xtr_http_kernel.event_listener.log_unit_listener import ( # noqa: PLC0415
121
+ LogUnitListener,
122
+ )
123
+
124
+ _ = (
125
+ services.set(LogUnitListener)
126
+ .add_tag(
127
+ _LISTENER_TAG,
128
+ event=RequestEvent,
129
+ method="on_request",
130
+ priority=_UNIT_OPEN_PRIORITY,
131
+ )
132
+ .add_tag(
133
+ _LISTENER_TAG,
134
+ event=TerminateEvent,
135
+ method="on_terminate",
136
+ priority=_UNIT_CLOSE_PRIORITY,
137
+ )
138
+ )
139
+ _ = services.set(_error_logging_listener(config.log_channel)).add_tag(
140
+ _LISTENER_TAG, event=ExceptionEvent, method="on_exception"
141
+ )
142
+ if bundle_active(builder, "console"):
143
+ services.load("xtr_http_kernel.command")
144
+
145
+ @override
146
+ def process(self, builder: ContainerBuilder) -> None:
147
+ """Order every tagged middleware into the stack, highest priority outermost.
148
+
149
+ Runs after every bundle loaded, so the builder holds every tag. The
150
+ tag's ``priority`` attribute is 0 when absent; ties keep
151
+ registration order.
152
+
153
+ Raises:
154
+ InvalidMiddlewarePriorityError: If a tag's ``priority`` is not
155
+ an integer.
156
+ """
157
+ entries: list[tuple[int, int, ServiceKey]] = []
158
+ for order, (key, attributes) in enumerate(
159
+ builder.find_tagged_service_ids(MIDDLEWARE_TAG).items()
160
+ ):
161
+ priority = attributes[0].get("priority", 0)
162
+ if not isinstance(priority, int):
163
+ raise InvalidMiddlewarePriorityError(key, priority)
164
+ entries.append((priority, order, key))
165
+ entries.sort(key=lambda entry: (-entry[0], entry[1]))
166
+ _ = builder.get_definition(MiddlewareStack).set_argument(
167
+ "keys", tuple(key for _, _, key in entries)
168
+ )
169
+
170
+
171
+ async def _middleware_stack(
172
+ container: ContainerInterface, keys: tuple[ServiceKey, ...]
173
+ ) -> MiddlewareStack:
174
+ """Build the stack by resolving each ordered key through ``container``.
175
+
176
+ ``keys`` comes from the bundle's ``process`` hook: the tagged
177
+ definitions, already ordered highest priority first.
178
+ """
179
+ factories = [
180
+ cast("MiddlewareFactory", await container.get(provided, qualifier))
181
+ for provided, qualifier in keys
182
+ ]
183
+ return MiddlewareStack(factories)
184
+
185
+
186
+ def _error_logging_listener(
187
+ channel: str,
188
+ ) -> Callable[[ContainerInterface], Coroutine[object, object, ErrorLoggingListener]]:
189
+ """Build the factory giving the error listener the ``channel`` logger.
190
+
191
+ The channel comes from this bundle's config, so it cannot sit in an
192
+ annotation the way a constant channel could: the factory asks the
193
+ container at build time instead.
194
+ """
195
+
196
+ async def error_logging_listener(container: ContainerInterface) -> ErrorLoggingListener:
197
+ return ErrorLoggingListener(await container.get(LoggerInterface, channel))
198
+
199
+ return error_logging_listener
200
+
201
+
202
+ def _add_request_channel(config: object) -> object:
203
+ """Declare the default channel through the logging config's own ``with_channels``.
204
+
205
+ Duck-typed: this bundle depends on the logging contracts only, never on
206
+ xtr-logging, so it asks the config it is handed rather than importing
207
+ its type.
208
+ """
209
+ with_channels = cast("Callable[[str], object] | None", getattr(config, "with_channels", None))
210
+ default_channel = HttpKernelConfig().log_channel
211
+ return with_channels(default_channel) if with_channels is not None else config
@@ -0,0 +1,62 @@
1
+ """Configuration for :class:`~xtr_http_kernel.bundle.http_kernel_bundle.HttpKernelBundle`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Final
8
+
9
+ __all__ = ["HttpKernelConfig"]
10
+
11
+ _HTTP_TOKEN: Final = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
12
+ """What a header field name may be made of."""
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class HttpKernelConfig:
17
+ """What an application may change about the request lifecycle.
18
+
19
+ Attributes:
20
+ request_id_header: The header the request id is read from and echoed
21
+ on. Must be a non-empty HTTP token.
22
+ trust_request_id: Whether a well-formed incoming id is kept. When
23
+ false every request gets a fresh one.
24
+ disallow_search_indexing: Whether every response is marked
25
+ ``X-Robots-Tag: noindex``.
26
+ log_channel: The logging channel the error listener writes to. The
27
+ bundle declares the default channel on the logging config; an
28
+ application choosing another name declares that channel in its
29
+ own logging configuration.
30
+ middleware_priority: Where the lifecycle middleware sits among the
31
+ contributed factories — highest outermost.
32
+ app: The ``"package.module:app"`` import string the console commands
33
+ load the application from, when they need one.
34
+
35
+ Raises:
36
+ ValueError: When ``request_id_header`` is not an HTTP token,
37
+ ``log_channel`` is empty, or ``app`` does not hold exactly one
38
+ module and one attribute around a single ``:``.
39
+ """
40
+
41
+ request_id_header: str = "X-Request-Id"
42
+ trust_request_id: bool = True
43
+ disallow_search_indexing: bool = False
44
+ log_channel: str = "request"
45
+ middleware_priority: int = 0
46
+ app: str | None = None
47
+
48
+ def __post_init__(self) -> None:
49
+ """Refuse values the lifecycle would silently misread."""
50
+ if not _HTTP_TOKEN.fullmatch(self.request_id_header):
51
+ message = (
52
+ f"request_id_header must be a non-empty HTTP token, not {self.request_id_header!r}"
53
+ )
54
+ raise ValueError(message)
55
+ if not self.log_channel:
56
+ message = "log_channel must not be empty"
57
+ raise ValueError(message)
58
+ if self.app is not None:
59
+ module, separator, attribute = self.app.partition(":")
60
+ if not (module and separator and attribute) or ":" in attribute:
61
+ message = f'app must read "package.module:app", not {self.app!r}'
62
+ raise ValueError(message)
@@ -0,0 +1,35 @@
1
+ """The middleware factory the bundle tags for the kernel to order into the stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ # Read at runtime: the container fills the constructor from this annotation.
8
+ from xtr_event_dispatcher_contracts import EventDispatcherInterface # noqa: TC002
9
+
10
+ from xtr_http_kernel.request_lifecycle_middleware import RequestLifecycleMiddleware
11
+
12
+ if TYPE_CHECKING:
13
+ from starlette.types import ASGIApp
14
+
15
+ __all__ = ["RequestLifecycleMiddlewareFactory"]
16
+
17
+
18
+ @final
19
+ class RequestLifecycleMiddlewareFactory:
20
+ """Wraps a downstream app in the lifecycle middleware, around the container's dispatcher.
21
+
22
+ Tagged ``http_kernel.middleware`` with the config's
23
+ ``middleware_priority`` as the tag's ``priority``, so the bundle orders
24
+ it into the stack when the kernel is built — highest outermost.
25
+ """
26
+
27
+ __slots__ = ("_dispatcher",)
28
+
29
+ def __init__(self, dispatcher: EventDispatcherInterface) -> None:
30
+ """Hand ``dispatcher`` to every middleware built."""
31
+ self._dispatcher = dispatcher
32
+
33
+ def __call__(self, app: ASGIApp) -> ASGIApp:
34
+ """Return ``app`` wrapped in the lifecycle middleware."""
35
+ return RequestLifecycleMiddleware(app, dispatcher=self._dispatcher)
@@ -0,0 +1,9 @@
1
+ """Console commands the http kernel bundle contributes when a console is active."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .debug_router_command import DebugRouterCommand
6
+ from .route_description import RouteDescription
7
+ from .router_match_command import RouterMatchCommand
8
+
9
+ __all__ = ["DebugRouterCommand", "RouteDescription", "RouterMatchCommand"]
@@ -0,0 +1,113 @@
1
+ """One view of an application's routes, whichever way the framework hands them over.
2
+
3
+ Newer releases keep a router included under a prefix as a wrapper around that
4
+ router rather than as the routes it holds, and offer the effective view of
5
+ every route — prefixes applied — through ``iter_route_contexts``. Older ones
6
+ put each route in the list directly, already effective. This module is the
7
+ only place that difference lives.
8
+
9
+ A release that grew the wrapper before it grew the view of one lists an
10
+ included router as the single opaque route the application holds: there is no
11
+ supported way in, so the row names the wrapper for what it is rather than
12
+ inventing a path for it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Protocol, cast, final
19
+
20
+ from fastapi import routing as fastapi_routing
21
+ from starlette.routing import Host, Mount, Route, WebSocketRoute
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable, Iterable, Sequence
25
+
26
+ from fastapi.routing import RouteContext
27
+ from starlette.routing import BaseRoute, Match
28
+ from starlette.types import Scope
29
+
30
+ __all__ = ["RouteView", "route_contexts"]
31
+
32
+
33
+ class _Matcher(Protocol):
34
+ """What answers whether a request reaches a route."""
35
+
36
+ def matches(self, scope: Scope, /) -> tuple[Match, Scope]:
37
+ """Return how well ``scope`` fits, and what the route reads out of it."""
38
+ ...
39
+
40
+
41
+ @final
42
+ @dataclass(frozen=True, slots=True)
43
+ class RouteView:
44
+ """What routing sees for one route, every prefix already applied.
45
+
46
+ Attributes:
47
+ route: The route itself, for what only its kind can answer.
48
+ path: The path requests are matched against, ``None`` for a route the
49
+ framework keeps to itself.
50
+ name: The name the route answers to, ``None`` when it has none.
51
+ methods: The methods it answers, ``None`` when it takes whatever
52
+ arrives or is not a method route at all.
53
+ endpoint: What it hands the request to, ``None`` when it hands it to
54
+ another application instead.
55
+ matcher: What answers :meth:`matches` — the framework's own view of
56
+ the route, or the route itself.
57
+ """
58
+
59
+ route: BaseRoute
60
+ path: str | None
61
+ name: str | None
62
+ methods: set[str] | None
63
+ endpoint: object | None
64
+ matcher: object
65
+
66
+ def matches(self, scope: Scope) -> tuple[Match, Scope]:
67
+ """Return how well ``scope`` fits this route, and what it reads out of it.
68
+
69
+ The framework's own view of a route stands in for it and forwards what
70
+ it is asked for, so it answers this without declaring it; the hop
71
+ through ``object`` is what says the shape is known rather than guessed.
72
+ """
73
+ return cast("_Matcher", self.matcher).matches(scope)
74
+
75
+
76
+ def route_contexts(routes: Sequence[BaseRoute]) -> Sequence[RouteView]:
77
+ """Read every route of an application, prefixes applied.
78
+
79
+ A release offering the effective view of each route is asked for it; the
80
+ rest hand their routes over already effective.
81
+ """
82
+ effective = cast(
83
+ "Callable[[Sequence[BaseRoute]], Iterable[RouteContext]] | None",
84
+ getattr(fastapi_routing, "iter_route_contexts", None),
85
+ )
86
+ if effective is None:
87
+ return [_of_route(route) for route in routes]
88
+ return [_of_context(context) for context in effective(routes)]
89
+
90
+
91
+ def _of_context(context: RouteContext) -> RouteView:
92
+ """Read the framework's own view of a route."""
93
+ return RouteView(
94
+ route=context.route,
95
+ path=context.path,
96
+ name=context.name,
97
+ methods=context.methods,
98
+ endpoint=context.endpoint,
99
+ matcher=context,
100
+ )
101
+
102
+
103
+ def _of_route(route: BaseRoute) -> RouteView:
104
+ """Read a route a release hands over already effective.
105
+
106
+ Each line below names the kinds of route that can answer that much; a kind
107
+ that answers none of it is left to be named by itself.
108
+ """
109
+ methods = route.methods if isinstance(route, Route) else None
110
+ endpoint = route.endpoint if isinstance(route, (Route, WebSocketRoute)) else None
111
+ path = route.path if isinstance(route, (Mount, Route, WebSocketRoute)) else None
112
+ name = route.name if isinstance(route, (Host, Mount, Route, WebSocketRoute)) else None
113
+ return RouteView(route, path, name, methods, endpoint, route)
@@ -0,0 +1,52 @@
1
+ """``debug:router``: every route of an application, as routing reads them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import final
6
+
7
+ from xtr_console import ConsoleStyle, ExitCode, as_command, escape
8
+
9
+ from ._route_contexts import route_contexts
10
+ from .route_description import RouteDescription
11
+ from .router_command import RouterCommand
12
+
13
+ __all__ = ["DebugRouterCommand"]
14
+
15
+
16
+ @as_command("debug:router")
17
+ @final
18
+ class DebugRouterCommand(RouterCommand):
19
+ """Lists every route an application holds, in the order routing tries them."""
20
+
21
+ __slots__ = ()
22
+
23
+ async def __call__(self, io: ConsoleStyle, *, app: str | None = None) -> int:
24
+ """List the application's routes, prefixes applied.
25
+
26
+ A router included under a prefix is listed under it, and a mounted
27
+ application is listed as the one route that reaches it — which is
28
+ what routing sees.
29
+
30
+ Args:
31
+ io: Where the command writes.
32
+ app: The application to read, as ``package.module:app``; the
33
+ configured one when left out.
34
+ """
35
+ application = self._app_or_report(io, app)
36
+ if application is None:
37
+ return ExitCode.INVALID
38
+ described = [RouteDescription.of(view) for view in route_contexts(application.routes)]
39
+ io.section(f"Routes ({len(described)})")
40
+ io.table(
41
+ ("Methods", "Path", "Name", "Endpoint"),
42
+ [
43
+ (
44
+ escape(route.methods),
45
+ escape(route.path),
46
+ escape(route.name),
47
+ escape(route.endpoint),
48
+ )
49
+ for route in described
50
+ ],
51
+ )
52
+ return ExitCode.SUCCESS