From 0c814ceb34841ec71f264cff1ca20d8ab767cddd Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 15:55:41 -0800 Subject: [PATCH 01/13] top_homepage() plugin hook, refs #1191 --- datasette/hookspecs.py | 25 +++++++ datasette/templates/index.html | 2 + datasette/views/index.py | 25 ++++++- docs/plugin_hooks.rst | 115 +++++++++++++++++++++++++++++++++ tests/test_plugins.py | 19 ++++++ 5 files changed, 184 insertions(+), 2 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index b6975dce7a..deea6cb9bc 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -158,3 +158,28 @@ def skip_csrf(datasette, scope): @hookspec def handle_exception(datasette, request, exception): """Handle an uncaught exception. Can return a Response or None.""" + + +@hookspec +def top_homepage(datasette, request): + """HTML to include at the top of the homepage""" + + +# @hookspec +# def top_database(datasette, request, database): +# """HTML to include at the top of the database page""" + + +# @hookspec +# def top_table(datasette, request, database, table): +# """HTML to include at the top of the table page""" + + +# @hookspec +# def top_row(datasette, request, database, table, row): +# """HTML to include at the top of the row page""" + + +# @hookspec +# def top_query(datasette, request, database, query): +# """HTML to include at the top of the query page""" diff --git a/datasette/templates/index.html b/datasette/templates/index.html index 06e0963512..203abca8e7 100644 --- a/datasette/templates/index.html +++ b/datasette/templates/index.html @@ -7,6 +7,8 @@ {% block content %}

{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}

+{{ top_homepage() }} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% for database in databases %} diff --git a/datasette/views/index.py b/datasette/views/index.py index 95b2930250..b95daaae38 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -1,10 +1,12 @@ -import hashlib import json -from datasette.utils import add_cors_headers, CustomJSONEncoder +from datasette.plugins import pm +from datasette.utils import add_cors_headers, await_me_maybe, CustomJSONEncoder from datasette.utils.asgi import Response from datasette.version import __version__ +from markupsafe import Markup + from .base import BaseView @@ -142,5 +144,24 @@ async def get(self, request): "private": not await self.ds.permission_allowed( None, "view-instance" ), + "top_homepage": include_block_function( + "top_homepage", self.ds, request + ), }, ) + + +def include_block_function(name, datasette, request, **kwargs): + method = getattr(pm.hook, name, None) + if method is None: + raise Exception("No hook found for {}".format(name)) + + async def inner(): + html_bits = [] + for hook in method(datasette=datasette, request=request, **kwargs): + html = await await_me_maybe(hook) + if html is not None: + html_bits.append(html) + return Markup("".join(html_bits)) + + return inner diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 9115c3dfa6..7e053d05b8 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1641,3 +1641,118 @@ This hook is responsible for returning a dictionary corresponding to Datasette : return metadata Example: `datasette-remote-metadata plugin `__ + + +@hookimpl +def top_homepage(datasette, request): + """HTML to include at the top of the homepage""" + + +@hookimpl +def top_database(datasette, request, database): + """HTML to include at the top of the database page""" + + +@hookimpl +def top_table(datasette, request, database, table): + """HTML to include at the top of the table page""" + + +@hookimpl +def top_row(datasette, request, database, table, row): + """HTML to include at the top of the row page""" + + +@hookimpl +def top_query(datasette, request, database, query): + """HTML to include at the top of the query page""" + + +.. _plugin_hook_top_homepage: + +top_homepage(datasette, request) +-------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +Returns HTML to be displayed at the top of the Datasette homepage. + +.. _plugin_hook_top_database: + +top_database(datasette, request, database) +------------------------------------------ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +Returns HTML to be displayed at the top of the database page. + +.. _plugin_hook_top_table: + +top_table(datasette, request, database, table) +--------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +``table`` - string + The name of the table. + +Returns HTML to be displayed at the top of the table page. + +.. _plugin_hook_top_row: + +top_row(datasette, request, database, table, row) +------------------------------------------------ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +``table`` - string + The name of the table. + +``row`` - ``sqlite.Row`` + The SQLite row object being displayed. + +Returns HTML to be displayed at the top of the row page. + +.. _plugin_hook_top_query: + +top_query(datasette, request, database, query) +--------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +``query`` - string + The name of the canned query. + +Returns HTML to be displayed at the top of the canned query page. diff --git a/tests/test_plugins.py b/tests/test_plugins.py index bdd4ba4943..346c55eb9b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1334,3 +1334,22 @@ def jinja2_environment_from_request(self, request, env): assert "Hello museums!" in response2.text finally: pm.unregister(name="EnvironmentPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_homepage(): + class HookPlugin: + __name__ = "HookPlugin" + + @hookimpl + def top_homepage(self, request): + return "XXX-YYY: " + request.args["z"] + + try: + pm.register(HookPlugin(), name="HookPlugin") + datasette = Datasette(memory=True) + response = await datasette.client.get("/?z=foo") + assert response.status_code == 200 + assert "XXX-YYY: foo" in response.text + finally: + pm.unregister(name="HookPlugin") From 70336ccae6acd10fe2701f8c473d63e24d02be5e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 16:02:56 -0800 Subject: [PATCH 02/13] top_query v.s. top_canned_query --- datasette/hookspecs.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index deea6cb9bc..e5912af543 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -182,4 +182,9 @@ def top_homepage(datasette, request): # @hookspec # def top_query(datasette, request, database, query): -# """HTML to include at the top of the query page""" +# """HTML to include at the top of the query results page""" + + +# @hookspec +# def top_canned_query(datasette, request, database, query): +# """HTML to include at the top of the canned query page""" From 89ede509eb574f7319eb8ce56969e1085bc5f635 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 16:05:34 -0800 Subject: [PATCH 03/13] Docs for top_query v top_canned_query, refs #1191 --- datasette/hookspecs.py | 4 ++-- docs/plugin_hooks.rst | 25 ++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index e5912af543..e9ad494d8c 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -181,10 +181,10 @@ def top_homepage(datasette, request): # @hookspec -# def top_query(datasette, request, database, query): +# def top_query(datasette, request, database, sql): # """HTML to include at the top of the query results page""" # @hookspec -# def top_canned_query(datasette, request, database, query): +# def top_canned_query(datasette, request, database, query_name): # """HTML to include at the top of the canned query page""" diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 7e053d05b8..655e07f404 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1740,8 +1740,27 @@ Returns HTML to be displayed at the top of the row page. .. _plugin_hook_top_query: -top_query(datasette, request, database, query) ---------------------------------------------- +top_query(datasette, request, database, sql) +-------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``database`` - string + The name of the database. + +``sql`` - string + The SQL query. + +Returns HTML to be displayed at the top of the query results page. + +.. _plugin_hook_top_canned_query: + +top_canned_query(datasette, request, database, query_name) +---------------------------------------------------------- ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. @@ -1752,7 +1771,7 @@ top_query(datasette, request, database, query) ``database`` - string The name of the database. -``query`` - string +``query_name`` - string The name of the canned query. Returns HTML to be displayed at the top of the canned query page. From dee371f04e31fc59618c1d6cd516c91e3335d77e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 18:17:49 -0800 Subject: [PATCH 04/13] Use an assertion instead --- datasette/plugins.py | 1 + datasette/views/index.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/datasette/plugins.py b/datasette/plugins.py index 1ed3747f85..75141f9e71 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -25,6 +25,7 @@ "datasette.default_magic_parameters", "datasette.blob_renderer", "datasette.default_menu_links", + "datasette.default_include_blocks", "datasette.handle_exception", "datasette.forbidden", ) diff --git a/datasette/views/index.py b/datasette/views/index.py index b95daaae38..c6ac470e66 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -153,8 +153,7 @@ async def get(self, request): def include_block_function(name, datasette, request, **kwargs): method = getattr(pm.hook, name, None) - if method is None: - raise Exception("No hook found for {}".format(name)) + assert method is not None, "No hook found for {}".format(name) async def inner(): html_bits = [] From c414e48acd9e477733dd951fa5e52786e0aec1a3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:04:05 -0800 Subject: [PATCH 05/13] Removed accidental default plugin line --- datasette/plugins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/datasette/plugins.py b/datasette/plugins.py index 75141f9e71..1ed3747f85 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -25,7 +25,6 @@ "datasette.default_magic_parameters", "datasette.blob_renderer", "datasette.default_menu_links", - "datasette.default_include_blocks", "datasette.handle_exception", "datasette.forbidden", ) From 04df009a8e49c0b074ac207254bfa594a255302a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:13:38 -0800 Subject: [PATCH 06/13] Refactor out to datasette.utils.make_slot_function() --- datasette/utils/__init__.py | 17 +++++++++++++++++ datasette/views/index.py | 19 ++----------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 0f449b89a1..8914c043bd 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1283,3 +1283,20 @@ def fail_if_plugins_in_metadata(metadata: dict, filename=None): f'Datasette no longer accepts plugin configuration in --metadata. Move your "plugins" configuration blocks to a separate file - we suggest calling that datasette.{suggested_extension} - and start Datasette with datasette -c datasette.{suggested_extension}. See https://docs.datasette.io/en/latest/configuration.html for more details.' ) return metadata + + +def make_slot_function(name, datasette, request, **kwargs): + from datasette.plugins import pm + + method = getattr(pm.hook, name, None) + assert method is not None, "No hook found for {}".format(name) + + async def inner(): + html_bits = [] + for hook in method(datasette=datasette, request=request, **kwargs): + html = await await_me_maybe(hook) + if html is not None: + html_bits.append(html) + return markupsafe.Markup("".join(html_bits)) + + return inner diff --git a/datasette/views/index.py b/datasette/views/index.py index c6ac470e66..595cf2347d 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -1,7 +1,7 @@ import json from datasette.plugins import pm -from datasette.utils import add_cors_headers, await_me_maybe, CustomJSONEncoder +from datasette.utils import add_cors_headers, make_slot_function, CustomJSONEncoder from datasette.utils.asgi import Response from datasette.version import __version__ @@ -144,23 +144,8 @@ async def get(self, request): "private": not await self.ds.permission_allowed( None, "view-instance" ), - "top_homepage": include_block_function( + "top_homepage": make_slot_function( "top_homepage", self.ds, request ), }, ) - - -def include_block_function(name, datasette, request, **kwargs): - method = getattr(pm.hook, name, None) - assert method is not None, "No hook found for {}".format(name) - - async def inner(): - html_bits = [] - for hook in method(datasette=datasette, request=request, **kwargs): - html = await await_me_maybe(hook) - if html is not None: - html_bits.append(html) - return Markup("".join(html_bits)) - - return inner From e6be5df80b67f4b500a06688aa7e32d5659e6ebf Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:19:23 -0800 Subject: [PATCH 07/13] Implemented top_database slot --- datasette/hookspecs.py | 6 ++--- datasette/templates/database.html | 2 ++ datasette/views/database.py | 5 +++- tests/test_plugins.py | 38 +++++++++++++++++++++++-------- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index e9ad494d8c..bcb3a3a5e9 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -165,9 +165,9 @@ def top_homepage(datasette, request): """HTML to include at the top of the homepage""" -# @hookspec -# def top_database(datasette, request, database): -# """HTML to include at the top of the database page""" +@hookspec +def top_database(datasette, request, database): + """HTML to include at the top of the database page""" # @hookspec diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 3d4dae07ea..4b125a448e 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -34,6 +34,8 @@

{{ metadata.title or database }}{% if private %} 🔒{% endif %}

{% endif %} +{{ top_database() }} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if allow_execute_sql %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 03e70379ad..e9d7a9b9b4 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,5 +1,4 @@ from dataclasses import dataclass, field -from typing import Callable from urllib.parse import parse_qsl, urlencode import asyncio import hashlib @@ -18,6 +17,7 @@ call_with_supported_arguments, derive_named_parameters, format_bytes, + make_slot_function, tilde_decode, to_css_class, validate_sql_select, @@ -161,6 +161,9 @@ async def database_actions(): f"{'*' if template_name == template.name else ''}{template_name}" for template_name in templates ], + "top_database": make_slot_function( + "top_database", datasette, request, database=database + ), } return Response.html( await datasette.render_template( diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 346c55eb9b..5be90a0879 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1336,20 +1336,40 @@ def jinja2_environment_from_request(self, request, env): pm.unregister(name="EnvironmentPlugin") +class SlotPlugin: + __name__ = "SlotPlugin" + + @hookimpl + def top_homepage(self, request): + return "Xtop_homepage: " + request.args["z"] + + @hookimpl + def top_database(self, request): + async def inner(): + return "Xtop_database: " + request.args["z"] + + return inner + + @pytest.mark.asyncio async def test_hook_top_homepage(): - class HookPlugin: - __name__ = "HookPlugin" + try: + pm.register(SlotPlugin(), name="SlotPlugin") + datasette = Datasette(memory=True) + response = await datasette.client.get("/?z=foo") + assert response.status_code == 200 + assert "Xtop_homepage: foo" in response.text + finally: + pm.unregister(name="SlotPlugin") - @hookimpl - def top_homepage(self, request): - return "XXX-YYY: " + request.args["z"] +@pytest.mark.asyncio +async def test_hook_top_database(): try: - pm.register(HookPlugin(), name="HookPlugin") + pm.register(SlotPlugin(), name="SlotPlugin") datasette = Datasette(memory=True) - response = await datasette.client.get("/?z=foo") + response = await datasette.client.get("/_memory?z=bar") assert response.status_code == 200 - assert "XXX-YYY: foo" in response.text + assert "Xtop_database: bar" in response.text finally: - pm.unregister(name="HookPlugin") + pm.unregister(name="SlotPlugin") From fe0c7894086993490361bd6b818ac81d8259a810 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:22:53 -0800 Subject: [PATCH 08/13] top_table() --- datasette/templates/table.html | 2 ++ datasette/views/table.py | 8 ++++++++ tests/test_plugins.py | 15 +++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 88580e5282..5aee6319ab 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -45,6 +45,8 @@

{{ metadata.get("title") or table }}{% if is_view %} (view){% endif %}{% if {% endif %} +{{ top_table() }} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if metadata.get("columns") %} diff --git a/datasette/views/table.py b/datasette/views/table.py index 7ee5d6bf7f..be7479f85d 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -17,6 +17,7 @@ append_querystring, compound_keys_after_sql, format_bytes, + make_slot_function, tilde_encode, escape_sqlite, filters_should_redirect, @@ -842,6 +843,13 @@ async def fetch_data(request, _next=None): f"{'*' if template_name == template.name else ''}{template_name}" for template_name in templates ], + top_table=make_slot_function( + "top_table", + datasette, + request, + database=resolved.db.name, + table=resolved.table, + ), ), request=request, view_name="table", diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 5be90a0879..e64c3b063f 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1350,6 +1350,10 @@ async def inner(): return inner + @hookimpl + def top_table(self, request): + return "Xtop_table: " + request.args["z"] + @pytest.mark.asyncio async def test_hook_top_homepage(): @@ -1373,3 +1377,14 @@ async def test_hook_top_database(): assert "Xtop_database: bar" in response.text finally: pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_table(ds_client): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + response = await ds_client.get("/fixtures/facetable?z=baz") + assert response.status_code == 200 + assert "Xtop_table: baz" in response.text + finally: + pm.unregister(name="SlotPlugin") From bfef66171e81065d08a0398b1830b506cf932db9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:23:22 -0800 Subject: [PATCH 09/13] Forgot to uncomment top_table hookspec --- datasette/hookspecs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index bcb3a3a5e9..207f87bed6 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -170,9 +170,9 @@ def top_database(datasette, request, database): """HTML to include at the top of the database page""" -# @hookspec -# def top_table(datasette, request, database, table): -# """HTML to include at the top of the table page""" +@hookspec +def top_table(datasette, request, database, table): + """HTML to include at the top of the table page""" # @hookspec From 98725658246f5ae72f3c5b0918e2e8f582667b75 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:29:41 -0800 Subject: [PATCH 10/13] top_row() --- datasette/hookspecs.py | 6 +++--- datasette/templates/row.html | 2 ++ datasette/views/row.py | 12 +++++++++--- tests/test_plugins.py | 33 +++++++++++++++++++++++++-------- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 207f87bed6..7183aef132 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -175,9 +175,9 @@ def top_table(datasette, request, database, table): """HTML to include at the top of the table page""" -# @hookspec -# def top_row(datasette, request, database, table, row): -# """HTML to include at the top of the row page""" +@hookspec +def top_row(datasette, request, database, table, row): + """HTML to include at the top of the row page""" # @hookspec diff --git a/datasette/templates/row.html b/datasette/templates/row.html index 4d179a8545..6d4b996e1e 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -22,6 +22,8 @@ {% block content %}

{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}

+{{ top_row() }} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}

This data as {% for name, url in renderers.items() %}{{ name }}{{ ", " if not loop.last }}{% endfor %}

diff --git a/datasette/views/row.py b/datasette/views/row.py index 8f07a66249..ce877753fc 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -2,11 +2,9 @@ from datasette.database import QueryInterrupted from .base import DataView, BaseView, _error from datasette.utils import ( - tilde_decode, - urlsafe_components, + make_slot_function, to_css_class, escape_sqlite, - row_sql_params_pks, ) import json import sqlite_utils @@ -73,6 +71,14 @@ async def template_data(): .get(database, {}) .get("tables", {}) .get(table, {}), + "top_row": make_slot_function( + "top_row", + self.ds, + request, + database=resolved.db.name, + table=resolved.table, + row=rows[0], + ), } data = { diff --git a/tests/test_plugins.py b/tests/test_plugins.py index e64c3b063f..8d5302ed8b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1341,18 +1341,24 @@ class SlotPlugin: @hookimpl def top_homepage(self, request): - return "Xtop_homepage: " + request.args["z"] + return "Xtop_homepage:" + request.args["z"] @hookimpl - def top_database(self, request): + def top_database(self, request, database): async def inner(): - return "Xtop_database: " + request.args["z"] + return "Xtop_database:{}:{}".format(database, request.args["z"]) return inner @hookimpl - def top_table(self, request): - return "Xtop_table: " + request.args["z"] + def top_table(self, request, database, table): + return "Xtop_table:{}:{}:{}".format(database, table, request.args["z"]) + + @hookimpl + def top_row(self, request, database, table, row): + return "Xtop_row:{}:{}:{}:{}".format( + database, table, row["name"], request.args["z"] + ) @pytest.mark.asyncio @@ -1362,7 +1368,7 @@ async def test_hook_top_homepage(): datasette = Datasette(memory=True) response = await datasette.client.get("/?z=foo") assert response.status_code == 200 - assert "Xtop_homepage: foo" in response.text + assert "Xtop_homepage:foo" in response.text finally: pm.unregister(name="SlotPlugin") @@ -1374,7 +1380,7 @@ async def test_hook_top_database(): datasette = Datasette(memory=True) response = await datasette.client.get("/_memory?z=bar") assert response.status_code == 200 - assert "Xtop_database: bar" in response.text + assert "Xtop_database:_memory:bar" in response.text finally: pm.unregister(name="SlotPlugin") @@ -1385,6 +1391,17 @@ async def test_hook_top_table(ds_client): pm.register(SlotPlugin(), name="SlotPlugin") response = await ds_client.get("/fixtures/facetable?z=baz") assert response.status_code == 200 - assert "Xtop_table: baz" in response.text + assert "Xtop_table:fixtures:facetable:baz" in response.text + finally: + pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_row(ds_client): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + response = await ds_client.get("/fixtures/facet_cities/1?z=bax") + assert response.status_code == 200 + assert "Xtop_row:fixtures:facet_cities:San Francisco:bax" in response.text finally: pm.unregister(name="SlotPlugin") From a097d6d04d28d1ef9d7798dd29211218643d83f3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:39:18 -0800 Subject: [PATCH 11/13] top_query() and top_canned_query() --- datasette/hookspecs.py | 12 ++++++------ datasette/templates/query.html | 2 ++ datasette/views/database.py | 16 ++++++++++++++++ docs/plugin_hooks.rst | 30 ++---------------------------- tests/test_plugins.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 7183aef132..2f4c602774 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -180,11 +180,11 @@ def top_row(datasette, request, database, table, row): """HTML to include at the top of the row page""" -# @hookspec -# def top_query(datasette, request, database, sql): -# """HTML to include at the top of the query results page""" +@hookspec +def top_query(datasette, request, database, sql): + """HTML to include at the top of the query results page""" -# @hookspec -# def top_canned_query(datasette, request, database, query_name): -# """HTML to include at the top of the canned query page""" +@hookspec +def top_canned_query(datasette, request, database, query_name): + """HTML to include at the top of the canned query page""" diff --git a/datasette/templates/query.html b/datasette/templates/query.html index b8f06f848a..1815e592ca 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -30,6 +30,8 @@

{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}

+{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %} + {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
diff --git a/datasette/views/database.py b/datasette/views/database.py index e9d7a9b9b4..caeb4e46e5 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -249,6 +249,12 @@ class QueryContext: "help": "List of templates that were considered for rendering this page" } ) + top_query: callable = field( + metadata={"help": "Callable to render the top_query slot"} + ) + top_canned_query: callable = field( + metadata={"help": "Callable to render the top_canned_query slot"} + ) async def get_tables(datasette, request, db): @@ -730,6 +736,16 @@ async def fetch_data_for_csv(request, _next=None): f"{'*' if template_name == template.name else ''}{template_name}" for template_name in templates ], + top_query=make_slot_function( + "top_query", datasette, request, database=database, sql=sql + ), + top_canned_query=make_slot_function( + "top_canned_query", + datasette, + request, + database=database, + query_name=canned_query["name"] if canned_query else None, + ), ), request=request, view_name="database", diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 655e07f404..b3976de8ae 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1642,32 +1642,6 @@ This hook is responsible for returning a dictionary corresponding to Datasette : Example: `datasette-remote-metadata plugin `__ - -@hookimpl -def top_homepage(datasette, request): - """HTML to include at the top of the homepage""" - - -@hookimpl -def top_database(datasette, request, database): - """HTML to include at the top of the database page""" - - -@hookimpl -def top_table(datasette, request, database, table): - """HTML to include at the top of the table page""" - - -@hookimpl -def top_row(datasette, request, database, table, row): - """HTML to include at the top of the row page""" - - -@hookimpl -def top_query(datasette, request, database, query): - """HTML to include at the top of the query page""" - - .. _plugin_hook_top_homepage: top_homepage(datasette, request) @@ -1700,7 +1674,7 @@ Returns HTML to be displayed at the top of the database page. .. _plugin_hook_top_table: top_table(datasette, request, database, table) ---------------------------------------------- +---------------------------------------------- ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. @@ -1719,7 +1693,7 @@ Returns HTML to be displayed at the top of the table page. .. _plugin_hook_top_row: top_row(datasette, request, database, table, row) ------------------------------------------------- +------------------------------------------------- ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 8d5302ed8b..784c460a03 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1360,6 +1360,14 @@ def top_row(self, request, database, table, row): database, table, row["name"], request.args["z"] ) + @hookimpl + def top_query(self, request, database, sql): + return "Xtop_query:{}:{}:{}".format(database, sql, request.args["z"]) + + @hookimpl + def top_canned_query(self, request, database, query_name): + return "Xtop_query:{}:{}:{}".format(database, query_name, request.args["z"]) + @pytest.mark.asyncio async def test_hook_top_homepage(): @@ -1405,3 +1413,25 @@ async def test_hook_top_row(ds_client): assert "Xtop_row:fixtures:facet_cities:San Francisco:bax" in response.text finally: pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_query(ds_client): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + response = await ds_client.get("/fixtures?sql=select+1&z=x") + assert response.status_code == 200 + assert "Xtop_query:fixtures:select 1:x" in response.text + finally: + pm.unregister(name="SlotPlugin") + + +@pytest.mark.asyncio +async def test_hook_top_canned_query(ds_client): + try: + pm.register(SlotPlugin(), name="SlotPlugin") + response = await ds_client.get("/fixtures/from_hook?z=xyz") + assert response.status_code == 200 + assert "Xtop_query:fixtures:from_hook:xyz" in response.text + finally: + pm.unregister(name="SlotPlugin") From c87b4d408b89549b031b1c745c489cbd4c723bca Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:44:21 -0800 Subject: [PATCH 12/13] Better template slots documentation --- docs/plugin_hooks.rst | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index b3976de8ae..ce648ba721 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1642,10 +1642,21 @@ This hook is responsible for returning a dictionary corresponding to Datasette : Example: `datasette-remote-metadata plugin `__ +.. _plugin_hook_slots: + +Template slots +-------------- + +The following set of plugin hooks can be used to return extra HTML content that will be inserted into the corresponding page, directly below the ``

`` heading. + +Multiple plugins can contribute content here. The order in which it is displayed can be controlled using Pluggy's `call time order options `__. + +Each of these plugin hooks can return either a string or an awaitable function that returns a string. + .. _plugin_hook_top_homepage: top_homepage(datasette, request) --------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. @@ -1658,7 +1669,7 @@ Returns HTML to be displayed at the top of the Datasette homepage. .. _plugin_hook_top_database: top_database(datasette, request, database) ------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. @@ -1674,7 +1685,7 @@ Returns HTML to be displayed at the top of the database page. .. _plugin_hook_top_table: top_table(datasette, request, database, table) ----------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. @@ -1693,7 +1704,7 @@ Returns HTML to be displayed at the top of the table page. .. _plugin_hook_top_row: top_row(datasette, request, database, table, row) -------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. @@ -1715,7 +1726,7 @@ Returns HTML to be displayed at the top of the row page. .. _plugin_hook_top_query: top_query(datasette, request, database, sql) --------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. @@ -1734,7 +1745,7 @@ Returns HTML to be displayed at the top of the query results page. .. _plugin_hook_top_canned_query: top_canned_query(datasette, request, database, query_name) ----------------------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. From eb13117b09d96d7d50916a58fc13a04df627b874 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 30 Jan 2024 19:47:42 -0800 Subject: [PATCH 13/13] Plugin hooks documentation unit test fix --- tests/test_docs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_docs.py b/tests/test_docs.py index 17c01a0bb7..0a803861c6 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -41,7 +41,9 @@ def plugin_hooks_content(): "plugin", [name for name in dir(app.pm.hook) if not name.startswith("_")] ) def test_plugin_hooks_are_documented(plugin, plugin_hooks_content): - headings = get_headings(plugin_hooks_content, "-") + headings = set() + headings.update(get_headings(plugin_hooks_content, "-")) + headings.update(get_headings(plugin_hooks_content, "~")) assert plugin in headings hook_caller = getattr(app.pm.hook, plugin) arg_names = [a for a in hook_caller.spec.argnames if a != "__multicall__"]