This document defines how to manage exit codes, signal handling, stdin/stdout, and process cleanup for CLI tools.
| Code | Meaning | When |
|---|---|---|
0 |
Success | All steps completed successfully |
1 |
Partial failure | Some steps failed but process completed |
2 |
Fatal error | Unrecoverable error (invalid input, unhandled exception) |
## In CLI action handler
@app.command()
def analyze(...) -> None:
try:
results = orchestrator.run()
has_failure = any(not r.success for r in results)
raise typer.Exit(code=1 if has_failure else 0)
except AppError as e:
typer.echo(f"[{e.code}] Error: {e}", err=True)
raise typer.Exit(code=2)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(code=2)- Always exit explicitly — do not let the process hang
- Use
raise typer.Exit()orraise SystemExit()only in the CLI layer — core logic raises exceptions, CLI layer decides exit code - Never use
sys.exit()in library code — it prevents proper cleanup and makes code untestable
Handle termination signals to clean up resources:
import signal
import sys
def setup_signal_handlers(orchestrator: PipelineOrchestrator) -> None:
def handler(signum: int, frame) -> None:
logger.info("Shutting down...")
orchestrator.save_state()
sys.exit(128 + signum)
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGTERM, handler)| Signal | Exit Code | Meaning |
|---|---|---|
| SIGINT | 130 | User interrupted (Ctrl+C) |
| SIGTERM | 143 | Process terminated |
Python converts SIGINT to KeyboardInterrupt. Handle it in the CLI layer:
@app.command()
def analyze(...) -> None:
try:
results = orchestrator.run()
except KeyboardInterrupt:
typer.echo("\nInterrupted. State saved. Resume with: my-tool resume", err=True)
orchestrator.save_state()
raise typer.Exit(code=130)class AppError(Exception):
def __init__(self, message: str, code: str = "UNKNOWN_ERROR", severity: str = "fatal") -> None:
super().__init__(message)
self.code = code
self.severity = severityCreate subclasses for each error domain:
class InputValidationError(AppError):
def __init__(self, message: str) -> None:
super().__init__(message, "INPUT_VALIDATION", "fatal")
class ConnectionError(AppError):
def __init__(self, url: str, cause: str | None = None) -> None:
msg = f"Cannot connect to: {url}" + (f" ({cause})" if cause else "")
super().__init__(msg, "CONNECTION_ERROR", "warning")
self.url = urlFormat errors differently based on verbosity:
def format_error(error: Exception, verbose: bool = False) -> str:
if isinstance(error, AppError):
msg = f"[{error.code}] Error: {error}"
if verbose:
import traceback
msg += "\n" + traceback.format_exc()
return msg
return f"Error: {error}"| Stream | Usage |
|---|---|
stdout |
Program output (results, data, reports) |
stderr |
Diagnostics (logs, progress, errors, warnings) |
This enables piping: my-tool analyze > result.json 2> log.txt
Use structlog or stdlib logging configured to write to stderr:
import logging
import sys
def setup_logger(name: str, verbose: bool = False) -> logging.Logger:
logger = logging.getLogger(name)
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("[%(levelname)s][%(name)s] %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
return loggerWith structlog:
import structlog
structlog.configure(
processors=[
structlog.dev.ConsoleRenderer(),
],
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
)[project]
name = "my-tool"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"typer>=0.9",
"pydantic>=2.0",
"pydantic-settings>=2.0",
]
[project.scripts]
my-tool = "my_tool.cli.main:app"#!/usr/bin/env python3
## src/my_tool/cli/main.py
import typer
app = typer.Typer()
## Register commands...
if __name__ == "__main__":
app()## Run directly during development
uv run my-tool analyze --url https://example.com
## Or via python -m
python -m my_tool.cli.main analyze --url https://example.comUse context managers for resources that need cleanup:
from contextlib import contextmanager
@contextmanager
def managed_browser():
browser = launch_browser()
try:
yield browser
finally:
browser.close()
## Usage
with managed_browser() as browser:
collect_data(browser, config)For async resources:
from contextlib import asynccontextmanager
@asynccontextmanager
async def managed_session():
async with aiohttp.ClientSession() as session:
yield session| Resource | Cleanup Action |
|---|---|
| Browser instances (Playwright) | browser.close() |
| File handles | Context manager or handle.close() |
| Temporary directories | shutil.rmtree(tmp_dir) |
| Child processes | process.terminate() |
| Pipeline state | orchestrator.save_state() |
| HTTP sessions | session.close() |
| Database connections | connection.close() |
- Exit codes: 0 (success), 1 (partial failure), 2 (fatal)
- Handle SIGINT/SIGTERM for graceful shutdown
typer.Exit()only in CLI layer — never in library code- stdout for data, stderr for diagnostics
- Error hierarchy with codes and severity levels
- Always clean up resources with context managers
- Module-scoped loggers with configurable verbosity