From 00a972c7196d05d29fb059b7de79aba3808ca74c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 28 Aug 2018 14:34:18 +0100 Subject: [PATCH 1/5] Add App class (#42) * Add App class * Black formatting * Support kwargs in App routes * Black formatting * Use protocol based routing in app * WebSocket close when no route found * session.send(...), session.receive(...) for test client * Support app.mount(prefix, app) * Black formatting * Black formatting * Minor tweaks to websocket test client * Add 'App' to README --- README.md | 47 ++++++++++++++++++++++++- starlette/__init__.py | 2 ++ starlette/app.py | 77 +++++++++++++++++++++++++++++++++++++++++ starlette/routing.py | 30 +++++++++++----- starlette/testclient.py | 43 +++++++++++++---------- tests/test_app.py | 75 +++++++++++++++++++++++++++++++++++++++ tests/test_routing.py | 11 ++++-- 7 files changed, 255 insertions(+), 30 deletions(-) create mode 100644 starlette/app.py create mode 100644 tests/test_app.py diff --git a/README.md b/README.md index 922a4165..3a5c9603 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Starlette is a small library for working with [ASGI](https://asgi.readthedocs.io/en/latest/). -It gives you `Request` and `Response` classes, request routing, websocket support, +It gives you `Request` and `Response` classes, websocket support, routing, static files support, and a test client. **Requirements:** @@ -72,6 +72,7 @@ You can run the application with any ASGI server, including [uvicorn](http://www * [Static Files](#static-files) * [Test Client](#test-client) * [Debugging](#debugging) +* [Applications](#applications) --- @@ -615,4 +616,48 @@ app = DebugMiddleware(App) --- +## Applications + +Starlette also includes an `App` class that nicely ties together all of +its other functionality. + +```python +from starlette import App +from starlette.response import PlainTextResponse +from starlette.staticfiles import StaticFiles + + +app = App() +app.mount("/static", StaticFiles(directory="static")) + + +@app.route('/') +def homepage(request): + return PlainTextResponse('Hello, world!') + + +@app.route('/user/{username}') +def user(request, username): + return PlainTextResponse('Hello, %s!' % username) + + +@app.websocket_route('/ws') +async def websocket_endpoint(session): + await session.accept() + await session.send_text('Hello, websocket!') + await session.close() +``` + +### Adding routes to the application + +You can use any of the following to add handled routes to the application: + +* `.add_route(path, func, methods=["GET"])` - Add an HTTP route. The function may be either a coroutine or a regular function, with a signature like `func(request **kwargs) -> response`. +* `.add_websocket_route(path, func)` - Add a websocket session route. The function must be a coroutine, with a signature like `func(session, **kwargs)`. +* `.mount(prefix, app)` - Include an ASGI app, mounted under the given path prefix +* `.route(path)` - Add an HTTP route, decorator style. +* `.websocket_route(path)` - Add a WebSocket route, decorator style. + +--- +

Starlette is BSD licensed code.
Designed & built in Brighton, England.

— ⭐️ —

diff --git a/starlette/__init__.py b/starlette/__init__.py index a0819db8..8596cbeb 100644 --- a/starlette/__init__.py +++ b/starlette/__init__.py @@ -1,3 +1,4 @@ +from starlette.app import App from starlette.response import ( FileResponse, HTMLResponse, @@ -12,6 +13,7 @@ from starlette.testclient import TestClient __all__ = ( + "App", "FileResponse", "HTMLResponse", "JSONResponse", diff --git a/starlette/app.py b/starlette/app.py new file mode 100644 index 00000000..3120d686 --- /dev/null +++ b/starlette/app.py @@ -0,0 +1,77 @@ +from starlette.request import Request +from starlette.routing import Path, PathPrefix, Router +from starlette.types import ASGIApp, ASGIInstance, Receive, Scope, Send +from starlette.websockets import WebSocketSession +import asyncio + + +def request_response(func): + """ + Taks a function or coroutine `func(request, **kwargs) -> response`, + and returns an ASGI application. + """ + is_coroutine = asyncio.iscoroutinefunction(func) + + def app(scope: Scope) -> ASGIInstance: + async def awaitable(receive: Receive, send: Send) -> None: + request = Request(scope, receive=receive) + kwargs = scope.get("kwargs", {}) + if is_coroutine: + response = await func(request, **kwargs) + else: + response = func(request, **kwargs) + await response(receive, send) + + return awaitable + + return app + + +def websocket_session(func): + """ + Takes a coroutine `func(session, **kwargs)`, and returns an ASGI application. + """ + + def app(scope: Scope) -> ASGIInstance: + async def awaitable(receive: Receive, send: Send) -> None: + session = WebSocketSession(scope, receive=receive, send=send) + kwargs = scope.get("kwargs", {}) + await func(session, **kwargs) + + return awaitable + + return app + + +class App: + def __init__(self) -> None: + self.router = Router(routes=[]) + + def mount(self, path: str, app: ASGIApp): + prefix = PathPrefix(path, app=app) + 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) + self.router.routes.append(instance) + + def add_websocket_route(self, path: str, route) -> None: + instance = Path(path, websocket_session(route), protocol="websocket") + self.router.routes.append(instance) + + def route(self, path: str): + def decorator(func): + self.add_route(path, func) + + return decorator + + def websocket_route(self, path: str): + def decorator(func): + self.add_websocket_route(path, func) + + return decorator + + def __call__(self, scope: Scope) -> ASGIInstance: + return self.router(scope) diff --git a/starlette/routing.py b/starlette/routing.py index 8832135a..4e0cd051 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -1,4 +1,4 @@ -from starlette import Response +from starlette.response import Response from starlette.types import Scope, ASGIApp, ASGIInstance import re import typing @@ -14,23 +14,29 @@ class Route: class Path(Route): def __init__( - self, path: str, app: ASGIApp, methods: typing.Sequence[str] = () + self, + path: str, + app: ASGIApp, + methods: typing.Sequence[str] = (), + protocol: str = None, ) -> None: self.path = path self.app = app + self.protocol = protocol self.methods = methods regex = "^" + path + "$" regex = re.sub("{([a-zA-Z_][a-zA-Z0-9_]*)}", r"(?P<\1>[^/]+)", regex) self.path_regex = re.compile(regex) def matches(self, scope: Scope) -> typing.Tuple[bool, Scope]: - match = self.path_regex.match(scope["path"]) - if match: - kwargs = dict(scope.get("kwargs", {})) - kwargs.update(match.groupdict()) - child_scope = dict(scope) - child_scope["kwargs"] = kwargs - return True, child_scope + if self.protocol is None or scope["type"] == self.protocol: + match = self.path_regex.match(scope["path"]) + if match: + kwargs = dict(scope.get("kwargs", {})) + kwargs.update(match.groupdict()) + child_scope = dict(scope) + child_scope["kwargs"] = kwargs + return True, child_scope return False, {} def __call__(self, scope: Scope) -> ASGIInstance: @@ -81,6 +87,12 @@ class Router: return self.not_found(scope) def not_found(self, scope: Scope) -> ASGIInstance: + if scope["type"] == "websocket": + + async def close(receive, send): + await send({"type": "websocket.close"}) + + return close return Response("Not found", 404, media_type="text/plain") diff --git a/starlette/testclient.py b/starlette/testclient.py index b3fb4f6c..faf0dc0e 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -140,11 +140,11 @@ class WebSocketTestSession: self._receive_queue = queue.Queue() self._send_queue = queue.Queue() self._thread = threading.Thread(target=self._run) - self._receive_queue.put({"type": "websocket.connect"}) + self.send({"type": "websocket.connect"}) self._thread.start() - message = self._send_queue.get() - self._raise_on_close_or_exception(message) - self.accepted_subprotocol = message["subprotocol"] + message = self.receive() + self._raise_on_close(message) + self.accepted_subprotocol = message.get("subprotocol", None) def __enter__(self): return self @@ -174,38 +174,45 @@ class WebSocketTestSession: async def _asgi_send(self, message): self._send_queue.put(message) - def _raise_on_close_or_exception(self, message): - if isinstance(message, BaseException): - raise message + def _raise_on_close(self, message): if message["type"] == "websocket.close": - raise WebSocketDisconnect(message["code"]) + raise WebSocketDisconnect(message.get("code", 1000)) + + def send(self, message): + self._receive_queue.put(message) def send_text(self, data): - self._receive_queue.put({"type": "websocket.receive", "text": data}) + self.send({"type": "websocket.receive", "text": data}) def send_bytes(self, data): - self._receive_queue.put({"type": "websocket.receive", "bytes": data}) + self.send({"type": "websocket.receive", "bytes": data}) def send_json(self, data): encoded = json.dumps(data).encode("utf-8") - self._receive_queue.put({"type": "websocket.receive", "bytes": encoded}) + self.send({"type": "websocket.receive", "bytes": encoded}) def close(self, code=1000): - self._receive_queue.put({"type": "websocket.disconnect", "code": code}) + self.send({"type": "websocket.disconnect", "code": code}) + + def receive(self): + message = self._send_queue.get() + if isinstance(message, BaseException): + raise message + return message def receive_text(self): - message = self._send_queue.get() - self._raise_on_close_or_exception(message) + message = self.receive() + self._raise_on_close(message) return message["text"] def receive_bytes(self): - message = self._send_queue.get() - self._raise_on_close_or_exception(message) + message = self.receive() + self._raise_on_close(message) return message["bytes"] def receive_json(self): - message = self._send_queue.get() - self._raise_on_close_or_exception(message) + message = self.receive() + self._raise_on_close(message) encoded = message["bytes"] return json.loads(encoded.decode("utf-8")) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 00000000..bed94db3 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,75 @@ +from starlette import App +from starlette.response import PlainTextResponse +from starlette.staticfiles import StaticFiles +from starlette.testclient import TestClient +import os + + +app = App() + + +@app.route("/func") +def func_homepage(request): + return PlainTextResponse("Hello, world!") + + +@app.route("/async") +async def async_homepage(request): + return PlainTextResponse("Hello, world!") + + +@app.route("/user/{username}") +def user_page(request, username): + return PlainTextResponse("Hello, %s!" % username) + + +@app.websocket_route("/ws") +async def websocket_endpoint(session): + await session.accept() + await session.send_text("Hello, world!") + await session.close() + + +client = TestClient(app) + + +def test_func_route(): + response = client.get("/func") + assert response.status_code == 200 + assert response.text == "Hello, world!" + + +def test_async_route(): + response = client.get("/async") + 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_websocket_route(): + with client.wsconnect("/ws") as session: + text = session.receive_text() + assert text == "Hello, world!" + + +def test_400(): + response = client.get("/404") + assert response.status_code == 404 + + +def test_app_mount(tmpdir): + path = os.path.join(tmpdir, "example.txt") + with open(path, "w") as file: + file.write("") + + app = App() + app.mount("/static", StaticFiles(directory=tmpdir)) + client = TestClient(app) + response = client.get("/static/example.txt") + assert response.status_code == 200 + assert response.text == "" diff --git a/tests/test_routing.py b/tests/test_routing.py index 16eeb41a..51071c89 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -1,6 +1,7 @@ from starlette import Response, TestClient from starlette.routing import Path, PathPrefix, Router, ProtocolRouter -from starlette.websockets import WebSocketSession +from starlette.websockets import WebSocketSession, WebSocketDisconnect +import pytest def homepage(scope): @@ -78,7 +79,10 @@ def websocket_endpoint(scope): mixed_protocol_app = ProtocolRouter( - {"http": http_endpoint, "websocket": websocket_endpoint} + { + "http": Router([Path("/", app=http_endpoint)]), + "websocket": Router([Path("/", app=websocket_endpoint)]), + } ) @@ -91,3 +95,6 @@ def test_protocol_switch(): with client.wsconnect("/") as session: assert session.receive_json() == {"hello": "world"} + + with pytest.raises(WebSocketDisconnect): + client.wsconnect("/404") From 2519f83765022ffb0c82a96b99024f8e68e90723 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 28 Aug 2018 14:45:06 +0100 Subject: [PATCH 2/5] client.wsconnect -> client.websocket_connect --- starlette/testclient.py | 2 +- tests/test_app.py | 2 +- tests/test_routing.py | 4 ++-- tests/test_websockets.py | 28 ++++++++++++++-------------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/starlette/testclient.py b/starlette/testclient.py index faf0dc0e..71f45948 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -232,7 +232,7 @@ class _TestClient(requests.Session): url = urljoin(self.base_url, url) return super().request(method, url, **kwargs) - def wsconnect(self, url: str, subprotocols=None, **kwargs) -> WebSocketTestSession: + def websocket_connect(self, url: str, subprotocols=None, **kwargs) -> WebSocketTestSession: url = urljoin("ws://testserver", url) headers = kwargs.get("headers", {}) headers.setdefault("connection", "upgrade") diff --git a/tests/test_app.py b/tests/test_app.py index bed94db3..73b6a479 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -52,7 +52,7 @@ def test_route_kwargs(): def test_websocket_route(): - with client.wsconnect("/ws") as session: + with client.websocket_connect("/ws") as session: text = session.receive_text() assert text == "Hello, world!" diff --git a/tests/test_routing.py b/tests/test_routing.py index 51071c89..3cad2c8c 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -93,8 +93,8 @@ def test_protocol_switch(): assert response.status_code == 200 assert response.text == "Hello, world" - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: assert session.receive_json() == {"hello": "world"} with pytest.raises(WebSocketDisconnect): - client.wsconnect("/404") + client.websocket_connect("/404") diff --git a/tests/test_websockets.py b/tests/test_websockets.py index 2416a315..ed528fec 100644 --- a/tests/test_websockets.py +++ b/tests/test_websockets.py @@ -14,7 +14,7 @@ def test_session_url(): return asgi client = TestClient(app) - with client.wsconnect("/123?a=abc") as session: + with client.websocket_connect("/123?a=abc") as session: data = session.receive_json() assert data == {"url": "ws://testserver/123?a=abc"} @@ -31,7 +31,7 @@ def test_session_query_params(): return asgi client = TestClient(app) - with client.wsconnect("/?a=abc&b=456") as session: + with client.websocket_connect("/?a=abc&b=456") as session: data = session.receive_json() assert data == {"params": {"a": "abc", "b": "456"}} @@ -48,7 +48,7 @@ def test_session_headers(): return asgi client = TestClient(app) - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: expected_headers = { "accept": "*/*", "accept-encoding": "gzip, deflate", @@ -73,7 +73,7 @@ def test_session_port(): return asgi client = TestClient(app) - with client.wsconnect("ws://example.com:123/123?a=abc") as session: + with client.websocket_connect("ws://example.com:123/123?a=abc") as session: data = session.receive_json() assert data == {"port": 123} @@ -90,7 +90,7 @@ def test_session_send_and_receive_text(): return asgi client = TestClient(app) - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: session.send_text("Hello, world!") data = session.receive_text() assert data == "Message was: Hello, world!" @@ -108,7 +108,7 @@ def test_session_send_and_receive_bytes(): return asgi client = TestClient(app) - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: session.send_bytes(b"Hello, world!") data = session.receive_bytes() assert data == b"Message was: Hello, world!" @@ -126,7 +126,7 @@ def test_session_send_and_receive_json(): return asgi client = TestClient(app) - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: session.send_json({"hello": "world"}) data = session.receive_json() assert data == {"message": {"hello": "world"}} @@ -148,7 +148,7 @@ def test_client_close(): return asgi client = TestClient(app) - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: session.close(code=1001) assert close_code == 1001 @@ -163,7 +163,7 @@ def test_application_close(): return asgi client = TestClient(app) - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: with pytest.raises(WebSocketDisconnect) as exc: session.receive_text() assert exc.value.code == 1001 @@ -179,7 +179,7 @@ def test_rejected_connection(): client = TestClient(app) with pytest.raises(WebSocketDisconnect) as exc: - client.wsconnect("/") + client.websocket_connect("/") assert exc.value.code == 1001 @@ -194,7 +194,7 @@ def test_subprotocol(): return asgi client = TestClient(app) - with client.wsconnect("/", subprotocols=["soap", "wamp"]) as session: + with client.websocket_connect("/", subprotocols=["soap", "wamp"]) as session: assert session.accepted_subprotocol == "wamp" @@ -207,7 +207,7 @@ def test_session_exception(): client = TestClient(app) with pytest.raises(AssertionError): - client.wsconnect("/123?a=abc") + client.websocket_connect("/123?a=abc") def test_duplicate_close(): @@ -222,7 +222,7 @@ def test_duplicate_close(): client = TestClient(app) with pytest.raises(RuntimeError): - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: pass @@ -239,7 +239,7 @@ def test_duplicate_disconnect(): client = TestClient(app) with pytest.raises(RuntimeError): - with client.wsconnect("/") as session: + with client.websocket_connect("/") as session: session.close() From 779516a5ac73f3bec5ecd8f453e3c692290f06cf Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 28 Aug 2018 14:45:23 +0100 Subject: [PATCH 3/5] Fixes to TestClient websocket docs --- README.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3a5c9603..dc9ac625 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ pip3 install starlette **Example:** ```python -from starlette import Response +from starlette.response import Response class App: @@ -112,7 +112,7 @@ class App: Takes some text or bytes and returns an HTML response. ```python -from starlette import HTMLResponse +from starlette.response import HTMLResponse class App: @@ -129,7 +129,7 @@ class App: Takes some text or bytes and returns an plain text response. ```python -from starlette import PlainTextResponse +from starlette.response import PlainTextResponse class App: @@ -146,7 +146,7 @@ class App: Takes some data and returns an `application/json` encoded response. ```python -from starlette import JSONResponse +from starlette.response import JSONResponse class App: @@ -163,7 +163,7 @@ class App: Returns an HTTP redirect. Uses a 302 status code by default. ```python -from starlette import PlainTextResponse, RedirectResponse +from starlette.response import PlainTextResponse, RedirectResponse class App: @@ -183,7 +183,8 @@ class App: Takes an async generator and streams the response body. ```python -from starlette import Request, StreamingResponse +from starlette.request import Request +from starlette.response import StreamingResponse import asyncio @@ -219,7 +220,7 @@ Takes a different set of arguments to instantiate than the other response types: File responses will include appropriate `Content-Length`, `Last-Modified` and `ETag` headers. ```python -from starlette import FileResponse +from starlette.response import FileResponse class App: @@ -515,7 +516,8 @@ The test client allows you to make requests against your ASGI application, using the `requests` library. ```python -from starlette import HTMLResponse, TestClient +from starlette.response import HTMLResponse +from starlette.testclient import TestClient class App: @@ -563,7 +565,7 @@ class App: def test_app(): client = TestClient(App) - with client.wsconnect('/') as session: + with client.websocket_connect('/') as session: data = session.receive_text() assert data == 'Hello, world!' ``` @@ -577,10 +579,16 @@ always raised by the test client. #### Establishing a test session -* `.wsconnect(url, subprotocols=None, **options)` - Takes the same set of arguments as `requests.get()`. +* `.websocket_connect(url, subprotocols=None, **options)` - Takes the same set of arguments as `requests.get()`. May raise `starlette.websockets.Disconnect` if the application does not accept the websocket connection. +#### Sending data + +* `.send_text(data)` - Send the given text to the application. +* `.send_bytes(data)` - Send the given bytes to the application. +* `.send_json(data)` - Send the given data to the application. + #### Receiving data * `.receive_text()` - Wait for incoming text sent by the application and return it. @@ -622,7 +630,7 @@ Starlette also includes an `App` class that nicely ties together all of its other functionality. ```python -from starlette import App +from starlette.app import App from starlette.response import PlainTextResponse from starlette.staticfiles import StaticFiles From 1e78ddb45c3790992d596014b79e26900f70382f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 28 Aug 2018 14:45:50 +0100 Subject: [PATCH 4/5] Black formatting --- starlette/testclient.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/starlette/testclient.py b/starlette/testclient.py index 71f45948..2118b50e 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -232,7 +232,9 @@ class _TestClient(requests.Session): url = urljoin(self.base_url, url) return super().request(method, url, **kwargs) - def websocket_connect(self, url: str, subprotocols=None, **kwargs) -> WebSocketTestSession: + def websocket_connect( + self, url: str, subprotocols=None, **kwargs + ) -> WebSocketTestSession: url = urljoin("ws://testserver", url) headers = kwargs.get("headers", {}) headers.setdefault("connection", "upgrade") From 0b4acd4432891500d8752a7663aa965f5c519f88 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 28 Aug 2018 14:46:47 +0100 Subject: [PATCH 5/5] Version 0.2 --- starlette/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/starlette/__init__.py b/starlette/__init__.py index 8596cbeb..e163e824 100644 --- a/starlette/__init__.py +++ b/starlette/__init__.py @@ -24,4 +24,4 @@ __all__ = ( "Request", "TestClient", ) -__version__ = "0.1.17" +__version__ = "0.2.0"