"""Mixin class for errors, warnings and information
A class derived from `OWBaseWidget` can include member classes `Error`, `Warning`
and `Information`, derived from the same-named `OWBaseWidget` classes. Each of
those contains members that are instances of `UnboundMsg`, which is, for
convenience, also exposed as `Orange.widgets.widget.Msg`. These members
represent all possible errors, like `Error.no_discrete_vars`, with exception
of the deprecated old-style errors.
When the widget is instantiated, classes `Error`, `Warning` and `Information`
are instantiated and bound to the widget: their attribute `widget` is the link
to the widget that instantiated them. Their member messages are replaced with
instances of `_BoundMsg`, which are bound to the group through the `group`
attribute.
A message is shown by calling, e.g. `self.Error.no_discrete_vars()`. The call
formats the message and tells the group to activate it::
self.formatted = self.format(*args, **kwargs)
self.group.activate_msg(self)
The group adds it to the dictionary of active messages (attribute `active`)
and emits the signal `messageActivated`. The signal is connected to the
widget's method `update_widget_state`, which shows the message in the bar, and
`WidgetManager`'s `__on_widget_state_changed`, which manages the icons on the
canvas.
Clearing messages work analogously.
"""
import sys
import traceback
from operator import attrgetter
from warnings import warn
from inspect import getattr_static
from typing import Optional
from AnyQt.QtWidgets import QStyle, QSizePolicy
from orangewidget.utils.messagewidget import MessagesWidget
class UnboundMsg(str):
"""
The class used for declaring messages in classes derived from
MessageGroup. When instantiating the message group, instances of this
class are replaced by instances of `_BoundMsg` that are bound to the group.
Note: this class is aliased to `Orange.widgets.widget.Msg`.
"""
def __new__(cls, msg):
return str.__new__(cls, msg)
def bind(self, group, owner_class=None):
return _BoundMsg(self, group, owner_class)
# The method is implemented in _BoundMsg
# pylint: disable=unused-variable
def __call__(self, *args, shown=True, exc_info=None, **kwargs):
"""
Show the message, or hide it if `show` is set `False`
`*args` and `**kwargs` are passed to the `format` method.
Args:
shown (bool): keyword-only argument that can be set to `False` to
hide the message
exc_info (Union[BaseException, bool, None]): Optional exception
instance whose traceback to store in the message. Can also be
a `True` value in which case the exception is retrieved from
sys.exc_info()
*args: arguments for `format`
**kwargs: keyword arguments for `format`
"""
raise RuntimeError("Message is not bound")
# The method is implemented in _BoundMsg
def clear(self):
"""Remove the message."""
raise RuntimeError("Message is not bound")
# The method is implemented in _BoundMsg
def is_shown(self):
"""Return True if message is currently displayed."""
raise RuntimeError("Message is not bound")
# Ensure that two instance of message are always different
# In particular, there may be multiple messages "{}".
def __hash__(self):
return id(self)
def __eq__(self, other):
return self is other
class _BoundMsg(UnboundMsg):
"""
A message that is bound to the group.
Instances of this class provide the call operator for raising the message,
and method `clear` for removing it.
When the message is called, the arguments are passed to the message's
format` method and the resulting string is stored in the attribute
`formatted`.
Attributes:
group (MessageGroup): the group to which this message belongs
owner_class (OWBaseWidget): the class in which the message is defined
formatted (str): formatted message
"""
def __new__(cls, unbound_msg, group, owner_class=None):
self = UnboundMsg.__new__(cls, unbound_msg)
self.group = group
self.owner_class = owner_class
self.formatted = ""
self.tb = None # type: Optional[str]
return self
def __call__(self, *args, shown=True, exc_info=None, **kwargs):
self.tb = None
if not shown:
self.clear()
else:
self.formatted = self.format(*args, **kwargs)
if exc_info:
if isinstance(exc_info, BaseException):
exc_info = (type(exc_info), exc_info,
exc_info.__traceback__)
elif not isinstance(exc_info, tuple):
exc_info = sys.exc_info()
if exc_info is not None:
self.tb = "".join(traceback.format_exception(*exc_info))
self.group.activate_msg(self)
def clear(self):
self.group.deactivate_msg(self)
def is_shown(self):
return self in self.group.active
def __str__(self):
return self.formatted
class _OldStyleMsg(_BoundMsg):
"""
Class for handling the old-style messages.
Instances of this class are instantiated by the old methods `error`,
`warning` and `information` and added to the list of active messages,
with their old-style id's as keys. Instance of `_OldStyleMsg` are not
members of message groups.
"""
def __new__(cls, text, group):
self = _BoundMsg.__new__(cls, text, group)
self.formatted = text
return self
class MessageGroup:
"""
A groups of messages, e.g. errors, warnings, information messages
Widget's `__init__` searches for instances of this class among the widget's
class member and instantiates them.
Attributes:
widget (widget.OWBaseWidget): the widget instance to which the group belongs
"""
def __init__(self, widget):
self.widget = widget
# Note: active messages are stored in the dictionary, in which
# the key and the corresponding value are one and the same object,
# except for old-style classes, for which the key is an (old-style)
# id. When we remove support for old-style messages (Orange 4),
# this dictionary can be replaced with a set.
self._active = {}
self._general = UnboundMsg("{}")
self._bind_messages()
@property
def active(self):
"""
Sequence[_BoundMsg]: Sequence of all currently active messages.
"""
return self._active.values()
def _bind_messages(self):
def bind_subgroup(subgroup, widget_class):
for name, msg in subgroup.__dict__.items():
if type(msg) is UnboundMsg:
msg = msg.bind(self, widget_class)
self.__dict__[name] = msg
# Iterate through all base classes of this message group.
# Among the widgets in mro, find the one that defined this class
# and bind all messages in the group to that class.
# This is needed so that each message has its "owner" and that `clear`
# method can clear only messages from the owner (see method `clear`)
for group in type(self).mro():
for widget_class in type(self.widget).mro():
if widget_class.__dict__.get(group.__name__) is group:
break
else:
# MessageGroups outside widget classes (e.g. mixins)
widget_class = None
bind_subgroup(group, widget_class)
# This binds `_general` -- and any similar cases in which a message is
# added to the instance and not as class attribute
bind_subgroup(self, None)
def add_message(self, name, msg="{}"):
"""Add and bind message to a group that is already instantiated
and bound.
If the message with that name already exists, the method does nothing.
The method is used by helpers like this (simplified) one::
def check_results(results, msg_group):
msg_group.add_message("invalid_results",
"Results do not include any data")
msg_group.invalid_results.clear()
if results.data is None:
msg_group.invalid_results()
The helper is called from several widgets with
`check_results(results, self.Error)`
Args:
name (str): the name of the member with the message
msg (str or UnboundMsg): message text or instance (default `"{}"`)
"""
if not isinstance(msg, UnboundMsg):
msg = UnboundMsg(msg)
if name not in self.__dict__:
self.__dict__[name] = msg.bind(self)
def activate_msg(self, msg, msg_id=None):
"""Activate a message and emit the signal messageActivated
Args:
msg (_BoundMsg): the message to activate
msg_id (int): id for old-style message (to be removed in the future)
"""
key = msg if msg_id is None else msg_id
if self._active.get(key) == msg:
self.widget.messageActivated.emit(msg)
return
self._active[key] = msg
self.widget.messageActivated.emit(msg)
def deactivate_msg(self, msg):
"""Deactivate a message and emit the signal messageDeactivated.
Args:
msg (_BoundMsg): the message to deactivate
"""
if msg not in self._active:
return
inst_msg = self._active.pop(msg)
self.widget.messageDeactivated.emit(
inst_msg if isinstance(msg, int) else msg)
# When when we no longer support old-style messages, replace with:
# if msg not in self._active:
# return
# del self._active[msg]
# self.widget.messageDeactivated.emit(msg)
# self has default value to avoid PyCharm warnings when calling
# self.Error.clear(): PyCharm doesn't know that Error is instantiated
def clear(self=None, *, owner=None):
"""Deactivate all active message from this group."""
for msg in list(self._active):
if owner is None or msg.owner_class is owner:
self.deactivate_msg(msg)
def _add_general(self, id_or_text, text, shown):
"""Handler for methods `error`, `warning` and `information`;
do not call directly.
The message is shown as general message. This method also
handles deprecated messages with id's."""
if id_or_text is None or id_or_text == "":
self._general.clear()
elif isinstance(id_or_text, str):
self._general(id_or_text, shown=shown)
# remaining cases handle deprecated messages with id's
elif text:
self.activate_msg(_OldStyleMsg(text, self), id_or_text)
elif isinstance(id_or_text, list):
for msg_id in id_or_text:
self.deactivate_msg(msg_id)
else:
self.deactivate_msg(id_or_text)
class MessagesMixin:
"""
Base class for message mixins. The class provides a constructor for
instantiating and binding message groups.
Widgets should use `WidgetMessageMixin rather than this class.
"""
def __init__(self):
# type(self).__dict__ wouldn't return inherited messages, hence dir
self.message_groups = []
for name in dir(self):
group_class = getattr_static(self, name)
if isinstance(group_class, type) and \
issubclass(group_class, MessageGroup) and \
group_class is not MessageGroup:
bound_group = group_class(self)
setattr(self, name, bound_group)
self.message_groups.append(bound_group)
self.message_groups.sort(key=attrgetter("severity"), reverse=True)
class WidgetMessagesMixin(MessagesMixin):
"""
Provide the necessary methods for handling messages in widgets.
The class defines member classes `Error`, `Warning` and `Information` that
serve as base classes for these message groups.
"""
class Error(MessageGroup):
"""Base class for groups of error messages in widgets"""
severity = 3
bar_background = "#ffc6c6"
bar_icon = QStyle.SP_MessageBoxCritical
class Warning(MessageGroup):
"""Base class for groups of warning messages in widgets"""
severity = 2
bar_background = "#ffffc9"
bar_icon = QStyle.SP_MessageBoxWarning
class Information(MessageGroup):
"""Base class for groups of information messages in widgets"""
severity = 1
bar_background = "#ceceff"
bar_icon = QStyle.SP_MessageBoxInformation
def __init__(self):
super().__init__()
self.message_bar = None
self.messageActivated.connect(self.update_message_state)
self.messageDeactivated.connect(self.update_message_state)
def clear_messages(self):
"""Clear all messages"""
for group in self.message_groups:
group.clear()
def update_message_state(self):
"""Show and update (or hide) the content of the widget's message bar.
The method is connected to widget's signals `messageActivated` and
`messageDeactivated`.
"""
if self.message_bar is None:
return
assert isinstance(self.message_bar, MessagesWidget)
def msg(m):
# type: (_BoundMsg) -> MessagesWidget.Message
text = str(m)
extra = ""
if "\n" in text:
text, extra = text.split("\n", 1)
return MessagesWidget.Message(
MessagesWidget.Severity(m.group.severity),
text=text, informativeText=extra,
detailedText=m.tb if m.tb else ""
)
messages = [msg
for group in self.message_groups
for msg in group.active]
self.message_bar.clear()
if messages:
self.message_bar.setMessages((m, msg(m)) for m in messages)
self.message_bar.setVisible(bool(messages))
def insert_message_bar(self):
"""Insert message bar into the widget.
This method must be called at the appropriate place in the widget
layout setup by any widget that is using this mixin."""
self.message_bar = MessagesWidget(self)
self.message_bar.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Maximum)
self.layout().addWidget(self.message_bar)
self.message_bar.setVisible(False)
# pylint doesn't know that Information, Error and Warning are instantiated
# and thus the methods are bound
# pylint: disable=no-value-for-parameter
# This class and classes Information, Error and Warning are friends
# pylint: disable=protected-access
@staticmethod
def _warn_obsolete(text_or_id, what):
if not isinstance(text_or_id, str) and text_or_id is not None:
warn("'{}' with id is deprecated; use {} class".
format(what, what.title()), stacklevel=3)
def information(self, text_or_id=None, text=None, shown=True):
self._warn_obsolete(text_or_id, "information")
self.Information._add_general(text_or_id, text, shown)
def warning(self, text_or_id=None, text=None, shown=True):
self._warn_obsolete(text_or_id, "warning")
self.Warning._add_general(text_or_id, text, shown)
def error(self, text_or_id=None, text=None, shown=True):
self._warn_obsolete(text_or_id, "error")
self.Error._add_general(text_or_id, text, shown)