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
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 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.
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,--versionorplugins list. These are the commands people use to diagnose plugin problems. - Show state in one place.
plugins listwith 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=1in 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.