The moment other people write plugins for your CLI, every internal refactor becomes a potential breaking change for code you have never seen. A plugin that imported mytool.core.settings.Settings stops working when you move that class. A hook that gained a required argument crashes every plugin that implemented the old signature. Users see "mytool broke after upgrading", file the bug against you, and the plugin author finds out last. The way out is to treat the plugin interface as an API in its own right: a small, explicit surface that plugins may depend on, a version number for that surface separate from the CLI's own version, a compatibility check when plugins load, and a deprecation process for changing it. This guide builds all four. It belongs to the plugin architectures for extensible CLIs topic.
Prerequisites
- A CLI with a plugin mechanism — entry-point commands as in discovering plugins with entry points, or hooks as in hook-based plugins with pluggy.
packagingfor version specifiers (uv add packaging).
Decide what plugins may touch
Plugins will import whatever they can reach. If the only way to get the current settings is from mytool.core.settings import Settings, that import becomes a de facto API. So give plugins a front door, and make everything else explicitly internal:
# src/mytool/plugin_api.py
"""The public API for mytool plugins. Everything else in mytool is internal.
Stability: anything exported here follows the plugin API version below.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from mytool.hookspecs import hookimpl
PLUGIN_API_VERSION = "2.1"
__all__ = ["PLUGIN_API_VERSION", "hookimpl", "Context", "Target", "Reporter"]
@dataclass(frozen=True)
class Target:
"""A deploy target, as plugins see it."""
name: str
url: str
environment: str
class Reporter(Protocol):
def info(self, message: str) -> None: ...
def warn(self, message: str) -> None: ...
@dataclass(frozen=True)
class Context:
"""What the host passes to hooks. Fields may be added in minor versions."""
targets: tuple[Target, ...]
reporter: Reporter
dry_run: bool = False
The module is small on purpose. It exposes data types plugins receive (frozen dataclasses, so plugins cannot mutate host state), protocols for services the host provides, the hookimpl marker, and the version constant. Internal classes are converted into these public types at the boundary, so the internal Settings can be refactored freely.
Give the plugin API its own version
The CLI's version tracks everything users see; the plugin API version tracks only what plugin authors see. Keeping them separate means a CLI 4.0 that changes command names need not break plugins, and a plugin API 3.0 can happen in a CLI minor release if necessary. Changes to the plugin API follow semantic versioning on their own terms:
Adding a hook, adding an optional argument to a hook, or adding a field to a dataclass the host passes in are all minor — existing plugins keep working. pluggy makes the hook case especially safe, because implementations may accept any subset of a hook's arguments. Removing or renaming anything, changing what a return value means, or making a new argument mandatory are major.
The recipe: checking compatibility at load time
Each plugin declares the plugin API range it was written for — as a module attribute next to its entry point target:
# mytool_aws/plugin.py (in the plugin package)
from mytool.plugin_api import Context, hookimpl
REQUIRES_PLUGIN_API = ">=2.0,<3"
@hookimpl
def mytool_before_deploy(context: Context) -> None:
for t in context.targets:
context.reporter.info(f"checking AWS credentials for {t.name}")
The host checks that declaration before registering the plugin:
# src/mytool/plugin_loader.py
from __future__ import annotations
from dataclasses import dataclass
from importlib.metadata import entry_points
from types import ModuleType
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import Version
from mytool.plugin_api import PLUGIN_API_VERSION
GROUP = "mytool"
@dataclass
class Rejected:
name: str
dist: str
reason: str
def compatible(module: ModuleType) -> str | None:
"""Return None if compatible, else the reason it is not."""
spec_text = getattr(module, "REQUIRES_PLUGIN_API", None)
if spec_text is None:
return "does not declare REQUIRES_PLUGIN_API"
try:
spec = SpecifierSet(spec_text)
except InvalidSpecifier:
return f"invalid REQUIRES_PLUGIN_API {spec_text!r}"
if Version(PLUGIN_API_VERSION) not in spec:
return f"needs plugin API {spec_text}, this mytool provides {PLUGIN_API_VERSION}"
return None
def load_compatible(pm) -> list[Rejected]:
rejected: list[Rejected] = []
for ep in sorted(entry_points(group=GROUP), key=lambda e: e.name):
dist = f"{ep.dist.name} {ep.dist.version}" if ep.dist else "?"
try:
module = ep.load()
except Exception as exc:
rejected.append(Rejected(ep.name, dist, f"failed to import: {exc}"))
continue
reason = compatible(module)
if reason:
rejected.append(Rejected(ep.name, dist, reason))
else:
pm.register(module, name=ep.name)
return rejected
The command layer prints one warning per rejected plugin — naming the plugin, its package and version, and the reason — and carries on with the compatible ones. Users see "plugin 'aws' (mytool-aws 1.4.0) needs plugin API >=1.0,<2, this mytool provides 2.1" and know exactly what to upgrade, instead of seeing a TypeError from deep inside a hook call.
Dependency constraints in the plugin's own pyproject.toml (mytool>=3.2,<5) still matter — they stop installers from putting incompatible versions together in the first place. The runtime check is the second line of defence for environments where that did not happen, such as a user upgrading the CLI with pipx upgrade while an injected plugin stays behind.
Changing a hook without breaking plugins
When a hook must change incompatibly, run old and new side by side for a period:
- Add the new hook in a minor API release, and have the host call both the new and the old one.
- Warn when a plugin implements the old hook, naming the plugin and the replacement. With pluggy,
pm.hook.mytool_old_hook.get_hookimpls()lists which plugins implement it, so the warning can be precise. - Document the migration in the plugin changelog with a before-and-after example.
- Remove the old hook in the next major API version, and bump
PLUGIN_API_VERSIONso unmigrated plugins are rejected cleanly at load time.
import warnings
def warn_deprecated_hooks(pm) -> None:
for impl in pm.hook.mytool_pre_deploy.get_hookimpls(): # the old hook
warnings.warn(
f"plugin {impl.plugin_name!r} implements mytool_pre_deploy, which is deprecated "
"and will be removed in plugin API 3.0; implement mytool_before_deploy instead",
DeprecationWarning, stacklevel=2,
)
UX considerations
- Publish the plugin API reference separately from user documentation, generated from
plugin_api.pyand the hook specs, with the version prominently displayed. - Show API compatibility in
plugins list. A column with each plugin's declared range and whether it is compatible answers most support questions. - Provide a plugin template. A cookiecutter template with the correct
REQUIRES_PLUGIN_API, entry point and tests makes the right thing the default, as in building a cookiecutter template for Typer CLIs. - Test popular plugins in your CI. If a handful of plugins matter to your users, install them in a CI job and run their test suites against your main branch. You find out about breakage before a release does.
Testing the behaviour
The compatibility check is pure logic over module attributes, so it tests cleanly with throwaway modules:
# tests/test_plugin_compat.py
import types
import pytest
from mytool import plugin_api
from mytool.plugin_loader import compatible
def plugin(requires: str | None) -> types.ModuleType:
mod = types.ModuleType("fake_plugin")
if requires is not None:
mod.REQUIRES_PLUGIN_API = requires
return mod
@pytest.mark.parametrize("requires", [">=2.0,<3", "~=2.1", ">=2"])
def test_compatible_ranges(requires):
assert compatible(plugin(requires)) is None
@pytest.mark.parametrize("requires, fragment", [
(">=1.0,<2", "needs plugin API"),
("banana", "invalid"),
(None, "does not declare"),
])
def test_incompatible_plugins_are_explained(requires, fragment):
assert fragment in compatible(plugin(requires))
def test_public_api_surface_is_stable():
assert set(plugin_api.__all__) == {"PLUGIN_API_VERSION", "hookimpl", "Context", "Target", "Reporter"}
The last test is a small contract: changing what plugin_api exports requires editing it, which makes the change visible in review — the same principle as the CLI contract tests in semantic versioning policy for CLI tools.
Conclusion
A plugin ecosystem is only as stable as the interface it is built on. Give plugins one small public module with frozen data types, protocols and the hook marker; version that surface separately from the CLI; have plugins declare the range they support and check it at load time with a clear message; and change hooks by adding, warning and only then removing. Plugin authors get a contract they can rely on, and you keep the freedom to refactor everything behind it.
Frequently asked questions
Is REQUIRES_PLUGIN_API better than a dependency on the CLI package?
They work together. The dependency constraint keeps installers from creating incompatible environments; the runtime check catches the ones that exist anyway and produces a clear message. The API range is also more precise, because it tracks the plugin surface rather than the whole CLI.
Should the host import plugin modules to check their declared version?
It has to import the module to read the attribute. If import itself might fail on an incompatible host, put the declaration in the plugin's package metadata instead — for example a Requires-Dist on a tiny mytool-plugin-api marker package with its own version — so it can be read without importing.
How do I stop plugins from importing internal modules anyway?
You cannot fully prevent it in Python, but you can make it obviously unsupported: an underscore-prefixed internal package (mytool._internal), a clear statement in the docs, and plugin_api.__all__ as the documented surface. Plugins that reach inside accept the risk explicitly.
When is it worth having a plugin API version at all?
As soon as plugins are written by people who do not release together with the CLI. For plugins maintained in the same repository, a workspace with shared tests, as in uv workspaces for multi-package CLIs, is often enough.