From 8c642f04e0608bf537fdd1f76d64c2367fb04d57 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 14 Nov 2019 15:14:22 -0800 Subject: [PATCH] Render templates using Jinja async mode Closes #628 --- datasette/app.py | 6 ++++-- datasette/views/base.py | 2 +- docs/plugins.rst | 23 ++++++++++++----------- tests/fixtures.py | 8 +++++++- tests/test_plugins.py | 18 ++++++++++++++++++ tests/test_templates/show_json.html | 1 + 6 files changed, 43 insertions(+), 15 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 4ba4adfb..02fcf303 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -583,7 +583,9 @@ class Datasette: ), ] ) - self.jinja_env = Environment(loader=template_loader, autoescape=True) + self.jinja_env = Environment( + loader=template_loader, autoescape=True, enable_async=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 @@ -730,5 +732,5 @@ class DatasetteRouter(AsgiRouter): else: template = self.ds.jinja_env.select_template(templates) await asgi_send_html( - send, template.render(info), status=status, headers=headers + send, await template.render_async(info), status=status, headers=headers ) diff --git a/datasette/views/base.py b/datasette/views/base.py index 062c6956..5182479c 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -139,7 +139,7 @@ class BaseView(AsgiView): extra_template_vars.update(extra_vars) return Response.html( - template.render( + await template.render_async( { **context, **{ diff --git a/docs/plugins.rst b/docs/plugins.rst index 6df7ff6a..e5a3d7dd 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -629,7 +629,9 @@ Function that returns a dictionary If you return a function it will be executed. If it returns a dictionary those values will will be merged into the template context. Function that returns an awaitable function that returns a dictionary - You can also return a function which returns an awaitable function which returns a dictionary. This means you can execute additional SQL queries using ``datasette.execute()``. + You can also return a function which returns an awaitable function which returns a dictionary. + +Datasette runs Jinja2 in `async mode `__, which means you can add awaitable functions to the template scope and they will be automatically awaited when they are rendered by the template. Here's an example plugin that returns an authentication object from the ASGI scope: @@ -641,20 +643,19 @@ Here's an example plugin that returns an authentication object from the ASGI sco "auth": request.scope.get("auth") } -And here's an example which returns the current version of SQLite: +And here's an example which adds a ``sql_first(sql_query)`` function which executes a SQL statement and returns the first column of the first row of results: .. code-block:: python @hookimpl - def extra_template_vars(datasette): - async def inner(): - first_db = list(datasette.databases.keys())[0] - return { - "sqlite_version": ( - await datasette.execute(first_db, "select sqlite_version()") - ).rows[0][0] - } - return inner + def extra_template_vars(datasette, database): + async def sql_first(sql, dbname=None): + dbname = dbname or database or next(iter(datasette.databases.keys())) + return (await datasette.execute(dbname, sql)).rows[0][0] + +You can then use the new function in a template like so:: + + SQLite version: {{ sql_first("select sqlite_version()") }} .. _plugin_register_output_renderer: diff --git a/tests/fixtures.py b/tests/fixtures.py index 87e66f99..3e4203f7 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -446,13 +446,19 @@ def render_cell(value, database): @hookimpl def extra_template_vars(template, database, table, view_name, request, datasette): + async def query_database(sql): + first_db = list(datasette.databases.keys())[0] + return ( + await datasette.execute(first_db, sql) + ).rows[0][0] async def inner(): return { "extra_template_vars_from_awaitable": json.dumps({ "template": template, "scope_path": request.scope["path"], "awaitable": True, - }, default=lambda b: b.decode("utf8")) + }, default=lambda b: b.decode("utf8")), + "query_database": query_database, } return inner diff --git a/tests/test_plugins.py b/tests/test_plugins.py index b1c7fd9a..42d063f4 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,5 +1,6 @@ from bs4 import BeautifulSoup as Soup from .fixtures import app_client, make_app_client, TEMP_PLUGIN_SECRET_FILE # noqa +from datasette.utils import sqlite3 import base64 import json import os @@ -214,3 +215,20 @@ def test_plugins_extra_template_vars(restore_working_directory): "awaitable": True, "scope_path": "/-/metadata", } == extra_template_vars_from_awaitable + + +def test_plugins_async_template_function(restore_working_directory): + for client in make_app_client( + template_dir=str(pathlib.Path(__file__).parent / "test_templates") + ): + response = client.get("/-/metadata") + assert response.status == 200 + extra_from_awaitable_function = ( + Soup(response.body, "html.parser") + .select("pre.extra_from_awaitable_function")[0] + .text + ) + expected = ( + sqlite3.connect(":memory:").execute("select sqlite_version()").fetchone()[0] + ) + assert expected == extra_from_awaitable_function diff --git a/tests/test_templates/show_json.html b/tests/test_templates/show_json.html index bbf1bc06..cff04fb4 100644 --- a/tests/test_templates/show_json.html +++ b/tests/test_templates/show_json.html @@ -5,4 +5,5 @@ Test data for extra_template_vars:
{{ extra_template_vars|safe }}
{{ extra_template_vars_from_awaitable|safe }}
+
{{ query_database("select sqlite_version();") }}
{% endblock %}