Hooks
The pipeline emits six events at well-defined points. Five are
lifecycle hooks (they fire during tn_venv.session.run_session).
The sixth, HELP_EPILOG, is special: it fires only when the user
asks for tn-venv --help, and its listener returns a string
instead of mutating a HookContext.
This page is the canonical reference for every hook’s timing, payload, and fault-isolation contract.
The six hooks at a glance
Hook |
When |
Listener payload |
|
|---|---|---|---|
|
after option resolution and interpreter discovery, before the destination lock |
|
|
|
immediately before |
|
|
|
after every activation script has been written, before seeding |
|
|
|
after the seeder finishes (or skips) |
|
|
|
last, just before |
|
all of the above plus |
|
when |
n/a — listener returns |
n/a |
The lifecycle hooks fire in this order during a normal run, but
the CLI is allowed to short-circuit (--help, --version,
--list-plugins, --list-pythons, --dry-run) before any of them.
A plugin that depends on SESSION_END running should not assume
--help will ever fire it.
HookContext
Every lifecycle hook receives a single argument — a HookContext:
@dataclass
class HookContext:
options: Options | None # the resolved Options
reporter: Reporter | None # for warn / info / debug / ok
result: SessionResult | None # populated for SESSION_END only
data: dict[str, Any] # per-run scratch space
data is shared across every hook of the same run. The pipeline
populates it incrementally:
After hook |
|
|---|---|
|
|
|
|
|
|
|
|
|
same, plus |
Plugins may add their own keys (e.g. data["my_plugin_started_at"] = time.time()). Two plugins that write to the same key collide — last
writer wins. The pipeline’s own keys are reserved (cfg_path,
env_dir, scripts, seed_result, result).
ctx.options may be None for hooks fired outside a real session
(test paths, programmatic invocations). Always guard with
if ctx.options is not None: or use getattr(ctx, "options", None).
ctx.result is only populated for SESSION_END. Other lifecycle hooks
see None. Plugins can read ctx.result.env_dir etc. during
SESSION_END to build side-effects that need the final path.
Listener signature
Lifecycle hooks: a single positional HookContext argument. Must
return None. Returning anything else is silently ignored today
and will become a TypeError in a future release.
def my_listener(ctx: HookContext) -> None:
if ctx.options and ctx.options.python:
ctx.reporter.info(
f"asked for Python {ctx.options.python[0]}"
)
HELP_EPILOG hooks: no arguments, return str | None. The
loader concatenates non-None returns with a blank-line separator.
Returning None (or an empty string) opts the plugin out for that
invocation.
def my_help_text() -> str | None:
return "extras from my_plugin:\n thing one\n thing two"
Priority
hooks.add(hook, fn, priority=N) controls dispatch order. Higher
priorities run first. Ties keep insertion order. DEFAULT_PRIORITY
is 100. Built-ins typically use lower numbers so user plugins run
before them.
hooks.add(
HookName.POST_ACTIVATORS,
my_func,
priority=200, # runs before priority=100, which runs before 0
plugin_name=self.name,
)
Priority is per-hook: a priority=200 listener on SESSION_START
has no effect on POST_ACTIVATORS ordering.
Multiple registrations
A plugin can register the same listener under multiple hooks, or multiple listeners under the same hook:
class Multi(Plugin):
def register(self, hooks):
# Same function on three hooks — fine:
hooks.add(HookName.SESSION_START, self._log_start, plugin_name=self.name)
hooks.add(HookName.POST_ACTIVATORS, self._log_start, plugin_name=self.name)
hooks.add(HookName.SESSION_END, self._log_end, plugin_name=self.name)
Listeners run in priority order; ties keep insertion order.
Fault isolation
A listener that raises an exception is logged and ignored. The
pipeline keeps running. Other listeners on the same hook still fire;
later hooks still fire; the SessionResult is still returned
normally.
The warning goes through the Reporter passed in the context, or
the registry’s reporter if the context’s is None. Programmatic
callers who don’t want warnings should pass
reporter=tn_venv.report.SILENT to load_plugins.
Subclassing Plugin to override the default
The default Plugin.register() is a no-op. Subclasses override it:
class MyPlugin(Plugin):
name = "my_plugin"
def register(self, hooks) -> None:
hooks.add(
HookName.SESSION_END,
self._on_end,
priority=50, # run after built-ins
plugin_name=self.name,
)
def _on_end(self, ctx: HookContext) -> None:
env_dir = ctx.result.env_dir
# ...
Plugin.__init_subclass__ auto-fills cls.name from the class name
if you don’t set it. Always set name explicitly so a later
rename doesn’t change the plugin’s identifier.
The Plugin API at a glance
class Plugin:
name: str = "" # identifier (stable identifier)
def __init_subclass__(cls, **kwargs): ... # auto-fills 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.
"""
The full reference for HookRegistry (which the register() hook
gets passed) is in Authoring.
Recipes
Stamping a build tag into pyvenv.cfg
class BuildStamp(Plugin):
name = "build_stamp"
def register(self, hooks):
def _stamp(ctx):
cfg = ctx.data.get("cfg_path")
if cfg is None or not cfg.exists():
return
with cfg.open("a", encoding="utf-8") as fh:
fh.write(f"\n# build-stamp: {time.time()}\n")
hooks.add(HookName.POST_ACTIVATORS, _stamp, plugin_name=self.name)
Reporting the install side-effect to a log file
class InstallLog(Plugin):
name = "install_log"
LOG = Path("/var/log/tn-venv.jsonl")
def register(self, hooks):
hooks.add(HookName.SESSION_END, self._on_end, plugin_name=self.name)
def _on_end(self, ctx):
with self.LOG.open("a") as fh:
fh.write(json.dumps({
"env_dir": str(ctx.result.env_dir),
"seed": getattr(ctx.result.seed, "pip", None),
}) + "\n")
Logging the user’s effective options
class OptionLogger(Plugin):
name = "option_logger"
def register(self, hooks):
@hooks.add(HookName.SESSION_START, plugin_name=self.name)
def _log(ctx):
ctx.reporter.info(f"effective options: {ctx.options!r}")
Failing loudly on a bad configuration
class RequiredPython(Plugin):
name = "required_python"
MIN = (3, 11)
def register(self, hooks):
hooks.add(HookName.PRE_CREATE, self._check, plugin_name=self.name)
def _check(self, ctx):
if not ctx.options:
return
spec = ctx.options.python[0] if ctx.options.python else None
if spec is None:
raise ConfigError("--python is required when this plugin is loaded")
# ... parse `spec` and compare ...
Note the deliberate raise. The loader catches it, logs a warning,
and skips the plugin. The pipeline continues without this plugin’s
hooks. Use this pattern sparingly — failing loud on bad data is good
when done in a ConfigError, but the fault-isolation layer means
the pipeline never aborts because of a plugin error.