diff --git a/datasette/app.py b/datasette/app.py index ebab3bee..ca2efa91 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -302,6 +302,13 @@ class Datasette: self._permission_checks = collections.deque(maxlen=200) self._root_token = secrets.token_hex(32) + async def invoke_startup(self): + for hook in pm.hook.startup(datasette=self): + if callable(hook): + hook = hook() + if asyncio.iscoroutine(hook): + hook = await hook + def sign(self, value, namespace="default"): return URLSafeSerializer(self._secret, namespace).dumps(value) diff --git a/datasette/cli.py b/datasette/cli.py index ff9a2d5c..bba72484 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -397,6 +397,9 @@ def serve( # Private utility mechanism for writing unit tests return ds + # Run the "startup" plugin hooks + asyncio.get_event_loop().run_until_complete(ds.invoke_startup()) + # Run async sanity checks - but only if we're not under pytest asyncio.get_event_loop().run_until_complete(check_databases(ds)) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index ab3e131c..9fceee41 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -5,6 +5,11 @@ hookspec = HookspecMarker("datasette") hookimpl = HookimplMarker("datasette") +@hookspec +def startup(datasette): + "Fires directly after Datasette first starts running" + + @hookspec def asgi_wrapper(datasette): "Returns an ASGI middleware callable to wrap our ASGI application with" diff --git a/docs/plugins.rst b/docs/plugins.rst index 608f93da..289be649 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -995,6 +995,39 @@ This example plugin adds a ``x-databases`` HTTP header listing the currently att Examples: `datasette-auth-github `_, `datasette-search-all `_, `datasette-media `_ +.. _plugin_hook_startup: + +startup(datasette) +~~~~~~~~~~~~~~~~~~ + +This hook fires when the Datasette application server first starts up. You can implement a regular function, for example to validate required plugin configuration: + +.. code-block:: python + + @hookimpl + def startup(datasette): + config = datasette.plugin_config("my-plugin") or {} + assert "required-setting" in config, "my-plugin requires setting required-setting" + +Or you can return an async function which will be awaited on startup. Use this option if you need to make any database queries: + + @hookimpl + def startup(datasette): + async def inner(): + db = datasette.get_database() + if "my_table" not in await db.table_names(): + await db.execute_write(""" + create table my_table (mycol text) + """, block=True) + return inner + + +Potential use-cases: + +* Run some initialization code for the plugin +* Create database tables that a plugin needs +* Validate the metadata configuration for a plugin on startup, and raise an error if it is invalid + .. _plugin_hook_actor_from_request: actor_from_request(datasette, request) diff --git a/tests/fixtures.py b/tests/fixtures.py index 907bf895..09819575 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -49,6 +49,7 @@ EXPECTED_PLUGINS = [ "register_facet_classes", "register_routes", "render_cell", + "startup", ], }, { diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index a0f7441b..3f019a84 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -167,3 +167,8 @@ def register_routes(): (r"/two/(?P.*)$", two), (r"/three/$", three), ] + + +@hookimpl +def startup(datasette): + datasette._startup_hook_fired = True diff --git a/tests/test_cli.py b/tests/test_cli.py index 6939fe57..90aa990d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,7 @@ from click.testing import CliRunner import io import json import pathlib +import pytest import textwrap diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 0fae3740..c0a7438f 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -572,3 +572,9 @@ def test_register_routes_asgi(app_client): response = app_client.get("/three/") assert {"hello": "world"} == response.json assert "1" == response.headers["x-three"] + + +@pytest.mark.asyncio +async def test_startup(app_client): + await app_client.ds.invoke_startup() + assert app_client.ds._startup_hook_fired