First partially working version of ASGI-powered Datasette #272

Lots still to do:

* Static files are not being served
* Streaming CSV files don't work
* Tests all fail
* Some URLs (e.g. the 'next' link on tables) are incorrect

But... the server does start up and you can browse databases/tables
This commit is contained in:
Simon Willison 2019-06-22 18:06:24 -07:00
commit 180d5be811
4 changed files with 123 additions and 155 deletions

View file

@ -17,7 +17,7 @@ from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
from sanic import Sanic, response from sanic import Sanic, response
from sanic.exceptions import InvalidUsage, NotFound from sanic.exceptions import InvalidUsage, NotFound
from .views.base import DatasetteError, ureg from .views.base import DatasetteError, ureg, AsgiRouter
from .views.database import DatabaseDownload, DatabaseView from .views.database import DatabaseDownload, DatabaseView
from .views.index import IndexView from .views.index import IndexView
from .views.special import JsonDataView from .views.special import JsonDataView
@ -126,8 +126,15 @@ CONFIG_OPTIONS = (
DEFAULT_CONFIG = {option.name: option.default for option in CONFIG_OPTIONS} DEFAULT_CONFIG = {option.name: option.default for option in CONFIG_OPTIONS}
async def favicon(request): async def favicon(scope, recieve, send):
return response.text("") await send(
{
"type": "http.response.start",
"status": 200,
"headers": [[b"content-type", b"text/plain"]],
}
)
await send({"type": "http.response.body", "body": b""})
class Datasette: class Datasette:
@ -543,21 +550,8 @@ class Datasette:
self.renderers[renderer["extension"]] = renderer["callback"] self.renderers[renderer["extension"]] = renderer["callback"]
def app(self): def app(self):
class TracingSanic(Sanic): "Returns an ASGI app function that serves the whole of Datasette"
async def handle_request(self, request, write_callback, stream_callback): # TODO: re-implement ?_trace= mechanism, see class TracingSanic
if request.args.get("_trace"):
request["traces"] = []
request["trace_start"] = time.time()
with capture_traces(request["traces"]):
await super().handle_request(
request, write_callback, stream_callback
)
else:
await super().handle_request(
request, write_callback, stream_callback
)
app = TracingSanic(__name__)
default_templates = str(app_root / "datasette" / "templates") default_templates = str(app_root / "datasette" / "templates")
template_paths = [] template_paths = []
if self.template_dir: if self.template_dir:
@ -588,134 +582,86 @@ class Datasette:
pm.hook.prepare_jinja2_environment(env=self.jinja_env) pm.hook.prepare_jinja2_environment(env=self.jinja_env)
self.register_renderers() self.register_renderers()
routes = []
def add_route(view, regex):
routes.append((regex, view))
# Generate a regex snippet to match all registered renderer file extensions # Generate a regex snippet to match all registered renderer file extensions
renderer_regex = "|".join(r"\." + key for key in self.renderers.keys()) renderer_regex = "|".join(r"\." + key for key in self.renderers.keys())
app.add_route(IndexView.as_view(self), r"/<as_format:(\.jsono?)?$>") add_route(IndexView.as_asgi(self), r"/(?P<as_format>(\.jsono?)?$)")
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires # TODO: /favicon.ico and /-/static/ deserve far-future cache expires
app.add_route(favicon, "/favicon.ico") add_route(favicon, "/favicon.ico")
app.static("/-/static/", str(app_root / "datasette" / "static")) # # TODO: re-enable the static bits
for path, dirname in self.static_mounts: # app.static("/-/static/", str(app_root / "datasette" / "static"))
app.static(path, dirname) # for path, dirname in self.static_mounts:
# Mount any plugin static/ directories # app.static(path, dirname)
for plugin in get_plugins(pm): # # Mount any plugin static/ directories
if plugin["static_path"]: # for plugin in get_plugins(pm):
modpath = "/-/static-plugins/{}/".format(plugin["name"]) # if plugin["static_path"]:
app.static(modpath, plugin["static_path"]) # modpath = "/-/static-plugins/{}/".format(plugin["name"])
app.add_route( # app.static(modpath, plugin["static_path"])
JsonDataView.as_view(self, "metadata.json", lambda: self._metadata), add_route(
r"/-/metadata<as_format:(\.json)?$>", JsonDataView.as_asgi(self, "metadata.json", lambda: self._metadata),
r"/-/metadata(?P<as_format>(\.json)?)$",
) )
app.add_route( add_route(
JsonDataView.as_view(self, "versions.json", self.versions), JsonDataView.as_asgi(self, "versions.json", self.versions),
r"/-/versions<as_format:(\.json)?$>", r"/-/versions(?P<as_format>(\.json)?)$",
) )
app.add_route( add_route(
JsonDataView.as_view(self, "plugins.json", self.plugins), JsonDataView.as_asgi(self, "plugins.json", self.plugins),
r"/-/plugins<as_format:(\.json)?$>", r"/-/plugins(?P<as_format>(\.json)?)$",
) )
app.add_route( add_route(
JsonDataView.as_view(self, "config.json", lambda: self._config), JsonDataView.as_asgi(self, "config.json", lambda: self._config),
r"/-/config<as_format:(\.json)?$>", r"/-/config(?P<as_format>(\.json)?)$",
) )
app.add_route( add_route(
JsonDataView.as_view(self, "databases.json", self.connected_databases), JsonDataView.as_asgi(self, "databases.json", self.connected_databases),
r"/-/databases<as_format:(\.json)?$>", r"/-/databases(?P<as_format>(\.json)?)$",
) )
app.add_route( add_route(
DatabaseDownload.as_view(self), r"/<db_name:[^/]+?><as_db:(\.db)$>" DatabaseDownload.as_asgi(self), r"/(?P<db_name>[^/]+?)(?P<as_db>\.db)$"
) )
app.add_route( add_route(
DatabaseView.as_view(self), DatabaseView.as_asgi(self),
r"/<db_name:[^/]+?><as_format:(" + renderer_regex + r"|.jsono|\.csv)?$>", r"/(?P<db_name>[^/]+?)(?P<as_format>"
+ renderer_regex
+ r"|.jsono|\.csv)?$",
) )
app.add_route( add_route(
TableView.as_view(self), r"/<db_name:[^/]+>/<table_and_format:[^/]+?$>" TableView.as_asgi(self),
r"/(?P<db_name>[^/]+)/(?P<table_and_format>[^/]+?$)",
) )
app.add_route( add_route(
RowView.as_view(self), RowView.as_asgi(self),
r"/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_format:(" r"/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_format:("
+ renderer_regex + renderer_regex
+ r")?$>", + r")?$>",
) )
self.register_custom_units() self.register_custom_units()
app = AsgiRouter(routes)
# On 404 with a trailing slash redirect to path without that slash: # On 404 with a trailing slash redirect to path without that slash:
# pylint: disable=unused-variable # pylint: disable=unused-variable
@app.middleware("response") # TODO: re-enable this
def redirect_on_404_with_trailing_slash(request, original_response): # @app.middleware("response")
if original_response.status == 404 and request.path.endswith("/"): # def redirect_on_404_with_trailing_slash(request, original_response):
path = request.path.rstrip("/") # if original_response.status == 404 and request.path.endswith("/"):
if request.query_string: # path = request.path.rstrip("/")
path = "{}?{}".format(path, request.query_string) # if request.query_string:
return response.redirect(path) # path = "{}?{}".format(path, request.query_string)
# return response.redirect(path)
@app.middleware("response")
async def add_traces_to_response(request, response):
if request.get("traces") is None:
return
traces = request["traces"]
trace_info = {
"request_duration_ms": 1000 * (time.time() - request["trace_start"]),
"sum_trace_duration_ms": sum(t["duration_ms"] for t in traces),
"num_traces": len(traces),
"traces": traces,
}
if "text/html" in response.content_type and b"</body>" in response.body:
extra = json.dumps(trace_info, indent=2)
extra_html = "<pre>{}</pre></body>".format(extra).encode("utf8")
response.body = response.body.replace(b"</body>", extra_html)
elif "json" in response.content_type and response.body.startswith(b"{"):
data = json.loads(response.body.decode("utf8"))
if "_trace" not in data:
data["_trace"] = trace_info
response.body = json.dumps(data).encode("utf8")
@app.exception(Exception)
def on_exception(request, exception):
title = None
help = None
if isinstance(exception, NotFound):
status = 404
info = {}
message = exception.args[0]
elif isinstance(exception, InvalidUsage):
status = 405
info = {}
message = exception.args[0]
elif isinstance(exception, DatasetteError):
status = exception.status
info = exception.error_dict
message = exception.message
if exception.messagge_is_html:
message = Markup(message)
title = exception.title
else:
status = 500
info = {}
message = str(exception)
traceback.print_exc()
templates = ["500.html"]
if status != 500:
templates = ["{}.html".format(status)] + templates
info.update(
{"ok": False, "error": message, "status": status, "title": title}
)
if request is not None and request.path.split("?")[0].endswith(".json"):
r = response.json(info, status=status)
else:
template = self.jinja_env.select_template(templates)
r = response.html(template.render(info), status=status)
if self.cors:
r.headers["Access-Control-Allow-Origin"] = "*"
return r
# First time server starts up, calculate table counts for immutable databases # First time server starts up, calculate table counts for immutable databases
@app.listener("before_server_start") # TODO: re-enable this mechanism
async def setup_db(app, loop): # @app.listener("before_server_start")
for dbname, database in self.databases.items(): # async def setup_db(app, loop):
if not database.is_mutable: # for dbname, database in self.databases.items():
await database.table_counts(limit=60 * 60 * 1000) # if not database.is_mutable:
# await database.table_counts(limit=60 * 60 * 1000)
return app return app

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import uvicorn
import click import click
from click import formatting from click import formatting
from click_default_group import DefaultGroup from click_default_group import DefaultGroup
@ -354,4 +355,4 @@ def serve(
asyncio.get_event_loop().run_until_complete(ds.run_sanity_checks()) asyncio.get_event_loop().run_until_complete(ds.run_sanity_checks())
# Start the server # Start the server
ds.app().run(host=host, port=port, debug=debug) uvicorn.run(ds.app(), host=host, port=port, log_level="info")

View file

@ -10,6 +10,7 @@ import pint
from sanic import response from sanic import response
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.views import HTTPMethodView from sanic.views import HTTPMethodView
from sanic.request import Request as SanicRequest
from datasette import __version__ from datasette import __version__
from datasette.plugins import pm from datasette.plugins import pm
@ -54,7 +55,7 @@ class AsgiRouter:
routes = routes or [] routes = routes or []
self.routes = [ self.routes = [
# Compile any strings to regular expressions # Compile any strings to regular expressions
(re.compile(pattern) if isinstance(pattern, str) else pattern, view) ((re.compile(pattern) if isinstance(pattern, str) else pattern), view)
for pattern, view in routes for pattern, view in routes
] ]
@ -77,29 +78,48 @@ class AsgiRouter:
await send({"type": "http.response.body", "body": b"<h1>404</h1>"}) await send({"type": "http.response.body", "body": b"<h1>404</h1>"})
async def hello_world(scope, receive, send):
assert scope["type"] == "http"
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [[b"content-type", b"text/html"]],
}
)
await send({"type": "http.response.body", "body": b"<h1>Hello world!</h1>"})
app = AsgiRouter([("/hello/", hello_world)])
class AsgiView(HTTPMethodView): class AsgiView(HTTPMethodView):
async def asgi(self, scope, receive, send): @classmethod
# Uses scope to create a Sanic-compatible request object, def as_asgi(cls, *class_args, **class_kwargs):
# then dispatches that to self.get(...) or self.options(...) async def view(scope, receive, send):
# along with keyword arguments that were already tucked # Uses scope to create a Sanic-compatible request object,
# into scope["url_route"]["kwargs"] by the router # then dispatches that to self.get(...) or self.options(...)
# https://channels.readthedocs.io/en/latest/topics/routing.html#urlrouter # along with keyword arguments that were already tucked
pass # into scope["url_route"]["kwargs"] by the router
# https://channels.readthedocs.io/en/latest/topics/routing.html#urlrouter
path = scope.get("raw_path", scope["path"].encode("utf8"))
if scope["query_string"]:
path = path + b"?" + scope["query_string"]
request = SanicRequest(path, {}, "1.1", scope["method"], None)
class Woo:
def get_extra_info(self, key):
return False
request.app = Woo()
request.app.websocket_enabled = False
request.transport = Woo()
self = view.view_class(*class_args, **class_kwargs)
response = await self.dispatch_request(
request, **scope["url_route"]["kwargs"]
)
await send(
{
"type": "http.response.start",
"status": response.status,
"headers": [
[key.encode("utf-8"), value.encode("utf-8")]
for key, value in response.headers.items()
],
}
)
await send({"type": "http.response.body", "body": response.body})
view.view_class = cls
view.__doc__ = cls.__doc__
view.__module__ = cls.__module__
view.__name__ = cls.__name__
return view
class BaseView(AsgiView): class BaseView(AsgiView):
@ -250,17 +270,17 @@ class DataView(BaseView):
kwargs["table"] = table kwargs["table"] = table
if _format: if _format:
kwargs["as_format"] = ".{}".format(_format) kwargs["as_format"] = ".{}".format(_format)
elif "table" in kwargs: elif kwargs.get("table"):
kwargs["table"] = urllib.parse.unquote_plus(kwargs["table"]) kwargs["table"] = urllib.parse.unquote_plus(kwargs["table"])
should_redirect = "/{}-{}".format(name, expected) should_redirect = "/{}-{}".format(name, expected)
if "table" in kwargs: if kwargs.get("table"):
should_redirect += "/" + urllib.parse.quote_plus(kwargs["table"]) should_redirect += "/" + urllib.parse.quote_plus(kwargs["table"])
if "pk_path" in kwargs: if kwargs.get("pk_path"):
should_redirect += "/" + kwargs["pk_path"] should_redirect += "/" + kwargs["pk_path"]
if "as_format" in kwargs: if kwargs.get("as_format"):
should_redirect += kwargs["as_format"] should_redirect += kwargs["as_format"]
if "as_db" in kwargs: if kwargs.get("as_db"):
should_redirect += kwargs["as_db"] should_redirect += kwargs["as_db"]
if ( if (

View file

@ -37,7 +37,7 @@ setup(
author="Simon Willison", author="Simon Willison",
license="Apache License, Version 2.0", license="Apache License, Version 2.0",
url="https://github.com/simonw/datasette", url="https://github.com/simonw/datasette",
packages=find_packages(exclude='tests'), packages=find_packages(exclude="tests"),
package_data={"datasette": ["templates/*.html"]}, package_data={"datasette": ["templates/*.html"]},
include_package_data=True, include_package_data=True,
install_requires=[ install_requires=[
@ -48,6 +48,7 @@ setup(
"hupper==1.0", "hupper==1.0",
"pint==0.8.1", "pint==0.8.1",
"pluggy>=0.12.0", "pluggy>=0.12.0",
"uvicorn>=0.8.1",
], ],
entry_points=""" entry_points="""
[console_scripts] [console_scripts]