Skip to content

Commit 1770def

Browse files
committed
oweditdomain: More forgiving restore
Ignore categories when exact match for a variable is not found in the stored settings (i.e. match by name and type only). If a stored transform is a `CategoriesMapping` transform that does not match the input, then it is dropped and a warning is shown.
1 parent 2b92e9a commit 1770def

File tree

2 files changed

+96
-27
lines changed

2 files changed

+96
-27
lines changed

Orange/widgets/data/oweditdomain.py

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
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
1011
from itertools import zip_longest, repeat, chain
@@ -17,6 +18,7 @@
1718

1819
import numpy as np
1920
import pandas as pd
21+
2022
from AnyQt.QtWidgets import (
2123
QWidget, QListView, QTreeView, QVBoxLayout, QHBoxLayout, QFormLayout,
2224
QLineEdit, QAction, QActionGroup, QGroupBox,
@@ -35,6 +37,7 @@
3537
)
3638
from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
3739

40+
from orangecanvas.utils import assocf
3841
from orangewidget.utils.listview import ListViewSearch
3942

4043
import Orange.data
@@ -2008,9 +2011,17 @@ class Outputs:
20082011
class Error(widget.OWWidget.Error):
20092012
duplicate_var_name = widget.Msg("A variable name is duplicated.")
20102013

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

2013-
_domain_change_hints = Setting({}, schema_only=True)
2024+
_domain_change_hints: dict = Setting({}, schema_only=True)
20142025
_merge_dialog_settings = Setting({}, schema_only=True)
20152026
output_table_name = Setting("", schema_only=True)
20162027

@@ -2101,8 +2112,8 @@ def clear(self):
21012112
self.data = None
21022113
self.variables_model.clear()
21032114
self.clear_editor()
2104-
21052115
self._merge_dialog_settings = {}
2116+
self.Warning.clear()
21062117

21072118
def reset_selected(self):
21082119
"""Reset the currently selected variable to its original state."""
@@ -2157,6 +2168,27 @@ def setup_model(self, data: Orange.data.Table):
21572168
for i, d in enumerate(columns):
21582169
model.setData(model.index(i), d, Qt.EditRole)
21592170

2171+
def _sanitize_transform(
2172+
self, var: Variable, trs: Sequence[Transform]
2173+
) -> Sequence[Transform]:
2174+
def does_categories_mapping_apply(
2175+
var: Categorical, tr: CategoriesMapping) -> bool:
2176+
return set(var.categories) \
2177+
== set(ci for ci, _ in tr.mapping if ci is not None)
2178+
if isinstance(var, Categorical):
2179+
trs_ = []
2180+
for tr in trs:
2181+
if isinstance(tr, CategoriesMapping):
2182+
if does_categories_mapping_apply(var, tr):
2183+
trs_.append(tr)
2184+
else:
2185+
self.Warning.cat_mapping_does_not_apply(var.name)
2186+
else:
2187+
trs_.append(tr)
2188+
return trs_
2189+
else:
2190+
return trs
2191+
21602192
def _restore(self):
21612193
"""
21622194
Restore the edit transform from saved state.
@@ -2167,15 +2199,19 @@ def _restore(self):
21672199
for i in range(model.rowCount()):
21682200
midx = model.index(i, 0)
21692201
coldesc = model.data(midx, Qt.EditRole) # type: DataVector
2170-
tr, key = self._restore_transform(coldesc.vtype)
2171-
if tr:
2172-
model.setData(midx, tr, TransformRole)
2173-
if first_key is None:
2174-
first_key = key
2202+
res = self._find_stored_transform(coldesc.vtype)
2203+
if res:
2204+
key, tr = res
2205+
if tr:
2206+
self._store_transform(coldesc.vtype, tr, key)
2207+
tr = self._sanitize_transform(coldesc.vtype, tr)
2208+
model.setData(midx, tr, TransformRole)
2209+
if first_key is None:
2210+
first_key = key
21752211
# Reduce the number of hints to MAX_HINTS, but keep all current hints
21762212
# Current hints start with `first_key`.
21772213
while len(hints) > MAX_HINTS and \
2178-
(key := next(iter(hints))) is not first_key:
2214+
(key := next(iter(hints))) != first_key:
21792215
del hints[key] # pylint: disable=unsupported-delete-operation
21802216

21812217
# Restore the current variable selection
@@ -2236,8 +2272,9 @@ def _on_variable_changed(self):
22362272
self._store_transform(var, transform)
22372273
self._invalidate()
22382274

2239-
def _store_transform(self, var, transform, deconvar=None):
2240-
# type: (Variable, List[Transform]) -> None
2275+
def _store_transform(
2276+
self, var: Variable, transform: Iterable[Transform], deconvar=None
2277+
) -> None:
22412278
deconvar = deconvar or deconstruct(var)
22422279
# Remove the existing key (if any) to put the new one at the end,
22432280
# to make sure it comes after the sentinel
@@ -2246,25 +2283,32 @@ def _store_transform(self, var, transform, deconvar=None):
22462283
self._domain_change_hints[deconvar] = \
22472284
[deconstruct(t) for t in transform]
22482285

2249-
def _restore_transform(self, var):
2250-
# type: (Variable) -> List[Transform]
2286+
def _find_stored_transform(
2287+
self, var: Variable
2288+
) -> Tuple[tuple, Sequence[Transform]] | None:
2289+
"""Find stored transform for `var`."""
2290+
def reconstruct_transform(tr_: list[tuple]) -> list[Transform]:
2291+
trs = []
2292+
for t in tr_:
2293+
try:
2294+
trs.append(cast(Transform, reconstruct(*t)))
2295+
except (AttributeError, TypeError, NameError):
2296+
self.Warning.transform_restore_failed(
2297+
str(t), var.name, exc_info=True,
2298+
)
2299+
return trs
2300+
2301+
hints = self._domain_change_hints
22512302
key = deconstruct(var)
2252-
tr_ = self._domain_change_hints.get(key, [])
2253-
tr = []
2303+
tr = hints.get(key) # exact match
2304+
if tr is not None:
2305+
return key, reconstruct_transform(tr)
22542306

2255-
for t in tr_:
2256-
try:
2257-
tr.append(reconstruct(*t))
2258-
except (NameError, TypeError) as err:
2259-
warnings.warn(
2260-
f"Failed to restore transform: {t}, {err}",
2261-
UserWarning, stacklevel=2
2262-
)
2263-
if tr:
2264-
self._store_transform(var, tr, key)
2265-
else:
2266-
key = None
2267-
return tr, key
2307+
# match by name and type only
2308+
item = assocf(hints.items(),
2309+
lambda k: k[0] == key[0] and k[1][0] == var.name)
2310+
if item is not None:
2311+
return item[0], reconstruct_transform(item[1])
22682312

22692313
def _invalidate(self):
22702314
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)