From dd11c69b5b115bc97b7139c59fbd1dd555c4fbde Mon Sep 17 00:00:00 2001 From: erm Date: Mon, 30 Jul 2018 19:42:50 +1000 Subject: [PATCH 1/7] Renaming asgi_application, implementing CBV pattern, add_route method on router --- starlette/__init__.py | 4 ++-- starlette/decorators.py | 3 +-- starlette/routing.py | 10 +++++++++- starlette/views.py | 26 ++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 starlette/views.py diff --git a/starlette/__init__.py b/starlette/__init__.py index 5eef5c96d..d4fc9342f 100644 --- a/starlette/__init__.py +++ b/starlette/__init__.py @@ -1,4 +1,4 @@ -from starlette.decorators import asgi_application +from starlette.decorators import make_asgi from starlette.response import ( FileResponse, HTMLResponse, @@ -12,7 +12,7 @@ __all__ = ( - "asgi_application", + "make_asgi", "FileResponse", "HTMLResponse", "JSONResponse", diff --git a/starlette/decorators.py b/starlette/decorators.py index 6328c3bae..03d48b43c 100644 --- a/starlette/decorators.py +++ b/starlette/decorators.py @@ -1,10 +1,9 @@ import asyncio from starlette.request import Request -from starlette.response import Response from starlette.types import ASGIInstance, Receive, Send, Scope -def asgi_application(func): +def make_asgi(func): is_coroutine = asyncio.iscoroutinefunction(func) def app(scope: Scope) -> ASGIInstance: diff --git a/starlette/routing.py b/starlette/routing.py index 4c47f3539..ed8528acc 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -69,7 +69,9 @@ def __call__(self, scope: Scope) -> ASGIInstance: class Router: - def __init__(self, routes: typing.List[Route], default: ASGIApp = None) -> None: + def __init__( + self, routes: typing.List[Route] = [], default: ASGIApp = None + ) -> None: self.routes = routes self.default = self.not_found if default is None else default @@ -80,5 +82,11 @@ def __call__(self, scope: Scope) -> ASGIInstance: return route(child_scope) return self.not_found(scope) + def add_route( + self, path: str, *, app: ASGIApp, methods: typing.Sequence[str] = () + ) -> None: + route = Path(path=path, app=app, methods=methods) + self.routes.append(route) + def not_found(self, scope: Scope) -> ASGIInstance: return Response("Not found", 404, media_type="text/plain") diff --git a/starlette/views.py b/starlette/views.py new file mode 100644 index 000000000..f4a0f9ad5 --- /dev/null +++ b/starlette/views.py @@ -0,0 +1,26 @@ +import typing +from functools import update_wrapper +from starlette.decorators import make_asgi + + +class View: + def dispatch(self, request): + request_method = request.method if request.method != "HEAD" else "GET" + func = getattr(self, request_method.lower(), None) + if func is None: + raise Exception( + f"Method {request_method} is not implemented for this view." + ) + return func(request) + + @classmethod + def as_view(cls) -> typing.Callable: + def view(scope): + self = cls() + return make_asgi(self.dispatch)(scope) + + view.view_class = cls + update_wrapper(view, cls, updated=()) + update_wrapper(view, cls.dispatch, assigned=()) + + return view From 853e7cb9f879f4feaa45f6832955a39f338c70e9 Mon Sep 17 00:00:00 2001 From: erm Date: Mon, 30 Jul 2018 19:59:00 +1000 Subject: [PATCH 2/7] Refactor view to allow both sync/async methods --- starlette/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/starlette/views.py b/starlette/views.py index f4a0f9ad5..67b86f5fe 100644 --- a/starlette/views.py +++ b/starlette/views.py @@ -4,20 +4,20 @@ class View: - def dispatch(self, request): - request_method = request.method if request.method != "HEAD" else "GET" + def dispatch(self, scope): + request_method = scope["method"] if scope["method"] != "HEAD" else "GET" func = getattr(self, request_method.lower(), None) if func is None: raise Exception( f"Method {request_method} is not implemented for this view." ) - return func(request) + return make_asgi(func)(scope) @classmethod def as_view(cls) -> typing.Callable: def view(scope): self = cls() - return make_asgi(self.dispatch)(scope) + return self.dispatch(scope) view.view_class = cls update_wrapper(view, cls, updated=()) From 663e979b6acec4c7bc5fc933139e44d80a7abbfa Mon Sep 17 00:00:00 2001 From: erm Date: Mon, 30 Jul 2018 20:12:46 +1000 Subject: [PATCH 3/7] Type hints for CBV --- starlette/views.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/starlette/views.py b/starlette/views.py index 67b86f5fe..9a269ec22 100644 --- a/starlette/views.py +++ b/starlette/views.py @@ -1,10 +1,10 @@ -import typing from functools import update_wrapper from starlette.decorators import make_asgi +from starlette.types import ASGIApp, ASGIInstance, Scope class View: - def dispatch(self, scope): + def dispatch(self, scope: Scope) -> ASGIInstance: request_method = scope["method"] if scope["method"] != "HEAD" else "GET" func = getattr(self, request_method.lower(), None) if func is None: @@ -14,13 +14,12 @@ def dispatch(self, scope): return make_asgi(func)(scope) @classmethod - def as_view(cls) -> typing.Callable: - def view(scope): + def as_view(cls) -> ASGIApp: + def view(scope: Scope): self = cls() return self.dispatch(scope) view.view_class = cls update_wrapper(view, cls, updated=()) update_wrapper(view, cls.dispatch, assigned=()) - return view From 50e66e846ea5e007b401650c24254276e5874b53 Mon Sep 17 00:00:00 2001 From: erm Date: Sat, 18 Aug 2018 02:45:42 +1000 Subject: [PATCH 4/7] Implement asgi decorator method directly in view class, remove classmethod --- starlette/views.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/starlette/views.py b/starlette/views.py index 9a269ec22..82a9e6217 100644 --- a/starlette/views.py +++ b/starlette/views.py @@ -1,9 +1,13 @@ -from functools import update_wrapper -from starlette.decorators import make_asgi -from starlette.types import ASGIApp, ASGIInstance, Scope +import asyncio + +from starlette.request import Request +from starlette.types import ASGIApp, ASGIInstance, Receive, Send, Scope class View: + def __call__(self, scope: Scope) -> ASGIApp: + return self.dispatch(scope) + def dispatch(self, scope: Scope) -> ASGIInstance: request_method = scope["method"] if scope["method"] != "HEAD" else "GET" func = getattr(self, request_method.lower(), None) @@ -11,15 +15,14 @@ def dispatch(self, scope: Scope) -> ASGIInstance: raise Exception( f"Method {request_method} is not implemented for this view." ) - return make_asgi(func)(scope) + is_coroutine = asyncio.iscoroutinefunction(func) - @classmethod - def as_view(cls) -> ASGIApp: - def view(scope: Scope): - self = cls() - return self.dispatch(scope) + async def awaitable(receive: Receive, send: Send) -> None: + request = Request(scope, receive) + if is_coroutine: + response = await func(request) + else: + response = func(request) + await response(receive, send) - view.view_class = cls - update_wrapper(view, cls, updated=()) - update_wrapper(view, cls.dispatch, assigned=()) - return view + return awaitable From b865efc8e3bd46c4cfd3c5e72cc6b7df4f7715f3 Mon Sep 17 00:00:00 2001 From: erm Date: Thu, 30 Aug 2018 00:03:58 +1000 Subject: [PATCH 5/7] Refactor CBV, remove router add_route method in favor of App.add_route method, tests, documentation --- docs/views.md | 32 ++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + starlette/routing.py | 10 +--------- starlette/views.py | 19 ++++++------------- 4 files changed, 40 insertions(+), 22 deletions(-) create mode 100644 docs/views.md diff --git a/docs/views.md b/docs/views.md new file mode 100644 index 000000000..d276e0a56 --- /dev/null +++ b/docs/views.md @@ -0,0 +1,32 @@ + +Starlette includes a `View` class that provides a class-based view pattern which +handles HTTP method dispatching and provides additional structure for HTTP views. + +```python +from starlette import PlainTextResponse +from starlette.app import App +from starlette.views import View + + +app = App() + + +class HomepageView(View): + async def get(self, request, **kwargs): + response = PlainTextResponse(f"Hello, world!") + return response + + +class UserView(View): + async def get(self, request, **kwargs): + username = kwargs.get("username") + response = PlainTextResponse(f"Hello, {username}") + return response + + +app.add_route("/", HomepageView()) +app.add_route("/user/{username}", UserView()) +``` + +Class-based views will respond with "404 Not found" or "406 Method not allowed" +responses for requests which do not match. diff --git a/mkdocs.yml b/mkdocs.yml index f80ae1a4a..0b2c0dbd8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ nav: - Applications: 'applications.md' - Test Client: 'test_client.md' - Debugging: 'debugging.md' + - Views: 'views.md' markdown_extensions: - markdown.extensions.codehilite: diff --git a/starlette/routing.py b/starlette/routing.py index 288d7a2f7..4e0cd0516 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -75,9 +75,7 @@ def __call__(self, scope: Scope) -> ASGIInstance: class Router: - def __init__( - self, routes: typing.List[Route] = [], default: ASGIApp = None - ) -> None: + def __init__(self, routes: typing.List[Route], default: ASGIApp = None) -> None: self.routes = routes self.default = self.not_found if default is None else default @@ -88,12 +86,6 @@ def __call__(self, scope: Scope) -> ASGIInstance: return route(child_scope) return self.not_found(scope) - def add_route( - self, path: str, *, app: ASGIApp, methods: typing.Sequence[str] = () - ) -> None: - route = Path(path=path, app=app, methods=methods) - self.routes.append(route) - def not_found(self, scope: Scope) -> ASGIInstance: if scope["type"] == "websocket": diff --git a/starlette/views.py b/starlette/views.py index 82a9e6217..ed20fbd5b 100644 --- a/starlette/views.py +++ b/starlette/views.py @@ -1,28 +1,21 @@ -import asyncio - from starlette.request import Request +from starlette.response import Response from starlette.types import ASGIApp, ASGIInstance, Receive, Send, Scope class View: - def __call__(self, scope: Scope) -> ASGIApp: - return self.dispatch(scope) + def __call__(self, scope: Scope, **kwargs) -> ASGIApp: + return self.dispatch(scope, **kwargs) - def dispatch(self, scope: Scope) -> ASGIInstance: + def dispatch(self, scope: Scope, **kwargs) -> ASGIInstance: request_method = scope["method"] if scope["method"] != "HEAD" else "GET" func = getattr(self, request_method.lower(), None) if func is None: - raise Exception( - f"Method {request_method} is not implemented for this view." - ) - is_coroutine = asyncio.iscoroutinefunction(func) + return Response("Not found", 404, media_type="text/plain") async def awaitable(receive: Receive, send: Send) -> None: request = Request(scope, receive) - if is_coroutine: - response = await func(request) - else: - response = func(request) + response = await func(request, **kwargs) await response(receive, send) return awaitable From a9cee8f6235eebaf59f34019f353ba5f39185c65 Mon Sep 17 00:00:00 2001 From: erm Date: Thu, 30 Aug 2018 00:05:13 +1000 Subject: [PATCH 6/7] Include tests --- tests/test_views.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/test_views.py diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 000000000..36ff244b4 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,50 @@ +import pytest +from starlette import App +from starlette.views import View +from starlette.response import PlainTextResponse +from starlette.testclient import TestClient + + +app = App() + + +class HomepageView(View): + async def get(self, request, **kwargs): + username = kwargs.get("username") + if username: + response = PlainTextResponse(f"Hello, {username}!") + else: + response = PlainTextResponse("Hello, world!") + return response + + +app.add_route("/", HomepageView()) +app.add_route("/user/{username}", HomepageView()) +app.add_route("/no-method", View()) + + +client = TestClient(app) + + +def test_route(): + response = client.get("/") + assert response.status_code == 200 + assert response.text == "Hello, world!" + + +def test_route_kwargs(): + response = client.get("/user/tomchristie") + assert response.status_code == 200 + assert response.text == "Hello, tomchristie!" + + +def test_route_method(): + response = client.post("/") + assert response.status_code == 406 + assert response.text == "Method not allowed" + + +def test_method_missing(): + response = client.get("/no-method") + assert response.status_code == 404 + assert response.text == "Not found" From d03e8baec0e1684971e6d377af45bb861bd025cc Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 30 Aug 2018 13:39:50 +0100 Subject: [PATCH 7/7] Add support for class-based views --- docs/views.md | 46 ++++++++++++++++++++++++++++----------------- starlette/app.py | 17 +++++++++++++---- starlette/views.py | 29 ++++++++++++++-------------- tests/test_views.py | 28 ++++++++------------------- 4 files changed, 65 insertions(+), 55 deletions(-) diff --git a/docs/views.md b/docs/views.md index d276e0a56..f2628c8e3 100644 --- a/docs/views.md +++ b/docs/views.md @@ -1,32 +1,44 @@ Starlette includes a `View` class that provides a class-based view pattern which -handles HTTP method dispatching and provides additional structure for HTTP views. +handles HTTP method dispatching. + +The `View` class can be used as an other ASGI application: ```python -from starlette import PlainTextResponse -from starlette.app import App +from starlette.response import PlainTextResponse from starlette.views import View -app = App() +class App(View): + async def get(self, request): + return PlainTextResponse(f"Hello, world!") +``` + +If you're using a Starlette application instance to handle routing, you can +dispatch to a View class by using the `@app.route()` decorator, or the +`app.add_route()` function. Make sure to dispatch to the class itself, rather +than to an instance of the class: +```python +from starlette.app import App +from starlette.response import PlainTextResponse +from starlette.views import View -class HomepageView(View): - async def get(self, request, **kwargs): - response = PlainTextResponse(f"Hello, world!") - return response + +app = App() -class UserView(View): - async def get(self, request, **kwargs): - username = kwargs.get("username") - response = PlainTextResponse(f"Hello, {username}") - return response +@app.route("/") +class Homepage(View): + async def get(self, request): + return PlainTextResponse(f"Hello, world!") -app.add_route("/", HomepageView()) -app.add_route("/user/{username}", UserView()) +@app.route("/{username}") +class User(View): + async def get(self, request, username): + return PlainTextResponse(f"Hello, {username}") ``` -Class-based views will respond with "404 Not found" or "406 Method not allowed" -responses for requests which do not match. +Class-based views will respond with "406 Method not allowed" responses for any +request methods which do not map to a corresponding handler. diff --git a/starlette/app.py b/starlette/app.py index f125e94f1..31bf3494a 100644 --- a/starlette/app.py +++ b/starlette/app.py @@ -3,6 +3,7 @@ from starlette.types import ASGIApp, ASGIInstance, Receive, Scope, Send from starlette.websockets import WebSocketSession import asyncio +import inspect def request_response(func): @@ -52,24 +53,32 @@ def mount(self, path: str, app: ASGIApp): self.router.routes.append(prefix) def add_route(self, path: str, route, methods=None) -> None: - if methods is None: - methods = ["GET"] - instance = Path(path, request_response(route), protocol="http", methods=methods) + if not inspect.isclass(route): + route = request_response(route) + if methods is None: + methods = ["GET"] + + instance = Path(path, route, protocol="http", methods=methods) self.router.routes.append(instance) def add_websocket_route(self, path: str, route) -> None: - instance = Path(path, websocket_session(route), protocol="websocket") + if not inspect.isclass(route): + route = websocket_session(route) + + instance = Path(path, route, protocol="websocket") self.router.routes.append(instance) def route(self, path: str): def decorator(func): self.add_route(path, func) + return func return decorator def websocket_route(self, path: str): def decorator(func): self.add_websocket_route(path, func) + return func return decorator diff --git a/starlette/views.py b/starlette/views.py index ed20fbd5b..7d6dff852 100644 --- a/starlette/views.py +++ b/starlette/views.py @@ -1,21 +1,22 @@ from starlette.request import Request -from starlette.response import Response -from starlette.types import ASGIApp, ASGIInstance, Receive, Send, Scope +from starlette.response import Response, PlainTextResponse +from starlette.types import Receive, Send, Scope class View: - def __call__(self, scope: Scope, **kwargs) -> ASGIApp: - return self.dispatch(scope, **kwargs) + def __init__(self, scope: Scope): + self.scope = scope - def dispatch(self, scope: Scope, **kwargs) -> ASGIInstance: - request_method = scope["method"] if scope["method"] != "HEAD" else "GET" - func = getattr(self, request_method.lower(), None) - if func is None: - return Response("Not found", 404, media_type="text/plain") + async def __call__(self, receive: Receive, send: Send): + request = Request(self.scope, receive=receive) + kwargs = self.scope.get("kwargs", {}) + response = await self.dispatch(request, **kwargs) + await response(receive, send) - async def awaitable(receive: Receive, send: Send) -> None: - request = Request(scope, receive) - response = await func(request, **kwargs) - await response(receive, send) + async def dispatch(self, request: Request, **kwargs) -> Response: + handler_name = "get" if request.method == "HEAD" else request.method.lower() + handler = getattr(self, handler_name, self.method_not_allowed) + return await handler(request, **kwargs) - return awaitable + async def method_not_allowed(self, request: Request, **kwargs) -> Response: + return PlainTextResponse("Method not allowed", 406) diff --git a/tests/test_views.py b/tests/test_views.py index 36ff244b4..c59e2992f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -8,19 +8,13 @@ app = App() -class HomepageView(View): - async def get(self, request, **kwargs): - username = kwargs.get("username") - if username: - response = PlainTextResponse(f"Hello, {username}!") - else: - response = PlainTextResponse("Hello, world!") - return response - - -app.add_route("/", HomepageView()) -app.add_route("/user/{username}", HomepageView()) -app.add_route("/no-method", View()) +@app.route("/") +@app.route("/{username}") +class Homepage(View): + async def get(self, request, username=None): + if username is None: + return PlainTextResponse("Hello, world!") + return PlainTextResponse(f"Hello, {username}!") client = TestClient(app) @@ -33,7 +27,7 @@ def test_route(): def test_route_kwargs(): - response = client.get("/user/tomchristie") + response = client.get("/tomchristie") assert response.status_code == 200 assert response.text == "Hello, tomchristie!" @@ -42,9 +36,3 @@ def test_route_method(): response = client.post("/") assert response.status_code == 406 assert response.text == "Method not allowed" - - -def test_method_missing(): - response = client.get("/no-method") - assert response.status_code == 404 - assert response.text == "Not found"