Architecture

Versioning a Plugin API for a Python CLI

Evolve a Python CLI without breaking its plugins: a small public plugin_api module, an API version separate from the CLI’s, compatibility checks at load time and deprecations.

Updated

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

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:

What a plugin may depend on Layers of a CLI as seen by plugins: a small published plugin API, documented hooks and entry point groups, and internal modules that plugins must not import. What a plugin may depend on mytool.plugin_api public types, helpers, version constant — stable, semver-covered Entry point groups + hook specs public names and signatures are contracts mytool.cli, mytool.core, ... internal may change in any release A small, explicit surface is what lets the host evolve without breaking every plugin.
# 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:

Plugin API changes and their impact Kinds of change to a plugin API, whether existing plugins keep working, and the version bump each requires. Plugin API changes and their impact Change Old plugins work? API version Add an optional hook argument yes minor Add a new hook yes minor Add a field to a passed object yes minor Remove or rename a hook argument no major Change a hook's return meaning no major Pluggy lets implementations ignore extra arguments, which makes additive changes safe.

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:

Evolving a hook without breaking plugins A timeline for replacing a plugin hook: add the new hook, call both, warn plugins still using the old one, then remove it at the next major plugin API version. Evolving a hook without breaking plugins Add new hook old still called API 1.3 Warn on old name the plugin API 1.4 Plugins migrate changelog guide API 1.x Remove old hook refuse old plugins API 2.0 the plugin API has its own version, separate from the CLI's Naming the offending plugin in the warning is what gets it fixed.
  1. Add the new hook in a minor API release, and have the host call both the new and the old one.
  2. 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.
  3. Document the migration in the plugin changelog with a before-and-after example.
  4. Remove the old hook in the next major API version, and bump PLUGIN_API_VERSION so 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.py and 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.