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

@ -7,9 +7,8 @@ import urllib
import jinja2
import pint
from sanic import response
from sanic.exceptions import NotFound
from sanic.views import HTTPMethodView
from html import escape
from datasette import __version__
from datasette.plugins import pm
@ -26,6 +25,14 @@ from datasette.utils import (
sqlite3,
to_css_class,
)
from datasette.utils.asgi import (
AsgiStream,
AsgiWriter,
AsgiRouter,
AsgiView,
NotFound,
Response,
)
ureg = pint.UnitRegistry()
@ -49,7 +56,14 @@ class DatasetteError(Exception):
self.messagge_is_html = messagge_is_html
class BaseView(HTTPMethodView):
class BaseView(AsgiView):
ds = None
async def head(self, *args, **kwargs):
response = await self.get(*args, **kwargs)
response.body = b""
return response
def _asset_urls(self, key, template, context):
# Flatten list-of-lists from plugins:
seen_urls = set()
@ -104,7 +118,7 @@ class BaseView(HTTPMethodView):
datasette=self.ds,
):
body_scripts.append(jinja2.Markup(script))
return response.html(
return Response.html(
template.render(
{
**context,
@ -136,7 +150,7 @@ class DataView(BaseView):
self.ds = datasette
def options(self, request, *args, **kwargs):
r = response.text("ok")
r = Response.text("ok")
if self.ds.cors:
r.headers["Access-Control-Allow-Origin"] = "*"
return r
@ -146,7 +160,7 @@ class DataView(BaseView):
path = "{}?{}".format(path, request.query_string)
if remove_args:
path = path_with_removed_args(request, remove_args, path=path)
r = response.redirect(path)
r = Response.redirect(path)
r.headers["Link"] = "<{}>; rel=preload".format(path)
if self.ds.cors:
r.headers["Access-Control-Allow-Origin"] = "*"
@ -195,17 +209,17 @@ class DataView(BaseView):
kwargs["table"] = table
if _format:
kwargs["as_format"] = ".{}".format(_format)
elif "table" in kwargs:
elif kwargs.get("table"):
kwargs["table"] = urllib.parse.unquote_plus(kwargs["table"])
should_redirect = "/{}-{}".format(name, expected)
if "table" in kwargs:
if kwargs.get("table"):
should_redirect += "/" + urllib.parse.quote_plus(kwargs["table"])
if "pk_path" in kwargs:
if kwargs.get("pk_path"):
should_redirect += "/" + kwargs["pk_path"]
if "as_format" in kwargs:
if kwargs.get("as_format"):
should_redirect += kwargs["as_format"]
if "as_db" in kwargs:
if kwargs.get("as_db"):
should_redirect += kwargs["as_db"]
if (
@ -246,7 +260,7 @@ class DataView(BaseView):
response_or_template_contexts = await self.data(
request, database, hash, **kwargs
)
if isinstance(response_or_template_contexts, response.HTTPResponse):
if isinstance(response_or_template_contexts, Response):
return response_or_template_contexts
else:
data, _, _ = response_or_template_contexts
@ -282,13 +296,13 @@ class DataView(BaseView):
if not first:
data, _, _ = await self.data(request, database, hash, **kwargs)
if first:
writer.writerow(headings)
await writer.writerow(headings)
first = False
next = data.get("next")
for row in data["rows"]:
if not expanded_columns:
# Simple path
writer.writerow(row)
await writer.writerow(row)
else:
# Look for {"value": "label": } dicts and expand
new_row = []
@ -298,10 +312,10 @@ class DataView(BaseView):
new_row.append(cell["label"])
else:
new_row.append(cell)
writer.writerow(new_row)
await writer.writerow(new_row)
except Exception as e:
print("caught this", e)
r.write(str(e))
await r.write(str(e))
return
content_type = "text/plain; charset=utf-8"
@ -315,7 +329,7 @@ class DataView(BaseView):
)
headers["Content-Disposition"] = disposition
return response.stream(stream_fn, headers=headers, content_type=content_type)
return AsgiStream(stream_fn, headers=headers, content_type=content_type)
async def get_format(self, request, database, args):
""" Determine the format of the response from the request, from URL
@ -363,7 +377,7 @@ class DataView(BaseView):
response_or_template_contexts = await self.data(
request, database, hash, **kwargs
)
if isinstance(response_or_template_contexts, response.HTTPResponse):
if isinstance(response_or_template_contexts, Response):
return response_or_template_contexts
else:
@ -414,17 +428,11 @@ class DataView(BaseView):
if result is None:
raise NotFound("No data")
response_args = {
"content_type": result.get("content_type", "text/plain"),
"status": result.get("status_code", 200),
}
if type(result.get("body")) == bytes:
response_args["body_bytes"] = result.get("body")
else:
response_args["body"] = result.get("body")
r = response.HTTPResponse(**response_args)
r = Response(
body=result.get("body"),
status=result.get("status_code", 200),
content_type=result.get("content_type", "text/plain"),
)
else:
extras = {}
if callable(extra_template_data):

View file

@ -1,10 +1,9 @@
import os
from sanic import response
from datasette.utils import to_css_class, validate_sql_select
from datasette.utils.asgi import AsgiFileDownload
from .base import DataView, DatasetteError
from .base import DatasetteError, DataView
class DatabaseView(DataView):
@ -79,8 +78,8 @@ class DatabaseDownload(DataView):
if not db.path:
raise DatasetteError("Cannot download database", status=404)
filepath = db.path
return await response.file_stream(
return AsgiFileDownload(
filepath,
filename=os.path.basename(filepath),
mime_type="application/octet-stream",
content_type="application/octet-stream",
)

View file

@ -1,9 +1,8 @@
import hashlib
import json
from sanic import response
from datasette.utils import CustomJSONEncoder
from datasette.utils.asgi import Response
from datasette.version import __version__
from .base import BaseView
@ -104,9 +103,9 @@ class IndexView(BaseView):
headers = {}
if self.ds.cors:
headers["Access-Control-Allow-Origin"] = "*"
return response.HTTPResponse(
return Response(
json.dumps({db["name"]: db for db in databases}, cls=CustomJSONEncoder),
content_type="application/json",
content_type="application/json; charset=utf-8",
headers=headers,
)
else:

View file

@ -1,5 +1,5 @@
import json
from sanic import response
from datasette.utils.asgi import Response
from .base import BaseView
@ -17,8 +17,10 @@ class JsonDataView(BaseView):
headers = {}
if self.ds.cors:
headers["Access-Control-Allow-Origin"] = "*"
return response.HTTPResponse(
json.dumps(data), content_type="application/json", headers=headers
return Response(
json.dumps(data),
content_type="application/json; charset=utf-8",
headers=headers,
)
else:

View file

@ -3,13 +3,12 @@ import itertools
import json
import jinja2
from sanic.exceptions import NotFound
from sanic.request import RequestParameters
from datasette.plugins import pm
from datasette.utils import (
CustomRow,
QueryInterrupted,
RequestParameters,
append_querystring,
compound_keys_after_sql,
escape_sqlite,
@ -24,6 +23,7 @@ from datasette.utils import (
urlsafe_components,
value_as_boolean,
)
from datasette.utils.asgi import NotFound
from datasette.filters import Filters
from .base import DataView, DatasetteError, ureg
@ -219,8 +219,7 @@ class TableView(RowTableShared):
if is_view:
order_by = ""
# We roll our own query_string decoder because by default Sanic
# drops anything with an empty value e.g. ?name__exact=
# Ensure we don't drop anything with an empty value e.g. ?name__exact=
args = RequestParameters(
urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
)