Architecture

Hook-Based Plugins for Python CLIs with pluggy

Build a hook-based plugin system for a Python CLI with pluggy, the library behind pytest: hook specs, implementations, entry-point loading, ordering and tests.

Updated

Adding whole commands through entry points covers one kind of extensibility. Often what plugins need is finer-grained: contribute extra validation checks to mytool lint, add an output format to every listing command, transform a config after it is loaded, react when a deployment finishes. That is a hook system — the host defines named extension points with fixed signatures, plugins implement whichever they care about, and the host calls every implementation at the right moment. You could build it by hand; pluggy is the small, battle-tested library that pytest, tox, datasette and many others use for exactly this. This guide builds a hook-based plugin system for a CLI with pluggy: specifying hooks, implementing them, loading plugins from entry points, controlling result collection and ordering, and testing. It belongs to the plugin architectures for extensible CLIs topic.

Prerequisites

How pluggy works

pluggy has three moving parts. Hook specifications (@hookspec) declare the extension points and their arguments, written by the host. Hook implementations (@hookimpl) are functions in plugins with names matching a spec. A PluginManager registers plugins, validates their implementations against the specs, and calls them.

A hook call through pluggy The host calls a hook through the plugin manager, which calls each registered implementation in order and returns the list of results to the host. A hook call through pluggy Host CLI PluginManager Plugin A Plugin B hook.mytool_checks(config=cfg) call impl (LIFO order) call impl [results from B, A] Implementations may accept a subset of the hook arguments, so specs can grow without breaking plugins.

When the host calls pm.hook.mytool_checks(config=cfg), pluggy calls every registered implementation of mytool_checks — the most recently registered first — and returns their results as a list. Implementations may accept any subset of the spec's arguments by name, which is what lets you add arguments to a hook later without breaking existing plugins.

The recipe: specifying hooks

# src/mytool/hookspecs.py
from __future__ import annotations

from typing import Any

import pluggy

hookspec = pluggy.HookspecMarker("mytool")
hookimpl = pluggy.HookimplMarker("mytool")


@hookspec
def mytool_checks(config: dict[str, Any]) -> list[tuple[str, str]]:
    """Return (check_name, problem) tuples for a loaded config. Return [] when all is well."""


@hookspec
def mytool_output_formats() -> dict[str, Any]:
    """Return {format_name: render(rows) -> str} for extra --format choices."""


@hookspec(firstresult=True)
def mytool_resolve_target(url: str) -> str | None:
    """Turn a custom URL scheme into a deploy target; return None if not yours."""

HookspecMarker("mytool") and HookimplMarker("mytool") share a project name, which is how pluggy links specs to implementations. The host exports hookimpl so plugins import it from one stable place — ideally a small public module such as mytool.plugin_api, as discussed in versioning a plugin API.

Hook specifications and implementations The pluggy markers and options used on hook specifications and implementations and what each controls. Hook specifications and implementations Marker / option Where Effect @hookspec host declares the hook and its arguments @hookimpl plugin implements a hook firstresult=True spec stop at the first non-None result tryfirst / trylast impl adjust call order wrapper=True impl run code around other impls pytest is built on these same five ideas, which is why its plugin ecosystem is so large.

The recipe: the plugin manager and built-ins

# src/mytool/pm.py
from __future__ import annotations

import functools

import pluggy

from mytool import builtin_checks, hookspecs


@functools.cache
def get_plugin_manager() -> pluggy.PluginManager:
    pm = pluggy.PluginManager("mytool")
    pm.add_hookspecs(hookspecs)
    pm.register(builtin_checks, name="builtin")          # built-ins use the same mechanism
    pm.load_setuptools_entrypoints("mytool")             # third-party plugins
    return pm
# src/mytool/builtin_checks.py
from typing import Any

from mytool.hookspecs import hookimpl


@hookimpl
def mytool_checks(config: dict[str, Any]) -> list[tuple[str, str]]:
    problems = []
    if "name" not in config:
        problems.append(("required-name", "config has no 'name'"))
    if config.get("replicas", 1) < 1:
        problems.append(("replicas", "replicas must be at least 1"))
    return problems

Registering the built-in checks as a plugin is a deliberate design choice: it proves the hook interface is sufficient for real work, and it means built-ins and third-party checks behave identically. load_setuptools_entrypoints("mytool") loads every package that declares an entry point in the mytool group — each entry point's target module is registered as a plugin.

The command layer calls the hooks and combines the results:

# src/mytool/cli.py
import json
from pathlib import Path

import typer

from mytool.pm import get_plugin_manager

app = typer.Typer()


@app.callback()
def main() -> None:
    """Config tools."""


@app.command()
def check(config_file: Path) -> None:
    """Run every registered check against CONFIG_FILE."""
    config = json.loads(config_file.read_text(encoding="utf-8"))
    results = get_plugin_manager().hook.mytool_checks(config=config)
    problems = [p for plugin_result in results for p in plugin_result]
    for name, message in sorted(problems):
        typer.echo(f"{config_file}: [{name}] {message}")
    raise typer.Exit(1 if problems else 0)


if __name__ == "__main__":
    app()

The recipe: a third-party plugin

# mytool_security/checks.py  (a separate package)
from typing import Any

from mytool.hookspecs import hookimpl


@hookimpl
def mytool_checks(config: dict[str, Any]) -> list[tuple[str, str]]:
    if str(config.get("image", "")).endswith(":latest"):
        return [("pinned-image", "use a pinned image tag, not :latest")]
    return []
# mytool-security/pyproject.toml
[project.entry-points.mytool]
security = "mytool_security.checks"

Once installed into the same environment, mytool check deploy.json runs both the built-in checks and the security checks, with no change to the host.

Collecting all results or the first

Collect every result, or the first? A decision for hook result handling: collect all results when plugins contribute items, use firstresult when one plugin should answer. Collect every result, or the first? What does the host need back? Contributions from every plugin List of results checks, commands, formats One answer from whoever can give it firstresult=True resolve a URL scheme Contribution hooks are the common case in CLIs: each plugin adds checks or commands.

Most CLI hooks are contribution hooks: each plugin adds checks, output formats or commands, and the host wants every result — pluggy's default. Some hooks are resolution hooks: "who can handle this URL scheme?", where the first plugin to return a non-None answer wins. Declare those with firstresult=True on the spec, and pluggy stops calling implementations as soon as one returns something. Ordering can be nudged with @hookimpl(tryfirst=True) or trylast=True — useful for a built-in fallback that should only answer when no plugin does.

Wrappers for cross-cutting behaviour

Some plugins do not contribute results; they want to run code around the other implementations — timing every check, catching and reporting their exceptions, or adjusting the combined result. pluggy supports this with wrapper implementations. A function marked @hookimpl(wrapper=True) is a generator: code before its yield runs before the other implementations, the yield returns their combined result, and whatever the wrapper returns becomes the hook's result.

import time

from mytool.hookspecs import hookimpl


@hookimpl(wrapper=True)
def mytool_checks(config):
    start = time.perf_counter()
    results = yield                      # every other implementation runs here
    elapsed = time.perf_counter() - start
    if elapsed > 1.0:
        results.append([("slow-checks", f"checks took {elapsed:.1f}s")])
    return results

A host can ship such wrappers itself — for example, one that converts exceptions from individual plugins into reported problems instead of crashing the command — and a --profile-plugins flag can register a timing wrapper only when asked.

UX considerations

  • Attribute results to plugins. When a plugin's check fails, users need to know which plugin raised it. Prefix check names with the plugin name, or use pm.hook.mytool_checks.get_hookimpls() to call implementations one by one and label their results.
  • Contain plugin errors. An exception in one implementation propagates out of the hook call by default. For contribution hooks, wrap the call so a crashing plugin is reported and skipped, not fatal — the same principle as isolating broken plugins at load time.
  • List what is active. A plugins list command using pm.list_name_plugin() shows every registered plugin, which is the first thing to check when a check unexpectedly runs or does not.
  • Keep hook names prefixed. mytool_checks, not checks: pluggy hooks share one namespace per project, and prefixes make plugin code self-explanatory.

Testing the behaviour

pluggy makes testing easy because plugins are just objects: register a class or module with implementations directly on a fresh PluginManager, without entry points:

# tests/test_hooks.py
import pluggy

from mytool import builtin_checks, hookspecs
from mytool.hookspecs import hookimpl


def fresh_pm(*plugins) -> pluggy.PluginManager:
    pm = pluggy.PluginManager("mytool")
    pm.add_hookspecs(hookspecs)
    for p in plugins:
        pm.register(p)
    return pm


class LatestTagCheck:
    @hookimpl
    def mytool_checks(self, config):
        return [("pinned-image", "no :latest")] if config.get("image", "").endswith(":latest") else []


def test_results_are_collected_from_all_plugins():
    pm = fresh_pm(builtin_checks, LatestTagCheck())
    results = pm.hook.mytool_checks(config={"image": "web:latest"})
    flat = sorted(p for r in results for p in r)
    assert flat == [("pinned-image", "no :latest"), ("required-name", "config has no 'name'")]


def test_first_result_wins():
    class Git:
        @hookimpl
        def mytool_resolve_target(self, url):
            return f"git:{url[6:]}" if url.startswith("git://") else None

    class Fallback:
        @hookimpl(trylast=True)
        def mytool_resolve_target(self, url):
            return f"path:{url}"

    pm = fresh_pm(Git(), Fallback())
    assert pm.hook.mytool_resolve_target(url="git://repo") == "git:repo"
    assert pm.hook.mytool_resolve_target(url="./dist") == "path:./dist"


def test_bad_implementation_signature_is_rejected():
    class Wrong:
        @hookimpl
        def mytool_checks(self, config, unknown_arg):
            return []

    import pytest
    with pytest.raises(pluggy.PluginValidationError):
        fresh_pm(Wrong())

The last test demonstrates one of pluggy's most valuable properties: a plugin whose implementation asks for an argument the spec does not provide is rejected at registration time with a clear error, rather than failing mysteriously when the hook is called.

Conclusion

When plugins need to extend behaviour inside commands rather than add whole commands, a hook system is the right shape, and pluggy provides it in a few dozen lines: specs declare extension points, implementations contribute behaviour, a plugin manager validates and calls them, and entry points load third-party plugins. Register built-ins through the same mechanism, choose between collecting all results and firstresult, attribute and contain plugin failures, and test by registering plain objects on a fresh manager. It is the architecture pytest's enormous plugin ecosystem runs on, and it scales down to a single CLI just as well.

Frequently asked questions

Entry-point commands or pluggy hooks?

Use entry points for plugins that add whole command groups, and pluggy hooks for plugins that extend behaviour within existing commands. Many CLIs use both: pluggy's load_setuptools_entrypoints can even provide the command-contributing hook itself.

Can hooks be async?

pluggy calls implementations synchronously. For async plugins, have hooks return coroutines or awaitables and let the host gather them — or keep hooks synchronous and do async work inside the host.

How do I let plugins add options to existing commands?

Define a hook such as mytool_add_options(command_name) that returns Click options, and apply them when building the command. It works, but it makes --help depend on installed plugins; document it clearly.

Does pluggy add startup cost?

pluggy itself is small. The cost is loading plugins, which imports them. Create the plugin manager lazily — only in commands that call hooks — to keep --help fast.