Users install an exporter plugin for your tool with pip, gem or npm, run the tool, and the exporter is missing, because each plugin also needs a line in a registration list. Loading every installed package with the right name removes that step. Then a second package claiming the same name replaces the first, depending on install paths, and the tool cannot say which package won.
What you get
You will end up with a host that finds plugins because they are installed, prints each with the package that provides it, and refuses to start when two packages claim one name. This is for you if you maintain a tool other people extend.
Short answer
In Python, declare each plugin under [project.entry-points."myapp.plugins"] and read the group
with importlib.metadata.entry_points(group=...), which imports nothing until you load one. In
Ruby, ship a known file such as myapp/plugin.rb and load it with Gem.find_latest_files. In
Node, prefer an explicit import list. Print every plugin with the distribution that provides it,
and refuse a name two distributions claim.
You will need
Python 3.10 or later, for EntryPoint.dist and selection by group; Ruby with RubyGems; Node 20.11
or later. Verified 2026-10-15 against Python 3.13.16, Ruby 3.3.6 with RubyGems 3.5.22, and Node
22.22.0. The plugin interface itself, the hooks a plugin implements, is a separate design; in
Python, pluggy is the common answer.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| ESLint flat config explicit imports | Node tools whose users already edit a config file | One import per plugin, so an installed package does nothing until somebody edits the config | Users expect a plugin to work the moment it is installed |
| Gem.find_latest_files | Ruby applications whose plugins ship as gems | Each plugin file must run to register, so discovery executes every installed plugin | You want to list plugins without running their code |
Prefix scan of node_modules | A Node tool that wants install-and-it-works, as Karma does by default | Any package with the prefix becomes active, including one installed as another package’s dependency | You cannot audit which packages land in node_modules |
| Python entry points via importlib.metadata | Python tools whose plugins ship as installed distributions | Every distribution can join, and the specification leaves a name clash to you | Plugins are local files rather than installed packages |
| RubyGems rubygems_plugin.rb | Extending the gem command with commands or install hooks | Runs on every gem command, from the latest version of every installed gem, and not when your application requires the gem | You want plugins for your own application |
Entry points are the strongest of the five, because the plugin’s name and location live in metadata the host can read without importing anything. The gem file convention needs each plugin to run before the host knows its name. An explicit import list costs a line per plugin and is the only option whose active set a reviewer can read in one file.
Declare the plugin in its own package
A Python plugin names the host’s group and its own entry in pyproject.toml. Installing the
distribution writes that table to entry_points.txt in its dist-info directory.
[project.entry-points."meterco.exporters"]
csv = "meterco_csv:CsvExporter"
The entry points specification
asks a host to start its group name with a PyPI name it owns, which is why the group is
meterco.exporters and not exporters.
Read entry points without importing them
The host reads the group, keeps the first entry point for each name, and records every name that a second distribution also claims.
found, clashes = {}, {}
for ep in entry_points(group=group):
first = found.setdefault(ep.name, ep)
if first.dist.name != ep.dist.name:
clashes.setdefault(ep.name, [first]).append(ep)
The two plugin distributions in the sample were installed with pip install --target into
python/site-packages, which the command puts on the path.
PYTHONPATH=python/site-packages python3 python/host.py
installed in meterco.exporters:
csv meterco_csv:CsvExporter from meterco-csv 1.0.0
jsonl meterco_jsonl:JsonlExporter from meterco-jsonl 1.2.0
imported so far: []
enabled ['csv']: ['meterco_csv.CsvExporter']
Discovery imported nothing. Only csv was loaded, so the jsonl module, which prints a line when
imported, never ran. Printing the list at startup is what makes the active set visible.
Do the same with installed gems
Ruby has no entry point metadata. The convention, which the
Gem.find_files documentation
shows with the path rdoc/discover, is a file at a known path in every plugin gem.
def discover
@registry = {} # load runs a file again, so a second call starts empty
Gem.find_latest_files('meterco/plugin', false).each do |path|
spec = Gem::Specification.latest_specs(true).find do |s|
path.start_with?(s.full_gem_path + File::SEPARATOR)
end
@loading = spec ? "#{spec.name} #{spec.version}" : path
load path
end
@registry
ensure
@loading = nil
end
find_latest_files skips older versions of a gem, where find_files would load both. The
false leaves out $LOAD_PATH, so a stray file in the application’s own lib cannot register.
load runs a plugin file again on every call, so discover empties the registry first; without
that line, a second call reports each plugin as clashing with itself.
Check it worked
Each language’s tests assert the same three things: every plugin is reported with its provider, nothing is loaded before it is enabled, and a clash is refused with both names.
python3 python/test_host.py
ok finds each plugin with its distribution
ok discovery imports nothing
ok load imports only what is enabled
ok an enabled plugin that is missing is an error
ok a plugin that fails to import is named
ok a name claimed twice is refused, naming both
ok the same distribution twice is not a clash
7 passed, 0 failed
ruby ruby/test_host.rb
ok each exporter is registered with the gem that provides it
ok a registered exporter works
ok discovering twice finds the same exporters, not a clash
ok a name claimed by two gems is refused, naming both
ok a plugin file on $LOAD_PATH alone is not discovered
ok the application never loads rubygems_plugin.rb
6 passed, 0 failed
node --test host.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
When it goes wrong
A third-party distribution, acme-meterco-csv, also declares csv. Two common one-liners pick
different winners, and swapping the order of two path entries swaps both.
PYTHONPATH=python/site-packages:python/acme-site-packages python3 python/naive.py
PYTHONPATH=python/acme-site-packages:python/site-packages python3 python/naive.py
PYTHONPATH=python/site-packages:python/acme-site-packages
eps['csv'] -> meterco-csv 1.0.0
{ep.name: ep}['csv'] -> acme-meterco-csv 0.3.0
PYTHONPATH=python/acme-site-packages:python/site-packages
eps['csv'] -> acme-meterco-csv 0.3.0
{ep.name: ep}['csv'] -> meterco-csv 1.0.0
Inside one directory it is worse: importlib.metadata
lists a path entry
with os.listdir, whose order Python
documents as arbitrary. The specification says the consumer decides how to handle such conflicts.1 Decide by
refusing, as host.py does when given both directories:
PYTHONPATH=python/site-packages:python/acme-site-packages python3 python/host.py
refusing to start: two distributions claim one plugin name
csv: meterco_csv:CsvExporter from meterco-csv 1.0.0, acme_meterco_csv:Exporter from acme-meterco-csv 0.3.0
A Node host that loads every meterco-exporter-* package activates one the application never
chose. meterco-exporter-jsonl depends on meterco-exporter-debug, and npm hoisted the dependency to
the top of node_modules, as its folder layout
describes.
node host.mjs
declared in package.json: csv (meterco-exporter-csv 1.0.0), jsonl (meterco-exporter-jsonl 1.2.0)
explicit config imports: csv, jsonl
meterco-exporter-debug: module code ran
every meterco-exporter-*: csv (meterco-exporter-csv 1.0.0), debug (meterco-exporter-debug 0.1.0), jsonl (meterco-exporter-jsonl 1.2.0)
The debug module’s code ran because a name matched. ESLint’s eslintrc avoided this by resolving
only the plugin names a config listed, and flat config replaced the strings with imported
objects.2 Read your own package.json, or keep an explicit list.
rubygems_plugin.rb looks like the Ruby answer, but it extends the gem command, not your
application. RubyGems loads it from the latest version of every installed gem whenever that
command runs:3
GEM_HOME=ruby/gems GEM_PATH=ruby/gems gem list meterco
meterco-jsonl: rubygems_plugin.rb loaded by the gem command
meterco-csv (1.0.0)
meterco-jsonl (1.2.0)
A ruby process that requires the gem does not load it, which the last Ruby test pins.
When not to do this
Do not discover plugins in a service that runs in production from a shared environment. Every package installed beside it, by anyone, can join the process without passing the review that approved the service. Pin an explicit list there.
Do not load everything you discover. List entry points, compare them against configuration, and import only the enabled ones; the Python host imports nothing until then. Ruby’s file convention cannot do this, so in Ruby keep the plugin file to a registration call and move the work into code it requires on demand.
Do not use rubygems_plugin.rb for application plugins. It extends the gem command, and RubyGems
loads it when that command runs, not when your application requires the gem.
Do not resolve a clash by picking a winner. A clash means two packages claim the same name, and the user who installed both is the only one who knows which they meant.
Related how-tos
Last verified
Verified 2026-10-15 against Python 3.13.16, Ruby 3.3.6 with RubyGems 3.5.22, Node 22.22.0 and npm
10.9.4. Every output block is what the preceding command printed in the sample directory. The
Python plugins were built as wheels and installed with pip install --target, and the gems with
gem install --install-dir, before the runs; both install directories are committed so the
commands run offline.
Footnotes
-
The specification says it in one sentence: if different distributions provide the same name, the consumer decides how to handle such conflicts. Its history section dates the document to October 2017, written to formalize the existing entry points feature of
setuptools. Formalizing it did not include choosing a winner. ↩︎ Back to text -
The ESLint team gave its own account of the change in the second part of its flat config series. Plugins are imported like any other module, it notes, so the
eslint-plugin-prefix is no longer enforced. The sentence that drops the requirement goes on to ask plugin authors to keep the prefix anyway, as a favor. ↩︎ Back to text -
The feature is old. The RubyGems changelog for 1.3.2, in April 2009, announces that RubyGems loads plugins from
rubygems_plugin.rbin installed gems, for adding commands or install hooks. Version 2.1.0, in 2013, narrowed it to the latest installed version of each gem. Four years passed between the two entries, during which every installed version of a gem could contribute its plugin to the same command. ↩︎ Back to text