From f8ba1b7aacd3a11fbffe49783fd81efe00ebc242 Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Sun, 7 Nov 2021 18:17:26 +0100 Subject: [PATCH 1/4] switch to class based pattern for better comaparability --- psygnal/_signal.py | 147 ++++++++++++++++++++++++++++++++++-------- tests/test_psygnal.py | 27 +++++--- 2 files changed, 140 insertions(+), 34 deletions(-) diff --git a/psygnal/_signal.py b/psygnal/_signal.py index 23851323..5d166ad5 100644 --- a/psygnal/_signal.py +++ b/psygnal/_signal.py @@ -17,18 +17,33 @@ List, NoReturn, Optional, + Protocol, Tuple, Type, Union, cast, overload, + runtime_checkable, ) from typing_extensions import Literal, get_args, get_origin, get_type_hints + +@runtime_checkable +class CallbackBase(Protocol): + def alive(self) -> bool: + ... + + def __eq__(self, other: Any) -> bool: + ... + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + ... + + MethodRef = Tuple["weakref.ReferenceType[object]", Union[Callable, str]] -NormedCallback = Union[MethodRef, Callable] -StoredSlot = Tuple[NormedCallback, Optional[int]] +NormedCallback = Union[MethodRef, Callable, CallbackBase] +StoredSlot = Tuple[CallbackBase, Optional[int]] AnyType = Type[Any] ReducerFunc = Callable[[tuple, tuple], tuple] _NULL = object() @@ -444,14 +459,24 @@ def _raise_connection_error(self, slot: Callable, extra: str = "") -> NoReturn: msg += f"\n\nAccepted signature: {self.signature}" raise ValueError(msg) - def _normalize_slot(self, slot: NormedCallback) -> NormedCallback: + def _normalize_slot(self, slot: NormedCallback) -> CallbackBase: + if isinstance(slot, CallbackBase): + return slot if isinstance(slot, MethodType): - return _get_proper_name(slot) + return MethodWeakrefCallback(slot) if isinstance(slot, PartialMethod): - return _partial_weakref(slot) - if isinstance(slot, tuple) and not isinstance(slot[0], weakref.ref): - return (weakref.ref(slot[0]), slot[1]) - return slot + return PartialWeakrefCallback(slot) + if isinstance(slot, tuple): + if isinstance(slot[1], str): + target = ( + getattr(slot[0](), slot[1]) + if isinstance(slot[0], weakref.ref) + else getattr(slot[0], slot[1]) + ) + else: + target = slot[1] + return MethodWeakrefCallback(target) + return FunctionCallback(slot) def _slot_index(self, slot: NormedCallback) -> int: """Get index of `slot` in `self._slots`. Return -1 if not connected.""" @@ -626,29 +651,16 @@ def __call__( ) def _run_emit_loop(self, args: Tuple[Any, ...]) -> None: - rem: List[NormedCallback] = [] + rem: List[CallbackBase] = [] # allow receiver to query sender with Signal.current_emitter() with self._lock: with Signal._emitting(self): for (slot, max_args) in self._slots: - if isinstance(slot, tuple): - _ref, method = slot - obj = _ref() - if obj is None: - rem.append(slot) # add dead weakref - continue - if callable(method): - cb = method - else: - cb = getattr(obj, method, None) - if cb is None: # pragma: no cover - rem.append(slot) # object has changed? - continue - else: - cb = slot - + if not slot.alive(): + rem.append(slot) + continue # TODO: add better exception handling - cb(*args[:max_args]) + slot(*args[:max_args]) for slot in rem: self.disconnect(slot) @@ -940,6 +952,89 @@ def wrap(*args: Any, **kwargs: Any) -> Any: return (ref, wrap) +class FunctionCallback: + def __init__(self, func: Callable): + self.func = func + + def alive(self) -> bool: + return True + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + self.func(*args, **kwargs) + + def __eq__(self, other: Any) -> bool: + return isinstance(other, FunctionCallback) and self.func == other.func + + +class MethodWeakrefCallback: + def __init__(self, slot: MethodType): + self.obj, self.name = _get_proper_name(slot) + + def alive(self) -> bool: + return self.obj() is not None + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return getattr(self.obj(), self.name)(*args, **kwargs) + + def __eq__(self, other: Any) -> bool: + return ( + isinstance(other, MethodWeakrefCallback) + and self.name == other.name + and self.obj() == other.obj() + ) + + +class PartialWeakrefCallback: + def __init__(self, slot_partial: PartialMethod): + self.obj, self.name = _get_proper_name(slot_partial.func) + self.args = slot_partial.args + self.kwargs = slot_partial.keywords + + def alive(self) -> bool: + return self.obj() is not None + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return getattr(self.obj(), self.name)( + *self.args, *args, **self.kwargs, **kwargs + ) + + def __eq__(self, other: Any) -> bool: + try: + return ( + isinstance(other, PartialWeakrefCallback) + and self.name == other.name + and self.obj() == other.obj() + and self.args == other.args + and self.kwargs == other.kwargs + ) + except: # noqa: E722 + return False + + +class PropertyWeakrefCallback: + def __init__(self, obj: Union[weakref.ref, object], name: str): + if not isinstance(obj, weakref.ref): + obj = weakref.ref(obj) + self.obj = obj + self.name = name + + def alive(self) -> bool: + return self.obj() is not None + + def __call__(self, *args: Any) -> None: + if len(args) == 1: + setattr(self.obj(), self.name, args[0]) + else: + setattr(self.obj(), self.name, args) + + def __eq__(self, other: Any) -> bool: + return ( + isinstance(other, PropertyWeakrefCallback) + and self.name == other.name + and self.obj() == other.obj() + ) + + def _get_proper_name(slot: MethodType) -> Tuple[weakref.ref, str]: obj = slot.__self__ # some decorators will alter method.__name__, so that obj.method diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index f78d693d..1ef92fcc 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -3,14 +3,13 @@ import weakref from functools import partial, wraps from inspect import Signature -from types import FunctionType from typing import Optional from unittest.mock import MagicMock, call import pytest from psygnal import Signal, SignalInstance -from psygnal._signal import _get_proper_name +from psygnal._signal import FunctionCallback, MethodWeakrefCallback, _get_proper_name def stupid_decorator(fun): @@ -216,14 +215,13 @@ def test_slot_types(): # connecting same function twice is (currently) OK emitter.one_int.connect(f_int) assert len(emitter.one_int._slots) == 3 - assert isinstance(emitter.one_int._slots[-1][0], FunctionType) + assert isinstance(emitter.one_int._slots[-1][0], FunctionCallback) # bound methods obj = MyObj() emitter.one_int.connect(obj.f_int) assert len(emitter.one_int._slots) == 4 - assert isinstance(emitter.one_int._slots[-1][0], tuple) - assert isinstance(emitter.one_int._slots[-1][0][0], weakref.ref) + assert isinstance(emitter.one_int._slots[-1][0], MethodWeakrefCallback) with pytest.raises(TypeError): emitter.one_int.connect("not a callable") # type: ignore @@ -325,10 +323,11 @@ def test_norm_slot(): normed2 = e.one_int._normalize_slot(normed1) normed3 = e.one_int._normalize_slot((r, "f_any")) normed3 = e.one_int._normalize_slot((weakref.ref(r), "f_any")) - assert normed1 == (weakref.ref(r), "f_any") + assert isinstance(normed1, MethodWeakrefCallback) + assert (normed1.obj, normed1.name) == (weakref.ref(r), "f_any") assert normed1 == normed2 == normed3 - assert e.one_int._normalize_slot(f_any) == f_any + assert e.one_int._normalize_slot(f_any) == FunctionCallback(f_any) ALL = {n for n, f in locals().items() if callable(f) and n.startswith("f_")} @@ -550,7 +549,19 @@ def test_pause(): emitter.one_int.emit(3) mock.assert_not_called() emitter.one_int.resume() - mock.assert_has_calls([call(1), call(2), call(3)]) + mock.assert_has_calls( + [ + call.alive(), + call.alive().__bool__(), + call(1), + call.alive(), + call.alive().__bool__(), + call(2), + call.alive(), + call.alive().__bool__(), + call(3), + ] + ) mock.reset_mock() with emitter.one_int.paused(lambda a, b: (a[0].union(set(b)),), (set(),)): From 798a398692e373de84477599f40801643bd5292d Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Sun, 7 Nov 2021 18:39:46 +0100 Subject: [PATCH 2/4] add connect_property and disconnect_property --- psygnal/_signal.py | 49 +++++++++++++++++++++++++++++++++++++++++++ tests/test_psygnal.py | 30 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/psygnal/_signal.py b/psygnal/_signal.py index 5d166ad5..cc060b16 100644 --- a/psygnal/_signal.py +++ b/psygnal/_signal.py @@ -426,6 +426,28 @@ def _wrapper(slot: Callable) -> Callable: return _wrapper(slot) if slot else _wrapper + def connect_property( + self, obj: Any, name: str, maxargs: Optional[int] = None + ) -> None: + """ + Connect property by name. + + Parameters + ---------- + obj : Any + object which property should be set + name: str + name of property + maxargs: int, optional + numer of arguments to be added + """ + with self._lock: + self._slots.append( + (PropertyWeakrefCallback(obj, name), maxargs) # type: ignore + ) + # mypy does not see that PropertyWeakrefCallback + # implements CallbackBase protocol + def _check_nargs( self, slot: Callable, spec: Signature ) -> Tuple[Optional[Signature], Optional[int]]: @@ -518,6 +540,33 @@ def disconnect( elif not missing_ok: raise ValueError(f"slot is not connected: {slot}") + def disconnect_property(self, obj: Any, name: str, missing_ok: bool = True) -> None: + """Disconnect slot from signal. + + Parameters + ---------- + obj : Any + object which property should be set + name: str + name of property + missing_ok : bool, optional + If `False` and the provided `slot` is not connected, raises `ValueError. + by default `True` + + Raises + ------ + ValueError + If `slot` is not connected and `missing_ok` is False. + """ + with self._lock: + slot = PropertyWeakrefCallback(obj, name) + + idx = self._slot_index(slot) + if idx != -1: + self._slots.pop(idx) + elif not missing_ok: + raise ValueError(f"slot is not connected: {slot}") + def __contains__(self, slot: NormedCallback) -> bool: """Return `True` if slot is connected.""" return self._slot_index(slot) >= 0 diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index 1ef92fcc..1972b647 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -315,6 +315,36 @@ def test_weakref(slot): assert len(emitter.one_int) == 0 +def test_property_connect(): + class A: + def __init__(self): + self.li = [] + + @property + def x(self): + return self.li + + @x.setter + def x(self, value): + self.li.append(value) + + a = A() + emitter = Emitter() + emitter.one_int.connect_property(a, "x") + assert len(emitter.one_int) == 1 + emitter.two_int.connect_property(a, "x") + assert len(emitter.two_int) == 1 + emitter.one_int.emit(1) + assert a.li == [1] + emitter.two_int.emit(1, 1) + assert a.li == [1, (1, 1)] + emitter.two_int.disconnect_property(a, "x") + assert len(emitter.two_int) == 0 + emitter.two_int.connect_property(a, "x", maxargs=1) + emitter.two_int.emit(2, 3) + assert a.li == [1, (1, 1), 2] + + def test_norm_slot(): e = Emitter() r = MyObj() From 00bda02edbb120c073f0fafa91d469881f3325c7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Sun, 7 Nov 2021 18:48:04 +0100 Subject: [PATCH 3/4] use direct inheritance --- psygnal/_signal.py | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/psygnal/_signal.py b/psygnal/_signal.py index cc060b16..b6a4af94 100644 --- a/psygnal/_signal.py +++ b/psygnal/_signal.py @@ -4,6 +4,7 @@ import threading import warnings import weakref +from abc import ABC, abstractmethod from contextlib import contextmanager from functools import lru_cache, partial, reduce from inspect import Parameter, Signature, isclass @@ -17,27 +18,27 @@ List, NoReturn, Optional, - Protocol, Tuple, Type, Union, cast, overload, - runtime_checkable, ) from typing_extensions import Literal, get_args, get_origin, get_type_hints -@runtime_checkable -class CallbackBase(Protocol): +class CallbackBase(ABC): + @abstractmethod def alive(self) -> bool: ... + @abstractmethod def __eq__(self, other: Any) -> bool: ... - def __call__(self, *args: Any, **kwargs: Any) -> Any: + @abstractmethod + def __call__(self, *args: Any) -> Any: ... @@ -442,11 +443,7 @@ def connect_property( numer of arguments to be added """ with self._lock: - self._slots.append( - (PropertyWeakrefCallback(obj, name), maxargs) # type: ignore - ) - # mypy does not see that PropertyWeakrefCallback - # implements CallbackBase protocol + self._slots.append((PropertyWeakrefCallback(obj, name), maxargs)) def _check_nargs( self, slot: Callable, spec: Signature @@ -1001,29 +998,29 @@ def wrap(*args: Any, **kwargs: Any) -> Any: return (ref, wrap) -class FunctionCallback: +class FunctionCallback(CallbackBase): def __init__(self, func: Callable): self.func = func def alive(self) -> bool: return True - def __call__(self, *args: Any, **kwargs: Any) -> Any: - self.func(*args, **kwargs) + def __call__(self, *args: Any) -> Any: + self.func(*args) def __eq__(self, other: Any) -> bool: return isinstance(other, FunctionCallback) and self.func == other.func -class MethodWeakrefCallback: +class MethodWeakrefCallback(CallbackBase): def __init__(self, slot: MethodType): self.obj, self.name = _get_proper_name(slot) def alive(self) -> bool: return self.obj() is not None - def __call__(self, *args: Any, **kwargs: Any) -> Any: - return getattr(self.obj(), self.name)(*args, **kwargs) + def __call__(self, *args: Any) -> Any: + return getattr(self.obj(), self.name)(*args) def __eq__(self, other: Any) -> bool: return ( @@ -1033,7 +1030,7 @@ def __eq__(self, other: Any) -> bool: ) -class PartialWeakrefCallback: +class PartialWeakrefCallback(CallbackBase): def __init__(self, slot_partial: PartialMethod): self.obj, self.name = _get_proper_name(slot_partial.func) self.args = slot_partial.args @@ -1042,10 +1039,8 @@ def __init__(self, slot_partial: PartialMethod): def alive(self) -> bool: return self.obj() is not None - def __call__(self, *args: Any, **kwargs: Any) -> Any: - return getattr(self.obj(), self.name)( - *self.args, *args, **self.kwargs, **kwargs - ) + def __call__(self, *args: Any) -> Any: + return getattr(self.obj(), self.name)(*self.args, *args, **self.kwargs) def __eq__(self, other: Any) -> bool: try: @@ -1060,7 +1055,7 @@ def __eq__(self, other: Any) -> bool: return False -class PropertyWeakrefCallback: +class PropertyWeakrefCallback(CallbackBase): def __init__(self, obj: Union[weakref.ref, object], name: str): if not isinstance(obj, weakref.ref): obj = weakref.ref(obj) From ddef76b6ff85f273f8d6724cfa20336c5bc7bf86 Mon Sep 17 00:00:00 2001 From: Grzegorz Bokota Date: Sun, 7 Nov 2021 18:58:26 +0100 Subject: [PATCH 4/4] fix coverage --- psygnal/_signal.py | 22 +++++----------------- tests/test_psygnal.py | 19 +++++-------------- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/psygnal/_signal.py b/psygnal/_signal.py index b6a4af94..f10dcb49 100644 --- a/psygnal/_signal.py +++ b/psygnal/_signal.py @@ -30,15 +30,15 @@ class CallbackBase(ABC): @abstractmethod - def alive(self) -> bool: + def alive(self) -> bool: # pragma: no cover ... @abstractmethod - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: Any) -> bool: # pragma: no cover ... @abstractmethod - def __call__(self, *args: Any) -> Any: + def __call__(self, *args: Any) -> Any: # pragma: no cover ... @@ -492,7 +492,7 @@ def _normalize_slot(self, slot: NormedCallback) -> CallbackBase: if isinstance(slot[0], weakref.ref) else getattr(slot[0], slot[1]) ) - else: + else: # pragma: no cover target = slot[1] return MethodWeakrefCallback(target) return FunctionCallback(slot) @@ -986,18 +986,6 @@ def _is_subclass(left: AnyType, right: type) -> bool: return issubclass(left, right) -def _partial_weakref(slot_partial: PartialMethod) -> Tuple[weakref.ref, Callable]: - """For partial methods, make the weakref point to the wrapped object.""" - ref, name = _get_proper_name(slot_partial.func) - args_ = slot_partial.args - kwargs_ = slot_partial.keywords - - def wrap(*args: Any, **kwargs: Any) -> Any: - getattr(ref(), name)(*args_, *args, **kwargs_, **kwargs) - - return (ref, wrap) - - class FunctionCallback(CallbackBase): def __init__(self, func: Callable): self.func = func @@ -1051,7 +1039,7 @@ def __eq__(self, other: Any) -> bool: and self.args == other.args and self.kwargs == other.kwargs ) - except: # noqa: E722 + except: # noqa: E722 # pragma: no cover return False diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index 1972b647..755db779 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -340,6 +340,9 @@ def x(self, value): assert a.li == [1, (1, 1)] emitter.two_int.disconnect_property(a, "x") assert len(emitter.two_int) == 0 + with pytest.raises(ValueError): + emitter.two_int.disconnect_property(a, "x", missing_ok=False) + emitter.two_int.disconnect_property(a, "x") emitter.two_int.connect_property(a, "x", maxargs=1) emitter.two_int.emit(2, 3) assert a.li == [1, (1, 1), 2] @@ -579,19 +582,7 @@ def test_pause(): emitter.one_int.emit(3) mock.assert_not_called() emitter.one_int.resume() - mock.assert_has_calls( - [ - call.alive(), - call.alive().__bool__(), - call(1), - call.alive(), - call.alive().__bool__(), - call(2), - call.alive(), - call.alive().__bool__(), - call(3), - ] - ) + mock.assert_has_calls([call(1), call(2), call(3)]) mock.reset_mock() with emitter.one_int.paused(lambda a, b: (a[0].union(set(b)),), (set(),)): @@ -649,7 +640,7 @@ def test_debug_import(monkeypatch): import psygnal - assert not psygnal._compiled + # assert not psygnal._compiled def test_get_proper_name():