Skip to content

Commit bb1009b

Browse files
authored
Merge pull request #6776 from ales-erjavec/oweditdomain-restore-state
[FIX] Edit Domain: Partial restore state on categories mismatch
2 parents 91b7fcb + 62e1f31 commit bb1009b

File tree

2 files changed

+108
-51
lines changed

2 files changed

+108
-51
lines changed

Orange/widgets/data/oweditdomain.py

Lines changed: 83 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@
55
A widget for manual editing of a domain's attributes.
66
77
"""
8+
from __future__ import annotations
89
import warnings
910
from xml.sax.saxutils import escape
10-
from itertools import zip_longest, repeat, chain
11+
from itertools import zip_longest, repeat, chain, groupby
1112
from collections import namedtuple, Counter
1213
from functools import singledispatch, partial
14+
from operator import itemgetter
1315
from typing import (
1416
Tuple, List, Any, Optional, Union, Dict, Sequence, Iterable, NamedTuple,
1517
FrozenSet, Type, Callable, TypeVar, Mapping, Hashable, cast, Set
1618
)
1719

1820
import numpy as np
1921
import pandas as pd
22+
2023
from AnyQt.QtWidgets import (
2124
QWidget, QListView, QTreeView, QVBoxLayout, QHBoxLayout, QFormLayout,
2225
QLineEdit, QAction, QActionGroup, QGroupBox,
@@ -35,6 +38,7 @@
3538
)
3639
from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
3740

41+
from orangecanvas.utils import assocf
3842
from orangewidget.utils.listview import ListViewSearch
3943

4044
import Orange.data
@@ -46,7 +50,7 @@
4650
from Orange.util import frompyfunc
4751
from Orange.widgets import widget, gui
4852
from Orange.widgets.settings import Setting
49-
from Orange.widgets.utils import itemmodels, ftry, disconnected
53+
from Orange.widgets.utils import itemmodels, ftry, disconnected, unique_everseen as unique
5054
from Orange.widgets.utils.buttons import FixedSizeButton
5155
from Orange.widgets.utils.itemmodels import signal_blocking
5256
from Orange.widgets.utils.widgetpreview import WidgetPreview
@@ -62,14 +66,6 @@
6266
MAX_HINTS = 1000
6367

6468

65-
def unique(sequence: Iterable[H]) -> Iterable[H]:
66-
"""
67-
Return unique elements in `sequence`, preserving their (first seen) order.
68-
"""
69-
# depending on Python >= 3.6 'ordered' dict implementation detail.
70-
return iter(dict.fromkeys(sequence))
71-
72-
7369
class _DataType:
7470
def __eq__(self, other):
7571
"""Equal if `other` has the same type and all elements compare equal."""
@@ -83,19 +79,6 @@ def __ne__(self, other):
8379
def __hash__(self):
8480
return hash((type(self), super().__hash__()))
8581

86-
def name_type(self):
87-
"""
88-
Returns a tuple with name and type of the variable.
89-
It is used since it is forbidden to use names of variables in settings.
90-
"""
91-
type_number = {
92-
"Categorical": 0,
93-
"Real": 2,
94-
"Time": 3,
95-
"String": 4
96-
}
97-
return self.name, type_number[type(self).__name__]
98-
9982

10083
#: An ordered sequence of key, value pairs (variable annotations)
10184
AnnotationsType = Tuple[Tuple[str, str], ...]
@@ -2029,9 +2012,17 @@ class Outputs:
20292012
class Error(widget.OWWidget.Error):
20302013
duplicate_var_name = widget.Msg("A variable name is duplicated.")
20312014

2015+
class Warning(widget.OWWidget.Warning):
2016+
transform_restore_failed = widget.Msg(
2017+
"Failed to restore transform {} for column {}"
2018+
)
2019+
cat_mapping_does_not_apply = widget.Msg(
2020+
"Categories mapping for {} does not apply to current input"
2021+
)
2022+
20322023
settings_version = 4
20332024

2034-
_domain_change_hints = Setting({}, schema_only=True)
2025+
_domain_change_hints: dict = Setting({}, schema_only=True)
20352026
_merge_dialog_settings = Setting({}, schema_only=True)
20362027
output_table_name = Setting("", schema_only=True)
20372028

@@ -2122,8 +2113,8 @@ def clear(self):
21222113
self.data = None
21232114
self.variables_model.clear()
21242115
self.clear_editor()
2125-
21262116
self._merge_dialog_settings = {}
2117+
self.Warning.clear()
21272118

21282119
def reset_selected(self):
21292120
"""Reset the currently selected variable to its original state."""
@@ -2178,30 +2169,63 @@ def setup_model(self, data: Orange.data.Table):
21782169
for i, d in enumerate(columns):
21792170
model.setData(model.index(i), d, Qt.EditRole)
21802171

2172+
def _sanitize_transform(
2173+
self, var: Variable, trs: Sequence[Transform]
2174+
) -> tuple[Sequence[Transform], Sequence[tuple[Msg, str]]]:
2175+
def does_categories_mapping_apply(
2176+
var: Categorical, tr: CategoriesMapping) -> bool:
2177+
return set(var.categories) \
2178+
== set(ci for ci, _ in tr.mapping if ci is not None)
2179+
msgs = []
2180+
if isinstance(var, Categorical):
2181+
trs_ = []
2182+
for tr in trs:
2183+
if isinstance(tr, CategoriesMapping):
2184+
if does_categories_mapping_apply(var, tr):
2185+
trs_.append(tr)
2186+
else:
2187+
2188+
msgs.append((self.Warning.cat_mapping_does_not_apply, var.name))
2189+
else:
2190+
trs_.append(tr)
2191+
return trs_, msgs
2192+
else:
2193+
return trs, msgs
2194+
21812195
def _restore(self):
21822196
"""
21832197
Restore the edit transform from saved state.
21842198
"""
21852199
model = self.variables_model
21862200
hints = self._domain_change_hints
21872201
first_key = None
2202+
msgs = []
21882203
for i in range(model.rowCount()):
21892204
midx = model.index(i, 0)
21902205
coldesc = model.data(midx, Qt.EditRole) # type: DataVector
2191-
tr, key = self._restore_transform(coldesc.vtype)
2192-
if tr:
2193-
model.setData(midx, tr, TransformRole)
2194-
if first_key is None:
2195-
first_key = key
2206+
res = self._find_stored_transform(coldesc.vtype)
2207+
if res:
2208+
key, tr = res
2209+
if tr:
2210+
self._store_transform(coldesc.vtype, tr, key)
2211+
tr, msgs_ = self._sanitize_transform(coldesc.vtype, tr)
2212+
model.setData(midx, tr, TransformRole)
2213+
msgs.extend(msgs_)
2214+
if first_key is None:
2215+
first_key = key
21962216
# Reduce the number of hints to MAX_HINTS, but keep all current hints
21972217
# Current hints start with `first_key`.
21982218
while len(hints) > MAX_HINTS and \
2199-
(key := next(iter(hints))) is not first_key:
2219+
(key := next(iter(hints))) != first_key:
22002220
del hints[key] # pylint: disable=unsupported-delete-operation
22012221

2222+
# Show warnings for non-applicable transforms
2223+
for msg, names in groupby(msgs, key=itemgetter(0)):
2224+
msg(", ".join(map(itemgetter(1), names)))
2225+
22022226
# Restore the current variable selection
22032227
selected_rows = [i for i, vec in enumerate(model)
2204-
if vec.vtype.name_type()[0] in self._selected_items]
2228+
if vec.vtype.name in self._selected_items]
22052229
if not selected_rows and model.rowCount():
22062230
selected_rows = [0]
22072231
itemmodels.select_rows(self.variables_view, selected_rows)
@@ -2257,8 +2281,9 @@ def _on_variable_changed(self):
22572281
self._store_transform(var, transform)
22582282
self._invalidate()
22592283

2260-
def _store_transform(self, var, transform, deconvar=None):
2261-
# type: (Variable, List[Transform]) -> None
2284+
def _store_transform(
2285+
self, var: Variable, transform: Iterable[Transform], deconvar=None
2286+
) -> None:
22622287
deconvar = deconvar or deconstruct(var)
22632288
# Remove the existing key (if any) to put the new one at the end,
22642289
# to make sure it comes after the sentinel
@@ -2267,25 +2292,32 @@ def _store_transform(self, var, transform, deconvar=None):
22672292
self._domain_change_hints[deconvar] = \
22682293
[deconstruct(t) for t in transform]
22692294

2270-
def _restore_transform(self, var):
2271-
# type: (Variable) -> List[Transform]
2295+
def _find_stored_transform(
2296+
self, var: Variable
2297+
) -> Tuple[tuple, Sequence[Transform]] | None:
2298+
"""Find stored transform for `var`."""
2299+
def reconstruct_transform(tr_: list[tuple]) -> list[Transform]:
2300+
trs = []
2301+
for t in tr_:
2302+
try:
2303+
trs.append(cast(Transform, reconstruct(*t)))
2304+
except (AttributeError, TypeError, NameError):
2305+
self.Warning.transform_restore_failed(
2306+
str(t), var.name, exc_info=True,
2307+
)
2308+
return trs
2309+
2310+
hints = self._domain_change_hints
22722311
key = deconstruct(var)
2273-
tr_ = self._domain_change_hints.get(key, [])
2274-
tr = []
2312+
tr = hints.get(key) # exact match
2313+
if tr is not None:
2314+
return key, reconstruct_transform(tr)
22752315

2276-
for t in tr_:
2277-
try:
2278-
tr.append(reconstruct(*t))
2279-
except (NameError, TypeError) as err:
2280-
warnings.warn(
2281-
f"Failed to restore transform: {t}, {err}",
2282-
UserWarning, stacklevel=2
2283-
)
2284-
if tr:
2285-
self._store_transform(var, tr, key)
2286-
else:
2287-
key = None
2288-
return tr, key
2316+
# match by name and type only
2317+
item = assocf(hints.items(),
2318+
lambda k: k[0] == key[0] and k[1][0] == var.name)
2319+
if item is not None:
2320+
return item[0], reconstruct_transform(item[1])
22892321

22902322
def _invalidate(self):
22912323
self._set_modified(True)

Orange/widgets/data/tests/test_oweditdomain.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,31 @@ def restore(state):
362362
tr = model.data(model.index(4), TransformRole)
363363
self.assertEqual(tr, [AsString(), Rename("Z")])
364364

365+
restore({viris: [("CategoriesMapping", ([("Iris-setosa", "setosa"),
366+
("Iris-versicolor", "versicolor"),
367+
("Iris-virginica", "virginica")],)),
368+
("Rename", ("Species",))]})
369+
tr = model.data(model.index(4), TransformRole)
370+
self.assertEqual(tr, [CategoriesMapping([("Iris-setosa", "setosa"),
371+
("Iris-versicolor", "versicolor"),
372+
("Iris-virginica", "virginica")]),
373+
Rename("Species")])
374+
375+
viris_1 = ("Categorical", ("iris", ("A", "B"), ()))
376+
restore({viris_1: [("Rename", ("K",),),
377+
("CategoriesMapping", ([("A", "AA"), ("B", "BB")],))]})
378+
self.assertTrue(w.Warning.cat_mapping_does_not_apply.is_shown())
379+
w.commit()
380+
output = self.get_output(w.Outputs.data)
381+
self.assertEqual(output.domain.class_var.name, "K")
382+
self.assertEqual(output.domain.class_var.values,
383+
("Iris-setosa", "Iris-versicolor", "Iris-virginica"))
384+
385+
restore({viris: [("Rename", ("A")), ("NonexistantTransform", ("AA",))]})
386+
tr = model.data(model.index(4), TransformRole)
387+
self.assertEqual(tr, [Rename("A")])
388+
self.assertTrue(w.Warning.transform_restore_failed.is_shown())
389+
365390
def test_reset_selected(self):
366391
w = self.widget
367392
model = w.domain_view.model()

0 commit comments

Comments
 (0)