Plugin-based Architectures Expert¶
When you'd use this
Build extensible systems where features plug in without touching the core.
Let third parties (or your team) extend an app via plugins without modifying its core — for extensible tools, IDEs, and platforms.
What you'll learn¶
- Build a plugin registry with decorators
- Define stable extension points (contracts)
- Discover plugins dynamically — including from separate packages
- Version and isolate plugins safely
- Know when a plugin system is worth it
The idea¶
The idea — a key concept in Plugin-based Architectures.
A plugin architecture lets you add features without modifying the core. The core defines extension points (what a plugin must provide) and a registry (how plugins announce themselves). New capabilities arrive as self-contained plugins that snap in.
You've used this everywhere: pytest fixtures/plugins, Flask extensions, VS Code extensions, Django apps. The core stays small and stable; the ecosystem grows around it.
┌──────────── CORE ────────────┐
│ extension point (contract) │
│ plugin registry │
└───────────────┬───────────────┘
┌───────────┼───────────┐
[plugin A] [plugin B] [plugin C] ← added without touching core
A registry with decorators¶
Let plugins self-register via a decorator so a factory can find them.
The simplest, most Pythonic plugin system: a dict registry populated by a decorator. Fully runnable.
registry: dict[str, type] = {}
def register(name: str):
"""Decorator that adds a class to the registry under `name`."""
def deco(cls):
registry[name] = cls
return cls
return deco
The extension point (contract)¶
Every plugin must satisfy this interface — that's the contract the core relies on:
Plugins¶
Each plugin is a class that implements the contract and registers itself:
import json
@register("json")
class JsonExporter(Exporter):
def export(self, data: dict) -> str:
return json.dumps(data)
@register("csv")
class CsvExporter(Exporter):
def export(self, data: dict) -> str:
return ";".join(f"{k}={v}" for k, v in data.items())
The core uses plugins by name¶
def get_exporter(name: str) -> Exporter:
if name not in registry:
raise KeyError(f"no plugin named {name!r}; available: {sorted(registry)}")
return registry[name]()
Running it:
data = {"a": 1, "b": 2}
print("available:", sorted(registry))
print("json ->", get_exporter("json").export(data))
print("csv ->", get_exporter("csv").export(data))
get_exporter("xml") # not registered
Output:
available: ['csv', 'json']
json -> {"a": 1, "b": 2}
csv -> a=1;b=2
Traceback (most recent call last):
...
KeyError: "no plugin named 'xml'; available: ['csv', 'json']"
The payoff: adding an XmlExporter means writing one new @register("xml") class — in this file or any other module that gets imported. get_exporter and the rest of the core never change. The error message even lists what's available, which is exactly the kind of helpful failure a plugin system should give.
Discovering plugins from separate packages¶
Load plugins shipped as independent packages via entry points.
A registry only knows about plugins whose module has been imported. For plugins shipped as their own installable packages, Python's standard mechanism is entry points declared in packaging metadata.
A plugin package declares in its pyproject.toml:
The host application discovers all installed plugins at runtime:
from importlib.metadata import entry_points
def load_plugins(group: str) -> dict[str, type]:
found = {}
for ep in entry_points(group=group):
found[ep.name] = ep.load() # imports and returns the class
return found
# plugins = load_plugins("myapp.exporters")
# -> {'yaml': <class 'myapp_yaml_plugin.YamlExporter'>}
Entry-points example needs an installed plugin package
load_plugins runs, but it only finds plugins that third-party packages have registered under that group — so it isn't meaningfully run-verified in isolation here (the decorator registry above is). This is exactly how pytest, Flask, and many tools discover plugins: install a package, and it's automatically available with no code change in the host.
Two discovery styles, summarized:
- Decorator/registry — great within one codebase; plugins live in modules you import.
- Entry points — great across packages; third parties can extend your app just by
pip install-ing their plugin. No import statement in your code.
There's also directory scanning (import every .py in a plugins/ folder via importlib), but entry points are cleaner and the modern standard.
Versioning and safety¶
Version the plugin API and validate plugins to avoid breakage.
Once third parties write plugins, you inherit responsibilities:
- Version the contract. If you change what
export()must accept or return, old plugins break. Version your plugin API (e.g. aPLUGIN_API_VERSION) and refuse or warn on mismatches. - Fail gracefully. One broken plugin shouldn't crash the host. Wrap
ep.load()and each plugin call in try/except and log failures. - Trust boundaries. A plugin is arbitrary code running in your process — it can do anything your app can. Only load plugins you trust. True sandboxing of untrusted plugins is hard (see the Security section's Sandboxing and Secure Plugin Systems topics).
- Signature verification. For plugins from outside sources, consider verifying a signature before loading, so you only run code from known publishers.
Plugins run with full privileges
Loading a plugin means executing its code inside your application. There's no built-in isolation — a malicious or buggy plugin can read your data, files, and secrets. Treat plugin sources like dependencies: vet them, pin versions, and for untrusted code use process/OS-level isolation rather than trying to sandbox within Python.
When to build a plugin system¶
Worth it when:
- You genuinely need third parties (or separate teams) to extend the app without editing the core.
- There's a clear, stable extension point (like "an exporter" or "an auth backend").
- The set of extensions is open-ended and grows over time.
Overkill when:
- You have a fixed, known set of two or three variants — a simple
if/dict is clearer. - Nobody outside your team will ever add one.
Start with the registry, graduate to entry points
Begin with the in-code decorator registry. Only add entry-point discovery when you actually need external packages to plug in. The contract (the Exporter interface) stays the same either way.
Practice exercises¶
- Add an
XmlExporterplugin without modifyingget_exporter, and confirm it appears inavailable. - Make
get_exporterreturn a default (e.g. JSON) instead of raising when the name is unknown, and decide which behavior is better for your use case. - Wrap plugin loading so one plugin raising on import doesn't stop the others; log which failed.
- Add a
list_plugins()function that returns each plugin's name and docstring for a--help-style listing. - Sketch a
PLUGIN_API_VERSIONcheck that refuses to load a plugin built against an incompatible contract version.
💬 Discussion
Have a question about this topic? Found an error? Share your thoughts below.