Added pluggy and first hook, register_commands - refs #569, #567

This commit is contained in:
Simon Willison 2023-07-22 12:04:31 -07:00
commit b379a2a0c3
11 changed files with 224 additions and 2 deletions

View file

@ -11,6 +11,8 @@ This page lists the ``--help`` for every ``sqlite-utils`` CLI sub-command.
.. [[[cog
from sqlite_utils import cli
import sys
sys._called_from_test = True
from click.testing import CliRunner
import textwrap
commands = list(cli.cli.commands.keys())
@ -1500,4 +1502,19 @@ See :ref:`cli_spatialite_indexes`.
-h, --help Show this message and exit.
.. _cli_ref_plugins:
plugins
=======
::
Usage: sqlite-utils plugins [OPTIONS]
List installed plugins
Options:
-h, --help Show this message and exit.
.. [[[end]]]

View file

@ -36,6 +36,7 @@ Contents
installation
cli
python-api
plugins
reference
cli-reference
contributing

109
docs/plugins.rst Normal file
View file

@ -0,0 +1,109 @@
.. _plugins:
=========
Plugins
=========
``sqlite-utils`` supports plugins, which can be used to add extra features to the software.
Plugins can add new commands, for example ``sqlite-utils some-command ...``
Plugins can be installed using the ``sqlite-utils install`` command:
.. code-block:: bash
sqlite-utils install sqlite-utils-name-of-plugin
You can see a JSON list of plugins that have been installed by running this:
.. code-block:: bash
sqlite-utils plugins
.. _plugins_building:
Building a plugin
-----------------
Plugins are created in a directory named after the plugin. To create a "hello world" plugin, first create a ``hello-world`` directory:
.. code-block:: bash
mkdir hello-world
cd hello-world
In that folder create two files. The first is a ``pyproject.toml`` file describing the plugin:
.. code-block:: toml
[project]
name = "sqlite-utils-hello-world"
version = "0.1"
[project.entry-points.sqlite_utils]
hello_world = "sqlite_utils_hello_world"
The ```[project.entry-points.sqlite_utils]`` section tells ``sqlite-tils`` which module to load when executing the plugin.
Then create ``sqlite_utils_hello_world.py`` with the following content:
.. code-block:: python
import click
import sqlite_utils
@sqlite_utils.hookimpl
def register_commands(cli):
@cli.command()
def hello_world():
"Say hello world"
click.echo("Hello world!")
Install the plugin in "editable" mode - so you can make changes to the code and have them picked up instantly by ``sqlite-utils`` - like this:
.. code-block:: bash
sqlite-utils install -e .
Or pass the path to your plugin directory:
.. code-block:: bash
sqlite-utils install -e `/dev/sqlite-utils-hello-world
Now, running this should execute your new command:
.. code-block:: bash
sqlite-utils hello-world
Your command will also be listed in the output of ``sqlite-utils --help``.
.. _plugins_hooks:
Plugin hooks
------------
Plugin hooks allow ``sqlite-utils`` to be customized. There is currently one hook.
.. _plugins_hooks_register_commands:
register_commands(cli)
~~~~~~~~~~~~~~~~~~~~~~
This hook can be used to register additional commands with the ``sqlite-utils`` CLI. It is called with the ``cli`` object, which is a ``click.Group`` instance.
Example implementation:
.. code-block:: python
import click
import sqlite_utils
@sqlite_utils.hookimpl
def register_commands(cli):
@cli.command()
def hello_world():
"Say hello world"
click.echo("Hello world!")

View file

@ -29,6 +29,7 @@ setup(
"click-default-group-wheel",
"tabulate",
"python-dateutil",
"pluggy",
],
extras_require={
"test": ["pytest", "black", "hypothesis", "cogapp"],
@ -45,6 +46,7 @@ setup(
"types-click",
"types-tabulate",
"types-python-dateutil",
"types-pluggy",
"data-science-types",
],
"flake8": ["flake8"],

View file

@ -1,4 +1,6 @@
from .db import Database
from .utils import suggest_column_types
from .hookspecs import hookimpl
from .hookspecs import hookspec
__all__ = ["Database", "suggest_column_types"]
__all__ = ["Database", "suggest_column_types", "hookimpl", "hookspec"]

View file

@ -7,6 +7,7 @@ import pathlib
from runpy import run_module
import sqlite_utils
from sqlite_utils.db import AlterError, BadMultiValues, DescIndex, NoTable
from sqlite_utils.plugins import pm, get_plugins
from sqlite_utils.utils import maximize_csv_field_size_limit
from sqlite_utils import recipes
import textwrap
@ -3078,6 +3079,15 @@ def create_spatial_index(db_path, table, column_name, load_extension):
db[table].create_spatial_index(column_name)
@cli.command(name="plugins")
def plugins_list():
"List installed plugins"
click.echo(json.dumps(get_plugins(), indent=2))
pm.hook.register_commands(cli=cli)
def _render_common(title, values):
if values is None:
return ""

10
sqlite_utils/hookspecs.py Normal file
View file

@ -0,0 +1,10 @@
from pluggy import HookimplMarker
from pluggy import HookspecMarker
hookspec = HookspecMarker("sqlite_utils")
hookimpl = HookimplMarker("sqlite_utils")
@hookspec
def register_commands(cli):
"""Register additional CLI commands, e.g. 'sqlite-utils mycommand ...'"""

26
sqlite_utils/plugins.py Normal file
View file

@ -0,0 +1,26 @@
import pluggy
import sys
from . import hookspecs
pm = pluggy.PluginManager("sqlite_utils")
pm.add_hookspecs(hookspecs)
if not hasattr(sys, "_called_from_test"):
# Only load plugins if not running tests
pm.load_setuptools_entrypoints("sqlite_utils")
def get_plugins():
plugins = []
plugin_to_distinfo = dict(pm.list_plugin_distinfo())
for plugin in pm.get_plugins():
plugin_info = {
"name": plugin.__name__,
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
}
distinfo = plugin_to_distinfo.get(plugin)
if distinfo:
plugin_info["version"] = distinfo.version
plugin_info["name"] = distinfo.project_name
plugins.append(plugin_info)
return plugins

View file

@ -8,6 +8,12 @@ create table Gosh2 (c1 text, c2 text, c3 text);
"""
def pytest_configure(config):
import sys
sys._called_from_test = True
@pytest.fixture
def fresh_db():
return Database(memory=True)

View file

@ -11,7 +11,9 @@ recipes_re = re.compile(r"r\.(\w+)\(")
@pytest.fixture(scope="session")
def documented_commands():
rst = (docs_path / "cli.rst").read_text()
rst = ""
for doc in ("cli.rst", "plugins.rst"):
rst += (docs_path / doc).read_text()
return {
command
for command in commands_re.findall(rst)

37
tests/test_plugins.py Normal file
View file

@ -0,0 +1,37 @@
from click.testing import CliRunner
import click
import importlib
from sqlite_utils import cli, hookimpl, plugins
def test_register_commands():
importlib.reload(cli)
assert plugins.get_plugins() == []
class HelloWorldPlugin:
__name__ = "HelloWorldPlugin"
@hookimpl
def register_commands(self, cli):
@cli.command(name="hello-world")
def hello_world():
"Print hello world"
click.echo("Hello world!")
try:
plugins.pm.register(HelloWorldPlugin(), name="HelloWorldPlugin")
importlib.reload(cli)
assert plugins.get_plugins() == [
{"name": "HelloWorldPlugin", "hooks": ["register_commands"]}
]
runner = CliRunner()
result = runner.invoke(cli.cli, ["hello-world"])
assert result.exit_code == 0
assert result.output == "Hello world!\n"
finally:
plugins.pm.unregister(name="HelloWorldPlugin")
importlib.reload(cli)
assert plugins.get_plugins() == []