diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index 2cd1dd3..9f9a4e8 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -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]]] diff --git a/docs/index.rst b/docs/index.rst index 2bca2c8..27a22c3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,6 +36,7 @@ Contents installation cli python-api + plugins reference cli-reference contributing diff --git a/docs/plugins.rst b/docs/plugins.rst new file mode 100644 index 0000000..cb3090c --- /dev/null +++ b/docs/plugins.rst @@ -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!") + diff --git a/setup.py b/setup.py index 5d67f0d..7dbd670 100644 --- a/setup.py +++ b/setup.py @@ -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"], diff --git a/sqlite_utils/__init__.py b/sqlite_utils/__init__.py index 614f984..a55c2d2 100644 --- a/sqlite_utils/__init__.py +++ b/sqlite_utils/__init__.py @@ -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"] diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index 0c3a222..df9fbea 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -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 "" diff --git a/sqlite_utils/hookspecs.py b/sqlite_utils/hookspecs.py new file mode 100644 index 0000000..cf05f7d --- /dev/null +++ b/sqlite_utils/hookspecs.py @@ -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 ...'""" diff --git a/sqlite_utils/plugins.py b/sqlite_utils/plugins.py new file mode 100644 index 0000000..5eaf1f6 --- /dev/null +++ b/sqlite_utils/plugins.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 621d67e..3932f05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_docs.py b/tests/test_docs.py index c305c7a..a36b053 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -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) diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..dcb9e7b --- /dev/null +++ b/tests/test_plugins.py @@ -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() == []