source: orange/Orange/OrangeWidgets/Data/OWEditDomain.py @ 10358:2bc0e1d04db4

Revision 10358:2bc0e1d04db4, 21.9 KB checked in by Ales Erjavec <ales.erjavec@…>, 2 years ago (diff)

Fixed feature descriptor lookup.

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