Source code for napari.utils.notifications

from __future__ import annotations

import logging
import os
import sys
import threading
import warnings
from collections.abc import Callable, Sequence
from datetime import datetime
from enum import auto
from typing import TYPE_CHECKING

from napari.utils.events import Event, EventEmitter
from napari.utils.misc import StringEnum

if TYPE_CHECKING:
    from types import TracebackType

name2num = {
    'error': 40,
    'warning': 30,
    'info': 20,
    'debug': 10,
    'none': 0,
}

__all__ = [
    'ErrorNotification',
    'Notification',
    'NotificationManager',
    'NotificationSeverity',
    'WarningNotification',
    'show_console_notification',
    'show_debug',
    'show_error',
    'show_info',
    'show_warning',
]


[docs] class NotificationSeverity(StringEnum): """Severity levels for the notification dialog. Along with icons for each.""" ERROR = auto() WARNING = auto() INFO = auto() DEBUG = auto() NONE = auto() def as_icon(self): return { self.ERROR: 'ⓧ', self.WARNING: '⚠️', self.INFO: 'ⓘ', self.DEBUG: '🐛', self.NONE: '', }[self] def __lt__(self, other): return name2num[str(self)] < name2num[str(other)] def __le__(self, other): return name2num[str(self)] <= name2num[str(other)] def __gt__(self, other): return name2num[str(self)] > name2num[str(other)] def __ge__(self, other): return name2num[str(self)] >= name2num[str(other)] def __eq__(self, other): return str(self) == str(other) def __hash__(self): return hash(self.value)
ActionSequence = Sequence[tuple[str, Callable[[], None]]]
[docs] class Notification(Event): """A Notifcation event. Usually created by :class:`NotificationManager`. Parameters ---------- message : str The main message/payload of the notification. severity : str or NotificationSeverity, optional The severity of the notification, by default `NotificationSeverity.WARNING`. actions : sequence of tuple, optional Where each tuple is a `(str, callable)` 2-tuple where the first item is a name for the action (which may, for example, be put on a button), and the callable is a callback to perform when the action is triggered. (for example, one might show a traceback dialog). by default () """ def __init__( self, message: str, severity: str | NotificationSeverity = NotificationSeverity.WARNING, actions: ActionSequence = (), **kwargs, ) -> None: self.severity = NotificationSeverity(severity) super().__init__(type_name=str(self.severity).lower(), **kwargs) self._message = message self.actions = actions # let's store when the object was created; self.date = datetime.now() @property def message(self): return self._message @message.setter def message(self, value): self._message = value @classmethod def from_exception(cls, exc: BaseException, **kwargs) -> Notification: return ErrorNotification(exc, **kwargs) @classmethod def from_warning(cls, warning: Warning, **kwargs) -> Notification: return WarningNotification(warning, **kwargs) def __str__(self): return f'{str(self.severity).upper()}: {self.message}'
[docs] class ErrorNotification(Notification): """ Notification at an Error severity level. """ exception: BaseException def __init__(self, exception: BaseException, *args, **kwargs) -> None: msg = getattr(exception, 'message', str(exception)) actions = getattr(exception, 'actions', ()) super().__init__(msg, NotificationSeverity.ERROR, actions) self.exception = exception def as_html(self): from napari.utils._tracebacks import get_tb_formatter fmt = get_tb_formatter() exc_info = ( self.exception.__class__, self.exception, self.exception.__traceback__, ) return fmt(exc_info, as_html=True) def as_text(self): from napari.utils._tracebacks import get_tb_formatter fmt = get_tb_formatter() exc_info = ( self.exception.__class__, self.exception, self.exception.__traceback__, ) return fmt(exc_info, as_html=False, color='NoColor') def __str__(self): from napari.utils._tracebacks import get_tb_formatter fmt = get_tb_formatter() exc_info = ( self.exception.__class__, self.exception, self.exception.__traceback__, ) return fmt(exc_info, as_html=False)
[docs] class WarningNotification(Notification): """ Notification at a Warning severity level. """ warning: Warning def __init__( self, warning: Warning, filename=None, lineno=None, *args, **kwargs ) -> None: msg = getattr(warning, 'message', str(warning)) actions = getattr(warning, 'actions', ()) super().__init__(msg, NotificationSeverity.WARNING, actions) self.warning = warning self.filename = filename self.lineno = lineno def __str__(self): category = type(self.warning).__name__ return f'{self.filename}:{self.lineno}: {category}: {self.warning}!'
[docs] class NotificationManager: """ A notification manager, to route all notifications through. Only one instance is in general available through napari; as we need notification to all flow to a single location that is registered with the sys.except_hook and showwarning hook. This can and should be used a context manager; the context manager will properly re-entered, and install/remove hooks and keep them in a stack to restore them. While it might seem unnecessary to make it re-entrant; or to make the re-entrancy no-op; one need to consider that this could be used inside another context manager that modify except_hook and showwarning. Currently the original except and show warnings hooks are not called; but this could be changed in the future; this poses some questions with the re-entrency of the hooks themselves. """ records: list[Notification] _instance: NotificationManager | None = None def __init__(self) -> None: self.records: list[Notification] = [] self.exit_on_error = os.getenv('NAPARI_EXIT_ON_ERROR') in ('1', 'True') self.catch_error = os.getenv('NAPARI_CATCH_ERRORS') not in ( '0', 'False', ) self.notification_ready = self.changed = EventEmitter( source=self, event_class=Notification ) self._originals_except_hooks: list[Callable] = [] self._original_showwarnings_hooks: list[Callable] = [] self._originals_thread_except_hooks: list[Callable] = [] self._seen_warnings: set[tuple[str, type, str, int]] = set() def __enter__(self): self.install_hooks() return self def __exit__(self, *args, **kwargs): self.restore_hooks()
[docs] def install_hooks(self): """ Install a `sys.excepthook`, a `showwarning` hook and a threading.excepthook to display any message in the UI, storing the previous hooks to be restored if necessary. """ # TODO: we might want to display the additional thread information self._originals_thread_except_hooks.append(threading.excepthook) threading.excepthook = self.receive_thread_error self._originals_except_hooks.append(sys.excepthook) self._original_showwarnings_hooks.append(warnings.showwarning) sys.excepthook = self.receive_error warnings.showwarning = self.receive_warning
[docs] def restore_hooks(self): """ Remove hooks installed by `install_hooks` and restore previous hooks. """ threading.excepthook = self._originals_thread_except_hooks.pop() sys.excepthook = self._originals_except_hooks.pop() warnings.showwarning = self._original_showwarnings_hooks.pop()
def dispatch(self, notification: Notification): self.records.append(notification) self.notification_ready(notification) def receive_thread_error( self, args: tuple[ type[BaseException], BaseException, TracebackType | None, threading.Thread | None, ], ): self.receive_error(*args) def receive_error( self, exctype: type[BaseException], value: BaseException, traceback: TracebackType | None = None, thread: threading.Thread | None = None, ): if isinstance(value, KeyboardInterrupt): sys.exit('Closed by KeyboardInterrupt') if self.exit_on_error: sys.__excepthook__(exctype, value, traceback) sys.exit('Exit on error') if not self.catch_error: sys.__excepthook__(exctype, value, traceback) return self.dispatch(Notification.from_exception(value)) def receive_warning( self, message: Warning, category: type[Warning], filename: str, lineno: int, file=None, line=None, ): msg = message if isinstance(message, str) else message.args[0] if (msg, category, filename, lineno) in self._seen_warnings: return self._seen_warnings.add((msg, category, filename, lineno)) self.dispatch( Notification.from_warning( message, filename=filename, lineno=lineno ) ) def receive_info(self, message: str): self.dispatch(Notification(message, 'INFO'))
notification_manager = NotificationManager()
[docs] def show_debug(message: str): """ Show a debug message in the notification manager. """ notification_manager.dispatch( Notification(message, severity=NotificationSeverity.DEBUG) )
[docs] def show_info(message: str): """ Show an info message in the notification manager. """ notification_manager.dispatch( Notification(message, severity=NotificationSeverity.INFO) )
[docs] def show_warning(message: str): """ Show a warning in the notification manager. """ notification_manager.dispatch( Notification(message, severity=NotificationSeverity.WARNING) )
[docs] def show_error(message: str): """ Show an error in the notification manager. """ notification_manager.dispatch( Notification(message, severity=NotificationSeverity.ERROR) )
[docs] def show_console_notification(notification: Notification): """ Show a notification in the console. """ try: from napari.settings import get_settings if ( notification.severity < get_settings().application.console_notification_level ): return print(notification) # noqa: T201 except Exception: logging.getLogger('napari').exception( 'An error occurred while trying to format an error and show it in console.\n' 'You can try to uninstall IPython to disable rich traceback formatting\n' 'And/or report a bug to napari' ) # this will likely get silenced by QT. raise