What plugin discovery actually checks
Four checks stand between an installed plugin and a loaded one, and their order decides what each can stop.
On this page · 11 sections
- The door check at a party
- Check 1: find the entry point
- Checks 2 to 4: the loop
- Check 2: letting a plugin in runs it
- Check 3: checking ID once they’re inside
- Check 4: two distributions, one name
- The same failures in pytest, Flask and stevedore
- What I’d change: check ID at the door
- Check what you can before letting them in
- Compare versions by packaging’s rules, and refuse other major versions
- Put the reason in the error
- If you write or install plugins
- Three checks for your own host
- The demo
You install a plugin, pip list shows it, and your app says it isn’t there:
textpipe-future: warming up the model cacheWARNING textpipe.plugins: skipping plugin 'summarize': textpipe_future.Summarize targets textpipe API 2.0, but textpipe 1.4 is installedPluginNotFoundError: No usable plugin named 'summarize' in 'textpipe.filters' (usable: shout).Three lines, three surprises. The plugin was installed with pip install --no-deps, which skips a package’s requirements, so nothing stopped it going in next to a host too old for it. The app did refuse it as too new, but only after running its code: the first line was printed by the plugin itself. And the error your code catches says “not found”, not why.
The app here is textpipe, a small made-up host, the app that loads plugins, for text filters. Real Python hosts, pytest, Flask’s command-line tool, and tools built on pluggy or stevedore, each have a discovery loop that does the same job. The loop decides whether an installed plugin is used or skipped, whether its code runs before anything checks it fits, and what happens when two packages pick the same name, and real hosts answer each of those differently: the same broken plugin is a logged warning in one and a crash at startup in another. I’ll take textpipe’s loop apart check by check, map the same failures onto pytest, Flask and stevedore, and end with three tests you can run against your own host.
The short version
- A host finds plugins in four checks: find the plugin’s entry in installed metadata, import its code, check its version, check its name isn’t taken.
- Importing a plugin runs it, so a version check that reads the plugin’s class always runs after the plugin’s code. To refuse a plugin before it runs, check what its package declares instead.
- In textpipe, “not found” means “skipped, read the log”. Keep the reason, and put it in the error.
- Two packages with one name: hosts disagree, some raising, some keeping the first, some the last. Raising is the safe answer.
The door check at a party
Think of a host checking guests at a party. They look for your name on the list. They let you in. Then they check your ID, and find you’re not old enough. By then you’ve already been in the kitchen.
That’s the order a typical Python plugin loader works in. Its list is installed package metadata, letting a guest in is importing the plugin, and checking ID is the version check, which often can’t happen until the guest is inside, because the version is written on the plugin’s own class. Each check below is one step at that door.
Five one-file plugins, installed in fresh virtual environments with real wheels, hit every outcome:
| Plugin | What it does |
|---|---|
textpipe-shout | registers shout, and works |
textpipe-caps | registers caps, and works |
textpipe-future | registers summarize, written for textpipe 2.0, and prints a line when imported |
textpipe-broken | registers translate, and imports a module that isn’t installed |
textpipe-loud | registers shout too |
Check 1: find the entry point
A plugin is a separate distribution, the thing pip installs (textpipe-future), which contains an importable package (textpipe_future). It announces itself with an entry point, a line of package metadata that says “I’m here”: a group the host searches, and the object to load. It’s declared in pyproject.toml:
[project.entry-points."textpipe.filters"]summarize = "textpipe_future:Summarize"So textpipe.filters is the group, summarize the plugin’s name, and textpipe_future:Summarize the class to load. The build tool writes that into an entry_points.txt file, and pip installs it with the rest of the distribution’s metadata in a .dist-info folder next to the code. The host reads it with importlib.metadata:
>>> from importlib.metadata import entry_points>>> for ep in entry_points(group="textpipe.filters"):... print(ep.name, ep.value, ep.dist.name)summarize textpipe_future:Summarize textpipe-futureshout textpipe_shout:Shout textpipe-shoutNothing is imported yet: an EntryPoint is those strings plus ep.dist, the distribution that declared it. This is the guest list, read without letting anyone in.
It’s also why a plugin you’re developing can vanish. A source checkout on PYTHONPATH has code but no installed metadata, so it isn’t on the list, unless an earlier build left an *.egg-info folder (setuptools’ metadata in the source tree) beside it. Then it shows up, with that stale metadata.
Checks 2 to 4: the loop
Here’s textpipe’s discovery method, the whole door. _loader is importlib.metadata.entry_points; tests hand it fake entry points instead, which the last section uses:
def _discover(self, group): claims = {} # name -> (package, class) for ep in self._loader(group=group): # 1. find: read installed metadata if not self._admit(group, ep): continue try: cls = ep.load() # 2. import: runs the plugin's module self._check_version(cls) # 3. version except PluginIncompatibleError as exc: self._skip(group, ep.name, str(exc)) continue except Exception as exc: self._skip(group, ep.name, repr(exc), verb="could not import plugin") continue package = getattr(ep.dist, "name", None) # 4. name: which package claims it earlier = claims.get(ep.name) if earlier and earlier[0] and package and earlier[0] != package: raise PluginCollisionError( collision_message(group, ep.name, earlier, (package, cls))) claims[ep.name] = (package, cls) return {name: cls for name, (_package, cls) in claims.items()}Read it in order:
- Find. Loop over every entry point in the group.
_admitis a place for checks that need only metadata, which a subclass can fill in; the basic registry admits everything. - Import.
ep.load()imports the plugin’s module and returns its class. If that raises,_skiplogs a warning and moves on. - Version.
_check_versionreads the class’s declared API version, and a too-new plugin is skipped the same way. - Name.
claimsmaps each name to the distribution that claimed it. If a second distribution claims a name that’s taken, the loop raises rather than choosing.

The pattern behind it: when a plugin can’t work here, the loader logs it and moves on, so one broken plugin can’t take down an app that doesn’t use it. When the loader would have to guess, two distributions with one name, it raises. Otherwise sys.path order, the order Python searches folders for code, then the order files happen to be listed in, would pick the winner, and neither says anything about which plugin you meant.
Eager discovery, and the stand-in entry points
Discovery here is eager: the first request imports every plugin in the group, whichever one you asked for. stevedore, a plugin library from OpenStack, offers several managers, ways of loading a group; its named managers check the name first and import only the plugins you asked for. And ep.dist is None only for entry points built by hand, which is why the collision check only compares two claims that both name a distribution.
Check 2: letting a plugin in runs it
ep.load() imports the module named in the entry point, and importing a module runs everything at its top level: other imports, a print, opening a connection, starting a thread. Here’s the whole of textpipe-future:
from textpipe import Filter
print("textpipe-future: warming up the model cache") # runs on import
class Summarize(Filter): api_version = "2.0"
def apply(self, text): return text[:40]That print is the guest in the kitchen. It runs the moment the plugin is imported, before anything has asked whether it fits.
If the import fails, the plugin is skipped. textpipe-broken imports a module that isn’t installed, so the first discovery logs a warning, and every later request for it gets a bare “no usable plugin”:
WARNING textpipe.plugins: could not import plugin 'translate': ModuleNotFoundError("No module named 'language_tables'")PluginNotFoundError: No usable plugin named 'translate' in 'textpipe.filters' (usable: shout).Two corners of the import check
The except Exception mislabels one case: an api_version that isn’t a string fails in the version check but is logged as “could not import plugin”. And it doesn’t catch SystemExit, so a plugin calling sys.exit() at import still stops the host.
Check 3: checking ID once they’re inside
textpipe-future declares the API version it targets as a class attribute, api_version = "2.0". Reading it needs the class, and the class needs the import, so this check always runs after the plugin’s code. A version on the class tells the host what the plugin claims to target; it can’t protect the host from code that runs at import.
How the versions are compared matters too. textpipe’s parse_version_prefix keeps the leading number of each dotted part, so "2.0" becomes (2, 0), and refuses the plugin when its tuple is bigger than the host’s. That mostly works, with two traps:
| Plugin declares | Host | textpipe says | Why |
|---|---|---|---|
1.0.12345 | 1.4 | loads | (1, 0, 12345) is less than (1, 4) |
2.0rc1 | 1.4 | refused, as 2.0 | pre-release tags are stripped |
1.4.0 | 1.4 | refused | Python compares tuples element by element, and a longer tuple beats its own prefix: (1, 4, 0) > (1, 4) |
1.0 | 2.0 | loads | the check only looks one way |
The third row is wrong by Python packaging’s own rules. Its version spec (PEP 440) pads the shorter version with zeros: “X.Y and X.Y.0 are not considered distinct release numbers.”
The fourth row is the one I’d fix before a host’s first breaking release. A plugin declaring 1.0 loads on a host at 2.0, after exactly the breaking changes a new major version, the first number, exists to signal. The fix is to refuse other major versions too. But the check compares against textpipe’s package version, so once it refuses other majors, every major release of textpipe would count as a plugin-API break, whether the plugin interface changed or not. A separate constant, say textpipe.PLUGIN_API = "1.0", bumped only when that interface changes, avoids it.
Check 4: two distributions, one name
Two guests give the door the same name. The entry points spec only asks for names to be unique within one distribution’s group, and leaves the rest to the host: “If different distributions provide the same name, the consumer decides how to handle such conflicts.” Install textpipe-shout and textpipe-loud, which both register shout:
PluginCollisionError: Two packages register 'shout' in 'textpipe.filters': textpipe-loud (textpipe_loud:Loud) and textpipe-shout (textpipe_shout:Shout). Rename the entry point in one of them, or uninstall one.It names both distributions and both fixes, and takes the whole group down: nothing is cached, so every later request re-runs discovery, retries any import that failed, re-logs the warnings, and raises again. The check also only sees plugins that passed checks 2 and 3: if one shout distribution can’t import, the collision is never reported.
Why the name check runs after the import, and a duplicate the loop never sees
The name check needs only metadata, so it could run before the import, as pluggy’s does (silently keeping the first). But since textpipe raises, a duplicate that could never have loaded would then block the group; after the import, only a duplicate that actually loads can block it.
One kind of duplicate never reaches the loop at all. Install textpipe-shout 0.1.0, and put a 0.2.0 copy (code and .dist-info) in a folder first on PYTHONPATH. Since Python 3.10, entry_points() deduplicates distributions by normalized name and keeps the first on sys.path, so the loop sees one shout and loads 0.2.0. distributions() still returns both copies; a host that wants to catch this has to walk it and compare names itself:
entry_points: shout -> textpipe-shout 0.2.0distributions: textpipe-shout 0.2.0, textpipe-shout 0.1.0Choosing between two plugins that both work comes after discovery. Plugins subclass textpipe’s base classes, and Casing is the one for case-changing filters. When the app asks for any plugin of a kind, select(Casing), and two qualify, textpipe raises until the app pins one, names the plugin it wants:
select(Casing): AmbiguousPluginError: 2 plugins are a Casing: caps, shout. Pin one: select(Casing, pin=...).select(Casing, pin='caps'): textpipe_caps:CapsWhy not a priority number?
The usual alternative to pinning is a priority number. But when each plugin’s author picks it, it tells the registry nothing: there’s no shared scale, and an author competing for the slot has every reason to pick the highest number. keyring, a library for reaching the system’s password store, is the exception that proves it: its plugins, called backends, compute their own priority at runtime, from whether they’re usable on this machine.
The same failures in pytest, Flask and stevedore
The same kinds of plugin behave like this in pytest 9.1.1 on pluggy 1.6.0, Flask 3.1.3 and stevedore 5.9.1, each tried with small test plugins in a fresh environment:
| What’s wrong | textpipe | pytest (pluggy) | Flask CLI | stevedore |
|---|---|---|---|---|
| The plugin’s import fails | Skipped with a warning; later “no usable plugin” | pytest exits at startup with the ModuleNotFoundError | Every flask command fails with the traceback | ExtensionManager skips it, logging “Could not load” if the app configures logging; DriverManager re-raises the error |
| Written for a newer host | Skipped, after its code ran | No version check; it loads (though a plugin function pytest doesn’t recognise stops the run) | No check; it loads | No check; it loads |
| Two distributions, one name | Raises; the whole group is unavailable | The first found wins; the second is never imported | Both are imported; the last one replaces the first | DriverManager raises MultipleMatches; ExtensionManager loads both, and looking the name up returns the last one found |
ExtensionManager is stevedore’s manager for loading every plugin in a group, and DriverManager the one for loading exactly one. Every cell reproduces with sh real_hosts/run.sh in plugin-discovery/, which pins each version.
Two cases come out the same everywhere, by how Python finds metadata rather than by anything run.sh tests: a plugin with no installed metadata is never seen, and a distribution installed twice silently resolves to the first copy on sys.path. In the first row, “not found” means “skipped, read the log” in textpipe and stevedore’s ExtensionManager, while pytest and Flask stop at startup with the traceback.
What I’d change: check ID at the door
The demo’s stricter.py makes three changes through the registry’s hooks.
Check what you can before letting them in
If the plugin’s distribution declares which host versions it needs (dependencies = ["textpipe>=2.0"] in its pyproject.toml), that requirement is in ep.dist.requires, and _admit can check it before ep.load(). That’s checking ID at the door:
def _admit(self, group, ep): try: spec = declared_host_requirement(ep) # textpipe's range, from ep.dist.requires except UNREADABLE as exc: # an unparseable line or marker self._skip(group, ep.name, f"unreadable requirement ({exc}); not imported") return False if spec is not None and not spec.contains(self._host_version, prereleases=True): self._skip(group, ep.name, f"its package requires textpipe{spec}, and " f"textpipe {self._host_version} is installed; not imported") return False return Truedeclared_host_requirement finds the plugin’s requirement on textpipe, parsed with the packaging library. It skips any requirement whose marker, the condition after ;, doesn’t apply, such as extra == "gpu" for an optional extra. If the host’s version isn’t in the declared range, the plugin is skipped without being imported; prereleases=True lets a pre-release host like 2.0rc1 count as in range. UNREADABLE is the set of errors a requirement can raise when it can’t be parsed or its marker can’t be evaluated, so one bad line skips one plugin, rather than breaking discovery for all of them. With this, the warming-up line from the opening never prints:
WARNING textpipe.plugins: skipping plugin 'summarize': its package requires textpipe>=2.0, and textpipe 1.4 is installed; not importedThat’s the most this check can do. It isn’t a safety boundary: a plugin that passes every check still runs whatever is at its top level.
Doesn't pip already check this?
The same requirement lets pip stop a mismatched install, but only for the packages in the current command. Without --no-deps, pip won’t install textpipe-future next to textpipe 1.4. Yet with textpipe 2.0 and textpipe-future installed properly, pip install textpipe==1.4 downgrades anyway. It prints the conflict under an ERROR: header but exits 0, and after that only pip check notices:
$ pip install textpipe==1.4 # with textpipe 2.0 and textpipe-future installedERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.textpipe-future 0.1.0 requires textpipe>=2.0, but you have textpipe 1.4 which is incompatible.Successfully installed textpipe-1.4$ pip checktextpipe-future 0.1.0 has requirement textpipe>=2.0, but you have textpipe 1.4.So there are three places a version check can run, and the earlier, the more it prevents:

Compare versions by packaging’s rules, and refuse other major versions
packaging’s Version knows that 1.4 equals 1.4.0 and that 2.0rc1 comes before 2.0, so the stricter check refuses a plugin when its major version differs from the host’s, or it’s newer. That fixes the third and fourth rows of the table above.
Put the reason in the error
The stricter registry remembers why it skipped each plugin, and get() says so:
PluginNotFoundError: 'summarize' is installed but not usable: its package requires textpipe>=2.0, and textpipe 1.4 is installed; not imported.Now the error your code catches is one someone can act on.
Try every case yourself. Pick a pair of plugins and see which check stops it; then, for the first pair, switch to The stricter registry and watch the warming-up line disappear:
This lab runs a real call in your browser, so it needs JavaScript.
If you write or install plugins
- Keep your module’s top level free of side effects; do the work in the plugin’s methods.
- Declare the host range in
dependencies("textpipe>=1.4,<2"), so pip, and a host that checks before importing, can refuse a mismatch. - Develop with an editable install (
pip install -e .), notPYTHONPATH: it writes the metadata the host needs to find you. - Run
pip checkafter installing or upgrading anything next to a host. - When a host says “not found”, read the warnings above the error. In textpipe, and in stevedore’s
ExtensionManagerif the app configures logging, that’s where the reason is.
Three checks for your own host
Each failure above is a question you can ask of your own loader, without installing anything, using a stand-in entry point: an object with name, dist and load(), all textpipe’s registry reads, whose load() counts how many times it’s called:
from types import SimpleNamespace
class StandIn: """Enough of importlib.metadata.EntryPoint for textpipe's registry to use."""
def __init__(self, name, cls, package, requires=()): self.name, self.cls, self.loads = name, cls, 0 self.dist = SimpleNamespace(name=package, requires=list(requires))
def load(self): self.loads += 1 return self.clstextpipe takes its entry points as a constructor argument, so a test just hands it stand-ins. (Patching the name wouldn’t reach textpipe’s default, which is bound when the registry is created.) If your host looks up importlib.metadata.entry_points itself and calls it during discovery, patch that name in a pytest test instead:
# if your host's module does `from importlib.metadata import entry_points`:monkeypatch.setattr(myhost.plugins, "entry_points", lambda **kw: stand_ins)# or, if it calls importlib.metadata.entry_points(...) directly, as Flask's CLI does:monkeypatch.setattr(importlib.metadata, "entry_points", lambda **kw: stand_ins)That only works for a loader that calls entry_points: pluggy walks distributions() instead, so for pytest you’d fake whole distributions, as pluggy’s own tests do, or test with real installed plugins, as the demo’s run_scenarios.sh does.
Then ask three questions, using your registry’s discovery call (available() here) and its lookup by name (get() here):
- Is a too-new plugin refused, and was it imported first? Give it a stand-in whose class targets a newer API and whose distribution declares
textpipe>=2.0. Run discovery, see whether the plugin came back, and readloads. - Does “not found” say why? Ask for that plugin by name, and read the message.
- What does a collision do, and does it stay broken? Give it two stand-ins with one name from different distributions, and run discovery twice.
Against textpipe’s two registries:
Registry 1. too-new plugin: refused: yes, imported: yes 2. lookup: PluginNotFoundError: No usable plugin named 'new' in 'textpipe.filters' (usable: none). 3. collision, two requests: PluginCollisionError, PluginCollisionErrorStricterRegistry 1. too-new plugin: refused: yes, imported: no 2. lookup: PluginNotFoundError: 'new' is installed but not usable: its package requires textpipe>=2.0, and textpipe 1.4 is installed; not imported. 3. collision, two requests: PluginCollisionError, PluginCollisionErrorRead your own answers the same way:
| If you see | It means | Fix |
|---|---|---|
| ”imported: yes” on the first line | Incompatible plugins run their import-time code in your process | Read the declared requirement from ep.dist.requires before calling ep.load() |
| A bare “not found” on the second | Your users will be reading logs | Keep each skip reason, and put it in the error |
| ”returned” on the third, not an error | Your host picks the winner by the order the metadata happens to be found in: sys.path, then directory listing | Raise. The cost is that the group stays unavailable, on every request, until someone renames or uninstalls one |
The demo
The host, the five plugins and 31 tests are in plugin-discovery/. run_scenarios.sh builds every package as a wheel and runs each scenario in a fresh virtual environment, which is where the traces here come from (the version-table cases and the import corners come from the unit tests); real_hosts/run.sh does the same for pytest, Flask and stevedore at pinned versions.
cd mainlog-code/plugin-discoverysh run_scenarios.sh /tmp/textpipe-work # every trace above, in fresh environmentspython3 -m venv .venv && .venv/bin/pip install packaging pytest /tmp/textpipe-work/dist/textpipe-1.4-py3-none-any.whl.venv/bin/python -m pytest testssh real_hosts/run.sh /tmp/real-hosts-work # the pytest, Flask and stevedore tableInstalling the built wheel, rather than pip install ./textpipe, keeps pip from building inside the source tree, which would leave an *.egg-info folder behind: the stale-metadata trap from check 1.
Related posts

The one-sentence API design test
Six AI agents each designed one extension to my framework, and each design held up on its own. Side by side they disagreed about names every user touches. One question sorted them out, and writing down the rules my existing code already followed made the answer countable.
The unwritten rulesapi-designai-agentspython
How many concepts is too many
Framework debates trade concept counts that measure different things. Count what the smallest real program makes its user write, in separate columns and per reader, find the rules the count can't see by misspelling them, and keep an extra concept only when it moves a check earlier or gives users a seam they can see.
The unwritten rulespythonapi-designframeworks
Green on my laptop, red in CI
A pytest plugin passed every test from the source checkout and failed twice in CI, because only the installed wheel told pytest the plugin existed.
The unwritten rulespythontesting
Software craftThe one-sentence API design test
Six AI agents each designed one extension to my framework, and each design held up on its own. Side by side they disagreed about names every user touches. One question sorted them out, and writing down the rules my existing code already followed made the answer countable.
api-designai-agentsOct 2026
Software craftHow many concepts is too many
Framework debates trade concept counts that measure different things. Count what the smallest real program makes its user write, in separate columns and per reader, find the rules the count can't see by misspelling them, and keep an extra concept only when it moves a check earlier or gives users a seam they can see.
pythonapi-designOct 2026
Software craftGreen on my laptop, red in CI
A pytest plugin passed every test from the source checkout and failed twice in CI, because only the installed wheel told pytest the plugin existed.
pythontestingSep 2026
Comments