Repository: napari/napari

Issue:
Fix evented model array equality tests
# Description
This PR will address the comment by @kevinyamauchi https://github.com/napari/napari/pull/2042#discussion_r567911985 around equality testing for models with arrays. Right now it only has tests.

## Type of change
<!-- Please delete options that are not relevant. -->
- [ ] Bug-fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

# References
<!-- What resources, documentation, and guides were used in the creation of this PR? -->
<!-- If this is a bug-fix or otherwise resolves an issue, reference it here with "closes #(issue)" -->

# How has this been tested?
<!-- Please describe the tests that you ran to verify your changes. -->
- [ ] example: the test suite for my feature covers cases x, y, and z
- [ ] example: all tests pass with my change

## Final checklist:
- [ ] My PR is the minimum possible work for the desired functionality
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] I have added tests that prove my fix is effective or that my feature works



Discussion:
This seems quite challenging actually - if you look at the `__eq__` method in pydantic it is just taking the `.dict` method then doing the `==`, see https://github.com/samuelcolvin/pydantic/blob/43308d1b24c5d1163c44b8fa786d1b27e000c64e/pydantic/main.py#L850-L854

I'm not sure if we should just overwrite this ourselves completely? Thoughts @tlambert03? Any advice/ recommendations here appreciated 
> I'm not sure if we should just overwrite this ourselves completely? Thoughts @tlambert03? Any advice/ recommendations here appreciated

yeah, it's a bit tricky.  I think we'll need to special case it (since it's just dict comparison).  ~We could take advantage of the `exclude` parameter in `dict()`, and pull out all array fields to compare them separately~ (see next comment for slightly better):

```python
class EventedModel(BaseModel):

    ...
    
    def __eq__(self, other) -> bool:
        import numpy as np

        array_fields = {
            n for n, f in self.__fields__.items() if _is_array(f.type_)
        }
        _dict = self.dict(exclude=array_fields)
        if isinstance(other, BaseModel):
            other_dict = other.dict(exclude=array_fields)
            if _dict == other_dict:
                
                return all(
                    np.array_equal(getattr(self, f), getattr(other, f))
                    for f in array_fields
                )
            return False
        else:
            # haven't worked on this part yet
            return _dict == other


def _is_array(obj):
    import numpy as np
    import dask.array as da

    return issubclass(obj, (np.ndarray, da.Array))
```

actually... now that we're using BaseModel instead of dataclasses ... we can take better advantage of the metaclass to detect if we're working with a model that has arrays, and only then override the `__eq__` method:

```python

def _is_array(obj):
    import numpy as np
    import dask.array as da

    return issubclass(obj, (np.ndarray, da.Array))


def make_eq(array_fields):
    def _eq_(self, other) -> bool:
        import numpy as np

        _dict = self.dict(exclude=array_fields)
        if isinstance(other, BaseModel):
            other_dict = other.dict(exclude=array_fields)
            if _dict == other_dict:

                return all(
                    np.array_equal(getattr(self, f), getattr(other, f))
                    for f in array_fields
                )
            return False
        else:
            # haven't worked on this part yet
            return _dict == other

    return _eq_


class _Meta(ModelMetaclass):
    def __new__(mcs, name, bases, namespace, **kwargs):
        cls = super().__new__(mcs, name, bases, namespace, **kwargs)
        array_fields = {
            n for n, f in cls.__fields__.items() if _is_array(f.type_)
        }
        if array_fields:
            cls.__eq__ = make_eq(array_fields)
        return cls


class EventedModel(BaseModel, metaclass=_Meta):
    ...
```
> actually... now that we're using BaseModel instead of dataclasses ... we can take better advantage of the metaclass to detect if we're working with a model that has arrays, and only then override the __eq__ method:

@tlambert03 would this approach allow us to drop `__equality_checks__` and `from .dataclass import _type_to_compare, is_equal` that we use inside the event emission? Ideally we'd have just one unified way to do equality checking.

Also right now this is only relevant for our custom type [Array](https://github.com/napari/napari/blob/master/napari/utils/events/custom_types.py#L4) I wonder if we can't leverage that class more - i.e. add a special `__eq__` method to that type that then gets used here. It would keep all the numpy imports and calls localized to that class, which we'll probably need to extend as we think about supporting arrays of other types 

Resolving patch:
diff --git a/napari/utils/events/evented_model.py b/napari/utils/events/evented_model.py
--- a/napari/utils/events/evented_model.py
+++ b/napari/utils/events/evented_model.py
@@ -1,19 +1,45 @@
+import operator
 import warnings
 from typing import ClassVar, Dict, Set
 
 from pydantic import BaseModel, PrivateAttr
+from pydantic.main import ModelMetaclass
 
+from ...utils.misc import EqOperator, pick_equality_operator
 from .custom_types import JSON_ENCODERS
-from .dataclass import _type_to_compare, is_equal
 from .event import EmitterGroup, Event
 
 
-class EventedModel(BaseModel):
+class EqualityMetaclass(ModelMetaclass):
+    """pydantic ModelMetaclass that preps "equality checking" operations.
+
+    A metaclass is the thing that "constructs" a class, and ``ModelMetaclass``
+    is where pydantic puts a lot of it's type introspection and ``ModelField``
+    creation logic.  Here, we simply tack on one more function, that builds a
+    ``cls.__eq_operators__`` dict which is mapping of field name to a function
+    that can be called to check equality of the value of that field with some
+    other object.  (used in ``EventedModel.__eq__``)
+
+    This happens only once, when an ``EventedModel`` class is created (and not
+    when each instance of an ``EventedModel`` is instantiated).
+    """
+
+    def __new__(mcs, name, bases, namespace, **kwargs):
+        cls = super().__new__(mcs, name, bases, namespace, **kwargs)
+        cls.__eq_operators__ = {
+            n: pick_equality_operator(f.type_)
+            for n, f in cls.__fields__.items()
+        }
+        return cls
+
+
+class EventedModel(BaseModel, metaclass=EqualityMetaclass):
 
     # add private attributes for event emission
     _events: EmitterGroup = PrivateAttr(default_factory=EmitterGroup)
-    __equality_checks__: Dict = PrivateAttr(default_factory=dict)
-    __slots__: ClassVar[Set[str]] = {"__weakref__"}
+
+    __eq_operators__: ClassVar[Dict[str, EqOperator]]
+    __slots__: ClassVar[Set[str]] = {"__weakref__"}  # type: ignore
 
     # pydantic BaseModel configuration.  see:
     # https://pydantic-docs.helpmanual.io/usage/model_config/
@@ -44,16 +70,6 @@ def __init__(self, *args, **kwargs):
         self._events.source = self
         self._events.add(**dict.fromkeys(self.__fields__))
 
-        # create dict with compare functions for fields which cannot be compared
-        # using standard equal operator, like numpy arrays.
-        compare_dict = {
-            field.name: _type_to_compare(field.type_)
-            for field in self.__fields__.values()
-            if _type_to_compare(field.type_) is not None
-        }
-
-        self.__equality_checks__.update(compare_dict)
-
     def __setattr__(self, name, value):
         if name not in getattr(self, 'events', {}):
             # fallback to default behavior
@@ -68,9 +84,9 @@ def __setattr__(self, name, value):
 
         # if different we emit the event with new value
         after = getattr(self, name)
-        if not self.__equality_checks__.get(name, is_equal)(after, before):
-            # emit event
-            getattr(self.events, name)(value=after)
+        are_equal = self.__eq_operators__.get(name, operator.eq)
+        if not are_equal(after, before):
+            getattr(self.events, name)(value=after)  # emit event
 
     # expose the private EmitterGroup publically
     @property
@@ -81,8 +97,8 @@ def asdict(self):
         """Convert a model to a dictionary."""
         warnings.warn(
             (
-                "The `asdict` method has been renamed `dict` and is now deprecated. It will be"
-                " removed in 0.4.7"
+                "The `asdict` method has been renamed `dict` and is now "
+                "deprecated. It will be removed in 0.4.7"
             ),
             category=FutureWarning,
             stacklevel=2,
@@ -95,9 +111,9 @@ def update(self, values):
         Parameters
         ----------
         values : dict, napari.utils.events.EventedModel
-            Values to update the model with. If an EventedModel is passed it is first
-            converted to a dictionary. The keys of this dictionary must be found as
-            attributes on the current model.
+            Values to update the model with. If an EventedModel is passed it is
+            first converted to a dictionary. The keys of this dictionary must
+            be found as attributes on the current model.
         """
         if isinstance(values, self.__class__):
             values = values.dict()
@@ -110,3 +126,21 @@ def update(self, values):
 
         if block.count:
             self.events(Event(self))
+
+    def __eq__(self, other) -> bool:
+        """Check equality with another object.
+
+        We override the pydantic approach (which just checks
+        ``self.dict() == other.dict()``) to accomodate more complicated types
+        like arrays, whose truth value is often ambiguous. ``__eq_operators__``
+        is constructed in ``EqualityMetaclass.__new__``
+        """
+        if isinstance(other, EventedModel):
+            for f_name, eq in self.__eq_operators__.items():
+                if f_name not in other.__eq_operators__:
+                    return False
+                if not eq(getattr(self, f_name), getattr(other, f_name)):
+                    return False
+            return True
+        else:
+            return self.dict() == other
diff --git a/napari/utils/misc.py b/napari/utils/misc.py
--- a/napari/utils/misc.py
+++ b/napari/utils/misc.py
@@ -8,7 +8,15 @@
 import sys
 from enum import Enum, EnumMeta
 from os import PathLike, fspath, path
-from typing import TYPE_CHECKING, Optional, Sequence, Type, TypeVar
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Optional,
+    Sequence,
+    Type,
+    TypeVar,
+)
 
 import numpy as np
 
@@ -390,3 +398,59 @@ def ensure_list_of_layer_data_tuple(val):
         except TypeError:
             pass
     raise TypeError('Not a valid list of layer data tuples!')
+
+
+EqOperator = Callable[[Any, Any], bool]
+
+
+def pick_equality_operator(obj: Any) -> EqOperator:
+    """Return a function that can check equality between ``obj`` and another.
+
+    Rather than always using ``==`` (i.e. ``operator.eq``), this function
+    returns operators that are aware of object types: mostly "array types with
+    more than one element" whose truth value is ambiguous.
+
+    This function works for both classes (types) and instances.  If an instance
+    is passed, it will be first cast to a type with type(obj).
+
+    Parameters
+    ----------
+    obj : Any
+        An object whose equality with another object you want to check.
+
+    Returns
+    -------
+    operator : Callable[[Any, Any], bool]
+        An operation that can be called as ``operator(obj, other)`` to check
+        equality between objects of type ``type(obj)``.
+    """
+    import operator
+
+    if not inspect.isclass(obj):
+        obj = type(obj)
+
+    if issubclass(obj, np.ndarray):
+        return np.array_equal
+
+    import dask.array
+
+    if issubclass(obj, dask.array.Array):
+        return operator.is_
+
+    try:
+        import zarr.core
+
+        if issubclass(obj, zarr.core.Array):
+            return operator.is_
+    except ImportError:
+        pass
+
+    try:
+        import xarray.core.dataarray
+
+        if issubclass(obj, xarray.core.dataarray.DataArray):
+            return np.array_equal
+    except ImportError:
+        pass
+
+    return operator.eq


Test patch:
diff --git a/napari/utils/events/_tests/test_evented_model.py b/napari/utils/events/_tests/test_evented_model.py
--- a/napari/utils/events/_tests/test_evented_model.py
+++ b/napari/utils/events/_tests/test_evented_model.py
@@ -1,6 +1,7 @@
 from typing import ClassVar
 from unittest.mock import Mock
 
+import dask.array as da
 import numpy as np
 import pytest
 from pydantic import Field
@@ -103,6 +104,86 @@ class Model(EventedModel):
         model.shaped2_values = [1]
 
 
+def test_evented_model_array_updates():
+    """Test updating an evented pydantic model with an array."""
+
+    class Model(EventedModel):
+        """Demo evented model."""
+
+        values: Array[int]
+
+    model = Model(values=[1, 2, 3])
+
+    # Mock events
+    model.events.values = Mock(model.events.values)
+
+    np.testing.assert_almost_equal(model.values, np.array([1, 2, 3]))
+
+    # Updating with new data
+    model.values = [1, 2, 4]
+    assert model.events.values.call_count == 1
+    np.testing.assert_almost_equal(
+        model.events.values.call_args[1]['value'], np.array([1, 2, 4])
+    )
+    model.events.values.reset_mock()
+
+    # Updating with same data, no event should be emitted
+    model.values = [1, 2, 4]
+    model.events.values.assert_not_called()
+
+
+def test_evented_model_array_equality():
+    """Test checking equality with an evented model with custom array."""
+
+    class Model(EventedModel):
+        """Demo evented model."""
+
+        values: Array[int]
+
+    model1 = Model(values=[1, 2, 3])
+    model2 = Model(values=[1, 5, 6])
+
+    assert model1 == model1
+    assert model1 != model2
+
+    model2.values = [1, 2, 3]
+    assert model1 == model2
+
+
+def test_evented_model_np_array_equality():
+    """Test checking equality with an evented model with direct numpy."""
+
+    class Model(EventedModel):
+        values: np.ndarray
+
+    model1 = Model(values=np.array([1, 2, 3]))
+    model2 = Model(values=np.array([1, 5, 6]))
+
+    assert model1 == model1
+    assert model1 != model2
+
+    model2.values = np.array([1, 2, 3])
+    assert model1 == model2
+
+
+def test_evented_model_da_array_equality():
+    """Test checking equality with an evented model with direct dask."""
+
+    class Model(EventedModel):
+        values: da.Array
+
+    r = da.ones((64, 64))
+    model1 = Model(values=r)
+    model2 = Model(values=da.ones((64, 64)))
+
+    assert model1 == model1
+    # dask arrays will only evaluate as equal if they are the same object.
+    assert model1 != model2
+
+    model2.values = r
+    assert model1 == model2
+
+
 def test_values_updated():
     class User(EventedModel):
         """Demo evented model.