"""
Click command utilities and exception helpers for the :mod:`pyfcstm.entry` package.
This module provides standardized exception classes and a command wrapper that
integrates with :mod:`click` to ensure consistent error reporting in CLI
commands. It also includes a utility to print detailed exception information
with tracebacks for unexpected errors.
The main public components are:
* :class:`ClickWarningException` - Warning-style Click exception with yellow output
* :class:`ClickErrorException` - Error-style Click exception with red output
* :class:`KeyboardInterrupted` - Specialized warning for keyboard interrupts
* :func:`print_exception` - Pretty-print exception details with traceback
* :func:`command_wrap` - Decorator to wrap Click commands with standardized handling
Example::
>>> import click
>>> from pyfcstm.entry.base import command_wrap
>>>
>>> @click.command()
... @command_wrap()
... def main():
... raise ValueError("Boom")
...
>>> # Running the command prints a formatted error and exits.
"""
import builtins
import itertools
import os
import sys
import traceback
from functools import wraps, partial
from typing import Optional, IO, Callable, TypeVar
try:
from typing import ParamSpec
except ImportError:
from typing_extensions import ParamSpec
import click
from click.exceptions import ClickException
CONTEXT_SETTINGS = dict(
help_option_names=['-h', '--help']
)
P = ParamSpec("P")
R = TypeVar("R")
[docs]
class ClickWarningException(ClickException):
"""
Custom exception class for displaying warnings in yellow color.
:param message: The error message.
:type message: str
"""
[docs]
def show(self, file: Optional[IO] = None) -> None:
"""
Display the warning message in yellow.
:param file: File to write the output to. This parameter is ignored and
output is always written to ``sys.stderr``.
:type file: Optional[IO]
"""
click.secho(self.format_message(), fg='yellow', file=sys.stderr)
[docs]
class ClickErrorException(ClickException):
"""
Custom exception class for displaying errors in red color.
:param message: The error message.
:type message: str
"""
[docs]
def show(self, file: Optional[IO] = None) -> None:
"""
Display the error message in red.
:param file: File to write the output to. This parameter is ignored and
output is always written to ``sys.stderr``.
:type file: Optional[IO]
"""
click.secho(self.format_message(), fg='red', file=sys.stderr)
[docs]
def print_exception(err: BaseException, print_func: Optional[Callable[..., None]] = None) -> None:
"""
Print exception information, including a formatted traceback.
The output includes the traceback header and frames (if available), followed
by the exception class name and message. A custom ``print`` callable can be
provided to control where output goes (e.g., a Click ``secho`` function).
:param err: The exception object to display.
:type err: BaseException
:param print_func: Custom print function. If not provided, uses built-in ``print``.
:type print_func: Optional[Callable[..., None]]
:return: ``None``. The function prints directly to the output stream.
:rtype: None
Example::
>>> try:
... 1 / 0
... except Exception as exc:
... print_exception(exc)
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
"""
print_func = print_func or builtins.print
lines = list(itertools.chain(*map(
lambda x: x.splitlines(keepends=False),
traceback.format_tb(err.__traceback__)
)))
if lines:
print_func('Traceback (most recent call last):')
print_func(os.linesep.join(lines))
if len(err.args) == 0:
print_func(f'{type(err).__name__}')
elif len(err.args) == 1:
print_func(f'{type(err).__name__}: {err.args[0]}')
else:
print_func(f'{type(err).__name__}: {err.args}')
[docs]
class KeyboardInterrupted(ClickWarningException):
"""
Exception class for handling keyboard interruptions.
This exception is raised when the wrapped Click command receives a
:class:`KeyboardInterrupt`. It is a warning-level exception with a specific
exit code.
:param msg: Custom message to display. Defaults to ``"Interrupted."``.
:type msg: Optional[str]
"""
exit_code = 0x7
[docs]
def __init__(self, msg: Optional[str] = None) -> None:
"""
Initialize the exception.
:param msg: Custom message to display. Defaults to ``"Interrupted."``.
:type msg: Optional[str]
"""
ClickWarningException.__init__(self, msg or 'Interrupted.')
[docs]
def command_wrap() -> Callable[[Callable[P, R]], Callable[P, R]]:
"""
Decorator factory for wrapping Click commands with consistent error handling.
The wrapper provides the following behavior:
* Re-raises :class:`click.ClickException` without modification.
* Converts :class:`KeyboardInterrupt` into :class:`KeyboardInterrupted`.
* For any other exception, prints a red error header, outputs a traceback
using :func:`print_exception`, and exits the current Click context with
exit code ``1``.
:return: A decorator that wraps Click command functions.
:rtype: Callable[[Callable[..., R]], Callable[..., R]]
Example::
>>> import click
>>> from pyfcstm.entry.base import command_wrap
>>>
>>> @click.command()
... @command_wrap()
... def main():
... raise RuntimeError("Unexpected")
...
>>> # Running the command emits a formatted error and exits with code 1.
"""
def _decorator(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def _new_func(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return func(*args, **kwargs)
except ClickException:
# Click's own user-facing errors already format themselves.
raise
except KeyboardInterrupt:
# Translate Ctrl-C into our own typed sentinel so caller code
# can pattern-match on it.
raise KeyboardInterrupted
except BaseException as err:
# Top-of-stack CLI barrier: format *any* remaining failure
# (``Exception`` subclasses AND ``SystemExit``-style escapes
# that aren't ``KeyboardInterrupt``) into a friendly red
# message and exit non-zero. This is the only place in the
# codebase where ``BaseException`` is acceptable — it is the
# last-resort process boundary for the ``pyfcstm`` console
# script. The full traceback is still printed via
# ``print_exception`` so the underlying bug stays diagnosable.
click.secho('Unexpected error found when running pyfcstm!', fg='red', file=sys.stderr)
print_exception(err, partial(click.secho, fg='red', file=sys.stderr))
click.get_current_context().exit(1)
return _new_func
return _decorator