source: orange/Orange/OrangeWidgets/Data/OWEditDomain.py @ 11887:d95a255f103e

Revision 11887:d95a255f103e, 21.2 KB checked in by Ales Erjavec <ales.erjavec@…>, 2 weeks ago (diff)

Fixed an AttributeError in Edit Domain widget.

Line 
1import unicodedata
2
3from OWWidget import *
4from OWItemModels import VariableListModel, PyListModel
5
6import OWGUI
7
8import Orange
9
10NAME = "Edit Domain"
11DESCRIPTION = """Supports renaming of features and their values."""
12ICON = "icons/EditDomain.svg"
13PRIORITY = 3125
14AUTHOR = "Ales Erjavec"
15AUTHOR_EMAIL = "ales.erjavec(@at@)fri.uni-lj.si"
16INPUTS = [("Data", Orange.data.Table, "set_data")]
17OUTPUTS = [("Data", Orange.data.Table, )]
18
19
20def is_discrete(var):
21    return isinstance(var, Orange.feature.Discrete)
22
23
24def is_continuous(var):
25    return isinstance(var, Orange.feature.Continuous)
26
27
28def get_qualified(module, name):
29    """Return a qualified module member ``name`` inside the named
30    ``module``.
31
32    The module (or package) first gets imported and the name
33    is retrieved from the module's global namespace.
34
35    """
36    # see __import__.__doc__ for why 'fromlist' is used
37    module = __import__(module, fromlist=[name])
38    return getattr(module, name)
39
40
41def variable_description(var):
42    """Return a variable descriptor.
43
44    A descriptor is a hashable tuple which should uniquely define
45    the variable i.e. (module, type_name, variable_name,
46    any_kwargs, sorted-attributes-items).
47
48    """
49    var_type = type(var)
50    if is_discrete(var):
51        return (var_type.__module__,
52                var_type.__name__,
53                var.name,
54                (("values", tuple(var.values)),),
55                tuple(sorted(var.attributes.items())))
56    else:
57        return (var_type.__module__,
58                var_type.__name__,
59                var.name,
60                (),
61                tuple(sorted(var.attributes.items())))
62
63
64def variable_from_description(description):
65    """Construct a variable from its description (see
66    :func:`variable_description`).
67
68    """
69    module, type_name, name, kwargs, attrs = description
70    try:
71        type = get_qualified(module, type_name)
72    except (ImportError, AttributeError), ex:
73        raise ValueError("Invalid descriptor type '{}.{}"
74                         "".format(module, type_name))
75
76    var = type(name, **dict(list(kwargs)))
77    var.attributes.update(attrs)
78    return var
79
80from PyQt4 import QtCore, QtGui
81
82QtCore.Slot = QtCore.pyqtSlot
83QtCore.Signal = QtCore.pyqtSignal
84
85
86class PyStandardItem(QStandardItem):
87    def __lt__(self, other):
88        return id(self) < id(other)
89
90
91class DictItemsModel(QStandardItemModel):
92    """A Qt Item Model class displaying the contents of a python
93    dictionary.
94
95    """
96    # Implement a proper model with in-place editing.
97    # (Maybe it should be a TableModel with 2 columns)
98    def __init__(self, parent=None, dict={}):
99        QStandardItemModel.__init__(self, parent)
100        self.setHorizontalHeaderLabels(["Key", "Value"])
101        self.set_dict(dict)
102
103    def set_dict(self, dict):
104        self._dict = dict
105        self.clear()
106        self.setHorizontalHeaderLabels(["Key", "Value"])
107        for key, value in sorted(dict.items()):
108            key_item = PyStandardItem(QString(key))
109            value_item = PyStandardItem(QString(value))
110            key_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
111            value_item.setFlags(value_item.flags() | Qt.ItemIsEditable)
112            self.appendRow([key_item, value_item])
113
114    def get_dict(self):
115        dict = {}
116        for row in range(self.rowCount()):
117            key_item = self.item(row, 0)
118            value_item = self.item(row, 1)
119            dict[str(key_item.text())] = str(value_item.text())
120        return dict
121
122
123class VariableEditor(QWidget):
124    """An editor widget for a variable.
125
126    Can edit the variable name, and its attributes dictionary.
127
128    """
129    def __init__(self, parent=None):
130        QWidget.__init__(self, parent)
131        self.setup_gui()
132
133    def setup_gui(self):
134        layout = QVBoxLayout()
135        self.setLayout(layout)
136
137        self.main_form = QFormLayout()
138        self.main_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
139        layout.addLayout(self.main_form)
140
141        self._setup_gui_name()
142        self._setup_gui_labels()
143
144    def _setup_gui_name(self):
145        self.name_edit = QLineEdit()
146        self.main_form.addRow("Name", self.name_edit)
147        self.name_edit.editingFinished.connect(self.on_name_changed)
148
149    def _setup_gui_labels(self):
150        vlayout = QVBoxLayout()
151        vlayout.setContentsMargins(0, 0, 0, 0)
152        vlayout.setSpacing(1)
153
154        self.labels_edit = QTreeView()
155        self.labels_edit.setEditTriggers(QTreeView.CurrentChanged)
156        self.labels_edit.setRootIsDecorated(False)
157
158        self.labels_model = DictItemsModel()
159        self.labels_edit.setModel(self.labels_model)
160
161        self.labels_edit.selectionModel().selectionChanged.connect(
162            self.on_label_selection_changed)
163
164        # Necessary signals to know when the labels change
165        self.labels_model.dataChanged.connect(self.on_labels_changed)
166        self.labels_model.rowsInserted.connect(self.on_labels_changed)
167        self.labels_model.rowsRemoved.connect(self.on_labels_changed)
168
169        vlayout.addWidget(self.labels_edit)
170        hlayout = QHBoxLayout()
171        hlayout.setContentsMargins(0, 0, 0, 0)
172        hlayout.setSpacing(1)
173        self.add_label_action = QAction("+", self,
174                        toolTip="Add a new label.",
175                        triggered=self.on_add_label,
176                        enabled=False,
177                        shortcut=QKeySequence(QKeySequence.New))
178
179        self.remove_label_action = QAction(
180                        unicodedata.lookup("MINUS SIGN"), self,
181                        toolTip="Remove selected label.",
182                        triggered=self.on_remove_label,
183                        enabled=False,
184                        shortcut=QKeySequence(QKeySequence.Delete))
185
186        button_size = OWGUI.toolButtonSizeHint()
187        button_size = QSize(button_size, button_size)
188
189        button = QToolButton(self)
190        button.setFixedSize(button_size)
191        button.setDefaultAction(self.add_label_action)
192        hlayout.addWidget(button)
193
194        button = QToolButton(self)
195        button.setFixedSize(button_size)
196        button.setDefaultAction(self.remove_label_action)
197        hlayout.addWidget(button)
198        hlayout.addStretch(10)
199        vlayout.addLayout(hlayout)
200
201        self.main_form.addRow("Labels", vlayout)
202
203    def set_data(self, var):
204        """Set the variable to edit.
205        """
206        self.clear()
207        self.var = var
208
209        if var is not None:
210            self.name_edit.setText(var.name)
211            self.labels_model.set_dict(dict(var.attributes))
212            self.add_label_action.setEnabled(True)
213        else:
214            self.add_label_action.setEnabled(False)
215            self.remove_label_action.setEnabled(False)
216
217    def get_data(self):
218        """Retrieve the modified variable.
219        """
220        name = str(self.name_edit.text())
221        labels = self.labels_model.get_dict()
222
223        # Is the variable actually changed.
224        if not self.is_same():
225            var = type(self.var)(name)
226            var.attributes.update(labels)
227            self.var = var
228        else:
229            var = self.var
230
231        return var
232
233    def is_same(self):
234        """Is the current model state the same as the input.
235        """
236        name = str(self.name_edit.text())
237        labels = self.labels_model.get_dict()
238
239        return self.var and name == self.var.name and labels == self.var.attributes
240
241    def clear(self):
242        """Clear the editor state.
243        """
244        self.var = None
245        self.name_edit.setText("")
246        self.labels_model.set_dict({})
247
248    def maybe_commit(self):
249        if not self.is_same():
250            self.commit()
251
252    def commit(self):
253        """Emit a ``variable_changed()`` signal.
254        """
255        self.emit(SIGNAL("variable_changed()"))
256
257    @QtCore.Slot()
258    def on_name_changed(self):
259        self.maybe_commit()
260
261    @QtCore.Slot()
262    def on_labels_changed(self, *args):
263        self.maybe_commit()
264
265    @QtCore.Slot()
266    def on_add_label(self):
267        self.labels_model.appendRow([PyStandardItem(""), PyStandardItem("")])
268        row = self.labels_model.rowCount() - 1
269        index = self.labels_model.index(row, 0)
270        self.labels_edit.edit(index)
271
272    @QtCore.Slot()
273    def on_remove_label(self):
274        rows = self.labels_edit.selectionModel().selectedRows()
275        if rows:
276            row = rows[0]
277            self.labels_model.removeRow(row.row())
278
279    @QtCore.Slot()
280    def on_label_selection_changed(self):
281        selected = self.labels_edit.selectionModel().selectedRows()
282        self.remove_label_action.setEnabled(bool(len(selected)))
283
284
285class DiscreteVariableEditor(VariableEditor):
286    """An editor widget for editing a discrete variable.
287
288    Extends the :class:`VariableEditor` to enable editing of
289    variables values.
290
291    """
292    def setup_gui(self):
293        layout = QVBoxLayout()
294        self.setLayout(layout)
295
296        self.main_form = QFormLayout()
297        self.main_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
298        layout.addLayout(self.main_form)
299
300        self._setup_gui_name()
301        self._setup_gui_values()
302        self._setup_gui_labels()
303
304    def _setup_gui_values(self):
305        self.values_edit = QListView()
306        self.values_edit.setEditTriggers(QTreeView.CurrentChanged)
307        self.values_model = PyListModel(flags=Qt.ItemIsSelectable | \
308                                        Qt.ItemIsEnabled | Qt.ItemIsEditable)
309        self.values_edit.setModel(self.values_model)
310
311        self.values_model.dataChanged.connect(self.on_values_changed)
312        self.main_form.addRow("Values", self.values_edit)
313
314    def set_data(self, var):
315        """Set the variable to edit
316        """
317        VariableEditor.set_data(self, var)
318        self.values_model.wrap([])
319        if var is not None:
320            for v in var.values:
321                self.values_model.append(v)
322
323    def get_data(self):
324        """Retrieve the modified variable
325        """
326        name = str(self.name_edit.text())
327        labels = self.labels_model.get_dict()
328        values = map(str, self.values_model)
329
330        if not self.is_same():
331            var = type(self.var)(name, values=values)
332            var.attributes.update(labels)
333            self.var = var
334        else:
335            var = self.var
336
337        return var
338
339    def is_same(self):
340        """Is the current model state the same as the input.
341        """
342        values = map(str, self.values_model)
343        return VariableEditor.is_same(self) and self.var.values == values
344
345    def clear(self):
346        """Clear the model state.
347        """
348        VariableEditor.clear(self)
349        self.values_model.wrap([])
350
351    @QtCore.Slot()
352    def on_values_changed(self):
353        self.maybe_commit()
354
355
356class ContinuousVariableEditor(VariableEditor):
357    # TODO: enable editing of number_of_decimals, scientific format ...
358    pass
359
360
361class OWEditDomain(OWWidget):
362    contextHandlers = {
363        "": DomainContextHandler(
364            "",
365            ["domain_change_hints", "selected_index"]
366        )
367    }
368    settingsList = ["auto_commit"]
369
370    def __init__(self, parent=None, signalManager=None, title="Edit Domain"):
371        OWWidget.__init__(self, parent, signalManager, title)
372
373        self.inputs = [("Data", Orange.data.Table, self.set_data)]
374        self.outputs = [("Data", Orange.data.Table)]
375
376        # Settings
377
378        # Domain change hints maps from input variables description to
379        # the modified variables description as returned by
380        # `variable_description` function
381        self.domain_change_hints = {}
382        self.selected_index = 0
383        self.auto_commit = False
384        self.changed_flag = False
385
386        self.loadSettings()
387
388        #####
389        # GUI
390        #####
391
392        # The list of domain's variables.
393        box = OWGUI.widgetBox(self.controlArea, "Domain Features")
394        self.domain_view = QListView()
395        self.domain_view.setSelectionMode(QListView.SingleSelection)
396
397        self.domain_model = VariableListModel()
398
399        self.domain_view.setModel(self.domain_model)
400
401        self.connect(self.domain_view.selectionModel(),
402                     SIGNAL("selectionChanged(QItemSelection, QItemSelection)"),
403                     self.on_selection_changed)
404
405        box.layout().addWidget(self.domain_view)
406
407        # A stack for variable editor widgets.
408        box = OWGUI.widgetBox(self.mainArea, "Edit Feature")
409        self.editor_stack = QStackedWidget()
410        box.layout().addWidget(self.editor_stack)
411
412        box = OWGUI.widgetBox(self.controlArea, "Reset")
413
414        OWGUI.button(box, self, "Reset selected",
415                     callback=self.reset_selected,
416                     tooltip="Reset changes made to the selected feature"
417                     )
418
419        OWGUI.button(box, self, "Reset all",
420                     callback=self.reset_all,
421                     tooltip="Reset all changes made to the domain"
422                     )
423
424        box = OWGUI.widgetBox(self.controlArea, "Commit")
425
426        b = OWGUI.button(box, self, "&Commit",
427                         callback=self.commit,
428                         tooltip="Commit the data with the changed domain",
429                         )
430
431        cb = OWGUI.checkBox(box, self, "auto_commit",
432                            label="Commit automatically",
433                            tooltip="Commit the changed domain on any change",
434                            callback=self.commit_if)
435
436        OWGUI.setStopper(self, b, cb, "changed_flag",
437                         callback=self.commit)
438
439        self._editor_cache = {}
440        self.data = None
441        self.edited_variable_index = -1
442
443        self.resize(600, 500)
444
445    def clear(self):
446        """Clear the widget state.
447        """
448        self.data = None
449        self.domain_model[:] = []
450        self.domain_change_hints = {}
451        self.clear_editor()
452
453    def clear_editor(self):
454        """Clear the current editor widget
455        """
456        current = self.editor_stack.currentWidget()
457        if current:
458            QObject.disconnect(current, SIGNAL("variable_changed()"),
459                               self.on_variable_changed)
460            current.set_data(None)
461
462    def set_data(self, data=None):
463        self.closeContext("")
464        self.clear()
465        self.data = data
466        if data is not None:
467            input_domain = data.domain
468            all_vars = (list(input_domain.variables) +
469                        list(input_domain.class_vars) +
470                        input_domain.getmetas().values())
471
472            self.openContext("", data)
473
474            edited_vars = []
475
476            # Apply any saved transformations as listed in
477            # `domain_change_hints`
478
479            for var in all_vars:
480                desc = variable_description(var)
481                changed = self.domain_change_hints.get(desc, None)
482                if changed is not None:
483                    try:
484                        new = variable_from_description(changed)
485                    except ValueError, ex:
486#                        print ex
487                        new = None
488
489                    if new is not None:
490                        # Make sure orange's domain transformations will work.
491                        new.source_variable = var
492                        new.get_value_from = Orange.core.ClassifierFromVar(whichVar=var)
493                        var = new
494
495                edited_vars.append(var)
496
497            self.all_vars = all_vars
498            self.input_domain = input_domain
499
500            # Sets the model to display in the 'Domain Features' view
501            self.domain_model[:] = edited_vars
502
503            # Try to restore the variable selection
504            index = self.selected_index
505            if self.selected_index >= len(all_vars):
506                index = 0 if len(all_vars) else -1
507            if index >= 0:
508                self.select_variable(index)
509
510            self.changed_flag = True
511            self.commit_if()
512        else:
513            # To force send None on output
514            self.commit()
515
516    def on_selection_changed(self, *args):
517        """When selection in 'Domain Features' view changes.
518        """
519        i = self.selected_var_index()
520        if i is not None:
521            self.open_editor(i)
522            self.selected_index = i
523
524    def selected_var_index(self):
525        """Return the selected row in 'Domain Features' view or None
526        if no row is selected.
527
528        """
529        rows = self.domain_view.selectionModel().selectedRows()
530        if rows:
531            return rows[0].row()
532        else:
533            return None
534
535    def select_variable(self, index):
536        """Select the variable with ``index`` in the 'Domain Features'
537        view.
538
539        """
540        sel_model = self.domain_view.selectionModel()
541        sel_model.select(self.domain_model.index(index, 0),
542                         QItemSelectionModel.ClearAndSelect)
543
544    def open_editor(self, index):
545        """Open the editor for variable at ``index`` and move it
546        to the top if the stack.
547
548        """
549        # First remove and clear the current editor if any
550        self.clear_editor()
551
552        var = self.domain_model[index]
553
554        editor = self.editor_for_variable(var)
555        editor.set_data(var)
556        self.edited_variable_index = index
557
558        QObject.connect(editor, SIGNAL("variable_changed()"),
559                        self.on_variable_changed)
560        self.editor_stack.setCurrentWidget(editor)
561
562    def editor_for_variable(self, var):
563        """Return the editor for ``var``'s variable type.
564
565        The editors are cached and reused by type.
566
567        """
568        editor = None
569        if is_discrete(var):
570            editor = DiscreteVariableEditor
571        elif is_continuous(var):
572            editor = ContinuousVariableEditor
573        else:
574            editor = VariableEditor
575
576        if type(var) not in self._editor_cache:
577            editor = editor()
578            self._editor_cache[type(var)] = editor
579            self.editor_stack.addWidget(editor)
580
581        return self._editor_cache[type(var)]
582
583    def on_variable_changed(self):
584        """When the user edited the current variable in editor.
585        """
586        var = self.domain_model[self.edited_variable_index]
587        editor = self.editor_stack.currentWidget()
588        new_var = editor.get_data()
589
590        # Replace the variable in the 'Domain Features' view/model
591        self.domain_model[self.edited_variable_index] = new_var
592        old_var = self.all_vars[self.edited_variable_index]
593
594        # Store the transformation hint.
595        self.domain_change_hints[variable_description(old_var)] = \
596                    variable_description(new_var)
597
598        # Make orange's domain transformation work.
599        new_var.source_variable = old_var
600        new_var.get_value_from = Orange.core.ClassifierFromVar(whichVar=old_var)
601
602        self.commit_if()
603
604    def reset_all(self):
605        """Reset all variables to the input state.
606        """
607        self.domain_change_hints = {}
608        if self.data is not None:
609            # To invalidate stored hints
610            self.closeContext("")
611            self.openContext("", self.data)
612            self.domain_model[:] = self.all_vars
613            self.select_variable(self.selected_index)
614            self.commit_if()
615
616    def reset_selected(self):
617        """Reset the currently selected variable to its original
618        state.
619
620        """
621        if self.data is not None:
622            var = self.all_vars[self.selected_index]
623            desc = variable_description(var)
624            if desc in self.domain_change_hints:
625                del self.domain_change_hints[desc]
626
627            # To invalidate stored hints
628            self.closeContext("")
629            self.openContext("", self.data)
630
631            self.domain_model[self.selected_index] = var
632            self.editor_stack.currentWidget().set_data(var)
633            self.commit_if()
634
635    def commit_if(self):
636        if self.auto_commit:
637            self.commit()
638        else:
639            self.changed_flag = True
640
641    def commit(self):
642        """Commit the changed data to output.
643        """
644        new_data = None
645        if self.data is not None:
646            n_vars = len(self.input_domain.variables)
647            n_class_vars = len(self.input_domain.class_vars)
648            all_new_vars = list(self.domain_model)
649            variables = all_new_vars[: n_vars]
650            class_var = None
651            if self.input_domain.class_var:
652                class_var = variables[-1]
653                attributes = variables[:-1]
654            else:
655                attributes = variables
656
657            class_vars = all_new_vars[n_vars: n_vars + n_class_vars]
658            new_metas = all_new_vars[n_vars + n_class_vars:]
659            new_domain = Orange.data.Domain(attributes, class_var,
660                                            class_vars=class_vars)
661
662            # Assumes getmetas().items() order has not changed.
663            # TODO: store metaids in set_data method
664            for (mid, _), new in zip(self.input_domain.getmetas().items(),
665                                     new_metas):
666                new_domain.addmeta(mid, new)
667
668            new_data = Orange.data.Table(new_domain, self.data)
669
670        self.send("Data", new_data)
671        self.changed_flag = False
672
673
674def main():
675    import sys
676    app = QApplication(sys.argv)
677    w = OWEditDomain()
678    data = Orange.data.Table("iris")
679#    data = Orange.data.Table("rep:GDS636.tab")
680    w.set_data(data)
681    w.show()
682    rval = app.exec_()
683    w.set_data(None)
684    w.saveSettings()
685    return rval
686
687if __name__ == "__main__":
688    import sys
689    sys.exit(main())
Note: See TracBrowser for help on using the repository browser.