Repository: biolab/orange3

Issue:
Header type annotation (colours) not saved correctly
Colours are not saved/reloaded correctly. I tried to set colours for a categorical feature and then saved the file with header type annotation.  Upon importing the new file into the Orange the data does not have correct colours. The colours from the saved data are loaded correctly in terms of hues, but the categorical instances are mixed between the categories. 
![Screenshot from 2020-03-09 13-32-24](https://user-images.githubusercontent.com/47607471/76213504-1da90580-620b-11ea-9b27-955ac9d524b5.png)



Discussion:

Resolving patch:
diff --git a/Orange/widgets/utils/colorpalettes.py b/Orange/widgets/utils/colorpalettes.py
--- a/Orange/widgets/utils/colorpalettes.py
+++ b/Orange/widgets/utils/colorpalettes.py
@@ -7,6 +7,7 @@
 from AnyQt.QtCore import Qt
 from AnyQt.QtGui import QImage, QPixmap, QColor, QIcon
 
+from Orange.data import Variable, DiscreteVariable, ContinuousVariable
 from Orange.util import Enum, hex_to_color, color_to_hex
 
 NAN_COLOR = (128, 128, 128)
@@ -559,20 +560,23 @@ def get_default_curve_colors(n):
 def patch_variable_colors():
     # This function patches Variable with properties and private attributes:
     # pylint: disable=protected-access
-    from Orange.data import Variable, DiscreteVariable, ContinuousVariable
-
     def get_colors(var):
         return var._colors
 
     def set_colors(var, colors):
         var._colors = colors
+        if isinstance(var._colors, np.ndarray):
+            var._colors.flags.writeable = False
         var._palette = None
+        if "palette" in var.attributes:
+            del var.attributes["palette"]
+
+    def continuous_set_colors(var, colors):
+        set_colors(var, colors)
         var.attributes["colors"] = [
             color_to_hex(color) if isinstance(color, (Sequence, np.ndarray))
             else color
             for color in colors]
-        if "palette" in var.attributes:
-            del var.attributes["palette"]
 
     def get_palette(var):
         return var._palette
@@ -616,28 +620,46 @@ def continuous_get_palette(var):
         return var._palette
 
     def discrete_get_colors(var):
-        if var._colors is None or len(var._colors) < len(var.values):
+        def retrieve_colors():
             if var._palette is not None or "palette" in var.attributes:
-                var._colors = var.palette.palette[:len(var.values)]
-            else:
-                var._colors = np.empty((0, 3), dtype=object)
+                palette = var.palette.palette
+                if len(palette) >= nvalues:
+                    return palette[:nvalues]
+
             colors = var.attributes.get("colors")
-            if colors:
-                try:
-                    var._colors = np.vstack(
-                        ([hex_to_color(color) for color in colors],
-                         var._colors[len(colors):]))
+            if isinstance(colors, list) and len(colors) == nvalues:
+                try:  # catch errors in hex_to_color due to malformed files
+                    return np.array([hex_to_color(color) for color in colors])
+                except ValueError:
+                    pass
+
+            palette = LimitedDiscretePalette(nvalues).palette
+            if isinstance(colors, dict):
+                try:  # catch errors in hex_to_color due to malformed files
+                    proposed = [value in colors and hex_to_color(colors[value])
+                                for value in var.values]
                 except ValueError:
                     pass
-            if len(var._colors) < len(var.values):
-                var._colors = LimitedDiscretePalette(len(var.values)).palette
+                else:
+                    used = set(colors.values())
+                    available = (color for color in palette
+                                 if color_to_hex(color) not in used)
+                    return np.array(
+                        [color or next(available) for color in proposed])
+
+            return palette
+
+        nvalues = len(var.values)
+        if var._colors is None or len(var._colors) != nvalues:
+            var._colors = retrieve_colors()
             var._colors.flags.writeable = False
         return var._colors
 
     def discrete_set_colors(var, colors):
-        colors = colors.copy()
-        colors.flags.writeable = False
-        set_colors(var, colors)
+        set_colors(var, np.array(colors))
+        var.attributes["colors"] = \
+            {value: color_to_hex(color)
+             for value, color in zip(var.values, colors)}
 
     def discrete_get_palette(var):
         if var._palette is None:
@@ -658,5 +680,5 @@ def discrete_get_palette(var):
     DiscreteVariable.colors = property(discrete_get_colors, discrete_set_colors)
     DiscreteVariable.palette = property(discrete_get_palette, set_palette)
 
-    ContinuousVariable.colors = property(continuous_get_colors, set_colors)
+    ContinuousVariable.colors = property(continuous_get_colors, continuous_set_colors)
     ContinuousVariable.palette = property(continuous_get_palette, set_palette)


Test patch:
diff --git a/Orange/widgets/utils/tests/test_colorpalettes.py b/Orange/widgets/utils/tests/test_colorpalettes.py
--- a/Orange/widgets/utils/tests/test_colorpalettes.py
+++ b/Orange/widgets/utils/tests/test_colorpalettes.py
@@ -7,6 +7,7 @@
 from AnyQt.QtGui import QImage, QColor, QIcon
 
 from orangewidget.tests.base import GuiTest
+from Orange.util import color_to_hex
 from Orange.data import DiscreteVariable, ContinuousVariable, Variable
 # pylint: disable=wildcard-import,unused-wildcard-import
 from Orange.widgets.utils.colorpalettes import *
@@ -520,12 +521,16 @@ def test_exclusive(self):
         colors = [Mock(), Mock()]
         palette = Mock()
         var.colors = colors
+        # set_color for variable does not set this attribute; derived methods do
+        var.attributes["colors"] = colors
         var.palette = palette
         self.assertIsNone(var.colors)
         self.assertTrue("palette" in var.attributes)
         self.assertFalse("colors" in var.attributes)
 
         var.colors = colors
+        # set_color for variable does not set this attribute; derived methods do
+        var.attributes["colors"] = colors
         self.assertIsNone(var.palette)
         self.assertTrue("colors" in var.attributes)
         self.assertFalse("palette" in var.attributes)
@@ -540,13 +545,19 @@ def test_colors(self):
 
         var.colors = np.arange(6).reshape((2, 3))
         np.testing.assert_almost_equal(var.colors, [[0, 1, 2], [3, 4, 5]])
-        self.assertEqual(var.attributes["colors"], ["#000102", "#030405"])
+        self.assertEqual(var.attributes["colors"],
+                         {"F": "#000102", "M": "#030405"})
         self.assertFalse(var.colors.flags.writeable)
         with self.assertRaises(ValueError):
             var.colors[0] = [42, 41, 40]
 
         var = DiscreteVariable.make("x", values=("A", "B"))
-        var.attributes["colors"] = ['#0a0b0c', '#0d0e0f']
+        var.attributes["colors"] = {"A": "#0a0b0c", "B": "#0d0e0f"}
+        np.testing.assert_almost_equal(var.colors, [[10, 11, 12], [13, 14, 15]])
+
+        # Backward compatibility with list-like attributes
+        var = DiscreteVariable.make("x", values=("A", "B"))
+        var.attributes["colors"] = ["#0a0b0c", "#0d0e0f"]
         np.testing.assert_almost_equal(var.colors, [[10, 11, 12], [13, 14, 15]])
 
         # Test ncolors adapts to nvalues
@@ -557,6 +568,14 @@ def test_colors(self):
         var.add_value('k')
         self.assertEqual(len(var.colors), 4)
 
+        # Missing colors are retrieved from palette
+        var = DiscreteVariable.make("x", values=("A", "B", "C"))
+        palette = LimitedDiscretePalette(3).palette
+        var.attributes["colors"] = {"C": color_to_hex(palette[0]),
+                                    "B": "#0D0E0F"}
+        np.testing.assert_almost_equal(var.colors,
+                                       [palette[1], [13, 14, 15], palette[0]])
+
     def test_colors_fallback_to_palette(self):
         var = DiscreteVariable.make("a", values=("F", "M"))
         var.palette = Dark2Colors
@@ -564,6 +583,8 @@ def test_colors_fallback_to_palette(self):
         self.assertEqual(len(colors), 2)
         for color, palcol in zip(colors, Dark2Colors):
             np.testing.assert_equal(color, palcol.getRgb()[:3])
+        # the palette has to stay specified
+        self.assertEqual(var.attributes["palette"], var.palette.name)
 
         var = DiscreteVariable.make("a", values=[f"{i}" for i in range(40)])
         var.palette = Dark2Colors
@@ -571,6 +592,8 @@ def test_colors_fallback_to_palette(self):
         self.assertEqual(len(colors), 40)
         for color, palcol in zip(colors, LimitedDiscretePalette(40)):
             np.testing.assert_equal(color, palcol.getRgb()[:3])
+        # the palette has to stay specified
+        self.assertEqual(var.attributes["palette"], var.palette.name)
 
     def test_colors_default(self):
         var = DiscreteVariable.make("a", values=("F", "M"))
@@ -618,6 +641,12 @@ def test_get_palette(self):
         palette = var.palette
         np.testing.assert_equal(palette.palette, [[10, 11, 12], [13, 14, 15]])
 
+    def test_ignore_malfformed_atrtibutes(self):
+        var = DiscreteVariable("a", values=("M", "F"))
+        var.attributes["colors"] = {"F": "foo", "M": "bar"}
+        palette = var.palette
+        np.testing.assert_equal(palette.palette,
+                                LimitedDiscretePalette(2).palette)
 
 class PatchedContinuousVariableTest(unittest.TestCase):
     def test_colors(self):