Plug-in discovery (entry points)
When to use
- You want optional extensions (extra file formats, transforms) shipped separately.
- Core app shouldn’t know every plug-in; just discover & load them.
- Teams can add features by installing a package, no code changes.
Avoid when a fixed small set of features is enough—use a simple registry dict.
Diagram (text)
Core app ──> load_plugins("myapp.transformers") ──> {name: plugin}
▲
Distribution entry points
(3rd-party packages expose plugins)
Python example (≤40 lines, type-hinted)
from __future__ import annotations
from importlib.metadata import entry_points
from typing import Protocol, Iterable, Dict, Any
class Transformer(Protocol):
name: str
def transform(self, rows: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]: ...
def load_plugins(group: str = "myapp.transformers") -> Dict[str, Transformer]:
plugins: Dict[str, Transformer] = {}
# built-in example
class Upper:
name = "upper"
def transform(self, rows):
for r in rows:
yield {**r, "name": str(r.get("name", "")).upper()}
plugins[Upper.name] = Upper()
# third-party via entry points
for ep in entry_points().select(group=group):
obj = ep.load() # class or factory
plugin: Transformer = obj() if callable(obj) else obj
plugins[plugin.name] = plugin
return plugins
def run_pipeline(rows: Iterable[dict[str, Any]], steps: list[str]) -> list[dict[str, Any]]:
plugins = load_plugins()
for s in steps:
rows = plugins[s].transform(rows)
return list(rows)
Tiny pytest (cements it)
def test_discovers_and_runs_plugins(monkeypatch):
# Fake an external plugin published via entry points
class Tag:
name = "tag"
def transform(self, rows):
for r in rows:
rr = dict(r); rr["tag"] = 1; yield rr
class FakeEP: # mimics importlib.metadata.EntryPoint
def load(self): return Tag
class FakeEPS:
def select(self, group): return [FakeEP()]
# Monkeypatch the imported entry_points symbol in this module
import types, sys
this = sys.modules[__name__]
monkeypatch.setattr(this, "entry_points", lambda: FakeEPS())
out = run_pipeline([{"name": "a"}], ["upper", "tag"])
assert out == [{"name": "A", "tag": 1}]
Trade-offs & pitfalls
- Pros: Extensible without redeploy; clean core; third parties can innovate independently.
- Cons: Indirection; harder to trace; version/compatibility management needed.
- Pitfalls:
- Untrusted code loading—whitelist groups/names, sandbox if needed.
- Name clashes—decide last-write wins or error on duplicates.
- Import errors—wrap
ep.load()and log/skip bad plugins with clear messages.
Pythonic alternatives
- Simple registry:
REGISTRY = {"upper": Upper()}for small, static sets. - Module scanning: import
myapp.plugins.*with naming convention (no packaging needed). - Config-driven: list dotted paths in YAML/TOML and import with
importlib. - Protocols (as above) keep plug-in contracts duck-typed; pydantic to validate plug-in config.
Mini exercise
Add safety to load_plugins:
- Reject plugins missing required attributes (
name,transform). - Add an optional allowlist parameter (set of names); load only those.
Write a test where one bad plugin is skipped and only allowlisted ones run.
Checks (quick checklist)
- Clear contract (Protocol) for plugins.
- Discovery isolates failures (bad plugin doesn’t crash the app).
- Deterministic resolution for duplicate names.
- Configurable allow/deny lists or version checks.
- Tests simulate entry point loading and ordering.




