From b92b5b2aa844b09b0dde907baae3ccc3e46fbdc6 Mon Sep 17 00:00:00 2001 From: Vesna Tanko Date: Thu, 11 Mar 2021 08:28:01 +0100 Subject: [PATCH 1/5] Word List: New widget --- orangecontrib/text/widgets/owwordlist.py | 580 ++++++++++++++++++ .../text/widgets/tests/test_owwordlist.py | 375 +++++++++++ 2 files changed, 955 insertions(+) create mode 100644 orangecontrib/text/widgets/owwordlist.py create mode 100644 orangecontrib/text/widgets/tests/test_owwordlist.py diff --git a/orangecontrib/text/widgets/owwordlist.py b/orangecontrib/text/widgets/owwordlist.py new file mode 100644 index 000000000..6c7cb5403 --- /dev/null +++ b/orangecontrib/text/widgets/owwordlist.py @@ -0,0 +1,580 @@ +# pylint: disable=missing-docstring,no-name-in-module +from contextlib import contextmanager +from typing import Optional, List, Dict, Set +import os + +import numpy as np + +from AnyQt.QtCore import Qt, QModelIndex, QItemSelectionModel, \ + QItemSelection, QItemSelectionRange, Signal +from AnyQt.QtGui import QKeySequence, QPalette, QColor, QPainter +from AnyQt.QtWidgets import QListView, QSizePolicy, QGridLayout, QLineEdit, \ + QRadioButton, QGroupBox, QToolButton, QMenu, QAction, QFileDialog, \ + QStyledItemDelegate, QStyleOptionViewItem, QWidget + +from orangewidget.utils.listview import ListViewSearch + +from Orange.data import Table, StringVariable, Domain +from Orange.widgets import gui +from Orange.widgets.settings import Setting, DomainContextHandler, \ + ContextSetting +from Orange.widgets.utils.annotated_data import create_annotated_table +from Orange.widgets.utils.itemmodels import DomainModel, ModelActionsWidget, \ + PyListModel +from Orange.widgets.widget import Input, Output, OWWidget, Msg + +from orangecontrib.text import Corpus + + +@contextmanager +def disconnected(signal, slot, connection_type=Qt.AutoConnection): + signal.disconnect(slot) + try: + yield + finally: + signal.connect(slot, connection_type) + + +class WordList: + NotModified, Modified = range(2) + + def __init__(self, name: str, words: List, filename: Optional[str] = None): + self.name = name + self.filename = filename + # library words + self.words = list(words) + # current words + self.cached_words = list(words) + # update rule changes current words + self.update_rule_flag = WordList.NotModified + + @property + def flags(self) -> int: + # 0 - NotModified, 1 - Modified + return int(self.words != self.cached_words or + self.update_rule_flag == WordList.Modified) + + def as_dict(self) -> Dict: + return {"name": self.name, + "words": list(self.words), + "filename": self.filename} + + @classmethod + def from_dict(cls, state: Dict) -> "WordList": + return WordList(state["name"], + state["words"].copy(), + filename=state.get("filename")) + + @staticmethod + def generate_word_list_name(taken_names: List[str]) -> str: + default_name = "Untitled" + indices = {0} + for name in taken_names: + if name.startswith(default_name): + try: + indices.add(int(name[len(default_name):])) + except ValueError: + pass + index = min(set(range(max(indices) + 1 + 1)) - indices) + return f"{default_name} {index}" + + +class UpdateRules: + INTERSECT, UNION, INPUT, LIBRARY = range(4) + ITEMS = ["Intersection", "Union", "Only Input", "Ignore Input"] + + @staticmethod + def update(model: PyListModel, lib_words: List, in_words: List, rule: int): + if rule == UpdateRules.INTERSECT: + intersect = set(lib_words) & set(in_words) + model.wrap(list({k: None for k in lib_words if k in intersect})) + elif rule == UpdateRules.UNION: + model.wrap(list({k: None for k in lib_words + in_words})) + elif rule == UpdateRules.INPUT: + model.wrap(list(in_words)) + elif rule == UpdateRules.LIBRARY: + model.wrap(list(lib_words)) + else: + raise NotImplementedError + + +class WordListItemDelegate(QStyledItemDelegate): + def displayText(self, word_list: WordList, _) -> str: + return "*" + word_list.name if word_list.flags & WordList.Modified \ + else word_list.name + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, + index: QModelIndex): + word_list = index.data(Qt.DisplayRole) + if word_list.flags & WordList.Modified: + option = QStyleOptionViewItem(option) + option.palette.setColor(QPalette.Text, QColor(Qt.red)) + option.palette.setColor(QPalette.Highlight, QColor(Qt.darkRed)) + option.palette.setColor(QPalette.HighlightedText, QColor(Qt.white)) + super().paint(painter, option, index) + + def createEditor(self, parent: QWidget, _, __) -> QLineEdit: + return QLineEdit(parent) + + def setEditorData(self, editor: QLineEdit, index: QModelIndex): + word_list = index.data(Qt.DisplayRole) + editor.setText(word_list.name) + + def setModelData(self, editor: QLineEdit, + model: PyListModel, index: QModelIndex): + model[index.row()].name = str(editor.text()) + + +class ListView(ListViewSearch): + drop_finished = Signal() + + def __init__(self): + super().__init__( + editTriggers=QListView.DoubleClicked | QListView.EditKeyPressed, + sizePolicy=QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding), + minimumWidth=200, + selectionMode=QListView.ExtendedSelection, + dragEnabled=True, + acceptDrops=True, + defaultDropAction=Qt.MoveAction, + ) + self.setDropIndicatorShown(True) + + def startDrag(self, actions): + super().startDrag(actions) + self.drop_finished.emit() + + +class OWWordList(OWWidget): + name = "Word List" + description = "Create a list of words." + icon = "icons/WordList.svg" + priority = 1000 + + class Inputs: + words = Input("Words", Table) + + class Outputs: + selected_words = Output("Selected Words", Table) + words = Output("Words", Corpus) + + class Warning(OWWidget.Warning): + no_string_vars = Msg("Input 'Words' needs at least one Text variable.") + + NONE, CACHED, LIBRARY = range(3) # library list modification types + + want_main_area = False + resizing_enabled = True + + settingsHandler = DomainContextHandler() + # word_list_library: List[Dict] = Setting([ + # {"name": "Untitled", + # "words": ["foo", "bar", "baz"]}, + # {"name": "Word list", + # "words": ["word 1", "word 2", "word 3", "word 4"]}, + # ]) + word_list_library: List[Dict] = Setting([ + {"name": WordList.generate_word_list_name([]), "words": []}, + ]) + word_list_index: int = Setting(0) + words_var: Optional[StringVariable] = ContextSetting(None) + update_rule_index: int = Setting(UpdateRules.INTERSECT) + words: List[str] = Setting(None, schema_only=True) + selected_words: Set[str] = Setting(set(), schema_only=True) + + def __init__(self): + super().__init__(self) + flags = Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable + self.library_model = PyListModel([], self, flags=flags) + self.words_model = PyListModel([], self, flags=flags, enable_dnd=True) + + self.library_view: QListView = None + self.words_view: ListView = None + + self.__input_words_model = DomainModel(valid_types=(StringVariable,)) + self.__input_words: Optional[Table] = None + + self.__library_box: QGroupBox = gui.vBox(None, "Library") + self.__input_box: QGroupBox = gui.vBox(None, "Input") + self.__words_box: QGroupBox = gui.vBox(None, box=True) + self.__update_rule_rb: QRadioButton = None + + self.__add_word_action: QAction = None + self.__remove_word_action: QAction = None + + self._setup_gui() + self._restore_state() + self.settingsAboutToBePacked.connect(self._save_state) + + def _setup_gui(self): + layout = QGridLayout() + gui.widgetBox(self.controlArea, orientation=layout) + + self._setup_library_box() + self._setup_input_box() + self._setup_words_box() + + layout.addWidget(self.__library_box, 0, 0) + layout.addWidget(self.__input_box, 1, 0) + layout.addWidget(self.__words_box, 0, 1, 0, 1) + + def _setup_library_box(self): + self.library_view = QListView( + editTriggers=QListView.DoubleClicked | QListView.EditKeyPressed, + minimumWidth=200, + sizePolicy=QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding), + ) + self.library_view.setItemDelegate(WordListItemDelegate(self)) + self.library_view.setModel(self.library_model) + self.library_view.selectionModel().selectionChanged.connect( + self.__on_library_selection_changed + ) + + self.__library_box.layout().setSpacing(1) + self.__library_box.layout().addWidget(self.library_view) + + actions_widget = ModelActionsWidget() + actions_widget.layout().setSpacing(1) + + action = QAction("+", self) + action.setToolTip("Add a new word list to the library") + action.triggered.connect(self.__on_add_word_list) + actions_widget.addAction(action) + + action = QAction("\N{MINUS SIGN}", self) + action.setToolTip("Remove word list from library") + action.triggered.connect(self.__on_remove_word_list) + actions_widget.addAction(action) + + action = QAction("Update", self) + action.setToolTip("Save changes in the editor to library") + action.setShortcut(QKeySequence(QKeySequence.Save)) + action.triggered.connect(self.__on_update_word_list) + actions_widget.addAction(action) + + gui.rubber(actions_widget.layout()) + + action = QAction("More", self, toolTip="More actions") + + new_from_file = QAction("Import Words from File", self) + new_from_file.triggered.connect(self.__on_import_word_list) + + save_to_file = QAction("Save Words to File", self) + save_to_file.setShortcut(QKeySequence(QKeySequence.SaveAs)) + save_to_file.triggered.connect(self.__on_save_word_list) + + menu = QMenu(actions_widget) + menu.addAction(new_from_file) + menu.addAction(save_to_file) + action.setMenu(menu) + button = actions_widget.addAction(action) + button.setPopupMode(QToolButton.InstantPopup) + self.__library_box.layout().addWidget(actions_widget) + + def __on_library_selection_changed(self, selected: QItemSelection, *_): + index = [i.row() for i in selected.indexes()] + if index: + current = index[0] + word_list: WordList = self.library_model[current] + self.word_list_index = current + self.words_model.wrap(list(word_list.cached_words)) + self._apply_update_rule() + + def __on_add_word_list(self): + taken = [l.name for l in self.library_model] + name = WordList.generate_word_list_name(taken) + word_list = WordList(name, self.words_model[:]) + self.library_model.append(word_list) + self._set_selected_word_list(len(self.library_model) - 1) + + def __on_remove_word_list(self): + index = self._get_selected_word_list_index() + if index is not None: + del self.library_model[index] + self._set_selected_word_list(max(index - 1, 0)) + self._apply_update_rule() + + def __on_update_word_list(self): + self._set_word_list_modified(mod_type=self.LIBRARY) + + def __on_import_word_list(self): + filename, _ = QFileDialog.getOpenFileName( + self, "Open Word List", + os.path.expanduser("~/"), + "Text files (*.txt)\nAll files(*.*)" + ) + if filename: + name = os.path.basename(filename) + with open(filename, encoding="utf-8") as f: + words = [line.strip() for line in f.readlines()] + self.library_model.append(WordList(name, words, filename=filename)) + self._set_selected_word_list(len(self.library_model) - 1) + self._apply_update_rule() + + def __on_save_word_list(self): + index = self._get_selected_word_list_index() + if index is not None: + word_list = self.library_model[index] + filename = word_list.filename + else: + filename = os.path.expanduser("~/") + + filename, _ = QFileDialog.getSaveFileName( + self, "Save Word List", filename, + "Text files (*.txt)\nAll files(*.*)" + ) + if filename: + head, tail = os.path.splitext(filename) + if not tail: + filename = head + ".txt" + + with open(filename, "w", encoding="utf-8") as f: + for word in self.words_model: + f.write(f"{word}\n") + + def _setup_input_box(self): + gui.comboBox( + self.__input_box, self, "words_var", label="Word variable:", + orientation=Qt.Vertical, model=self.__input_words_model, + callback=self._apply_update_rule + ) + gui.radioButtons( + self.__input_box, self, "update_rule_index", UpdateRules.ITEMS, + label="Update: ", orientation=Qt.Vertical, + callback=self.__on_update_rule_changed + ) + self.__input_box.setEnabled(False) + + def __on_update_rule_changed(self): + self._enable_words_actions() + self._apply_update_rule() + + def _setup_words_box(self): + self.words_view = ListView() + self.words_view.drop_finished.connect(self.__on_words_data_changed) + self.words_view.setModel(self.words_model) + self.words_view.selectionModel().selectionChanged.connect( + self.__on_words_selection_changed) + + self.words_model.dataChanged.connect(self.__on_words_data_changed) + + self.__words_box.layout().setSpacing(1) + self.__words_box.layout().addWidget(self.words_view) + + actions_widget = ModelActionsWidget() + actions_widget.layout().setSpacing(1) + + action = QAction("+", self.words_view, toolTip="Add a new word") + action.triggered.connect(self.__on_add_word) + actions_widget.addAction(action) + self.__add_word_action = action + + action = QAction("\N{MINUS SIGN}", self, toolTip="Remove word") + action.triggered.connect(self.__on_remove_word) + actions_widget.addAction(action) + self.__remove_word_action = action + + gui.rubber(actions_widget) + + action = QAction("Sort", self) + action.setToolTip("Sort words alphabetically") + action.triggered.connect(self.__on_apply_sorting) + actions_widget.addAction(action) + + self.__words_box.layout().addWidget(actions_widget) + + def __on_words_data_changed(self): + self._set_word_list_modified(mod_type=self.CACHED) + self.commit() + + def __on_words_selection_changed(self): + self.commit() + + def __on_add_word(self): + row = self.words_model.rowCount() + if not self.words_model.insertRow(self.words_model.rowCount()): + return + with disconnected(self.words_view.selectionModel().selectionChanged, + self.__on_words_selection_changed): + self._set_selected_words([0]) + index = self.words_model.index(row, 0) + self.words_view.setCurrentIndex(index) + self.words_model.setItemData(index, {Qt.EditRole: ""}) + self.words_view.edit(index) + + def __on_remove_word(self): + rows = self.words_view.selectionModel().selectedRows(0) + if not rows: + return + + indices = sorted([row.row() for row in rows], reverse=True) + with disconnected(self.words_view.selectionModel().selectionChanged, + self.__on_words_selection_changed): + for index in indices: + self.words_model.removeRow(index) + if self.words_model: + self._set_selected_words([max(0, indices[-1] - 1)]) + self.__on_words_data_changed() + + def __on_apply_sorting(self): + if not self.words_model: + return + words = self.words_model[:] + mask = np.zeros(len(words), dtype=bool) + selection = self._get_selected_words_indices() + if selection: + mask[selection] = True + + indices = np.argsort(words) + self.words_model.wrap([words[i] for i in indices]) + self._set_word_list_modified(mod_type=self.CACHED) + if selection: + self._set_selected_words(list(np.flatnonzero(mask[indices]))) + else: + self.commit() + + @Inputs.words + def set_words(self, words: Optional[Table]): + self.closeContext() + self.__input_words = words + self._check_input_words() + self._init_controls() + self.openContext(self.__input_words) + self._apply_update_rule() + + def _check_input_words(self): + self.Warning.no_string_vars.clear() + if self.__input_words: + metas = self.__input_words.domain.metas + if not any(isinstance(m, StringVariable) for m in metas): + self.Warning.no_string_vars() + self.__input_words = None + + def _init_controls(self): + words = self.__input_words + domain = words.domain if words is not None else None + self.__input_words_model.set_domain(domain) + if len(self.__input_words_model) > 0: + self.words_var = self.__input_words_model[0] + self.__input_box.setEnabled(bool(self.__input_words_model)) + self._enable_words_actions() + + def _enable_words_actions(self): + if bool(self.__input_words_model) \ + and self.update_rule_index != UpdateRules.LIBRARY: + self.words_view.setEditTriggers(QListView.NoEditTriggers) + self.__add_word_action.setEnabled(False) + self.__remove_word_action.setEnabled(False) + else: + self.words_view.setEditTriggers( + QListView.DoubleClicked | QListView.EditKeyPressed + ) + self.__add_word_action.setEnabled(True) + self.__remove_word_action.setEnabled(True) + + def _apply_update_rule(self): + lib_index = self._get_selected_word_list_index() + lib_words, in_words, update_rule = [], [], UpdateRules.LIBRARY + if lib_index is not None: + lib_words = self.library_model[lib_index].cached_words + else: + lib_words = self.words_model[:] + if self.__input_words is not None: + in_words = self.__input_words.get_column_view(self.words_var)[0] + in_words = list(in_words) + update_rule = self.update_rule_index + + UpdateRules.update(self.words_model, lib_words, in_words, update_rule) + if lib_index is not None: + cached = self.library_model[lib_index].cached_words + modified = WordList.NotModified if cached == self.words_model[:] \ + else WordList.Modified + self.library_model[lib_index].update_rule_flag = modified + self._set_word_list_modified(mod_type=self.NONE) + self.library_view.repaint() + self.commit() + + def commit(self): + words, selected_words = None, None + if self.words_model: + words_var = StringVariable("Words") + words_var.attributes = {"type": "words"} + domain = Domain([], metas=[words_var]) + _words = Table.from_list(domain, [[w] for w in self.words_model]) + _words.name = "Words" + selection = self._get_selected_words_indices() + if selection: + selected_words = _words[selection] + words = create_annotated_table(_words, selection) + self.Outputs.words.send(words) + self.Outputs.selected_words.send(selected_words) + + def _set_word_list_modified(self, mod_type): + index = self._get_selected_word_list_index() + if index is not None: + if mod_type == self.LIBRARY: + self.library_model[index].words = self.words_model[:] + self.library_model[index].cached_words = self.words_model[:] + self.library_model[index].update_rule_flag \ + = WordList.NotModified + elif mod_type == self.CACHED: + self.library_model[index].cached_words = self.words_model[:] + elif mod_type == self.NONE: + pass + else: + raise NotImplementedError + self.library_model.emitDataChanged(index) + self.library_view.repaint() + + def _set_selected_word_list(self, index: int): + sel_model: QItemSelectionModel = self.library_view.selectionModel() + sel_model.select(self.library_model.index(index, 0), + QItemSelectionModel.ClearAndSelect) + + def _get_selected_word_list_index(self) -> Optional[int]: + rows = self.library_view.selectionModel().selectedRows() + return rows[0].row() if rows else None + + def _set_selected_words(self, indices: List[int]): + selection = QItemSelection() + sel_model: QItemSelectionModel = self.words_view.selectionModel() + for i in indices: + selection.append(QItemSelectionRange(self.words_model.index(i, 0))) + sel_model.select(selection, QItemSelectionModel.ClearAndSelect) + + def _get_selected_words_indices(self) -> List[int]: + rows = self.words_view.selectionModel().selectedRows() + return [row.row() for row in rows] + + def _restore_state(self): + index = self.word_list_index + source = [WordList.from_dict(s) for s in self.word_list_library] + self.library_model.wrap(source) + self._set_selected_word_list(index) + + if self.words is not None: + self.words_model.wrap(list(self.words)) + self._set_word_list_modified(mod_type=self.CACHED) + if self.selected_words: + indices = [i for i, w in enumerate(self.words) + if w in self.selected_words] + self._set_selected_words(indices) + else: + self.commit() + + def _save_state(self): + self.word_list_library = [s.as_dict() for s in self.library_model] + self.words = self.words_model[:] + + selected_indices = self._get_selected_words_indices() + self.selected_words = set(np.array(self.words_model)[selected_indices]) + + +if __name__ == "__main__": + from Orange.widgets.utils.widgetpreview import WidgetPreview + + words_vars = [StringVariable("S1"), StringVariable("S2")] + lst = [["foo", "A"], ["bar", "B"], ["foobar", "C"]] + input_table = Table.from_list(Domain([], metas=words_vars), lst) + # WidgetPreview(OWWordList).run(set_words=input_table) + WidgetPreview(OWWordList).run() diff --git a/orangecontrib/text/widgets/tests/test_owwordlist.py b/orangecontrib/text/widgets/tests/test_owwordlist.py new file mode 100644 index 000000000..bd7ad2a33 --- /dev/null +++ b/orangecontrib/text/widgets/tests/test_owwordlist.py @@ -0,0 +1,375 @@ +# pylint: disable=missing-docstring,protected-access +import unittest +from unittest.mock import Mock, patch +import tempfile +import os + +from AnyQt.QtCore import Qt +from AnyQt.QtWidgets import QGroupBox, QFileDialog + +from Orange.data import Table, StringVariable, Domain +from Orange.widgets.tests.base import WidgetTest + +from orangecontrib.text.widgets.owwordlist import OWWordList, UpdateRules, \ + WordList +from orangewidget.tests.utils import simulate + + +class TestWordList(unittest.TestCase): + def test_generate_name(self): + name = WordList.generate_word_list_name([]) + self.assertEqual(name, "Untitled 1") + + name = WordList.generate_word_list_name(["foo"]) + self.assertEqual(name, "Untitled 1") + + name = WordList.generate_word_list_name(["foo", "Untitled 1"]) + self.assertEqual(name, "Untitled 2") + + name = WordList.generate_word_list_name( + ["foo", "Untitled 1", "Untitled A"]) + self.assertEqual(name, "Untitled 2") + + name = WordList.generate_word_list_name(["Untitled 2"]) + self.assertEqual(name, "Untitled 1") + + name = WordList.generate_word_list_name( + ["Untitled", "Untitled 2", "Untitled 3"]) + self.assertEqual(name, "Untitled 1") + + name = WordList.generate_word_list_name( + ["Untitled 1", "Untitled 2", "Untitled 4"]) + self.assertEqual(name, "Untitled 3") + + +class TestUpdateRule(unittest.TestCase): + def test_update_intersect(self): + rule = UpdateRules.INTERSECT + model = Mock() + model.wrap = Mock() + UpdateRules.update(model, ["foo"], ["bar"], rule) + model.wrap.assert_called_once_with([]) + + model.wrap.reset_mock() + UpdateRules.update(model, ["foo"], ["foo"], rule) + model.wrap.assert_called_once_with(["foo"]) + + model.wrap.reset_mock() + UpdateRules.update(model, ["foo", "foo"], ["foo"], rule) + model.wrap.assert_called_once_with(["foo"]) + + model.wrap.reset_mock() + UpdateRules.update(model, ["foo"], ["foo", "foo"], rule) + model.wrap.assert_called_once_with(["foo"]) + + def test_update_union(self): + rule = UpdateRules.UNION + model = Mock() + model.wrap = Mock() + UpdateRules.update(model, ["foo"], ["bar"], rule) + model.wrap.assert_called_once_with(["foo", "bar"]) + + model.wrap.reset_mock() + UpdateRules.update(model, ["foo"], ["bar", "foo"], rule) + model.wrap.assert_called_once_with(["foo", "bar"]) + + model.wrap.reset_mock() + UpdateRules.update(model, ["foo", "foo"], ["foo"], rule) + model.wrap.assert_called_once_with(["foo"]) + + model.wrap.reset_mock() + UpdateRules.update(model, ["foo"], ["foo", "foo"], rule) + model.wrap.assert_called_once_with(["foo"]) + + def test_update_input(self): + rule = UpdateRules.INPUT + model = Mock() + model.wrap = Mock() + UpdateRules.update(model, ["foo"], ["bar"], rule) + model.wrap.assert_called_once_with(["bar"]) + + def test_update_input(self): + rule = UpdateRules.LIBRARY + model = Mock() + model.wrap = Mock() + UpdateRules.update(model, ["foo"], ["bar"], rule) + model.wrap.assert_called_once_with(["foo"]) + + +class TestOWWordList(WidgetTest): + def setUp(self): + self._word_list_1 = ["foo", "bar", "baz"] + self._word_list_2 = ["word 1", "word 2", "word 3", "word 4"] + settings = {"word_list_library": [{"name": "Hello world", + "words": self._word_list_1}, + {"name": "Word list 1", + "words": self._word_list_2}], + "words": self._word_list_1} + self.widget = self.create_widget(OWWordList, stored_settings=settings) + + words_var = [StringVariable("S1"), StringVariable("S2")] + lst = [["foo", "A"], ["bar", "B"], ["foobar", "C"]] + self._input_table = Table.from_list(Domain([], metas=words_var), lst) + + def test_default(self): + self.assertEqual(self.widget.library_model[0].name, "Hello world") + self.assertEqual(self.widget.library_model[1].name, "Word list 1") + self.assertEqual(self.widget._get_selected_word_list_index(), 0) + self.assertListEqual(self.widget._get_selected_words_indices(), []) + self.assertListEqual(self.widget.words_model[:], self._word_list_1) + + def test_outputs(self): + self.widget._set_selected_words([0, 1]) + + words = self.get_output(self.widget.Outputs.words) + self.assertEqual(words.name, "Words") + self.assertEqual(len(words.domain.attributes), 0) + self.assertEqual(len(words.domain.class_vars), 1) + self.assertEqual(words.domain.class_vars[0].name, "Selected") + self.assertEqual(len(words.domain.metas), 1) + self.assertEqual(words.domain.metas[0].name, "Words") + self.assertDictEqual(words.domain.metas[0].attributes, + {"type": "words"}) + self.assertListEqual([1, 1, 0], list(words.Y)) + self.assertListEqual(list(words.metas[:, 0]), self._word_list_1) + + selected_words = self.get_output(self.widget.Outputs.selected_words) + self.assertEqual(selected_words.name, "Words") + self.assertEqual(len(selected_words.domain.attributes), 0) + self.assertEqual(len(selected_words.domain.class_vars), 0) + self.assertEqual(len(selected_words.domain.metas), 1) + self.assertEqual(selected_words.domain.metas[0].name, "Words") + self.assertDictEqual(selected_words.domain.metas[0].attributes, + {"type": "words"}) + self.assertListEqual(["foo", "bar"], list(selected_words.metas[:, 0])) + + def test_library_sel_changed(self): + self.widget._set_selected_word_list(1) + self.assertEqual(self.widget._get_selected_word_list_index(), 1) + words = self.get_output(self.widget.Outputs.words) + self.assertListEqual(list(words.metas[:, 0]), + ["word 1", "word 2", "word 3", "word 4"]) + + def test_library_add(self): + self.widget._OWWordList__on_add_word_list() + sel_wlist = self.widget._get_selected_word_list_index() + sel_words = self.widget._get_selected_words_indices() + self.assertEqual(sel_wlist, 2) + self.assertListEqual(sel_words, []) + self.assertListEqual(self.widget.words_model[:], self._word_list_1) + + self.widget._set_selected_word_list(1) + self.assertListEqual(self.widget.words_model[:], self._word_list_2) + + def test_library_remove(self): + self.widget._OWWordList__on_remove_word_list() + self.assertEqual(self.widget.library_model.rowCount(), 1) + self.assertEqual(self.widget.words_model.rowCount(), 4) + sel_wlist = self.widget._get_selected_word_list_index() + sel_words = self.widget._get_selected_words_indices() + self.assertEqual(sel_wlist, 0) + self.assertListEqual(sel_words, []) + + self.widget._OWWordList__on_remove_word_list() + self.assertEqual(self.widget.library_model.rowCount(), 0) + self.assertEqual(self.widget.words_model.rowCount(), 4) + sel_wlist = self.widget._get_selected_word_list_index() + sel_words = self.widget._get_selected_words_indices() + self.assertIsNone(sel_wlist, 0) + self.assertListEqual(sel_words, []) + + def test_library_update(self): + self.assertEqual(self.widget._get_selected_word_list_index(), 0) + model = self.widget.words_model + model.setItemData(model.index(2, 0), {Qt.EditRole: "foobar"}) + settings = self.widget.settingsHandler.pack_data(self.widget) + self.assertListEqual(settings["word_list_library"][0]["words"], + ["foo", "bar", "baz"]) + self.widget._OWWordList__on_update_word_list() + settings = self.widget.settingsHandler.pack_data(self.widget) + self.assertListEqual(settings["word_list_library"][0]["words"], + ["foo", "bar", "foobar"]) + + def test_library_import(self): + with tempfile.NamedTemporaryFile(delete=False) as f: + f.write(b"traffic \n") + f.write(b"control car bike bus\n") + with patch.object(QFileDialog, "getOpenFileName", + Mock(return_value=(f.name, None))): + self.widget._OWWordList__on_import_word_list() + self.assertEqual(self.widget._get_selected_word_list_index(), 2) + self.assertEqual(self.widget.library_model[2].name, + os.path.basename(f.name)) + self.assertListEqual(self.widget.words_model[:], + ["traffic", "control car bike bus"]) + + def test_library_save(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", + delete=False) as f: + pass + with patch.object(QFileDialog, "getSaveFileName", + Mock(return_value=(f.name, None))): + self.widget._OWWordList__on_save_word_list() + with open(f.name) as dummy_f: + self.assertListEqual(dummy_f.read().strip().split("\n"), + self._word_list_1) + + def test_words_sel_changed(self): + self.widget._set_selected_words([0, 2]) + output = self.get_output(self.widget.Outputs.selected_words) + self.assertListEqual(list(output.metas[:, 0]), ["foo", "baz"]) + + def test_words_sel_changed_commit_invoked_once(self): + self.widget.commit = Mock() + self.widget._set_selected_words([0, 2]) + self.widget.commit.assert_called_once() + + def test_add_word(self): + self.widget.commit = Mock() + self.widget._OWWordList__on_add_word() + self.widget.commit.assert_called_once() + + def test_remove_word(self): + self.widget._set_selected_words([0]) + self.widget._OWWordList__on_remove_word() + output = self.get_output(self.widget.Outputs.words) + self.assertListEqual(list(output.metas[:, 0]), ["bar", "baz"]) + + self.widget._OWWordList__on_remove_word() + self.widget._OWWordList__on_remove_word() + self.assertIsNone(self.get_output(self.widget.Outputs.words)) + + def test_remove_word_commit_invoked_once(self): + self.widget._set_selected_words([0, 1]) + self.widget.commit = Mock() + self.widget._OWWordList__on_remove_word() + self.widget.commit.assert_called_once() + + def test_remove_word_no_selection(self): + self.widget.commit = Mock() + self.widget._OWWordList__on_remove_word() + self.widget.commit.assert_not_called() + + def test_sort_words(self): + self.widget._set_selected_words([0, 1]) + self.widget._OWWordList__on_apply_sorting() + selected_words = self.get_output(self.widget.Outputs.selected_words) + self.assertListEqual(["bar", "foo"], list(selected_words.metas[:, 0])) + + def test_sort_words_commit_invoked_once(self): + self.widget.commit = Mock() + self.widget._OWWordList__on_apply_sorting() + self.widget.commit.assert_called_once() + + def test_input_controls_enabled(self): + box: QGroupBox = self.widget._OWWordList__input_box + self.assertFalse(box.isEnabled()) + + iris = Table("iris") + self.send_signal(self.widget.Inputs.words, iris) + self.assertFalse(box.isEnabled()) + + zoo = Table("zoo") + self.send_signal(self.widget.Inputs.words, zoo) + self.assertTrue(box.isEnabled()) + + self.send_signal(self.widget.Inputs.words, None) + self.assertFalse(box.isEnabled()) + + def test_input_data_warning(self): + self.assertFalse(self.widget.Warning.no_string_vars.is_shown()) + + iris = Table("iris") + self.send_signal(self.widget.Inputs.words, iris) + self.assertTrue(self.widget.Warning.no_string_vars.is_shown()) + + zoo = Table("zoo") + self.send_signal(self.widget.Inputs.words, zoo) + self.assertFalse(self.widget.Warning.no_string_vars.is_shown()) + + self.send_signal(self.widget.Inputs.words, None) + self.assertFalse(self.widget.Warning.no_string_vars.is_shown()) + + def test_input_data(self): + self.send_signal(self.widget.Inputs.words, self._input_table) + self.assertEqual(self.widget.words_var.name, "S1") + self.assertListEqual(self.widget.words_model[:], ["foo", "bar"]) + + def test_input_var_changed(self): + self.send_signal(self.widget.Inputs.words, self._input_table) + simulate.combobox_activate_index(self.widget.controls.words_var, 1) + self.assertEqual(self.widget.words_var.name, "S2") + self.assertListEqual(self.widget.words_model[:], []) + + def test_update_rule_changed(self): + buttons = self.widget.controls.update_rule_index.buttons + self.send_signal(self.widget.Inputs.words, self._input_table) + + buttons[UpdateRules.UNION].click() + self.assertListEqual(self.widget.words_model[:], + ["foo", "bar", "baz", "foobar"]) + + buttons[UpdateRules.INPUT].click() + self.assertListEqual(self.widget.words_model[:], + ["foo", "bar", "foobar"]) + + buttons[UpdateRules.LIBRARY].click() + self.assertListEqual(self.widget.words_model[:], + ["foo", "bar", "baz"]) + + @patch("orangecontrib.text.widgets.owwordlist.OWWordList.commit") + def test_commit_invoked_once(self, commit: Mock): + self.create_widget(OWWordList, stored_settings=[]) + commit.assert_called_once() + + settings = {"word_list_library": [{"name": "Hello world", + "words": self._word_list_1}, + {"name": "Word list 1", + "words": self._word_list_2}]} + commit.reset_mock() + self.create_widget(OWWordList, stored_settings=settings) + commit.assert_called_once() + + settings = {"word_list_library": [{"name": "Hello world", + "words": self._word_list_1}, + {"name": "Word list 1", + "words": self._word_list_2}], + "selected_words": set(self._word_list_1)} + commit.reset_mock() + widget = self.create_widget(OWWordList, stored_settings=settings) + commit.assert_called_once() + + commit.reset_mock() + words_var = [StringVariable("S1"), StringVariable("S2")] + lst = [["foo", "A"], ["bar", "B"], ["foobar", "C"]] + words = Table.from_list(Domain([], metas=words_var), lst) + self.send_signal(widget.Inputs.words, words) + commit.assert_called_once() + + def test_saved_settings(self): + self.widget._set_selected_word_list(1) + self.widget.library_model[1].name = "New title" + model = self.widget.words_model + model.setData(model.index(0, 0), "changed", Qt.EditRole) + self.widget._set_selected_words([0, 2]) + + settings = self.widget.settingsHandler.pack_data(self.widget) + self.assertEqual(settings["word_list_index"], 1) + self.assertEqual(settings["word_list_library"][1]["name"], "New title") + self.assertSetEqual(settings["selected_words"], {"changed", "word 3"}) + self.assertListEqual(settings["words"], + ["changed", "word 2", "word 3", "word 4"]) + + def test_saved_workflow(self): + settings = {"word_list_library": [{"name": "Hello world", + "words": self._word_list_1}, + {"name": "Word list 1", + "words": self._word_list_2}], + "words": {"foo"}} + widget = self.create_widget(OWWordList, stored_settings=settings) + words = self.get_output(widget.Outputs.words, widget=widget) + self.assertListEqual(list(words.metas[:, 0]), ["foo"]) + + +if __name__ == "__main__": + unittest.main() From c9236f06b5c1c75ea87eea16873c636f3918270a Mon Sep 17 00:00:00 2001 From: Vesna Tanko Date: Tue, 23 Mar 2021 11:23:13 +0100 Subject: [PATCH 2/5] Add orange-widget-base to requrements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 5f26f69be..d30839df5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ docx2txt>=0.6 lxml biopython # Enables Pubmed widget. ufal.udpipe >=1.2.0.3 +orange-widget-base >=4.12.0 From 51d20781fb9c093d3b717f479cf64f323eb36731 Mon Sep 17 00:00:00 2001 From: Vesna Tanko Date: Tue, 23 Mar 2021 14:40:05 +0100 Subject: [PATCH 3/5] Word List: Handle words selection --- orangecontrib/text/widgets/owwordlist.py | 43 +++++++++++------- .../text/widgets/tests/test_owwordlist.py | 44 ++++++++++++++++++- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/orangecontrib/text/widgets/owwordlist.py b/orangecontrib/text/widgets/owwordlist.py index 6c7cb5403..b024b1a4a 100644 --- a/orangecontrib/text/widgets/owwordlist.py +++ b/orangecontrib/text/widgets/owwordlist.py @@ -23,8 +23,6 @@ PyListModel from Orange.widgets.widget import Input, Output, OWWidget, Msg -from orangecontrib.text import Corpus - @contextmanager def disconnected(signal, slot, connection_type=Qt.AutoConnection): @@ -156,7 +154,7 @@ class Inputs: class Outputs: selected_words = Output("Selected Words", Table) - words = Output("Words", Corpus) + words = Output("Words", Table) class Warning(OWWidget.Warning): no_string_vars = Msg("Input 'Words' needs at least one Text variable.") @@ -277,6 +275,7 @@ def __on_library_selection_changed(self, selected: QItemSelection, *_): current = index[0] word_list: WordList = self.library_model[current] self.word_list_index = current + self.selected_words = set() self.words_model.wrap(list(word_list.cached_words)) self._apply_update_rule() @@ -492,9 +491,19 @@ def _apply_update_rule(self): self.library_model[lib_index].update_rule_flag = modified self._set_word_list_modified(mod_type=self.NONE) self.library_view.repaint() - self.commit() + + # Apply selection. selection_changed invokes commit(). + # If there is no selection, call commit explicitly. + if any(w in self.selected_words for w in self.words_model): + self.set_selected_words() + self.words_view.repaint() + else: + self.commit() def commit(self): + selection = self._get_selected_words_indices() + self.selected_words = set(np.array(self.words_model)[selection]) + words, selected_words = None, None if self.words_model: words_var = StringVariable("Words") @@ -502,7 +511,6 @@ def commit(self): domain = Domain([], metas=[words_var]) _words = Table.from_list(domain, [[w] for w in self.words_model]) _words.name = "Words" - selection = self._get_selected_words_indices() if selection: selected_words = _words[selection] words = create_annotated_table(_words, selection) @@ -546,29 +554,34 @@ def _get_selected_words_indices(self) -> List[int]: rows = self.words_view.selectionModel().selectedRows() return [row.row() for row in rows] + def set_selected_words(self): + if self.selected_words: + indices = [i for i, w in enumerate(self.words_model) + if w in self.selected_words] + self._set_selected_words(indices) + def _restore_state(self): - index = self.word_list_index source = [WordList.from_dict(s) for s in self.word_list_library] self.library_model.wrap(source) - self._set_selected_word_list(index) + # __on_library_selection_changed() (invoked by _set_selected_word_list) + # clears self.selected_words + selected_words = self.selected_words + self._set_selected_word_list(self.word_list_index) if self.words is not None: self.words_model.wrap(list(self.words)) self._set_word_list_modified(mod_type=self.CACHED) - if self.selected_words: - indices = [i for i, w in enumerate(self.words) - if w in self.selected_words] - self._set_selected_words(indices) - else: + if selected_words: + self.selected_words = selected_words + self.set_selected_words() + elif len(self.word_list_library) > self.word_list_index and \ + self.word_list_library[self.word_list_index] != self.words: self.commit() def _save_state(self): self.word_list_library = [s.as_dict() for s in self.library_model] self.words = self.words_model[:] - selected_indices = self._get_selected_words_indices() - self.selected_words = set(np.array(self.words_model)[selected_indices]) - if __name__ == "__main__": from Orange.widgets.utils.widgetpreview import WidgetPreview diff --git a/orangecontrib/text/widgets/tests/test_owwordlist.py b/orangecontrib/text/widgets/tests/test_owwordlist.py index bd7ad2a33..3b8cbe058 100644 --- a/orangecontrib/text/widgets/tests/test_owwordlist.py +++ b/orangecontrib/text/widgets/tests/test_owwordlist.py @@ -361,14 +361,54 @@ def test_saved_settings(self): ["changed", "word 2", "word 3", "word 4"]) def test_saved_workflow(self): + settings = {"word_list_library": [{"name": "Hello world", + "words": self._word_list_1}, + {"name": "Word list 1", + "words": self._word_list_2}]} + widget = self.create_widget(OWWordList, stored_settings=settings) + words = self.get_output(widget.Outputs.words, widget=widget) + self.assertListEqual(list(words.metas[:, 0]), ["foo", "bar", "baz"]) + swords = self.get_output(widget.Outputs.selected_words, widget=widget) + self.assertIsNone(swords) + + def test_saved_workflow_with_changed_words(self): + settings = {"word_list_library": [{"name": "Hello world", + "words": self._word_list_1}, + {"name": "Word list 1", + "words": self._word_list_2}], + "words": ["bar", "foo"]} + widget = self.create_widget(OWWordList, stored_settings=settings) + words = self.get_output(widget.Outputs.words, widget=widget) + self.assertListEqual(list(words.metas[:, 0]), ["bar", "foo"]) + swords = self.get_output(widget.Outputs.selected_words, widget=widget) + self.assertIsNone(swords) + + def test_saved_workflow_with_selection(self): + settings = {"word_list_library": [{"name": "Hello world", + "words": self._word_list_1}, + {"name": "Word list 1", + "words": self._word_list_2}], + "words": ["bar", "foo"], + "selected_words": {"foo"}} + widget = self.create_widget(OWWordList, stored_settings=settings) + words = self.get_output(widget.Outputs.words, widget=widget) + self.assertListEqual(list(words.metas[:, 0]), ["bar", "foo"]) + swords = self.get_output(widget.Outputs.selected_words, widget=widget) + self.assertListEqual(list(swords.metas[:, 0]), ["foo"]) + + def test_saved_workflow_with_input(self): settings = {"word_list_library": [{"name": "Hello world", "words": self._word_list_1}, {"name": "Word list 1", "words": self._word_list_2}], - "words": {"foo"}} + "words": ["bar", "foo"], + "selected_words": {"foo"}} widget = self.create_widget(OWWordList, stored_settings=settings) + self.send_signal(widget.Inputs.words, self._input_table, widget=widget) words = self.get_output(widget.Outputs.words, widget=widget) - self.assertListEqual(list(words.metas[:, 0]), ["foo"]) + self.assertListEqual(list(words.metas[:, 0]), ["bar", "foo"]) + swords = self.get_output(widget.Outputs.selected_words, widget=widget) + self.assertListEqual(list(swords.metas[:, 0]), ["foo"]) if __name__ == "__main__": From 88e561f60d71597ed4cc22d81043a4be839bce79 Mon Sep 17 00:00:00 2001 From: Vesna Tanko Date: Wed, 24 Mar 2021 09:17:20 +0100 Subject: [PATCH 4/5] Word List: Implement send_report() --- orangecontrib/text/widgets/owwordlist.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/orangecontrib/text/widgets/owwordlist.py b/orangecontrib/text/widgets/owwordlist.py index b024b1a4a..8d4825b44 100644 --- a/orangecontrib/text/widgets/owwordlist.py +++ b/orangecontrib/text/widgets/owwordlist.py @@ -582,6 +582,18 @@ def _save_state(self): self.word_list_library = [s.as_dict() for s in self.library_model] self.words = self.words_model[:] + def send_report(self): + library = self.library_model[self.word_list_index].name \ + if self.library_model else "/" + settings = [("Library", library)] + if self.__input_words: + self.report_data("Input Words", self.__input_words) + settings.append(("Word variable", self.words_var)) + rule = UpdateRules.ITEMS[self.update_rule_index] + settings.append(("Update", rule)) + self.report_items("Settings", settings) + self.report_paragraph("Words", ", ".join(self.words_model[:])) + if __name__ == "__main__": from Orange.widgets.utils.widgetpreview import WidgetPreview From 70d09662fad2297b93bfec8542329b7500af952b Mon Sep 17 00:00:00 2001 From: Vesna Tanko Date: Tue, 30 Mar 2021 13:44:31 +0200 Subject: [PATCH 5/5] Word List: Minor fixes --- orangecontrib/text/widgets/owwordlist.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/orangecontrib/text/widgets/owwordlist.py b/orangecontrib/text/widgets/owwordlist.py index 8d4825b44..6088fa587 100644 --- a/orangecontrib/text/widgets/owwordlist.py +++ b/orangecontrib/text/widgets/owwordlist.py @@ -79,7 +79,7 @@ def generate_word_list_name(taken_names: List[str]) -> str: class UpdateRules: INTERSECT, UNION, INPUT, LIBRARY = range(4) - ITEMS = ["Intersection", "Union", "Only Input", "Ignore Input"] + ITEMS = ["Intersection", "Union", "Only input", "Ignore input"] @staticmethod def update(model: PyListModel, lib_words: List, in_words: List, rule: int): @@ -157,7 +157,7 @@ class Outputs: words = Output("Words", Table) class Warning(OWWidget.Warning): - no_string_vars = Msg("Input 'Words' needs at least one Text variable.") + no_string_vars = Msg("Input needs at least one Text variable.") NONE, CACHED, LIBRARY = range(3) # library list modification types @@ -165,12 +165,6 @@ class Warning(OWWidget.Warning): resizing_enabled = True settingsHandler = DomainContextHandler() - # word_list_library: List[Dict] = Setting([ - # {"name": "Untitled", - # "words": ["foo", "bar", "baz"]}, - # {"name": "Word list", - # "words": ["word 1", "word 2", "word 3", "word 4"]}, - # ]) word_list_library: List[Dict] = Setting([ {"name": WordList.generate_word_list_name([]), "words": []}, ])