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:

  1. Registers itself as a tn_venv.plugins entry point.

  2. Prints hello from demo_plugin at SESSION_START.

  3. 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:

  • name is required. Subclasses without a name attribute get the class name auto-assigned by Plugin.__init_subclass__, but you should set it explicitly so the name doesn’t change if you rename the class later.

  • register() runs once per load_plugins() call. Anything you put there runs at import-of-the-entry-point, before any tn-venv command runs.

  • Listener return values are ignored (until the day we add a pipeline-short-circuiting hook). Communicate through ctx.data, ctx.options, or ctx.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

tn-venv --list-plugins doesn’t show your plugin

entry-point group or value typo

re-check [project.entry-points."tn_venv.plugins"]; the group uses underscores (tn_venv.plugins), the target uses dots

Plugin loads but no hook fires

you forgot to override register(), or your hook name is misspelled

register() must call hooks.add(...) at least once; hook names are the HookName enum members

hello from demo_plugin fires twice

both the entry-point loader and TN_VENV_PLUGINS are loading it

remove the duplicate, or rely on dedup-by-class

Plugin import fails silently

typo in the dotted path

run python -c \"from tn_venv_demo_plugin import DemoPlugin\" to confirm the path is importable

Plugin fires on tn-venv --help

expected — see CLI integration

if you don’t want that, guard register() with an env-var

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_EPILOG and tn-venv --list-plugins.