Skip to content

Commit

Permalink
Fix EventedModel signatures with PySide2 imported (#2265)
Browse files Browse the repository at this point in the history
  • Loading branch information
tlambert03 committed Feb 14, 2021
1 parent b36cfdc commit 46f242b
Show file tree
Hide file tree
Showing 2 changed files with 64 additions and 7 deletions.
12 changes: 12 additions & 0 deletions napari/utils/events/_tests/test_evented_model.py
@@ -1,3 +1,4 @@
import inspect
from typing import ClassVar
from unittest.mock import Mock

Expand Down Expand Up @@ -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"
59 changes: 52 additions & 7 deletions 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``
Expand All @@ -25,15 +69,16 @@ 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()
}
return cls


class EventedModel(BaseModel, metaclass=EqualityMetaclass):
class EventedModel(BaseModel, metaclass=EventedMetaclass):

# add private attributes for event emission
_events: EmitterGroup = PrivateAttr(default_factory=EmitterGroup)
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 46f242b

Please sign in to comment.