Repository: biolab/orange3

Issue:
Let DomainContextHandler handle dict with Variables as keys
**Describe the bug**

Discretize widget uses `DomainContextHandler`, but the context handler does not understand the widget's context setting (a dictionary with attributes as keys), and hence matches all domains.

**To Reproduce**

1. Feed Iris to Discretize, set one variable's method to whatever.
2. Feed heart disease to discretize. (At this point, the widget will re-use the context from Iris!)
3. Feed Iris to Discretize. The setting from the first step was lost.

**Expected behavior**

~~Discretize widget should have a context handler whose~~
- ~~match checks the dictionary~~
- ~~encode and decode handles the dictionary.~~

~~Alternatively,~~ `DomainContextHandler` could be improved to handle dicts whose keys are variables. Discretize is not the only widget that needs it: Select Columns implements its own `SelectAttributesDomainContextHandler` for the same reason.



Discussion:
Edit Domain probably has the same problem, although I can't find a way to make it fail.
Impute: same thing as Discretize, with the same procedure to reproduce.

Resolving patch:
diff --git a/Orange/widgets/settings.py b/Orange/widgets/settings.py
--- a/Orange/widgets/settings.py
+++ b/Orange/widgets/settings.py
@@ -29,6 +29,9 @@
 (initializing) the value of the setting attributes.
 """
 
+# Overriden methods in these classes add arguments
+# pylint: disable=arguments-differ,unused-argument,no-value-for-parameter
+
 import copy
 import itertools
 import logging
@@ -127,6 +130,10 @@ def filter_value(self, setting, data, domain, attrs, metas):
             new_value = [item for item in value
                          if self.is_valid_item(setting, item, attrs, metas)]
             data[setting.name] = new_value
+        elif isinstance(value, dict):
+            new_value = {item: val for item, val in value.items()
+                         if self.is_valid_item(setting, item, attrs, metas)}
+            data[setting.name] = new_value
         elif value is not None:
             if (value[1] >= 0 and
                     not self._var_exists(setting, value, attrs, metas)):
@@ -159,11 +166,17 @@ def encode_setting(cls, context, setting, value):
         if isinstance(value, list):
             if all(e is None or isinstance(e, Variable) for e in value) \
                     and any(e is not None for e in value):
-                return [None if e is None else cls.encode_variable(e)
-                        for e in value], -3
+                return ([None if e is None else cls.encode_variable(e)
+                         for e in value],
+                        -3)
             else:
                 return copy.copy(value)
 
+        elif isinstance(value, dict) \
+                and all(isinstance(e, Variable) for e in value):
+            return ({cls.encode_variable(e): val for e, val in value.items()},
+                    -4)
+
         if isinstance(value, Variable):
             if isinstance(setting, ContextSetting):
                 return cls.encode_variable(value)
@@ -173,14 +186,22 @@ def encode_setting(cls, context, setting, value):
 
         return copy.copy(value), -2
 
-    def decode_setting(self, setting, value, domain=None):
+    # backward compatibility, pylint: disable=keyword-arg-before-vararg
+    def decode_setting(self, setting, value, domain=None, *args):
+        def get_var(name):
+            if domain is None:
+                raise ValueError("Cannot decode variable without domain")
+            return domain[name]
+
         if isinstance(value, tuple):
-            if value[1] == -3:
-                return [None if e is None else domain[e[0]] for e in value[0]]
-            if value[1] >= 100:
-                if domain is None:
-                    raise ValueError("Cannot decode variable without domain")
-                return domain[value[0]]
+            data, dtype = value
+            if dtype == -3:
+                return[None if name_type is None else get_var(name_type[0])
+                       for name_type in data]
+            if dtype == -4:
+                return {get_var(name): val for (name, _), val in data.items()}
+            if dtype >= 100:
+                return get_var(data)
             return value[0]
         else:
             return value
@@ -212,19 +233,20 @@ def match(self, context, domain, attrs, metas):
                 if isinstance(value, list):
                     matches.append(
                         self.match_list(setting, value, context, attrs, metas))
-                elif isinstance(value, tuple) \
-                        and len(value) == 2 \
-                        and isinstance(value[0], list) \
-                        and value[1] == -3:
-                    matches.append(
-                        self.match_list(setting, value[0], context, attrs, metas))
+                # type check is a (not foolproof) check in case of a pair that
+                # would, by conincidence, have -3 or -4 as the second element
+                elif isinstance(value, tuple) and len(value) == 2 \
+                       and (value[1] == -3 and isinstance(value[0], list)
+                            or (value[1] == -4 and isinstance(value[0], dict))):
+                    matches.append(self.match_list(setting, value[0], context,
+                                                   attrs, metas))
                 elif value is not None:
                     matches.append(
                         self.match_value(setting, value, attrs, metas))
         except IncompatibleContext:
             return self.NO_MATCH
 
-        if self.first_match:
+        if self.first_match and matches and sum(m[0] for m in matches):
             return self.MATCH
 
         matches.append((0, 0))
@@ -374,10 +396,10 @@ def migrate_str_to_variable(settings, names=None, none_placeholder=None):
             all settings with values `(str, int)` are migrated.
     """
     def _fix(name):
-        var, vartype = settings.values[name]
-        if 0 <= vartype <= 100:
-            settings.values[name] = (var, 100 + vartype)
-        elif var == none_placeholder and vartype == -2:
+        var, vtype = settings.values[name]
+        if 0 <= vtype <= 100:
+            settings.values[name] = (var, 100 + vtype)
+        elif var == none_placeholder and vtype == -2:
             settings.values[name] = None
 
     if names is None:


Test patch:
diff --git a/Orange/widgets/tests/test_domain_context_handler.py b/Orange/widgets/tests/test_domain_context_handler.py
--- a/Orange/widgets/tests/test_domain_context_handler.py
+++ b/Orange/widgets/tests/test_domain_context_handler.py
@@ -97,7 +97,7 @@ def test_match_when_nothing_to_match(self):
         self.handler.bind(SimpleWidget)
 
         context = Mock(values={})
-        self.assertEqual(1, self.handler.match(context, *self.args))
+        self.assertEqual(0.1, self.handler.match(context, *self.args))
 
     def test_match_returns_zero_on_incompatible_context(self):
         self.handler.bind(SimpleWidget)
@@ -225,6 +225,25 @@ def test_filter(before_value, after_value):
         # All other values in list should remain
         test_filter([0, [1, 2, 3], "abcd", 5.4], [0, [1, 2, 3], "abcd", 5.4])
 
+    def test_filter_value_dict(self):
+        setting = ContextSetting({})
+        setting.name = "value"
+
+        def test_filter(before_value, after_value):
+            data = dict(value=before_value)
+            self.handler.filter_value(setting, data, *self.args)
+            self.assertEqual(data.get("value", None), after_value)
+
+        # filter list values
+        test_filter({}, {})
+        # When list contains attributes asa tuple of (name, type),
+        # Attributes not present in domain should be filtered out
+        test_filter({("d1", Discrete): 1, ("d1", Continuous): 2,
+                     ("c1", Continuous): 3, ("c1", Discrete): 4},
+                    {("d1", Discrete): 1, ("c1", Continuous): 3})
+        # All other values in list should remain
+        test_filter([0, [1, 2, 3], "abcd", 5.4], [0, [1, 2, 3], "abcd", 5.4])
+
     def test_encode_setting(self):
         setting = ContextSetting(None)
 
@@ -264,6 +283,26 @@ def test_encode_list_settings(self):
         self.assertEqual(val, [None, None])
         self.assertIsNot(val, a_list)
 
+    def test_encode_dict_settings(self):
+        setting = ContextSetting(None)
+
+        var1, var2 = self.domain[:2]
+        val = self.handler.encode_setting(None, setting, {var1: 1, var2: 2})
+        self.assertEqual(
+            val,
+            ({(var1.name, 100 + vartype(var1)): 1,
+              (var2.name, 100 + vartype(var2)): 2}, -4))
+
+        a_dict = {1: 2, 2: 3, 3: 4}
+        val = self.handler.encode_setting(None, setting, a_dict)
+        self.assertEqual(val, ({1: 2, 2: 3, 3: 4}, -2))
+        self.assertIsNot(val, a_dict)
+
+        a_dict = {}
+        val = self.handler.encode_setting(None, setting, a_dict)
+        self.assertEqual(val, ({}, -4))
+        self.assertIsNot(val, a_dict)
+
     def test_decode_setting(self):
         setting = ContextSetting(None)
 
@@ -277,6 +316,11 @@ def test_decode_setting(self):
                                           all_metas_domain)
         self.assertIs(val, var)
 
+        self.assertRaises(ValueError,
+                          self.handler.decode_setting,
+                          setting, (var.name, 100 + vartype(var)))
+        self.handler.decode_setting(setting, None, None)
+
     def test_decode_list_setting(self):
         setting = ContextSetting(None)
 
@@ -292,6 +336,42 @@ def test_decode_list_setting(self):
         val = self.handler.decode_setting(setting, [1, 2, 3], self.domain)
         self.assertEqual(val, [1, 2, 3])
 
+        self.assertRaises(ValueError,
+                          self.handler.decode_setting,
+                          setting,
+                          ([None,
+                            (var1.name, 100 + vartype(var1)),
+                            (var2.name, 100 + vartype(var2))], -3)
+                          )
+        val = self.handler.decode_setting(setting, ([None, None], -3), None)
+        self.assertEqual(val, [None, None])
+
+
+    def test_decode_dict_setting(self):
+        setting = ContextSetting(None)
+
+        var1, var2 = self.domain[:2]
+        val = self.handler.decode_setting(
+            setting,
+            ({(var1.name, 100 + vartype(var1)): 1,
+              (var2.name, 100 + vartype(var2)): 2}, -4),
+            self.domain)
+        self.assertEqual(val, {var1: 1, var2: 2})
+
+        val = self.handler.decode_setting(
+            setting, ({1: 2, 2: 3, 3: 4}, -2), self.domain)
+        self.assertEqual(val, {1: 2, 2: 3, 3: 4})
+
+        self.assertRaises(ValueError,
+                          self.handler.decode_setting,
+                          setting,
+                          ({(var1.name, 100 + vartype(var1)): 1,
+                            (var2.name, 100 + vartype(var2)): 2}, -4))
+
+        val = self.handler.decode_setting(setting, ({1: 2, 2: 3, 3: 4}, -2))
+        self.assertEqual(val, {1: 2, 2: 3, 3: 4})
+
+
     def test_backward_compatible_params(self):
         with warnings.catch_warnings(record=True) as w:
             warnings.simplefilter("always")