skip to content

How does importlib.metadata.entry_points() let one distribution discover another's plugins?

level: middleimportance: must knowfreq 40%

answer

  1. Installing the plugin is the wiring
  2. The host never imports the plugin by name
  3. Metadata files, not code, are scanned
  4. Group is the published contract name
  5. Loading is a separate, explicit step

basics

~10 s

Each installed distribution can advertise name-to-module:object records under a group name in its metadata. entry_points(group="yourapp.plugins") reads those records from every installed distribution without importing them, and EntryPoint.load() imports one on demand.

solid answer

~40 s

The host publishes a **group** name as its plugin contract. A third party declares, in its own `pyproject.toml`, `[project.entry-points."hooksrv.handlers"]` with `name = "their.module:TheirClass"`; installing that distribution is the entire wiring step. At runtime the host calls `entry_points(group="hooksrv.handlers")`, which scans the `.dist-info` metadata of distributions visible on `sys.path` and returns an `EntryPoints` collection. Each `EntryPoint` exposes `.name`, `.value`, `.module`, `.attr` and `.load()`; only `.load()` imports anything, so discovery is cheap and executes no plugin code. `console_scripts` is just a reserved group consumed by the installer. Note the API moved: 3.10 added `group=`/`name=` selection and deprecated the old dict-of-groups return, and 3.12 removed it.

code

python · 7 lines
python
from importlib.metadata import entry_points

handlers = {}
for ep in entry_points(group="hooksrv.handlers"):
    print("found", ep.name, "in group", ep.group, "->", ep.value)
    handlers[ep.name] = ep.load()
print(f"{len(handlers)} handler(s) active")

go deeper

for a junior

Recall that installing a distribution can add capabilities another program picks up automatically, and that the declaration lives in pyproject.toml under a group name rather than in any code.

for a middle

Be able to write both halves from memory: the [project.entry-points."group"] table and the entry_points(group=...) loop with EntryPoint.load(). Explain that discovery reads metadata and only load() imports.

for a senior

Show judgement about the contract: namespaced group names, validating the loaded object, duplicate names across distributions, and the fact that discovery reflects the environment rather than the source tree.

for a principal

Own whether extensibility belongs in the product at all, what stability promise the group name carries once third parties depend on it, and how you version or retire a plugin interface you no longer control the consumers of.

### The data model An **entry point** is a three-part record stored in an installed distribution's metadata: * a **group** — a namespace string such as `console_scripts` or `hooksrv.handlers`, * a **name** — unique within that group *for that distribution*, * a **value** — `module.path:object`, optionally with a dotted attribute chain after the colon. The records live in `<name>-<version>.dist-info/entry_points.txt` inside the environment's `site-packages`. They are **metadata, not code**: a distribution can advertise an object it never imports, and a reader can see the advertisement without importing anything of the advertiser. ### Declaring a plugin The host project publishes a group name as its plugin contract. A completely unrelated third-party project then writes, in its own `pyproject.toml`: ```toml [project.entry-points."hooksrv.handlers"] github = "hooksrv_github.handler:GitHubHandler" ``` Installing that distribution into the same environment is the entire wiring step. There is no config file to edit, no import line to add to the host, and — critically — the host never needs to know the plugin's name at build time. The reserved groups `console_scripts` and `gui_scripts` are the same mechanism with the installer as the consumer. ### Discovering them ```python from importlib.metadata import entry_points for ep in entry_points(group="hooksrv.handlers"): handler_cls = ep.load() ``` `entry_points()` walks the distributions visible on `sys.path`, parses their metadata, and returns an `EntryPoints` collection — a tuple-like object you can filter with `.select(group=...)` or, equivalently, by passing `group=` / `name=` straight to `entry_points()`. Each element is an `EntryPoint` exposing `.name`, `.value`, `.group`, `.module`, `.attr` and `.load()`. Only `.load()` executes anything: it imports the module named by `.module` and resolves `.attr` on it, returning the object. Discovery is therefore cheap and safe; activation is neither. ### The API changed under you This is version-sensitive and interviewers who have maintained a library will ask about it. In Python 3.9 and earlier, `entry_points()` returned a plain dict of group name to list, so code said `entry_points()["hooksrv.handlers"]`. **3.10** added the selection keywords and deprecated the dict interface; **3.12 removed it**. On 3.14, `entry_points()` returns `EntryPoints`, and subscripting it with a string selects by **entry point name**, not by group — so the old dict-style line does not raise a helpful error, it raises `KeyError` for a group that plainly exists. `entry_points()` also takes no positional arguments: `entry_points("hooksrv.handlers")` is a `TypeError`. For libraries supporting older interpreters, the compatibility answer is the backport distribution rather than version-sniffing. ### Designing the contract The group name is a public API, so namespace it with your project name to avoid colliding with somebody else's idea of `handlers`. Publish what the loaded object must *be* — a class implementing a documented protocol, a factory callable, a module — and validate it after `.load()`, because the type system cannot check across a distribution boundary. Names are not globally unique: two installed distributions may register the same name in your group, and the collection will contain both, so decide precedence deterministically instead of relying on discovery order. Two operational details matter. First, entry points are recorded **at install time**, so editing `pyproject.toml` in an editable checkout does not change what discovery sees until you reinstall. Second, discovery reflects the environment, not your source tree: the same code discovers a different plugin set in a container image than on a developer laptop, which is exactly the property that makes the mechanism useful and exactly the property that makes plugin bugs environment-shaped. The wider plugin-architecture question — when a host should be extensible at all, and how to keep a microkernel core thin — is design theory that belongs elsewhere; what is Python-specific is that the registry is the installed environment itself, maintained by the installer, and readable without importing a single plugin.

  • Does calling entry_points() import the plugin packages it finds?
    No. It reads `entry_points.txt` from the `.dist-info` directories of distributions on `sys.path` and parses text. Nothing of the advertising distribution is imported, which is why discovery is safe to run at startup and why a plugin can advertise a module whose heavy dependencies are never paid for unless someone activates it. `EntryPoint.load()` is the step that imports the module and resolves the attribute.
  • You added an entry point to pyproject.toml in an editable checkout, but discovery does not see it. Why?
    Entry points are written into the distribution's metadata at install time, so the `.dist-info` in your environment still reflects the previous `pyproject.toml`. Reinstall the project to regenerate it. This catches people out precisely because an editable install makes source edits live, so they assume metadata edits are live too.
  • How do you keep your group name from clashing with someone else's plugins?
    Namespace it with your project: `hooksrv.handlers`, not `handlers`. The group string is a global namespace shared by every installed distribution, and a generic name means an unrelated project's plugins turn up in your discovery results — where they will load and then fail your contract check. Document the group name and the interface the loaded object must satisfy as part of your public API.

The installed environment is a noticeboard: every distribution pins a card saying which group it can serve and where to find it, and the host reads the cards long before it phones any of the numbers.

saying these in an interview costs you the question

  • Thinks discovery imports every plugin package it finds
  • Uses the removed dict form entry_points()["group"]
  • Believes the host must depend on its plugins
  • Confuses the importable package name with the distribution name
  • Assumes entry point names are unique across distributions
  • Expects a pyproject edit to take effect without reinstalling

context