From 5ab411c733233435d613d04c610a5a41fd0b7735 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 27 May 2020 22:57:05 -0700 Subject: [PATCH] can_render mechanism for register_output_renderer, closes #770 --- datasette/app.py | 8 ++--- datasette/utils/__init__.py | 6 +++- datasette/views/base.py | 27 ++++++++++++++--- docs/plugins.rst | 22 ++++++++++---- tests/plugins/register_output_renderer.py | 26 +++++++++++++++- tests/test_plugins.py | 37 ++++++++++++++++++++++- 6 files changed, 108 insertions(+), 18 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 941b2895..40d39ac9 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -228,7 +228,7 @@ class Datasette: if config_dir and (config_dir / "config.json").exists() and not config: config = json.load((config_dir / "config.json").open()) self._config = dict(DEFAULT_CONFIG, **(config or {})) - self.renderers = {} # File extension -> renderer function + self.renderers = {} # File extension -> (renderer, can_render) functions self.version_note = version_note self.executor = futures.ThreadPoolExecutor( max_workers=self.config("num_sql_threads") @@ -574,7 +574,7 @@ class Datasette: def register_renderers(self): """ Register output renderers which output data in custom formats. """ # Built-in renderers - self.renderers["json"] = json_renderer + self.renderers["json"] = (json_renderer, lambda: True) # Hooks hook_renderers = [] @@ -588,8 +588,8 @@ class Datasette: for renderer in hook_renderers: self.renderers[renderer["extension"]] = ( # It used to be called "callback" - remove this in Datasette 1.0 - renderer.get("render") - or renderer["callback"] + renderer.get("render") or renderer["callback"], + renderer.get("can_render") or (lambda: True), ) async def render_template( diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 03157072..2dab8e14 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -811,6 +811,10 @@ def call_with_supported_arguments(fn, **kwargs): call_with = [] for parameter in parameters: if parameter not in kwargs: - raise TypeError("{} requires parameters {}".format(fn, tuple(parameters))) + raise TypeError( + "{} requires parameters {}, missing: {}".format( + fn, tuple(parameters), set(parameters) - set(kwargs.keys()) + ) + ) call_with.append(kwargs[parameter]) return fn(*call_with) diff --git a/datasette/views/base.py b/datasette/views/base.py index d56fd2f6..06b78d5f 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -389,7 +389,7 @@ class DataView(BaseView): # Dispatch request to the correct output format renderer # (CSV is not handled here due to streaming) result = call_with_supported_arguments( - self.ds.renderers[_format], + self.ds.renderers[_format][0], datasette=self.ds, columns=data.get("columns") or [], rows=data.get("rows") or [], @@ -426,10 +426,27 @@ class DataView(BaseView): if data.get("expandable_columns"): url_labels_extra = {"_labels": "on"} - renderers = { - key: path_with_format(request, key, {**url_labels_extra}) - for key in self.ds.renderers.keys() - } + renderers = {} + for key, (_, can_render) in self.ds.renderers.items(): + it_can_render = call_with_supported_arguments( + can_render, + 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, + ) + if asyncio.iscoroutine(it_can_render): + it_can_render = await it_can_render + if it_can_render: + renderers[key] = path_with_format( + request, key, {**url_labels_extra} + ) + url_csv_args = {"_size": "max", **url_labels_extra} url_csv = path_with_format(request, "csv", url_csv_args) url_csv_path = url_csv.split("?")[0] diff --git a/docs/plugins.rst b/docs/plugins.rst index ebf6adf6..b27daf3f 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -744,14 +744,17 @@ Registers a new output renderer, to output data in a custom format. The hook fun def register_output_renderer(datasette): return { "extension": "test", - "render": render_test + "render": render_demo, + "can_render": can_render_demo, # Optional } -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. +This will register ``render_demo`` to be called when paths with the extension ``.test`` (for example ``/database.test``, ``/database/table.test``, or ``/database/table/row.test``) are requested. -``render_test`` is a Python function. It can be a regular function or an ``async def render_test()`` awaitable function, depending on if it needs to make any asynchronous calls. +``render_demo`` is a Python function. It can be a regular function or an ``async def render_demo()`` awaitable function, depending on if it needs to make any asynchronous calls. -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. +``can_render_demo`` is a Python function (or ``async def`` function) which acepts the same arguments as ``render_demo`` but just returns ``True`` or ``False``. It lets Datasette know if the current SQL query can be represented by the plugin - and hence influnce if a link to this output format is displayed in the user interface. If you omit the ``"can_render"`` key from the dictionary every query will be treated as being supported by the plugin. + +When a request is received, the ``"render"`` 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. ``datasette`` - :ref:`internals_datasette` For accessing plugin configuration and executing queries. @@ -798,7 +801,7 @@ A simple example of an output renderer callback function: .. code-block:: python - def render_test(): + def render_demo(): return { "body": "Hello World" } @@ -807,7 +810,7 @@ Here is a more complex example: .. code-block:: python - async def render_test(datasette, columns, rows): + async def render_demo(datasette, columns, rows): db = next(iter(datasette.databases.values())) result = await db.execute("select sqlite_version()") first_row = " | ".join(columns) @@ -821,6 +824,13 @@ Here is a more complex example: "headers": {"x-sqlite-version": result.first()[0]}, } +And here is an example ``can_render`` function which returns ``True`` only if the query results contain the columns ``atom_id``, ``atom_title`` and ``atom_updated``: + +.. code-block:: python + + def can_render_demo(columns): + return {"atom_id", "atom_title", "atom_updated"}.issubset(columns) + 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 index d4c1228d..a9f0f157 100644 --- a/tests/plugins/register_output_renderer.py +++ b/tests/plugins/register_output_renderer.py @@ -2,6 +2,26 @@ from datasette import hookimpl import json +async def can_render( + datasette, columns, rows, sql, query_name, database, table, request, view_name +): + # We stash this on datasette so the calling unit test can see it + datasette._can_render_saw = { + "datasette": datasette, + "columns": columns, + "rows": rows, + "sql": sql, + "query_name": query_name, + "database": database, + "table": table, + "request": request, + "view_name": view_name, + } + if request.args.get("_no_can_render"): + return False + return True + + async def render_test_all_parameters( datasette, columns, rows, sql, query_name, database, table, request, view_name, data ): @@ -39,6 +59,10 @@ def render_test_no_parameters(): @hookimpl def register_output_renderer(datasette): return [ - {"extension": "testall", "render": render_test_all_parameters}, + { + "extension": "testall", + "render": render_test_all_parameters, + "can_render": can_render, + }, {"extension": "testnone", "callback": render_test_no_parameters}, ] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index e9556b31..a34328a9 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -9,7 +9,7 @@ from .fixtures import ( from datasette.app import Datasette from datasette import cli from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm -from datasette.utils import sqlite3 +from datasette.utils import sqlite3, CustomRow from jinja2.environment import Template import base64 import json @@ -411,6 +411,41 @@ def test_register_output_renderer_custom_headers(app_client): assert "2" == response.headers["x-gosh"] +def test_register_output_renderer_can_render(app_client): + response = app_client.get("/fixtures/facetable?_no_can_render=1") + assert response.status == 200 + links = ( + Soup(response.body, "html.parser") + .find("p", {"class": "export-links"}) + .findAll("a") + ) + actual = [l["href"].split("/")[-1] for l in links] + # Should not be present because we sent ?_no_can_render=1 + assert "facetable.testall?_labels=on" not in actual + # Check that it was passed the values we expected + assert hasattr(app_client.ds, "_can_render_saw") + assert { + "datasette": app_client.ds, + "columns": [ + "pk", + "created", + "planet_int", + "on_earth", + "state", + "city_id", + "neighborhood", + "tags", + "complex_array", + "distinct_some_null", + ], + "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", + "view_name": "table", + }.items() <= app_client.ds._can_render_saw.items() + + @pytest.mark.asyncio async def test_prepare_jinja2_environment(app_client): template = app_client.ds.jinja_env.from_string(