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

Source: https://voxgig.com/howto/discover-installed-plugins-without-explicit-registration

- Audience: platform-team
- Level: intermediate
- Languages: python, ruby, javascript
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://pluggy.readthedocs.io/en/stable/) is the common answer.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [ESLint flat config explicit imports](https://eslint.org/docs/latest/use/configure/plugins) | 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](https://docs.ruby-lang.org/en/3.3/Gem.html#method-c-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`](https://karma-runner.github.io/6.4/config/plugins.html) | 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](https://docs.python.org/3/library/importlib.metadata.html#entry-points) | 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](https://guides.rubygems.org/plugins/) | 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.

```toml title="python/plugins/meterco-csv/pyproject.toml"
[project.entry-points."meterco.exporters"]
csv = "meterco_csv:CsvExporter"
```

The [entry points specification](https://packaging.python.org/en/latest/specifications/entry-points/)
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.

```python title="python/host.py"
    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.

```bash
PYTHONPATH=python/site-packages python3 python/host.py
```

```text output
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](https://docs.ruby-lang.org/en/3.3/Gem.html#method-c-find_files)
shows with the path `rdoc/discover`, is a file at a known path in every plugin gem.

```ruby title="ruby/host.rb"
      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.

```bash
python3 python/test_host.py
```

```text output
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
```

```bash
ruby ruby/test_host.rb
```

```text output
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
```

```bash
node --test host.test.mjs
```

```text output
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.

```bash
PYTHONPATH=python/site-packages:python/acme-site-packages python3 python/naive.py
PYTHONPATH=python/acme-site-packages:python/site-packages python3 python/naive.py
```

```text output
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](https://github.com/python/cpython/blob/3.13/Lib/importlib/metadata/__init__.py)
with [`os.listdir`](https://docs.python.org/3/library/os.html#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:

```bash
PYTHONPATH=python/site-packages:python/acme-site-packages python3 python/host.py
```

```text output
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](https://docs.npmjs.com/cli/v10/configuring-npm/folders/)
describes.

```bash
node host.mjs
```

```text output
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]

```bash
GEM_HOME=ruby/gems GEM_PATH=ruby/gems gem list meterco
```

```text output
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

- [Test a plugin through every lifecycle stage](/howto/test-a-plugin-through-every-lifecycle-stage)

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

[^1]: The [specification](https://packaging.python.org/en/latest/specifications/entry-points/#data-model)
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.

[^2]: The ESLint team gave its own account of the change in
[the second part of its flat config series](https://eslint.org/blog/2022/08/new-config-system-part-2/).
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.

[^3]: The feature is old. The RubyGems [changelog](https://github.com/ruby/rubygems/blob/v3.5.22/CHANGELOG.md)
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.