From 510e01f2240ef5ce6cf444ea8c295e996d57f61e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 30 Jul 2018 08:55:26 -0700 Subject: [PATCH 1/4] render_cell(value) plugin hook Still needs performance testing before I merge this into master --- datasette/hookspecs.py | 5 +++++ datasette/views/base.py | 20 +++++++++++------ docs/plugins.rst | 49 +++++++++++++++++++++++++++++++++++++++++ tests/fixtures.py | 26 ++++++++++++++++++++++ tests/test_plugins.py | 18 +++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) 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/views/base.py b/datasette/views/base.py index 45bc8183..fbb9b173 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -493,14 +493,20 @@ 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 + from datasette.app import pm + 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/docs/plugins.rst b/docs/plugins.rst index fc351bf6..add20669 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -267,3 +267,52 @@ 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 + stripped = value.strip() + if not stripped.startswith("{") and stripped.endswith("}"): + return None + try: + data = json.loads(value) + except ValueError: + 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..955b155d 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,30 @@ 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 + stripped = value.strip() + if not stripped.startswith("{") and stripped.endswith("}"): + return None + try: + data = json.loads(value) + except ValueError: + 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 = ''' 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" From 61e9a4d60177f573835a244372e9de3d5567cb64 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 4 Aug 2018 17:04:53 -0700 Subject: [PATCH 2/4] Apply render_cell plugin on table views, refs #352 --- datasette/app.py | 18 +----------------- datasette/utils.py | 1 - datasette/views/base.py | 2 +- datasette/views/table.py | 8 ++++++-- tests/fixtures.py | 11 +++++++++-- tests/test_api.py | 3 ++- 6 files changed, 19 insertions(+), 24 deletions(-) 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/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 fbb9b173..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, @@ -494,7 +495,6 @@ class BaseView(RenderMixin): for value in row: display_value = value # Let the plugins have a go - from datasette.app import pm plugin_value = pm.hook.render_cell(value=value) if plugin_value is not None: display_value = plugin_value 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/tests/fixtures.py b/tests/fixtures.py index 955b155d..ffacfa51 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -223,6 +223,8 @@ def extra_js_urls(): @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 @@ -230,6 +232,8 @@ def render_cell(value): 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"] @@ -389,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"}' }] From 8dd255adae1076d2b7a921fd76cbfcdbae2a20a0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 4 Aug 2018 17:06:50 -0700 Subject: [PATCH 3/4] Updated example render_cell plugin in docs --- docs/plugins.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/plugins.rst b/docs/plugins.rst index add20669..f69fed95 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -297,6 +297,8 @@ If the value matches that pattern, the plugin returns an HTML link element: @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 @@ -304,6 +306,8 @@ If the value matches that pattern, the plugin returns an HTML link element: 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"] From 2e538d924f3b17f82e94e8e8b5a05abcf9e1e697 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 4 Aug 2018 17:09:46 -0700 Subject: [PATCH 4/4] Added missing file --- datasette/plugins.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 datasette/plugins.py 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)