Repository: napari/napari

Issue:
EventedModel is incompatible with dask delayed objects
## 🐛 Bug

`napari.utils.events.EventedModel` instantiation fails if one of the attributes is a `dask.Delayed` object. I'm not aware of this particular usage anywhere inside napari but it could feasibly come up so I thought it best to make an issue. 

I know this is appearing as a pydantic issue here (not napari or `EventedModel` specific) but in my use case the error appeared when equality checking two `EventedModel` objects which had delayed attributes, not on instantiation.

I'm looking at a few ways to solve this now, for my use case simply removing the `__eq__` override in `EventedModel` works but this isn't a good solution

https://github.com/napari/napari/blob/255822fb17f25c79133ec40dba0e626b2bf66b20/napari/utils/events/evented_model.py#L202-L218

## To Reproduce

```python
from dask import delayed
from dask.delayed import DelayedLeaf
from napari.utils.events import EventedModel


@delayed
def my_function():
    pass


class MyObject(EventedModel):
    attribute: DelayedLeaf = my_function
```

```python
Traceback (most recent call last):
  File "/Users/aburt/ccpem-projects/pp2d-relion/bug_report_eventedmodel_equality.py", line 10, in <module>
    class MyObject(EventedModel):
  File "/opt/homebrew/Caskroom/miniforge/base/envs/pp2d-relion/lib/python3.9/site-packages/napari/utils/events/evented_model.py", line 79, in __new__
    cls = super().__new__(mcs, name, bases, namespace, **kwargs)
  File "pydantic/main.py", line 299, in pydantic.main.ModelMetaclass.__new__
  File "pydantic/fields.py", line 411, in pydantic.fields.ModelField.infer
  File "pydantic/fields.py", line 342, in pydantic.fields.ModelField.__init__
  File "pydantic/fields.py", line 445, in pydantic.fields.ModelField.prepare
  File "pydantic/fields.py", line 473, in pydantic.fields.ModelField._set_default_and_type
  File "pydantic/fields.py", line 345, in pydantic.fields.ModelField.get_default
  File "pydantic/utils.py", line 627, in pydantic.utils.smart_deepcopy
  File "/opt/homebrew/Caskroom/miniforge/base/envs/pp2d-relion/lib/python3.9/site-packages/dask/delayed.py", line 580, in __bool__
    raise TypeError("Truth of Delayed objects is not supported")
TypeError: Truth of Delayed objects is not supported
```




Discussion:
I think the key here is the [`pick_equality_operator` function ](https://github.com/napari/napari/blob/609fd9f4cc54832c752d3691d7f16e15fbf86517/napari/utils/misc.py#L408)

That function takes an object (which in this case would be a dask.Delayed object) and returns an appropriate function for checking equality.  In "hard" cases like this, we simply return `operator.is_`, which just checks for object identity (probably the best we can do). 
The evented model then populates the equality checker for each type in the model on this line here:

https://github.com/napari/napari/blob/609fd9f4cc54832c752d3691d7f16e15fbf86517/napari/utils/events/evented_model.py#L81-L82

So, to fix this, maybe just try adding `'dask.delayed.Delayed': operator.is_` to the `_known_arrays` dict in `pick_equality_operator`?
Thanks for taking a look!

I tried your suggestion and it seems like using `Delayed` objects in evented models provides lots of opportunities for failure. 

I can get the `EventedModel` to instantiate properly like this
```Python
from dask import delayed
from dask.delayed import DelayedLeaf
from napari.utils.events import EventedModel
from typing import Optional

@delayed
def my_function():
    pass


class MyObject(EventedModel):
    attribute: Optional[DelayedLeaf] = None


o1 = MyObject()
o1.attribute = my_function
```

but equality checking in the following example fails 
```Python
from dask import delayed
from dask.delayed import DelayedLeaf
from napari.utils.events import EventedModel
from typing import Optional

@delayed
def my_function():
    pass


class MyObject(EventedModel):
    attribute: Optional[DelayedLeaf] = None


o1, o2 = MyObject(), MyObject()
o1.attribute, o2.attribute = my_function, my_function

o1 == o2
```

with the following traceback

```python
Traceback (most recent call last):
  File "/opt/homebrew/Caskroom/miniforge/base/envs/napari-vispy/lib/python3.9/site-packages/IPython/core/interactiveshell.py", line 3441, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-2-b23fb3c80ec7>", line 1, in <module>
    runfile('/Users/aburt/Programming/napari/playground/delayed_in_eventedmodel.py', wdir='/Users/aburt/Programming/napari/playground')
  File "/Applications/PyCharm.app/Contents/plugins/python/helpers/pydev/_pydev_bundle/pydev_umd.py", line 197, in runfile
    pydev_imports.execfile(filename, global_vars, local_vars)  # execute the script
  File "/Applications/PyCharm.app/Contents/plugins/python/helpers/pydev/_pydev_imps/_pydev_execfile.py", line 18, in execfile
    exec(compile(contents+"\n", file, 'exec'), glob, loc)
  File "/Users/aburt/Programming/napari/playground/delayed_in_eventedmodel.py", line 16, in <module>
    o1.attribute, o2.attribute = my_function, my_function
  File "/Users/aburt/Programming/napari/napari/utils/events/evented_model.py", line 154, in __setattr__
    if not are_equal(after, before):
  File "/opt/homebrew/Caskroom/miniforge/base/envs/napari-vispy/lib/python3.9/site-packages/dask/delayed.py", line 580, in __bool__
    raise TypeError("Truth of Delayed objects is not supported")
TypeError: Truth of Delayed objects is not supported
```

Attempting to modify the `__bool__` method on the `Delayed` object in a model validator politely told me that Delayed objects were immutable 🙂 

I've changed approach now and have something (without Dask) which is working okay for delayed data in an otherwise evented model

```python
import numpy as np
from pathlib import Path
from typing import Optional, Callable

from napari.utils.events import EventedModel


class LazyModel(EventedModel):
    """A (lazy) evented data model
    """
    path: Path
    reader: Callable = None
    _lazy_data: Optional[np.ndarray] = None

    @property
    def lazy_data(self):
        self._lazy_data = self.reader(self.path)
        return self._lazy_data

    @lazy_data.setter
    def lazy_data(self, value):
        self._lazy_data = value

    def clear_lazy_data(self):
        self._lazy_data = None


def my_reader(*args):
    return np.ones((5, 5))


test = LazyModel(path='test', reader=my_reader)

assert test._lazy_data is None
assert isinstance(test.lazy_data, np.ndarray)
assert test._lazy_data is not None
test.clear_lazy_data()
assert test._lazy_data is None
assert isinstance(test.lazy_data, np.ndarray)

```
looks like the fully qualified name of Delayed is not `'dask.delayed.Delayed'` but rather `'dask.Delayed'`

so change the known arrays to look like this instead and it should work:

```python
    _known_arrays = {
        'numpy.ndarray': np.array_equal,  # numpy.ndarray
        'dask.Array': operator.is_,  # dask.array.core.Array
        'zarr.Array': operator.is_,  # zarr.core.Array
        'xarray.DataArray': np.array_equal,  # xarray.core.dataarray.DataArray
        'dask.Delayed': operator.is_,
    }
```

(in other words, it was _still_ using `operator.eq` for Delayed comparison))

Resolving patch:
diff --git a/napari/utils/misc.py b/napari/utils/misc.py
--- a/napari/utils/misc.py
+++ b/napari/utils/misc.py
@@ -435,6 +435,7 @@ def pick_equality_operator(obj) -> Callable[[Any, Any], bool]:
     _known_arrays = {
         'numpy.ndarray': np.array_equal,  # numpy.ndarray
         'dask.Array': operator.is_,  # dask.array.core.Array
+        'dask.Delayed': operator.is_,  # dask.delayed.Delayed
         'zarr.Array': operator.is_,  # zarr.core.Array
         'xarray.DataArray': np.array_equal,  # xarray.core.dataarray.DataArray
     }


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
@@ -5,6 +5,8 @@
 import dask.array as da
 import numpy as np
 import pytest
+from dask import delayed
+from dask.delayed import Delayed
 from pydantic import Field
 
 from napari.utils.events import EmitterGroup, EventedModel
@@ -300,3 +302,19 @@ class Model(EventedModel):
     assert raw == r'{"nest": {"obj": {"a": 1, "b": "hi"}}}'
     deserialized = Model.parse_raw(raw)
     assert deserialized == m
+
+
+def test_evented_model_dask_delayed():
+    """Test that evented models work with dask delayed objects"""
+
+    class MyObject(EventedModel):
+        attribute: Delayed
+
+    @delayed
+    def my_function():
+        pass
+
+    o1 = MyObject(attribute=my_function)
+
+    # check that equality checking works as expected
+    assert o1 == o1