Skip to content

Commit

Permalink
can_render mechanism for register_output_renderer, closes #770
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed May 28, 2020
1 parent 75cd432 commit 5ab411c
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 18 deletions.
8 changes: 4 additions & 4 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def __init__(
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")
Expand Down Expand Up @@ -574,7 +574,7 @@ def table_metadata(self, database, table):
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 = []
Expand All @@ -588,8 +588,8 @@ def register_renderers(self):
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(
Expand Down
6 changes: 5 additions & 1 deletion datasette/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
27 changes: 22 additions & 5 deletions datasette/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ async def view_get(self, request, database, hash, correct_hash_provided, **kwarg
# 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 [],
Expand Down Expand Up @@ -426,10 +426,27 @@ async def view_get(self, request, database, hash, correct_hash_provided, **kwarg
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]
Expand Down
22 changes: 16 additions & 6 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
}
Expand All @@ -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)
Expand All @@ -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 <https://github.com/simonw/datasette-atom>`_, `datasette-ics <https://github.com/simonw/datasette-ics>`_

.. _plugin_register_facet_classes:
Expand Down
26 changes: 25 additions & 1 deletion tests/plugins/register_output_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@
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
):
Expand Down Expand Up @@ -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},
]
37 changes: 36 additions & 1 deletion tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit 5ab411c

Please sign in to comment.