Designing a plugin API
Use Protocol or ABC to define a stable plugin interface, version it to avoid breaking changes, and document the contract every plugin must fulfill.
- Use Protocol to define a stable plugin interface that third-party authors can implement
- Version the API with an api_version attribute and check it at load time
- Explain why a minimal API surface is a long-term maintenance advantage
A plugin system is only as strong as the contract it publishes. If the contract is vague, plugin authors guess at what your tool expects. If it changes without warning, plugins break silently. Designing the API up front — and committing to it — is what separates a usable plugin system from a fragile one.
Define the interface with Protocol
typing.Protocol lets you describe what a plugin must look like without requiring
plugin authors to import your package or inherit from your base class. Any class
that implements the right methods satisfies the Protocol automatically — this is
called structural subtyping.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Processor(Protocol):
"""
Contract every mytool processor plugin must satisfy.
API version: 2.
"""
api_version: int
def process(self, lines: list[str]) -> list[str]:
"""Transform lines and return the result."""
...The @runtime_checkable decorator allows isinstance(obj, Processor) checks at
load time, which is useful for catching incompatible plugins early.
An ABC alternative works equally well and is slightly more familiar to authors
coming from Java or C#:
from abc import ABC, abstractmethod
class Processor(ABC):
api_version: int = 2
@abstractmethod
def process(self, lines: list[str]) -> list[str]: ...Both approaches communicate the same contract. Protocol is preferred when you
want to avoid coupling the plugin package to yours; ABC is preferred when you
want super() call chaining or mixin behaviour.
Version the API
Every method or attribute you add to the Protocol is a breaking change for existing plugins if you make it required. A version attribute lets you detect and handle mismatches at load time:
CURRENT_API_VERSION = 2
def load_plugin(ep) -> Processor | None:
cls = ep.load()
version = getattr(cls, "api_version", None)
if version != CURRENT_API_VERSION:
print(
f"Skipping plugin '{ep.name}': "
f"expected api_version={CURRENT_API_VERSION}, got {version}"
)
return None
return cls()When you must add a new capability, increment CURRENT_API_VERSION and provide a
migration guide. Plugins that have not been updated are skipped with a clear
warning rather than raising a cryptic AttributeError.
Never remove a method from the Protocol without a deprecation cycle. Third-party plugin authors cannot know you made the change until their code breaks. Announce the deprecation in a minor release, keep the method as a no-op for one major version, then remove it.
Keep the API surface minimal
Every method you put in the Protocol is a method every plugin author must
implement. A process(lines) method and an api_version attribute is a small
contract. A 12-method abstract base class is a burden that will discourage authors
from writing plugins at all.
A useful rule: if you are not sure whether a capability belongs in the Protocol,
leave it out. You can always add an optional method later (check with hasattr);
you cannot remove a required one.
Document the contract
Plugin authors will not read your source code. Put the contract in a public
document — a PLUGIN_API.md or a dedicated section in your project's
documentation:
- The exact import path for the
ProcessorProtocol. - All required attributes and their types.
- The current
api_versionvalue and what changed from the previous version. - A minimal example plugin with
pyproject.tomlentry point declaration.
The entry point group (mytool.processors), the Protocol, and the documentation
together constitute your plugin API. All three must be kept in sync.
Where to go next
Next: dynamic loading — using importlib.metadata.entry_points to discover,
import, and version-check plugins at runtime.