A plugin system needs two separate capabilities: discovery answers which installed components advertise support, and loading imports a selected component. Directory scanning mixes those jobs with assumptions about filesystem layout. It misses zip imports and editable-install details, imports accidental files, struggles with namespace packages, and has no standard place for a plugin name or object target.
Python packaging already supplies a metadata registry: entry points. A distribution declares a group, a name within that group, and an import target such as package.module:factory. The host queries installed distribution metadata, applies policy, and calls EntryPoint.load() only for accepted candidates.
This article builds the host side. It does not prescribe one packaging frontend; distributions can publish equivalent metadata through pyproject.toml and build tools. The runtime contract lives in importlib.metadata.
Version boundary.
importlib.metadatais Python's standard interface to installed distribution metadata. Its entry-point selection API has evolved, so code supporting older Python versions needs compatibility handling. Selection, validation, duplicate policy, and activation are application decisions. Experiments were verified on CPython 3.14.7 without modifying the environment.
Experiment 1: distribution names are not import names
Installed project metadata and import packages occupy different namespaces.
from importlib.metadata import PackageNotFoundError, distribution
for project_name in ("pip", "definitely-not-installed-deepcuts"):
try:
dist = distribution(project_name)
except PackageNotFoundError:
print(project_name, "missing")
else:
print(project_name, dist.metadata["Name"])
The distribution name used by an installer can differ from the package imported by Python. One distribution can provide several top-level packages, and namespace packages can be spread across distributions. Converting hyphens to underscores and scanning an import directory is not reliable discovery.
Entry points bridge the namespaces explicitly. Their metadata belongs to a distribution, while their value names an importable object. Preserve both identities in diagnostics: operators need to know which installed project advertised a broken target.
Experiment 2: query one private group
Hosts should own a collision-resistant group name, commonly based on the project.
from importlib.metadata import entry_points
group = "python_deepcuts.renderers"
plugins = entry_points(group=group)
print(type(plugins).__name__)
print(all(plugin.group == group for plugin in plugins))
print(len(plugins) >= 0)
An empty result is normal in this environment. Discovery reads metadata; it does not load plugin modules. That separation keeps startup predictable and lets a host list, filter, authorize, or report candidates before executing third-party code.
Python 3.12 established the selectable entry-points API shape used here, and 3.13 stopped making EntryPoint tuple-like. For Python 3.14-only code, use keyword selection directly. A multi-version library should test its compatibility adapter on every supported version rather than infer behavior from one release.
Experiment 3: inspect without loading
Construct representative metadata and examine its fields.
from importlib.metadata import EntryPoint
plugin = EntryPoint(
name="json",
value="json:loads",
group="example.parsers",
)
print("[state] entry-point name:", plugin.name)
print("[state] entry-point module:", plugin.module)
print("[state] entry-point attribute:", plugin.attr)
print("[state] entry-point group:", plugin.group)
print("[state] entry-point target:", plugin.value)
This prints a logical plugin name, module, attribute, group, and raw target. Modern hosts should treat extras on entry points as legacy and avoid using them for dependency activation. Dependencies belong in distribution metadata and installer workflows.
Metadata is untrusted input from installed packages. Validate the group and logical name, enforce allowlists where appropriate, and display the target without importing it. A plugin installation already adds code to the environment; loading that code grants the host process's privileges.
Experiment 4: loading imports the target
load() resolves the module and optional attribute through normal import machinery.
from importlib.metadata import EntryPoint
function_plugin = EntryPoint(
name="decode",
value="json:loads",
group="example.parsers",
)
module_plugin = EntryPoint(
name="json_module",
value="json",
group="example.parsers",
)
decode = function_plugin.load()
module = module_plugin.load()
print(decode('{"answer": 42}')["answer"])
print(module.__name__)
Loading executes any not-yet-imported module top level. Errors can include a missing target module, a missing attribute, syntax failure, dependency failure inside the plugin, or arbitrary initialization exceptions. Do not collapse all of these into "plugin not found."
Discover early if you need a catalog; load late when a plugin is selected. Lazy loading reduces startup work and isolates failures to used capabilities. For commands that must guarantee every configured plugin works before serving traffic, add an explicit validation phase during deployment or startup.
Experiment 5: validate a behavioral contract
Entry points identify objects; they do not prove those objects match the host API.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderer(Protocol):
def render(self, value: object) -> str:
...
class TextRenderer:
def render(self, value):
return str(value)
class WrongPlugin:
pass
for candidate in (TextRenderer(), WrongPlugin()):
print("[check] runtime renderer match:", type(candidate).__name__, isinstance(candidate, Renderer))
The protocol check verifies attribute presence, not full signature semantics or behavior. A robust host can load a factory, validate required methods, inspect a declared plugin API version, and run a cheap initialization check. Static protocols help plugin authors during development; runtime checks provide clearer host errors.
Keep the contract small. Passing the entire application container into plugins creates accidental coupling and makes compatibility impossible. Supply a documented context object or narrow services. Version the contract when incompatible changes are unavoidable, perhaps through a new entry-point group such as myapp.renderers.v2.
Experiment 6: make duplicates a policy decision
Two distributions can advertise the same entry-point name. Do not silently accept iteration order.
from importlib.metadata import EntryPoint
candidates = [
EntryPoint(name="text", value="json:dumps", group="example.renderers"),
EntryPoint(name="text", value="pprint:pformat", group="example.renderers"),
]
by_name = {}
for candidate in candidates:
by_name.setdefault(candidate.name, []).append(candidate)
duplicates = {name: values for name, values in by_name.items() if len(values) > 1}
print("[state] duplicate plugin names:", sorted(duplicates))
print("[state] conflicting plugin targets:", [item.value for item in duplicates["text"]])
Installed metadata order is not a safe precedence rule. A host can reject duplicates, require configuration to select a distribution, or namespace logical names. Rejection is the simplest default because environment changes cannot silently replace behavior.
When entry points came from real discovery, entry_point.dist identifies the owning distribution. Distribution objects do not promise value equality semantics, so compare normalized project names and versions rather than relying on object equality. Include owner, version, and target in conflict reports.
Experiment 7: test the host with entry-point doubles
Host logic should accept discovered metadata as input so tests do not install distributions.
from importlib.metadata import EntryPoint
def select_plugins(candidates, allowed_names):
selected = {}
for candidate in candidates:
if candidate.name not in allowed_names:
continue
if candidate.name in selected:
raise ValueError(f"duplicate plugin: {candidate.name}")
selected[candidate.name] = candidate
return selected
fixtures = [
EntryPoint(name="json", value="json:loads", group="example.parsers"),
EntryPoint(name="ignored", value="math:sqrt", group="example.parsers"),
]
selected = select_plugins(fixtures, {"json"})
print(list(selected))
print(selected["json"].load()("42"))
This tests selection with real EntryPoint values and loads only a standard-library target. In production, keep a thin discovery function around entry_points(group=...), then pass its result into deterministic policy code.
Use an isolated virtual environment or subprocess for one integration test that builds and installs a tiny fixture distribution. That catches malformed packaging metadata and import targets without making the whole unit suite depend on the developer's installed plugins.
Experiment 8: separate discovery, activation, and execution
A lazy registry can store metadata and load once on demand.
from importlib.metadata import EntryPoint
class PluginRegistry:
def __init__(self, entries):
self._entries = {entry.name: entry for entry in entries}
self._loaded = {}
def get(self, name):
if name not in self._loaded:
self._loaded[name] = self._entries[name].load()
return self._loaded[name]
registry = PluginRegistry([
EntryPoint(name="decode", value="json:loads", group="example.parsers")
])
first = registry.get("decode")
second = registry.get("decode")
print(first is second)
print(first("true"))
This prints True and True. sys.modules already caches imported module objects; the registry additionally caches the resolved target and provides a place for policy and diagnostics. A production registry should reject duplicate entries before building the dictionary and produce a domain-specific error for unknown names.
Activation may need more than loading. A factory can receive host context and return a managed plugin instance. Define who owns shutdown, whether one instance is shared, whether calls can be concurrent, and how health is reported. Import metadata does not answer lifecycle questions.
Publishing metadata
A plugin distribution commonly declares an entry point in pyproject.toml:
[project.entry-points."myapp.renderers"]
markdown = "acme_renderer:create_renderer"
The group belongs to the host contract; markdown is the selectable logical name; the value points to an importable factory. The plugin's distribution metadata should also declare compatible Python and host-library dependencies. Entry points do not automatically enforce semantic API compatibility.
Avoid import-name conventions such as "scan every module beginning with myapp_plugin_." pkgutil.iter_modules() documents that naming-convention approach, but it scans import locations and discovers candidates by filename rather than explicit metadata. Namespace-package discovery is better scoped but still couples discovery to import layout. Entry points are preferable when independently distributed plugins need explicit advertisement.
There are legitimate alternatives. A single application repository may use an explicit configuration list of import strings. A framework-owned namespace package can provide a simple ecosystem convention. Built-in plugins can use a plain dictionary. Choose the least dynamic mechanism that meets deployment needs.
Failure and security boundaries
Plugin code runs in process. Protocol validation is not isolation, and catching exceptions is not containment. A plugin can mutate globals, start threads, read credentials, or terminate the process. Only load trusted installed distributions. For mutually untrusted extensions, define an out-of-process protocol with authentication, resource limits, serialization, and lifecycle supervision.
Report failures with stages: discovery, policy, import, factory construction, activation, execution, and shutdown. Include logical name, distribution, version, and target. Preserve exception chaining so operators see whether the plugin itself or one of its dependencies failed.
Decide whether one bad plugin prevents startup. Required configured plugins should usually fail fast. Optional unselected plugins need not load. A catalog endpoint can report unavailable candidates without activating them. Make policy explicit rather than allowing incidental import order to decide availability.
Compatibility and reproducible environments
Entry-point discovery reflects the current interpreter environment. Two machines with different installed distributions can produce different catalogs even when application source is identical. Lock deployment dependencies, record environment provenance, and expose the selected plugin distribution versions in diagnostics. Discovery should not become an undeclared feature flag.
Host and plugin versions need a compatibility handshake beyond successful import. A plugin distribution can declare a dependency range on the host API package, while the loaded factory can expose a small protocol version for a final runtime check. Reject incompatibility before the plugin mutates registries or opens resources.
Avoid deriving precedence from distribution version alone. A newer unrelated distribution should not silently replace a configured plugin. Configuration selects logical behavior; packaging constraints establish whether the selected implementation can run.
For long-lived processes, installing or uninstalling distributions underneath the interpreter is unsafe. Metadata caches, import caches, loaded modules, and active plugin instances can disagree. Build a new environment and restart the process. Hot replacement requires a much larger lifecycle and isolation protocol than entry points provide.
Shutdown is part of activation
If a plugin factory opens threads, files, clients, or task groups, activation must return an object with a documented shutdown path. Register cleanup only after successful activation, call it in reverse activation order, and preserve multiple shutdown failures rather than abandoning remaining cleanup after the first one.
A context manager or explicit start() and close() protocol makes ownership visible. Importing a plugin should not start resources merely as a module side effect. Keeping import inert allows catalog inspection, validation tooling, and command completion to run without connecting to production systems.
Practical decisions
- Use a host-owned entry-point group and a documented, versioned plugin contract.
- Keep discovery metadata-only; load and activate only selected plugins.
- Reject duplicate logical names unless configuration resolves them explicitly.
- Preserve distribution name, version, target, and failure stage in diagnostics.
- Accept candidate collections in host logic so unit tests avoid environment mutation.
- Add one isolated packaging integration test for published metadata.
- Define plugin instance lifetime, concurrency, shutdown, and error ownership outside import code.
- Use process isolation when plugins are not fully trusted.
Exercises
- Design a
myapp.exporters.v1protocol with one factory and two narrow context services. - Extend the registry to reject duplicates and report every conflicting target before loading anything.
- Add an allowlist keyed by both logical plugin name and normalized distribution name.
- Build a tiny fixture distribution with a
pyproject.tomlentry point and verify it in a temporary virtual environment. - Simulate failures during load, factory creation, activation, and execution; preserve each original cause.
- Compare entry points with an explicit import-string configuration for a closed deployment and justify the simpler choice.
Keep this model
Plugin discovery is installed-distribution metadata, not filesystem exploration. An entry point explicitly connects a host-owned group and logical name to an import target. Discovery can remain inert; loading enters normal import semantics; activation and lifecycle belong to the host's application protocol.
Keep those stages separate. Validate a small contract, make conflicts deterministic, test policy without changing the environment, and remember that in-process extensibility is trust, not isolation. The result is a plugin system operators can inspect and developers can evolve without depending on directory accidents.