Source

orange / Orange / OrangeWidgets / Data / OWEditDomain.py

Full commit
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
from OWWidget import *
from OWItemModels import VariableListModel, PyListModel

import OWGUI

import Orange

NAME = "Edit Domain"
DESCRIPTION = """Supports renaming of features and their values."""
ICON = "icons/EditDomain.svg"
PRIORITY = 3125
AUTHOR = "Ales Erjavec"
AUTHOR_EMAIL = "ales.erjavec(@at@)fri.uni-lj.si"
INPUTS = [("Data", Orange.data.Table, "set_data")]
OUTPUTS = [("Data", Orange.data.Table, )]


def is_discrete(var):
    return isinstance(var, Orange.feature.Discrete)


def is_continuous(var):
    return isinstance(var, Orange.feature.Continuous)


def get_qualified(module, name):
    """Return a qualified module member ``name`` inside the named
    ``module``.

    The module (or package) first gets imported and the name
    is retrieved from the module's global namespace.

    """
    # see __import__.__doc__ for why 'fromlist' is used
    module = __import__(module, fromlist=[name])
    return getattr(module, name)


def variable_description(var):
    """Return a variable descriptor.

    A descriptor is a hashable tuple which should uniquely define
    the variable i.e. (module, type_name, variable_name,
    any_kwargs, sorted-attributes-items).

    """
    var_type = type(var)
    if is_discrete(var):
        return (var_type.__module__,
                var_type.__name__,
                var.name,
                (("values", tuple(var.values)),),
                tuple(sorted(var.attributes.items())))
    else:
        return (var_type.__module__,
                var_type.__name__,
                var.name,
                (),
                tuple(sorted(var.attributes.items())))


def variable_from_description(description):
    """Construct a variable from its description (see
    :func:`variable_description`).

    """
    module, type_name, name, kwargs, attrs = description
    try:
        type = get_qualified(module, type_name)
    except (ImportError, AttributeError), ex:
        raise ValueError("Invalid descriptor type '{}.{}"
                         "".format(module, type_name))

    var = type(name, **dict(list(kwargs)))
    var.attributes.update(attrs)
    return var

from PyQt4 import QtCore, QtGui

QtCore.Slot = QtCore.pyqtSlot
QtCore.Signal = QtCore.pyqtSignal


class PyStandardItem(QStandardItem):
    def __lt__(self, other):
        return id(self) < id(other)


class DictItemsModel(QStandardItemModel):
    """A Qt Item Model class displaying the contents of a python
    dictionary.

    """
    # Implement a proper model with in-place editing.
    # (Maybe it should be a TableModel with 2 columns)
    def __init__(self, parent=None, dict={}):
        QStandardItemModel.__init__(self, parent)
        self.setHorizontalHeaderLabels(["Key", "Value"])
        self.set_dict(dict)

    def set_dict(self, dict):
        self._dict = dict
        self.clear()
        self.setHorizontalHeaderLabels(["Key", "Value"])
        for key, value in sorted(dict.items()):
            key_item = PyStandardItem(QString(key))
            value_item = PyStandardItem(QString(value))
            key_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
            value_item.setFlags(value_item.flags() | Qt.ItemIsEditable)
            self.appendRow([key_item, value_item])

    def get_dict(self):
        dict = {}
        for row in range(self.rowCount()):
            key_item = self.item(row, 0)
            value_item = self.item(row, 1)
            dict[str(key_item.text())] = str(value_item.text())
        return dict


class VariableEditor(QWidget):
    """An editor widget for a variable.

    Can edit the variable name, and its attributes dictionary.

    """
    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.setup_gui()

    def setup_gui(self):
        layout = QVBoxLayout()
        self.setLayout(layout)

        self.main_form = QFormLayout()
        self.main_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
        layout.addLayout(self.main_form)

        self._setup_gui_name()
        self._setup_gui_labels()

    def _setup_gui_name(self):
        self.name_edit = QLineEdit()
        self.main_form.addRow("Name", self.name_edit)
        self.name_edit.editingFinished.connect(self.on_name_changed)

    def _setup_gui_labels(self):
        vlayout = QVBoxLayout()
        vlayout.setContentsMargins(0, 0, 0, 0)
        vlayout.setSpacing(1)

        self.labels_edit = QTreeView()
        self.labels_edit.setEditTriggers(QTreeView.CurrentChanged)
        self.labels_edit.setRootIsDecorated(False)

        self.labels_model = DictItemsModel()
        self.labels_edit.setModel(self.labels_model)

        self.labels_edit.selectionModel().selectionChanged.connect(
            self.on_label_selection_changed)

        # Necessary signals to know when the labels change
        self.labels_model.dataChanged.connect(self.on_labels_changed)
        self.labels_model.rowsInserted.connect(self.on_labels_changed)
        self.labels_model.rowsRemoved.connect(self.on_labels_changed)

        vlayout.addWidget(self.labels_edit)
        hlayout = QHBoxLayout()
        hlayout.setContentsMargins(0, 0, 0, 0)
        hlayout.setSpacing(1)
        self.add_label_action = QAction("+", self,
                        toolTip="Add a new label.",
                        triggered=self.on_add_label,
                        enabled=False,
                        shortcut=QKeySequence(QKeySequence.New))

        self.remove_label_action = QAction("-", self,
                        toolTip="Remove selected label.",
                        triggered=self.on_remove_label,
                        enabled=False,
                        shortcut=QKeySequence(QKeySequence.Delete))

        button_size = OWGUI.toolButtonSizeHint()
        button_size = QSize(button_size, button_size)

        button = QToolButton(self)
        button.setFixedSize(button_size)
        button.setDefaultAction(self.add_label_action)
        hlayout.addWidget(button)

        button = QToolButton(self)
        button.setFixedSize(button_size)
        button.setDefaultAction(self.remove_label_action)
        hlayout.addWidget(button)
        hlayout.addStretch(10)
        vlayout.addLayout(hlayout)

        self.main_form.addRow("Labels", vlayout)

    def set_data(self, var):
        """Set the variable to edit.
        """
        self.clear()
        self.var = var

        if var is not None:
            self.name_edit.setText(var.name)
            self.labels_model.set_dict(dict(var.attributes))
            self.add_label_action.setEnabled(True)
        else:
            self.add_label_action.setEnabled(False)
            self.remove_label_action.setEnabled(False)

    def get_data(self):
        """Retrieve the modified variable.
        """
        name = str(self.name_edit.text())
        labels = self.labels_model.get_dict()

        # Is the variable actually changed.
        if not self.is_same():
            var = type(self.var)(name)
            var.attributes.update(labels)
            self.var = var
        else:
            var = self.var

        return var

    def is_same(self):
        """Is the current model state the same as the input.
        """
        name = str(self.name_edit.text())
        labels = self.labels_model.get_dict()

        return self.var and name == self.var.name and labels == self.var.attributes

    def clear(self):
        """Clear the editor state.
        """
        self.var = None
        self.name_edit.setText("")
        self.labels_model.set_dict({})

    def maybe_commit(self):
        if not self.is_same():
            self.commit()

    def commit(self):
        """Emit a ``variable_changed()`` signal.
        """
        self.emit(SIGNAL("variable_changed()"))

    @QtCore.Slot()
    def on_name_changed(self):
        self.maybe_commit()

    @QtCore.Slot()
    def on_labels_changed(self, *args):
        self.maybe_commit()

    @QtCore.Slot()
    def on_add_label(self):
        self.labels_model.appendRow([PyStandardItem(""), PyStandardItem("")])
        row = self.labels_model.rowCount() - 1
        index = self.labels_model.index(row, 0)
        self.labels_edit.edit(index)

    @QtCore.Slot()
    def on_remove_label(self):
        rows = self.labels_edit.selectionModel().selectedRows()
        if rows:
            row = rows[0]
            self.labels_model.removeRow(row.row())

    @QtCore.Slot()
    def on_label_selection_changed(self):
        selected = self.labels_edit.selectionModel().selectedRows()
        self.remove_label_action.setEnabled(bool(len(selected)))


class DiscreteVariableEditor(VariableEditor):
    """An editor widget for editing a discrete variable.

    Extends the :class:`VariableEditor` to enable editing of
    variables values.

    """
    def setup_gui(self):
        layout = QVBoxLayout()
        self.setLayout(layout)

        self.main_form = QFormLayout()
        self.main_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
        layout.addLayout(self.main_form)

        self._setup_gui_name()
        self._setup_gui_values()
        self._setup_gui_labels()

    def _setup_gui_values(self):
        self.values_edit = QListView()
        self.values_edit.setEditTriggers(QTreeView.CurrentChanged)
        self.values_model = PyListModel(flags=Qt.ItemIsSelectable | \
                                        Qt.ItemIsEnabled | Qt.ItemIsEditable)
        self.values_edit.setModel(self.values_model)

        self.values_model.dataChanged.connect(self.on_values_changed)
        self.main_form.addRow("Values", self.values_edit)

    def set_data(self, var):
        """Set the variable to edit
        """
        VariableEditor.set_data(self, var)
        self.values_model.wrap([])
        if var is not None:
            for v in var.values:
                self.values_model.append(v)

    def get_data(self):
        """Retrieve the modified variable
        """
        name = str(self.name_edit.text())
        labels = self.labels_model.get_dict()
        values = map(str, self.values_model)

        if not self.is_same():
            var = type(self.var)(name, values=values)
            var.attributes.update(labels)
            self.var = var
        else:
            var = self.var

        return var

    def is_same(self):
        """Is the current model state the same as the input.
        """
        values = map(str, self.values_model)
        return VariableEditor.is_same(self) and self.var.values == values

    def clear(self):
        """Clear the model state.
        """
        VariableEditor.clear(self)
        self.values_model.wrap([])

    @QtCore.Slot()
    def on_values_changed(self):
        self.maybe_commit()


class ContinuousVariableEditor(VariableEditor):
    # TODO: enable editing of number_of_decimals, scientific format ...
    pass


class OWEditDomain(OWWidget):
    contextHandlers = {
        "": DomainContextHandler(
            "",
            ["domain_change_hints", "selected_index"]
        )
    }
    settingsList = ["auto_commit"]

    def __init__(self, parent=None, signalManager=None, title="Edit Domain"):
        OWWidget.__init__(self, parent, signalManager, title)

        self.inputs = [("Data", Orange.data.Table, self.set_data)]
        self.outputs = [("Data", Orange.data.Table)]

        # Settings

        # Domain change hints maps from input variables description to
        # the modified variables description as returned by
        # `variable_description` function
        self.domain_change_hints = {}
        self.selected_index = 0
        self.auto_commit = False
        self.changed_flag = False

        self.loadSettings()

        #####
        # GUI
        #####

        # The list of domain's variables.
        box = OWGUI.widgetBox(self.controlArea, "Domain Features")
        self.domain_view = QListView()
        self.domain_view.setSelectionMode(QListView.SingleSelection)

        self.domain_model = VariableListModel()

        self.domain_view.setModel(self.domain_model)

        self.connect(self.domain_view.selectionModel(),
                     SIGNAL("selectionChanged(QItemSelection, QItemSelection)"),
                     self.on_selection_changed)

        box.layout().addWidget(self.domain_view)

        # A stack for variable editor widgets.
        box = OWGUI.widgetBox(self.mainArea, "Edit Feature")
        self.editor_stack = QStackedWidget()
        box.layout().addWidget(self.editor_stack)

        box = OWGUI.widgetBox(self.controlArea, "Reset")

        OWGUI.button(box, self, "Reset selected",
                     callback=self.reset_selected,
                     tooltip="Reset changes made to the selected feature"
                     )

        OWGUI.button(box, self, "Reset all",
                     callback=self.reset_all,
                     tooltip="Reset all changes made to the domain"
                     )

        box = OWGUI.widgetBox(self.controlArea, "Commit")

        b = OWGUI.button(box, self, "&Commit",
                         callback=self.commit,
                         tooltip="Commit the data with the changed domain",
                         )

        cb = OWGUI.checkBox(box, self, "auto_commit",
                            label="Commit automatically",
                            tooltip="Commit the changed domain on any change",
                            callback=self.commit_if)

        OWGUI.setStopper(self, b, cb, "changed_flag",
                         callback=self.commit)

        self._editor_cache = {}

        self.resize(600, 500)

    def clear(self):
        """Clear the widget state.
        """
        self.data = None
        self.domain_model[:] = []
        self.domain_change_hints = {}
        self.clear_editor()

    def clear_editor(self):
        """Clear the current editor widget
        """
        current = self.editor_stack.currentWidget()
        if current:
            QObject.disconnect(current, SIGNAL("variable_changed()"),
                               self.on_variable_changed)
            current.set_data(None)

    def set_data(self, data=None):
        self.closeContext("")
        self.clear()
        self.data = data
        if data is not None:
            input_domain = data.domain
            all_vars = (list(input_domain.variables) +
                        list(input_domain.class_vars) +
                        input_domain.getmetas().values())

            self.openContext("", data)

            edited_vars = []

            # Apply any saved transformations as listed in
            # `domain_change_hints`

            for var in all_vars:
                desc = variable_description(var)
                changed = self.domain_change_hints.get(desc, None)
                if changed is not None:
                    try:
                        new = variable_from_description(changed)
                    except ValueError, ex:
#                        print ex
                        new = None

                    if new is not None:
                        # Make sure orange's domain transformations will work.
                        new.source_variable = var
                        new.get_value_from = Orange.core.ClassifierFromVar(whichVar=var)
                        var = new

                edited_vars.append(var)

            self.all_vars = all_vars
            self.input_domain = input_domain

            # Sets the model to display in the 'Domain Features' view
            self.domain_model[:] = edited_vars

            # Try to restore the variable selection
            index = self.selected_index
            if self.selected_index >= len(all_vars):
                index = 0 if len(all_vars) else -1
            if index >= 0:
                self.select_variable(index)

            self.changed_flag = True
            self.commit_if()
        else:
            # To force send None on output
            self.commit()

    def on_selection_changed(self, *args):
        """When selection in 'Domain Features' view changes.
        """
        i = self.selected_var_index()
        if i is not None:
            self.open_editor(i)
            self.selected_index = i

    def selected_var_index(self):
        """Return the selected row in 'Domain Features' view or None
        if no row is selected.

        """
        rows = self.domain_view.selectionModel().selectedRows()
        if rows:
            return rows[0].row()
        else:
            return None

    def select_variable(self, index):
        """Select the variable with ``index`` in the 'Domain Features'
        view.

        """
        sel_model = self.domain_view.selectionModel()
        sel_model.select(self.domain_model.index(index, 0),
                         QItemSelectionModel.ClearAndSelect)

    def open_editor(self, index):
        """Open the editor for variable at ``index`` and move it
        to the top if the stack.

        """
        # First remove and clear the current editor if any
        self.clear_editor()

        var = self.domain_model[index]

        editor = self.editor_for_variable(var)
        editor.set_data(var)
        self.edited_variable_index = index

        QObject.connect(editor, SIGNAL("variable_changed()"),
                        self.on_variable_changed)
        self.editor_stack.setCurrentWidget(editor)

    def editor_for_variable(self, var):
        """Return the editor for ``var``'s variable type.

        The editors are cached and reused by type.

        """
        editor = None
        if is_discrete(var):
            editor = DiscreteVariableEditor
        elif is_continuous(var):
            editor = ContinuousVariableEditor
        else:
            editor = VariableEditor

        if type(var) not in self._editor_cache:
            editor = editor()
            self._editor_cache[type(var)] = editor
            self.editor_stack.addWidget(editor)

        return self._editor_cache[type(var)]

    def on_variable_changed(self):
        """When the user edited the current variable in editor.
        """
        var = self.domain_model[self.edited_variable_index]
        editor = self.editor_stack.currentWidget()
        new_var = editor.get_data()

        # Replace the variable in the 'Domain Features' view/model
        self.domain_model[self.edited_variable_index] = new_var
        old_var = self.all_vars[self.edited_variable_index]

        # Store the transformation hint.
        self.domain_change_hints[variable_description(old_var)] = \
                    variable_description(new_var)

        # Make orange's domain transformation work.
        new_var.source_variable = old_var
        new_var.get_value_from = Orange.core.ClassifierFromVar(whichVar=old_var)

        self.commit_if()

    def reset_all(self):
        """Reset all variables to the input state.
        """
        self.domain_change_hints = {}
        if self.data is not None:
            # To invalidate stored hints
            self.closeContext("")
            self.openContext("", self.data)
            self.domain_model[:] = self.all_vars
            self.select_variable(self.selected_index)
            self.commit_if()

    def reset_selected(self):
        """Reset the currently selected variable to its original
        state.

        """
        if self.data is not None:
            var = self.all_vars[self.selected_index]
            desc = variable_description(var)
            if desc in self.domain_change_hints:
                del self.domain_change_hints[desc]

            # To invalidate stored hints
            self.closeContext("")
            self.openContext("", self.data)

            self.domain_model[self.selected_index] = var
            self.editor_stack.currentWidget().set_data(var)
            self.commit_if()

    def commit_if(self):
        if self.auto_commit:
            self.commit()
        else:
            self.changed_flag = True

    def commit(self):
        """Commit the changed data to output.
        """
        new_data = None
        if self.data is not None:
            n_vars = len(self.input_domain.variables)
            n_class_vars = len(self.input_domain.class_vars)
            all_new_vars = list(self.domain_model)
            variables = all_new_vars[: n_vars]
            class_var = None
            if self.input_domain.class_var:
                class_var = variables[-1]
                attributes = variables[:-1]
            else:
                attributes = variables

            class_vars = all_new_vars[n_vars: n_vars + n_class_vars]
            new_metas = all_new_vars[n_vars + n_class_vars:]
            new_domain = Orange.data.Domain(attributes, class_var,
                                            class_vars=class_vars)

            # Assumes getmetas().items() order has not changed.
            # TODO: store metaids in set_data method
            for (mid, _), new in zip(self.input_domain.getmetas().items(),
                                     new_metas):
                new_domain.addmeta(mid, new)

            new_data = Orange.data.Table(new_domain, self.data)

        self.send("Data", new_data)
        self.changed_flag = False


def main():
    import sys
    app = QApplication(sys.argv)
    w = OWEditDomain()
    data = Orange.data.Table("iris")
#    data = Orange.data.Table("rep:GDS636.tab")
    w.set_data(data)
    w.show()
    rval = app.exec_()
    w.set_data(None)
    w.saveSettings()
    return rval

if __name__ == "__main__":
    import sys
    sys.exit(main())