diff --git a/Orange/widgets/data/owconcatenate.py b/Orange/widgets/data/owconcatenate.py index 3ecc73681ba..3bd8cbae6f0 100644 --- a/Orange/widgets/data/owconcatenate.py +++ b/Orange/widgets/data/owconcatenate.py @@ -5,11 +5,10 @@ Concatenate (append) two or more datasets. """ - from collections import OrderedDict, namedtuple, defaultdict from functools import reduce from itertools import chain, count -from typing import List +from typing import List, Optional, Sequence import numpy as np from AnyQt.QtWidgets import QFormLayout @@ -21,9 +20,9 @@ from Orange.widgets import widget, gui, settings from Orange.widgets.settings import Setting from Orange.widgets.utils.annotated_data import add_columns -from Orange.widgets.utils.sql import check_sql_input +from Orange.widgets.utils.sql import check_sql_input, check_sql_input_sequence from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import Input, Output, Msg +from Orange.widgets.widget import Input, MultiInput, Output, Msg class OWConcatenate(widget.OWWidget): @@ -35,10 +34,9 @@ class OWConcatenate(widget.OWWidget): class Inputs: primary_data = Input("Primary Data", Orange.data.Table) - additional_data = Input("Additional Data", - Orange.data.Table, - multiple=True, - default=True) + additional_data = MultiInput( + "Additional Data", Orange.data.Table, default=True + ) class Outputs: data = Output("Data", Orange.data.Table) @@ -86,7 +84,7 @@ def __init__(self): super().__init__() self.primary_data = None - self.more_data = OrderedDict() + self._more_data_input: List[Optional[Orange.data.Table]] = [] self.mergebox = gui.vBox(self.controlArea, "Variable Merging") box = gui.radioButtons( @@ -158,12 +156,22 @@ def set_primary_data(self, data): self.primary_data = data @Inputs.additional_data - @check_sql_input - def set_more_data(self, data=None, sig_id=None): - if data is not None: - self.more_data[sig_id] = data - elif sig_id in self.more_data: - del self.more_data[sig_id] + @check_sql_input_sequence + def set_more_data(self, index, data): + self._more_data_input[index] = data + + @Inputs.additional_data.insert + @check_sql_input_sequence + def insert_more_data(self, index, data): + self._more_data_input.insert(index, data) + + @Inputs.additional_data.remove + def remove_more_data(self, index): + self._more_data_input.pop(index) + + @property + def more_data(self) -> Sequence[Orange.data.Table]: + return [t for t in self._more_data_input if t is not None] def handleNewSignals(self): self.mergebox.setDisabled(self.primary_data is not None) @@ -177,8 +185,8 @@ def incompatible_types(self): types_ = set() if self.primary_data is not None: types_.add(type(self.primary_data)) - for key in self.more_data: - types_.add(type(self.more_data[key])) + for table in self.more_data: + types_.add(type(table)) if len(types_) > 1: return True @@ -188,13 +196,13 @@ def apply(self): self.Warning.renamed_variables.clear() tables, domain, source_var = [], None, None if self.primary_data is not None: - tables = [self.primary_data] + list(self.more_data.values()) + tables = [self.primary_data] + list(self.more_data) domain = self.primary_data.domain elif self.more_data: if self.ignore_compute_value: tables = self._dumb_tables() else: - tables = self.more_data.values() + tables = self.more_data domains = [table.domain for table in tables] domain = self.merge_domains(domains) @@ -230,7 +238,7 @@ def enumerated_parts(domain): return enumerate((domain.attributes, domain.class_vars, domain.metas)) compute_value_groups = defaultdict(set) - for table in self.more_data.values(): + for table in self.more_data: for part, part_vars in enumerated_parts(table.domain): for var in part_vars: desc = (var.name, type(var), part) @@ -240,7 +248,7 @@ def enumerated_parts(domain): if len(compute_values) > 1} dumb_tables = [] - for table in self.more_data.values(): + for table in self.more_data: dumb_domain = Orange.data.Domain( *[[var.copy(compute_value=None) if (var.name, type(var), part) in to_dumbify @@ -352,5 +360,5 @@ def _unique_vars(seq: List[Orange.data.Variable]): if __name__ == "__main__": # pragma: no cover WidgetPreview(OWConcatenate).run( - set_more_data=[(Orange.data.Table("iris"), 0), - (Orange.data.Table("zoo"), 1)]) + insert_more_data=[(0, Orange.data.Table("iris")), + (1, Orange.data.Table("zoo"))]) diff --git a/Orange/widgets/data/owpythonscript.py b/Orange/widgets/data/owpythonscript.py index 656adeed74d..fe527a37170 100644 --- a/Orange/widgets/data/owpythonscript.py +++ b/Orange/widgets/data/owpythonscript.py @@ -35,7 +35,7 @@ from Orange.widgets.utils import itemmodels from Orange.widgets.settings import Setting from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import OWWidget, Input, Output +from Orange.widgets.widget import OWWidget, MultiInput, Output if TYPE_CHECKING: from typing_extensions import TypedDict @@ -526,14 +526,18 @@ class OWPythonScript(OWWidget): keywords = ["program", "function"] class Inputs: - data = Input("Data", Table, replaces=["in_data"], - default=True, multiple=True) - learner = Input("Learner", Learner, replaces=["in_learner"], - default=True, multiple=True) - classifier = Input("Classifier", Model, replaces=["in_classifier"], - default=True, multiple=True) - object = Input("Object", object, replaces=["in_object"], - default=False, multiple=True) + data = MultiInput( + "Data", Table, replaces=["in_data"], default=True + ) + learner = MultiInput( + "Learner", Learner, replaces=["in_learner"], default=True + ) + classifier = MultiInput( + "Classifier", Model, replaces=["in_classifier"], default=True + ) + object = MultiInput( + "Object", object, replaces=["in_object"], default=False + ) class Outputs: data = Output("Data", Table, replaces=["out_data"]) @@ -562,7 +566,7 @@ def __init__(self): super().__init__() for name in self.signal_names: - setattr(self, name, {}) + setattr(self, name, []) self.splitCanvas = QSplitter(Qt.Vertical, self.mainArea) self.mainArea.layout().addWidget(self.splitCanvas) @@ -779,29 +783,65 @@ def _saveState(self): self.scriptText = self.text.toPlainText() self.splitterState = bytes(self.splitCanvas.saveState()) - def handle_input(self, obj, sig_id, signal): + def set_input(self, index, obj, signal): dic = getattr(self, signal) - if obj is None: - if sig_id in dic.keys(): - del dic[sig_id] - else: - dic[sig_id] = obj + dic[index] = obj + + def insert_input(self, index, obj, signal): + dic = getattr(self, signal) + dic.insert(index, obj) + + def remove_input(self, index, signal): + dic = getattr(self, signal) + dic.pop(index) @Inputs.data - def set_data(self, data, sig_id): - self.handle_input(data, sig_id, "data") + def set_data(self, index, data): + self.set_input(index, data, "data") + + @Inputs.data.insert + def insert_data(self, index, data): + self.insert_input(index, data, "data") + + @Inputs.data.remove + def remove_data(self, index): + self.remove_input(index, "data") @Inputs.learner - def set_learner(self, data, sig_id): - self.handle_input(data, sig_id, "learner") + def set_learner(self, index, learner): + self.set_input(index, learner, "learner") + + @Inputs.learner.insert + def insert_learner(self, index, learner): + self.insert_input(index, learner, "learner") + + @Inputs.learner.remove + def remove_learner(self, index): + self.remove_input(index, "learner") @Inputs.classifier - def set_classifier(self, data, sig_id): - self.handle_input(data, sig_id, "classifier") + def set_classifier(self, index, classifier): + self.set_input(index, classifier, "classifier") + + @Inputs.classifier.insert + def insert_classifier(self, index, classifier): + self.insert_input(index, classifier, "classifier") + + @Inputs.classifier.remove + def remove_classifier(self, index): + self.remove_input(index, "classifier") @Inputs.object - def set_object(self, data, sig_id): - self.handle_input(data, sig_id, "object") + def set_object(self, index, object): + self.set_input(index, object, "object") + + @Inputs.object.insert + def insert_object(self, index, object): + self.insert_input(index, object, "object") + + @Inputs.object.remove + def remove_object(self, index): + self.remove_input(index, "object") def handleNewSignals(self): # update fake signature labels @@ -925,7 +965,7 @@ def initial_locals_state(self): d = {} for name in self.signal_names: value = getattr(self, name) - all_values = list(value.values()) + all_values = list(value) one_value = all_values[0] if len(all_values) == 1 else None d["in_" + name + "s"] = all_values d["in_" + name] = one_value diff --git a/Orange/widgets/data/owrank.py b/Orange/widgets/data/owrank.py index 4b746473989..85c71b05d06 100644 --- a/Orange/widgets/data/owrank.py +++ b/Orange/widgets/data/owrank.py @@ -1,6 +1,6 @@ import logging import warnings -from collections import OrderedDict, namedtuple +from collections import namedtuple from functools import partial from itertools import chain from types import SimpleNamespace @@ -33,7 +33,7 @@ from Orange.widgets.utils.itemmodels import PyTableModel from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import AttributeList, Input, Msg, Output, OWWidget +from Orange.widgets.widget import AttributeList, Input, MultiInput, Output, Msg, OWWidget log = logging.getLogger(__name__) @@ -256,7 +256,7 @@ class OWRank(OWWidget, ConcurrentWidgetMixin): class Inputs: data = Input("Data", Table) - scorer = Input("Scorer", score.Scorer, multiple=True) + scorer = MultiInput("Scorer", score.Scorer, filter_none=True) class Outputs: reduced_data = Output("Reduced Data", Table, default=True) @@ -292,7 +292,7 @@ class Warning(OWWidget.Warning): def __init__(self): OWWidget.__init__(self) ConcurrentWidgetMixin.__init__(self) - self.scorers = OrderedDict() + self.scorers: List[ScoreMeta] = [] self.out_domain_desc = None self.data = None self.problem_type_mode = ProblemType.CLASSIFICATION @@ -440,18 +440,27 @@ def handleNewSignals(self): self.on_select() @Inputs.scorer - def set_learner(self, scorer, id): # pylint: disable=redefined-builtin - if scorer is None: - self.scorers.pop(id, None) - else: - # Avoid caching a (possibly stale) previous instance of the same - # Scorer passed via the same signal - if id in self.scorers: - self.scorers_results = {} + def set_learner(self, index, scorer): + self.scorers[index] = ScoreMeta( + scorer.name, scorer.name, scorer, + ProblemType.from_variable(scorer.class_type), + False + ) + self.scorers_results = {} - self.scorers[id] = ScoreMeta(scorer.name, scorer.name, scorer, - ProblemType.from_variable(scorer.class_type), - False) + @Inputs.scorer.insert + def insert_learner(self, index: int, scorer): + self.scorers.insert(index, ScoreMeta( + scorer.name, scorer.name, scorer, + ProblemType.from_variable(scorer.class_type), + False + )) + self.scorers_results = {} + + @Inputs.scorer.remove + def remove_learner(self, index): + self.scorers.pop(index) + self.scorers_results = {} def _get_methods(self): return [ @@ -469,7 +478,7 @@ def _get_methods(self): def _get_scorers(self): scorers = [] - for scorer in self.scorers.values(): + for scorer in self.scorers: if scorer.problem_type in ( self.problem_type_mode, ProblemType.UNSUPERVISED, diff --git a/Orange/widgets/data/owtable.py b/Orange/widgets/data/owtable.py index c8af2dacf4e..f8fa6b302af 100644 --- a/Orange/widgets/data/owtable.py +++ b/Orange/widgets/data/owtable.py @@ -3,7 +3,8 @@ import itertools import concurrent.futures -from collections import OrderedDict, namedtuple +from collections import namedtuple +from typing import List, Optional from math import isnan @@ -37,7 +38,7 @@ from Orange.widgets.utils.tableview import TableView, \ table_selection_to_mime_data from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import OWWidget, Input, Output +from Orange.widgets.widget import OWWidget, MultiInput, Output from Orange.widgets.utils import datacaching from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) @@ -183,7 +184,7 @@ class OWDataTable(OWWidget): keywords = [] class Inputs: - data = Input("Data", Table, multiple=True, auto_summary=False) + data = MultiInput("Data", Table, auto_summary=False, filter_none=True) class Outputs: selected_data = Output("Selected Data", Table, default=True) @@ -205,9 +206,7 @@ class Outputs: def __init__(self): super().__init__() - - self._inputs = OrderedDict() - + self._inputs: List[TableSlot] = [] self.__pending_selected_rows = self.selected_rows self.selected_rows = None self.__pending_selected_cols = self.selected_cols @@ -256,79 +255,90 @@ def copy_to_clipboard(self): def sizeHint(): return QSize(800, 500) + def _create_table_view(self): + view = DataTableView() + view.setSortingEnabled(True) + view.setItemDelegate(TableDataDelegate(view)) + + if self.select_rows: + view.setSelectionBehavior(QTableView.SelectRows) + + header = view.horizontalHeader() + header.setSectionsMovable(True) + header.setSectionsClickable(True) + header.setSortIndicatorShown(True) + header.setSortIndicator(-1, Qt.AscendingOrder) + + # QHeaderView does not 'reset' the model sort column, + # because there is no guaranty (requirement) that the + # models understand the -1 sort column. + def sort_reset(index, order): + if view.model() is not None and index == -1: + view.model().sort(index, order) + header.sortIndicatorChanged.connect(sort_reset) + return view + @Inputs.data - def set_dataset(self, data, tid=None): + def set_dataset(self, index: int, data: Table): """Set the input dataset.""" - if data is not None: - datasetname = getattr(data, "name", "Data") - if tid in self._inputs: - # update existing input slot - slot = self._inputs[tid] - view = slot.view - # reset the (header) view state. - view.setModel(None) - view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) - assert self.tabs.indexOf(view) != -1 - self.tabs.setTabText(self.tabs.indexOf(view), datasetname) - else: - view = DataTableView() - view.setSortingEnabled(True) - view.setItemDelegate(TableDataDelegate(view)) - - if self.select_rows: - view.setSelectionBehavior(QTableView.SelectRows) - - header = view.horizontalHeader() - header.setSectionsMovable(True) - header.setSectionsClickable(True) - header.setSortIndicatorShown(True) - header.setSortIndicator(-1, Qt.AscendingOrder) - - # QHeaderView does not 'reset' the model sort column, - # because there is no guaranty (requirement) that the - # models understand the -1 sort column. - def sort_reset(index, order): - if view.model() is not None and index == -1: - view.model().sort(index, order) - - header.sortIndicatorChanged.connect(sort_reset) - self.tabs.addTab(view, datasetname) - - view.dataset = data - self.tabs.setCurrentWidget(view) - - self._setup_table_view(view, data) - slot = TableSlot(tid, data, table_summary(data), view) - view.input_slot = slot - self._inputs[tid] = slot - - self.tabs.setCurrentIndex(self.tabs.indexOf(view)) - - self._set_input_summary(slot) - - if isinstance(slot.summary.len, concurrent.futures.Future): - def update(_): - QMetaObject.invokeMethod( - self, "_update_info", Qt.QueuedConnection) - - slot.summary.len.add_done_callback(update) - - elif tid in self._inputs: - slot = self._inputs.pop(tid) - view = slot.view - view.hide() - view.deleteLater() - self.tabs.removeTab(self.tabs.indexOf(view)) - - current = self.tabs.currentWidget() - if current is not None: - self._set_input_summary(current.input_slot) - else: - self._set_input_summary(None) + datasetname = getattr(data, "name", "Data") + slot = self._inputs[index] + view = slot.view + # reset the (header) view state. + view.setModel(None) + view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) + assert self.tabs.indexOf(view) != -1 + self.tabs.setTabText(self.tabs.indexOf(view), datasetname) + view.dataset = data + slot = TableSlot(index, data, table_summary(data), view) + view.input_slot = slot + self._inputs[index] = slot + self._setup_table_view(view, data) + self.tabs.setCurrentWidget(view) + + @Inputs.data.insert + def insert_dataset(self, index: int, data: Table): + datasetname = getattr(data, "name", "Data") + view = self._create_table_view() + slot = TableSlot(None, data, table_summary(data), view) + view.dataset = data + view.input_slot = slot + self._inputs.insert(index, slot) + self.tabs.insertTab(index, view, datasetname) + self._setup_table_view(view, data) + self.tabs.setCurrentWidget(view) + + @Inputs.data.remove + def remove_dataset(self, index): + slot = self._inputs.pop(index) + view = slot.view + self.tabs.removeTab(self.tabs.indexOf(view)) + view.setModel(None) + view.hide() + view.deleteLater() - self.tabs.tabBar().setVisible(self.tabs.count() > 1) + current = self.tabs.currentWidget() + if current is not None: + self._set_input_summary(current.input_slot) - if data and self.__pending_selected_rows is not None: + def handleNewSignals(self): + super().handleNewSignals() + self.tabs.tabBar().setVisible(self.tabs.count() > 1) + data: Optional[Table] = None + current = self.tabs.currentWidget() + slot = None + if current is not None: + data = current.dataset + slot = current.input_slot + + if slot and isinstance(slot.summary.len, concurrent.futures.Future): + def update(_): + QMetaObject.invokeMethod( + self, "_update_info", Qt.QueuedConnection) + slot.summary.len.add_done_callback(update) + self._set_input_summary(slot) + + if data is not None and self.__pending_selected_rows is not None: self.selected_rows = self.__pending_selected_rows self.__pending_selected_rows = None else: @@ -346,12 +356,7 @@ def update(_): def _setup_table_view(self, view, data): """Setup the `view` (QTableView) with `data` (Orange.data.Table) """ - if data is None: - view.setModel(None) - return - datamodel = RichTableModel(data) - rowcount = data.approx_len() if self.color_by_class and data.domain.has_discrete_class: @@ -849,6 +854,8 @@ def is_sortable(table): if __name__ == "__main__": # pragma: no cover WidgetPreview(OWDataTable).run( - [(Table("iris"), "iris"), - (Table("brown-selected"), "brown-selected"), - (Table("housing"), "housing")]) + insert_dataset=[ + (0, Table("iris")), + (1, Table("brown-selected")), + (2, Table("housing")) + ]) diff --git a/Orange/widgets/data/tests/test_owconcatenate.py b/Orange/widgets/data/tests/test_owconcatenate.py index 25a0fa56089..f81b6cf41c6 100644 --- a/Orange/widgets/data/tests/test_owconcatenate.py +++ b/Orange/widgets/data/tests/test_owconcatenate.py @@ -4,6 +4,7 @@ from unittest.mock import patch, Mock import numpy as np +from numpy.testing import assert_array_equal from Orange.data import ( Table, Domain, ContinuousVariable, DiscreteVariable, StringVariable @@ -493,6 +494,27 @@ def test_ignore_compute_value(self): self.assertEqual(len(output.domain.metas), 1) self.assertIs(output.domain.metas[0].compute_value.variable, ma4) # renamed + def test_explicit_closing(self): + w = self.widget + self.send_signal(w.Inputs.additional_data, self.iris[:1], 0) + self.send_signal(w.Inputs.additional_data, self.iris[1:2], 1) + self.send_signal(w.Inputs.additional_data, self.iris[2:3], 2) + + def assert_output_equal(expected: np.ndarray): + out = self.get_output(w.Outputs.data) + assert_array_equal(out.X, expected) + + assert_output_equal(self.iris[:3].X) + self.send_signal(w.Inputs.additional_data, None, 1) + assert_output_equal(self.iris[:3:2].X) + self.send_signal(w.Inputs.additional_data, self.iris[1:2], 1) + assert_output_equal(self.iris[:3].X) + self.send_signal(w.Inputs.additional_data, + w.Inputs.additional_data.closing_sentinel, 1) + assert_output_equal(self.iris[:3:2].X) + self.send_signal(w.Inputs.additional_data, self.iris[1:2], 1) + assert_output_equal(np.vstack((self.iris[:3:2].X, self.iris[1:2].X))) + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/data/tests/test_owpythonscript.py b/Orange/widgets/data/tests/test_owpythonscript.py index 6158e372b5d..b754ea10573 100644 --- a/Orange/widgets/data/tests/test_owpythonscript.py +++ b/Orange/widgets/data/tests/test_owpythonscript.py @@ -13,7 +13,7 @@ from Orange.widgets.data.owpythonscript import OWPythonScript, \ read_file_content, Script, OWPythonScriptDropHandler from Orange.widgets.tests.base import WidgetTest -from Orange.widgets.widget import OWWidget +from Orange.widgets.widget import OWWidget, Input # import tests for python editor from Orange.widgets.data.utils.pythoneditor.tests.test_api import * @@ -44,11 +44,13 @@ def test_inputs(self): ("Learner", self.learner), ("Classifier", self.model), ("Object", "object")): - self.assertEqual(getattr(self.widget, input_.lower()), {}) + self.assertEqual(getattr(self.widget, input_.lower()), []) self.send_signal(input_, data, 1) - self.assertEqual(getattr(self.widget, input_.lower()), {1: data}) + self.assertEqual(getattr(self.widget, input_.lower()), [data]) self.send_signal(input_, None, 1) - self.assertEqual(getattr(self.widget, input_.lower()), {}) + self.assertEqual(getattr(self.widget, input_.lower()), [None]) + self.send_signal(input_, Input.Closed, 1) + self.assertEqual(getattr(self.widget, input_.lower()), []) def test_outputs(self): """Check widget's outputs""" @@ -126,12 +128,19 @@ def test_multiple_signals(self): self.send_signal("Data", None, 2) click() + datas = console_locals["in_datas"] + self.assertEqual(len(datas), 2) + self.assertIs(datas[0], self.iris) + self.assertIs(datas[1], None) + + self.send_signal("Data", Input.Closed, 2) + click() self.assertIs(console_locals["in_data"], self.iris) datas = console_locals["in_datas"] self.assertEqual(len(datas), 1) self.assertIs(datas[0], self.iris) - self.send_signal("Data", None, 1) + self.send_signal("Data", Input.Closed, 1) click() self.assertIsNone(console_locals["in_data"]) self.assertEqual(console_locals["in_datas"], []) diff --git a/Orange/widgets/data/tests/test_owrank.py b/Orange/widgets/data/tests/test_owrank.py index c015f313dd3..96d23f5ef2a 100644 --- a/Orange/widgets/data/tests/test_owrank.py +++ b/Orange/widgets/data/tests/test_owrank.py @@ -59,16 +59,16 @@ def test_input_data_disconnect(self): def test_input_scorer(self): """Check widget's scorer with scorer on the input""" - self.assertEqual(self.widget.scorers, {}) + self.assertEqual(self.widget.scorers, []) self.send_signal(self.widget.Inputs.scorer, self.log_reg, 1) self.wait_until_finished() - value = self.widget.scorers[1] + value = self.widget.scorers[0] self.assertEqual(self.log_reg, value.scorer) self.assertIsInstance(value.scorer, Scorer) def test_input_scorer_fitter(self): heart_disease = Table('heart_disease') - self.assertEqual(self.widget.scorers, {}) + self.assertEqual(self.widget.scorers, []) model = self.widget.ranksModel @@ -94,14 +94,14 @@ def test_input_scorer_fitter(self): self.assertIn(name, last_column) self.send_signal("Scorer", None, 1) - self.assertEqual(self.widget.scorers, {}) + self.assertEqual(self.widget.scorers, []) def test_input_scorer_disconnect(self): """Check widget's scorer after disconnecting scorer on the input""" self.send_signal(self.widget.Inputs.scorer, self.log_reg, 1) self.assertEqual(len(self.widget.scorers), 1) self.send_signal(self.widget.Inputs.scorer, None, 1) - self.assertEqual(self.widget.scorers, {}) + self.assertEqual(self.widget.scorers, []) def test_output_data(self): """Check data on the output after apply""" diff --git a/Orange/widgets/evaluate/owpredictions.py b/Orange/widgets/evaluate/owpredictions.py index f91ae6f92b0..dc613c5befb 100644 --- a/Orange/widgets/evaluate/owpredictions.py +++ b/Orange/widgets/evaluate/owpredictions.py @@ -1,9 +1,8 @@ -from collections import namedtuple from contextlib import contextmanager from functools import partial from operator import itemgetter from itertools import chain -from typing import Set, List, Sequence, Union +from typing import Set, Sequence, Union, Optional, List, NamedTuple import numpy from AnyQt.QtWidgets import ( @@ -28,7 +27,7 @@ from Orange.widgets.evaluate.utils import ( ScoreTable, usable_scorers, learner_name, scorer_caller) from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import OWWidget, Msg, Input, Output +from Orange.widgets.widget import OWWidget, Msg, Input, Output, MultiInput from Orange.widgets.utils.itemmodels import TableModel from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.state_summary import format_summary_details @@ -36,12 +35,12 @@ from Orange.widgets.utils.itemdelegates import TableDataDelegate # Input slot for the Predictors channel -PredictorSlot = namedtuple( - "PredictorSlot", - ["predictor", # The `Model` instance - "name", # Predictor name - "results"] # Computed prediction results or None. -) +PredictorSlot = NamedTuple( + "PredictorSlot", [ + ("predictor", Model), # The `Model` instance + ("name", str), # Predictor name + ("results", Optional[Results]), # Computed prediction results or None. +]) class OWPredictions(OWWidget): @@ -55,7 +54,7 @@ class OWPredictions(OWWidget): class Inputs: data = Input("Data", Orange.data.Table) - predictors = Input("Predictors", Model, multiple=True) + predictors = MultiInput("Predictors", Model, filter_none=True) class Outputs: predictions = Output("Predictions", Orange.data.Table) @@ -81,7 +80,7 @@ def __init__(self): super().__init__() self.data = None # type: Optional[Orange.data.Table] - self.predictors = {} # type: Dict[object, PredictorSlot] + self.predictors = [] # type: List[PredictorSlot] self.class_values = [] # type: List[str] self._delegates = [] self.left_width = 10 @@ -186,21 +185,25 @@ def _store_selection(self): def class_var(self): return self.data and self.data.domain.class_var - # pylint: disable=redefined-builtin @Inputs.predictors - def set_predictor(self, predictor=None, id=None): - if id in self.predictors: - if predictor is not None: - self.predictors[id] = self.predictors[id]._replace( - predictor=predictor, name=predictor.name, results=None) - else: - del self.predictors[id] - elif predictor is not None: - self.predictors[id] = PredictorSlot(predictor, predictor.name, None) + def set_predictor(self, index, predictor: Model): + item = self.predictors[index] + self.predictors[index] = item._replace( + predictor=predictor, name=predictor.name, results=None + ) + + @Inputs.predictors.insert + def insert_predictor(self, index, predictor: Model): + item = PredictorSlot(predictor, predictor.name, None) + self.predictors.insert(index, item) + + @Inputs.predictors.remove + def remove_predictor(self, index): + self.predictors.pop(index) def _set_class_values(self): class_values = [] - for slot in self.predictors.values(): + for slot in self.predictors: class_var = slot.predictor.domain.class_var if class_var and class_var.is_discrete: for value in class_var.values: @@ -236,7 +239,7 @@ def _call_predictors(self): else: classless_data = self.data - for inputid, slot in self.predictors.items(): + for index, slot in enumerate(self.predictors): if isinstance(slot.results, Results): continue @@ -248,7 +251,7 @@ def _call_predictors(self): pred = predictor(classless_data, Model.Value) prob = numpy.zeros((len(pred), 0)) except (ValueError, DomainTransformationError) as err: - self.predictors[inputid] = \ + self.predictors[index] = \ slot._replace(results=f"{predictor.name}: {err}") continue @@ -261,7 +264,7 @@ def _call_predictors(self): results.unmapped_probabilities = prob results.unmapped_predicted = pred results.probabilities = results.predicted = None - self.predictors[inputid] = slot._replace(results=results) + self.predictors[index] = slot._replace(results=results) target = predictor.domain.class_var if target != self.class_var: @@ -280,8 +283,8 @@ def _update_scores(self): scorers = usable_scorers(self.class_var) if self.class_var else [] self.score_table.update_header(scorers) errors = [] - for inputid, pred in self.predictors.items(): - results = self.predictors[inputid].results + for pred in self.predictors: + results = pred.results if not isinstance(results, Results) or results.predicted is None: continue row = [QStandardItem(learner_name(pred.predictor)), @@ -315,14 +318,14 @@ def _set_errors(self): # in _call_predictors errors = "\n".join( f"- {p.predictor.name}: {p.results}" - for p in self.predictors.values() + for p in self.predictors if isinstance(p.results, str) and p.results) self.Error.predictor_failed(errors, shown=bool(errors)) if self.class_var: inv_targets = "\n".join( f"- {pred.name} predicts '{pred.domain.class_var.name}'" - for pred in (p.predictor for p in self.predictors.values() + for pred in (p.predictor for p in self.predictors if isinstance(p.results, Results) and p.results.probabilities is None)) self.Warning.wrong_targets(inv_targets, shown=bool(inv_targets)) @@ -333,7 +336,7 @@ def _get_details(self): details = "Data:
" details += format_summary_details(self.data, format=Qt.RichText) details += "
" - pred_names = [v.name for v in self.predictors.values()] + pred_names = [v.name for v in self.predictors] n_predictors = len(self.predictors) if n_predictors: n_valid = len(self._non_errored_predictors()) @@ -349,11 +352,11 @@ def _get_details(self): return details def _invalidate_predictions(self): - for inputid, pred in list(self.predictors.items()): - self.predictors[inputid] = pred._replace(results=None) + for i, pred in enumerate(self.predictors): + self.predictors[i] = pred._replace(results=None) def _non_errored_predictors(self): - return [p for p in self.predictors.values() + return [p for p in self.predictors if isinstance(p.results, Results)] def _reordered_probabilities(self, prediction): @@ -502,7 +505,7 @@ def _get_colors(self): def _update_prediction_delegate(self): self._delegates.clear() colors = self._get_colors() - for col, slot in enumerate(self.predictors.values()): + for col, slot in enumerate(self.predictors): target = slot.predictor.domain.class_var shown_probs = ( () if target.is_continuous else @@ -1273,4 +1276,4 @@ def pred_error(data, *args, **kwargs): WidgetPreview(OWPredictions).run( set_data=iris2, - set_predictor=[(pred, i) for i, pred in enumerate(predictors_)]) + insert_predictor=list(enumerate(predictors_))) diff --git a/Orange/widgets/evaluate/owtestandscore.py b/Orange/widgets/evaluate/owtestandscore.py index bec4903f6df..9260e850b26 100644 --- a/Orange/widgets/evaluate/owtestandscore.py +++ b/Orange/widgets/evaluate/owtestandscore.py @@ -9,9 +9,11 @@ from functools import partial, reduce from concurrent.futures import Future -from collections import OrderedDict, namedtuple +from collections import OrderedDict from itertools import count -from typing import Any, Optional, List, Dict, Callable +from typing import ( + Any, Optional, List, Dict, Callable, Sequence, NamedTuple, Tuple +) import numpy as np import baycomp @@ -39,16 +41,16 @@ from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.utils.concurrent import ThreadExecutor, TaskState -from Orange.widgets.widget import OWWidget, Msg, Input, Output +from Orange.widgets.widget import OWWidget, Msg, Input, MultiInput, Output log = logging.getLogger(__name__) -InputLearner = namedtuple( - "InputLearner", - ["learner", # :: Orange.base.Learner - "results", # :: Option[Try[Orange.evaluation.Results]] - "stats"] # :: Option[Sequence[Try[float]]] -) + +class InputLearner(NamedTuple): + learner: Orange.base.Learner + results: Optional['Try[Orange.evaluation.Results]'] + stats: Optional[Sequence['Try[float]']] + key: Any class Try(abc.ABC): @@ -138,7 +140,9 @@ class OWTestAndScore(OWWidget): class Inputs: train_data = Input("Data", Table, default=True) test_data = Input("Test Data", Table) - learner = Input("Learner", Learner, multiple=True) + learner = MultiInput( + "Learner", Learner, filter_none=True + ) preprocessor = Input("Preprocessor", Preprocess) class Outputs: @@ -227,8 +231,10 @@ def __init__(self): self.test_data_missing_vals = False self.scorers = [] self.__pending_comparison_criterion = self.comparison_criterion - - #: An Ordered dictionary with current inputs and their testing results. + self.__id_gen = count() + self._learner_inputs = [] # type: List[Tuple[Any, Learner]] + #: An Ordered dictionary with current inputs and their testing results + #: (keyed by ids generated by __id_gen). self.learners = OrderedDict() # type: Dict[Any, Input] self.__state = State.Waiting @@ -356,22 +362,34 @@ def _update_controls(self): self.resampling = OWTestAndScore.KFold @Inputs.learner - def set_learner(self, learner, key): + def set_learner(self, index: int, learner: Learner): """ - Set the input `learner` for `key`. + Set the input `learner` at `index`. Parameters ---------- - learner : Optional[Orange.base.Learner] - key : Any + index: int + learner: Orange.base.Learner """ - if key in self.learners and learner is None: - # Removed - self._invalidate([key]) - del self.learners[key] - elif learner is not None: - self.learners[key] = InputLearner(learner, None, None) - self._invalidate([key]) + key, _ = self._learner_inputs[index] + slot = self.learners[key] + self.learners[key] = slot._replace(learner=learner, results=None) + self._invalidate([key]) + + @Inputs.learner.insert + def insert_learner(self, index: int, learner: Learner): + key = next(self.__id_gen) + self._learner_inputs.insert(index, (key, learner)) + self.learners[key] = InputLearner(learner, None, None, key) + self.learners = {key: self.learners[key] for key, _ in self._learner_inputs} + self._invalidate([key]) + + @Inputs.learner.remove + def remove_learner(self, index: int): + key, _ = self._learner_inputs[index] + self._invalidate([key]) + self._learner_inputs.pop(index) + self.learners.pop(key) @Inputs.train_data def set_train_data(self, data): @@ -1213,5 +1231,5 @@ def results_one_vs_rest(results, pos_index): WidgetPreview(OWTestAndScore).run( set_train_data=preview_data, set_test_data=preview_data, - set_learner=[(learner, i) for i, learner in enumerate(prev_learners)] + insert_learner=list(enumerate(prev_learners)) ) diff --git a/Orange/widgets/evaluate/tests/test_owpredictions.py b/Orange/widgets/evaluate/tests/test_owpredictions.py index 125f9d49dbb..6b7281f5683 100644 --- a/Orange/widgets/evaluate/tests/test_owpredictions.py +++ b/Orange/widgets/evaluate/tests/test_owpredictions.py @@ -492,6 +492,39 @@ def test_unregister_prediction_model(self): self.send_signal(self.widget.Inputs.predictors, log_reg_iris) self.widget.selection_store.unregister.called_with(prev_model) + def test_multi_inputs(self): + w = self.widget + data = self.iris[::5].copy() + + p1 = ConstantLearner()(data) + p1.name = "P1" + p2 = ConstantLearner()(data) + p2.name = "P2" + p3 = ConstantLearner()(data) + p3.name = "P3" + for i, p in enumerate([p1, p2, p3], 1): + self.send_signal(w.Inputs.predictors, p, i) + self.send_signal(w.Inputs.data, data) + + def check_evres(expected): + out = self.get_output(w.Outputs.evaluation_results) + self.assertSequenceEqual(out.learner_names, expected) + + check_evres(["P1", "P2", "P3"]) + + self.send_signal(w.Inputs.predictors, None, 2) + check_evres(["P1", "P3"]) + + self.send_signal(w.Inputs.predictors, p2, 2) + check_evres(["P1", "P2", "P3"]) + + self.send_signal(w.Inputs.predictors, + w.Inputs.predictors.closing_sentinel, 2) + check_evres(["P1", "P3"]) + + self.send_signal(w.Inputs.predictors, p2, 2) + check_evres(["P1", "P3", "P2"]) + class SelectionModelTest(unittest.TestCase): def setUp(self): diff --git a/Orange/widgets/evaluate/tests/test_owtestandscore.py b/Orange/widgets/evaluate/tests/test_owtestandscore.py index 1c03fef1622..17981d418af 100644 --- a/Orange/widgets/evaluate/tests/test_owtestandscore.py +++ b/Orange/widgets/evaluate/tests/test_owtestandscore.py @@ -71,14 +71,31 @@ def test_basic(self): self.assertIsNotNone(res.domain) self.assertIsNotNone(res.data) - def test_more_learners(self): - data = Table("iris")[::15] + def test_multiple_learners(self): + def check_evres_names(expeced): + res = self.get_output(self.widget.Outputs.evaluations_results) + self.assertSequenceEqual(res.learner_names, expeced) + + data = Table("iris")[::15].copy() + m1 = MajorityLearner() + m1.name = "M1" + m2 = MajorityLearner() + m2.name = "M2" self.send_signal(self.widget.Inputs.train_data, data) - self.send_signal(self.widget.Inputs.learner, MajorityLearner(), 0) - self.get_output(self.widget.Outputs.evaluations_results, wait=5000) - self.send_signal(self.widget.Inputs.learner, MajorityLearner(), 1) - res = self.get_output(self.widget.Outputs.evaluations_results, wait=5000) + self.send_signal(self.widget.Inputs.learner, m1, 1) + self.send_signal(self.widget.Inputs.learner, m2, 2) + res = self.get_output(self.widget.Outputs.evaluations_results) np.testing.assert_equal(res.probabilities[0], res.probabilities[1]) + check_evres_names(["M1", "M2"]) + self.send_signal(self.widget.Inputs.learner, None, 1) + check_evres_names(["M2"]) + self.send_signal(self.widget.Inputs.learner, m1, 1) + check_evres_names(["M1", "M2"]) + self.send_signal(self.widget.Inputs.learner, + self.widget.Inputs.learner.closing_sentinel, 1) + check_evres_names(["M2"]) + self.send_signal(self.widget.Inputs.learner, m1, 1) + check_evres_names(["M2", "M1"]) def test_testOnTest(self): data = Table("iris") @@ -272,13 +289,13 @@ def test_resort_on_data_change(self): setosa = iris[:51] versicolor = iris[49:100] - class SetosaLearner: + class SetosaLearner(Learner): def __call__(self, data): model = ConstantModel([1., 0, 0]) model.domain = iris.domain return model - class VersicolorLearner: + class VersicolorLearner(Learner): def __call__(self, data): model = ConstantModel([0, 1., 0]) model.domain = iris.domain @@ -287,9 +304,8 @@ def __call__(self, data): # this is done manually to avoid multiple computations self.widget.resampling = 5 self.widget.set_train_data(iris) - self.widget.set_learner(SetosaLearner(), 1) - self.widget.set_learner(VersicolorLearner(), 2) - + self.widget.insert_learner(0, SetosaLearner()) + self.widget.insert_learner(1, VersicolorLearner()) self.send_signal(self.widget.Inputs.test_data, setosa, wait=5000) self.widget.adjustSize() @@ -305,10 +321,10 @@ def __call__(self, data): # Ensure that the click on header caused an ascending sort # Ascending sort means that wrong model should be listed first self.assertEqual(header.sortIndicatorOrder(), Qt.AscendingOrder) - self.assertEqual(view.model().index(0, 0).data(), "VersicolorLearner") + self.assertEqual(view.model().index(0, 0).data(), "versicolor") self.send_signal(self.widget.Inputs.test_data, versicolor, wait=5000) - self.assertEqual(view.model().index(0, 0).data(), "SetosaLearner") + self.assertEqual(view.model().index(0, 0).data(), "setosa") def _retrieve_scores(self): w = self.widget diff --git a/Orange/widgets/model/owstack.py b/Orange/widgets/model/owstack.py index a91fd5a85e1..8ad2398b2c5 100644 --- a/Orange/widgets/model/owstack.py +++ b/Orange/widgets/model/owstack.py @@ -1,11 +1,11 @@ -from collections import OrderedDict +from typing import List from Orange.base import Learner from Orange.data import Table from Orange.ensembles.stack import StackedFitter from Orange.widgets.settings import Setting from Orange.widgets.utils.owlearnerwidget import OWBaseLearner -from Orange.widgets.widget import Input +from Orange.widgets.widget import Input, MultiInput class OWStackedLearner(OWBaseLearner): @@ -19,11 +19,11 @@ class OWStackedLearner(OWBaseLearner): learner_name = Setting("Stack") class Inputs(OWBaseLearner.Inputs): - learners = Input("Learners", Learner, multiple=True) + learners = MultiInput("Learners", Learner, filter_none=True) aggregate = Input("Aggregate", Learner) def __init__(self): - self.learners = OrderedDict() + self.learners: List[Learner] = [] self.aggregate = None super().__init__() @@ -31,27 +31,34 @@ def add_main_layout(self): pass @Inputs.learners - def set_learners(self, learner, id): # pylint: disable=redefined-builtin - if id in self.learners and learner is None: - del self.learners[id] - elif learner is not None: - self.learners[id] = learner - self.apply() + def set_learner(self, index: int, learner: Learner): + self.learners[index] = learner + + @Inputs.learners.insert + def insert_learner(self, index, learner): + self.learners.insert(index, learner) + + @Inputs.learners.remove + def remove_learner(self, index): + self.learners.pop(index) @Inputs.aggregate def set_aggregate(self, aggregate): self.aggregate = aggregate + + def handleNewSignals(self): + super().handleNewSignals() self.apply() def create_learner(self): if not self.learners: return None return self.LEARNER( - tuple(self.learners.values()), aggregate=self.aggregate, + tuple(self.learners), aggregate=self.aggregate, preprocessors=self.preprocessors) def get_learner_parameters(self): - return (("Base learners", [l.name for l in self.learners.values()]), + return (("Base learners", [l.name for l in self.learners]), ("Aggregator", self.aggregate.name if self.aggregate else 'default')) diff --git a/Orange/widgets/report/tests/test_report.py b/Orange/widgets/report/tests/test_report.py index 2b638190adc..d6b041d4102 100644 --- a/Orange/widgets/report/tests/test_report.py +++ b/Orange/widgets/report/tests/test_report.py @@ -107,12 +107,9 @@ def test_report_widgets_evaluate(self): results.learner_names = ["LR l2"] w = self.create_widget(OWTestAndScore) - set_learner = getattr(w, w.Inputs.learner.handler) - set_train = getattr(w, w.Inputs.train_data.handler) - set_test = getattr(w, w.Inputs.test_data.handler) - set_learner(LogisticRegressionLearner(), 0) - set_train(data) - set_test(data) + w.insert_learner(0, LogisticRegressionLearner()) + w.set_train_data(data) + w.set_test_data(data) w.create_report_html() rep.make_report(w) diff --git a/Orange/widgets/utils/sql.py b/Orange/widgets/utils/sql.py index 986bb3fee16..d9ae17d79e8 100644 --- a/Orange/widgets/utils/sql.py +++ b/Orange/widgets/utils/sql.py @@ -31,3 +31,28 @@ def new_f(widget, data, *args, **kwargs): return f(widget, data, *args, **kwargs) return new_f + + +def check_sql_input_sequence(f): + """ + Wrapper for widget's set_data/insert_data methodss that first checks + if the input is a SqlTable and: + - if small enough, download all data and convert to Table + - for large sql tables, show an error + + :param f: widget's `set_data` method to wrap + :return: wrapped method that handles SQL data inputs + """ + @wraps(f) + def new_f(widget, index, data, *args, **kwargs): + widget.Error.add_message("download_sql_data", _download_sql_data) + widget.Error.download_sql_data.clear() + if isinstance(data, SqlTable): + if data.approx_len() < AUTO_DL_LIMIT: + data = Table(data) + else: + widget.Error.download_sql_data() + data = None + return f(widget, index, data, *args, **kwargs) + + return new_f diff --git a/Orange/widgets/visualize/owvenndiagram.py b/Orange/widgets/visualize/owvenndiagram.py index 4533cfa189a..b4d4a0201a7 100644 --- a/Orange/widgets/visualize/owvenndiagram.py +++ b/Orange/widgets/visualize/owvenndiagram.py @@ -11,6 +11,7 @@ from functools import reduce from operator import attrgetter from xml.sax.saxutils import escape +from typing import Dict, Any, List, Mapping, Optional import numpy as np @@ -32,9 +33,9 @@ from Orange.widgets.utils import itemmodels, colorpalettes from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) -from Orange.widgets.utils.sql import check_sql_input +from Orange.widgets.utils.sql import check_sql_input_sequence from Orange.widgets.utils.widgetpreview import WidgetPreview -from Orange.widgets.widget import Input, Output, Msg +from Orange.widgets.widget import MultiInput, Output, Msg _InputData = namedtuple("_InputData", ["key", "name", "table"]) @@ -69,7 +70,7 @@ class OWVennDiagram(widget.OWWidget): settings_version = 2 class Inputs: - data = Input("Data", Table, multiple=True) + data = MultiInput("Data", Table) class Outputs: selected_data = Output("Selected Data", Table, default=True) @@ -106,10 +107,11 @@ def __init__(self): # Diagram update is in progress self._updating = False - # Input update is in progress - self._inputUpdate = False - # Input datasets in the order they were 'connected'. - self.data = {} + self.__id_gen = count() # 'key' generator for _InputData + #: Connected input dataset signals. + self._data_inputs: List[_InputData] = [] + # Input non-none datasets in the order they were 'connected'. + self.__data: Optional[Dict[Any, _InputData]] = None # Extracted input item sets in the order they were 'connected' self.itemsets = {} # A list with 2 ** len(self.data) elements that store item sets @@ -187,30 +189,48 @@ def _resize(self): self.vennwidget.resize(size, size) self.scene.setSceneRect(self.scene.itemsBoundingRect()) + @property + def data(self) -> Mapping[Any, _InputData]: + if self.__data is None: + self.__data = { + item.key: item for item in self._data_inputs[:5] + if item.table is not None + } + return self.__data + @Inputs.data - @check_sql_input - def setData(self, data, key=None): - self.Error.too_many_inputs.clear() - if not self._inputUpdate: - self._inputUpdate = True - if key in self.data: - if data is None: - # Remove the input - # Clear possible warnings. - self.Warning.clear() - del self.data[key] - else: - # Update existing item - self.data[key] = self.data[key]._replace(name=data.name, table=data) - - elif data is not None: - # TODO: Allow setting more them 5 inputs and let the user - # select the 5 to display. - if len(self.data) == 5: - self.Error.too_many_inputs() - return - # Add a new input - self.data[key] = _InputData(key, data.name, data) + @check_sql_input_sequence + def setData(self, index: int, data: Optional[Table]): + item = self._data_inputs[index] + item = item._replace( + name=data.name if data is not None else "", + table=data + ) + self._data_inputs[index] = item + self.__data = None # invalidate self.data + self._setInterAttributes() + + @Inputs.data.insert + @check_sql_input_sequence + def insertData(self, index: int, data: Optional[Table]): + key = next(self.__id_gen) + item = _InputData( + key, name=data.name if data is not None else "", table=data + ) + self._data_inputs.insert(index, item) + self.__data = None # invalidate self.data + if len(self._data_inputs) > 5: + self.Error.too_many_inputs() + self._setInterAttributes() + + @Inputs.data.remove + def removeData(self, index: int): + self.__data = None # invalidate self.data + self._data_inputs.pop(index) + if len(self._data_inputs) <= 5: + self.Error.too_many_inputs.clear() + # Clear possible warnings. + self.Warning.clear() self._setInterAttributes() def data_equality(self): @@ -234,7 +254,6 @@ def settings_compatible(self): return True def handleNewSignals(self): - self._inputUpdate = False self.vennwidget.clear() if not self.settings_compatible(): self.invalidateOutput() @@ -1450,18 +1469,18 @@ def main(): # pragma: no cover res = ShuffleSplit(n_resamples=5, test_size=0.7, stratified=False) indices = iter(res.get_indices(data)) datasets = [] - for i in range(1, 6): + for i in range(5): sample, _ = next(indices) data1 = data[sample] data1.name = chr(ord("A") + i) - datasets.append((data1, i)) + datasets.append((i, data1)) else: domain = data.domain data1 = data.transform(Domain(domain.attributes[:15], domain.class_var)) data2 = data.transform(Domain(domain.attributes[10:], domain.class_var)) - datasets = [(data1, 1), (data2, 2)] + datasets = [(0, data1), (1, data2)] - WidgetPreview(OWVennDiagram).run(setData=datasets) + WidgetPreview(OWVennDiagram).run(insertData=datasets) if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/visualize/tests/test_owvenndiagram.py b/Orange/widgets/visualize/tests/test_owvenndiagram.py index 9d025291cea..d56a0b80fd7 100644 --- a/Orange/widgets/visualize/tests/test_owvenndiagram.py +++ b/Orange/widgets/visualize/tests/test_owvenndiagram.py @@ -216,6 +216,28 @@ def test_multiple_input_over_cols(self): self.assertFalse(out_domain[3].attributes[selected_atr_name]) self.assertFalse(out_domain[4].attributes[selected_atr_name]) + def test_test_explicit_closing(self): + data = self.data[:3] + self.widget.rowwise = True + self.send_signal(self.signal_name, data[:1], 1) + self.send_signal(self.signal_name, data[1:2], 2) + self.send_signal(self.signal_name, data[2:3], 3) + out = self.get_output(self.widget.Outputs.annotated_data) + np.testing.assert_array_equal(out.ids, data[:3].ids) + + self.send_signal(self.signal_name, None, 2) + out = self.get_output(self.widget.Outputs.annotated_data) + np.testing.assert_array_equal(out.ids, data[0:3:2].ids) + + self.send_signal(self.signal_name, data[1:2], 2) + out = self.get_output(self.widget.Outputs.annotated_data) + np.testing.assert_array_equal(out.ids, data[:3].ids) + + self.send_signal(self.signal_name, + self.widget.Inputs.data.closing_sentinel, 1) + out = self.get_output(self.widget.Outputs.annotated_data) + np.testing.assert_array_equal(out.ids, data[1:3].ids) + def test_no_data(self): """Check that the widget doesn't crash on empty data""" self.send_signal(self.signal_name, self.data[:0], 1) @@ -285,7 +307,8 @@ def test_too_many_inputs(self): self.send_signal(self.signal_name, self.data, 6) self.assertTrue(self.widget.Error.too_many_inputs.is_shown()) - self.send_signal(self.signal_name, None, 6) + self.send_signal(self.signal_name, + self.widget.Inputs.data.closing_sentinel, 6) self.assertFalse(self.widget.Error.too_many_inputs.is_shown()) def test_no_attributes(self): diff --git a/Orange/widgets/widget.py b/Orange/widgets/widget.py index 9afcc31a822..ce55d94a891 100644 --- a/Orange/widgets/widget.py +++ b/Orange/widgets/widget.py @@ -5,7 +5,7 @@ Default, NonDefault, Single, Multiple, Dynamic, Explicit ) from orangewidget.widget import ( - OWBaseWidget, Message, Msg, StateInfo, Input, Output, + OWBaseWidget, Message, Msg, StateInfo, Input, Output, MultiInput ) from Orange.widgets.utils.progressbar import ProgressBarMixin @@ -16,8 +16,8 @@ import Orange.widgets.utils.state_summary # pylint: disable=unused-import __all__ = [ - "OWWidget", "Input", "Output", "AttributeList", "Message", "Msg", - "StateInfo", + "OWWidget", "Input", "Output", "MultiInput", "AttributeList", "Message", + "Msg", "StateInfo", # these are re-exported here for legacy reasons. Use Input/Output instead. "InputSignal", "OutputSignal", diff --git a/requirements-gui.txt b/requirements-gui.txt index 0eb8d96df49..cf2f556cfb5 100644 --- a/requirements-gui.txt +++ b/requirements-gui.txt @@ -1,5 +1,5 @@ orange-canvas-core>=0.1.21,<0.2a -orange-widget-base>=4.13.0 +orange-widget-base>=4.14.0 PyQt5>=5.12,!=5.15.1 # 5.15.1 skipped because of QTBUG-87057 - affects select columns PyQtWebEngine>=5.12