From 30c18576d603366dc3bd83ba50de1b7e70844430 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 27 Aug 2021 18:39:42 -0700 Subject: [PATCH] register_commands() plugin hook, closes #1449 --- datasette/cli.py | 3 +++ datasette/hookspecs.py | 5 ++++ docs/plugin_hooks.rst | 45 +++++++++++++++++++++++++++++++++ tests/test_plugins.py | 57 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/datasette/cli.py b/datasette/cli.py index 65da5613..22e2338a 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -595,6 +595,9 @@ def serve( uvicorn.run(ds.app(), **uvicorn_kwargs) +pm.hook.register_commands(cli=cli) + + async def check_databases(ds): # Run check_connection against every connected database # to confirm they are all usable diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 56c79d23..1d4e3b27 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -79,6 +79,11 @@ def register_routes(datasette): """Register URL routes: return a list of (regex, view_function) pairs""" +@hookspec +def register_commands(cli): + """Register additional CLI commands, e.g. 'datasette mycommand ...'""" + + @hookspec def actor_from_request(datasette, request): """Return an actor dictionary based on the incoming request""" diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 5cdb1623..a6fe1071 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -587,6 +587,51 @@ See :ref:`writing_plugins_designing_urls` for tips on designing the URL routes u Examples: `datasette-auth-github `__, `datasette-psutil `__ +.. _plugin_register_commands: + +register_commands(cli) +---------------------- + +``cli`` - the root Datasette `Click command group `__ + Use this to register additional CLI commands + +Register additional CLI commands that can be run using ``datsette yourcommand ...``. This provides a mechanism by which plugins can add new CLI commands to Datasette. + +This example registers a new ``datasette verify file1.db file2.db`` command that checks if the provided file paths are valid SQLite databases: + +.. code-block:: python + + from datasette import hookimpl + import click + import sqlite3 + + @hookimpl + def register_commands(cli): + @cli.command() + @click.argument("files", type=click.Path(exists=True), nargs=-1) + def verify(files): + "Verify that files can be opened by Datasette" + for file in files: + conn = sqlite3.connect(str(file)) + try: + conn.execute("select * from sqlite_master") + except sqlite3.DatabaseError: + raise click.ClickException("Invalid database: {}".format(file)) + +The new command can then be executed like so:: + + datasette verify fixtures.db + +Help text (from the docstring for the function plus any defined Click arguments or options) will become available using:: + + datasette verify --help + +Plugins can register multiple commands by making multiple calls to the ``@cli.command()`` decorator.Consult the `Click documentation `__ for full details on how to build a CLI command, including how to define arguments and options. + +Note that ``register_commands()`` plugins cannot used with the :ref:`--plugins-dir mechanism ` - they need to be installed into the same virtual environment as Datasette using ``pip install``. Provided it has a ``setup.py`` file (see :ref:`writing_plugins_packaging`) you can run ``pip install`` directly against the directory in which you are developing your plugin like so:: + + pip install -e path/to/my/datasette-plugin + .. _plugin_register_facet_classes: register_facet_classes() diff --git a/tests/test_plugins.py b/tests/test_plugins.py index ec8ff0c5..a024c39b 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -6,13 +6,15 @@ from .fixtures import ( TEMP_PLUGIN_SECRET_FILE, TestClient as _TestClient, ) # noqa +from click.testing import CliRunner from datasette.app import Datasette -from datasette import cli +from datasette import cli, hookimpl from datasette.plugins import get_plugins, DEFAULT_PLUGINS, pm from datasette.utils.sqlite import sqlite3 from datasette.utils import CustomRow from jinja2.environment import Template import base64 +import importlib import json import os import pathlib @@ -902,3 +904,56 @@ def test_hook_get_metadata(app_client): assert "Hello from local metadata" == meta["databases"]["from-local"]["title"] assert "Hello from the plugin hook" == meta["databases"]["from-hook"]["title"] pm.hook.get_metadata = og_pm_hook_get_metadata + + +def _extract_commands(output): + lines = output.split("Commands:\n", 1)[1].split("\n") + return {line.split()[0].replace("*", "") for line in lines if line.strip()} + + +def test_hook_register_commands(): + # Without the plugin should have seven commands + runner = CliRunner() + result = runner.invoke(cli.cli, "--help") + commands = _extract_commands(result.output) + assert commands == { + "serve", + "inspect", + "install", + "package", + "plugins", + "publish", + "uninstall", + } + + # Now install a plugin + class VerifyPlugin: + __name__ = "VerifyPlugin" + + @hookimpl + def register_commands(self, cli): + @cli.command() + def verify(): + pass + + @cli.command() + def unverify(): + pass + + pm.register(VerifyPlugin(), name="verify") + importlib.reload(cli) + result2 = runner.invoke(cli.cli, "--help") + commands2 = _extract_commands(result2.output) + assert commands2 == { + "serve", + "inspect", + "install", + "package", + "plugins", + "publish", + "uninstall", + "verify", + "unverify", + } + pm.unregister(name="verify") + importlib.reload(cli)