Building a Plugin System in Python
Every extensible tool you’ve ever used — a linter with custom rules, a static site generator with themes, a CLI with third-party commands — is built on the same underlying idea: a stable interface the core application knows about, and independently loadable code that implements it. Let’s build that mechanism in Python from scratch, starting with a plain interface and working up to plugins that are discovered automatically without editing the core application’s code at all.
Step 1: Define the Plugin Interface
The foundation of any plugin system is a contract — an abstract base class that every plugin must implement, so the core application can call plugins without knowing anything about their internals.
# plugin_base.py
from abc import ABC, abstractmethod
class TextProcessor(ABC):
name: str
@abstractmethod
def process(self, text: str) -> str:
"""Transform the input text and return the result."""
raise NotImplementedError
Using ABC and @abstractmethod isn’t just documentation — Python actively refuses to instantiate a subclass that doesn’t implement process(), which catches a broken plugin at load time instead of at first use.
class BrokenPlugin(TextProcessor):
name = "broken"
# process() not implemented
BrokenPlugin()
TypeError: Can't instantiate abstract class BrokenPlugin with abstract method process
Step 2: Write a Couple of Real Plugins
With the interface fixed, plugins are just classes that implement it — each one lives in its own module, with no dependency on the others.
# plugins/uppercase.py
from plugin_base import TextProcessor
class UppercasePlugin(TextProcessor):
name = "uppercase"
def process(self, text: str) -> str:
return text.upper()
# plugins/reverse.py
from plugin_base import TextProcessor
class ReversePlugin(TextProcessor):
name = "reverse"
def process(self, text: str) -> str:
return text[::-1]
Step 3: Discover Plugins Dynamically with importlib
The naive approach — hardcoding from plugins.uppercase import UppercasePlugin in the core application — defeats the purpose, since adding a plugin would mean editing core code. Instead, scan the plugins directory at runtime and import whatever’s there.
# loader.py
import importlib
import pkgutil
from pathlib import Path
from plugin_base import TextProcessor
def discover_plugins(package_name: str = "plugins") -> list[TextProcessor]:
plugins = []
package = importlib.import_module(package_name)
for _, module_name, _ in pkgutil.iter_modules(package.__path__):
module = importlib.import_module(f"{package_name}.{module_name}")
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, TextProcessor)
and attr is not TextProcessor
):
plugins.append(attr())
return plugins
# main.py
from loader import discover_plugins
plugins = discover_plugins()
for plugin in plugins:
print(f"{plugin.name}: {plugin.process('Hello World')}")
$ python main.py
uppercase: HELLO WORLD
reverse: dlroW olleH
pkgutil.iter_modules() walks every .py file in the plugins package and imports it; the issubclass check then filters for classes that actually implement TextProcessor, ignoring anything else the module happens to define. Dropping a new file into plugins/ — say plugins/rot13.py — makes it show up in the next run with zero changes to loader.py or main.py.
Step 4: A Registry for Explicit Registration
Automatic directory scanning works well for a single codebase, but it doesn’t help when plugins live in separate installable packages. The more common real-world pattern is an explicit registry that plugins register themselves into, combined with Python’s entry points mechanism for cross-package discovery.
# registry.py
_registry: dict[str, type] = {}
def register(name: str):
def decorator(cls):
_registry[name] = cls
return cls
return decorator
def get_plugin(name: str):
return _registry[name]()
def all_plugins():
return dict(_registry)
# plugins/uppercase.py
from plugin_base import TextProcessor
from registry import register
@register("uppercase")
class UppercasePlugin(TextProcessor):
name = "uppercase"
def process(self, text: str) -> str:
return text.upper()
Step 5: Cross-Package Discovery with Entry Points
For plugins distributed as separate pip-installable packages (the way real tools like pytest and flake8 do it), Python’s packaging metadata supports declaring entry points — a way for an installed package to advertise “I provide a plugin for this other tool” without either package importing the other directly.
# pyproject.toml, inside a separate plugin package
[project.entry-points."myapp.plugins"]
uppercase = "myapp_uppercase_plugin:UppercasePlugin"
# loader.py — discovering plugins installed as separate packages
from importlib.metadata import entry_points
def discover_installed_plugins():
plugins = []
for ep in entry_points(group="myapp.plugins"):
plugin_class = ep.load()
plugins.append(plugin_class())
return plugins
$ pip install myapp-uppercase-plugin
$ python main.py
uppercase: HELLO WORLD
This is the mechanism that lets pip install alone make a new plugin available — no manual registration step, no editing a config file, because the entry point declaration in the plugin package’s own pyproject.toml is what advertises its existence to the host application.
Choosing Between the Approaches
Directory scanning (pkgutil.iter_modules) fits a single codebase where plugins live alongside the main application — simple, no packaging overhead, but plugins can’t be distributed independently.
Explicit registry with a decorator adds a clear, greppable list of what’s registered and lets plugins opt in explicitly rather than being picked up by isinstance-checking every class in a module — useful once a plugin package might define helper classes that aren’t themselves plugins.
Entry points is the right choice once plugins need to ship as separate installable packages — it’s how the broader Python ecosystem (pytest plugins, Flask extensions catalogued via entry points) actually does this in practice.
Conclusion
A plugin system, regardless of the specific discovery mechanism, always rests on the same foundation: a fixed interface (an ABC) that plugins implement, and a discovery step that finds implementations without the core application hardcoding references to them. Directory scanning works for a single-repo plugin folder; an explicit registry adds clarity and opt-in control; entry points scale to plugins distributed as independent packages. Start with the interface — get that contract right, and swapping the discovery mechanism later is a small, isolated change rather than a rewrite.