Repository: biolab/orange3

Issue:
Color file not correctly loaded?
<!-- 
Thanks for taking the time to report a bug!
If you're raising an issue about an add-on (i.e., installed via Options > Add-ons), raise an issue in the relevant add-on's issue tracker instead. See: https://github.com/biolab?q=orange3
To fix the bug, we need to be able to reproduce it. Please answer the following questions to the best of your ability.
-->


**What's wrong?**
<!-- Be specific, clear, and concise. Include screenshots if relevant. -->
<!-- If you're getting an error message, copy it, and enclose it with three backticks (```). -->
I may be misunderstanding how this is supposed to work, on the other hand I will argue that the help window for Color does not show the SAVE/LOAD/RESET buttons and accordingly does not explain them :-)  (So the help page, at least, would need to be updated.)

Anyway, in the attached workflow I have a widget Color in which I set the colours for Japan and United States, respectively.  I have pressed SAVE in order to create a file GroupedChartsColourMap.colors.  In another branch of the workflow I want to reuse these colour settings, so in the widget Color (1) I have pressed LOAD in order to read the saved colours.  However, this does not seem to affect the colour settings for Japan and United States in that widget, they retain their defaults.

My assumption was that the SAVE/LOAD pair allows me to reuse colour maps in different workflows, but this does not happen?


**How can we reproduce the problem?** 
<!-- Upload a zip with the .ows file and data. -->
<!-- Describe the steps (open this widget, click there, then add this...) -->

The workflow, data, and colors file are attached.  If you open the Color widget, you see the intended setting of country colours.  If you open Color (1), press LOAD and load the GroupedChartsColourMap.colors file, you can see that the colour settings in that widget are not affected.



**What's your environment?**
<!-- To find your Orange version, see "Help → About → Version" or `Orange.version.full_version` in code -->
- Operating system: macOS 12.1
- Orange version: 3.30.2
- How you installed Orange: From disk image at https://orangedatamining.com/download/#macos

[ColourMap.zip](https://github.com/biolab/orange3/files/7735248/ColourMap.zip)




Discussion:
- "USA" is not the same country as "United States". Orange is smart, but not that smart. :)
- In fact, Orange is quite stupid. Even variable "Country" is not the same variable as "country". (We could make variable names case insensitive, but God knows how many old workflows this would break. And it would probably be a bad idea anyway.)

Rename "Country" and the country and it will work. :)

But a little warning would be nice nevertheless. I will change the widget to warn the user about ignored variable definitions ("country", in your case), but not about the values.

Resolving patch:
diff --git a/Orange/widgets/data/owcolor.py b/Orange/widgets/data/owcolor.py
--- a/Orange/widgets/data/owcolor.py
+++ b/Orange/widgets/data/owcolor.py
@@ -647,16 +647,15 @@ def load(self):
             return
 
         try:
-            f = open(fname)
+            with open(fname) as f:
+                js = json.load(f)  #: dict
+                self._parse_var_defs(js)
         except IOError:
             QMessageBox.critical(self, "File error", "File cannot be opened.")
             return
-
-        try:
-            js = json.load(f)  #: dict
-            self._parse_var_defs(js)
         except (json.JSONDecodeError, InvalidFileFormat):
             QMessageBox.critical(self, "File error", "Invalid file format.")
+            return
 
     def _parse_var_defs(self, js):
         if not isinstance(js, dict) or set(js) != {"categorical", "numeric"}:
@@ -690,6 +689,7 @@ def _parse_var_defs(self, js):
 
         # First, construct all descriptions; assign later, after we know
         # there won't be exceptions due to invalid file format
+        unused_vars = []
         both_descs = []
         warnings = []
         for old_desc, repo, desc_type in (
@@ -700,11 +700,26 @@ def _parse_var_defs(self, js):
             for var_name, var_data in js[repo].items():
                 var = var_by_name.get(var_name)
                 if var is None:
+                    unused_vars.append(var_name)
                     continue
                 # This can throw InvalidFileFormat
                 new_descs[var_name], warn = desc_type.from_dict(var, var_data)
                 warnings += warn
             both_descs.append(new_descs)
+        if unused_vars:
+            names = [f"'{name}'" for name in unused_vars]
+            if len(unused_vars) == 1:
+                warn = f'Definition for variable {names[0]}, which does not ' \
+                       f'appear in the data, was ignored.\n'
+            else:
+                if len(unused_vars) <= 5:
+                    warn = 'Definitions for variables ' \
+                           f'{", ".join(names[:-1])} and {names[-1]}'
+                else:
+                    warn = f'Definitions for {", ".join(names[:4])} ' \
+                           f'and {len(names) - 4} other variables'
+                warn += ", which do not appear in the data, were ignored.\n"
+            warnings.insert(0, warn)
 
         self.disc_descs = [both_descs[0].get(desc.var.name, desc)
                            for desc in self.disc_descs]


Test patch:
diff --git a/Orange/widgets/data/tests/test_owcolor.py b/Orange/widgets/data/tests/test_owcolor.py
--- a/Orange/widgets/data/tests/test_owcolor.py
+++ b/Orange/widgets/data/tests/test_owcolor.py
@@ -793,6 +793,32 @@ def test_load(self, msg_box):
                 msg_box.reset_mock()
                 self.widget._parse_var_defs.assert_called_with(json.load.return_value)
 
+    @patch("Orange.widgets.data.owcolor.QMessageBox.warning")
+    def test_load_ignore_warning(self, msg_box):
+        self.widget._parse_var_defs(dict(categorical={}, numeric={}))
+        msg_box.assert_not_called()
+
+        no_change = dict(renamed_values={}, colors={})
+        for names, message in (
+                (("foo",),
+                 "'foo'"),
+                (("foo", "bar"),
+                 "'foo' and 'bar'"),
+                (("foo", "bar", "baz"),
+                 "'foo', 'bar' and 'baz'"),
+                (("foo", "bar", "baz", "qux"),
+                 "'foo', 'bar', 'baz' and 'qux'"),
+                (("foo", "bar", "baz", "qux", "quux"),
+                 "'foo', 'bar', 'baz', 'qux' and 'quux'"),
+                (("foo", "bar", "baz", "qux", "quux", "corge"),
+                 "'foo', 'bar', 'baz', 'qux' and 2 other"),
+                (("foo", "bar", "baz", "qux", "quux", "corge", "grault"),
+                 "'foo', 'bar', 'baz', 'qux' and 3 other")):
+            self.widget._parse_var_defs(dict(
+                categorical=dict.fromkeys(names, no_change),
+                numeric={}))
+            self.assertIn(message, msg_box.call_args[0][2])
+
     def _create_descs(self):
         disc_vars = [DiscreteVariable(f"var{c}", values=("a", "b", "c"))
                      for c in "AB"]
@@ -882,11 +908,6 @@ def test_parse_var_defs_no_rename(self, msg_box):
              "numeric": {"varD": {"rename": "varA"}}})
         msg_box.assert_not_called()
 
-        self.widget._parse_var_defs(
-            {"categorical": {"varA": {"rename": "X"}},
-             "numeric": {"var not": {"rename": "X"}}})
-        msg_box.assert_not_called()
-
 
 if __name__ == "__main__":
     unittest.main()