Quickstart
This page walks through writing, packaging, and loading your first
tn-venv plugin. By the end you will have a package that prints
a line every time tn-venv creates a virtual environment.
What we are building
A package called tn_venv_demo_plugin that:
Registers itself as a
tn_venv.pluginsentry point.Prints
hello from demo_pluginatSESSION_START.Captures the created environment’s path at
SESSION_END.
Step 1 — the plugin module
Create the directory:
tn_venv_demo_plugin/
└── src/
└── tn_venv_demo_plugin/
└── __init__.py
Write src/tn_venv_demo_plugin/__init__.py:
"""A trivial tn-venv plugin."""
from __future__ import annotations
from tn_venv.plugins import HookName, Plugin
class DemoPlugin(Plugin):
"""Logs a line at session start and captures the env dir at session end."""
# Stable identifier; required by Plugin. The loader emits this as
# the "name" column in ``tn-venv --list-plugins`` output.
name = "demo_plugin"
def register(self, hooks) -> None:
# Hooks.add() accepts either a callable directly or a
# decorator; both forms are demonstrated here.
hooks.add(
HookName.SESSION_START,
self._on_session_start,
priority=100, # DEFAULT_PRIORITY is 100
plugin_name=self.name,
)
@hooks.add(HookName.SESSION_END, plugin_name=self.name)
def _on_session_end(ctx):
# SESSION_END is the only lifecycle hook where ctx.result
# is guaranteed to be populated.
env_dir = ctx.result.env_dir if ctx.result else None
ctx.reporter.info(
f"[demo_plugin] session ended; env_dir = {env_dir}"
)
def _on_session_start(self, ctx) -> None:
# SESSION_START fires after option resolution and interpreter
# discovery, but before the destination lock is acquired.
ctx.reporter.info("hello from demo_plugin")
Three rules of thumb:
nameis required. Subclasses without anameattribute get the class name auto-assigned byPlugin.__init_subclass__, but you should set it explicitly so the name doesn’t change if you rename the class later.register()runs once perload_plugins()call. Anything you put there runs at import-of-the-entry-point, before anytn-venvcommand runs.Listener return values are ignored (until the day we add a pipeline-short-circuiting hook). Communicate through
ctx.data,ctx.options, orctx.result.
Step 2 — packaging metadata
Create pyproject.toml next to src/:
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tn_venv_demo_plugin"
version = "0.1.0"
description = "A trivial tn-venv plugin"
requires-python = ">=3.11"
# The entry-point group "tn_venv.plugins" is a hard-coded constant
# (see tn_venv.plugins.PLUGIN_ENTRY_POINT). The value on the right
# is the dotted path to your class — same form as a normal
# ``module:Class`` Python import target.
[project.entry-points."tn_venv.plugins"]
demo_plugin = "tn_venv_demo_plugin:DemoPlugin"
[tool.setuptools.packages.find]
where = ["src"]
The key (demo_plugin) is the entry-point name. It shows up in the
“source” column of tn-venv --list-plugins:
demo_plugin hooks: session_end, session_start
source: entry-point: demo_plugin=tn_venv_demo_plugin:DemoPlugin
Step 3 — install and run
pip install -e .
tn-venv .venv
Expected output:
==> creating virtual environment at ./.venv
interpreter: cpython 3.14.7 (64-bit) from /usr/bin/python3
hello from demo_plugin # ← your plugin
==> environment ready: ./.venv/Scripts/python.exe
[demo_plugin] session ended; env_dir = ./.venv # ← your plugin again
If you do not see your lines, run tn-venv --list-plugins and check
that demo_plugin is in the output. If it isn’t, your entry point
isn’t being advertised — see Discovery.
Step 4 — verify
tn-venv --list-plugins
Expected (with the built-in plugins):
==> discovering plugins…
demo_plugin hooks: session_end, session_start
source: entry-point: demo_plugin=tn_venv_demo_plugin:DemoPlugin
version_stamp hooks: post_activators
source: built-in: VersionStampPlugin
That’s it. You have a working plugin.
Common pitfalls
Symptom |
Cause |
Fix |
|---|---|---|
|
entry-point group or value typo |
re-check |
Plugin loads but no hook fires |
you forgot to override |
|
|
both the entry-point loader and |
remove the duplicate, or rely on dedup-by-class |
Plugin import fails silently |
typo in the dotted path |
run |
Plugin fires on |
expected — see CLI integration |
if you don’t want that, guard |
Next steps
Discovery — what the loader does behind the scenes.
Hooks — the full lifecycle hook reference.
Authoring —
register(),HookContext,PluginSource, and the rest of the API.CLI integration —
HELP_EPILOGandtn-venv --list-plugins.