From 52c4387c7d37c867104e3728cc1f4c4d1e100642 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 27 May 2020 19:21:41 -0700 Subject: [PATCH] Redesigned register_output_renderer plugin hook, closes #581 --- datasette/app.py | 6 +- datasette/views/base.py | 18 +++++- datasette/views/database.py | 1 + docs/plugins.rst | 53 ++++++++++++--- tests/plugins/register_output_renderer.py | 42 ++++++++++++ tests/test_api.py | 20 +++--- tests/test_html.py | 4 ++ tests/test_plugins.py | 78 +++++++++++++++++++++++ 8 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 tests/plugins/register_output_renderer.py diff --git a/datasette/app.py b/datasette/app.py index f1fcc5eb..941b2895 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -586,7 +586,11 @@ class Datasette: hook_renderers.append(hook) for renderer in hook_renderers: - self.renderers[renderer["extension"]] = renderer["callback"] + self.renderers[renderer["extension"]] = ( + # It used to be called "callback" - remove this in Datasette 1.0 + renderer.get("render") + or renderer["callback"] + ) async def render_template( self, templates, context=None, request=None, view_name=None diff --git a/datasette/views/base.py b/datasette/views/base.py index f5eafe63..5a5fe056 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -14,6 +14,7 @@ from datasette.database import QueryInterrupted from datasette.utils import ( InvalidSql, LimitedWriter, + call_with_supported_arguments, is_url, path_with_added_args, path_with_removed_args, @@ -387,7 +388,21 @@ class DataView(BaseView): if _format in self.ds.renderers.keys(): # Dispatch request to the correct output format renderer # (CSV is not handled here due to streaming) - result = self.ds.renderers[_format](request.args, data, self.name) + result = call_with_supported_arguments( + self.ds.renderers[_format], + datasette=self.ds, + columns=data.get("columns") or [], + rows=data.get("rows") or [], + sql=data.get("query", {}).get("sql", None), + query_name=data.get("query_name"), + database=database, + table=data.get("table"), + request=request, + view_name=self.name, + # These will be deprecated in Datasette 1.0: + args=request.args, + data=data, + ) if result is None: raise NotFound("No data") @@ -395,6 +410,7 @@ class DataView(BaseView): body=result.get("body"), status=result.get("status_code", 200), content_type=result.get("content_type", "text/plain"), + headers=result.get("headers"), ) else: extras = {} diff --git a/datasette/views/database.py b/datasette/views/database.py index cd27dd5f..15545fb8 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -188,6 +188,7 @@ class QueryView(DataView): return ( { "database": database, + "query_name": canned_query, "rows": results.rows, "truncated": results.truncated, "columns": columns, diff --git a/docs/plugins.rst b/docs/plugins.rst index feb14593..27f00476 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -744,19 +744,37 @@ Allows the plugin to register a new output renderer, to output data in a custom def register_output_renderer(datasette): return { "extension": "test", - "callback": render_test + "render": render_test } -This will register ``render_test`` to be called when paths with the extension ``.test`` (for example ``/database.test``, ``/database/table.test``, or ``/database/table/row.test``) are requested. When a request is received, the callback function is called with three positional arguments: +This will register ``render_test`` to be called when paths with the extension ``.test`` (for example ``/database.test``, ``/database/table.test``, or ``/database/table/row.test``) are requested. When a request is received, the callback function is called with zero or more of the following arguments. Datasette will inspect your callback function and pass arguments that match its function signature. -``args`` - dictionary - The GET parameters of the request +``datasette`` - :ref:`internals_datasette` + For accessing plugin configuration and executing queries. -``data`` - dictionary - The data to be rendered +``columns`` - list of strings + The names of the columns returned by this query. + +``rows`` - list of ``sqlite3.Row`` objects + The rows returned by the query. + +``sql`` - string + The SQL query that was executed. + +``query_name`` - string or None + If this was the execution of a :ref:`canned query `, the name of that query. + +``database`` - string + The name of the database. + +``table`` - string or None + The table or view, if one is being rendered. + +``request`` - :ref:`internals_request` + The incoming HTTP request. ``view_name`` - string - The name of the view where the renderer is being called. (``index``, ``database``, ``table``, and ``row`` are the most important ones.) + The name of the current view being called. ``index``, ``database``, ``table``, and ``row`` are the most important ones. The callback function can return ``None``, if it is unable to render the data, or a dictionary with the following keys: @@ -769,15 +787,34 @@ The callback function can return ``None``, if it is unable to render the data, o ``status_code`` - integer, optional The HTTP status code, default 200 +``headers`` - dictionary, optional + Extra HTTP headers to be returned in the response. + A simple example of an output renderer callback function: .. code-block:: python - def render_test(args, data, view_name): + def render_test(): return { "body": "Hello World" } +Here is a more complex example: + +.. code-block:: python + + def render_test(columns, rows): + first_row = " | ".join(columns) + lines = [first_row] + lines.append("=" * len(first_row)) + for row in rows: + lines.append(" | ".join(row)) + return { + "body": "Hello World", + "content_type": "text/plain; charset=utf-8", + "headers": {"x-pipes": "yay-pipes"} + } + Examples: `datasette-atom `_, `datasette-ics `_ .. _plugin_register_facet_classes: diff --git a/tests/plugins/register_output_renderer.py b/tests/plugins/register_output_renderer.py new file mode 100644 index 00000000..2ea5660e --- /dev/null +++ b/tests/plugins/register_output_renderer.py @@ -0,0 +1,42 @@ +from datasette import hookimpl +import json + + +def render_test_all_parameters( + datasette, columns, rows, sql, query_name, database, table, request, view_name, data +): + headers = {} + for custom_header in request.args.getlist("header") or []: + key, value = custom_header.split(":") + headers[key] = value + return { + "body": json.dumps( + { + "datasette": datasette, + "columns": columns, + "rows": rows, + "sql": sql, + "query_name": query_name, + "database": database, + "table": table, + "request": request, + "view_name": view_name, + }, + default=repr, + ), + "content_type": request.args.get("content_type", "text/plain"), + "status_code": int(request.args.get("status_code", 200)), + "headers": headers, + } + + +def render_test_no_parameters(): + return {"body": "Hello"} + + +@hookimpl +def register_output_renderer(datasette): + return [ + {"extension": "testall", "render": render_test_all_parameters}, + {"extension": "testnone", "callback": render_test_no_parameters}, + ] diff --git a/tests/test_api.py b/tests/test_api.py index 260d399b..f92da45e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1259,16 +1259,16 @@ def test_threads_json(app_client): def test_plugins_json(app_client): response = app_client.get("/-/plugins.json") - assert [ - {"name": "my_plugin.py", "static": False, "templates": False, "version": None}, - { - "name": "my_plugin_2.py", - "static": False, - "templates": False, - "version": None, - }, - {"name": "view_name.py", "static": False, "templates": False, "version": None}, - ] == sorted(response.json, key=lambda p: p["name"]) + expected = [ + {"name": name, "static": False, "templates": False, "version": None} + for name in ( + "my_plugin.py", + "my_plugin_2.py", + "register_output_renderer.py", + "view_name.py", + ) + ] + assert expected == sorted(response.json, key=lambda p: p["name"]) def test_versions_json(app_client): diff --git a/tests/test_html.py b/tests/test_html.py index 5a07953e..e602bf0e 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -546,6 +546,8 @@ def test_table_csv_json_export_interface(app_client): actual = [l["href"].split("/")[-1] for l in links] expected = [ "simple_primary_key.json?id__gt=2", + "simple_primary_key.testall?id__gt=2", + "simple_primary_key.testnone?id__gt=2", "simple_primary_key.csv?id__gt=2&_size=max", "#export", ] @@ -582,6 +584,8 @@ def test_csv_json_export_links_include_labels_if_foreign_keys(app_client): actual = [l["href"].split("/")[-1] for l in links] expected = [ "facetable.json?_labels=on", + "facetable.testall?_labels=on", + "facetable.testnone?_labels=on", "facetable.csv?_labels=on&_size=max", "#export", ] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 1546de92..0e4186d5 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -19,6 +19,8 @@ import textwrap import pytest import urllib +at_memory_re = re.compile(r" at 0x\w+") + @pytest.mark.xfail @pytest.mark.parametrize( @@ -329,3 +331,79 @@ def test_view_names(view_names_client, path, view_name): response = view_names_client.get(path) assert response.status == 200 assert "view_name:{}".format(view_name) == response.body.decode("utf8") + + +def test_register_output_renderer_no_parameters(app_client): + response = app_client.get("/fixtures/facetable.testnone") + assert 200 == response.status + assert b"Hello" == response.body + + +def test_register_output_renderer_all_parameters(app_client): + response = app_client.get("/fixtures/facetable.testall") + assert 200 == response.status + # Lots of 'at 0x103a4a690' in here - replace those so we can do + # an easy comparison + body = response.body.decode("utf-8") + body = at_memory_re.sub(" at 0xXXX", body) + assert { + "datasette": "", + "columns": [ + "pk", + "created", + "planet_int", + "on_earth", + "state", + "city_id", + "neighborhood", + "tags", + "complex_array", + "distinct_some_null", + ], + "rows": [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ], + "sql": "select pk, created, planet_int, on_earth, state, city_id, neighborhood, tags, complex_array, distinct_some_null from facetable order by pk limit 51", + "query_name": None, + "database": "fixtures", + "table": "facetable", + "request": "", + "view_name": "table", + } == json.loads(body) + # Test that query_name is set correctly + query_response = app_client.get("/fixtures/pragma_cache_size.testall") + assert "pragma_cache_size" == json.loads(query_response.body)["query_name"] + + +def test_register_output_renderer_custom_status_code(app_client): + response = app_client.get("/fixtures/pragma_cache_size.testall?status_code=202") + assert 202 == response.status + + +def test_register_output_renderer_custom_content_type(app_client): + response = app_client.get( + "/fixtures/pragma_cache_size.testall?content_type=text/blah" + ) + assert "text/blah" == response.headers["content-type"] + + +def test_register_output_renderer_custom_headers(app_client): + response = app_client.get( + "/fixtures/pragma_cache_size.testall?header=x-wow:1&header=x-gosh:2" + ) + assert "1" == response.headers["x-wow"] + assert "2" == response.headers["x-gosh"]