Discovery
When tn-venv loads plugins, it consults three sources in order and
deduplicates by class. This page explains what each source does, the
deduplication rules, and what happens when a source fails.
The three sources
load_plugins(reporter=None, *, env=None)
│
├─ built-ins ── always loaded first
│
├─ entry points ── group="tn_venv.plugins"
│ loaded from every installed distribution
│
└─ TN_VENV_PLUGINS ── comma-separated env-var override
1. Built-ins
The loader always instantiates tn_venv.plugins.builtin.VersionStampPlugin
first. Built-ins are part of the tn-venv distribution itself and ship
their own entry point free. To inspect the built-in list at runtime:
from tn_venv.plugins import require_plugin
cls = require_plugin("version_stamp") # returns VersionStampPlugin
require_plugin(name) raises ConfigError when no built-in matches
— it’s the equivalent of getattr for the built-in registry.
2. Entry points (third-party packages)
Every installed Python distribution can advertise plugins via the
tn_venv.plugins entry-point group:
[project.entry-points.tn_venv.plugins]
my_plugin = "my_pkg.module:MyPlugin"
The loader uses importlib.metadata.entry_points() to enumerate
distributions. For each tn_venv.plugins entry point, it:
Calls
ep.load()to import the target module / class.If the loaded object is a
Pluginsubclass → instantiate it.If the loaded object is a
Plugininstance → use it directly.If the loaded object is a module with a
PLUGINSiterable → iterate it and instantiate each member.If the loaded object is a module with
Pluginsubclasses but noPLUGINS→ instantiate the concrete ones (those with a non-emptyname; empty-named bases are skipped).
The entry-point name (the left-hand side) is purely diagnostic — it
shows up in tn-venv --list-plugins as entry-point: NAME=SPEC.
3. TN_VENV_PLUGINS (ad-hoc override)
The TN_VENV_PLUGINS environment variable lets you load a plugin
without installing it. Useful for development, debugging, and
running a one-off Python file as a plugin.
The variable takes a comma-separated list. Each item is either:
Form |
What happens |
|---|---|
|
import |
|
import |
(empty) |
ignored |
Empty entries are dropped. Duplicates are removed. Example:
TN_VENV_PLUGINS='my_pkg:DemoPlugin,/path/to/local.py:LocalPlugin' tn-venv .venv
Parsing helpers:
from tn_venv.plugins import parse_plugin_spec
parse_plugin_spec("a:A, b:B ,a,c") # → ['a:A', 'b:B', 'c']
parse_plugin_spec is the same split the loader uses internally —
it’s exposed for tests and tooling.
Order and deduplication
The three sources are merged in the order above, and the resulting list is deduplicated by plugin class:
load_plugins() →
[VersionStampPlugin, # from built-ins
GuiHookPlugin, GuiSubcommandPlugin, # from entry points
DemoPlugin] # from TN_VENV_PLUGINS
If the same Plugin subclass appears via two sources (e.g. an
entry point plus TN_VENV_PLUGINS), only the first occurrence
wins. The loader sets seen.add(cls) after the first successful
instantiation and skips the rest.
Plugin instances are deduplicated by their type — two
instances of DemoPlugin will not appear, but DemoPlugin() and
DemoPlugin() (different instances, same class) won’t both be
loaded.
The dedup is per-load_plugins() call, not per-process. Two calls
return fresh instances.
What load_plugins returns
A HookRegistry populated with every successfully-loaded plugin’s
hooks. Plugins whose register() ran without calling hooks.add
are still tracked in HookRegistry._hookless_plugins so
tn-venv --list-plugins can show them. See Hooks and
CLI integration.
Failure isolation
Each source is fault-isolated. The loader catches Exception (broad
on purpose — third-party code is hostile by definition) at every
boundary:
Failure |
What happens |
|---|---|
|
warning logged, plugin skipped, no other plugin affected |
|
warning logged, plugin skipped, no other plugin affected |
|
warning logged, plugin’s hooks are not registered, no other plugin affected |
|
the subclass itself never becomes usable; the loader treats it as not-a-Plugin |
|
re-raises — this is a tn-venv bug |
The warning goes through the Reporter passed to load_plugins.
Programmatic callers usually pass reporter=SILENT to silence
noise; the CLI passes the real reporter so users see failures.
Where the loader runs
The loader runs eagerly at the top of tn_venv.cli.cli_run,
before argparse sees the argv. The eager load is what lets a plugin
that wraps tn_venv.cli.cli_run (e.g. the tn-venv-gui package
installing its gui subcommand) intercept argv like
tn-venv gui --help before argparse sees the help flag. Cost is
small — only the built-in plugin is loaded in the common case.
The eager load is paired with a lazy fallback in
_Parser.format_help so hosts that print help without going through
cli_run (rare but possible — think embedded uses) still get the
plugin summary block.
Programmatic use
from tn_venv.plugins import load_plugins
from tn_venv.report import Reporter
# Load every plugin and dump each one, with the reporter silenced:
registry = load_plugins(reporter=SILENT)
# Or pass a custom env so tests don't pick up the user's
# TN_VENV_PLUGINS by accident:
registry = load_plugins(reporter=Reporter(verbosity=0), env={})
The returned HookRegistry is fresh on every call. Don’t cache it
across load_plugins() invocations — reloading is the documented
way to re-discover plugins (e.g. after installing a new package at
runtime, before invoking run_session again).
Edge cases worth knowing
A plugin class with empty
nameis treated as a private base and not instantiated. This lets you writeclass _Base(Plugin): passfollowed byclass Concrete(_Base): name = "concrete"without_BaseshadowingConcrete.Duplicate classes across modules are deduplicated. So
tn_venv_demo_plugin:DemoPluginin two different distributions results in exactly oneDemoPluginbeing loaded.Plugin()instance passed viaTN_VENV_PLUGINSis used as — not reinstantiated. Useful for unit tests that want a pre-configured instance.A package that ships only entry points, no
tn_venv.pluginskey, is silently ignored. Removing the key entirely is fine; no marker file is required.