Architecture

Isolating Plugin Failures in an Extensible Python CLI

Keep third-party plugins from taking a CLI down: attribute crashes to the plugin, offer a no-plugins safe mode, disable single plugins, and run untrusted ones out of process.

Updated

Plugins extend a CLI with code its maintainers never wrote, never reviewed and cannot fix. That code runs inside your process, with your users' credentials, and its bugs look like yours: a ZeroDivisionError traceback ending in mytool/cli.py, a hang, a corrupted JSON stream because a plugin printed debugging output to stdout. Users report those bugs to you. Safe loading — catching import errors when plugins are discovered — is the first line of defence, covered in discovering plugins with entry points. This guide covers what happens after a plugin has loaded: attributing runtime crashes to the plugin that caused them, a safe mode that starts the CLI with no plugins at all, switching off a single plugin, and — for code you do not trust — running plugins in a separate process. It belongs to the plugin architectures topic.

Prerequisites

  • A Typer (or Click) CLI that mounts plugin command groups from entry points.
  • The plugin API conventions from versioning a plugin API.

Where plugins fail

When plugins fail, and the defence The three moments a command line tool plugin can fail and how the host defends against each. When plugins fail, and the defence Moment Example Defence Load time ImportError in the plugin catch, skip, warn Run time a command raises attribute to the plugin Ambient prints to stdout, slow import disable it, or run out of process Only out-of-process plugins are isolated from crashes that kill the interpreter.

Plugin failures happen at three different moments, and each needs its own defence. At load time — the plugin's module raises on import, or exports the wrong object — the host catches the error, skips the plugin and warns. At run time — a plugin command raises — the host should report which plugin failed and where to report it, instead of a traceback that implicates the host. And ambiently — a plugin reconfigures logging, writes to stdout, patches a library, or is simply slow at import — where the only real remedies are switching the plugin off or running it out of process.

The recipe

Attribute runtime crashes

Wrap every command callback a plugin registers so exceptions carry the plugin's name and package:

# src/mytool/plugin_guard.py
from __future__ import annotations

import functools
from collections.abc import Callable

import typer


class PluginCrashed(Exception):
    def __init__(self, plugin: str, dist: str, original: BaseException) -> None:
        super().__init__(
            f"plugin {plugin!r} ({dist}) crashed: {type(original).__name__}: {original}")
        self.plugin = plugin
        self.dist = dist


def _guard(plugin: str, dist: str, func: Callable) -> Callable:
    @functools.wraps(func)                      # keeps the signature Typer reads
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except (typer.Exit, typer.Abort, KeyboardInterrupt):
            raise                               # normal control flow, not crashes
        except Exception as exc:
            raise PluginCrashed(plugin, dist, exc) from exc
    return wrapper


def isolate(plugin_app: typer.Typer, plugin: str, dist: str) -> typer.Typer:
    """Wrap every command (recursively) registered on a plugin's Typer app."""
    for info in plugin_app.registered_commands:
        info.callback = _guard(plugin, dist, info.callback)
    for group in plugin_app.registered_groups:
        isolate(group.typer_instance, plugin, dist)
    return plugin_app

functools.wraps copies the wrapped function's metadata and sets __wrapped__, which Typer follows when it inspects the signature — so the plugin's options and help are unchanged. typer.Exit, typer.Abort and Ctrl-C pass through untouched, because they are how commands end normally. Call isolate() when mounting each plugin, and handle PluginCrashed at the top level:

# src/mytool/main.py
import typer

from mytool.cli import app
from mytool.plugin_guard import PluginCrashed

EX_SOFTWARE = 70


def main() -> None:
    try:
        app()
    except PluginCrashed as exc:
        typer.echo(f"error: {exc}", err=True)
        typer.echo(f"This is a bug in the {exc.dist} plugin, not in mytool. "
                   f"Run with MYTOOL_DEBUG=1 for the full traceback.", err=True)
        raise SystemExit(EX_SOFTWARE)

The user now sees "plugin 'aws' (mytool-aws 0.3.1) crashed: ZeroDivisionError: division by zero" and a pointer to the right project. The traceback is still available — raise ... from exc keeps the chain — for anyone who asks for it, as in friendly error messages and tracebacks.

A plugin crash, correctly blamed Terminal session where a plugin command crashes and the host reports the plugin name, package version and a safe-mode hint. A plugin crash, correctly blamed bash $ mytool aws buckets error: plugin 'aws' (mytool-aws 0.3.1) crashed: ZeroDivisionError: division by zero This is a bug in the mytool-aws 0.3.1 plugin, not in mytool. $ MYTOOL_NO_PLUGINS=1 mytool status ok Exit code 70 lets automation tell a plugin crash from a usage error.

A safe mode with no plugins

When something is badly wrong — a plugin that breaks startup, slows every command to a crawl, or changes behaviour in a way nobody can explain — the fastest diagnosis is "does it happen without plugins?" Plugins are mounted before arguments are parsed, so a normal --no-plugins option arrives too late. An environment variable checked at mount time works:

import os


def plugins_enabled() -> bool:
    return os.environ.get("MYTOOL_NO_PLUGINS", "") in ("", "0")


def disabled_plugins(config: dict) -> set[str]:
    return set(config.get("plugins", {}).get("disabled", []))

MYTOOL_NO_PLUGINS=1 mytool status runs the bare host. A disabled = ["aws"] list in the configuration file switches off individual plugins without uninstalling them, which is gentler when the plugin is needed again next week. Both belong in the output of mytool plugins list, so the current state is always visible. If you prefer a flag, peek at sys.argv for --no-plugins in main() before importing the module that mounts plugins, and remove it from the argument list.

Ambient misbehaviour

Some plugin problems are not exceptions at all. Make the host robust where it can be, and document the rest as requirements for plugin authors:

  • Stdout discipline. Plugins must write diagnostics to stderr. The host can enforce this for its own machine-readable modes by rendering JSON itself from data the plugin returns, rather than letting plugins print.
  • No global configuration. Plugins must not call logging.basicConfig, install signal handlers or change the working directory at import. The audit-hook probe from avoiding import-time side effects can be pointed at plugin modules in a plugin's own CI.
  • Import cost. Load plugins lazily, so a slow plugin only costs time when its command runs.

Out-of-process plugins for code you do not trust

In-process isolation stops crashes from being misattributed; it does not stop a plugin from reading your users' tokens. When plugins come from anywhere, run them as separate programs. The model Git popularised — git foo runs an executable called git-foo found on PATH — needs no plugin API at all:

import os
import shutil
import subprocess
import sys


def run_external(name: str, args: list[str]) -> int:
    exe = shutil.which(f"mytool-{name}")
    if exe is None:
        raise SystemExit(f"error: unknown command {name!r}")
    env = {k: v for k, v in os.environ.items() if not k.startswith("MYTOOL_TOKEN")}
    return subprocess.run([exe, *args], env=env).returncode

The external program cannot touch the host's memory, its crashes are its own exit codes, and the host decides which environment variables to pass — here, everything except the host's credentials. The cost is a weaker contract: data passes as arguments, environment and standard streams, so define those clearly, ideally as JSON over stdin and stdout. Calling external commands safely with subprocess covers the invocation details.

In-process or out-of-process plugins A comparison of in-process entry-point plugins with git-style external command plugins. In-process or out-of-process plugins Property In-process External mytool-* Rich API, shared objects yes no — args, env, streams Survives a plugin segfault no yes Can read host credentials yes only what you pass Startup cost import time process spawn Trust decides the model: in-process for your ecosystem, external for anything else.

UX considerations

  • Blame accurately. Users deserve to know whether to report a bug to you or to a plugin author; include the plugin's package name and version, and its project URL from package metadata if it has one.
  • Never let a plugin break --help, --version or plugins list. These are the commands people use to diagnose plugin problems.
  • Show state in one place. plugins list with columns for loaded, failed and disabled plugins answers most support questions.
  • Use a distinct exit code for plugin crashes (70, EX_SOFTWARE, is conventional for internal errors) so automation can tell them apart.
  • Make safe mode discoverable. Mention MYTOOL_NO_PLUGINS=1 in the plugin crash message and in your troubleshooting docs.

Testing the behaviour

Test the guard with a plugin defined inside the test:

# tests/test_plugin_guard.py
import typer
from typer.testing import CliRunner

from mytool.plugin_guard import PluginCrashed, isolate


def make_host() -> typer.Typer:
    plugin = typer.Typer()

    @plugin.command()
    def boom(n: int = 1) -> None:
        print(1 / (n - 1))

    @plugin.command()
    def stop() -> None:
        raise typer.Exit(3)

    host = typer.Typer()

    @host.callback()
    def main() -> None:
        """Host."""

    host.add_typer(isolate(plugin, "aws", "mytool-aws 0.3.1"), name="aws")
    return host


def test_crash_is_attributed_to_the_plugin():
    result = CliRunner().invoke(make_host(), ["aws", "boom"])
    assert isinstance(result.exception, PluginCrashed)
    assert "mytool-aws 0.3.1" in str(result.exception)
    assert isinstance(result.exception.__cause__, ZeroDivisionError)


def test_exit_passes_through_unchanged():
    assert CliRunner().invoke(make_host(), ["aws", "stop"]).exit_code == 3


def test_signature_and_help_survive_wrapping():
    result = CliRunner().invoke(make_host(), ["aws", "boom", "--help"])
    assert result.exit_code == 0 and "--n" in result.output

The help test protects the subtle part: if a refactor drops functools.wraps, plugin options silently disappear. Add a test for safe mode by setting MYTOOL_NO_PLUGINS=1 with monkeypatch and asserting the plugin's command is absent from --help.

Conclusion

Plugins fail at load time, at run time and ambiently, and a host CLI needs a defence for each. Catch load errors during discovery; wrap plugin commands so runtime crashes are attributed to the plugin and reported with its package and version; offer an environment-variable safe mode and per-plugin disabling; publish clear rules about stdout and global state; and for untrusted code, run plugins as separate programs with a controlled environment. Users then know whose bug they hit — and can keep working while it is fixed.

Frequently asked questions

Can a plugin still crash the whole process?

Yes — a plugin can call os._exit, segfault in a C extension or exhaust memory, and nothing in-process prevents that. Only out-of-process plugins are isolated from those failures.

Should the host catch BaseException in plugins?

No. SystemExit and KeyboardInterrupt are how programs end and how users interrupt them; swallowing them makes the CLI impossible to stop. Catch Exception and let control-flow exceptions through, as the guard does.

How do I show plugin authors their own traceback?

Honour a debug variable (MYTOOL_DEBUG=1) that re-raises or prints the chained exception with traceback.print_exception. Plugin authors debugging their code want the full trace; users want the one-line attribution.

Is pluggy safer than entry-point command groups?

pluggy structures how hooks are called and combines results, which makes some failures easier to handle, but plugin code still runs in-process. The same attribution and safe-mode techniques apply; see hook-based plugins with pluggy.