Skip to content

Commit 0684aa4

Browse files
authored
Merge pull request #6415 from janezd/edit-domain-noncontext
[FIX] Edit Domain no longer forgets its settings
2 parents 200a1b7 + c5fa7b7 commit 0684aa4

File tree

2 files changed

+232
-18
lines changed

2 files changed

+232
-18
lines changed

Orange/widgets/data/oweditdomain.py

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
)
4343
from Orange.misc.collections import DictMissingConst
4444
from Orange.util import frompyfunc
45-
from Orange.widgets import widget, gui, settings
45+
from Orange.widgets import widget, gui
46+
from Orange.widgets.settings import Setting
4647
from Orange.widgets.utils import itemmodels, ftry, disconnected
4748
from Orange.widgets.utils.buttons import FixedSizeButton
4849
from Orange.widgets.utils.itemmodels import signal_blocking
@@ -56,6 +57,8 @@
5657
V = TypeVar("V", bound=Orange.data.Variable) # pylint: disable=invalid-name
5758
H = TypeVar("H", bound=Hashable) # pylint: disable=invalid-name
5859

60+
MAX_HINTS = 1000
61+
5962

6063
def unique(sequence: Iterable[H]) -> Iterable[H]:
6164
"""
@@ -1888,13 +1891,11 @@ class Outputs:
18881891
class Error(widget.OWWidget.Error):
18891892
duplicate_var_name = widget.Msg("A variable name is duplicated.")
18901893

1891-
settingsHandler = settings.DomainContextHandler()
1892-
settings_version = 2
1894+
settings_version = 3
18931895

1894-
_domain_change_store = settings.ContextSetting({})
1895-
_selected_item = settings.ContextSetting(None) # type: Optional[Tuple[str, int]]
1896-
_merge_dialog_settings = settings.ContextSetting({})
1897-
output_table_name = settings.ContextSetting("")
1896+
_domain_change_hints = Setting({}, schema_only=True)
1897+
_merge_dialog_settings = Setting({}, schema_only=True)
1898+
output_table_name = Setting("", schema_only=True)
18981899

18991900
want_main_area = False
19001901

@@ -1903,6 +1904,7 @@ def __init__(self):
19031904
self.data = None # type: Optional[Orange.data.Table]
19041905
#: The current selected variable index
19051906
self.selected_index = -1
1907+
self._selected_item = None
19061908
self._invalidated = False
19071909
self.typeindex = 0
19081910

@@ -1960,14 +1962,12 @@ def __init__(self):
19601962
@Inputs.data
19611963
def set_data(self, data):
19621964
"""Set input dataset."""
1963-
self.closeContext()
19641965
self.clear()
19651966
self.data = data
19661967

19671968
if self.data is not None:
19681969
self.setup_model(data)
19691970
self.le_output_name.setPlaceholderText(data.name)
1970-
self.openContext(self.data)
19711971
self._editor.set_merge_context(self._merge_dialog_settings)
19721972
self._restore()
19731973
else:
@@ -1983,8 +1983,6 @@ def clear(self):
19831983
assert self.selected_index == -1
19841984
self.selected_index = -1
19851985

1986-
self._selected_item = None
1987-
self._domain_change_store = {}
19881986
self._merge_dialog_settings = {}
19891987

19901988
def reset_selected(self):
@@ -2006,7 +2004,6 @@ def reset_selected(self):
20062004

20072005
def reset_all(self):
20082006
"""Reset all variables to their original state."""
2009-
self._domain_change_store = {}
20102007
if self.data is not None:
20112008
model = self.variables_model
20122009
for i in range(model.rowCount()):
@@ -2049,19 +2046,30 @@ def _restore(self, ):
20492046
Restore the edit transform from saved state.
20502047
"""
20512048
model = self.variables_model
2049+
hints = self._domain_change_hints
2050+
first_key = None
20522051
for i in range(model.rowCount()):
20532052
midx = model.index(i, 0)
20542053
coldesc = model.data(midx, Qt.EditRole) # type: DataVector
2055-
tr = self._restore_transform(coldesc.vtype)
2054+
tr, key = self._restore_transform(coldesc.vtype)
20562055
if tr:
20572056
model.setData(midx, tr, TransformRole)
2057+
if first_key is None:
2058+
first_key = key
2059+
# Reduce the number of hints to MAX_HINTS, but keep all current hints
2060+
# Current hints start with `first_key`.
2061+
while len(hints) > MAX_HINTS and \
2062+
(key := next(iter(hints))) is not first_key:
2063+
del hints[key] # pylint: disable=unsupported-delete-operation
20582064

20592065
# Restore the current variable selection
20602066
i = -1
20612067
if self._selected_item is not None:
20622068
for i, vec in enumerate(model):
20632069
if vec.vtype.name_type() == self._selected_item:
20642070
break
2071+
else:
2072+
self._selected_item = None
20652073
if i == -1 and model.rowCount():
20662074
i = 0
20672075

@@ -2114,13 +2122,20 @@ def _on_variable_changed(self):
21142122
self._store_transform(var, transform)
21152123
self._invalidate()
21162124

2117-
def _store_transform(self, var, transform):
2125+
def _store_transform(self, var, transform, deconvar=None):
21182126
# type: (Variable, List[Transform]) -> None
2119-
self._domain_change_store[deconstruct(var)] = [deconstruct(t) for t in transform]
2127+
deconvar = deconvar or deconstruct(var)
2128+
# Remove the existing key (if any) to put the new one at the end,
2129+
# to make sure it comes after the sentinel
2130+
self._domain_change_hints.pop(deconvar, None)
2131+
# pylint: disable=unsupported-assignment-operation
2132+
self._domain_change_hints[deconvar] = \
2133+
[deconstruct(t) for t in transform]
21202134

21212135
def _restore_transform(self, var):
21222136
# type: (Variable) -> List[Transform]
2123-
tr_ = self._domain_change_store.get(deconstruct(var), [])
2137+
key = deconstruct(var)
2138+
tr_ = self._domain_change_hints.get(key, [])
21242139
tr = []
21252140

21262141
for t in tr_:
@@ -2131,7 +2146,11 @@ def _restore_transform(self, var):
21312146
"Failed to restore transform: {}, {!r}".format(t, err),
21322147
UserWarning, stacklevel=2
21332148
)
2134-
return tr
2149+
if tr:
2150+
self._store_transform(var, tr, key)
2151+
else:
2152+
key = None
2153+
return tr, key
21352154

21362155
def _invalidate(self):
21372156
self._set_modified(True)
@@ -2295,6 +2314,29 @@ def migrate_context(cls, context, version):
22952314
store.append((deconstruct(src), [deconstruct(tr) for tr in trs]))
22962315
context.values["_domain_change_store"] = (dict(store), -2)
22972316

2317+
@classmethod
2318+
def migrate_settings(cls, settings, version):
2319+
if version == 2 and "context_settings" in settings:
2320+
contexts = settings["context_settings"]
2321+
valuess = []
2322+
for context in contexts:
2323+
cls.migrate_context(context, context.values["__version__"])
2324+
valuess.append(context.values)
2325+
# Fix the order of keys
2326+
hints = dict.fromkeys(
2327+
chain(*(values["_domain_change_store"][0]
2328+
for values in reversed(valuess)))
2329+
)
2330+
settings["output_table_name"] = ""
2331+
for values in valuess:
2332+
hints.update(values["_domain_change_store"][0])
2333+
new_name, _ = values.pop("output_table_name", ("", -2))
2334+
if new_name:
2335+
settings["output_table_name"] = new_name
2336+
while len(hints) > MAX_HINTS:
2337+
del hints[next(iter(hints))]
2338+
settings["_domain_change_hints"] = hints
2339+
del settings["context_settings"]
22982340

22992341
def enumerate_columns(
23002342
table: Orange.data.Table

Orange/widgets/data/tests/test_oweditdomain.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
QStyleOptionViewItem, QDialog, QMenu, QToolTip, QListView
1717
from AnyQt.QtTest import QTest, QSignalSpy
1818

19+
from orangewidget.settings import Context
1920
from orangewidget.tests.utils import simulate
2021

2122
from Orange.data import (
@@ -325,7 +326,7 @@ def test_restore(self):
325326
w = self.widget
326327

327328
def restore(state):
328-
w._domain_change_store = state
329+
w._domain_change_hints = state
329330
w._restore()
330331

331332
model = w.variables_model
@@ -338,6 +339,177 @@ def restore(state):
338339
tr = model.data(model.index(4), TransformRole)
339340
self.assertEqual(tr, [AsString(), Rename("Z")])
340341

342+
def test_hint_keeping(self):
343+
editor: ContinuousVariableEditor = self.widget.findChild(ContinuousVariableEditor)
344+
name_edit = editor.name_edit
345+
model = self.widget.domain_view.model()
346+
347+
def rename(fr, to):
348+
for idx in range(fr, to):
349+
self.widget.domain_view.setCurrentIndex(model.index(idx))
350+
cur_text = name_edit.text()
351+
if cur_text[0] != "x":
352+
name_edit.setText("x" + cur_text)
353+
editor.on_name_changed()
354+
355+
def data(fr, to):
356+
return Table.from_numpy(Domain(vars[fr:to]),
357+
np.zeros((1, to - fr)))
358+
359+
360+
vars = [ContinuousVariable(f"v{i}") for i in range(1020)]
361+
self.send_signal(data(0, 5))
362+
rename(2, 4)
363+
364+
self.send_signal(None)
365+
self.assertIsNone(self.get_output())
366+
367+
self.send_signal(data(3, 7))
368+
outp = self.get_output()
369+
self.assertEqual([var.name for var in outp.domain.attributes],
370+
["xv3", "v4", "v5", "v6"])
371+
372+
self.send_signal(data(0, 5))
373+
outp = self.get_output()
374+
self.assertEqual([var.name for var in outp.domain.attributes],
375+
["v0", "v1", "xv2", "xv3", "v4"])
376+
377+
self.send_signal(data(3, 7))
378+
outp = self.get_output()
379+
self.assertEqual([var.name for var in outp.domain.attributes],
380+
["xv3", "v4", "v5", "v6"])
381+
382+
# This is too large: widget should retain just hints related to
383+
# the current data
384+
self.send_signal(data(3, 1020))
385+
outp = self.get_output()
386+
self.assertEqual([var.name for var in outp.domain.attributes[:4]],
387+
["xv3", "v4", "v5", "v6"])
388+
rename(5, 1017)
389+
self.widget.commit()
390+
outp = self.get_output()
391+
self.assertEqual([var.name for var in outp.domain.attributes[-3:]],
392+
["xv1017", "xv1018", "xv1019"])
393+
394+
self.send_signal(None)
395+
self.assertIsNone(self.get_output())
396+
397+
# Tests that hints for the current data are kept
398+
# - including the earliest (v3) and latest (v1019)
399+
self.send_signal(data(3, 1020))
400+
outp = self.get_output()
401+
self.assertEqual([var.name for var in outp.domain.attributes[:4]],
402+
["xv3", "v4", "v5", "v6"])
403+
self.assertEqual([var.name for var in outp.domain.attributes[-3:]],
404+
["xv1017", "xv1018", "xv1019"])
405+
406+
# Tests that older hints are dropped: v2 should be lost
407+
self.send_signal(data(0, 5))
408+
outp = self.get_output()
409+
self.assertEqual([var.name for var in outp.domain.attributes],
410+
["v0", "v1", "v2", "xv3", "v4"])
411+
412+
def test_migrate_settings_hints_2_to_3(self):
413+
settings = {
414+
'__version__': 2,
415+
'context_settings':
416+
[Context(values={
417+
'_domain_change_store': (
418+
{('Categorical', ('a', ('mir1', 'mir4', 'mir2'), (), False)):
419+
[('Rename', ('disease mir',))],
420+
('Categorical', ('b', ('mir4', 'mir1', 'mir2'), (), False)):
421+
[('Rename', ('disease mirs',))]
422+
},
423+
-2),
424+
'_merge_dialog_settings': ({}, -4),
425+
'_selected_item': (('1', 0), -2),
426+
'output_table_name': ('boo', -2),
427+
'__version__': 2}),
428+
Context(values={
429+
'_domain_change_store': (
430+
{('Categorical', ('b', ('mir4', 'mir1', 'mir2'), (), False)):
431+
[('Rename', ('disease bmir',))],
432+
('Categorical', ('c', ('mir4', 'mir1', 'mir2'), (), False)):
433+
[('Rename', ('disease mirs',))]
434+
},
435+
-2),
436+
'_merge_dialog_settings': ({}, -4),
437+
'_selected_item': (('1', 0), -2),
438+
'output_table_name': ('far', -2),
439+
'__version__': 2}),
440+
]}
441+
migrated_hints = {
442+
('Categorical', ('b', ('mir4', 'mir1', 'mir2'), (), False)):
443+
[('Rename', ('disease bmir',))],
444+
('Categorical', ('c', ('mir4', 'mir1', 'mir2'), (), False)):
445+
[('Rename', ('disease mirs',))],
446+
('Categorical', ('a', ('mir1', 'mir4', 'mir2'), (), False)):
447+
[('Rename', ('disease mir',))],
448+
}
449+
widget = self.create_widget(OWEditDomain, stored_settings=settings)
450+
self.assertEqual(widget._domain_change_hints, migrated_hints)
451+
# order matters
452+
self.assertEqual(list(widget._domain_change_hints), list(migrated_hints))
453+
self.assertEqual(widget.output_table_name, "far")
454+
455+
def test_migrate_settings_2_to_3_realworld(self):
456+
settings = {
457+
'controlAreaVisible': True,
458+
'__version__': 2,
459+
'context_settings': [Context(
460+
values={
461+
'_domain_change_store':
462+
({('Real', ('sepal length', (1, 'f'), (), False)):
463+
[('AsString', ())],
464+
('Real', ('sepal width', (1, 'f'), (), False)):
465+
[('AsTime', ()), ('StrpTime', ('Detect automatically', None, 1, 1))],
466+
('Real', ('petal width', (1, 'f'), (), False)):
467+
[('Annotate', ((('a', 'b'),),))]}, -2),
468+
'_merge_dialog_settings': ({}, -4),
469+
'_selected_item': (('petal width', 2), -2),
470+
'output_table_name': ('', -2),
471+
'__version__': 2},
472+
attributes={'sepal length': 2, 'sepal width': 2,
473+
'petal length': 2, 'petal width': 2, 'iris': 1},
474+
metas={}
475+
)]
476+
}
477+
widget = self.create_widget(OWEditDomain, stored_settings=settings)
478+
self.assertEqual(
479+
widget._domain_change_hints,
480+
{('Real', ('sepal length', (1, 'f'), (), False)):
481+
[('AsString', ())],
482+
('Real', ('sepal width', (1, 'f'), (), False)):
483+
[('AsTime', ()),
484+
('StrpTime', ('Detect automatically', None, 1, 1))],
485+
('Real', ('petal width', (1, 'f'), (), False)):
486+
[('Annotate', ((('a', 'b'),),))]}
487+
)
488+
489+
def test_migrate_settings_name_2_to_3(self):
490+
settings = {
491+
'__version__': 2,
492+
'context_settings':
493+
[Context(values={
494+
'_domain_change_store': ({}, -2),
495+
'output_table_name': ('boo', -2),
496+
'__version__': 2}),
497+
Context(values={
498+
'_domain_change_store': ({}, -2),
499+
'output_table_name': ('far', -2),
500+
'__version__': 2}),
501+
Context(values={
502+
'_domain_change_store': ({}, -2),
503+
'output_table_name': ('', -2),
504+
'__version__': 2}),
505+
Context(values={
506+
'_domain_change_store': ({}, -2),
507+
'__version__': 2})
508+
]
509+
}
510+
widget = self.create_widget(OWEditDomain, stored_settings=settings)
511+
self.assertEqual(widget.output_table_name, "far")
512+
341513

342514
class TestEditors(GuiTest):
343515
def test_variable_editor(self):

0 commit comments

Comments
 (0)