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
- Python 3.10+,
pluggy1.5+ (uv add pluggy), and Typer or Click. - Familiarity with entry points, as in discovering plugins with entry points — pluggy uses them to find plugins.
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.
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.
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
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 listcommand usingpm.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, notchecks: 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.