Port Datasette from Sanic to ASGI + Uvicorn (#518)

Datasette now uses ASGI internally, and no longer depends on Sanic.

It now uses Uvicorn as the underlying HTTP server.

This was thirteen months in the making... for full details see the issue:

https://github.com/simonw/datasette/issues/272

And for a full sequence of commits plus commentary, see the pull request:

https://github.com/simonw/datasette/pull/518
This commit is contained in:
Simon Willison 2019-06-23 20:13:09 -07:00 committed by GitHub
commit ba8db9679f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1510 additions and 947 deletions

View file

@ -1,5 +1,7 @@
from datasette.app import Datasette
from datasette.utils import sqlite3
from asgiref.testing import ApplicationCommunicator
from asgiref.sync import async_to_sync
import itertools
import json
import os
@ -10,16 +12,82 @@ import sys
import string
import tempfile
import time
from urllib.parse import unquote
class TestResponse:
def __init__(self, status, headers, body):
self.status = status
self.headers = headers
self.body = body
@property
def json(self):
return json.loads(self.text)
@property
def text(self):
return self.body.decode("utf8")
class TestClient:
def __init__(self, sanic_test_client):
self.sanic_test_client = sanic_test_client
max_redirects = 5
def get(self, path, allow_redirects=True):
return self.sanic_test_client.get(
path, allow_redirects=allow_redirects, gather_request=False
def __init__(self, asgi_app):
self.asgi_app = asgi_app
@async_to_sync
async def get(self, path, allow_redirects=True, redirect_count=0, method="GET"):
return await self._get(path, allow_redirects, redirect_count, method)
async def _get(self, path, allow_redirects=True, redirect_count=0, method="GET"):
query_string = b""
if "?" in path:
path, _, query_string = path.partition("?")
query_string = query_string.encode("utf8")
instance = ApplicationCommunicator(
self.asgi_app,
{
"type": "http",
"http_version": "1.0",
"method": method,
"path": unquote(path),
"raw_path": path.encode("ascii"),
"query_string": query_string,
"headers": [[b"host", b"localhost"]],
},
)
await instance.send_input({"type": "http.request"})
# First message back should be response.start with headers and status
messages = []
start = await instance.receive_output(2)
messages.append(start)
assert start["type"] == "http.response.start"
headers = dict(
[(k.decode("utf8"), v.decode("utf8")) for k, v in start["headers"]]
)
status = start["status"]
# Now loop until we run out of response.body
body = b""
while True:
message = await instance.receive_output(2)
messages.append(message)
assert message["type"] == "http.response.body"
body += message["body"]
if not message.get("more_body"):
break
response = TestResponse(status, headers, body)
if allow_redirects and response.status in (301, 302):
assert (
redirect_count < self.max_redirects
), "Redirected {} times, max_redirects={}".format(
redirect_count, self.max_redirects
)
location = response.headers["Location"]
return await self._get(
location, allow_redirects=True, redirect_count=redirect_count + 1
)
return response
def make_app_client(
@ -32,6 +100,7 @@ def make_app_client(
is_immutable=False,
extra_databases=None,
inspect_data=None,
static_mounts=None,
):
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, filename)
@ -73,9 +142,10 @@ def make_app_client(
plugins_dir=plugins_dir,
config=config,
inspect_data=inspect_data,
static_mounts=static_mounts,
)
ds.sqlite_functions.append(("sleep", 1, lambda n: time.sleep(float(n))))
client = TestClient(ds.app().test_client)
client = TestClient(ds.app())
client.ds = ds
yield client
@ -88,7 +158,7 @@ def app_client():
@pytest.fixture(scope="session")
def app_client_no_files():
ds = Datasette([])
client = TestClient(ds.app().test_client)
client = TestClient(ds.app())
client.ds = ds
yield client

View file

@ -22,6 +22,7 @@ import urllib
def test_homepage(app_client):
response = app_client.get("/.json")
assert response.status == 200
assert "application/json; charset=utf-8" == response.headers["content-type"]
assert response.json.keys() == {"fixtures": 0}.keys()
d = response.json["fixtures"]
assert d["name"] == "fixtures"
@ -771,8 +772,8 @@ def test_paginate_tables_and_views(app_client, path, expected_rows, expected_pag
fetched.extend(response.json["rows"])
path = response.json["next_url"]
if path:
assert response.json["next"]
assert urllib.parse.urlencode({"_next": response.json["next"]}) in path
path = path.replace("http://localhost", "")
assert count < 30, "Possible infinite loop detected"
assert expected_rows == len(fetched)
@ -812,6 +813,8 @@ def test_paginate_compound_keys(app_client):
response = app_client.get(path)
fetched.extend(response.json["rows"])
path = response.json["next_url"]
if path:
path = path.replace("http://localhost", "")
assert page < 100
assert 1001 == len(fetched)
assert 21 == page
@ -833,6 +836,8 @@ def test_paginate_compound_keys_with_extra_filters(app_client):
response = app_client.get(path)
fetched.extend(response.json["rows"])
path = response.json["next_url"]
if path:
path = path.replace("http://localhost", "")
assert 2 == page
expected = [r[3] for r in generate_compound_rows(1001) if "d" in r[3]]
assert expected == [f["content"] for f in fetched]
@ -881,6 +886,8 @@ def test_sortable(app_client, query_string, sort_key, human_description_en):
assert human_description_en == response.json["human_description_en"]
fetched.extend(response.json["rows"])
path = response.json["next_url"]
if path:
path = path.replace("http://localhost", "")
assert 5 == page
expected = list(generate_sortable_rows(201))
expected.sort(key=sort_key)
@ -1191,6 +1198,7 @@ def test_plugins_json(app_client):
def test_versions_json(app_client):
response = app_client.get("/-/versions.json")
assert "python" in response.json
assert "3.0" == response.json.get("asgi")
assert "version" in response.json["python"]
assert "full" in response.json["python"]
assert "datasette" in response.json
@ -1236,6 +1244,8 @@ def test_page_size_matching_max_returned_rows(
fetched.extend(response.json["rows"])
assert len(response.json["rows"]) in (1, 50)
path = response.json["next_url"]
if path:
path = path.replace("http://localhost", "")
assert 201 == len(fetched)

View file

@ -46,7 +46,7 @@ def test_table_csv(app_client):
response = app_client.get("/fixtures/simple_primary_key.csv")
assert response.status == 200
assert not response.headers.get("Access-Control-Allow-Origin")
assert "text/plain; charset=utf-8" == response.headers["Content-Type"]
assert "text/plain; charset=utf-8" == response.headers["content-type"]
assert EXPECTED_TABLE_CSV == response.text
@ -59,7 +59,7 @@ def test_table_csv_cors_headers(app_client_with_cors):
def test_table_csv_with_labels(app_client):
response = app_client.get("/fixtures/facetable.csv?_labels=1")
assert response.status == 200
assert "text/plain; charset=utf-8" == response.headers["Content-Type"]
assert "text/plain; charset=utf-8" == response.headers["content-type"]
assert EXPECTED_TABLE_WITH_LABELS_CSV == response.text
@ -68,14 +68,14 @@ def test_custom_sql_csv(app_client):
"/fixtures.csv?sql=select+content+from+simple_primary_key+limit+2"
)
assert response.status == 200
assert "text/plain; charset=utf-8" == response.headers["Content-Type"]
assert "text/plain; charset=utf-8" == response.headers["content-type"]
assert EXPECTED_CUSTOM_CSV == response.text
def test_table_csv_download(app_client):
response = app_client.get("/fixtures/simple_primary_key.csv?_dl=1")
assert response.status == 200
assert "text/csv; charset=utf-8" == response.headers["Content-Type"]
assert "text/csv; charset=utf-8" == response.headers["content-type"]
expected_disposition = 'attachment; filename="simple_primary_key.csv"'
assert expected_disposition == response.headers["Content-Disposition"]

View file

@ -8,6 +8,7 @@ from .fixtures import ( # noqa
METADATA,
)
import json
import pathlib
import pytest
import re
import urllib.parse
@ -16,6 +17,7 @@ import urllib.parse
def test_homepage(app_client_two_attached_databases):
response = app_client_two_attached_databases.get("/")
assert response.status == 200
assert "text/html; charset=utf-8" == response.headers["content-type"]
soup = Soup(response.body, "html.parser")
assert "Datasette Fixtures" == soup.find("h1").text
assert (
@ -44,6 +46,29 @@ def test_homepage(app_client_two_attached_databases):
] == table_links
def test_http_head(app_client):
response = app_client.get("/", method="HEAD")
assert response.status == 200
def test_static(app_client):
response = app_client.get("/-/static/app2.css")
assert response.status == 404
response = app_client.get("/-/static/app.css")
assert response.status == 200
assert "text/css" == response.headers["content-type"]
def test_static_mounts():
for client in make_app_client(
static_mounts=[("custom-static", str(pathlib.Path(__file__).parent))]
):
response = client.get("/custom-static/test_html.py")
assert response.status == 200
response = client.get("/custom-static/not_exists.py")
assert response.status == 404
def test_memory_database_page():
for client in make_app_client(memory=True):
response = client.get("/:memory:")

View file

@ -3,11 +3,11 @@ Tests for various datasette helper functions.
"""
from datasette import utils
from datasette.utils.asgi import Request
from datasette.filters import Filters
import json
import os
import pytest
from sanic.request import Request
import sqlite3
import tempfile
from unittest.mock import patch
@ -53,7 +53,7 @@ def test_urlsafe_components(path, expected):
],
)
def test_path_with_added_args(path, added_args, expected):
request = Request(path.encode("utf8"), {}, "1.1", "GET", None)
request = Request.fake(path)
actual = utils.path_with_added_args(request, added_args)
assert expected == actual
@ -67,11 +67,11 @@ def test_path_with_added_args(path, added_args, expected):
],
)
def test_path_with_removed_args(path, args, expected):
request = Request(path.encode("utf8"), {}, "1.1", "GET", None)
request = Request.fake(path)
actual = utils.path_with_removed_args(request, args)
assert expected == actual
# Run the test again but this time use the path= argument
request = Request("/".encode("utf8"), {}, "1.1", "GET", None)
request = Request.fake("/")
actual = utils.path_with_removed_args(request, args, path=path)
assert expected == actual
@ -84,7 +84,7 @@ def test_path_with_removed_args(path, args, expected):
],
)
def test_path_with_replaced_args(path, args, expected):
request = Request(path.encode("utf8"), {}, "1.1", "GET", None)
request = Request.fake(path)
actual = utils.path_with_replaced_args(request, args)
assert expected == actual
@ -363,7 +363,7 @@ def test_table_columns():
],
)
def test_path_with_format(path, format, extra_qs, expected):
request = Request(path.encode("utf8"), {}, "1.1", "GET", None)
request = Request.fake(path)
actual = utils.path_with_format(request, format, extra_qs)
assert expected == actual