From a4c4dbe96972a6f0339f30d7d794932f70510eea Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Thu, 3 Feb 2022 10:44:50 -0600 Subject: [PATCH] refactor!: decouple Xpresso Router from Starlette Router (#42) --- pyproject.toml | 2 +- xpresso/_utils/deprecation.py | 12 ------- xpresso/_utils/routing.py | 13 ++++--- xpresso/applications.py | 16 ++++----- xpresso/routing/router.py | 66 +++++++++++++++++++++++------------ 5 files changed, 58 insertions(+), 51 deletions(-) delete mode 100644 xpresso/_utils/deprecation.py diff --git a/pyproject.toml b/pyproject.toml index 3e62b6f9..2224ac0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "xpresso" -version = "0.10.1" +version = "0.11.0" description = "A developer centric, performant Python web framework" authors = ["Adrian Garcia Badaracco "] readme = "README.md" diff --git a/xpresso/_utils/deprecation.py b/xpresso/_utils/deprecation.py deleted file mode 100644 index eb874141..00000000 --- a/xpresso/_utils/deprecation.py +++ /dev/null @@ -1,12 +0,0 @@ -import typing - - -def not_supported(method: str) -> typing.Callable[..., typing.Any]: - """Marks a method as not supported - Used to hard-deprecate things from Starlette - """ - - def raise_error(*args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn: - raise NotImplementedError(f"Use of {method} is not supported") - - return raise_error diff --git a/xpresso/_utils/routing.py b/xpresso/_utils/routing.py index f9e6e282..17434432 100644 --- a/xpresso/_utils/routing.py +++ b/xpresso/_utils/routing.py @@ -11,12 +11,15 @@ from starlette.routing import Router as StarletteRouter from xpresso.routing.pathitem import Path +from xpresso.routing.router import Router as XpressoRouter from xpresso.routing.websockets import WebSocketRoute +Router = typing.Union[XpressoRouter, StarletteRouter] + class App(Protocol): @property - def router(self) -> StarletteRouter: + def router(self) -> XpressoRouter: ... @@ -26,21 +29,21 @@ def router(self) -> StarletteRouter: @dataclass(frozen=True) class VisitedRoute(typing.Generic[AppType]): path: str - nodes: typing.List[typing.Union[StarletteRouter, AppType]] + nodes: typing.List[typing.Union[Router, AppType]] route: BaseRoute def visit_routes( app_type: typing.Type[AppType], - router: StarletteRouter, - nodes: typing.List[typing.Union[StarletteRouter, AppType]], + router: Router, + nodes: typing.List[typing.Union[Router, AppType]], path: str, ) -> typing.Generator[VisitedRoute[AppType], None, None]: for route in typing.cast(typing.Iterable[BaseRoute], router.routes): # type: ignore # for Pylance if isinstance(route, Mount): app: typing.Any = route.app mount_path: str = route.path # type: ignore # for Pylance - if isinstance(app, StarletteRouter): + if isinstance(app, (StarletteRouter, XpressoRouter)): yield VisitedRoute( path=path, nodes=nodes + [app], diff --git a/xpresso/applications.py b/xpresso/applications.py index ffbb5240..cdf0d580 100644 --- a/xpresso/applications.py +++ b/xpresso/applications.py @@ -207,15 +207,11 @@ def _setup(self) -> typing.List[typing.Callable[..., typing.AsyncIterator[None]] for node in route.nodes: if isinstance(node, Router): dependencies.extend(node.dependencies) - if node is not self.router: # avoid circul lifespan calls - lifespan = typing.cast( - typing.Callable[..., typing.AsyncContextManager[None]], - node.lifespan_context, # type: ignore # for Pylance + # avoid circular lifespan calls + if node is not self.router and node.lifespan is not None: + lifespans.append( + _wrap_lifespan_as_async_generator(node.lifespan) ) - if lifespan is not None: - lifespans.append( - _wrap_lifespan_as_async_generator(lifespan) - ) if isinstance(route.route, Path): for operation in route.route.operations.values(): operation.solve( @@ -236,7 +232,7 @@ def _setup(self) -> typing.List[typing.Callable[..., typing.AsyncIterator[None]] ) return lifespans - async def get_openapi(self) -> openapi_models.OpenAPI: + def get_openapi(self) -> openapi_models.OpenAPI: return genrate_openapi( visitor=visit_routes( app_type=App, router=self.router, nodes=[self, self.router], path="" @@ -259,7 +255,7 @@ def _get_doc_routes( async def openapi(req: Request) -> JSONResponse: if self.openapi is None: - self.openapi = await self.get_openapi() + self.openapi = self.get_openapi() res = JSONResponse(self.openapi.dict(exclude_none=True, by_alias=True)) return res diff --git a/xpresso/routing/router.py b/xpresso/routing/router.py index 77ae06d0..43bccb4a 100644 --- a/xpresso/routing/router.py +++ b/xpresso/routing/router.py @@ -1,18 +1,45 @@ +import sys import typing +if sys.version_info < (3, 8): + from typing_extensions import Protocol +else: + from typing import Protocol + import starlette.middleware from starlette.routing import BaseRoute from starlette.routing import Router as StarletteRouter -from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.types import Receive, Scope, Send -from xpresso._utils.deprecation import not_supported from xpresso.dependencies.models import Dependant from xpresso.responses import Responses -class Router(StarletteRouter): - routes: typing.List[BaseRoute] - _app: ASGIApp +class _ASGIApp(Protocol): + def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> typing.Awaitable[None]: + ... + + +_MiddlewareIterator = typing.Iterable[ + typing.Tuple[typing.Callable[..., _ASGIApp], typing.Mapping[str, typing.Any]] +] + + +class Router: + routes: typing.Sequence[BaseRoute] + lifespan: typing.Optional[ + typing.Callable[..., typing.AsyncContextManager[None]] + ] = None + dependencies: typing.Sequence[Dependant] + tags: typing.Sequence[str] + responses: Responses + include_in_schema: bool + _app: _ASGIApp def __init__( self, @@ -25,26 +52,28 @@ def __init__( typing.Callable[..., typing.AsyncContextManager[None]] ] = None, redirect_slashes: bool = True, - default: typing.Optional[ASGIApp] = None, - dependencies: typing.Optional[typing.List[Dependant]] = None, + default: typing.Optional[_ASGIApp] = None, + dependencies: typing.Optional[typing.Sequence[Dependant]] = None, tags: typing.Optional[typing.List[str]] = None, responses: typing.Optional[Responses] = None, include_in_schema: bool = True, ) -> None: - super().__init__( # type: ignore - routes=list(routes), + self.routes = list(routes) + self.lifespan = lifespan + self._router = StarletteRouter( + routes=self.routes, redirect_slashes=redirect_slashes, - default=default, # type: ignore - lifespan=lifespan, # type: ignore + default=default, # type: ignore[arg-type] + lifespan=lifespan, # type: ignore[arg-type] ) self.dependencies = list(dependencies or []) self.tags = list(tags or []) self.responses = dict(responses or {}) self.include_in_schema = include_in_schema - self._app = super().__call__ # type: ignore[assignment,misc] + self._app = self._router.__call__ if middleware is not None: - for cls, options in reversed(middleware): # type: ignore # for Pylance - self._app = cls(app=self._app, **options) # type: ignore[assignment,misc] + for cls, options in typing.cast(_MiddlewareIterator, reversed(middleware)): + self._app = cls(app=self._app, **options) async def __call__( self, @@ -53,12 +82,3 @@ async def __call__( send: Send, ) -> None: await self._app(scope, receive, send) # type: ignore[arg-type,call-arg,misc] - - mount = not_supported("mount") - host = not_supported("host") - add_route = not_supported("add_route") - add_websocket_route = not_supported("add_websocket_route") - route = not_supported("route") - websocket_route = not_supported("websocket_route") - add_event_handler = not_supported("add_event_handler") - on_event = not_supported("on_event")