Repository: napari/napari

Issue:
PySide2 conflicting with pydantic signatures
## 🐛 Bug
~I don't know how to explain this one yet... but simply importing napari is changing/breaking something about `pydantic.BaseModel`.  It may be doing many things, but the way I noticed it is that BaseModel signatures are breaking after napari is imported:~

**update**: PySide2 is still not playing well with pydantic (or apparently anything that tries to dynamically update a class `__signature__` attribute):

```python
In [1]: from pydantic import BaseModel

In [2]: import inspect

In [3]: class T(BaseModel):
   ...:     x: int
   ...:     y: str = 'sadf'
   ...:

# so far so good
In [4]: inspect.signature(T)
Out[4]: <Signature (*, x: int, y: str = 'sadf') -> None>

In [5]: import PySide2

In [6]: inspect.signature(T)
Out[6]: <Signature (**data: typing.Any) -> None>  # ??

# let's try that again
In [7]: class T(BaseModel):
   ...:     x: int
   ...:     y: str = 'sadf'
   ...:

In [8]: inspect.signature(T)  # ???
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-8-e75fbef4a311> in <module>
----> 1 inspect.signature(T)

~/miniconda3/envs/napdev/lib/python3.8/inspect.py in signature(obj, follow_wrapped)
   3091 def signature(obj, *, follow_wrapped=True):
   3092     """Get a signature object for the passed callable."""
-> 3093     return Signature.from_callable(obj, follow_wrapped=follow_wrapped)
   3094
   3095

~/miniconda3/envs/napdev/lib/python3.8/inspect.py in from_callable(cls, obj, follow_wrapped)
   2840     def from_callable(cls, obj, *, follow_wrapped=True):
   2841         """Constructs Signature for the given callable object."""
-> 2842         return _signature_from_callable(obj, sigcls=cls,
   2843                                         follow_wrapper_chains=follow_wrapped)
   2844

~/miniconda3/envs/napdev/lib/python3.8/inspect.py in _signature_from_callable(obj, follow_wrapper_chains, skip_bound_arg, sigcls)
   2250         if sig is not None:
   2251             if not isinstance(sig, Signature):
-> 2252                 raise TypeError(
   2253                     'unexpected object {!r} in __signature__ '
   2254                     'attribute'.format(sig))

TypeError: unexpected object <pydantic.utils.ClassAttribute object at 0x7fe9b925d4f0> in __signature__ attribute
```

I say "still" because [I thought](https://github.com/napari/napari/pull/2042#issuecomment-753519807) this [bug](https://bugreports.qt.io/browse/PYSIDE-1004), was solved in PySide2 5.13.1... which is why we bumped our pyside requirement.  This, however, seems to be a slightly different side of that old bug.  need to dig deeper


Discussion:
ok, some more details... this is really quite a mess.  I'm still only kind of following here. but [this patch](https://codereview.qt-project.org/c/pyside/pyside-setup/+/261411) in PySide2 describes their "fix" as follows:

> It turned out that the mock tool of the unittest module wants to
write into a `__signature__` attribute. We now allow this by implementing
a writable attribute that memorizes any written value.

I'm not sure, but it kinda seems like they're directly modifying how the `__signature__` attribute is retrieved for _everything_?  (seems atrocious, but maybe?)

pydantic, however, doesn't set the `__signature__` attribute directly to an instance of `inspect.Signature`, but rather uses a [descriptor object](https://github.com/samuelcolvin/pydantic/blob/13928e5b98dd2bc6c1e61546de20d870a9e3d3e9/pydantic/utils.py#L565) to hide the class signature on instances (they do this to [fix the signature on callable instance](https://github.com/samuelcolvin/pydantic/pull/1466)).  Apparently, when python then goes to get the class `__signature__`... pyside2 seems to have broken that descriptor "getter" and instead returns the descriptor class itself.  what a mess.

For us, I think we might be able to work around this by undoing what pydantic does with their signature descriptor, and simply being aware that we might need to modify the signature (if desired) on callable object instances.

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,16 +1,60 @@
 import operator
+import sys
 import warnings
+from contextlib import contextmanager
 from typing import Any, Callable, ClassVar, Dict, Set
 
-from pydantic import BaseModel, PrivateAttr
-from pydantic.main import ModelMetaclass
+from pydantic import BaseModel, PrivateAttr, main, utils
 
 from ...utils.misc import pick_equality_operator
 from .custom_types import JSON_ENCODERS
 from .event import EmitterGroup, Event
 
 
-class EqualityMetaclass(ModelMetaclass):
+@contextmanager
+def no_class_attributes():
+    """Context in which pydantic.main.ClassAttribute just passes value 2.
+
+    Due to a very annoying decision by PySide2, all class ``__signature__``
+    attributes may only be assigned **once**.  (This seems to be regardless of
+    whether the class has anything to do with PySide2 or not).  Furthermore,
+    the PySide2 ``__signature__`` attribute seems to break the python
+    descriptor protocol, which means that class attributes that have a
+    ``__get__`` method will not be able to successfully retrieve their value
+    (instead, the descriptor object itself will be accessed).
+
+    This plays terribly with Pydantic, which assigns a ``ClassAttribute``
+    object to the value of ``cls.__signature__`` in ``ModelMetaclass.__new__``
+    in order to avoid masking the call signature of object instances that have
+    a ``__call__`` method (https://github.com/samuelcolvin/pydantic/pull/1466).
+
+    So, because we only get to set the ``__signature__`` once, this context
+    manager basically "opts-out" of pydantic's ``ClassAttribute`` strategy,
+    thereby directly setting the ``cls.__signature__`` to an instance of
+    ``inspect.Signature``.
+
+    For additional context, see:
+    - https://github.com/napari/napari/issues/2264
+    - https://github.com/napari/napari/pull/2265
+    - https://bugreports.qt.io/browse/PYSIDE-1004
+    - https://codereview.qt-project.org/c/pyside/pyside-setup/+/261411
+    """
+
+    if "PySide2" not in sys.modules:
+        yield
+        return
+
+    # monkey patch the pydantic ClassAttribute object
+    # the second argument to ClassAttribute is the inspect.Signature object
+    main.ClassAttribute = lambda x, y: y
+    try:
+        yield
+    finally:
+        # undo our monkey patch
+        main.ClassAttribute = utils.ClassAttribute
+
+
+class EventedMetaclass(main.ModelMetaclass):
     """pydantic ModelMetaclass that preps "equality checking" operations.
 
     A metaclass is the thing that "constructs" a class, and ``ModelMetaclass``
@@ -25,7 +69,8 @@ class EqualityMetaclass(ModelMetaclass):
     """
 
     def __new__(mcs, name, bases, namespace, **kwargs):
-        cls = super().__new__(mcs, name, bases, namespace, **kwargs)
+        with no_class_attributes():
+            cls = super().__new__(mcs, name, bases, namespace, **kwargs)
         cls.__eq_operators__ = {
             n: pick_equality_operator(f.type_)
             for n, f in cls.__fields__.items()
@@ -33,7 +78,7 @@ def __new__(mcs, name, bases, namespace, **kwargs):
         return cls
 
 
-class EventedModel(BaseModel, metaclass=EqualityMetaclass):
+class EventedModel(BaseModel, metaclass=EventedMetaclass):
 
     # add private attributes for event emission
     _events: EmitterGroup = PrivateAttr(default_factory=EmitterGroup)
@@ -63,8 +108,8 @@ class Config:
         # https://pydantic-docs.helpmanual.io/usage/exporting_models/#modeljson
         json_encoders = JSON_ENCODERS
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
 
         # add events for each field
         self._events.source = self


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,3 +1,4 @@
+import inspect
 from typing import ClassVar
 from unittest.mock import Mock
 
@@ -231,3 +232,14 @@ class User(EventedModel):
     user1.events.id.assert_not_called()
     user2.events.id.assert_not_called()
     assert user1_events.call_count == 0
+
+
+def test_evented_model_signature():
+    class T(EventedModel):
+        x: int
+        y: str = 'yyy'
+        z = b'zzz'
+
+    assert isinstance(T.__signature__, inspect.Signature)
+    sig = inspect.signature(T)
+    assert str(sig) == "(*, x: int, y: str = 'yyy', z: bytes = b'zzz') -> None"