diff --git a/datasette/app.py b/datasette/app.py index cdfcb11d..73730521 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -439,35 +439,47 @@ class Datasette: self.executor, sql_operation_in_thread ) + def asgi_app(self): + self.configure_jinja() + from starlette.routing import Router, Mount, Route + from starlette.responses import FileResponse + from starlette.staticfiles import StaticFiles + + async def favicon(request): + return FileResponse( + path=str(app_root / "datasette" / "static" / "favicon.ico"), + media_type="image/x-icon" + ) + return Router([ + Route( + '/', + endpoint=IndexView(self).starlette_asgi_endpoint(), + methods=['GET'] + ), + Route( + '/favicon.ico', + favicon, + methods=['GET'] + ), + Mount( + '/-/static', + app=StaticFiles(directory=app_root / "datasette" / "static") + ), + Route( + '/{db_name}', + endpoint=DatabaseView(self).starlette_asgi_endpoint(), + methods=['GET'] + ), + Route( + '/{db_name}/{table}', + endpoint=TableView(self).starlette_asgi_endpoint(), + methods=['GET'] + ), + ]) + def app(self): app = Sanic(__name__) - default_templates = str(app_root / "datasette" / "templates") - template_paths = [] - if self.template_dir: - template_paths.append(self.template_dir) - template_paths.extend( - [ - plugin["templates_path"] - for plugin in get_plugins(pm) - if plugin["templates_path"] - ] - ) - template_paths.append(default_templates) - template_loader = ChoiceLoader( - [ - FileSystemLoader(template_paths), - # Support {% extends "default:table.html" %}: - PrefixLoader( - {"default": FileSystemLoader(default_templates)}, delimiter=":" - ), - ] - ) - self.jinja_env = Environment(loader=template_loader, autoescape=True) - self.jinja_env.filters["escape_css_string"] = escape_css_string - self.jinja_env.filters["quote_plus"] = lambda u: urllib.parse.quote_plus(u) - self.jinja_env.filters["escape_sqlite"] = escape_sqlite - self.jinja_env.filters["to_css_class"] = to_css_class - pm.hook.prepare_jinja2_environment(env=self.jinja_env) + self.configure_jinja() app.add_route(IndexView.as_view(self), r"/") # TODO: /favicon.ico and /-/static/ deserve far-future cache expires app.add_route(favicon, "/favicon.ico") @@ -561,3 +573,33 @@ class Datasette: return response.html(template.render(info), status=status) return app + + def configure_jinja(self): + default_templates = str(app_root / "datasette" / "templates") + template_paths = [] + if self.template_dir: + template_paths.append(self.template_dir) + template_paths.extend( + [ + plugin["templates_path"] + for plugin in get_plugins(pm) + if plugin["templates_path"] + ] + ) + template_paths.append(default_templates) + template_loader = ChoiceLoader( + [ + FileSystemLoader(template_paths), + # Support {% extends "default:table.html" %}: + PrefixLoader( + {"default": FileSystemLoader(default_templates)}, delimiter=":" + ), + ] + ) + self.jinja_env = Environment(loader=template_loader, autoescape=True) + self.jinja_env.filters["escape_css_string"] = escape_css_string + self.jinja_env.filters["quote_plus"] = lambda u: urllib.parse.quote_plus(u) + self.jinja_env.filters["escape_sqlite"] = escape_sqlite + self.jinja_env.filters["to_css_class"] = to_css_class + pm.hook.prepare_jinja2_environment(env=self.jinja_env) + diff --git a/datasette/cli.py b/datasette/cli.py index 2cadabbf..607fca36 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -256,6 +256,9 @@ def package( "-h", "--host", default="127.0.0.1", help="host for server, defaults to 127.0.0.1" ) @click.option("-p", "--port", default=8001, help="port for server, defaults to 8001") +@click.option( + "--asgi", is_flag=True, help="Run in ASGI mode" +) @click.option( "--debug", is_flag=True, help="Enable debug mode - useful for development" ) @@ -316,6 +319,7 @@ def serve( files, host, port, + asgi, debug, reload, cors, @@ -373,4 +377,9 @@ def serve( ) # Force initial hashing/table counting ds.inspect() - ds.app().run(host=host, port=port, debug=debug) + if asgi: + import uvicorn + app = ds.asgi_app() + uvicorn.run(app, host, port, log_level="info") + else: + ds.app().run(host=host, port=port, debug=debug) diff --git a/datasette/views/base.py b/datasette/views/base.py index 119b376b..bb41ca46 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -30,6 +30,9 @@ from datasette.utils import ( value_as_boolean, ) +from starlette.endpoints import HTTPEndpoint as StarletteEndpoint +from starlette.responses import Response as StarletteResponse + ureg = pint.UnitRegistry() HASH_LENGTH = 7 @@ -45,6 +48,39 @@ class DatasetteError(Exception): self.messagge_is_html = messagge_is_html +class RequestWrapper: + # Implements the subset of the Sanic request that my code uses + def __init__(self, starlette_request): + self._request = starlette_request + + @property + def url(self): + return self._request.url._url + + @property + def path(self): + return self._request.url.path + + @property + def query_string(self): + q = self._request._scope["query_string"].decode("utf8") + return q + + @property + def args(self): + # Key/list-of-values + # There's probably a better way to do this: + d = {} + for key, value in self._request.query_params.items(): + d.setdefault(key, []).append(value) + return d + + @property + def raw_args(self): + # Flat key/first-value dictionary + return dict(self.args) + + class RenderMixin(HTTPMethodView): def _asset_urls(self, key, template, context): @@ -73,6 +109,17 @@ class RenderMixin(HTTPMethodView): else: yield {"url": url} + def starlette_asgi_endpoint(self): + class App(StarletteEndpoint): + async def get(other_self, request): + #import pdb; pdb.set_trace() + sanic_response = await self.get( + RequestWrapper(request), **request.path_params, + ) + # TODO: media_type, status_code, headers etc + return StarletteResponse(sanic_response.body) + return App + def render(self, templates, **context): template = self.ds.jinja_env.select_template(templates) select_templates = [ diff --git a/datasette/views/index.py b/datasette/views/index.py index 32c04585..129d1f98 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -13,7 +13,7 @@ class IndexView(RenderMixin): def __init__(self, datasette): self.ds = datasette - async def get(self, request, as_format): + async def get(self, request, as_format=''): databases = [] for key, info in sorted(self.ds.inspect().items()): tables = [t for t in info["tables"].values() if not t["hidden"]]