CLI exit handling helpers: clean signals, exit codes, and error printing
Project description
lib_cli_exit_tools
Small helpers for robust CLI exit handling:
- Portable signal handling (SIGINT, SIGTERM/SIGBREAK)
- Consistent exception → exit code mapping
- Concise error printing with optional traceback and subprocess stdout/stderr capture
Install
Requires Python 3.13 or newer.
pip install lib_cli_exit_tools
See INSTALL.md for editable installs, pipx/uv usage, and troubleshooting tips.
Usage
Console script (all commands map to lib_cli_exit_tools.cli:main):
# After install (pip/pipx/uv tool)
lib-cli-exit-tools --help
lib-cli-exit-tools info
lib-cli-exit-tools fail # intentionally trigger RuntimeError to test error paths
# Aliases are also generated: `cli-exit-tools`, `lib_cli_exit_tools`
Examples
The snippets below move from a minimal “hello world” through a production-ready CLI that demonstrates configuration hooks and structured error handling.
1. Minimal command (copy-paste ready)
from __future__ import annotations
import click
from lib_cli_exit_tools import run_cli
@click.command()
def hello() -> None:
"""Say hello with automatic signal-aware exit handling."""
click.echo("Hello from lib_cli_exit_tools!")
if __name__ == "__main__":
raise SystemExit(run_cli(hello))
Run it with:
python hello.py
2. Verbose-mode helper using cli_session
from __future__ import annotations
import click
from lib_cli_exit_tools import cli_session, run_cli
@click.command()
@click.option("--verbose", is_flag=True, help="Show full tracebacks on error")
def cli(verbose: bool) -> int:
with cli_session(overrides={"traceback": verbose}) as execute:
return execute(failable, argv=[])
@click.command()
def failable() -> None:
raise RuntimeError("toggle --verbose to see the full traceback")
if __name__ == "__main__":
raise SystemExit(run_cli(cli))
Invoking python cli.py --verbose enables coloured tracebacks just for that
run and resets the configuration afterwards.
3. Full-featured multi-command CLI
from __future__ import annotations
import json
from dataclasses import dataclass
import click
from lib_cli_exit_tools import SignalSpec, cli_session, default_signal_specs
@dataclass(slots=True)
class Settings:
pretty: bool
verbose: bool
@click.group()
@click.option("--pretty/--no-pretty", default=True)
@click.option("--verbose", is_flag=True)
@click.pass_context
def cli(ctx: click.Context, pretty: bool, verbose: bool) -> None:
ctx.obj = Settings(pretty=pretty, verbose=verbose)
@cli.command()
@click.pass_obj
def info(settings: Settings) -> None:
payload = {"verbose": settings.verbose, "pretty": settings.pretty}
click.echo(json.dumps(payload, indent=2 if settings.pretty else None))
@cli.command()
@click.pass_obj
def fail(settings: Settings) -> None:
click.echo("About to fail…")
raise RuntimeError("intentional failure for diagnostics")
def main() -> int:
overrides = {"traceback": True, "traceback_force_color": True}
signals: list[SignalSpec] = default_signal_specs()
with cli_session(overrides=overrides) as execute:
return execute(cli, signal_specs=signals)
if __name__ == "__main__":
raise SystemExit(main())
This version wires configuration overrides, custom signal specs, and multiple
commands into a single composition point—mirroring how a production CLI can
layer policy logic around Click while still delegating exit-code translation to
lib_cli_exit_tools.
4. Custom signal handlers
run_cli accepts an explicit signal_spec sequence and a signal_installer
hook so you can provide bespoke behaviour. The example below installs a custom
SIGUSR1 handler alongside the library defaults:
from __future__ import annotations
import signal
from contextlib import ExitStack
from typing import Callable
import click
from lib_cli_exit_tools import SignalSpec, default_signal_specs, run_cli
CUSTOM_SIG = getattr(signal, "SIGUSR1", None)
def custom_signal_specs() -> list[SignalSpec]:
specs = default_signal_specs()
if CUSTOM_SIG is not None:
specs.append(
SignalSpec(
signum=CUSTOM_SIG,
exception=RuntimeError,
message="Received SIGUSR1",
exit_code=75,
)
)
return specs
def install_custom_signals(specs: list[SignalSpec] | None) -> Callable[[], None]:
stack = ExitStack()
for spec in specs or []:
def _handler(signum: int, frame: object | None, *, spec: SignalSpec = spec) -> None:
raise spec.exception(f"Signal {spec.message}")
try:
previous = signal.getsignal(spec.signum)
signal.signal(spec.signum, _handler)
except OSError:
continue
stack.callback(signal.signal, spec.signum, previous)
return stack.close
@click.command()
def main() -> None:
click.echo("Send SIGUSR1 to trigger the custom handler...")
if hasattr(signal, "pause"):
signal.pause()
if __name__ == "__main__":
custom_specs = custom_signal_specs()
raise SystemExit(
run_cli(
main,
signal_specs=custom_specs,
signal_installer=install_custom_signals,
)
)
The signal_installer callable receives the resolved SignalSpec list and
must return a zero-argument restorer. In this case, the helper installs handlers
via ExitStack, raising the specified exception whenever the signal fires and
restoring the previous handlers once run_cli completes.
Library:
import lib_cli_exit_tools
lib_cli_exit_tools.config.traceback = False # show short messages
try:
raise FileNotFoundError("missing.txt")
except Exception as e:
code = lib_cli_exit_tools.get_system_exit_code(e) # 2 on POSIX
lib_cli_exit_tools.print_exception_message() # prints: FileNotFoundError: missing.txt
raise SystemExit(code)
Command names registered on install (all invoke lib_cli_exit_tools.cli:main)
- lib-cli-exit-tools (default console script)
- cli-exit-tools (alias)
- lib_cli_exit_tools (alias)
- python -m lib_cli_exit_tools (module entry)
If you installed with --user or in a venv, make sure the corresponding bin directory is on PATH:
- Linux/macOS venv: .venv/bin
- Linux/macOS user: ~/.local/bin
- Windows venv: .venv\Scripts
- Windows user: %APPDATA%\Python\PythonXY\Scripts
Runtime configuration
All configuration lives on the module-level lib_cli_exit_tools.config object. Adjust it once during startup; the settings apply process-wide:
from lib_cli_exit_tools import config
config.traceback = True # emit full tracebacks instead of short messages
config.exit_code_style = "sysexits" # emit BSD-style exit codes (EX_USAGE, EX_NOINPUT, …)
config.broken_pipe_exit_code = 0 # treat BrokenPipeError as a benign truncation
Field reference:
traceback(bool, defaultFalse): whenTrue,handle_cli_exceptionrenders a full Rich traceback to stderr and then returns a non-zero exit code. The original exception is not re-raised; callers should rely on the rendered output and returned status. The bundled CLI toggles this via--traceback/--no-traceback.exit_code_style("errno"or"sysexits", default"errno"): controls the numeric mapping produced byget_system_exit_code.errnoreturns POSIX/Windows-style codes (e.g.,FileNotFoundError → 2,SIGINT → 130);sysexitsreturns BSD-style semantic codes (EX_NOINPUT,EX_USAGE, etc.).broken_pipe_exit_code(int, default141): overrides the exit status when aBrokenPipeErroris raised (the default mirrors128 + SIGPIPE). Set this to0if you want truncation to be treated as success.
Remember that config is module-level—if you call the library from multiple threads or embed it in another CLI, configure it once during bootstrap before handing control to user code. When you need temporary overrides (for tests or nested CLIs), wrap the change with the built-in context manager so state is restored automatically:
from lib_cli_exit_tools import config_overrides, config
with config_overrides(traceback=True):
# tracebacks enabled only within this block
run_something()
# state restored to previous values here
To return to baseline defaults, call lib_cli_exit_tools.reset_config().
Testing
Install the project with development extras before running the full test matrix:
pip install -e .[dev]
Afterwards, execute the consolidated quality gate:
make test
Need to run coverage manually without the helper? Use the new convenience target so
you never depend on a platform-specific coverage shim:
make coverage
Which internally delegates to the scripts toolbox:
python -m scripts coverage
The target expands to python -m coverage run -m pytest -vv and therefore works
even when the coverage console script is not available on your PATH. The helper
also sets COVERAGE_NO_SQL=1 ahead of each run so coverage falls back to the
file-based data format instead of SQLite, avoiding "database is locked" errors
on shared workstations.
Prefer the automation CLI when you need to tweak options without editing the Makefile:
python -m scripts.test --coverage=off --verbose
The suite includes OS-aware cases (POSIX, Windows-specific signal handling), so run it on each target platform you support to keep coverage consistent.
If your environment reports “cannot execute” when running pytest, the auto-generated entry-point script likely points at a removed interpreter. Reinstall the dev extras or invoke tests with python -m pytest (for example, python -m pytest tests/).
When coverage uploads are skipped (no Codecov token), make test still writes coverage.xml and codecov.xml to the project root so you can inspect results locally or feed them into other tooling.
Public API Reference
The package re-exports the helpers below via lib_cli_exit_tools.__all__. Import them directly with from lib_cli_exit_tools import ….
config
Mutable dataclass-like singleton holding process-wide settings. Configure it during CLI startup.
traceback(bool):Trueto surface full Python tracebacks;Falsekeeps short, coloured summaries.exit_code_style('errno' | 'sysexits'): Selects POSIX/Windows errno-style exit codes or BSDsysexitssemantics.broken_pipe_exit_code(int): Overrides the exit status forBrokenPipeError(default141).traceback_force_color(bool): Forces Rich-coloured tracebacks even when stderr is not a TTY.
run_cli(cli, argv=None, *, prog_name=None, signal_specs=None, install_signals=True, exception_handler=None, signal_installer=None) -> int
Wrap a Click command or group so every invocation shares the same signal handling and exit-code policy. Returns the numeric exit code instead of exiting the process.
Parameters:
cli:click.BaseCommandto execute.argv: Iterable of CLI arguments (excluding the program name) orNoneto defer to Click's defaults.prog_name: Override the program name shown in help/version output.signal_specs: Iterable ofSignalSpecobjects to customise signal handling; defaults todefault_signal_specs().install_signals: SetFalsewhen the host application already manages signal handlers.exception_handler: Callable receiving the raised exception and returning an exit code; defaults tohandle_cli_exception.signal_installer: Callable mirroringinstall_signal_handlersfor embedding scenarios.
cli_session(*, summary_limit=500, verbose_limit=10_000, overrides=None)
Context manager that snapshots :mod:lib_cli_exit_tools.config, optionally
applies temporary overrides, and yields a callable compatible with
run_cli.
Parameters:
summary_limit: Character budget when tracebacks are disabled.verbose_limit: Character budget when tracebacks are enabled.overrides: Mapping of configuration field/value pairs applied during the session.
Use it to restore configuration automatically—even when the wrapped command raises:
with cli_session(overrides={"traceback": True}) as run:
exit_code = run(click_command, argv=args)
handle_cli_exception(exc, *, signal_specs=None, echo=None) -> int
Translate exceptions raised by Click commands into deterministic exit codes, honouring configured signal mappings and traceback policy.
Parameters:
exc: Exception instance to classify.signal_specs: Optional iterable ofSignalSpecobjects for custom signal handling.echo: Callable matchingclick.echosignature, allowing custom stderr routing during tests or embedding.
get_system_exit_code(exc) -> int
Compute a platform-aware exit status for arbitrary exceptions (errno mappings on POSIX/Windows or BSD sysexits when enabled).
Parameters:
exc: Exception instance to classify.
print_exception_message(trace_back=config.traceback, length_limit=500, stream=None) -> None
Emit the active exception using Rich formatting. Produces a coloured traceback when trace_back is True, otherwise prints a truncated summary in red. Respects config.traceback_force_color and mirrors the behaviour of handle_cli_exception (tracebacks are rendered before the helper returns an exit status).
Parameters:
trace_back: Toggle between full traceback rendering (True) and short summary (False).length_limit: Maximum characters for summary output.stream: Target text stream; defaults tosys.stderr.
i_should_fail()
Deterministically raise RuntimeError('i should fail') to exercise error-handling paths. Useful for smoke-testing exit-code translation, CLI traceback toggles, and log formatting without inventing ad-hoc failing commands.
flush_streams() -> None
Best-effort flush of sys.stdout and sys.stderr, ensuring buffered output is written before exit.
default_signal_specs() -> list[SignalSpec]
Return the default signal mapping for the current platform (always includes SIGINT, plus SIGTERM/SIGBREAK when available).
install_signal_handlers(specs=None) -> Callable[[], None]
Register handlers that raise structured exceptions for the provided specs and return a restoration callback. Invoke the callback (typically in a finally block) to restore previous handlers.
Parameters:
specs: Iterable ofSignalSpecobjects; defaults todefault_signal_specs().
SignalSpec(signum: int, exception: type[BaseException], message: str, exit_code: int)
Lightweight dataclass describing how a signal maps to an exception, stderr message, and exit code.
Fields:
signum: Numeric signal value passed tosignal.signal.exception: Exception type raised by the handler.message: Human-readable text echoed when the signal fires.exit_code: Exit status returned to the OS.
CliSignalError and subclasses
Hierarchy of marker exceptions raised when signal handlers trigger. Use them to differentiate signal-driven exits from other failures.
CliSignalError: Base class.SigIntInterrupt: Raised onSIGINT(Ctrl+C); maps to exit code130.SigTermInterrupt: Raised onSIGTERM; maps to exit code143.SigBreakInterrupt: Raised on WindowsSIGBREAK; maps to exit code149.
default_signal_specs, install_signal_handlers, and run_cli contract summary
When run_cli executes your Click command it will:
- Build a signal spec list (custom or default).
- Optionally install handlers that raise the exceptions above.
- Execute the command with
standalone_mode=False. - Funnel any exception through
handle_cli_exception. - Restore prior signal handlers and flush streams before returning (or rely on any injected replacements).
This behaviour keeps CLI wiring consistent across projects embedding lib_cli_exit_tools while still allowing custom hooks when needed.
Advanced CLI wiring
For larger applications, keep module execution, console scripts, and shared helpers aligned. The snippet below shows how __main__.py can catch unexpected errors and map them through lib_cli_exit_tools before exiting:
# src/your_package/__main__.py
from __future__ import annotations
import lib_cli_exit_tools
from .cli import main
if __name__ == "__main__":
try:
exit_code = int(main())
except BaseException as exc: # fallback to shared exit helpers
lib_cli_exit_tools.print_exception_message()
exit_code = lib_cli_exit_tools.get_system_exit_code(exc)
raise SystemExit(exit_code)
A multi-command Click CLI can reuse the same configuration object and expose custom commands while still delegating wiring to run_cli:
# src/your_package/cli.py
from __future__ import annotations
from typing import Sequence
import click
import lib_cli_exit_tools
from . import __init__conf__
from .lib_template import hello_world as _hello_world
from .lib_template import i_should_fail as _fail
CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) # noqa: C408
@click.group(help=__init__conf__.title, context_settings=CLICK_CONTEXT_SETTINGS)
@click.version_option(
version=__init__conf__.version,
prog_name=__init__conf__.shell_command,
message=f"{__init__conf__.shell_command} version {__init__conf__.version}",
)
@click.option(
"--traceback/--no-traceback",
is_flag=True,
default=False,
help="Show full Python traceback on errors",
)
@click.pass_context
def cli(ctx: click.Context, traceback: bool) -> None:
"""Root CLI group. Stores global opts in context & shared config."""
ctx.ensure_object(dict)
ctx.obj["traceback"] = traceback
lib_cli_exit_tools.config.traceback = traceback
@cli.command("info", context_settings=CLICK_CONTEXT_SETTINGS)
def cli_info() -> None:
"""Print project information."""
__init__conf__.print_info()
@cli.command("hello", context_settings=CLICK_CONTEXT_SETTINGS)
def cli_hello() -> None:
"""Print the standard hello message."""
_hello_world()
@cli.command("fail", context_settings=CLICK_CONTEXT_SETTINGS)
def cli_fail() -> None:
"""Trigger the intentional failure helper."""
_fail()
def main(argv: Sequence[str] | None = None) -> int:
"""Entrypoint returning an exit code via shared run_cli helper."""
return lib_cli_exit_tools.run_cli(
cli,
argv=list(argv) if argv is not None else None,
prog_name=__init__conf__.shell_command,
)
When installed, the generated console scripts (lib-cli-exit-tools, cli-exit-tools, lib_cli_exit_tools) will import your_package.cli:main, and python -m your_package will follow the same code path via __main__.py.
Exit Codes
- SIGINT → 130, SIGTERM → 143 (POSIX), SIGBREAK → 149 (Windows)
- SystemExit(n) → n
- Common exceptions map to POSIX/Windows codes (FileNotFoundError, PermissionError, ValueError, etc.)
Broken pipe behavior
- Default: exit 141 quietly (128+SIGPIPE), no noisy error output.
- Configure:
config.broken_pipe_exit_code = 0to treat as benign truncation, or32(EPIPE).
Sysexits mode (optional)
- Set
config.exit_code_style = "sysexits"to map ValueError/TypeError → EX_USAGE(64), FileNotFoundError → EX_NOINPUT(66), PermissionError → EX_NOPERM(77), generic OSError → EX_IOERR(74).
Modern Python Toolchain
- Targets Python 3.13+ exclusively—no runtime shims or compatibility branches remain.
- Development extras track the latest stable releases published on PyPI so quality gates match local and CI environments.
- GitHub Actions workflows rely on the current major releases of
actions/checkout,actions/setup-python, andastral-sh/setup-uv, aligning the automation stack with the 2025 runner images.
Additional Documentation
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lib_cli_exit_tools-2.1.0.tar.gz.
File metadata
- Download URL: lib_cli_exit_tools-2.1.0.tar.gz
- Upload date:
- Size: 85.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
96803ded537eba1663480bad5983e529f887ae513c54e4c72d35be324cef578d
|
|
| MD5 |
f79a946d8e7dd8b8b25bc0b07c0bd8a3
|
|
| BLAKE2b-256 |
57e4d71aec3bebb05ee28838be8864b69811fe089ec7647ffea1f3d10891d2a1
|
File details
Details for the file lib_cli_exit_tools-2.1.0-py3-none-any.whl.
File metadata
- Download URL: lib_cli_exit_tools-2.1.0-py3-none-any.whl
- Upload date:
- Size: 32.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ec3058f749093411881f136cb9fece47fa43a3b1d63fe865b37753565ecba6ff
|
|
| MD5 |
b7d9c92e2439ccb5918f924933608f77
|
|
| BLAKE2b-256 |
2b1eb9e10cb4ee0642be6dc28ac98387ff068cc3b22189418d3bf6fe8054809f
|