Authoring
This page is the deep reference for the objects a plugin author
touches: Plugin, HookRegistry, HookContext, PluginSource,
and the dataclasses that wrap them.
Plugin
class Plugin:
"""Base class for tn-venv plugins."""
#: Stable, human-readable identifier. Defaults to the class name
#: via __init_subclass__, but you should set this explicitly.
name: str = ""
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
if not cls.name:
cls.name = cls.__name__
def register(self, hooks: HookRegistry) -> None:
"""Install hook callbacks on *hooks*.
Subclasses override this to participate in the session
lifecycle. The default implementation registers nothing, so
a plugin with no override is effectively a no-op.
"""
Why override register() instead of putting logic in __init__?
__init__ runs every time the loader instantiates your class — for
entry points, once per load_plugins() call. The hook callbacks
would be added to the registry every time the plugin is
constructed, which double-registers on a reload. Putting the work
in register() keeps construction cheap and registration explicit.
Empty name ⇒ private base class
If you write class _Base(Plugin): pass, the auto-assigned name
is _Base (the class name). The loader skips empty-named bases —
see Discovery for the full rule. Use empty name to mark
abstract bases; use a real string for concrete ones.
What 1.x guarantees about Plugin
Pluginitself never gains required arguments.namekeeps its semantics. Don’t add side effects to__init_subclass__other than filling inname.New
HookNameenum members may be added in minor versions. They are non-breaking because plugins opt in by name.
What Plugin does not guarantee:
That subclasses can be instantiated multiple times safely. The loader instantiates each class once per
load_plugins()call. If your__init__has side effects (e.g. opening a file), make them idempotent.
HookContext
A dataclass with four fields. See Hooks for the full discussion of when each field is populated. The fields:
Field |
Type |
Populated when |
|---|---|---|
|
|
every lifecycle hook; |
|
|
every lifecycle hook; falls back to the registry’s reporter if |
|
|
only |
|
|
always; populated incrementally by the pipeline and plugins |
The data dict is the only mutable field. Use it for cross-hook
state within a single run — never store anything in
Plugin.__init__ and expect it to survive across runs.
HookRegistry
The dispatcher. Plugins get a HookRegistry reference via
register(hooks). The registry is fresh per load_plugins() call
— don’t cache it in a module-level variable.
Constructor
HookRegistry(reporter: Reporter | None = None) -> None
If reporter is omitted, Reporter(verbosity=-1) is used (silent).
add() — register a listener
Two forms:
# Direct form — pass the callable explicitly:
hooks.add(
HookName.SESSION_START,
my_func,
priority=100, # optional, default DEFAULT_PRIORITY (100)
plugin_name="my_plugin", # optional, shown in warnings / introspection
)
# Decorator form — used as @hooks.add(hook, ...):
@hooks.add(HookName.SESSION_START, plugin_name="my_plugin")
def my_listener(ctx):
...
The decorator form is equivalent. Both register at the time
add() is called (or the decorator is applied); the registry
saves the callable and plugin_name for diagnostics.
Decorator trap: the decorator factory’s return value (the inner
function) is what plugins[0] stores. If you decorate a free
function and lose the return value, your listener is silently lost.
This is rarely hit in practice but worth noting.
emit() — dispatch
registry.emit(HookName.SESSION_START, ctx) -> HookContext
Returns the (possibly mutated) ctx. Exceptions from listeners
are caught and logged at warning level; other listeners on the same
hook still run.
collect_help_epilog() — for HELP_EPILOG listeners
text: str = registry.collect_help_epilog()
Concatenates the return values of every HELP_EPILOG listener with
blank-line separators. None and "" returns are skipped.
Introspection
For most code you don’t need to introspect the registry. But
tn-venv --list-plugins does, so the registry exposes:
Member |
Meaning |
|---|---|
|
The callables registered for |
|
The number of callables for |
|
The internal |
|
|
|
|
These are all part of the contract that tn-venv --list-plugins
relies on. Reading them is fine; mutating them externally will
silently desync the registry.
PluginSource
@dataclass(frozen=True)
class PluginSource:
"""Provenance for a loaded Plugin."""
kind: str # "built-in" | "entry-point" | "env"
spec: str # e.g. "VersionStampPlugin", or
# "hooks=tn_venv_gui.plugin:GuiHookPlugin", or "TN_VENV_PLUGINS"
Every successfully-loaded Plugin instance is annotated with a
private _tn_venv_source attribute (a PluginSource) by
load_plugins. The attribute is private (leading underscore) but
the dataclass itself is part of the public API — callers can read
it like:
registry = load_plugins(reporter=SILENT)
for plugin in registry._owners.values():
src = getattr(plugin, "_tn_venv_source", None)
if src is not None:
print(f"{plugin.name} from {src.kind}: {src.spec}")
Plugins should treat _tn_venv_source as read-only. The
leading underscore signals “set by the loader, not by the plugin
author”.
If your plugin uses __slots__ and disallows new attributes, the
loader will catch the resulting AttributeError and continue —
your plugin just won’t show up in the source: column of
tn-venv --list-plugins.
Adding a new hook
Hooks live on the HookName enum:
# tn_venv/plugins/api.py
class HookName(str, Enum):
SESSION_START = "session_start"
PRE_CREATE = "pre_create"
POST_ACTIVATORS = "post_activators"
POST_SEED = "post_seed"
SESSION_END = "session_end"
HELP_EPILOG = "help_epilog"
Don’t add a new hook to tn_venv.plugins and ship it from a
third-party plugin — tn-venv owns the hook set. If you need a hook
that doesn’t exist, file an issue describing the timing and payload
you need.
What you can do as a third-party plugin:
Subscribe to any existing hook.
Mutate
ctx.datawith your own keys (the pipeline’s keys are reserved but yours are not).Add a new attribute to a
HookContextsubclass if you pass your own subclass (advanced; not recommended).
require_plugin — looking up a built-in
from tn_venv.plugins import require_plugin
Vs = require_plugin("version_stamp") # → VersionStampPlugin
# or raises ConfigError if no built-in matches
require_plugin is the mirror image of getattr for the built-in
plugin registry. It’s exposed so third-party code can integrate with
a specific built-in without importing its module directly.
Where to read the source
The whole tn_venv.plugins package is small enough to read in one
sitting:
tn_venv/plugins/
├── __init__.py ← public re-exports
├── api.py ← HookName, HookContext, HookFn, Plugin, PluginSource
├── registry.py ← HookRegistry, _Listener, DEFAULT_PRIORITY
├── loader.py ← load_plugins, _iter_entry_points, _instantiate,
│ _from_entry_point, _from_module_attr,
│ _from_env, _resolve_target, _tag_source,
│ _register_with_owner, PluginSource
└── builtin.py ← VersionStampPlugin
loader.py is the file you’ll want to read if you’re debugging
discovery or wondering how the seen set works. The other three
files are mostly stable interfaces.