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.