From 4ac913224061f2dc4f673efab1a5ac6bc748854f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 4 Aug 2018 17:14:56 -0700 Subject: [PATCH] render_cell(value) plugin hook, closes #352 New plugin hook for customizing the way cells values are rendered in HTML. The first full example of this hook in use is https://github.com/simonw/datasette-json-html --- datasette/app.py | 18 +------------- datasette/hookspecs.py | 5 ++++ datasette/plugins.py | 17 +++++++++++++ datasette/utils.py | 1 - datasette/views/base.py | 20 +++++++++------ datasette/views/table.py | 8 ++++-- docs/plugins.rst | 53 ++++++++++++++++++++++++++++++++++++++++ tests/fixtures.py | 37 ++++++++++++++++++++++++++-- tests/test_api.py | 3 ++- tests/test_plugins.py | 18 ++++++++++++++ 10 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 datasette/plugins.py diff --git a/datasette/app.py b/datasette/app.py index 052131d0..e263cc48 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2,7 +2,6 @@ import asyncio import click import collections import hashlib -import importlib import itertools import os import sqlite3 @@ -14,7 +13,6 @@ from concurrent import futures from pathlib import Path from markupsafe import Markup -import pluggy from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader from sanic import Sanic, response from sanic.exceptions import InvalidUsage, NotFound @@ -28,7 +26,6 @@ from .views.index import IndexView from .views.special import JsonDataView from .views.table import RowView, TableView -from . import hookspecs from .utils import ( InterruptedError, Results, @@ -40,26 +37,13 @@ from .utils import ( to_css_class ) from .inspect import inspect_hash, inspect_views, inspect_tables +from .plugins import pm from .version import __version__ -default_plugins = ( - "datasette.publish.heroku", - "datasette.publish.now", -) - app_root = Path(__file__).parent.parent connections = threading.local() -pm = pluggy.PluginManager("datasette") -pm.add_hookspecs(hookspecs) -pm.load_setuptools_entrypoints("datasette") - -# Load default plugins -for plugin in default_plugins: - mod = importlib.import_module(plugin) - pm.register(mod, plugin) - ConfigOption = collections.namedtuple( "ConfigOption", ("name", "default", "help") diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 9546eebf..233a9aa0 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -28,3 +28,8 @@ def extra_js_urls(): @hookspec def publish_subcommand(publish): "Subcommands for 'datasette publish'" + + +@hookspec(firstresult=True) +def render_cell(value): + "Customize rendering of HTML table cell values" diff --git a/datasette/plugins.py b/datasette/plugins.py new file mode 100644 index 00000000..e416c07d --- /dev/null +++ b/datasette/plugins.py @@ -0,0 +1,17 @@ +import importlib +import pluggy +from . import hookspecs + +default_plugins = ( + "datasette.publish.heroku", + "datasette.publish.now", +) + +pm = pluggy.PluginManager("datasette") +pm.add_hookspecs(hookspecs) +pm.load_setuptools_entrypoints("datasette") + +# Load default plugins +for plugin in default_plugins: + mod = importlib.import_module(plugin) + pm.register(mod, plugin) diff --git a/datasette/utils.py b/datasette/utils.py index 8ecd9025..29360b35 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -16,7 +16,6 @@ import shutil import urllib import numbers - # From https://www.sqlite.org/lang_keywords.html reserved_words = set(( 'abort action add after all alter analyze and as asc attach autoincrement ' diff --git a/datasette/views/base.py b/datasette/views/base.py index 45bc8183..f376c327 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -13,6 +13,7 @@ from sanic.exceptions import NotFound from sanic.views import HTTPMethodView from datasette import __version__ +from datasette.plugins import pm from datasette.utils import ( CustomJSONEncoder, InterruptedError, @@ -493,14 +494,19 @@ class BaseView(RenderMixin): display_row = [] for value in row: display_value = value - if value in ("", None): - display_value = jinja2.Markup(" ") - elif is_url(str(value).strip()): - display_value = jinja2.Markup( - '{url}'.format( - url=jinja2.escape(value.strip()) + # Let the plugins have a go + plugin_value = pm.hook.render_cell(value=value) + if plugin_value is not None: + display_value = plugin_value + else: + if value in ("", None): + display_value = jinja2.Markup(" ") + elif is_url(str(display_value).strip()): + display_value = jinja2.Markup( + '{url}'.format( + url=jinja2.escape(value.strip()) + ) ) - ) display_row.append(display_value) display_rows.append(display_row) return { diff --git a/datasette/views/table.py b/datasette/views/table.py index 654e60fa..ae71d33d 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -5,6 +5,7 @@ import jinja2 from sanic.exceptions import NotFound from sanic.request import RequestParameters +from datasette.plugins import pm from datasette.utils import ( CustomRow, Filters, @@ -22,7 +23,6 @@ from datasette.utils import ( urlsafe_components, value_as_boolean, ) - from .base import BaseView, DatasetteError, ureg LINK_WITH_LABEL = '{label} {id}' @@ -166,7 +166,11 @@ class RowTableShared(BaseView): # already shown in the link column. continue - if isinstance(value, dict): + # First let the plugins have a go + plugin_display_value = pm.hook.render_cell(value=value) + if plugin_display_value is not None: + display_value = plugin_display_value + elif isinstance(value, dict): # It's an expanded foreign key - display link to other row label = value["label"] value = value["value"] diff --git a/docs/plugins.rst b/docs/plugins.rst index fc351bf6..f69fed95 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -267,3 +267,56 @@ command. Datasette uses this hook internally to implement the default ``now`` and ``heroku`` subcommands, so you can read `their source `_ to see examples of this hook in action. + +render_cell(value) +~~~~~~~~~~~~~~~~~~ + +Lets you customize the display of values within table cells in the HTML table view. + +``value`` is the value that was loaded from the database. + +If your hook returns ``None``, it will be ignored. Use this to indicate that your hook is not able to custom render this particular value. + +If the hook returns a string, that string will be rendered in the table cell. + +If you want to return HTML markup you can do so by returning a ``jinja2.Markup`` object. + +Here is an example of a custom ``render_cell()`` plugin which looks for values that are a JSON string matching the following format:: + + {"href": "https://www.example.com/", "label": "Name"} + +If the value matches that pattern, the plugin returns an HTML link element: + +.. code-block:: python + + from datasette import hookimpl + import jinja2 + import json + + + @hookimpl + def render_cell(value): + # Render {"href": "...", "label": "..."} as link + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped.startswith("{") and stripped.endswith("}"): + return None + try: + data = json.loads(value) + except ValueError: + return None + if not isinstance(data, dict): + return None + if set(data.keys()) != {"href", "label"}: + return None + href = data["href"] + if not ( + href.startswith("/") or href.startswith("http://") + or href.startswith("https://") + ): + return None + return jinja2.Markup('{label}'.format( + href=jinja2.escape(data["href"]), + label=jinja2.escape(data["label"] or "") or " " + )) diff --git a/tests/fixtures.py b/tests/fixtures.py index 6335494a..ffacfa51 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -208,6 +208,8 @@ def extra_js_urls(): PLUGIN2 = ''' from datasette import hookimpl +import jinja2 +import json @hookimpl @@ -216,6 +218,34 @@ def extra_js_urls(): 'url': 'https://example.com/jquery.js', 'sri': 'SRIHASH', }, 'https://example.com/plugin2.js'] + + +@hookimpl +def render_cell(value): + # Render {"href": "...", "label": "..."} as link + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped.startswith("{") and stripped.endswith("}"): + return None + try: + data = json.loads(value) + except ValueError: + return None + if not isinstance(data, dict): + return None + if set(data.keys()) != {"href", "label"}: + return None + href = data["href"] + if not ( + href.startswith("/") or href.startswith("http://") + or href.startswith("https://") + ): + return None + return jinja2.Markup('{label}'.format( + href=jinja2.escape(data["href"]), + label=jinja2.escape(data["label"] or "") or " " + )) ''' TABLES = ''' @@ -363,9 +393,12 @@ INSERT INTO "searchable_fts" (rowid, text1, text2, [name with . and spaces]) CREATE TABLE [select] ( [group] text, [having] text, - [and] text + [and] text, + [json] text +); +INSERT INTO [select] VALUES ('group', 'having', 'and', + '{"href": "http://example.com/", "label":"Example"}' ); -INSERT INTO [select] VALUES ('group', 'having', 'and'); CREATE TABLE infinity ( value REAL diff --git a/tests/test_api.py b/tests/test_api.py index 8f67a9eb..f76795fe 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -231,7 +231,7 @@ def test_database_page(app_client): ], }, }, { - 'columns': ['group', 'having', 'and'], + 'columns': ['group', 'having', 'and', 'json'], 'name': 'select', 'count': 1, 'hidden': False, @@ -599,6 +599,7 @@ def test_table_with_reserved_word_name(app_client): 'group': 'group', 'having': 'having', 'and': 'and', + 'json': '{"href": "http://example.com/", "label":"Example"}' }] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 487aa10c..fee27c24 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -3,6 +3,7 @@ from .fixtures import ( # noqa app_client, ) import pytest +import urllib def test_plugins_dir_plugin(app_client): @@ -67,3 +68,20 @@ def test_plugins_with_duplicate_js_urls(app_client): ) < srcs.index( 'https://example.com/plugin2.js' ) + + +def test_plugins_render_cell(app_client): + sql = """ + select '{"href": "http://example.com/", "label":"Example"}' + """.strip() + path = "/fixtures?" + urllib.parse.urlencode({ + "sql": sql, + }) + response = app_client.get(path) + td = Soup( + response.body, "html.parser" + ).find("table").find("tbody").find("td") + a = td.find("a") + assert a is not None, str(a) + assert a.attrs["href"] == "http://example.com/" + assert a.text == "Example"