Repository: biolab/orange3

Issue:
Deepcopied variables are not equal to their originals
**What's wrong?**

Deecopied variables with defined `compute_value` are not equal to their originals because of deepcopied `compute_value`.

**How can we reproduce the problem?** 

```python
>>> class A:
...    ...
...
>>> x = ContinuousVariable("x", compute_value=A())
>>> x == deepcopy(x)
False
```

In canvas, this causes problems because of https://github.com/biolab/orange3/blob/master/Orange/widgets/evaluate/owtestandscore.py#L987, which deepcopies all learners.

You can observe the consequence by trying https://github.com/biolab/orange3-educational/files/8737139/polynomial.regression.test.ows.zip posted by @wvdvegte in https://github.com/biolab/orange3-educational/issues/149 if you apply https://github.com/biolab/orange3-educational/issues/150. This workflow
- constructs a feature using Feature Constructor
- uses Select Columns to remove the features from which this feature is computed
- uses Test and Score to test Polynomial Regression.

Polynomial regression constructs a learner that is tied to a particular domain. Training data is converted into this domain. Although the input domain contains the same variables as data, they are different because the learner is deepcopied (and so are variables), so conversion fails.

**Solution**

We probably need to
- [ ] Define `__eq__` and `__hash__` for all classes used for `compute_value`
- [ ] Warn when classes without proper `__eq__` are used for `compute_value`.

**What's your environment?**

N/A


Discussion:
I see we've been already fixing this in #4922. (We = I, actually. But I forgot.)

We really need a warning.

Resolving patch:
diff --git a/Orange/data/variable.py b/Orange/data/variable.py
--- a/Orange/data/variable.py
+++ b/Orange/data/variable.py
@@ -1,4 +1,5 @@
 import re
+import types
 import warnings
 from collections.abc import Iterable
 
@@ -328,6 +329,14 @@ def __init__(self, name="", compute_value=None, *, sparse=False):
             warnings.warn("Variable must have a name", OrangeDeprecationWarning,
                           stacklevel=3)
         self._name = name
+        if compute_value is not None \
+                and not isinstance(compute_value, (types.BuiltinFunctionType,
+                                                   types.FunctionType)) \
+                and (type(compute_value).__eq__ is object.__eq__
+                     or compute_value.__hash__ is object.__hash__):
+            warnings.warn(f"{type(compute_value).__name__} should define"
+                          f"__eq__ and __hash__ to be used for compute_value")
+
         self._compute_value = compute_value
         self.unknown_str = MISSING_VALUES
         self.source_variable = None
diff --git a/Orange/widgets/data/owfeatureconstructor.py b/Orange/widgets/data/owfeatureconstructor.py
--- a/Orange/widgets/data/owfeatureconstructor.py
+++ b/Orange/widgets/data/owfeatureconstructor.py
@@ -1189,6 +1189,15 @@ def __reduce__(self):
     def __repr__(self):
         return "{0.__name__}{1!r}".format(*self.__reduce__())
 
+    def __hash__(self):
+        return hash((self.expression, tuple(self.args),
+                     tuple(sorted(self.extra_env.items())), self.cast))
+
+    def __eq__(self, other):
+        return type(self) is type(other) \
+            and self.expression == other.expression and self.args == other.args \
+            and self.extra_env == other.extra_env and self.cast == other.cast
+
 
 def unique(seq):
     seen = set()


Test patch:
diff --git a/Orange/data/tests/test_variable.py b/Orange/data/tests/test_variable.py
--- a/Orange/data/tests/test_variable.py
+++ b/Orange/data/tests/test_variable.py
@@ -235,6 +235,29 @@ def test_hash_eq(self):
         self.assertEqual(hash(a), hash(a1))
         self.assertEqual(hash(c1), hash(c2))
 
+    def test_compute_value_eq_warning(self):
+        with warnings.catch_warnings(record=True) as warns:
+            ContinuousVariable("x")
+            self.assertEqual(warns, [])
+            ContinuousVariable("x", compute_value=lambda *_: 42)
+            self.assertEqual(warns, [])
+
+            class Valid:
+                def __eq__(self, other):
+                    return self is other
+
+                def __hash__(self):
+                    return super().__hash__(self)
+
+            ContinuousVariable("x", compute_value=Valid())
+            self.assertEqual(warns, [])
+
+            class Invalid:
+                pass
+
+            ContinuousVariable("x", compute_value=Invalid())
+            self.assertNotEqual(warns, [])
+
 
 def variabletest(varcls):
     def decorate(cls):
diff --git a/Orange/widgets/data/tests/test_owfeatureconstructor.py b/Orange/widgets/data/tests/test_owfeatureconstructor.py
--- a/Orange/widgets/data/tests/test_owfeatureconstructor.py
+++ b/Orange/widgets/data/tests/test_owfeatureconstructor.py
@@ -324,6 +324,14 @@ def test_invalid_expression_variable(self):
         self.assertTrue(np.isnan(f(iris[0])))
         self.assertFalse(np.isnan(f(iris[1])))
 
+    def test_hash_eq(self):
+        iris = Table("iris")
+        f = FeatureFunc("1 / petal_length",
+                        [("petal_length", iris.domain["petal length"])])
+        g = copy.deepcopy(f)
+        self.assertEqual(f, g)
+        self.assertEqual(hash(f), hash(g))
+
 
 class OWFeatureConstructorTests(WidgetTest):
     def setUp(self):