Source code for orangewidget.utils.signals

import copy
import itertools
import warnings
from collections import defaultdict
from functools import singledispatch
import inspect
from typing import (
    NamedTuple, Union, Optional, Iterable, Dict, Tuple, Any, Sequence,
    Callable
)

from AnyQt.QtCore import Qt, pyqtSignal, QPoint
from AnyQt.QtWidgets import QWidgetAction, QMenu, QWidget, QLabel, QVBoxLayout

from orangecanvas.registry.description import (
    InputSignal, OutputSignal, Single, Multiple, Default, NonDefault,
    Explicit, Dynamic
)
# imported here for easier use by widgets, pylint: disable=unused-import
from orangecanvas.scheme.signalmanager import LazyValue

from orangewidget.utils.messagewidget import MessagesWidget
from orangewidget.workflow.utils import WeakKeyDefaultDict


# increasing counter for ensuring the order of Input/Output definitions
# is preserved when going through the unordered class namespace of
# WidgetSignalsMixin.Inputs/Outputs.
_counter = itertools.count()


class PartialSummary(NamedTuple):
    summary: Union[None, str, int] = None
    details: Optional[str] = None
    preview_func: Optional[Callable[[], QWidget]] = None


def base_summarize(_) -> PartialSummary:
    return PartialSummary()


summarize = singledispatch(base_summarize)
summarize.__doc__ = """
Function for summarizing the input or output data.

The function must be decorated with `@summarize.register`. It accepts an
argument of arbitrary type and returns a `PartialSummary`, which is a tuple
consisting of two strings: a short summary (usually a number) and details.
"""


SUMMARY_STYLE = """
<style>
    ul {
        margin-left: 4px;
        margin-top: 2px;
        -qt-list-indent:1
    }

    li {
        margin-bottom: 3px;
    }

    th {
        text-align: right;
    }
</style>
"""


def can_summarize(type_, name, explicit):
    if explicit is not None:
        return explicit
    if not isinstance(type_, tuple):
        type_ = (type_, )
    instr = f"To silence this warning, set auto_summary of '{name}' to False."
    for a_type in type_:
        if isinstance(a_type, str):
            warnings.warn(
                f"Output is specified with qualified name ({a_type}). "
                "To enable auto summary, set auto_summary to True. "
                + instr, UserWarning)
            return False
        if summarize.dispatch(a_type) is base_summarize:
            warnings.warn(
                f"register 'summarize' function for type {a_type.__name__}. "
                + instr, UserWarning, stacklevel=4)
            return False
    return True


Closed = type(
    "Closed", (object,), {
        "__doc__": "Explicit connection closing sentinel.",
        "__repr__": lambda self: "Closed",
        "__str__": lambda self: "Closed",
    }
)()


class _Signal:
    @staticmethod
    def get_flags(multiple, default, explicit, dynamic):
        """Compute flags from arguments"""

        return (Multiple if multiple else Single) | \
                (Default if default else NonDefault) | \
                (explicit and Explicit) | \
                (dynamic and Dynamic)

    def bound_signal(self, widget):
        """
        Return a copy of the signal bound to a widget.

        Called from `WidgetSignalsMixin.__init__`
        """
        new_signal = copy.copy(self)
        new_signal.widget = widget
        return new_signal


def getsignals(signals_cls):
    # This function is preferred over getmembers because it returns the signals
    # in order of appearance
    return [(k, v)
            for cls in reversed(inspect.getmro(signals_cls))
            for k, v in cls.__dict__.items()
            if isinstance(v, _Signal)]


[docs] class Input(InputSignal, _Signal): """ Description of an input signal. The class is used to declare input signals for a widget as follows (the example is taken from the widget Test & Score):: class Inputs: train_data = Input("Data", Table, default=True) test_data = Input("Test Data", Table) learner = Input("Learner", Learner, multiple=True) preprocessor = Input("Preprocessor", Preprocess) Every input signal must be used to decorate exactly one method that serves as the input handler, for instance:: @Inputs.train_data def set_train_data(self, data): ... Parameters ---------- name (str): signal name type (type): signal type id (str): a unique id of the signal doc (str, optional): signal documentation replaces (list of str): a list with names of signals replaced by this signal multiple (bool, optional): if set, multiple signals can be connected to this output (default: `False`) default (bool, optional): when the widget accepts multiple signals of the same type, one of them can set this flag to act as the default (default: `False`) explicit (bool, optional): if set, this signal is only used when it is the only option or when explicitly connected in the dialog (default: `False`) auto_summary (bool, optional): by default, the input is reflected in widget's summary for all types with registered `summarize` function. This can be overridden by explicitly setting `auto_summary` to `False` or `True`. Explicitly setting this argument will also silence warnings for types without the summary function and for types defined with a fully qualified string instead of an actual type object. """ Closed = Closed def __init__(self, name, type, id=None, doc=None, replaces=None, *, multiple=False, default=False, explicit=False, auto_summary=None, closing_sentinel=None): flags = self.get_flags(multiple, default, explicit, False) super().__init__(name, type, "", flags, id, doc, replaces or []) self.auto_summary = can_summarize(type, name, auto_summary) self._seq_id = next(_counter) self.closing_sentinel = closing_sentinel def __call__(self, method): """ Decorator that stores decorated method's name in the signal's `handler` attribute. The method is returned unchanged. """ if self.flags & Multiple: def summarize_wrapper(widget, value, id=None): # If this method is overridden, don't summarize if summarize_wrapper is getattr(type(widget), method.__name__): widget.set_partial_input_summary( self.name, summarize(value), id=id) method(widget, value, id) else: def summarize_wrapper(widget, value): if summarize_wrapper is getattr(type(widget), method.__name__): widget.set_partial_input_summary( self.name, summarize(value)) method(widget, value) # Re-binding with the same name can happen in derived classes # We do not allow re-binding to a different name; for the same class # it wouldn't work, in derived class it could mislead into thinking # that the signal is passed to two different methods if self.handler and self.handler != method.__name__: raise ValueError("Input {} is already bound to method {}". format(self.name, self.handler)) self.handler = method.__name__ return summarize_wrapper if self.auto_summary else method
class MultiInput(Input): """ A special multiple input descriptor. This type of input has explicit set/insert/remove interface to maintain fully ordered sequence input. This should be preferred to the plain `Input(..., multiple=True)` descriptor. This input type must register three methods in the widget implementation class corresponding to the insert, set/update and remove input commands:: class Inputs: values = MultiInput("Values", object) ... @Inputs.values def set_value(self, index: int, value: object): "Set/update the value at index" ... @Inputs.values.insert def insert_value(self, index: int, value: object): "Insert value at specified index" ... @Inputs.values.remove def remove_value(self, index: int): "Remove value at index" ... Parameters ---------- filter_none: bool If `True` any `None` values sent by workflow execution are implicitly converted to 'remove' notifications. When the value again changes to non-None the input is re-inserted into its proper position. .. versionadded:: 4.13.0 """ insert_handler: str = None remove_handler: str = None def __init__(self, *args, filter_none=False, **kwargs): multiple = kwargs.pop("multiple", True) if not multiple: raise ValueError("multiple cannot be set to False") super().__init__(*args, multiple=True, **kwargs) self.filter_none = filter_none self.closing_sentinel = Closed __summary_ids_mapping = WeakKeyDefaultDict(dict) __id_gen = itertools.count() def __get_summary_ids(self, widget: 'WidgetSignalsMixin'): ids = self.__summary_ids_mapping[widget] return ids.setdefault(self.name, []) def __call__(self, method): def summarize_wrapper(widget, index, value): # If this method is overridden, don't summarize if summarize_wrapper is getattr(type(widget), method.__name__): ids = self.__get_summary_ids(widget) widget.set_partial_input_summary( self.name, summarize(value), id=ids[index], index=index) method(widget, index, value) _ = super().__call__(method) return summarize_wrapper if self.auto_summary else method def insert(self, method): """Register the method as the insert handler""" def summarize_wrapper(widget, index, value): if summarize_wrapper is getattr(type(widget), method.__name__): ids = self.__get_summary_ids(widget) ids.insert(index, next(self.__id_gen)) widget.set_partial_input_summary( self.name, summarize(value), id=ids[index], index=index) method(widget, index, value) self.insert_handler = method.__name__ return summarize_wrapper if self.auto_summary else method def remove(self, method): """"Register the method as the remove handler""" def summarize_wrapper(widget, index): if summarize_wrapper is getattr(type(widget), method.__name__): ids = self.__get_summary_ids(widget) id_ = ids.pop(index) widget.set_partial_input_summary( self.name, summarize(None), id=id_) method(widget, index) self.remove_handler = method.__name__ return summarize_wrapper if self.auto_summary else method def bound_signal(self, widget): if self.insert_handler is None: raise RuntimeError('insert_handler is not set') if self.remove_handler is None: raise RuntimeError('remove_handler is not set') return super().bound_signal(widget) _not_set = object() def _parse_call_id_arg(id=_not_set): if id is _not_set: return None else: warnings.warn( "`id` parameter is deprecated and will be removed in the " "future", FutureWarning, stacklevel=3, ) return id
[docs] class Output(OutputSignal, _Signal): """ Description of an output signal. The class is used to declare output signals for a widget as follows (the example is taken from the widget Test & Score):: class Outputs: predictions = Output("Predictions", Table) evaluations_results = Output("Evaluation Results", Results) The signal is then transmitted by, for instance:: self.Outputs.predictions.send(predictions) Parameters ---------- name (str): signal name type (type): signal type id (str): a unique id of the signal doc (str, optional): signal documentation replaces (list of str): a list with names of signals replaced by this signal default (bool, optional): when the widget accepts multiple signals of the same type, one of them can set this flag to act as the default (default: `False`) explicit (bool, optional): if set, this signal is only used when it is the only option or when explicitly connected in the dialog (default: `False`) dynamic (bool, optional): Specifies that the instances on the output will in general be subtypes of the declared type and that the output can be connected to any input signal which can accept a subtype of the declared output type (default: `True`) auto_summary (bool, optional): by default, the output is reflected in widget's summary for all types with registered `summarize` function. This can be overridden by explicitly setting `auto_summary` to `False` or `True`. Explicitly setting this argument will also silence warnings for types without the summary function and for types defined with a fully qualified string instead of an actual type object. """ def __init__(self, name, type, id=None, doc=None, replaces=None, *, default=False, explicit=False, dynamic=True, auto_summary=None): flags = self.get_flags(False, default, explicit, dynamic) super().__init__(name, type, flags, id, doc, replaces or []) self.auto_summary = can_summarize(type, name, auto_summary) self.widget = None self._seq_id = next(_counter) def send(self, value, *args, **kwargs): """Emit the signal through signal manager.""" assert self.widget is not None id = _parse_call_id_arg(*args, **kwargs) signal_manager = self.widget.signalManager if signal_manager is not None: if id is not None: extra_args = (id,) else: extra_args = () signal_manager.send(self.widget, self.name, value, *extra_args) if self.auto_summary: self.widget.set_partial_output_summary( self.name, summarize(value), id=id) def invalidate(self): """Invalidate the current output value on the signal""" assert self.widget is not None signal_manager = self.widget.signalManager if signal_manager is not None: signal_manager.invalidate(self.widget, self.name)
class WidgetSignalsMixin: """Mixin for managing widget's input and output signals""" class Inputs: pass class Outputs: pass def __init__(self): self.input_summaries = {} self.output_summaries: Dict[str, PartialSummary] = {} self._bind_signals() def _bind_signals(self): for direction, summaries in (("Inputs", self.input_summaries), ("Outputs", self.output_summaries)): bound_cls = getattr(self, direction) bound_signals = bound_cls() for name, signal in getsignals(bound_cls): setattr(bound_signals, name, signal.bound_signal(self)) if signal.auto_summary: summaries[signal.name] = {} setattr(self, direction, bound_signals) def send(self, signalName, value, *args, **kwargs): """ Send a `value` on the `signalName` widget output. An output with `signalName` must be defined in the class ``outputs`` list. """ id = _parse_call_id_arg(*args, **kwargs) if not any(s.name == signalName for s in self.outputs): raise ValueError('{} is not a valid output signal for widget {}'.format( signalName, self.name)) if self.signalManager is not None: if id is not None: extra_args = (id,) else: extra_args = () self.signalManager.send(self, signalName, value, *extra_args) def handleNewSignals(self): """ Invoked by the workflow signal propagation manager after all signals handlers have been called. Reimplement this method in order to coalesce updates from multiple updated inputs. """ pass # Methods used by the meta class @classmethod def convert_signals(cls): """ Maintenance and sanity checks for signals. - Convert tuple descriptions into old-style signals for backwards compatibility - For new-style in classes, copy attribute name to id, if id is not set explicitly - Check that all input signals have handlers - Check that the same name and/or does not refer to different signals. This method is called from the meta-class. """ def signal_from_args(args, signal_type): if isinstance(args, tuple): return signal_type(*args) elif isinstance(args, signal_type): return copy.copy(args) if hasattr(cls, "inputs") and cls.inputs: cls.inputs = [signal_from_args(input_, InputSignal) for input_ in cls.inputs] if hasattr(cls, "outputs") and cls.outputs: cls.outputs = [signal_from_args(output, OutputSignal) for output in cls.outputs] for direction in ("Inputs", "Outputs"): klass = getattr(cls, direction, None) if klass is None: continue for name, signal in klass.__dict__.items(): if isinstance(signal, (_Signal)) and signal.id is None: signal.id = name cls._check_input_handlers() cls._check_ids_unique() @classmethod def _check_input_handlers(cls): unbound = [signal.name for _, signal in getsignals(cls.Inputs) if not signal.handler] if unbound: raise ValueError("unbound signal(s) in {}: {}". format(cls.__name__, ", ".join(unbound))) missing_handlers = [signal.handler for signal in cls.inputs if not hasattr(cls, signal.handler)] if missing_handlers: raise ValueError("missing handlers in {}: {}". format(cls.__name__, ", ".join(missing_handlers))) @classmethod def _check_ids_unique(cls): for direction in ("input", "output"): # Collect signals by name and by id, check for duplicates by_name = {} by_id = {} for signal in cls.get_signals(direction + "s"): if signal.name in by_name: raise RuntimeError( f"Name {signal.name} refers to different {direction} " f"signals of {cls.__name__}" ) by_name[signal.name] = signal if signal.id is not None: if signal.id in by_id: raise RuntimeError( f"Id {signal.id} refers to different {direction} " f"signals of {cls.__name__}" ) by_id[signal.id] = signal # Warn the same name and id refer to different signal for name in set(by_name) & set(by_id): if by_name[name] is not by_id[name]: warnings.warn( f"{name} appears as a name and an id of two different " f"{direction} signals in {cls.__name__}") @classmethod def get_signals(cls, direction, ignore_old_style=False): """ Return a list of `InputSignal` or `OutputSignal` needed for the widget description. For old-style signals, the method returns the original list. New-style signals are collected into a list. Parameters ---------- direction (str): `"inputs"` or `"outputs"` Returns ------- list of `InputSignal` or `OutputSignal` """ old_style = cls.__dict__.get(direction, None) if old_style and not ignore_old_style: return old_style signal_class = getattr(cls, direction.title()) signals = [signal for _, signal in getsignals(signal_class)] return list(sorted(signals, key=lambda s: s._seq_id)) def update_summaries(self): self._update_summary(self.input_summaries) self._update_summary(self.output_summaries) def set_partial_input_summary(self, name, partial_summary, *, id=None, index=None): self.__set_part_summary(self.input_summaries[name], id, partial_summary, index=index) self._update_summary(self.input_summaries) def set_partial_output_summary(self, name, partial_summary, *, id=None): self.__set_part_summary(self.output_summaries[name], id, partial_summary) self._update_summary(self.output_summaries) @staticmethod def __set_part_summary(summary, id, partial_summary, index=None): if partial_summary.summary is None: if id in summary: del summary[id] else: if index is None or id in summary: summary[id] = partial_summary else: # Insert inplace at specified index items = list(summary.items()) items.insert(index, (id, partial_summary)) summary.clear() summary.update(items) def _update_summary(self, summaries): from orangewidget.widget import StateInfo def format_short(partial): summary = partial.summary if summary is None: return "-" if isinstance(summary, int): return StateInfo.format_number(summary) if isinstance(summary, str): return summary raise ValueError("summary must be None, string or int; " f"got {type(summary).__name__}") def format_detail(partial): if partial.summary is None: return "-" return str(partial.details or partial.summary) def join_multiples(partials): if not partials: return "-", "-" shorts = " ".join(map(format_short, partials.values())) details = "<br/>".join(format_detail(partial) for partial in partials.values()) return shorts, details info = self.info is_input = summaries is self.input_summaries assert is_input or summaries is self.output_summaries if not summaries: return if not any(summaries.values()): summary = info.NoInput if is_input else info.NoOutput detail = "" else: summary, details = zip(*map(join_multiples, summaries.values())) summary = " | ".join(summary) detail = "<hr/><table>" \ + "".join(f"<tr><th><nobr>{name}</nobr>: " f"</th><td>{detail}</td></tr>" for name, detail in zip(summaries, details)) \ + "</table>" setter = info.set_input_summary if is_input else info.set_output_summary if detail: setter(summary, SUMMARY_STYLE + detail, format=Qt.RichText) else: setter(summary) def show_preview(self, summaries): view = QWidget(self) view.setLayout(QVBoxLayout()) for name, summary in summaries.items(): if not summary: view.layout().addWidget(QLabel("<hr/><table>" f"<tr><th><nobr>{name}</nobr>: " f"</th><td>-</td></tr>" "</table>")) for i, part in enumerate(summary.values(), start=1): part_no = f" ({i})" if len(summary) > 1 else "" detail = str(part.details or part.summary) or "-" view.layout().addWidget( QLabel("<hr/><table>" f"<tr><th><nobr>{name}{part_no}</nobr>: " f"</th><td>{detail}</td></tr>" "</table>") ) if part.preview_func: preview = part.preview_func() view.layout().addWidget(preview, 1) if view.layout().isEmpty(): return view.layout().addStretch() screen = self.windowHandle().screen() geometry = screen.availableGeometry() preview = QMenu(self) wid, hei = geometry.width(), geometry.height() preview.setFixedSize(wid // 2, hei // 2) view.setFixedSize(wid // 2 - 4, hei // 2 - 4) action = QWidgetAction(preview) action.setDefaultWidget(view) preview.addAction(action) preview.popup(QPoint(wid // 4, hei // 4), action) def get_input_meta(widget: WidgetSignalsMixin, name: str) -> Optional[Input]: """ Return the named input meta description from widget (if it exists). """ def as_input(obj): if isinstance(obj, Input): return obj elif isinstance(obj, InputSignal): rval = Input(obj.name, obj.type, obj.id, obj.doc, obj.replaces, multiple=not obj.single, default=obj.default, explicit=obj.explicit) rval.handler = obj.handler return rval elif isinstance(obj, tuple): return as_input(InputSignal(*obj)) else: raise TypeError inputs: Iterable[Input] = map(as_input, widget.get_signals("inputs")) for input_ in inputs: if input_.name == name: return input_ return None def get_widget_inputs( widget: WidgetSignalsMixin ) -> Dict[str, Sequence[Tuple[Any, Any]]]: state: Dict[str, Sequence[Tuple[Any, Any]]] state = widget.__dict__.setdefault( "_WidgetSignalsMixin__input_state", {} ) return state @singledispatch def notify_input_helper( input: Input, widget: WidgetSignalsMixin, obj, key=None, index=-1 ) -> None: """ Set the input to the `widget` in a way appropriate for the `input` type. """ raise NotImplementedError @notify_input_helper.register(Input) def set_input_helper( input: Input, widget: WidgetSignalsMixin, obj, key=None, index=-1 ): handler = getattr(widget, input.handler) if input.single: args = (obj,) else: args = (obj, key) handler(*args) @notify_input_helper.register(MultiInput) def set_multi_input_helper( input: MultiInput, widget: WidgetSignalsMixin, obj, key=None, index=-1, ): """ Set/update widget's input for a `MultiInput` input to obj. `key` must be a unique for an input slot to update. `index` defines the position where a new input (key that did not previously exist) is inserted. The default -1 indicates that the new input should be appended to the end. An input is removed by using inout.closing_sentinel as the obj. """ inputs_ = get_widget_inputs(widget) inputs = inputs_.setdefault(input.name, ()) filter_none = input.filter_none signal_old = None key_to_pos = {key: i for i, (key, _) in enumerate(inputs)} update = key in key_to_pos new = key not in key_to_pos remove = obj is input.closing_sentinel if new: if not 0 <= index < len(inputs): index = len(inputs) else: index = key_to_pos.get(key) assert index is not None inputs_updated = list(inputs) if new: inputs_updated.insert(index, (key, obj)) elif remove: signal_old = inputs_updated.pop(index) else: signal_old = inputs_updated[index] inputs_updated[index] = (key, obj) inputs_[input.name] = tuple(inputs_updated) if filter_none: def filter_f(obj): return obj is None else: filter_f = None def local_index( key: Any, inputs: Sequence[Tuple[Any, Any]], filter: Optional[Callable[[Any], bool]] = None, ) -> Optional[int]: i = 0 for k, obj in inputs: if key == k: return i elif filter is not None: i += int(not filter(obj)) else: i += 1 return None if filter_none: # normalize signal.value is None to Close signal. filtered = filter_f(obj) if new and filtered: # insert in inputs only (done above) return elif new: # Some inputs before this might be filtered invalidating the # effective index. Find appropriate index for insertion index = len([obj for _, obj in inputs[:index] if not filter_f(obj)]) elif remove: if filter_f(signal_old[1]): # was already notified as removed, only remove from inputs (done above) return else: index = local_index(key, inputs, filter_f) elif update and filtered: if filter_f(signal_old[1]): # did not change; remains filtered return else: # remove it remove = True new = False index = local_index(key, inputs, filter_f) assert index is not None elif update: index = local_index(key, inputs, filter_f) if signal_old is not None and filter_f(signal_old[1]) and not filtered: # update with non-none value, substitute as new signal new = True remove = False index = local_index(key, inputs, filter_f) if new: handler = input.insert_handler args = (index, obj) elif remove: handler = input.remove_handler args = (index, ) else: handler = input.handler args = (index, obj) assert index is not None handler = getattr(widget, handler) handler(*args)