Code of the Day
AdvancedPlugin Systems

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.

UtilitiesAdvanced6 min read
By the end of this lesson you will be able to:
  • 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 Processor Protocol.
  • All required attributes and their types.
  • The current api_version value and what changed from the previous version.
  • A minimal example plugin with pyproject.toml entry 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.

Finished reading? Mark it complete to track your progress.

On this page