From f0caa0f001ae8c50448b4328e806cc3f4ee320c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?=
Date: Fri, 24 May 2019 14:59:48 +0400
Subject: [PATCH 01/14] :sparkles: Add WebSocket exception handling
---
starlette/exceptions.py | 48 ++++++++++++++++++++++++++++------
starlette/middleware/errors.py | 15 ++++++++---
2 files changed, 52 insertions(+), 11 deletions(-)
diff --git a/starlette/exceptions.py b/starlette/exceptions.py
index 0ef621508..7e3f272a8 100644
--- a/starlette/exceptions.py
+++ b/starlette/exceptions.py
@@ -2,10 +2,12 @@
import http
import typing
+from starlette import status
from starlette.concurrency import run_in_threadpool
from starlette.requests import Request
from starlette.responses import PlainTextResponse, Response
from starlette.types import ASGIApp, Message, Receive, Scope, Send
+from starlette.websockets import WebSocket, WebSocketClose
class HTTPException(Exception):
@@ -16,13 +18,31 @@ def __init__(self, status_code: int, detail: str = None) -> None:
self.detail = detail
+class WebSocketException(Exception):
+ def __init__(self, code: int = status.WS_1008_POLICY_VIOLATION) -> None:
+ """
+ `code` defaults to 1008, from the WebSocket specification:
+
+ > 1008 indicates that an endpoint is terminating the connection
+ > because it has received a message that violates its policy. This
+ > is a generic status code that can be returned when there is no
+ > other more suitable status code (e.g., 1003 or 1009) or if there
+ > is a need to hide specific details about the policy.
+
+ Set `code` to any value allowed by
+ [the WebSocket specification](https://tools.ietf.org/html/rfc6455#section-7.4.1).
+ """
+ self.code = code
+
+
class ExceptionMiddleware:
def __init__(self, app: ASGIApp, debug: bool = False) -> None:
self.app = app
self.debug = debug # TODO: We ought to handle 404 cases if debug is set.
self._status_handlers = {} # type: typing.Dict[int, typing.Callable]
self._exception_handlers = {
- HTTPException: self.http_exception
+ HTTPException: self.http_exception,
+ WebSocketException: self.websocket_exception,
} # type: typing.Dict[typing.Type[Exception], typing.Callable]
def add_exception_handler(
@@ -45,7 +65,7 @@ def _lookup_exception_handler(
return None
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if scope["type"] != "http":
+ if scope["type"] not in {"http", "websocket"}:
await self.app(scope, receive, send)
return
@@ -76,14 +96,26 @@ async def sender(message: Message) -> None:
msg = "Caught handled exception, but response already started."
raise RuntimeError(msg) from exc
- request = Request(scope, receive=receive)
- if asyncio.iscoroutinefunction(handler):
- response = await handler(request, exc)
- else:
- response = await run_in_threadpool(handler, request, exc)
- await response(scope, receive, sender)
+ if scope["type"] == "http":
+ request = Request(scope, receive=receive)
+ if asyncio.iscoroutinefunction(handler):
+ response = await handler(request, exc)
+ else:
+ response = await run_in_threadpool(handler, request, exc)
+ await response(scope, receive, sender)
+ elif scope["type"] == "websocket":
+ websocket = WebSocket(scope, receive=receive, send=send)
+ if asyncio.iscoroutinefunction(handler):
+ await handler(websocket, exc)
+ else:
+ await run_in_threadpool(handler, websocket, exc)
def http_exception(self, request: Request, exc: HTTPException) -> Response:
if exc.status_code in {204, 304}:
return Response(b"", status_code=exc.status_code)
return PlainTextResponse(exc.detail, status_code=exc.status_code)
+
+ async def websocket_exception(
+ self, websocket: WebSocket, exc: WebSocketException
+ ) -> None:
+ await websocket.close(code=exc.code)
diff --git a/starlette/middleware/errors.py b/starlette/middleware/errors.py
index 54f5fd2ae..643ebc104 100644
--- a/starlette/middleware/errors.py
+++ b/starlette/middleware/errors.py
@@ -2,10 +2,12 @@
import traceback
import typing
+from starlette import status
from starlette.concurrency import run_in_threadpool
-from starlette.requests import Request
+from starlette.requests import Request, empty_receive
from starlette.responses import HTMLResponse, PlainTextResponse, Response
from starlette.types import ASGIApp, Message, Receive, Scope, Send
+from starlette.websockets import WebSocket, WebSocketState
STYLES = """
.traceback-container {
@@ -83,7 +85,7 @@ def __init__(
self.debug = debug
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if scope["type"] != "http":
+ if scope["type"] not in {"http", "websocket"}:
await self.app(scope, receive, send)
return
@@ -99,7 +101,7 @@ async def _send(message: Message) -> None:
try:
await self.app(scope, receive, _send)
except Exception as exc:
- if not response_started:
+ if not response_started and scope["type"] == "http":
request = Request(scope)
if self.debug:
# In debug mode, return traceback responses.
@@ -115,6 +117,13 @@ async def _send(message: Message) -> None:
response = await run_in_threadpool(self.handler, request, exc)
await response(scope, receive, send)
+ elif scope["type"] == "websocket":
+ websocket = WebSocket(scope, receive, send)
+ # https://tools.ietf.org/html/rfc6455#section-7.4.1
+ # 1011 indicates that a server is terminating the connection because
+ # it encountered an unexpected condition that prevented it from
+ # fulfilling the request.
+ await websocket.close(code=status.WS_1011_INTERNAL_ERROR)
# We always continue to raise the exception.
# This allows servers to log the error, or allows test clients
From 113d5c79dca0d45b1682de287e358205b3a1f9ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?=
Date: Fri, 24 May 2019 15:00:11 +0400
Subject: [PATCH 02/14] :white_check_mark: Test WebSocket exceptions
---
tests/middleware/test_errors.py | 9 ++----
tests/test_applications.py | 53 ++++++++++++++++++++++++++++++++-
2 files changed, 55 insertions(+), 7 deletions(-)
diff --git a/tests/middleware/test_errors.py b/tests/middleware/test_errors.py
index ff74d87bd..768a4ee0b 100644
--- a/tests/middleware/test_errors.py
+++ b/tests/middleware/test_errors.py
@@ -3,6 +3,7 @@
from starlette.middleware.errors import ServerErrorMiddleware
from starlette.responses import JSONResponse, Response
from starlette.testclient import TestClient
+from starlette.websockets import WebSocket, WebSocketDisconnect
def test_handler():
@@ -55,16 +56,12 @@ async def app(scope, receive, send):
client.get("/")
-def test_debug_not_http():
- """
- DebugMiddleware should just pass through any non-http messages as-is.
- """
-
+def test_debug_websocket():
async def app(scope, receive, send):
raise RuntimeError("Something went wrong")
app = ServerErrorMiddleware(app)
- with pytest.raises(RuntimeError):
+ with pytest.raises(WebSocketDisconnect):
client = TestClient(app)
client.websocket_connect("/")
diff --git a/tests/test_applications.py b/tests/test_applications.py
index bece5c7a6..9c2d4d25d 100644
--- a/tests/test_applications.py
+++ b/tests/test_applications.py
@@ -1,13 +1,18 @@
+import asyncio
import os
+import pytest
+
+from starlette import status
from starlette.applications import Starlette
from starlette.endpoints import HTTPEndpoint
-from starlette.exceptions import HTTPException
+from starlette.exceptions import HTTPException, WebSocketException
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.routing import Host, Mount, Route, Router, WebSocketRoute
from starlette.staticfiles import StaticFiles
from starlette.testclient import TestClient
+from starlette.websockets import WebSocketDisconnect
app = Starlette()
@@ -86,6 +91,28 @@ async def websocket_endpoint(session):
await session.close()
+@app.websocket_route("/ws-raise-websocket")
+async def websocket_raise_websocket_exception(websocket):
+ await websocket.accept()
+ raise WebSocketException(code=status.WS_1003_UNSUPPORTED_DATA)
+
+
+class CustomWSException(Exception):
+ pass
+
+
+@app.websocket_route("/ws-raise-custom")
+async def websocket_raise_custom(websocket):
+ await websocket.accept()
+ raise CustomWSException()
+
+
+@app.exception_handler(CustomWSException)
+def custom_ws_exception_handler(websocket, exc):
+ loop = asyncio.new_event_loop()
+ loop.run_until_complete(websocket.close(code=status.WS_1013_TRY_AGAIN_LATER))
+
+
client = TestClient(app)
@@ -164,6 +191,26 @@ def test_500():
assert response.json() == {"detail": "Server Error"}
+def test_websocket_raise_websocket_exception():
+ client = TestClient(app)
+ with client.websocket_connect("/ws-raise-websocket") as session:
+ response = session.receive()
+ assert response == {
+ "type": "websocket.close",
+ "code": status.WS_1003_UNSUPPORTED_DATA,
+ }
+
+
+def test_websocket_raise_custom_exception():
+ client = TestClient(app)
+ with client.websocket_connect("/ws-raise-custom") as session:
+ response = session.receive()
+ assert response == {
+ "type": "websocket.close",
+ "code": status.WS_1013_TRY_AGAIN_LATER,
+ }
+
+
def test_middleware():
client = TestClient(app, base_url="http://incorrecthost")
response = client.get("/func")
@@ -191,6 +238,10 @@ def test_routes():
),
Route("/500", endpoint=runtime_error, methods=["GET"]),
WebSocketRoute("/ws", endpoint=websocket_endpoint),
+ WebSocketRoute(
+ "/ws-raise-websocket", endpoint=websocket_raise_websocket_exception
+ ),
+ WebSocketRoute("/ws-raise-custom", endpoint=websocket_raise_custom),
]
From 1f0bc8640908320d71bd5680228a4ce59aa6a2ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?=
Date: Fri, 24 May 2019 15:11:30 +0400
Subject: [PATCH 03/14] :memo: Document WebSocketException
---
docs/exceptions.md | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/docs/exceptions.md b/docs/exceptions.md
index 5a7afd35b..732cf81cd 100644
--- a/docs/exceptions.md
+++ b/docs/exceptions.md
@@ -42,6 +42,14 @@ async def http_exception(request, exc):
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
```
+You might also want to override how `WebSocketException` is handled:
+
+```python
+@app.exception_handler(WebSocketException)
+async def websocket_exception(websocket, exc):
+ await websocket.close(code=1008)
+```
+
## Errors and handled exceptions
It is important to differentiate between handled exceptions and errors.
@@ -74,3 +82,11 @@ returning plain-text HTTP responses for any `HTTPException`.
You should only raise `HTTPException` inside routing or endpoints. Middleware
classes should instead just return appropriate responses directly.
+
+## WebSocketException
+
+You can use the `WebSocketException` class to raise errors inside of WebSocket endpoints.
+
+* `WebSocketException(code=1008)`
+
+You can set any code valid as defined [in the specification](https://tools.ietf.org/html/rfc6455#section-7.4.1).
From 31adab155d91865e014298670adecdb1077c26e0 Mon Sep 17 00:00:00 2001
From: Marcelo Trylesinski
Date: Wed, 11 Aug 2021 19:29:04 +0200
Subject: [PATCH 04/14] merge master
---
.codecov.yml | 11 -
.github/FUNDING.yml | 1 +
.github/ISSUE_TEMPLATE/2-bug-report.md | 53 +++
.github/ISSUE_TEMPLATE/3-feature-request.md | 33 ++
.github/ISSUE_TEMPLATE/config.yml | 7 +
.github/workflows/publish.yml | 27 ++
.github/workflows/test-suite.yml | 33 ++
.gitignore | 10 +-
.travis.yml | 19 -
CONTRIBUTING.md | 79 ----
MANIFEST.in | 3 +
README.md | 55 ++-
docs/applications.md | 61 ++-
docs/authentication.md | 32 +-
docs/background.md | 21 +-
docs/config.md | 34 +-
docs/database.md | 63 +++-
docs/endpoints.md | 32 +-
docs/events.md | 60 +--
docs/exceptions.md | 20 +-
docs/graphql.md | 38 +-
docs/index.md | 52 ++-
docs/js/chat.js | 3 +
docs/js/sidecar-1.5.0.js | 6 +
docs/middleware.md | 133 +++++--
docs/release-notes.md | 160 +++++++-
docs/requests.md | 44 ++-
docs/responses.md | 138 +++----
docs/routing.md | 216 +++++++++--
docs/schemas.md | 17 +-
docs/server-push.md | 36 ++
docs/staticfiles.md | 28 +-
docs/templates.md | 30 +-
docs/testclient.md | 61 +--
docs/third-party-packages.md | 75 +++-
docs/websockets.md | 21 +-
mkdocs.yml | 56 +--
requirements.txt | 21 +-
scripts/README.md | 5 +-
scripts/build | 13 +
scripts/check | 14 +
scripts/coverage | 10 +
scripts/docs | 10 +
scripts/install | 23 +-
scripts/lint | 9 +-
scripts/publish | 32 +-
scripts/test | 18 +-
setup.cfg | 39 ++
setup.py | 29 +-
starlette/__init__.py | 2 +-
starlette/applications.py | 122 ++++--
starlette/authentication.py | 19 +-
starlette/background.py | 4 +-
starlette/concurrency.py | 30 +-
starlette/config.py | 25 +-
starlette/convertors.py | 14 +-
starlette/datastructures.py | 110 ++++--
starlette/endpoints.py | 4 +-
starlette/exceptions.py | 27 +-
starlette/formparsers.py | 60 +--
starlette/graphql.py | 42 +--
starlette/middleware/__init__.py | 17 +
starlette/middleware/authentication.py | 6 +-
starlette/middleware/base.py | 67 ++--
starlette/middleware/cors.py | 46 ++-
starlette/middleware/errors.py | 142 +++++--
starlette/middleware/gzip.py | 21 +-
starlette/middleware/httpsredirect.py | 2 +-
starlette/middleware/sessions.py | 16 +-
starlette/middleware/trustedhost.py | 3 +-
starlette/middleware/wsgi.py | 73 ++--
starlette/requests.py | 160 +++++---
starlette/responses.py | 113 ++++--
starlette/routing.py | 395 ++++++++++++++------
starlette/schemas.py | 2 +
starlette/staticfiles.py | 57 +--
starlette/status.py | 19 +-
starlette/templating.py | 12 +-
starlette/testclient.py | 244 ++++++++----
starlette/websockets.py | 23 +-
tests/.ignore_lifespan | 3 -
tests/conftest.py | 25 ++
tests/middleware/__init__.py | 0
tests/middleware/test_base.py | 83 +++-
tests/middleware/test_cors.py | 296 +++++++++++++--
tests/middleware/test_errors.py | 28 +-
tests/middleware/test_gzip.py | 17 +-
tests/middleware/test_https_redirect.py | 21 +-
tests/middleware/test_lifespan.py | 108 ------
tests/middleware/test_session.py | 27 +-
tests/middleware/test_trusted_host.py | 13 +-
tests/middleware/test_wsgi.py | 34 +-
tests/test_applications.py | 142 +++++--
tests/test_authentication.py | 143 ++++++-
tests/test_background.py | 13 +-
tests/test_concurrency.py | 22 ++
tests/test_config.py | 5 +
tests/test_database.py | 18 +-
tests/test_datastructures.py | 41 ++
tests/test_endpoints.py | 41 +-
tests/test_exceptions.py | 41 +-
tests/test_formparsers.py | 141 +++++--
tests/test_graphql.py | 70 ++--
tests/test_requests.py | 260 +++++++++++--
tests/test_responses.py | 151 +++++---
tests/test_routing.py | 333 ++++++++++++++++-
tests/test_schemas.py | 5 +-
tests/test_staticfiles.py | 148 ++++++--
tests/test_templates.py | 5 +-
tests/test_testclient.py | 172 +++++++--
tests/test_websockets.py | 162 ++++++--
111 files changed, 4645 insertions(+), 1791 deletions(-)
delete mode 100644 .codecov.yml
create mode 100644 .github/FUNDING.yml
create mode 100644 .github/ISSUE_TEMPLATE/2-bug-report.md
create mode 100644 .github/ISSUE_TEMPLATE/3-feature-request.md
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/workflows/publish.yml
create mode 100644 .github/workflows/test-suite.yml
delete mode 100644 .travis.yml
delete mode 100644 CONTRIBUTING.md
create mode 100644 MANIFEST.in
create mode 100644 docs/js/chat.js
create mode 100644 docs/js/sidecar-1.5.0.js
create mode 100644 docs/server-push.md
create mode 100755 scripts/build
create mode 100755 scripts/check
create mode 100755 scripts/coverage
create mode 100755 scripts/docs
create mode 100644 setup.cfg
delete mode 100644 tests/.ignore_lifespan
create mode 100644 tests/conftest.py
create mode 100644 tests/middleware/__init__.py
delete mode 100644 tests/middleware/test_lifespan.py
create mode 100644 tests/test_concurrency.py
diff --git a/.codecov.yml b/.codecov.yml
deleted file mode 100644
index c2336342e..000000000
--- a/.codecov.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-coverage:
- precision: 2
- round: down
- range: "80...100"
-
- status:
- project: yes
- patch: no
- changes: no
-
-comment: off
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..2f87d94ca
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: encode
diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.md b/.github/ISSUE_TEMPLATE/2-bug-report.md
new file mode 100644
index 000000000..7c11706b7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/2-bug-report.md
@@ -0,0 +1,53 @@
+---
+name: Bug report
+about: Report a bug to help improve this project
+---
+
+### Checklist
+
+
+
+- [ ] The bug is reproducible against the latest release and/or `master`.
+- [ ] There are no similar issues or pull requests to fix it yet.
+
+### Describe the bug
+
+
+
+### To reproduce
+
+
+
+### Expected behavior
+
+
+
+### Actual behavior
+
+
+
+### Debugging material
+
+
+
+### Environment
+
+- OS:
+- Python version:
+- Starlette version:
+
+### Additional context
+
+
diff --git a/.github/ISSUE_TEMPLATE/3-feature-request.md b/.github/ISSUE_TEMPLATE/3-feature-request.md
new file mode 100644
index 000000000..97336f516
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/3-feature-request.md
@@ -0,0 +1,33 @@
+---
+name: Feature request
+about: Suggest an idea for this project.
+---
+
+### Checklist
+
+
+
+- [ ] There are no similar issues or pull requests for this yet.
+- [ ] I discussed this idea on the [community chat](https://gitter.im/encode/community) and feedback is positive.
+
+### Is your feature related to a problem? Please describe.
+
+
+
+## Describe the solution you would like.
+
+
+
+## Describe alternatives you considered
+
+
+
+## Additional context
+
+
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..2ad6e8e27
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,7 @@
+# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
+blank_issues_enabled: true
+contact_links:
+- name: Question
+ url: https://gitter.im/encode/community
+ about: >
+ Ask a question
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 000000000..b290d6e1a
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,27 @@
+---
+name: Publish
+
+on:
+ push:
+ tags:
+ - '*'
+
+jobs:
+ publish:
+ name: "Publish release"
+ runs-on: "ubuntu-latest"
+
+ steps:
+ - uses: "actions/checkout@v2"
+ - uses: "actions/setup-python@v2"
+ with:
+ python-version: 3.7
+ - name: "Install dependencies"
+ run: "scripts/install"
+ - name: "Build package & docs"
+ run: "scripts/build"
+ - name: "Publish to PyPI & deploy docs"
+ run: "scripts/publish"
+ env:
+ TWINE_USERNAME: __token__
+ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml
new file mode 100644
index 000000000..751c5193b
--- /dev/null
+++ b/.github/workflows/test-suite.yml
@@ -0,0 +1,33 @@
+---
+name: Test Suite
+
+on:
+ push:
+ branches: ["master"]
+ pull_request:
+ branches: ["master"]
+
+jobs:
+ tests:
+ name: "Python ${{ matrix.python-version }}"
+ runs-on: "ubuntu-latest"
+
+ strategy:
+ matrix:
+ python-version: ["3.6", "3.7", "3.8", "3.9", "3.10.0-beta.3"]
+
+ steps:
+ - uses: "actions/checkout@v2"
+ - uses: "actions/setup-python@v2"
+ with:
+ python-version: "${{ matrix.python-version }}"
+ - name: "Install dependencies"
+ run: "scripts/install"
+ - name: "Run linting checks"
+ run: "scripts/check"
+ - name: "Build package & docs"
+ run: "scripts/build"
+ - name: "Run tests"
+ run: "scripts/test"
+ - name: "Enforce coverage"
+ run: "scripts/coverage"
diff --git a/.gitignore b/.gitignore
index 7b5d4318c..bff8fa258 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,11 @@ test.db
.coverage
.pytest_cache/
.mypy_cache/
-starlette.egg-info/
-venv/
+__pycache__/
+htmlcov/
+site/
+*.egg-info/
+venv*/
+.python-version
+build/
+dist/
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 4f4ef5dc5..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-dist: xenial
-language: python
-
-cache: pip
-
-python:
- - "3.6"
- - "3.7"
- - "3.8-dev"
-
-install:
- - pip install -U -r requirements.txt
-
-script:
- - scripts/test
-
-after_script:
- - pip install codecov
- - codecov
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index 6acbc2c87..000000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# Contributing to Starlette
-
-The Starlette team happily welcomes contributions. This document will help you get ready to contribute to Starlette!
-
-To submit new code to the project you'll need to:
-
-* Fork the repo.
-* Clone your fork on your local computer: `git clone https://github.com//starlette.git`.
-* Install Starlette locally and run the tests: `./scripts/install`, `./scripts/test`.
-* Create a branch for your work, e.g. `git checkout -b fix-some-bug`.
-* Remember to include tests and documentation updates if applicable.
-* Once ready, push to your remote: `git push origin fix-some-bug`.
-* [Open a Pull Request][pull-request].
-
-## Install
-
-**Note**: These scripts are currently suited to **Linux** and **macOS**, but we would happily take pull requests to help us make them more cross-compatible.
-
-Use the `install` script to install project dependencies in a virtual environment.
-
-```bash
-./scripts/install
-```
-
-To use a specific Python executable, use the `-p` option, e.g.:
-
-```bash
-./scripts/install -p python3.7
-```
-
-## Running the tests
-
-The tests are written using [pytest] and located in the `tests/` directory.
-
-**Note**: tests should be run before making any changes to the code in order to make sure that everything is running as expected.
-
-We provide a stand-alone **test script** to run tests in a reliable manner. Run it with:
-
-```bash
-./scripts/test
-```
-
-By default, tests involving a database are excluded. To include them, set the `STARLETTE_TEST_DATABASES` environment variable. This should be a comma separated string of database URLs.
-
-```bash
-# Any of the following are valid for running the database tests...
-export STARLETTE_TEST_DATABASES="postgresql://localhost/starlette"
-export STARLETTE_TEST_DATABASES="mysql://localhost/starlette_test"
-export STARLETTE_TEST_DATABASES="postgresql://localhost/starlette, mysql://localhost/starlette_test"
-```
-
-## Linting
-
-We use [Black][black] as a code formatter. To run it along with a few other linting tools, we provide a stand-alone linting script:
-
-```bash
-./scripts/lint
-```
-
-If linting has anything to say about the code, it will format it in-place.
-
-To keep the code style consistent, you should apply linting before committing.
-
-## Documentation
-
-The documentation is built with [MkDocs], a Markdown-based documentation site generator.
-
-To run the docs site in hot-reload mode (useful when editing the docs), run `$ mkdocs serve` in the project root directory.
-
-For your information, the docs site configuration is located in the `mkdocs.yml` file.
-
-Please refer to the [MkDocs docs][MkDocs] for more usage information, including how to add new pages.
-
-[issues]: https://github.com/encode/starlette/issues/new
-[pull-request]: https://github.com/encode/starlette/compare
-[pytest]: https://docs.pytest.org
-[pytest-cov]: https://github.com/pytest-dev/pytest-cov
-[black]: https://www.google.com/search?client=safari&rls=en&q=github+black&ie=UTF-8&oe=UTF-8
-[MkDocs]: https://www.mkdocs.org
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 000000000..9cccc91b7
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE.md
+global-exclude __pycache__
+global-exclude *.py[co]
diff --git a/README.md b/README.md
index 4de0e8b02..8eedea952 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,8 @@
✨ The little ASGI framework that shines. ✨
-
-
-
-
-
+
+
@@ -25,7 +22,7 @@
# Starlette
Starlette is a lightweight [ASGI](https://asgi.readthedocs.io/en/latest/) framework/toolkit,
-which is ideal for building high performance asyncio services.
+which is ideal for building high performance async services.
It is production-ready, and gives you the following:
@@ -39,7 +36,8 @@ It is production-ready, and gives you the following:
* Session and Cookie support.
* 100% test coverage.
* 100% type annotated codebase.
-* Zero hard dependencies.
+* Few hard dependencies.
+* Compatible with `asyncio` and `trio` backends.
## Requirements
@@ -59,36 +57,42 @@ $ pip3 install uvicorn
## Example
+**example.py**:
+
```python
from starlette.applications import Starlette
from starlette.responses import JSONResponse
-import uvicorn
-
-app = Starlette(debug=True)
+from starlette.routing import Route
-@app.route('/')
async def homepage(request):
return JSONResponse({'hello': 'world'})
-if __name__ == '__main__':
- uvicorn.run(app, host='0.0.0.0', port=8000)
+routes = [
+ Route("/", endpoint=homepage)
+]
+
+app = Starlette(debug=True, routes=routes)
+```
+
+Then run the application using Uvicorn:
+
+```shell
+$ uvicorn example:app
```
For a more complete example, see [encode/starlette-example](https://github.com/encode/starlette-example).
## Dependencies
-Starlette does not have any hard dependencies, but the following are optional:
+Starlette only requires `anyio`, and the following are optional:
* [`requests`][requests] - Required if you want to use the `TestClient`.
-* [`aiofiles`][aiofiles] - Required if you want to use `FileResponse` or `StaticFiles`.
* [`jinja2`][jinja2] - Required if you want to use `Jinja2Templates`.
* [`python-multipart`][python-multipart] - Required if you want to support form parsing, with `request.form()`.
* [`itsdangerous`][itsdangerous] - Required for `SessionMiddleware` support.
* [`pyyaml`][pyyaml] - Required for `SchemaGenerator` support.
* [`graphene`][graphene] - Required for `GraphQLApp` support.
-* [`ujson`][ujson] - Required if you want to use `UJSONResponse`.
You can install all of these with `pip3 install starlette[full]`.
@@ -101,20 +105,16 @@ an ASGI toolkit. You can use any of its components independently.
from starlette.responses import PlainTextResponse
-class App:
- def __init__(self, scope):
- assert scope['type'] == 'http'
- self.scope = scope
-
- async def __call__(self, receive, send):
- response = PlainTextResponse('Hello, world!')
- await response(receive, send)
+async def app(scope, receive, send):
+ assert scope['type'] == 'http'
+ response = PlainTextResponse('Hello, world!')
+ await response(scope, receive, send)
```
-Run the `App` application in `example.py`:
+Run the `app` application in `example.py`:
```shell
-$ uvicorn example:App
+$ uvicorn example:app
INFO: Started server process [11509]
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
```
@@ -137,7 +137,6 @@ as [one of the fastest Python frameworks available](https://www.techempower.com/
For high throughput loads you should:
-* Make sure to install `ujson` and use `UJSONResponse`.
* Run using gunicorn using the `uvicorn` worker class.
* Use one or two workers per-CPU core. (You might need to experiment with this.)
* Disable access logging.
@@ -168,11 +167,9 @@ gunicorn -k uvicorn.workers.UvicornH11Worker ...
Starlette is BSD licensed code. Designed & built in Brighton, England.
[requests]: http://docs.python-requests.org/en/master/
-[aiofiles]: https://github.com/Tinche/aiofiles
[jinja2]: http://jinja.pocoo.org/
[python-multipart]: https://andrew-d.github.io/python-multipart/
[graphene]: https://graphene-python.org/
[itsdangerous]: https://pythonhosted.org/itsdangerous/
[sqlalchemy]: https://www.sqlalchemy.org
[pyyaml]: https://pyyaml.org/wiki/PyYAMLDocumentation
-[ujson]: https://github.com/esnme/ultrajson
diff --git a/docs/applications.md b/docs/applications.md
index 0092a3b4c..6fb74f19f 100644
--- a/docs/applications.md
+++ b/docs/applications.md
@@ -5,74 +5,57 @@ its other functionality.
```python
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
+from starlette.routing import Route, Mount, WebSocketRoute
from starlette.staticfiles import StaticFiles
-app = Starlette()
-app.debug = True
-app.mount('/static', StaticFiles(directory="static"))
-
-
-@app.route('/')
def homepage(request):
return PlainTextResponse('Hello, world!')
-@app.route('/user/me')
def user_me(request):
username = "John Doe"
return PlainTextResponse('Hello, %s!' % username)
-@app.route('/user/{username}')
def user(request):
username = request.path_params['username']
return PlainTextResponse('Hello, %s!' % username)
-
-@app.websocket_route('/ws')
async def websocket_endpoint(websocket):
await websocket.accept()
await websocket.send_text('Hello, websocket!')
await websocket.close()
-
-@app.on_event('startup')
def startup():
print('Ready to go')
-```
-
-### Instantiating the application
-* `Starlette(debug=False)` - Create a new Starlette application.
-### Adding routes to the application
+routes = [
+ Route('/', homepage),
+ Route('/user/me', user_me),
+ Route('/user/{username}', user),
+ WebSocketRoute('/ws', websocket_endpoint),
+ Mount('/static', StaticFiles(directory="static")),
+]
-You can use any of the following to add handled routes to the application:
-
-* `app.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`.
-* `app.add_websocket_route(path, func)` - Add a websocket session route. The function must be a coroutine, with a signature like `func(session, **kwargs)`.
-* `@app.route(path)` - Add an HTTP route, decorator style.
-* `@app.websocket_route(path)` - Add a WebSocket route, decorator style.
-
-### Adding event handlers to the application
-
-There are two ways to add event handlers:
+app = Starlette(debug=True, routes=routes, on_startup=[startup])
+```
-* `@app.on_event(event_type)` - Add an event, decorator style
-* `app.add_event_handler(event_type, func)` - Add an event through a function call.
+### Instantiating the application
-`event_type` must be specified as either `'startup'` or `'shutdown'`.
+::: starlette.applications.Starlette
+ :docstring:
-### Submounting other applications
+### Storing state on the app instance
-Submounting applications is a powerful way to include reusable ASGI applications.
+You can store arbitrary extra state on the application instance, using the
+generic `app.state` attribute.
-* `app.mount(prefix, app)` - Include an ASGI app, mounted under the given path prefix
+For example:
-### Customizing exception handling
+```python
+app.state.ADMIN_EMAIL = 'admin@example.org'
+```
-You can use either of the following to catch and handle particular types of
-exceptions that occur within the application:
+### Accessing the app instance
-* `app.add_exception_handler(exc_class_or_status_code, handler)` - Add an error handler. The handler function may be either a coroutine or a regular function, with a signature like `func(request, exc) -> response`.
-* `@app.exception_handler(exc_class_or_status_code)` - Add an error handler, decorator style.
-* `app.debug` - Enable or disable error tracebacks in the browser.
+Where a `request` is available (i.e. endpoints and middleware), the app is available on `request.app`.
diff --git a/docs/authentication.md b/docs/authentication.md
index e0e21853c..d4af5b216 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -5,12 +5,15 @@ interfaces will be available in your endpoints.
```python
+from starlette.applications import Starlette
from starlette.authentication import (
AuthenticationBackend, AuthenticationError, SimpleUser, UnauthenticatedUser,
AuthCredentials
)
+from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.responses import PlainTextResponse
+from starlette.routing import Route
import base64
import binascii
@@ -30,21 +33,24 @@ class BasicAuthBackend(AuthenticationBackend):
raise AuthenticationError('Invalid basic auth credentials')
username, _, password = decoded.partition(":")
- # TODO: You'd want to verify the username and password here,
- # possibly by installing `DatabaseMiddleware`
- # and retrieving user information from `request.database`.
+ # TODO: You'd want to verify the username and password here.
return AuthCredentials(["authenticated"]), SimpleUser(username)
-app = Starlette()
-app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
-
-
-@app.route('/')
async def homepage(request):
if request.user.is_authenticated:
- return PlainTextResponse('hello, ' + request.user.display_name)
- return PlainTextResponse('hello, you')
+ return PlainTextResponse('Hello, ' + request.user.display_name)
+ return PlainTextResponse('Hello, you')
+
+routes = [
+ Route("/", endpoint=homepage)
+]
+
+middleware = [
+ Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
+]
+
+app = Starlette(routes=routes, middleware=middleware)
```
## Users
@@ -81,7 +87,6 @@ incoming request includes the required authentication scopes.
from starlette.authentication import requires
-@app.route('/dashboard')
@requires('authenticated')
async def dashboard(request):
...
@@ -93,7 +98,6 @@ You can include either one or multiple required scopes:
from starlette.authentication import requires
-@app.route('/dashboard')
@requires(['authenticated', 'admin'])
async def dashboard(request):
...
@@ -107,7 +111,6 @@ about the URL layout from unauthenticated users.
from starlette.authentication import requires
-@app.route('/dashboard')
@requires(['authenticated', 'admin'], status_code=404)
async def dashboard(request):
...
@@ -120,12 +123,10 @@ page.
from starlette.authentication import requires
-@app.route('/homepage')
async def homepage(request):
...
-@app.route('/dashboard')
@requires('authenticated', redirect='homepage')
async def dashboard(request):
...
@@ -135,7 +136,6 @@ For class-based endpoints, you should wrap the decorator
around a method on the class.
```python
-@app.route("/dashboard")
class Dashboard(HTTPEndpoint):
@requires("authenticated")
async def get(self, request):
diff --git a/docs/background.md b/docs/background.md
index d27fa65fe..e10832a92 100644
--- a/docs/background.md
+++ b/docs/background.md
@@ -13,11 +13,12 @@ Signature: `BackgroundTask(func, *args, **kwargs)`
```python
from starlette.applications import Starlette
from starlette.responses import JSONResponse
+from starlette.routing import Route
from starlette.background import BackgroundTask
-app = Starlette()
-@app.route('/user/signup', methods=['POST'])
+...
+
async def signup(request):
data = await request.json()
username = data['username']
@@ -28,6 +29,14 @@ async def signup(request):
async def send_welcome_email(to_address):
...
+
+
+routes = [
+ ...
+ Route('/user/signup', endpoint=signup, methods=['POST'])
+]
+
+app = Starlette(routes=routes)
```
### BackgroundTasks
@@ -41,9 +50,6 @@ from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.background import BackgroundTasks
-app = Starlette()
-
-@app.route('/user/signup', methods=['POST'])
async def signup(request):
data = await request.json()
username = data['username']
@@ -60,4 +66,9 @@ async def send_welcome_email(to_address):
async def send_admin_notification(username):
...
+routes = [
+ Route('/user/signup', endpoint=signup, methods=['POST'])
+]
+
+app = Starlette(routes=routes)
```
diff --git a/docs/config.md b/docs/config.md
index 74259c5b0..7a93b22e9 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -21,8 +21,7 @@ DATABASE_URL = config('DATABASE_URL', cast=databases.DatabaseURL)
SECRET_KEY = config('SECRET_KEY', cast=Secret)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=CommaSeparatedStrings)
-app = Starlette()
-app.debug = DEBUG
+app = Starlette(debug=DEBUG)
...
```
@@ -86,7 +85,7 @@ type is useful.
CommaSeparatedStrings(['127.0.0.1', 'localhost'])
>>> print(list(settings.ALLOWED_HOSTS))
['127.0.0.1', 'localhost']
->>> print(len(settings.ALLOWED_HOSTS[0]))
+>>> print(len(settings.ALLOWED_HOSTS))
2
>>> print(settings.ALLOWED_HOSTS[0])
'127.0.0.1'
@@ -160,28 +159,27 @@ organisations = sqlalchemy.Table(
```python
from starlette.applications import Starlette
-from starlette.middleware.database import DatabaseMiddleware
+from starlette.middleware import Middleware
from starlette.middleware.session import SessionMiddleware
+from starlette.routing import Route
from myproject import settings
-app = Starlette()
+async def homepage(request):
+ ...
-app.debug = settings.DEBUG
+routes = [
+ Route("/", endpoint=homepage)
+]
-app.add_middleware(
- SessionMiddleware,
- secret_key=settings.SECRET_KEY,
-)
-app.add_middleware(
- DatabaseMiddleware,
- database_url=settings.DATABASE_URL,
- rollback_on_shutdown=settings.TESTING
-)
+middleware = [
+ Middleware(
+ SessionMiddleware,
+ secret_key=settings.SECRET_KEY,
+ )
+]
-@app.route('/', methods=['GET'])
-async def homepage(request):
- ...
+app = Starlette(debug=settings.DEBUG, routes=routes, middleware=middleware)
```
Now let's deal with our test configuration.
diff --git a/docs/database.md b/docs/database.md
index 39e605b75..ca1b85d6a 100644
--- a/docs/database.md
+++ b/docs/database.md
@@ -1,6 +1,6 @@
Starlette is not strictly tied to any particular database implementation.
-You can use it with an asynchronous ORM, such as [GINO](https://python-gino.readthedocs.io/en/latest/),
+You can use it with an asynchronous ORM, such as [GINO](https://python-gino.org/),
or use regular non-async endpoints, and integrate with [SQLAlchemy](https://www.sqlalchemy.org/).
In this documentation we'll demonstrate how to integrate against [the `databases` package](https://github.com/encode/databases),
@@ -27,6 +27,7 @@ import sqlalchemy
from starlette.applications import Starlette
from starlette.config import Config
from starlette.responses import JSONResponse
+from starlette.routing import Route
# Configuration from environment variables or '.env' file.
@@ -45,22 +46,10 @@ notes = sqlalchemy.Table(
sqlalchemy.Column("completed", sqlalchemy.Boolean),
)
-# Main application code.
database = databases.Database(DATABASE_URL)
-app = Starlette()
-
-
-@app.on_event("startup")
-async def startup():
- await database.connect()
-
-@app.on_event("shutdown")
-async def shutdown():
- await database.disconnect()
-
-@app.route("/notes", methods=["GET"])
+# Main application code.
async def list_notes(request):
query = notes.select()
results = await database.fetch_all(query)
@@ -73,8 +62,6 @@ async def list_notes(request):
]
return JSONResponse(content)
-
-@app.route("/notes", methods=["POST"])
async def add_note(request):
data = await request.json()
query = notes.insert().values(
@@ -86,8 +73,22 @@ async def add_note(request):
"text": data["text"],
"completed": data["completed"]
})
+
+routes = [
+ Route("/notes", endpoint=list_notes, methods=["GET"]),
+ Route("/notes", endpoint=add_note, methods=["POST"]),
+]
+
+app = Starlette(
+ routes=routes,
+ on_startup=[database.connect],
+ on_shutdown=[database.disconnect]
+)
```
+Finally, you will need to create the database tables. It is recommended to use
+Alembic, which we briefly go over in [Migrations](#migrations)
+
## Queries
Queries may be made with as [SQLAlchemy Core queries][sqlalchemy-core].
@@ -205,7 +206,7 @@ def create_test_database():
We use the `sqlalchemy_utils` package here for a few helpers in consistently
creating and dropping the database.
"""
- url = str(app.DATABASE_URL)
+ url = str(app.TEST_DATABASE_URL)
engine = create_engine(url)
assert not database_exists(url), 'Test database already exists. Aborting tests.'
create_database(url) # Create the test database.
@@ -264,6 +265,34 @@ target_metadata = app.metadata
...
```
+Then, using our notes example above, create an initial revision:
+
+```shell
+alembic revision -m "Create notes table"
+```
+
+And populate the new file (within `migrations/versions`) with the necessary directives:
+
+```python
+
+def upgrade():
+ op.create_table(
+ 'notes',
+ sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
+ sqlalchemy.Column("text", sqlalchemy.String),
+ sqlalchemy.Column("completed", sqlalchemy.Boolean),
+ )
+
+def downgrade():
+ op.drop_table('notes')
+```
+
+And run your first migration. Our notes app can now run!
+
+```shell
+alembic upgrade head
+```
+
**Running migrations during testing**
It is good practice to ensure that your test suite runs the database migrations
diff --git a/docs/endpoints.md b/docs/endpoints.md
index fe05434c3..1362f5e80 100644
--- a/docs/endpoints.md
+++ b/docs/endpoints.md
@@ -17,30 +17,32 @@ class App(HTTPEndpoint):
```
If you're using a Starlette application instance to handle routing, you can
-dispatch to an `HTTPEndpoint` 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:
+dispatch to an `HTTPEndpoint` class. Make sure to dispatch to the class itself,
+rather than to an instance of the class:
```python
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.endpoints import HTTPEndpoint
+from starlette.routing import Route
-app = Starlette()
-
-
-@app.route("/")
class Homepage(HTTPEndpoint):
async def get(self, request):
return PlainTextResponse(f"Hello, world!")
-@app.route("/{username}")
class User(HTTPEndpoint):
async def get(self, request):
username = request.path_params['username']
return PlainTextResponse(f"Hello, {username}")
+
+routes = [
+ Route("/", Homepage),
+ Route("/{username}", User)
+]
+
+app = Starlette(routes=routes)
```
HTTP endpoint classes will respond with "405 Method not allowed" responses for any
@@ -90,8 +92,8 @@ import uvicorn
from starlette.applications import Starlette
from starlette.endpoints import WebSocketEndpoint, HTTPEndpoint
from starlette.responses import HTMLResponse
+from starlette.routing import Route, WebSocketRoute
-app = Starlette()
html = """
@@ -127,22 +129,20 @@ html = """