Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/starlette/routing.py: 21%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from __future__ import annotations
3import contextlib
4import functools
5import inspect
6import re
7import traceback
8import types
9import warnings
10from collections.abc import Awaitable, Callable, Collection, Generator, Sequence
11from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager
12from enum import Enum
13from re import Pattern
14from typing import Any, TypeVar
16from starlette._exception_handler import wrap_app_handling_exceptions
17from starlette._utils import get_route_path, is_async_callable, parse_host_header
18from starlette.concurrency import run_in_threadpool
19from starlette.convertors import CONVERTOR_TYPES, Convertor
20from starlette.datastructures import URL, Headers, URLPath
21from starlette.exceptions import HTTPException, StarletteDeprecationWarning
22from starlette.middleware import Middleware
23from starlette.middleware.body_limit import RequestBodyLimitMiddleware
24from starlette.requests import Request
25from starlette.responses import PlainTextResponse, RedirectResponse, Response
26from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send
27from starlette.websockets import WebSocket, WebSocketClose
30class NoMatchFound(Exception):
31 """
32 Raised by `.url_for(name, **path_params)` and `.url_path_for(name, **path_params)`
33 if no matching route exists.
34 """
36 def __init__(self, name: str, path_params: dict[str, Any]) -> None:
37 params = ", ".join(list(path_params.keys()))
38 super().__init__(f'No route exists for name "{name}" and params "{params}".')
41class Match(Enum):
42 NONE = 0
43 PARTIAL = 1
44 FULL = 2
47def request_response(
48 func: Callable[[Request], Awaitable[Response] | Response],
49) -> ASGIApp:
50 """
51 Takes a function or coroutine `func(request) -> response`,
52 and returns an ASGI application.
53 """
54 f: Callable[[Request], Awaitable[Response]] = (
55 func if is_async_callable(func) else functools.partial(run_in_threadpool, func) # type: ignore[assignment, call-arg]
56 )
58 async def app(scope: Scope, receive: Receive, send: Send) -> None:
59 request = Request(scope, receive, send)
61 async def app(scope: Scope, receive: Receive, send: Send) -> None:
62 response = await f(request)
63 await response(scope, receive, send)
65 await wrap_app_handling_exceptions(app, request)(scope, receive, send)
67 return app
70def websocket_session(
71 func: Callable[[WebSocket], Awaitable[None]],
72) -> ASGIApp:
73 """
74 Takes a coroutine `func(session)`, and returns an ASGI application.
75 """
76 # assert asyncio.iscoroutinefunction(func), "WebSocket endpoints must be async"
78 async def app(scope: Scope, receive: Receive, send: Send) -> None:
79 session = WebSocket(scope, receive=receive, send=send)
81 async def app(scope: Scope, receive: Receive, send: Send) -> None:
82 await func(session)
84 await wrap_app_handling_exceptions(app, session)(scope, receive, send)
86 return app
89def get_name(endpoint: Callable[..., Any]) -> str:
90 return getattr(endpoint, "__name__", endpoint.__class__.__name__)
93def replace_params(
94 path: str,
95 param_convertors: dict[str, Convertor[Any]],
96 path_params: dict[str, str],
97) -> tuple[str, dict[str, str]]:
98 for key, value in list(path_params.items()):
99 if "{" + key + "}" in path:
100 convertor = param_convertors[key]
101 value = convertor.to_string(value)
102 path = path.replace("{" + key + "}", value)
103 path_params.pop(key)
104 return path, path_params
107# Match parameters in URL paths, eg. '{param}', and '{param:int}'
108PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
111def compile_path(
112 path: str,
113) -> tuple[Pattern[str], str, dict[str, Convertor[Any]]]:
114 """
115 Given a path string, like: "/{username:str}",
116 or a host string, like: "{subdomain}.mydomain.org", return a three-tuple
117 of (regex, format, {param_name:convertor}).
119 regex: "/(?P<username>[^/]+)"
120 format: "/{username}"
121 convertors: {"username": StringConvertor()}
122 """
123 is_host = not path.startswith("/")
125 path_regex = "^"
126 path_format = ""
127 duplicated_params: set[str] = set()
129 idx = 0
130 param_convertors = {}
131 for match in PARAM_REGEX.finditer(path):
132 param_name, convertor_type = match.groups("str")
133 convertor_type = convertor_type.lstrip(":")
134 assert convertor_type in CONVERTOR_TYPES, f"Unknown path convertor '{convertor_type}'"
135 convertor = CONVERTOR_TYPES[convertor_type]
137 path_regex += re.escape(path[idx : match.start()])
138 path_regex += f"(?P<{param_name}>{convertor.regex})"
140 path_format += path[idx : match.start()]
141 path_format += "{%s}" % param_name
143 if param_name in param_convertors:
144 duplicated_params.add(param_name)
146 param_convertors[param_name] = convertor
148 idx = match.end()
150 if duplicated_params:
151 names = ", ".join(sorted(duplicated_params))
152 ending = "s" if len(duplicated_params) > 1 else ""
153 raise ValueError(f"Duplicated param name{ending} {names} at path {path}")
155 if is_host:
156 # Align with `Host.matches()` behavior, which ignores port.
157 hostname = path[idx:]
158 if not hostname.endswith("]"):
159 hostname = hostname.rsplit(":", 1)[0]
160 path_regex += re.escape(hostname) + "$"
161 else:
162 path_regex += re.escape(path[idx:]) + "$"
164 path_format += path[idx:]
166 return re.compile(path_regex), path_format, param_convertors
169class BaseRoute:
170 def matches(self, scope: Scope) -> tuple[Match, Scope]:
171 raise NotImplementedError() # pragma: no cover
173 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
174 raise NotImplementedError() # pragma: no cover
176 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
177 raise NotImplementedError() # pragma: no cover
179 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
180 """
181 A route may be used in isolation as a stand-alone ASGI app.
182 This is a somewhat contrived case, as they'll almost always be used
183 within a Router, but could be useful for some tooling and minimal apps.
184 """
185 match, child_scope = self.matches(scope)
186 if match == Match.NONE:
187 if scope["type"] == "http":
188 response = PlainTextResponse("Not Found", status_code=404)
189 await response(scope, receive, send)
190 elif scope["type"] == "websocket": # pragma: no branch
191 websocket_close = WebSocketClose()
192 await websocket_close(scope, receive, send)
193 return
195 scope.update(child_scope)
196 await self.handle(scope, receive, send)
199class Route(BaseRoute):
200 def __init__(
201 self,
202 path: str,
203 endpoint: Callable[..., Any],
204 *,
205 methods: Collection[str] | None = None,
206 name: str | None = None,
207 include_in_schema: bool = True,
208 middleware: Sequence[Middleware] | None = None,
209 max_body_size: int | None = None,
210 ) -> None:
211 assert path.startswith("/"), "Routed paths must start with '/'"
212 self.path = path
213 self.endpoint = endpoint
214 self.name = get_name(endpoint) if name is None else name
215 self.include_in_schema = include_in_schema
217 endpoint_handler = endpoint
218 while isinstance(endpoint_handler, functools.partial):
219 endpoint_handler = endpoint_handler.func
220 if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
221 # Endpoint is function or method. Treat it as `func(request) -> response`.
222 self.app = request_response(endpoint)
223 if methods is None:
224 methods = ["GET"]
225 else:
226 # Endpoint is a class. Treat it as ASGI.
227 self.app = endpoint
229 if middleware is not None:
230 for cls, args, kwargs in reversed(middleware):
231 self.app = cls(self.app, *args, **kwargs)
232 if max_body_size is not None:
233 self.app = RequestBodyLimitMiddleware(self.app, max_body_size=max_body_size)
235 if methods is None:
236 self.methods = None
237 else:
238 self.methods = {method.upper() for method in methods}
239 if "GET" in self.methods:
240 self.methods.add("HEAD")
242 self.path_regex, self.path_format, self.param_convertors = compile_path(path)
244 def matches(self, scope: Scope) -> tuple[Match, Scope]:
245 path_params: dict[str, Any]
246 if scope["type"] == "http":
247 route_path = get_route_path(scope)
248 match = self.path_regex.match(route_path)
249 if match:
250 matched_params = match.groupdict()
251 for key, value in matched_params.items():
252 matched_params[key] = self.param_convertors[key].convert(value)
253 path_params = dict(scope.get("path_params", {}))
254 path_params.update(matched_params)
255 child_scope = {"endpoint": self.endpoint, "path_params": path_params}
256 if self.methods and scope["method"] not in self.methods:
257 return Match.PARTIAL, child_scope
258 else:
259 return Match.FULL, child_scope
260 return Match.NONE, {}
262 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
263 seen_params = set(path_params.keys())
264 expected_params = set(self.param_convertors.keys())
266 if name != self.name or seen_params != expected_params:
267 raise NoMatchFound(name, path_params)
269 path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
270 assert not remaining_params
271 return URLPath(path=path, protocol="http")
273 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
274 if self.methods and scope["method"] not in self.methods:
275 headers = {"Allow": ", ".join(self.methods)}
276 if "app" in scope:
277 raise HTTPException(status_code=405, headers=headers)
278 else:
279 response = PlainTextResponse("Method Not Allowed", status_code=405, headers=headers)
280 await response(scope, receive, send)
281 else:
282 await self.app(scope, receive, send)
284 def __eq__(self, other: Any) -> bool:
285 return (
286 isinstance(other, Route)
287 and self.path == other.path
288 and self.endpoint == other.endpoint
289 and self.methods == other.methods
290 )
292 def __repr__(self) -> str:
293 class_name = self.__class__.__name__
294 methods = sorted(self.methods or [])
295 path, name = self.path, self.name
296 return f"{class_name}(path={path!r}, name={name!r}, methods={methods!r})"
299class WebSocketRoute(BaseRoute):
300 def __init__(
301 self,
302 path: str,
303 endpoint: Callable[..., Any],
304 *,
305 name: str | None = None,
306 middleware: Sequence[Middleware] | None = None,
307 ) -> None:
308 assert path.startswith("/"), "Routed paths must start with '/'"
309 self.path = path
310 self.endpoint = endpoint
311 self.name = get_name(endpoint) if name is None else name
313 endpoint_handler = endpoint
314 while isinstance(endpoint_handler, functools.partial):
315 endpoint_handler = endpoint_handler.func
316 if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
317 # Endpoint is function or method. Treat it as `func(websocket)`.
318 self.app = websocket_session(endpoint)
319 else:
320 # Endpoint is a class. Treat it as ASGI.
321 self.app = endpoint
323 if middleware is not None:
324 for cls, args, kwargs in reversed(middleware):
325 self.app = cls(self.app, *args, **kwargs)
327 self.path_regex, self.path_format, self.param_convertors = compile_path(path)
329 def matches(self, scope: Scope) -> tuple[Match, Scope]:
330 path_params: dict[str, Any]
331 if scope["type"] == "websocket":
332 route_path = get_route_path(scope)
333 match = self.path_regex.match(route_path)
334 if match:
335 matched_params = match.groupdict()
336 for key, value in matched_params.items():
337 matched_params[key] = self.param_convertors[key].convert(value)
338 path_params = dict(scope.get("path_params", {}))
339 path_params.update(matched_params)
340 child_scope = {"endpoint": self.endpoint, "path_params": path_params}
341 return Match.FULL, child_scope
342 return Match.NONE, {}
344 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
345 seen_params = set(path_params.keys())
346 expected_params = set(self.param_convertors.keys())
348 if name != self.name or seen_params != expected_params:
349 raise NoMatchFound(name, path_params)
351 path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
352 assert not remaining_params
353 return URLPath(path=path, protocol="websocket")
355 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
356 await self.app(scope, receive, send)
358 def __eq__(self, other: Any) -> bool:
359 return isinstance(other, WebSocketRoute) and self.path == other.path and self.endpoint == other.endpoint
361 def __repr__(self) -> str:
362 return f"{self.__class__.__name__}(path={self.path!r}, name={self.name!r})"
365class Mount(BaseRoute):
366 def __init__(
367 self,
368 path: str,
369 app: ASGIApp | None = None,
370 routes: Sequence[BaseRoute] | None = None,
371 name: str | None = None,
372 *,
373 middleware: Sequence[Middleware] | None = None,
374 max_body_size: int | None = None,
375 ) -> None:
376 assert path == "" or path.startswith("/"), "Routed paths must start with '/'"
377 assert app is not None or routes is not None, "Either 'app=...', or 'routes=' must be specified"
378 self.path = path.rstrip("/")
379 if app is not None:
380 self._base_app: ASGIApp = app
381 else:
382 self._base_app = Router(routes=routes)
383 self.app = self._base_app
384 if middleware is not None:
385 for cls, args, kwargs in reversed(middleware):
386 self.app = cls(self.app, *args, **kwargs)
387 if max_body_size is not None:
388 self.app = RequestBodyLimitMiddleware(self.app, max_body_size=max_body_size)
389 self.name = name
390 self.path_regex, self.path_format, self.param_convertors = compile_path(self.path + "/{path:path}")
392 @property
393 def routes(self) -> list[BaseRoute]:
394 return getattr(self._base_app, "routes", [])
396 def matches(self, scope: Scope) -> tuple[Match, Scope]:
397 path_params: dict[str, Any]
398 if scope["type"] in ("http", "websocket"): # pragma: no branch
399 root_path = scope.get("root_path", "")
400 route_path = get_route_path(scope)
401 match = self.path_regex.match(route_path)
402 if match:
403 matched_params = match.groupdict()
404 for key, value in matched_params.items():
405 matched_params[key] = self.param_convertors[key].convert(value)
406 remaining_path = "/" + matched_params.pop("path")
407 matched_path = route_path[: -len(remaining_path)]
408 path_params = dict(scope.get("path_params", {}))
409 path_params.update(matched_params)
410 child_scope = {
411 "path_params": path_params,
412 # app_root_path will only be set at the top level scope,
413 # initialized with the (optional) value of a root_path
414 # set above/before Starlette. And even though any
415 # mount will have its own child scope with its own respective
416 # root_path, the app_root_path will always be available in all
417 # the child scopes with the same top level value because it's
418 # set only once here with a default, any other child scope will
419 # just inherit that app_root_path default value stored in the
420 # scope. All this is needed to support Request.url_for(), as it
421 # uses the app_root_path to build the URL path.
422 "app_root_path": scope.get("app_root_path", root_path),
423 "root_path": root_path + matched_path,
424 "endpoint": self.app,
425 }
426 return Match.FULL, child_scope
427 return Match.NONE, {}
429 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
430 if self.name is not None and name == self.name and "path" in path_params:
431 # 'name' matches "<mount_name>".
432 path_params["path"] = path_params["path"].lstrip("/")
433 path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
434 if not remaining_params:
435 return URLPath(path=path)
436 elif self.name is None or name.startswith(self.name + ":"):
437 if self.name is None:
438 # No mount name.
439 remaining_name = name
440 else:
441 # 'name' matches "<mount_name>:<child_name>".
442 remaining_name = name[len(self.name) + 1 :]
443 path_kwarg = path_params.get("path")
444 path_params["path"] = ""
445 path_prefix, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
446 if path_kwarg is not None:
447 remaining_params["path"] = path_kwarg
448 for route in self.routes or []:
449 try:
450 url = route.url_path_for(remaining_name, **remaining_params)
451 return URLPath(path=path_prefix.rstrip("/") + str(url), protocol=url.protocol)
452 except NoMatchFound:
453 pass
454 raise NoMatchFound(name, path_params)
456 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
457 await self.app(scope, receive, send)
459 def __eq__(self, other: Any) -> bool:
460 return isinstance(other, Mount) and self.path == other.path and self.app == other.app
462 def __repr__(self) -> str:
463 class_name = self.__class__.__name__
464 name = self.name or ""
465 return f"{class_name}(path={self.path!r}, name={name!r}, app={self.app!r})"
468class Host(BaseRoute):
469 def __init__(self, host: str, app: ASGIApp, name: str | None = None) -> None:
470 assert not host.startswith("/"), "Host must not start with '/'"
471 self.host = host
472 self.app = app
473 self.name = name
474 self.host_regex, self.host_format, self.param_convertors = compile_path(host)
476 @property
477 def routes(self) -> list[BaseRoute]:
478 return getattr(self.app, "routes", [])
480 def matches(self, scope: Scope) -> tuple[Match, Scope]:
481 if scope["type"] in ("http", "websocket"): # pragma:no branch
482 headers = Headers(scope=scope)
483 parsed_host = parse_host_header(headers.get("host"))
484 if parsed_host is None:
485 return Match.NONE, {}
486 host = parsed_host.host
488 match = self.host_regex.match(host)
489 if match:
490 matched_params = match.groupdict()
491 for key, value in matched_params.items():
492 matched_params[key] = self.param_convertors[key].convert(value)
493 path_params = dict(scope.get("path_params", {}))
494 path_params.update(matched_params)
495 child_scope = {"path_params": path_params, "endpoint": self.app}
496 return Match.FULL, child_scope
497 return Match.NONE, {}
499 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
500 if self.name is not None and name == self.name and "path" in path_params:
501 # 'name' matches "<mount_name>".
502 path = path_params.pop("path")
503 host, remaining_params = replace_params(self.host_format, self.param_convertors, path_params)
504 if not remaining_params:
505 return URLPath(path=path, host=host)
506 elif self.name is None or name.startswith(self.name + ":"):
507 if self.name is None:
508 # No mount name.
509 remaining_name = name
510 else:
511 # 'name' matches "<mount_name>:<child_name>".
512 remaining_name = name[len(self.name) + 1 :]
513 host, remaining_params = replace_params(self.host_format, self.param_convertors, path_params)
514 for route in self.routes or []:
515 try:
516 url = route.url_path_for(remaining_name, **remaining_params)
517 return URLPath(path=str(url), protocol=url.protocol, host=host)
518 except NoMatchFound:
519 pass
520 raise NoMatchFound(name, path_params)
522 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
523 await self.app(scope, receive, send)
525 def __eq__(self, other: Any) -> bool:
526 return isinstance(other, Host) and self.host == other.host and self.app == other.app
528 def __repr__(self) -> str:
529 class_name = self.__class__.__name__
530 name = self.name or ""
531 return f"{class_name}(host={self.host!r}, name={name!r}, app={self.app!r})"
534_T = TypeVar("_T")
537class _AsyncLiftContextManager(AbstractAsyncContextManager[_T]):
538 def __init__(self, cm: AbstractContextManager[_T]):
539 self._cm = cm
541 async def __aenter__(self) -> _T:
542 return self._cm.__enter__()
544 async def __aexit__(
545 self,
546 exc_type: type[BaseException] | None,
547 exc_value: BaseException | None,
548 traceback: types.TracebackType | None,
549 ) -> bool | None:
550 return self._cm.__exit__(exc_type, exc_value, traceback)
553def _wrap_gen_lifespan_context(
554 lifespan_context: Callable[[Any], Generator[Any, Any, Any]],
555) -> Callable[[Any], AbstractAsyncContextManager[Any]]:
556 cmgr = contextlib.contextmanager(lifespan_context)
558 @functools.wraps(cmgr)
559 def wrapper(app: Any) -> _AsyncLiftContextManager[Any]:
560 return _AsyncLiftContextManager(cmgr(app))
562 return wrapper
565class _DefaultLifespan:
566 def __init__(self, router: Router):
567 self._router = router
569 async def __aenter__(self) -> None:
570 pass
572 async def __aexit__(self, *exc_info: object) -> None:
573 pass
575 def __call__(self: _T, app: object) -> _T:
576 return self
579class Router:
580 def __init__(
581 self,
582 routes: Sequence[BaseRoute] | None = None,
583 redirect_slashes: bool = True,
584 default: ASGIApp | None = None,
585 # the generic to Lifespan[AppType] is the type of the top level application
586 # which the router cannot know statically, so we use Any
587 lifespan: Lifespan[Any] | None = None,
588 *,
589 middleware: Sequence[Middleware] | None = None,
590 max_body_size: int | None = None,
591 ) -> None:
592 self.routes = [] if routes is None else list(routes)
593 self.redirect_slashes = redirect_slashes
594 self.default = self.not_found if default is None else default
596 if lifespan is None:
597 self.lifespan_context: Lifespan[Any] = _DefaultLifespan(self)
599 elif inspect.isasyncgenfunction(lifespan):
600 warnings.warn(
601 "async generator function lifespans are deprecated, "
602 "use an @contextlib.asynccontextmanager function instead",
603 StarletteDeprecationWarning,
604 )
605 self.lifespan_context = asynccontextmanager(lifespan)
606 elif inspect.isgeneratorfunction(lifespan):
607 warnings.warn(
608 "generator function lifespans are deprecated, use an @contextlib.asynccontextmanager function instead",
609 StarletteDeprecationWarning,
610 )
611 self.lifespan_context = _wrap_gen_lifespan_context(lifespan)
612 else:
613 self.lifespan_context = lifespan
615 self.middleware_stack = self.app
616 if middleware:
617 for cls, args, kwargs in reversed(middleware):
618 self.middleware_stack = cls(self.middleware_stack, *args, **kwargs)
619 if max_body_size is not None:
620 self.middleware_stack = RequestBodyLimitMiddleware(self.middleware_stack, max_body_size=max_body_size)
622 async def not_found(self, scope: Scope, receive: Receive, send: Send) -> None:
623 if scope["type"] == "websocket":
624 websocket_close = WebSocketClose()
625 await websocket_close(scope, receive, send)
626 return
628 # If we're running inside a starlette application then raise an
629 # exception, so that the configurable exception handler can deal with
630 # returning the response. For plain ASGI apps, just return the response.
631 if "app" in scope:
632 raise HTTPException(status_code=404)
633 else:
634 response = PlainTextResponse("Not Found", status_code=404)
635 await response(scope, receive, send)
637 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
638 for route in self.routes:
639 try:
640 return route.url_path_for(name, **path_params)
641 except NoMatchFound:
642 pass
643 raise NoMatchFound(name, path_params)
645 async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
646 """
647 Handle ASGI lifespan messages, which allows us to manage application
648 startup and shutdown events.
649 """
650 started = False
651 app: Any = scope.get("app")
652 await receive()
653 try:
654 async with self.lifespan_context(app) as maybe_state:
655 if maybe_state is not None:
656 if "state" not in scope:
657 raise RuntimeError('The server does not support "state" in the lifespan scope.')
658 scope["state"].update(maybe_state)
659 await send({"type": "lifespan.startup.complete"})
660 started = True
661 await receive()
662 except BaseException:
663 exc_text = traceback.format_exc()
664 if started:
665 await send({"type": "lifespan.shutdown.failed", "message": exc_text})
666 else:
667 await send({"type": "lifespan.startup.failed", "message": exc_text})
668 raise
669 else:
670 await send({"type": "lifespan.shutdown.complete"})
672 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
673 """
674 The main entry point to the Router class.
675 """
676 await self.middleware_stack(scope, receive, send)
678 async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
679 assert scope["type"] in ("http", "websocket", "lifespan")
681 if "router" not in scope:
682 scope["router"] = self
684 if scope["type"] == "lifespan":
685 await self.lifespan(scope, receive, send)
686 return
688 partial = None
690 for route in self.routes:
691 # Determine if any route matches the incoming scope,
692 # and hand over to the matching route if found.
693 match, child_scope = route.matches(scope)
694 if match == Match.FULL:
695 scope["route"] = route
696 scope.update(child_scope)
697 await route.handle(scope, receive, send)
698 return
699 elif match == Match.PARTIAL and partial is None:
700 partial = route
701 partial_scope = child_scope
703 if partial is not None:
704 # Handle partial matches. These are cases where an endpoint is
705 # able to handle the request, but is not a preferred option.
706 # We use this in particular to deal with "405 Method Not Allowed".
707 scope["route"] = partial
708 scope.update(partial_scope)
709 await partial.handle(scope, receive, send)
710 return
712 route_path = get_route_path(scope)
713 if scope["type"] == "http" and self.redirect_slashes and route_path != "/":
714 redirect_scope = dict(scope)
715 if route_path.endswith("/"):
716 redirect_scope["path"] = redirect_scope["path"].rstrip("/")
717 else:
718 redirect_scope["path"] = redirect_scope["path"] + "/"
720 for route in self.routes:
721 match, child_scope = route.matches(redirect_scope)
722 if match != Match.NONE:
723 redirect_url = URL(scope=redirect_scope)
724 response = RedirectResponse(url=str(redirect_url))
725 await response(scope, receive, send)
726 return
728 await self.default(scope, receive, send)
730 def __eq__(self, other: Any) -> bool:
731 return isinstance(other, Router) and self.routes == other.routes
733 def mount(self, path: str, app: ASGIApp, name: str | None = None) -> None: # pragma: no cover
734 route = Mount(path, app=app, name=name)
735 self.routes.append(route)
737 def host(self, host: str, app: ASGIApp, name: str | None = None) -> None: # pragma: no cover
738 route = Host(host, app=app, name=name)
739 self.routes.append(route)
741 def add_route(
742 self,
743 path: str,
744 endpoint: Callable[[Request], Awaitable[Response] | Response],
745 methods: Collection[str] | None = None,
746 name: str | None = None,
747 include_in_schema: bool = True,
748 ) -> None: # pragma: no cover
749 route = Route(
750 path,
751 endpoint=endpoint,
752 methods=methods,
753 name=name,
754 include_in_schema=include_in_schema,
755 )
756 self.routes.append(route)
758 def add_websocket_route(
759 self,
760 path: str,
761 endpoint: Callable[[WebSocket], Awaitable[None]],
762 name: str | None = None,
763 ) -> None: # pragma: no cover
764 route = WebSocketRoute(path, endpoint=endpoint, name=name)
765 self.routes.append(route)