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
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:].split(":")[0]
158 path_regex += re.escape(hostname) + "$"
159 else:
160 path_regex += re.escape(path[idx:]) + "$"
162 path_format += path[idx:]
164 return re.compile(path_regex), path_format, param_convertors
167class BaseRoute:
168 def matches(self, scope: Scope) -> tuple[Match, Scope]:
169 raise NotImplementedError() # pragma: no cover
171 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
172 raise NotImplementedError() # pragma: no cover
174 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
175 raise NotImplementedError() # pragma: no cover
177 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
178 """
179 A route may be used in isolation as a stand-alone ASGI app.
180 This is a somewhat contrived case, as they'll almost always be used
181 within a Router, but could be useful for some tooling and minimal apps.
182 """
183 match, child_scope = self.matches(scope)
184 if match == Match.NONE:
185 if scope["type"] == "http":
186 response = PlainTextResponse("Not Found", status_code=404)
187 await response(scope, receive, send)
188 elif scope["type"] == "websocket": # pragma: no branch
189 websocket_close = WebSocketClose()
190 await websocket_close(scope, receive, send)
191 return
193 scope.update(child_scope)
194 await self.handle(scope, receive, send)
197class Route(BaseRoute):
198 def __init__(
199 self,
200 path: str,
201 endpoint: Callable[..., Any],
202 *,
203 methods: Collection[str] | None = None,
204 name: str | None = None,
205 include_in_schema: bool = True,
206 middleware: Sequence[Middleware] | None = None,
207 max_body_size: int | None = None,
208 ) -> None:
209 assert path.startswith("/"), "Routed paths must start with '/'"
210 self.path = path
211 self.endpoint = endpoint
212 self.name = get_name(endpoint) if name is None else name
213 self.include_in_schema = include_in_schema
215 endpoint_handler = endpoint
216 while isinstance(endpoint_handler, functools.partial):
217 endpoint_handler = endpoint_handler.func
218 if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
219 # Endpoint is function or method. Treat it as `func(request) -> response`.
220 self.app = request_response(endpoint)
221 if methods is None:
222 methods = ["GET"]
223 else:
224 # Endpoint is a class. Treat it as ASGI.
225 self.app = endpoint
227 if middleware is not None:
228 for cls, args, kwargs in reversed(middleware):
229 self.app = cls(self.app, *args, **kwargs)
230 if max_body_size is not None:
231 self.app = RequestBodyLimitMiddleware(self.app, max_body_size=max_body_size)
233 if methods is None:
234 self.methods = None
235 else:
236 self.methods = {method.upper() for method in methods}
237 if "GET" in self.methods:
238 self.methods.add("HEAD")
240 self.path_regex, self.path_format, self.param_convertors = compile_path(path)
242 def matches(self, scope: Scope) -> tuple[Match, Scope]:
243 path_params: dict[str, Any]
244 if scope["type"] == "http":
245 route_path = get_route_path(scope)
246 match = self.path_regex.match(route_path)
247 if match:
248 matched_params = match.groupdict()
249 for key, value in matched_params.items():
250 matched_params[key] = self.param_convertors[key].convert(value)
251 path_params = dict(scope.get("path_params", {}))
252 path_params.update(matched_params)
253 child_scope = {"endpoint": self.endpoint, "path_params": path_params}
254 if self.methods and scope["method"] not in self.methods:
255 return Match.PARTIAL, child_scope
256 else:
257 return Match.FULL, child_scope
258 return Match.NONE, {}
260 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
261 seen_params = set(path_params.keys())
262 expected_params = set(self.param_convertors.keys())
264 if name != self.name or seen_params != expected_params:
265 raise NoMatchFound(name, path_params)
267 path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
268 assert not remaining_params
269 return URLPath(path=path, protocol="http")
271 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
272 if self.methods and scope["method"] not in self.methods:
273 headers = {"Allow": ", ".join(self.methods)}
274 if "app" in scope:
275 raise HTTPException(status_code=405, headers=headers)
276 else:
277 response = PlainTextResponse("Method Not Allowed", status_code=405, headers=headers)
278 await response(scope, receive, send)
279 else:
280 await self.app(scope, receive, send)
282 def __eq__(self, other: Any) -> bool:
283 return (
284 isinstance(other, Route)
285 and self.path == other.path
286 and self.endpoint == other.endpoint
287 and self.methods == other.methods
288 )
290 def __repr__(self) -> str:
291 class_name = self.__class__.__name__
292 methods = sorted(self.methods or [])
293 path, name = self.path, self.name
294 return f"{class_name}(path={path!r}, name={name!r}, methods={methods!r})"
297class WebSocketRoute(BaseRoute):
298 def __init__(
299 self,
300 path: str,
301 endpoint: Callable[..., Any],
302 *,
303 name: str | None = None,
304 middleware: Sequence[Middleware] | None = None,
305 ) -> None:
306 assert path.startswith("/"), "Routed paths must start with '/'"
307 self.path = path
308 self.endpoint = endpoint
309 self.name = get_name(endpoint) if name is None else name
311 endpoint_handler = endpoint
312 while isinstance(endpoint_handler, functools.partial):
313 endpoint_handler = endpoint_handler.func
314 if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
315 # Endpoint is function or method. Treat it as `func(websocket)`.
316 self.app = websocket_session(endpoint)
317 else:
318 # Endpoint is a class. Treat it as ASGI.
319 self.app = endpoint
321 if middleware is not None:
322 for cls, args, kwargs in reversed(middleware):
323 self.app = cls(self.app, *args, **kwargs)
325 self.path_regex, self.path_format, self.param_convertors = compile_path(path)
327 def matches(self, scope: Scope) -> tuple[Match, Scope]:
328 path_params: dict[str, Any]
329 if scope["type"] == "websocket":
330 route_path = get_route_path(scope)
331 match = self.path_regex.match(route_path)
332 if match:
333 matched_params = match.groupdict()
334 for key, value in matched_params.items():
335 matched_params[key] = self.param_convertors[key].convert(value)
336 path_params = dict(scope.get("path_params", {}))
337 path_params.update(matched_params)
338 child_scope = {"endpoint": self.endpoint, "path_params": path_params}
339 return Match.FULL, child_scope
340 return Match.NONE, {}
342 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
343 seen_params = set(path_params.keys())
344 expected_params = set(self.param_convertors.keys())
346 if name != self.name or seen_params != expected_params:
347 raise NoMatchFound(name, path_params)
349 path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
350 assert not remaining_params
351 return URLPath(path=path, protocol="websocket")
353 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
354 await self.app(scope, receive, send)
356 def __eq__(self, other: Any) -> bool:
357 return isinstance(other, WebSocketRoute) and self.path == other.path and self.endpoint == other.endpoint
359 def __repr__(self) -> str:
360 return f"{self.__class__.__name__}(path={self.path!r}, name={self.name!r})"
363class Mount(BaseRoute):
364 def __init__(
365 self,
366 path: str,
367 app: ASGIApp | None = None,
368 routes: Sequence[BaseRoute] | None = None,
369 name: str | None = None,
370 *,
371 middleware: Sequence[Middleware] | None = None,
372 max_body_size: int | None = None,
373 ) -> None:
374 assert path == "" or path.startswith("/"), "Routed paths must start with '/'"
375 assert app is not None or routes is not None, "Either 'app=...', or 'routes=' must be specified"
376 self.path = path.rstrip("/")
377 if app is not None:
378 self._base_app: ASGIApp = app
379 else:
380 self._base_app = Router(routes=routes)
381 self.app = self._base_app
382 if middleware is not None:
383 for cls, args, kwargs in reversed(middleware):
384 self.app = cls(self.app, *args, **kwargs)
385 if max_body_size is not None:
386 self.app = RequestBodyLimitMiddleware(self.app, max_body_size=max_body_size)
387 self.name = name
388 self.path_regex, self.path_format, self.param_convertors = compile_path(self.path + "/{path:path}")
390 @property
391 def routes(self) -> list[BaseRoute]:
392 return getattr(self._base_app, "routes", [])
394 def matches(self, scope: Scope) -> tuple[Match, Scope]:
395 path_params: dict[str, Any]
396 if scope["type"] in ("http", "websocket"): # pragma: no branch
397 root_path = scope.get("root_path", "")
398 route_path = get_route_path(scope)
399 match = self.path_regex.match(route_path)
400 if match:
401 matched_params = match.groupdict()
402 for key, value in matched_params.items():
403 matched_params[key] = self.param_convertors[key].convert(value)
404 remaining_path = "/" + matched_params.pop("path")
405 matched_path = route_path[: -len(remaining_path)]
406 path_params = dict(scope.get("path_params", {}))
407 path_params.update(matched_params)
408 child_scope = {
409 "path_params": path_params,
410 # app_root_path will only be set at the top level scope,
411 # initialized with the (optional) value of a root_path
412 # set above/before Starlette. And even though any
413 # mount will have its own child scope with its own respective
414 # root_path, the app_root_path will always be available in all
415 # the child scopes with the same top level value because it's
416 # set only once here with a default, any other child scope will
417 # just inherit that app_root_path default value stored in the
418 # scope. All this is needed to support Request.url_for(), as it
419 # uses the app_root_path to build the URL path.
420 "app_root_path": scope.get("app_root_path", root_path),
421 "root_path": root_path + matched_path,
422 "endpoint": self.app,
423 }
424 return Match.FULL, child_scope
425 return Match.NONE, {}
427 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
428 if self.name is not None and name == self.name and "path" in path_params:
429 # 'name' matches "<mount_name>".
430 path_params["path"] = path_params["path"].lstrip("/")
431 path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
432 if not remaining_params:
433 return URLPath(path=path)
434 elif self.name is None or name.startswith(self.name + ":"):
435 if self.name is None:
436 # No mount name.
437 remaining_name = name
438 else:
439 # 'name' matches "<mount_name>:<child_name>".
440 remaining_name = name[len(self.name) + 1 :]
441 path_kwarg = path_params.get("path")
442 path_params["path"] = ""
443 path_prefix, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
444 if path_kwarg is not None:
445 remaining_params["path"] = path_kwarg
446 for route in self.routes or []:
447 try:
448 url = route.url_path_for(remaining_name, **remaining_params)
449 return URLPath(path=path_prefix.rstrip("/") + str(url), protocol=url.protocol)
450 except NoMatchFound:
451 pass
452 raise NoMatchFound(name, path_params)
454 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
455 await self.app(scope, receive, send)
457 def __eq__(self, other: Any) -> bool:
458 return isinstance(other, Mount) and self.path == other.path and self.app == other.app
460 def __repr__(self) -> str:
461 class_name = self.__class__.__name__
462 name = self.name or ""
463 return f"{class_name}(path={self.path!r}, name={name!r}, app={self.app!r})"
466class Host(BaseRoute):
467 def __init__(self, host: str, app: ASGIApp, name: str | None = None) -> None:
468 assert not host.startswith("/"), "Host must not start with '/'"
469 self.host = host
470 self.app = app
471 self.name = name
472 self.host_regex, self.host_format, self.param_convertors = compile_path(host)
474 @property
475 def routes(self) -> list[BaseRoute]:
476 return getattr(self.app, "routes", [])
478 def matches(self, scope: Scope) -> tuple[Match, Scope]:
479 if scope["type"] in ("http", "websocket"): # pragma:no branch
480 headers = Headers(scope=scope)
481 host = headers.get("host", "").split(":")[0]
482 match = self.host_regex.match(host)
483 if match:
484 matched_params = match.groupdict()
485 for key, value in matched_params.items():
486 matched_params[key] = self.param_convertors[key].convert(value)
487 path_params = dict(scope.get("path_params", {}))
488 path_params.update(matched_params)
489 child_scope = {"path_params": path_params, "endpoint": self.app}
490 return Match.FULL, child_scope
491 return Match.NONE, {}
493 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
494 if self.name is not None and name == self.name and "path" in path_params:
495 # 'name' matches "<mount_name>".
496 path = path_params.pop("path")
497 host, remaining_params = replace_params(self.host_format, self.param_convertors, path_params)
498 if not remaining_params:
499 return URLPath(path=path, host=host)
500 elif self.name is None or name.startswith(self.name + ":"):
501 if self.name is None:
502 # No mount name.
503 remaining_name = name
504 else:
505 # 'name' matches "<mount_name>:<child_name>".
506 remaining_name = name[len(self.name) + 1 :]
507 host, remaining_params = replace_params(self.host_format, self.param_convertors, path_params)
508 for route in self.routes or []:
509 try:
510 url = route.url_path_for(remaining_name, **remaining_params)
511 return URLPath(path=str(url), protocol=url.protocol, host=host)
512 except NoMatchFound:
513 pass
514 raise NoMatchFound(name, path_params)
516 async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
517 await self.app(scope, receive, send)
519 def __eq__(self, other: Any) -> bool:
520 return isinstance(other, Host) and self.host == other.host and self.app == other.app
522 def __repr__(self) -> str:
523 class_name = self.__class__.__name__
524 name = self.name or ""
525 return f"{class_name}(host={self.host!r}, name={name!r}, app={self.app!r})"
528_T = TypeVar("_T")
531class _AsyncLiftContextManager(AbstractAsyncContextManager[_T]):
532 def __init__(self, cm: AbstractContextManager[_T]):
533 self._cm = cm
535 async def __aenter__(self) -> _T:
536 return self._cm.__enter__()
538 async def __aexit__(
539 self,
540 exc_type: type[BaseException] | None,
541 exc_value: BaseException | None,
542 traceback: types.TracebackType | None,
543 ) -> bool | None:
544 return self._cm.__exit__(exc_type, exc_value, traceback)
547def _wrap_gen_lifespan_context(
548 lifespan_context: Callable[[Any], Generator[Any, Any, Any]],
549) -> Callable[[Any], AbstractAsyncContextManager[Any]]:
550 cmgr = contextlib.contextmanager(lifespan_context)
552 @functools.wraps(cmgr)
553 def wrapper(app: Any) -> _AsyncLiftContextManager[Any]:
554 return _AsyncLiftContextManager(cmgr(app))
556 return wrapper
559class _DefaultLifespan:
560 def __init__(self, router: Router):
561 self._router = router
563 async def __aenter__(self) -> None:
564 pass
566 async def __aexit__(self, *exc_info: object) -> None:
567 pass
569 def __call__(self: _T, app: object) -> _T:
570 return self
573class Router:
574 def __init__(
575 self,
576 routes: Sequence[BaseRoute] | None = None,
577 redirect_slashes: bool = True,
578 default: ASGIApp | None = None,
579 # the generic to Lifespan[AppType] is the type of the top level application
580 # which the router cannot know statically, so we use Any
581 lifespan: Lifespan[Any] | None = None,
582 *,
583 middleware: Sequence[Middleware] | None = None,
584 max_body_size: int | None = None,
585 ) -> None:
586 self.routes = [] if routes is None else list(routes)
587 self.redirect_slashes = redirect_slashes
588 self.default = self.not_found if default is None else default
590 if lifespan is None:
591 self.lifespan_context: Lifespan[Any] = _DefaultLifespan(self)
593 elif inspect.isasyncgenfunction(lifespan):
594 warnings.warn(
595 "async generator function lifespans are deprecated, "
596 "use an @contextlib.asynccontextmanager function instead",
597 StarletteDeprecationWarning,
598 )
599 self.lifespan_context = asynccontextmanager(lifespan)
600 elif inspect.isgeneratorfunction(lifespan):
601 warnings.warn(
602 "generator function lifespans are deprecated, use an @contextlib.asynccontextmanager function instead",
603 StarletteDeprecationWarning,
604 )
605 self.lifespan_context = _wrap_gen_lifespan_context(lifespan)
606 else:
607 self.lifespan_context = lifespan
609 self.middleware_stack = self.app
610 if middleware:
611 for cls, args, kwargs in reversed(middleware):
612 self.middleware_stack = cls(self.middleware_stack, *args, **kwargs)
613 if max_body_size is not None:
614 self.middleware_stack = RequestBodyLimitMiddleware(self.middleware_stack, max_body_size=max_body_size)
616 async def not_found(self, scope: Scope, receive: Receive, send: Send) -> None:
617 if scope["type"] == "websocket":
618 websocket_close = WebSocketClose()
619 await websocket_close(scope, receive, send)
620 return
622 # If we're running inside a starlette application then raise an
623 # exception, so that the configurable exception handler can deal with
624 # returning the response. For plain ASGI apps, just return the response.
625 if "app" in scope:
626 raise HTTPException(status_code=404)
627 else:
628 response = PlainTextResponse("Not Found", status_code=404)
629 await response(scope, receive, send)
631 def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
632 for route in self.routes:
633 try:
634 return route.url_path_for(name, **path_params)
635 except NoMatchFound:
636 pass
637 raise NoMatchFound(name, path_params)
639 async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
640 """
641 Handle ASGI lifespan messages, which allows us to manage application
642 startup and shutdown events.
643 """
644 started = False
645 app: Any = scope.get("app")
646 await receive()
647 try:
648 async with self.lifespan_context(app) as maybe_state:
649 if maybe_state is not None:
650 if "state" not in scope:
651 raise RuntimeError('The server does not support "state" in the lifespan scope.')
652 scope["state"].update(maybe_state)
653 await send({"type": "lifespan.startup.complete"})
654 started = True
655 await receive()
656 except BaseException:
657 exc_text = traceback.format_exc()
658 if started:
659 await send({"type": "lifespan.shutdown.failed", "message": exc_text})
660 else:
661 await send({"type": "lifespan.startup.failed", "message": exc_text})
662 raise
663 else:
664 await send({"type": "lifespan.shutdown.complete"})
666 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
667 """
668 The main entry point to the Router class.
669 """
670 await self.middleware_stack(scope, receive, send)
672 async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
673 assert scope["type"] in ("http", "websocket", "lifespan")
675 if "router" not in scope:
676 scope["router"] = self
678 if scope["type"] == "lifespan":
679 await self.lifespan(scope, receive, send)
680 return
682 partial = None
684 for route in self.routes:
685 # Determine if any route matches the incoming scope,
686 # and hand over to the matching route if found.
687 match, child_scope = route.matches(scope)
688 if match == Match.FULL:
689 scope.update(child_scope)
690 await route.handle(scope, receive, send)
691 return
692 elif match == Match.PARTIAL and partial is None:
693 partial = route
694 partial_scope = child_scope
696 if partial is not None:
697 # Handle partial matches. These are cases where an endpoint is
698 # able to handle the request, but is not a preferred option.
699 # We use this in particular to deal with "405 Method Not Allowed".
700 scope.update(partial_scope)
701 await partial.handle(scope, receive, send)
702 return
704 route_path = get_route_path(scope)
705 if scope["type"] == "http" and self.redirect_slashes and route_path != "/":
706 redirect_scope = dict(scope)
707 if route_path.endswith("/"):
708 redirect_scope["path"] = redirect_scope["path"].rstrip("/")
709 else:
710 redirect_scope["path"] = redirect_scope["path"] + "/"
712 for route in self.routes:
713 match, child_scope = route.matches(redirect_scope)
714 if match != Match.NONE:
715 redirect_url = URL(scope=redirect_scope)
716 response = RedirectResponse(url=str(redirect_url))
717 await response(scope, receive, send)
718 return
720 await self.default(scope, receive, send)
722 def __eq__(self, other: Any) -> bool:
723 return isinstance(other, Router) and self.routes == other.routes
725 def mount(self, path: str, app: ASGIApp, name: str | None = None) -> None: # pragma: no cover
726 route = Mount(path, app=app, name=name)
727 self.routes.append(route)
729 def host(self, host: str, app: ASGIApp, name: str | None = None) -> None: # pragma: no cover
730 route = Host(host, app=app, name=name)
731 self.routes.append(route)
733 def add_route(
734 self,
735 path: str,
736 endpoint: Callable[[Request], Awaitable[Response] | Response],
737 methods: Collection[str] | None = None,
738 name: str | None = None,
739 include_in_schema: bool = True,
740 ) -> None: # pragma: no cover
741 route = Route(
742 path,
743 endpoint=endpoint,
744 methods=methods,
745 name=name,
746 include_in_schema=include_in_schema,
747 )
748 self.routes.append(route)
750 def add_websocket_route(
751 self,
752 path: str,
753 endpoint: Callable[[WebSocket], Awaitable[None]],
754 name: str | None = None,
755 ) -> None: # pragma: no cover
756 route = WebSocketRoute(path, endpoint=endpoint, name=name)
757 self.routes.append(route)