Software craft The unwritten rules

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

You install a plugin, pip list shows it, and your app says it isn’t there:

text
textpipe-future: warming up the model cache
WARNING textpipe.plugins: skipping plugin 'summarize': textpipe_future.Summarize targets textpipe API 2.0, but textpipe 1.4 is installed
PluginNotFoundError: 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:

PluginWhat it does
textpipe-shoutregisters shout, and works
textpipe-capsregisters caps, and works
textpipe-futureregisters summarize, written for textpipe 2.0, and prints a line when imported
textpipe-brokenregisters translate, and imports a module that isn’t installed
textpipe-loudregisters 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:

toml
plugins/textpipe-future/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:

python
>>> 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-future
shout textpipe_shout:Shout textpipe-shout

Nothing 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:

python
textpipe/src/textpipe/registry.py
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:

  1. Find. Loop over every entry point in the group. _admit is a place for checks that need only metadata, which a subclass can fill in; the basic registry admits everything.
  2. Import. ep.load() imports the plugin’s module and returns its class. If that raises, _skip logs a warning and moves on.
  3. Version. _check_version reads the class’s declared API version, and a too-new plugin is skipped the same way.
  4. Name. claims maps each name to the distribution that claimed it. If a second distribution claims a name that’s taken, the loop raises rather than choosing.
A flowchart of the four checks, with the success path running straight down and every failure to its right. Check 1, find: entry_points reads metadata and imports nothing. Check 2, import: ep.load runs the plugin's module; on an error the plugin is skipped with a warning. Check 3, version: if api_version is newer than the host, the plugin is skipped with a warning. Check 4, name: if another package already claimed the name, discovery raises and the group is unavailable; otherwise the plugin is usable.
Two failures are skipped with a warning, both after the plugin’s code has started running. A name collision raises.

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:

python
plugins/textpipe-future/src/textpipe_future/__init__.py
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”:

text
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 declaresHosttextpipe saysWhy
1.0.123451.4loads(1, 0, 12345) is less than (1, 4)
2.0rc11.4refused, as 2.0pre-release tags are stripped
1.4.01.4refusedPython compares tuples element by element, and a longer tuple beats its own prefix: (1, 4, 0) > (1, 4)
1.02.0loadsthe 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:

text
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:

text
entry_points: shout -> textpipe-shout 0.2.0
distributions: textpipe-shout 0.2.0, textpipe-shout 0.1.0

Choosing 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:

text
select(Casing): AmbiguousPluginError: 2 plugins are a Casing: caps, shout. Pin one: select(Casing, pin=...).
select(Casing, pin='caps'): textpipe_caps:Caps
Why 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 wrongtextpipepytest (pluggy)Flask CLIstevedore
The plugin’s import failsSkipped with a warning; later “no usable plugin”pytest exits at startup with the ModuleNotFoundErrorEvery flask command fails with the tracebackExtensionManager skips it, logging “Could not load” if the app configures logging; DriverManager re-raises the error
Written for a newer hostSkipped, after its code ranNo version check; it loads (though a plugin function pytest doesn’t recognise stops the run)No check; it loadsNo check; it loads
Two distributions, one nameRaises; the whole group is unavailableThe first found wins; the second is never importedBoth are imported; the last one replaces the firstDriverManager 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:

python
textpipe/src/textpipe/stricter.py
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 True

declared_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:

text
WARNING textpipe.plugins: skipping plugin 'summarize': its package requires textpipe>=2.0, and textpipe 1.4 is installed; not imported

That’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:

text
$ pip install textpipe==1.4 # with textpipe 2.0 and textpipe-future installed
ERROR: 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 check
textpipe-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:

A timeline with three places a version check can run. 1, at pip install: pip reads the plugin package's requirement, textpipe>=2.0. It prevents a mismatched install, doesn't stop --no-deps installs or later downgrades, and is seen by whoever runs pip. 2, before ep.load: the host reads the same requirement from ep.dist.requires. It prevents import-time code from running, doesn't stop plugins that declare nothing, and is seen in the log, and in the error if the host keeps the reason. 3, after ep.load, once the plugin's code has run: the host reads the class's api_version. It prevents using a plugin that won't work, doesn't stop its import-time code, and is seen in the log, while a plain error only says not found.
At install, at the door, and inside: each catches what the one before it lets through.

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:

text
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:

Install two plugins, ask for onelive lab

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 .), not PYTHONPATH: it writes the metadata the host needs to find you.
  • Run pip check after 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 ExtensionManager if 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:

python
demo/three_checks.py
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.cls

textpipe 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:

python
# 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):

  1. 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 read loads.
  2. Does “not found” say why? Ask for that plugin by name, and read the message.
  3. 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:

text
python demo/three_checks.py
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, PluginCollisionError
StricterRegistry
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, PluginCollisionError

Read your own answers the same way:

If you seeIt meansFix
”imported: yes” on the first lineIncompatible plugins run their import-time code in your processRead the declared requirement from ep.dist.requires before calling ep.load()
A bare “not found” on the secondYour users will be reading logsKeep each skip reason, and put it in the error
”returned” on the third, not an errorYour host picks the winner by the order the metadata happens to be found in: sys.path, then directory listingRaise. 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.

Shell
Terminal window
cd mainlog-code/plugin-discovery
sh run_scenarios.sh /tmp/textpipe-work # every trace above, in fresh environments
python3 -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 tests
sh real_hosts/run.sh /tmp/real-hosts-work # the pytest, Flask and stevedore table

Installing 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.

Share this article

Related posts

3 items
  • Oct 21, 2026 12 min read Software craft

    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
  • Oct 2, 2026 16 min read Software craft

    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
  • Sep 15, 2026 11 min read Software craft

    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 craft

    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.

    api-designai-agents
    Oct 2026
  • Software craft

    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.

    pythonapi-design
    Oct 2026
  • Software craft

    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.

    pythontesting
    Sep 2026

Comments