How-to › Maintain libraries across languages

How to discover installed plugins without explicit registration#

Find plugins because they are installed, with Python entry points, a gem file convention or a Node prefix scan, and refuse to start when two claim one name.

Audience
Platform team
Level
intermediate
Topic
Design a plugin system
Languages
Python, Ruby and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
ESLint flat config explicit importsNode tools whose users already edit a config fileOne import per plugin, so an installed package does nothing until somebody edits the configUsers expect a plugin to work the moment it is installed
Gem.find_latest_filesRuby applications whose plugins ship as gemsEach plugin file must run to register, so discovery executes every installed pluginYou want to list plugins without running their code
Prefix scan of node_modulesA Node tool that wants install-and-it-works, as Karma does by defaultAny package with the prefix becomes active, including one installed as another package’s dependencyYou cannot audit which packages land in node_modules
Python entry points via importlib.metadataPython tools whose plugins ship as installed distributionsEvery distribution can join, and the specification leaves a name clash to youPlugins are local files rather than installed packages
RubyGems rubygems_plugin.rbExtending the gem command with commands or install hooksRuns on every gem command, from the latest version of every installed gem, and not when your application requires the gemYou 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.

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

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

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

  3. The feature is old. The RubyGems changelog for 1.3.2, in April 2009, announces that RubyGems loads plugins from rubygems_plugin.rb in 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.