From 92a1ab0460d3013b825c738686318da6afc137e8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 10 Feb 2024 12:04:37 -0500 Subject: [PATCH 01/43] wip --- src/psygnal/_evented_model_v1.py | 2 +- src/psygnal/_group2.py | 66 ++++++++++++++++++++++++++++++++ src/psygnal/_group_descriptor.py | 2 +- tests/test_evented_decorator.py | 4 +- tests/test_evented_model.py | 3 +- 5 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 src/psygnal/_group2.py diff --git a/src/psygnal/_evented_model_v1.py b/src/psygnal/_evented_model_v1.py index 1cc8433c..d73a7e2d 100644 --- a/src/psygnal/_evented_model_v1.py +++ b/src/psygnal/_evented_model_v1.py @@ -15,7 +15,7 @@ no_type_check, ) -from ._group import SignalGroup +from ._group2 import SignalGroup from ._group_descriptor import _check_field_equality, _pick_equality_operator from ._signal import Signal, SignalInstance diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py new file mode 100644 index 00000000..0084ea30 --- /dev/null +++ b/src/psygnal/_group2.py @@ -0,0 +1,66 @@ +from typing import Any, Mapping, NamedTuple + +from psygnal._signal import Signal, SignalInstance + + +class SignalAggInstance(SignalInstance): + def _slot_relay(self, *args: Any) -> None: + emitter = Signal.current_emitter() + if emitter: + info = EmissionInfo(emitter, args) + self._run_emit_loop((info,)) + + +class EmissionInfo(NamedTuple): + """Tuple containing information about an emission event. + + Attributes + ---------- + signal : SignalInstance + args: tuple + """ + + signal: SignalInstance + args: tuple[Any, ...] + + +class SignalGroup: + _signals_: Mapping[str, Signal] + + def __init__(self, instance: Any = None) -> None: + self._instance = instance + self._signal_instances = {n: getattr(self, n) for n in type(self)._signals_} + + def __init_subclass__(cls, strict: bool = False) -> None: + """Finds all Signal instances on the class and add them to `cls._signals_`.""" + cls._signals_ = {} + for k in dir(cls): + v = getattr(cls, k) + if isinstance(v, Signal): + cls._signals_[k] = v + super().__init_subclass__() + + def __getitem__(self, item: str) -> Signal: + return self._signals_[item] + + def __getattr__(self, name: str) -> Signal: + if name in self._signals_: + return self._signals_[name] + if name == "signals": # for backwards compatibility + # TODO: add deprecation warning + return self._signal_instances # type: ignore + raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") + + def __len__(self) -> int: + return len(self._signals_) + + def connect(self, *args, **kwargs) -> None: + breakpoint() + + +class MyGroup(SignalGroup): + sig1 = Signal(int) + sig2 = Signal(str) + + +g = MyGroup() diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index 46535585..d6ab629f 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -19,7 +19,7 @@ ) from ._dataclass_utils import iter_fields -from ._group import SignalGroup +from ._group2 import SignalGroup from ._signal import Signal, SignalInstance if TYPE_CHECKING: diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index 71b8d468..34aa99c1 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -16,12 +16,12 @@ from psygnal import ( - SignalGroup, SignalGroupDescriptor, evented, get_evented_namespace, is_evented, ) +from psygnal._group2 import SignalGroup decorated_or_descriptor = pytest.mark.parametrize( "decorator", [True, False], ids=["decorator", "descriptor"] @@ -38,7 +38,7 @@ def _check_events(cls, events_ns="events"): events = getattr(obj, events_ns) assert isinstance(events, SignalGroup) - assert set(events.signals) == {"bar", "baz", "qux"} + assert set(events._signals_) == {"bar", "baz", "qux"} mock = Mock() events.bar.connect(mock) diff --git a/tests/test_evented_model.py b/tests/test_evented_model.py index cfa6f4eb..a212143d 100644 --- a/tests/test_evented_model.py +++ b/tests/test_evented_model.py @@ -15,7 +15,8 @@ import pydantic.version from pydantic import BaseModel -from psygnal import EmissionInfo, EventedModel, SignalGroup +from psygnal import EmissionInfo, EventedModel +from psygnal._group2 import SignalGroup PYDANTIC_V2 = pydantic.version.VERSION.startswith("2") From 05602e42c0965e3c10903f38a1b6c640951f6d39 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Feb 2024 16:02:29 -0500 Subject: [PATCH 02/43] wip --- src/psygnal/__init__.py | 2 +- src/psygnal/_group2.py | 218 +++++++++++++++++++---- src/psygnal/containers/_evented_dict.py | 2 +- src/psygnal/containers/_evented_list.py | 2 +- src/psygnal/containers/_evented_proxy.py | 2 +- tests/test_group.py | 2 +- tests/test_psygnal.py | 6 +- 7 files changed, 195 insertions(+), 39 deletions(-) diff --git a/src/psygnal/__init__.py b/src/psygnal/__init__.py index 28c6ac98..3601a3e3 100644 --- a/src/psygnal/__init__.py +++ b/src/psygnal/__init__.py @@ -60,7 +60,7 @@ def version(package: str) -> str: from ._evented_decorator import evented from ._exceptions import EmitLoopError -from ._group import EmissionInfo, SignalGroup +from ._group2 import EmissionInfo, SignalGroup from ._group_descriptor import ( SignalGroupDescriptor, get_evented_namespace, diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index 0084ea30..466bc724 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -1,14 +1,17 @@ -from typing import Any, Mapping, NamedTuple +from __future__ import annotations -from psygnal._signal import Signal, SignalInstance +from typing import ( + Any, + Callable, + ClassVar, + ContextManager, + Iterable, + Iterator, + Mapping, + NamedTuple, +) - -class SignalAggInstance(SignalInstance): - def _slot_relay(self, *args: Any) -> None: - emitter = Signal.current_emitter() - if emitter: - info = EmissionInfo(emitter, args) - self._run_emit_loop((info,)) +from psygnal._signal import Signal, SignalInstance, _SignalBlocker class EmissionInfo(NamedTuple): @@ -24,43 +27,196 @@ class EmissionInfo(NamedTuple): args: tuple[Any, ...] -class SignalGroup: +class SignalRelay(SignalInstance): + """Special SignalInstance that can be used to connect to all signals in a group.""" + + def __init__(self, group: SignalGroup, instance: Any = None) -> None: + self._group = group + super().__init__(signature=(EmissionInfo,), instance=instance) + self._sig_was_blocked: dict[str, bool] = {} + for sig in group.values(): + sig.connect(self._slot_relay, check_nargs=False, check_types=False) + + def _slot_relay(self, *args: Any) -> None: + emitter = Signal.current_emitter() + if emitter: + info = EmissionInfo(emitter, args) + self._run_emit_loop((info,)) + + def connect_direct( + self, + slot: Callable | None = None, + *, + check_nargs: bool | None = None, + check_types: bool | None = None, + unique: bool | str = False, + max_args: int | None = None, + ) -> Callable[[Callable], Callable] | Callable: + """Connect `slot` to be called whenever *any* Signal in this group is emitted. + + Params are the same as {meth}`~psygnal.SignalInstance.connect`. It's probably + best to check whether `self.is_uniform()` + + Parameters + ---------- + slot : Callable + A callable to connect to this signal. If the callable accepts less + arguments than the signature of this slot, then they will be discarded when + calling the slot. + check_nargs : Optional[bool] + If `True` and the provided `slot` requires more positional arguments than + the signature of this Signal, raise `TypeError`. by default `True`. + check_types : Optional[bool] + If `True`, An additional check will be performed to make sure that types + declared in the slot signature are compatible with the signature + declared by this signal, by default `False`. + unique : Union[bool, str] + If `True`, returns without connecting if the slot has already been + connected. If the literal string "raise" is passed to `unique`, then a + `ValueError` will be raised if the slot is already connected. + By default `False`. + max_args : int, optional + If provided, `slot` will be called with no more more than `max_args` when + this SignalInstance is emitted. (regardless of how many arguments are + emitted). + + Returns + ------- + Union[Callable[[Callable], Callable], Callable] + [description] + """ + + def _inner(slot: Callable) -> Callable: + for sig in self._group.values(): + sig.connect( + slot, + check_nargs=check_nargs, + check_types=check_types, + unique=unique, + max_args=max_args, + ) + return slot + + return _inner if slot is None else _inner(slot) + + def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: + """Block this signal and all emitters from emitting.""" + super().block() + for k, v in self._group.items(): + if exclude and v in exclude or k in exclude: + continue + self._sig_was_blocked[k] = v._is_blocked + v.block() + + def unblock(self) -> None: + """Unblock this signal and all emitters, allowing them to emit.""" + super().unblock() + for k, v in self._group.items(): + if not self._sig_was_blocked.pop(k, False): + v.unblock() + + def blocked( + self, exclude: Iterable[str | SignalInstance] = () + ) -> ContextManager[None]: + """Context manager to temporarily block all emitters in this group. + + Parameters + ---------- + exclude : iterable of str or SignalInstance, optional + An iterable of signal instances or names to exempt from the block, + by default () + """ + return _SignalBlocker(self, exclude=exclude) + + def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> None: + """Disconnect slot from all signals. + + Parameters + ---------- + slot : callable, optional + The specific slot to disconnect. If `None`, all slots will be disconnected, + by default `None` + 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. + """ + for signal in self._group.values(): + signal.disconnect(slot, missing_ok) + super().disconnect(slot, missing_ok) + + +class SignalGroup(Mapping[str, SignalInstance]): _signals_: Mapping[str, Signal] + _uniform: ClassVar[bool] = False def __init__(self, instance: Any = None) -> None: - self._instance = instance - self._signal_instances = {n: getattr(self, n) for n in type(self)._signals_} + cls = type(self) + self._psygnal_instances: dict[str, SignalInstance] = { + name: signal.__get__(self, cls) for name, signal in cls._signals_.items() + } + self._psygnal_relay = SignalRelay(self, instance) def __init_subclass__(cls, strict: bool = False) -> None: """Finds all Signal instances on the class and add them to `cls._signals_`.""" - cls._signals_ = {} - for k in dir(cls): - v = getattr(cls, k) - if isinstance(v, Signal): - cls._signals_[k] = v + cls._signals_ = { + k: val + for k, val in getattr(cls, "__dict__", {}).items() + if isinstance(val, Signal) + } + + cls._uniform = _is_uniform(cls._signals_.values()) + if strict and not cls._uniform: + raise TypeError( + "All Signals in a strict SignalGroup must have the same signature" + ) super().__init_subclass__() - def __getitem__(self, item: str) -> Signal: - return self._signals_[item] - def __getattr__(self, name: str) -> Signal: if name in self._signals_: return self._signals_[name] if name == "signals": # for backwards compatibility # TODO: add deprecation warning - return self._signal_instances # type: ignore + return self._psygnal_instances # type: ignore + if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): + # TODO: add deprecation warning and redirect to `self.all` + return getattr(self._psygnal_relay, name) # type: ignore raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") def __len__(self) -> int: return len(self._signals_) - def connect(self, *args, **kwargs) -> None: - breakpoint() - - -class MyGroup(SignalGroup): - sig1 = Signal(int) - sig2 = Signal(str) - - -g = MyGroup() + def __getitem__(self, item: str) -> SignalInstance: + return self._psygnal_instances[item] + + def __iter__(self) -> Iterator[str]: + return iter(self._signals_) + + def __repr__(self) -> str: + """Return repr(self).""" + name = self.__class__.__name__ + instance = "" + nsignals = len(self) + signals = f"{nsignals} signals" if nsignals > 1 else "" + return f"" + + @classmethod + def is_uniform(cls) -> bool: + """Return true if all signals in the group have the same signature.""" + # TODO: Deprecate this method + return cls._uniform + + +def _is_uniform(signals: Iterable[Signal]) -> bool: + """Return True if all signals have the same signature.""" + seen: set[tuple[str, ...]] = set() + for s in signals: + v = tuple(str(p.annotation) for p in s.signature.parameters.values()) + if seen and v not in seen: # allow zero or one + return False + seen.add(v) + return True diff --git a/src/psygnal/containers/_evented_dict.py b/src/psygnal/containers/_evented_dict.py index ad504766..ac89ef0b 100644 --- a/src/psygnal/containers/_evented_dict.py +++ b/src/psygnal/containers/_evented_dict.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from typing import Self -from psygnal._group import SignalGroup +from psygnal._group2 import SignalGroup from psygnal._signal import Signal _K = TypeVar("_K") diff --git a/src/psygnal/containers/_evented_list.py b/src/psygnal/containers/_evented_list.py index a1a7c722..8e55c206 100644 --- a/src/psygnal/containers/_evented_list.py +++ b/src/psygnal/containers/_evented_list.py @@ -34,7 +34,7 @@ overload, ) -from psygnal._group import EmissionInfo, SignalGroup +from psygnal._group2 import EmissionInfo, SignalGroup from psygnal._signal import Signal, SignalInstance from psygnal.utils import iter_signal_instances diff --git a/src/psygnal/containers/_evented_proxy.py b/src/psygnal/containers/_evented_proxy.py index 5a051350..ad04e58a 100644 --- a/src/psygnal/containers/_evented_proxy.py +++ b/src/psygnal/containers/_evented_proxy.py @@ -9,7 +9,7 @@ f"{e}. Please `pip install psygnal[proxy]` to use EventedObjectProxies" ) from e -from psygnal._group import SignalGroup +from psygnal._group2 import SignalGroup from psygnal._signal import Signal T = TypeVar("T") diff --git a/tests/test_group.py b/tests/test_group.py index 66025e91..f375f3e2 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -213,4 +213,4 @@ def method(self): with pytest.warns(UserWarning, match="does not copy connected weakly"): group2 = deepcopy(group) - assert not len(group2) + assert not len(group2._psygnal_relay) diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index 5fd51b36..091b5aea 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -398,14 +398,14 @@ class MyGroup(SignalGroup): # simply by nature of being in a group, sig1 will have a callback assert len(emitter.sig1) == 1 # but the group itself doesn't have any - assert len(emitter) == 0 + assert len(emitter._psygnal_relay) == 0 # connecting something to the group adds to the group connections emitter.connect( partial(obj.f_int_int, 1) if slot == "partial" else getattr(obj, slot) ) assert len(emitter.sig1) == 1 - assert len(emitter) == 1 + assert len(emitter._psygnal_relay) == 1 emitter.sig1.emit(1) assert len(emitter.sig1) == 1 @@ -413,7 +413,7 @@ class MyGroup(SignalGroup): gc.collect() emitter.sig1.emit(1) # this should trigger deletion, so would emitter.emit() assert len(emitter.sig1) == 1 - assert len(emitter) == 0 # it's been cleaned up + assert len(emitter._psygnal_relay) == 0 # it's been cleaned up # def test_norm_slot(): From 45070731b0766ff2a01e1b9db791e2c5976187e5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Feb 2024 16:23:04 -0500 Subject: [PATCH 03/43] tests passing --- src/psygnal/containers/_evented_list.py | 4 ++-- src/psygnal/utils.py | 4 +++- tests/containers/test_evented_list.py | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/psygnal/containers/_evented_list.py b/src/psygnal/containers/_evented_list.py index 8e55c206..ad06d96a 100644 --- a/src/psygnal/containers/_evented_list.py +++ b/src/psygnal/containers/_evented_list.py @@ -34,7 +34,7 @@ overload, ) -from psygnal._group2 import EmissionInfo, SignalGroup +from psygnal._group2 import EmissionInfo, SignalGroup, SignalRelay from psygnal._signal import Signal, SignalInstance from psygnal.utils import iter_signal_instances @@ -419,7 +419,7 @@ def _reemit_child_event(self, *args: Any) -> None: if ( args - and isinstance(emitter, SignalGroup) + and isinstance(emitter, SignalRelay) and isinstance(args[0], EmissionInfo) ): emitter, args = args[0] diff --git a/src/psygnal/utils.py b/src/psygnal/utils.py index 429a4c51..0f5ac3b2 100644 --- a/src/psygnal/utils.py +++ b/src/psygnal/utils.py @@ -7,7 +7,7 @@ from typing import Any, Callable, Generator, Iterator from warnings import warn -from ._group import EmissionInfo +from ._group2 import EmissionInfo, SignalGroup from ._signal import SignalInstance __all__ = ["monitor_events", "iter_signal_instances"] @@ -103,6 +103,8 @@ def iter_signal_instances( attr = getattr(obj, n) if isinstance(attr, SignalInstance): yield attr + if isinstance(attr, SignalGroup): + yield attr._psygnal_relay _COMPILED_EXTS = (".so", ".pyd") diff --git a/tests/containers/test_evented_list.py b/tests/containers/test_evented_list.py index db4ad0ea..15426159 100644 --- a/tests/containers/test_evented_list.py +++ b/tests/containers/test_evented_list.py @@ -352,7 +352,11 @@ def __init__(self): assert root == [e_obj] e_obj.events.test2.emit("hi") - assert mock.call_count == 3 + assert [c[0][0].signal.name for c in mock.call_args_list] == [ + "inserting", + "inserted", + "child_event", + ] # when an object in the list owns an emitter group, then any emitter in that group # will also be detected, and child_event will emit (index, sub-emitter, args) From 5500c22a8097fbdd1ed69d497c02e5aa5d6c1dc0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Feb 2024 17:50:10 -0500 Subject: [PATCH 04/43] more fixes --- src/psygnal/_evented_model_v1.py | 4 ++-- src/psygnal/_evented_model_v2.py | 6 +++--- src/psygnal/_group2.py | 12 +++++++++--- src/psygnal/_signal.py | 4 ++-- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/psygnal/_evented_model_v1.py b/src/psygnal/_evented_model_v1.py index d73a7e2d..543e7500 100644 --- a/src/psygnal/_evented_model_v1.py +++ b/src/psygnal/_evented_model_v1.py @@ -347,7 +347,7 @@ def __setattr__(self, name: str, value: Any) -> None: if ( name == "_events" or not hasattr(self, "_events") # can happen on init - or name not in self._events.signals + or name not in self._events ): # fallback to default behavior return self._super_setattr_(name, value) @@ -433,7 +433,7 @@ def update(self, values: Union["EventedModel", dict], recurse: bool = True) -> N if not isinstance(values, dict): # pragma: no cover raise TypeError(f"values must be a dict or BaseModel. got {type(values)}") - with self.events.paused(): # TODO: reduce? + with self.events.all.paused(): # TODO: reduce? for key, value in values.items(): field = getattr(self, key) if isinstance(field, EventedModel) and recurse: diff --git a/src/psygnal/_evented_model_v2.py b/src/psygnal/_evented_model_v2.py index 2dc57403..1d2a6091 100644 --- a/src/psygnal/_evented_model_v2.py +++ b/src/psygnal/_evented_model_v2.py @@ -19,7 +19,7 @@ from pydantic._internal import _model_construction, _utils from pydantic.fields import Field, FieldInfo -from ._group import SignalGroup +from ._group2 import SignalGroup from ._group_descriptor import _check_field_equality, _pick_equality_operator from ._signal import Signal, SignalInstance @@ -333,7 +333,7 @@ def __setattr__(self, name: str, value: Any) -> None: if ( name == "_events" or not hasattr(self, "_events") - or name not in self._events.signals + or name not in self._events ): # can happen on init # fallback to default behavior return self._super_setattr_(name, value) @@ -419,7 +419,7 @@ def update(self, values: Union["EventedModel", dict], recurse: bool = True) -> N if not isinstance(values, dict): # pragma: no cover raise TypeError(f"values must be a dict or BaseModel. got {type(values)}") - with self.events.paused(): # TODO: reduce? + with self.events.all.paused(): # TODO: reduce? for key, value in values.items(): field = getattr(self, key) if isinstance(field, EventedModel) and recurse: diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index 466bc724..9834162e 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -11,6 +11,8 @@ NamedTuple, ) +from mypy_extensions import mypyc_attr + from psygnal._signal import Signal, SignalInstance, _SignalBlocker @@ -34,7 +36,7 @@ def __init__(self, group: SignalGroup, instance: Any = None) -> None: self._group = group super().__init__(signature=(EmissionInfo,), instance=instance) self._sig_was_blocked: dict[str, bool] = {} - for sig in group.values(): + for sig in group._psygnal_instances.values(): sig.connect(self._slot_relay, check_nargs=False, check_types=False) def _slot_relay(self, *args: Any) -> None: @@ -150,16 +152,20 @@ def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> N super().disconnect(slot, missing_ok) +@mypyc_attr(allow_interpreted_subclasses=True) class SignalGroup(Mapping[str, SignalInstance]): - _signals_: Mapping[str, Signal] + _signals_: ClassVar[Mapping[str, Signal]] _uniform: ClassVar[bool] = False - def __init__(self, instance: Any = None) -> None: + all: SignalRelay # but, can be modified at instantiation + + def __init__(self, instance: Any = None, relay_name: str = "all") -> None: cls = type(self) self._psygnal_instances: dict[str, SignalInstance] = { name: signal.__get__(self, cls) for name, signal in cls._signals_.items() } self._psygnal_relay = SignalRelay(self, instance) + setattr(self, relay_name, self._psygnal_relay) def __init_subclass__(cls, strict: bool = False) -> None: """Finds all Signal instances on the class and add them to `cls._signals_`.""" diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 489fafde..8a95b188 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -40,7 +40,7 @@ if TYPE_CHECKING: from typing_extensions import Literal - from ._group import EmissionInfo + from ._group2 import EmissionInfo from ._weak_callback import RefErrorChoice ReducerFunc = Callable[[tuple, tuple], tuple] @@ -924,7 +924,7 @@ def emit( return None if SignalInstance._debug_hook is not None: - from ._group import EmissionInfo + from ._group2 import EmissionInfo SignalInstance._debug_hook(EmissionInfo(self, args)) From 70dab51c3c728cfa29fe0ce21a390f9ae1e90dd5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Feb 2024 18:04:32 -0500 Subject: [PATCH 05/43] add init error --- src/psygnal/_group2.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index 9834162e..2735b99c 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -161,6 +161,10 @@ class SignalGroup(Mapping[str, SignalInstance]): def __init__(self, instance: Any = None, relay_name: str = "all") -> None: cls = type(self) + if not hasattr(cls, "_signals_"): + raise TypeError( + "Cannot instantiate SignalGroup directly. Use a subclass instead." + ) self._psygnal_instances: dict[str, SignalInstance] = { name: signal.__get__(self, cls) for name, signal in cls._signals_.items() } @@ -207,7 +211,7 @@ def __repr__(self) -> str: name = self.__class__.__name__ instance = "" nsignals = len(self) - signals = f"{nsignals} signals" if nsignals > 1 else "" + signals = f"{nsignals} signals" return f"" @classmethod From 8e4f9b8be556cdfc80df7d9e732f337583fb7320 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Feb 2024 18:15:44 -0500 Subject: [PATCH 06/43] add tests --- tests/test_group.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_group.py b/tests/test_group.py index f375f3e2..2604d238 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -208,6 +208,8 @@ def method(self): obj = T() group = MyGroup(obj) + assert deepcopy(group) is not group # but no warning + group.connect(obj.method) with pytest.warns(UserWarning, match="does not copy connected weakly"): From ead7d37abbfb7c4054b023be83ad23e0f0e50c99 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Feb 2024 20:48:44 -0500 Subject: [PATCH 07/43] fixes --- src/psygnal/_evented_model_v1.py | 2 +- src/psygnal/_evented_model_v2.py | 2 +- src/psygnal/_signal.py | 14 +++++++++++++- src/psygnal/utils.py | 6 +++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/psygnal/_evented_model_v1.py b/src/psygnal/_evented_model_v1.py index 543e7500..a1c24d9d 100644 --- a/src/psygnal/_evented_model_v1.py +++ b/src/psygnal/_evented_model_v1.py @@ -366,7 +366,7 @@ def __setattr__(self, name: str, value: Any) -> None: if ( len(signal_instance) < 2 # the signal itself has no listeners and not deps_with_callbacks # no dependent properties with listeners - and not len(self._events) # no listeners on the SignalGroup + and not len(self._events._psygnal_relay) # no listeners on the SignalGroup ): return self._super_setattr_(name, value) diff --git a/src/psygnal/_evented_model_v2.py b/src/psygnal/_evented_model_v2.py index 1d2a6091..35fc0999 100644 --- a/src/psygnal/_evented_model_v2.py +++ b/src/psygnal/_evented_model_v2.py @@ -352,7 +352,7 @@ def __setattr__(self, name: str, value: Any) -> None: if ( len(signal_instance) < 2 # the signal itself has no listeners and not deps_with_callbacks # no dependent properties with listeners - and not len(self._events) # no listeners on the SignalGroup + and not len(self._events._psygnal_relay) # no listeners on the SignalGroup ): return self._super_setattr_(name, value) diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 8a95b188..d7041e06 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -1123,7 +1123,19 @@ def __getstate__(self) -> dict: ) dd = {slot: getattr(self, slot) for slot in attrs} dd["_instance"] = self._instance() - dd["_slots"] = [x for x in self._slots if isinstance(x, StrongFunction)] + dd["_slots"] = [ + x + for x in self._slots + if ( + isinstance(x, StrongFunction) + # HACK + # this is a hack to retain the ability of a deep-copied signal group + # to connect to all signals in the group. + # reconsider this mechanism. It could also be achieved more directly + # as a special __deepcopy__ method on SignalGroup + or getattr(x, "_obj_qualname", None) == "SignalRelay._slot_relay" + ) + ] if len(self._slots) > len(dd["_slots"]): warnings.warn( "Pickling a SignalInstance does not copy connected weakly referenced " diff --git a/src/psygnal/utils.py b/src/psygnal/utils.py index 0f5ac3b2..567b4746 100644 --- a/src/psygnal/utils.py +++ b/src/psygnal/utils.py @@ -7,7 +7,7 @@ from typing import Any, Callable, Generator, Iterator from warnings import warn -from ._group2 import EmissionInfo, SignalGroup +from ._group2 import EmissionInfo, SignalGroup, SignalRelay from ._signal import SignalInstance __all__ = ["monitor_events", "iter_signal_instances"] @@ -58,6 +58,10 @@ def monitor_events( ) disconnectors = set() for siginst in iter_signal_instances(obj, include_private_attrs): + if isinstance(siginst, SignalRelay): + # TODO: ... but why? + continue + if _old_api: def _report(*args: Any, signal: SignalInstance = siginst) -> None: From 4b966bf241edb59045a124c4ebefcaf37173cddc Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 11 Feb 2024 21:23:38 -0500 Subject: [PATCH 08/43] catch warnings --- src/psygnal/_group2.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index 2735b99c..2d68bcbb 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -36,8 +36,13 @@ def __init__(self, group: SignalGroup, instance: Any = None) -> None: self._group = group super().__init__(signature=(EmissionInfo,), instance=instance) self._sig_was_blocked: dict[str, bool] = {} - for sig in group._psygnal_instances.values(): - sig.connect(self._slot_relay, check_nargs=False, check_types=False) + import warnings + # silence any warnings about failed weakrefs + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + for sig in group._psygnal_instances.values(): + sig.connect(self._slot_relay, check_nargs=False, check_types=False) def _slot_relay(self, *args: Any) -> None: emitter = Signal.current_emitter() @@ -186,17 +191,22 @@ def __init_subclass__(cls, strict: bool = False) -> None: ) super().__init_subclass__() - def __getattr__(self, name: str) -> Signal: - if name in self._signals_: - return self._signals_[name] - if name == "signals": # for backwards compatibility - # TODO: add deprecation warning - return self._psygnal_instances # type: ignore - if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): - # TODO: add deprecation warning and redirect to `self.all` - return getattr(self._psygnal_relay, name) # type: ignore + # TODO: change type hint after completing deprecation of direct access to + # names on SignalRelay object + def __getattr__(self, name: str) -> Any: + if name != "_psygnal_instances": + if name in self._psygnal_instances: + return self._psygnal_instances[name] + if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): + # TODO: add deprecation warning and redirect to `self.all` + return getattr(self._psygnal_relay, name) # type: ignore raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") + @property + def signals(self) -> Mapping[str, SignalInstance]: + # TODO: deprecate this property + return self._psygnal_instances + def __len__(self) -> int: return len(self._signals_) From 315a3aa4a35b50126441100e2d139fac9f920dbb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:10:44 +0000 Subject: [PATCH 09/43] style(pre-commit.ci): auto fixes [...] --- src/psygnal/_group2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index 2d68bcbb..195e752c 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -37,6 +37,7 @@ def __init__(self, group: SignalGroup, instance: Any = None) -> None: super().__init__(signature=(EmissionInfo,), instance=instance) self._sig_was_blocked: dict[str, bool] = {} import warnings + # silence any warnings about failed weakrefs with warnings.catch_warnings(): From 03c006c57d27463cfa23d8210ae61088bf9da785 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 12 Feb 2024 09:16:20 -0500 Subject: [PATCH 10/43] use annotation for public name --- src/psygnal/_group2.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index 2d68bcbb..696f967a 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -162,9 +162,11 @@ class SignalGroup(Mapping[str, SignalInstance]): _signals_: ClassVar[Mapping[str, Signal]] _uniform: ClassVar[bool] = False - all: SignalRelay # but, can be modified at instantiation + # see comment in __init__. This type annotation can be overriden by subclass + # to change the public name of the SignalRelay attribute + all: SignalRelay - def __init__(self, instance: Any = None, relay_name: str = "all") -> None: + def __init__(self, instance: Any = None) -> None: cls = type(self) if not hasattr(cls, "_signals_"): raise TypeError( @@ -174,6 +176,17 @@ def __init__(self, instance: Any = None, relay_name: str = "all") -> None: name: signal.__get__(self, cls) for name, signal in cls._signals_.items() } self._psygnal_relay = SignalRelay(self, instance) + + # determine the public name of the signal relay. + # by default, this is "all", but it can be overridden by the user by creating + # a new name for the SignalRelay annotation on a subclass of SignalGroup + # e.g. `my_name: SignalRelay` + relay_name = "all" + for base in cls.__mro__: + for key, val in getattr(base, "__annotations__", {}).items(): + if val is SignalRelay: + relay_name = key + break setattr(self, relay_name, self._psygnal_relay) def __init_subclass__(cls, strict: bool = False) -> None: From 2d8920a4d591da524186f991d7600691416836b3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 12 Feb 2024 09:26:21 -0500 Subject: [PATCH 11/43] don't use mutable mapping --- src/psygnal/_group2.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index 50ba1511..aa0cd962 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -95,8 +95,8 @@ def connect_direct( """ def _inner(slot: Callable) -> Callable: - for sig in self._group.values(): - sig.connect( + for sig in self._group: + self._group[sig].connect( slot, check_nargs=check_nargs, check_types=check_types, @@ -110,7 +110,8 @@ def _inner(slot: Callable) -> Callable: def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: """Block this signal and all emitters from emitting.""" super().block() - for k, v in self._group.items(): + for k in self._group: + v = self._group[k] if exclude and v in exclude or k in exclude: continue self._sig_was_blocked[k] = v._is_blocked @@ -119,9 +120,9 @@ def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: def unblock(self) -> None: """Unblock this signal and all emitters, allowing them to emit.""" super().unblock() - for k, v in self._group.items(): + for k in self._group: if not self._sig_was_blocked.pop(k, False): - v.unblock() + self._group[k].unblock() def blocked( self, exclude: Iterable[str | SignalInstance] = () @@ -153,13 +154,14 @@ def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> N ValueError If `slot` is not connected and `missing_ok` is False. """ - for signal in self._group.values(): - signal.disconnect(slot, missing_ok) + for signal in self._group: + self._group[signal].disconnect(slot, missing_ok) super().disconnect(slot, missing_ok) @mypyc_attr(allow_interpreted_subclasses=True) -class SignalGroup(Mapping[str, SignalInstance]): +# class SignalGroup(Mapping[str, SignalInstance]): +class SignalGroup: _signals_: ClassVar[Mapping[str, Signal]] _uniform: ClassVar[bool] = False From 07afe290863d62a98062110d51a2fb6368d38149 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 12 Feb 2024 12:28:33 -0500 Subject: [PATCH 12/43] fix: hacky fix tests --- src/psygnal/_group2.py | 22 +++++++++++++++------- tests/test_group.py | 22 +++++++++++++++++----- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index aa0cd962..a9f06f0b 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import ( Any, Callable, @@ -36,10 +37,8 @@ def __init__(self, group: SignalGroup, instance: Any = None) -> None: self._group = group super().__init__(signature=(EmissionInfo,), instance=instance) self._sig_was_blocked: dict[str, bool] = {} - import warnings - - # silence any warnings about failed weakrefs + # silence any warnings about failed weakrefs (will occur in compiled version) with warnings.catch_warnings(): warnings.simplefilter("ignore") for sig in group._psygnal_instances.values(): @@ -51,6 +50,11 @@ def _slot_relay(self, *args: Any) -> None: info = EmissionInfo(emitter, args) self._run_emit_loop((info,)) + def __getstate__(self) -> dict: + dd = super().__getstate__() + dd["_group"] = self._group + return dd + def connect_direct( self, slot: Callable | None = None, @@ -184,13 +188,13 @@ def __init__(self, instance: Any = None) -> None: # by default, this is "all", but it can be overridden by the user by creating # a new name for the SignalRelay annotation on a subclass of SignalGroup # e.g. `my_name: SignalRelay` - relay_name = "all" + self._psygnal_relay_name = "all" for base in cls.__mro__: for key, val in getattr(base, "__annotations__", {}).items(): if val is SignalRelay: - relay_name = key + self._psygnal_relay_name = key break - setattr(self, relay_name, self._psygnal_relay) + setattr(self, self._psygnal_relay_name, self._psygnal_relay) def __init_subclass__(cls, strict: bool = False) -> None: """Finds all Signal instances on the class and add them to `cls._signals_`.""" @@ -215,7 +219,7 @@ def __getattr__(self, name: str) -> Any: return self._psygnal_instances[name] if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): # TODO: add deprecation warning and redirect to `self.all` - return getattr(self._psygnal_relay, name) # type: ignore + return getattr(self._psygnal_relay, name) raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") @property @@ -246,6 +250,10 @@ def is_uniform(cls) -> bool: # TODO: Deprecate this method return cls._uniform + def __deepcopy__(self, memo: dict[int, Any]) -> SignalGroup: + # TODO: should we also copy connections? + return type(self)(instance=self._psygnal_relay.instance) + def _is_uniform(signals: Iterable[Signal]) -> bool: """Return True if all signals have the same signature.""" diff --git a/tests/test_group.py b/tests/test_group.py index 2604d238..2d645914 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1,3 +1,4 @@ +from copy import deepcopy from unittest.mock import Mock, call import pytest @@ -199,9 +200,7 @@ class T: assert group.instance is None -def test_group_deepcopy(): - from copy import deepcopy - +def test_group_deepcopy() -> None: class T: def method(self): ... @@ -212,7 +211,20 @@ def method(self): group.connect(obj.method) - with pytest.warns(UserWarning, match="does not copy connected weakly"): - group2 = deepcopy(group) + # with pytest.warns(UserWarning, match="does not copy connected weakly"): + group2 = deepcopy(group) assert not len(group2._psygnal_relay) + mock = Mock() + mock2 = Mock() + group.connect(mock) + group2.connect(mock2) + + group2.sig1.emit(1) + mock.assert_not_called() + mock2.assert_called_with(EmissionInfo(group2.sig1, (1,))) + + mock2.reset_mock() + group.sig1.emit(1) + mock.assert_called_with(EmissionInfo(group.sig1, (1,))) + mock2.assert_not_called() From ed0b934d5820226961fb7d0e64960eadc3bf2cd3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 12 Feb 2024 12:47:11 -0500 Subject: [PATCH 13/43] test: more tests --- src/psygnal/_group2.py | 7 +------ tests/test_evented_decorator.py | 2 +- tests/test_group.py | 12 ++++++++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py index a9f06f0b..ee829458 100644 --- a/src/psygnal/_group2.py +++ b/src/psygnal/_group2.py @@ -50,11 +50,6 @@ def _slot_relay(self, *args: Any) -> None: info = EmissionInfo(emitter, args) self._run_emit_loop((info,)) - def __getstate__(self) -> dict: - dd = super().__getstate__() - dd["_group"] = self._group - return dd - def connect_direct( self, slot: Callable | None = None, @@ -175,7 +170,7 @@ class SignalGroup: def __init__(self, instance: Any = None) -> None: cls = type(self) - if not hasattr(cls, "_signals_"): + if not hasattr(cls, "_signals_"): # pragma: no cover raise TypeError( "Cannot instantiate SignalGroup directly. Use a subclass instead." ) diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index 34aa99c1..4e0b3f91 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -38,7 +38,7 @@ def _check_events(cls, events_ns="events"): events = getattr(obj, events_ns) assert isinstance(events, SignalGroup) - assert set(events._signals_) == {"bar", "baz", "qux"} + assert set(events) == {"bar", "baz", "qux"} mock = Mock() events.bar.connect(mock) diff --git a/tests/test_group.py b/tests/test_group.py index 2d645914..ad84fedc 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -5,6 +5,7 @@ from typing_extensions import Annotated from psygnal import EmissionInfo, Signal, SignalGroup +from psygnal._group2 import SignalRelay class MyGroup(SignalGroup): @@ -228,3 +229,14 @@ def method(self): group.sig1.emit(1) mock.assert_called_with(EmissionInfo(group.sig1, (1,))) mock2.assert_not_called() + + +def test_group_relay_name() -> None: + class T(SignalGroup): + agg: SignalRelay + sig1 = Signal(int) + + t = T() + assert t.agg is t._psygnal_relay + # test getitem + assert t["sig1"] is t.sig1 From 367e46b6eb3b914b2610f00ede27042511b59a5a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 12 Feb 2024 19:31:13 -0500 Subject: [PATCH 14/43] replace old graoup --- src/psygnal/__init__.py | 2 +- src/psygnal/_evented_model_v1.py | 2 +- src/psygnal/_evented_model_v2.py | 2 +- src/psygnal/_group.py | 212 +++++++++--------- src/psygnal/_group2.py | 261 ----------------------- src/psygnal/_group_descriptor.py | 2 +- src/psygnal/_signal.py | 4 +- src/psygnal/containers/_evented_dict.py | 2 +- src/psygnal/containers/_evented_list.py | 2 +- src/psygnal/containers/_evented_proxy.py | 2 +- src/psygnal/utils.py | 2 +- tests/test_evented_decorator.py | 2 +- tests/test_evented_model.py | 2 +- tests/test_group.py | 2 +- 14 files changed, 124 insertions(+), 375 deletions(-) delete mode 100644 src/psygnal/_group2.py diff --git a/src/psygnal/__init__.py b/src/psygnal/__init__.py index bea516f8..fd2dfe36 100644 --- a/src/psygnal/__init__.py +++ b/src/psygnal/__init__.py @@ -50,7 +50,7 @@ from ._evented_decorator import evented from ._exceptions import EmitLoopError -from ._group2 import EmissionInfo, SignalGroup +from ._group import EmissionInfo, SignalGroup from ._group_descriptor import ( SignalGroupDescriptor, get_evented_namespace, diff --git a/src/psygnal/_evented_model_v1.py b/src/psygnal/_evented_model_v1.py index a1c24d9d..e6d620a7 100644 --- a/src/psygnal/_evented_model_v1.py +++ b/src/psygnal/_evented_model_v1.py @@ -15,7 +15,7 @@ no_type_check, ) -from ._group2 import SignalGroup +from ._group import SignalGroup from ._group_descriptor import _check_field_equality, _pick_equality_operator from ._signal import Signal, SignalInstance diff --git a/src/psygnal/_evented_model_v2.py b/src/psygnal/_evented_model_v2.py index 35fc0999..f1aa4245 100644 --- a/src/psygnal/_evented_model_v2.py +++ b/src/psygnal/_evented_model_v2.py @@ -19,7 +19,7 @@ from pydantic._internal import _model_construction, _utils from pydantic.fields import Field, FieldInfo -from ._group2 import SignalGroup +from ._group import SignalGroup from ._group_descriptor import _check_field_equality, _pick_equality_operator from ._signal import Signal, SignalInstance diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index bed9b8f0..dbb01c29 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -9,12 +9,14 @@ """ from __future__ import annotations +import warnings from typing import ( Any, Callable, ClassVar, ContextManager, Iterable, + Iterator, Mapping, NamedTuple, ) @@ -23,8 +25,6 @@ from psygnal._signal import Signal, SignalInstance, _SignalBlocker -__all__ = ["EmissionInfo", "SignalGroup"] - class EmissionInfo(NamedTuple): """Tuple containing information about an emission event. @@ -39,98 +39,23 @@ class EmissionInfo(NamedTuple): args: tuple[Any, ...] -@mypyc_attr(allow_interpreted_subclasses=True) -class SignalGroup(SignalInstance): - """`SignalGroup` that enables connecting to all `SignalInstances`. - - Parameters - ---------- - instance : Any, optional - An instance to which this event group is bound, by default None - name : str, optional - Optional name for this event group, by default will be the name of the group - subclass. (e.g., 'Events' in the example below.) - - Examples - -------- - >>> class Events(SignalGroup): - ... sig1 = Signal(str) - ... sig2 = Signal(str) - ... - >>> events = Events() - ... - >>> def some_callback(record): - ... record.signal # the SignalInstance that emitted - ... record.args # the args that were emitted - ... - >>> events.connect(some_callback) - - note that the `SignalGroup` may also be created with `strict=True`, which will - enforce that *all* signals have the same emission signature - - This is ok: - - >>> class Events(SignalGroup, strict=True): - ... sig1 = Signal(int) - ... sig1 = Signal(int) - - This will raise an exception - - >>> class Events(SignalGroup, strict=True): - ... sig1 = Signal(int) - ... sig1 = Signal(str) # not the same signature - """ - - _signals_: ClassVar[Mapping[str, Signal]] - _uniform: ClassVar[bool] = False +class SignalRelay(SignalInstance): + """Special SignalInstance that can be used to connect to all signals in a group.""" - # this is only here to indicate to mypy that all attributes are SignalInstances. - def __getattr__(self, name: str) -> SignalInstance: - raise AttributeError( - f"SignalGroup {type(self).__name__!r} has no attribute {name!r}" - ) - - def __init_subclass__(cls, strict: bool = False) -> None: - """Finds all Signal instances on the class and add them to `cls._signals_`.""" - cls._signals_ = {} - for k in dir(cls): - v = getattr(cls, k) - if isinstance(v, Signal): - cls._signals_[k] = v - - cls._uniform = _is_uniform(cls._signals_.values()) - if strict and not cls._uniform: - raise TypeError( - "All Signals in a strict SignalGroup must have the same signature" - ) - - return super().__init_subclass__() - - def __init__(self, instance: Any = None, name: str | None = None) -> None: - super().__init__( - signature=(EmissionInfo,), - instance=instance, - name=name or self.__class__.__name__, - ) + def __init__(self, group: SignalGroup, instance: Any = None) -> None: + self._group = group + super().__init__(signature=(EmissionInfo,), instance=instance) self._sig_was_blocked: dict[str, bool] = {} - for _, sig in self.signals.items(): - sig.connect(self._slot_relay, check_nargs=False, check_types=False) - - def __len__(self) -> int: - return len(self._slots) - - @property - def signals(self) -> dict[str, SignalInstance]: - """Return {name -> SignalInstance} map of all signal instances in this group.""" - return {n: getattr(self, n) for n in type(self)._signals_} - @classmethod - def is_uniform(cls) -> bool: - """Return true if all signals in the group have the same signature.""" - return cls._uniform + # silence any warnings about failed weakrefs (will occur in compiled version) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + for sig in group._psygnal_instances.values(): + sig.connect(self._slot_relay, check_nargs=False, check_types=False) def _slot_relay(self, *args: Any) -> None: - if emitter := Signal.current_emitter(): + emitter = Signal.current_emitter() + if emitter: info = EmissionInfo(emitter, args) self._run_emit_loop((info,)) @@ -178,8 +103,8 @@ def connect_direct( """ def _inner(slot: Callable) -> Callable: - for sig in self.signals.values(): - sig.connect( + for sig in self._group: + self._group[sig].connect( slot, check_nargs=check_nargs, check_types=check_types, @@ -193,7 +118,8 @@ def _inner(slot: Callable) -> Callable: def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: """Block this signal and all emitters from emitting.""" super().block() - for k, v in self.signals.items(): + for k in self._group: + v = self._group[k] if exclude and v in exclude or k in exclude: continue self._sig_was_blocked[k] = v._is_blocked @@ -202,9 +128,9 @@ def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: def unblock(self) -> None: """Unblock this signal and all emitters, allowing them to emit.""" super().unblock() - for k, v in self.signals.items(): + for k in self._group: if not self._sig_was_blocked.pop(k, False): - v.unblock() + self._group[k].unblock() def blocked( self, exclude: Iterable[str | SignalInstance] = () @@ -236,17 +162,101 @@ def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> N ValueError If `slot` is not connected and `missing_ok` is False. """ - for signal in self.signals.values(): - signal.disconnect(slot, missing_ok) + for signal in self._group: + self._group[signal].disconnect(slot, missing_ok) super().disconnect(slot, missing_ok) + +@mypyc_attr(allow_interpreted_subclasses=True) +# class SignalGroup(Mapping[str, SignalInstance]): +class SignalGroup: + _signals_: ClassVar[Mapping[str, Signal]] + _uniform: ClassVar[bool] = False + + # see comment in __init__. This type annotation can be overriden by subclass + # to change the public name of the SignalRelay attribute + all: SignalRelay + + def __init__(self, instance: Any = None) -> None: + cls = type(self) + if not hasattr(cls, "_signals_"): # pragma: no cover + raise TypeError( + "Cannot instantiate SignalGroup directly. Use a subclass instead." + ) + self._psygnal_instances: dict[str, SignalInstance] = { + name: signal.__get__(self, cls) for name, signal in cls._signals_.items() + } + self._psygnal_relay = SignalRelay(self, instance) + + # determine the public name of the signal relay. + # by default, this is "all", but it can be overridden by the user by creating + # a new name for the SignalRelay annotation on a subclass of SignalGroup + # e.g. `my_name: SignalRelay` + self._psygnal_relay_name = "all" + for base in cls.__mro__: + for key, val in getattr(base, "__annotations__", {}).items(): + if val is SignalRelay: + self._psygnal_relay_name = key + break + setattr(self, self._psygnal_relay_name, self._psygnal_relay) + + def __init_subclass__(cls, strict: bool = False) -> None: + """Finds all Signal instances on the class and add them to `cls._signals_`.""" + cls._signals_ = { + k: val + for k, val in getattr(cls, "__dict__", {}).items() + if isinstance(val, Signal) + } + + cls._uniform = _is_uniform(cls._signals_.values()) + if strict and not cls._uniform: + raise TypeError( + "All Signals in a strict SignalGroup must have the same signature" + ) + super().__init_subclass__() + + # TODO: change type hint after completing deprecation of direct access to + # names on SignalRelay object + def __getattr__(self, name: str) -> Any: + if name != "_psygnal_instances": + if name in self._psygnal_instances: + return self._psygnal_instances[name] + if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): + # TODO: add deprecation warning and redirect to `self.all` + return getattr(self._psygnal_relay, name) + raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") + + @property + def signals(self) -> Mapping[str, SignalInstance]: + # TODO: deprecate this property + return self._psygnal_instances + + def __len__(self) -> int: + return len(self._signals_) + + def __getitem__(self, item: str) -> SignalInstance: + return self._psygnal_instances[item] + + def __iter__(self) -> Iterator[str]: + return iter(self._signals_) + def __repr__(self) -> str: """Return repr(self).""" - name = f" {self._name!r}" if self._name else "" - instance = f" on {self.instance!r}" if self.instance else "" - nsignals = len(self.signals) - signals = f"{nsignals} signals" if nsignals > 1 else "" - return f"" + name = self.__class__.__name__ + instance = "" + nsignals = len(self) + signals = f"{nsignals} signals" + return f"" + + @classmethod + def is_uniform(cls) -> bool: + """Return true if all signals in the group have the same signature.""" + # TODO: Deprecate this method + return cls._uniform + + def __deepcopy__(self, memo: dict[int, Any]) -> SignalGroup: + # TODO: should we also copy connections? + return type(self)(instance=self._psygnal_relay.instance) def _is_uniform(signals: Iterable[Signal]) -> bool: diff --git a/src/psygnal/_group2.py b/src/psygnal/_group2.py deleted file mode 100644 index ee829458..00000000 --- a/src/psygnal/_group2.py +++ /dev/null @@ -1,261 +0,0 @@ -from __future__ import annotations - -import warnings -from typing import ( - Any, - Callable, - ClassVar, - ContextManager, - Iterable, - Iterator, - Mapping, - NamedTuple, -) - -from mypy_extensions import mypyc_attr - -from psygnal._signal import Signal, SignalInstance, _SignalBlocker - - -class EmissionInfo(NamedTuple): - """Tuple containing information about an emission event. - - Attributes - ---------- - signal : SignalInstance - args: tuple - """ - - signal: SignalInstance - args: tuple[Any, ...] - - -class SignalRelay(SignalInstance): - """Special SignalInstance that can be used to connect to all signals in a group.""" - - def __init__(self, group: SignalGroup, instance: Any = None) -> None: - self._group = group - super().__init__(signature=(EmissionInfo,), instance=instance) - self._sig_was_blocked: dict[str, bool] = {} - - # silence any warnings about failed weakrefs (will occur in compiled version) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - for sig in group._psygnal_instances.values(): - sig.connect(self._slot_relay, check_nargs=False, check_types=False) - - def _slot_relay(self, *args: Any) -> None: - emitter = Signal.current_emitter() - if emitter: - info = EmissionInfo(emitter, args) - self._run_emit_loop((info,)) - - def connect_direct( - self, - slot: Callable | None = None, - *, - check_nargs: bool | None = None, - check_types: bool | None = None, - unique: bool | str = False, - max_args: int | None = None, - ) -> Callable[[Callable], Callable] | Callable: - """Connect `slot` to be called whenever *any* Signal in this group is emitted. - - Params are the same as {meth}`~psygnal.SignalInstance.connect`. It's probably - best to check whether `self.is_uniform()` - - Parameters - ---------- - slot : Callable - A callable to connect to this signal. If the callable accepts less - arguments than the signature of this slot, then they will be discarded when - calling the slot. - check_nargs : Optional[bool] - If `True` and the provided `slot` requires more positional arguments than - the signature of this Signal, raise `TypeError`. by default `True`. - check_types : Optional[bool] - If `True`, An additional check will be performed to make sure that types - declared in the slot signature are compatible with the signature - declared by this signal, by default `False`. - unique : Union[bool, str] - If `True`, returns without connecting if the slot has already been - connected. If the literal string "raise" is passed to `unique`, then a - `ValueError` will be raised if the slot is already connected. - By default `False`. - max_args : int, optional - If provided, `slot` will be called with no more more than `max_args` when - this SignalInstance is emitted. (regardless of how many arguments are - emitted). - - Returns - ------- - Union[Callable[[Callable], Callable], Callable] - [description] - """ - - def _inner(slot: Callable) -> Callable: - for sig in self._group: - self._group[sig].connect( - slot, - check_nargs=check_nargs, - check_types=check_types, - unique=unique, - max_args=max_args, - ) - return slot - - return _inner if slot is None else _inner(slot) - - def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: - """Block this signal and all emitters from emitting.""" - super().block() - for k in self._group: - v = self._group[k] - if exclude and v in exclude or k in exclude: - continue - self._sig_was_blocked[k] = v._is_blocked - v.block() - - def unblock(self) -> None: - """Unblock this signal and all emitters, allowing them to emit.""" - super().unblock() - for k in self._group: - if not self._sig_was_blocked.pop(k, False): - self._group[k].unblock() - - def blocked( - self, exclude: Iterable[str | SignalInstance] = () - ) -> ContextManager[None]: - """Context manager to temporarily block all emitters in this group. - - Parameters - ---------- - exclude : iterable of str or SignalInstance, optional - An iterable of signal instances or names to exempt from the block, - by default () - """ - return _SignalBlocker(self, exclude=exclude) - - def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> None: - """Disconnect slot from all signals. - - Parameters - ---------- - slot : callable, optional - The specific slot to disconnect. If `None`, all slots will be disconnected, - by default `None` - 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. - """ - for signal in self._group: - self._group[signal].disconnect(slot, missing_ok) - super().disconnect(slot, missing_ok) - - -@mypyc_attr(allow_interpreted_subclasses=True) -# class SignalGroup(Mapping[str, SignalInstance]): -class SignalGroup: - _signals_: ClassVar[Mapping[str, Signal]] - _uniform: ClassVar[bool] = False - - # see comment in __init__. This type annotation can be overriden by subclass - # to change the public name of the SignalRelay attribute - all: SignalRelay - - def __init__(self, instance: Any = None) -> None: - cls = type(self) - if not hasattr(cls, "_signals_"): # pragma: no cover - raise TypeError( - "Cannot instantiate SignalGroup directly. Use a subclass instead." - ) - self._psygnal_instances: dict[str, SignalInstance] = { - name: signal.__get__(self, cls) for name, signal in cls._signals_.items() - } - self._psygnal_relay = SignalRelay(self, instance) - - # determine the public name of the signal relay. - # by default, this is "all", but it can be overridden by the user by creating - # a new name for the SignalRelay annotation on a subclass of SignalGroup - # e.g. `my_name: SignalRelay` - self._psygnal_relay_name = "all" - for base in cls.__mro__: - for key, val in getattr(base, "__annotations__", {}).items(): - if val is SignalRelay: - self._psygnal_relay_name = key - break - setattr(self, self._psygnal_relay_name, self._psygnal_relay) - - def __init_subclass__(cls, strict: bool = False) -> None: - """Finds all Signal instances on the class and add them to `cls._signals_`.""" - cls._signals_ = { - k: val - for k, val in getattr(cls, "__dict__", {}).items() - if isinstance(val, Signal) - } - - cls._uniform = _is_uniform(cls._signals_.values()) - if strict and not cls._uniform: - raise TypeError( - "All Signals in a strict SignalGroup must have the same signature" - ) - super().__init_subclass__() - - # TODO: change type hint after completing deprecation of direct access to - # names on SignalRelay object - def __getattr__(self, name: str) -> Any: - if name != "_psygnal_instances": - if name in self._psygnal_instances: - return self._psygnal_instances[name] - if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): - # TODO: add deprecation warning and redirect to `self.all` - return getattr(self._psygnal_relay, name) - raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") - - @property - def signals(self) -> Mapping[str, SignalInstance]: - # TODO: deprecate this property - return self._psygnal_instances - - def __len__(self) -> int: - return len(self._signals_) - - def __getitem__(self, item: str) -> SignalInstance: - return self._psygnal_instances[item] - - def __iter__(self) -> Iterator[str]: - return iter(self._signals_) - - def __repr__(self) -> str: - """Return repr(self).""" - name = self.__class__.__name__ - instance = "" - nsignals = len(self) - signals = f"{nsignals} signals" - return f"" - - @classmethod - def is_uniform(cls) -> bool: - """Return true if all signals in the group have the same signature.""" - # TODO: Deprecate this method - return cls._uniform - - def __deepcopy__(self, memo: dict[int, Any]) -> SignalGroup: - # TODO: should we also copy connections? - return type(self)(instance=self._psygnal_relay.instance) - - -def _is_uniform(signals: Iterable[Signal]) -> bool: - """Return True if all signals have the same signature.""" - seen: set[tuple[str, ...]] = set() - for s in signals: - v = tuple(str(p.annotation) for p in s.signature.parameters.values()) - if seen and v not in seen: # allow zero or one - return False - seen.add(v) - return True diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index b10268ab..682ea057 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -19,7 +19,7 @@ ) from ._dataclass_utils import iter_fields -from ._group2 import SignalGroup +from ._group import SignalGroup from ._signal import Signal, SignalInstance if TYPE_CHECKING: diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index ea5c14ed..8303f663 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -40,7 +40,7 @@ if TYPE_CHECKING: from typing_extensions import Literal - from ._group2 import EmissionInfo + from ._group import EmissionInfo from ._weak_callback import RefErrorChoice ReducerFunc = Callable[[tuple, tuple], tuple] @@ -921,7 +921,7 @@ def emit( return None if SignalInstance._debug_hook is not None: - from ._group2 import EmissionInfo + from ._group import EmissionInfo SignalInstance._debug_hook(EmissionInfo(self, args)) diff --git a/src/psygnal/containers/_evented_dict.py b/src/psygnal/containers/_evented_dict.py index ac89ef0b..ad504766 100644 --- a/src/psygnal/containers/_evented_dict.py +++ b/src/psygnal/containers/_evented_dict.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from typing import Self -from psygnal._group2 import SignalGroup +from psygnal._group import SignalGroup from psygnal._signal import Signal _K = TypeVar("_K") diff --git a/src/psygnal/containers/_evented_list.py b/src/psygnal/containers/_evented_list.py index ad06d96a..65b0a320 100644 --- a/src/psygnal/containers/_evented_list.py +++ b/src/psygnal/containers/_evented_list.py @@ -34,7 +34,7 @@ overload, ) -from psygnal._group2 import EmissionInfo, SignalGroup, SignalRelay +from psygnal._group import EmissionInfo, SignalGroup, SignalRelay from psygnal._signal import Signal, SignalInstance from psygnal.utils import iter_signal_instances diff --git a/src/psygnal/containers/_evented_proxy.py b/src/psygnal/containers/_evented_proxy.py index 8f08c943..46342c76 100644 --- a/src/psygnal/containers/_evented_proxy.py +++ b/src/psygnal/containers/_evented_proxy.py @@ -9,7 +9,7 @@ f"{e}. Please `pip install psygnal[proxy]` to use EventedObjectProxies" ) from e -from psygnal._group2 import SignalGroup +from psygnal._group import SignalGroup from psygnal._signal import Signal T = TypeVar("T") diff --git a/src/psygnal/utils.py b/src/psygnal/utils.py index 567b4746..e24ce644 100644 --- a/src/psygnal/utils.py +++ b/src/psygnal/utils.py @@ -7,7 +7,7 @@ from typing import Any, Callable, Generator, Iterator from warnings import warn -from ._group2 import EmissionInfo, SignalGroup, SignalRelay +from ._group import EmissionInfo, SignalGroup, SignalRelay from ._signal import SignalInstance __all__ = ["monitor_events", "iter_signal_instances"] diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index 4e0b3f91..2a31362b 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -21,7 +21,7 @@ get_evented_namespace, is_evented, ) -from psygnal._group2 import SignalGroup +from psygnal._group import SignalGroup decorated_or_descriptor = pytest.mark.parametrize( "decorator", [True, False], ids=["decorator", "descriptor"] diff --git a/tests/test_evented_model.py b/tests/test_evented_model.py index 80c9c1d5..3d822a7c 100644 --- a/tests/test_evented_model.py +++ b/tests/test_evented_model.py @@ -16,7 +16,7 @@ from pydantic import BaseModel from psygnal import EmissionInfo, EventedModel -from psygnal._group2 import SignalGroup +from psygnal._group import SignalGroup PYDANTIC_V2 = pydantic.version.VERSION.startswith("2") diff --git a/tests/test_group.py b/tests/test_group.py index ad84fedc..f72471b2 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -5,7 +5,7 @@ from typing_extensions import Annotated from psygnal import EmissionInfo, Signal, SignalGroup -from psygnal._group2 import SignalRelay +from psygnal._group import SignalRelay class MyGroup(SignalGroup): From 744b973cb70b916a982aac78dbd4dc5cc4f0223e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 12 Feb 2024 19:32:37 -0500 Subject: [PATCH 15/43] use psygnal_relay --- src/psygnal/_evented_model_v1.py | 2 +- src/psygnal/_evented_model_v2.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/psygnal/_evented_model_v1.py b/src/psygnal/_evented_model_v1.py index e6d620a7..f08fc12a 100644 --- a/src/psygnal/_evented_model_v1.py +++ b/src/psygnal/_evented_model_v1.py @@ -433,7 +433,7 @@ def update(self, values: Union["EventedModel", dict], recurse: bool = True) -> N if not isinstance(values, dict): # pragma: no cover raise TypeError(f"values must be a dict or BaseModel. got {type(values)}") - with self.events.all.paused(): # TODO: reduce? + with self.events._psygnal_relay.paused(): # TODO: reduce? for key, value in values.items(): field = getattr(self, key) if isinstance(field, EventedModel) and recurse: diff --git a/src/psygnal/_evented_model_v2.py b/src/psygnal/_evented_model_v2.py index f1aa4245..a007c6fb 100644 --- a/src/psygnal/_evented_model_v2.py +++ b/src/psygnal/_evented_model_v2.py @@ -419,7 +419,7 @@ def update(self, values: Union["EventedModel", dict], recurse: bool = True) -> N if not isinstance(values, dict): # pragma: no cover raise TypeError(f"values must be a dict or BaseModel. got {type(values)}") - with self.events.all.paused(): # TODO: reduce? + with self.events._psygnal_relay.paused(): # TODO: reduce? for key, value in values.items(): field = getattr(self, key) if isinstance(field, EventedModel) and recurse: From a78d9d61d7fab076f1f157be0faa7d622d4e4546 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 12 Feb 2024 19:34:43 -0500 Subject: [PATCH 16/43] undo change --- src/psygnal/_signal.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 8303f663..d5460455 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -1120,19 +1120,7 @@ def __getstate__(self) -> dict: ) dd = {slot: getattr(self, slot) for slot in attrs} dd["_instance"] = self._instance() - dd["_slots"] = [ - x - for x in self._slots - if ( - isinstance(x, StrongFunction) - # HACK - # this is a hack to retain the ability of a deep-copied signal group - # to connect to all signals in the group. - # reconsider this mechanism. It could also be achieved more directly - # as a special __deepcopy__ method on SignalGroup - or getattr(x, "_obj_qualname", None) == "SignalRelay._slot_relay" - ) - ] + dd["_slots"] = [x for x in self._slots if isinstance(x, StrongFunction)] if len(self._slots) > len(dd["_slots"]): warnings.warn( "Pickling a SignalInstance does not copy connected weakly referenced " From 9f44dfcfbd40b640bec98bbb7fcfb8a0749e814c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 12 Feb 2024 20:25:07 -0500 Subject: [PATCH 17/43] more test fixes --- src/psygnal/_group.py | 51 +++++++++++++++++---------- tests/containers/test_evented_list.py | 6 ++-- tests/test_evented_model.py | 16 +++++++-- tests/test_group.py | 28 +++++++-------- tests/test_psygnal.py | 22 ++++++------ typesafety/test_group.yml | 3 +- 6 files changed, 76 insertions(+), 50 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index dbb01c29..7b2470de 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -25,6 +25,8 @@ from psygnal._signal import Signal, SignalInstance, _SignalBlocker +__all__ = ["EmissionInfo", "SignalGroup"] + class EmissionInfo(NamedTuple): """Tuple containing information about an emission event. @@ -42,20 +44,23 @@ class EmissionInfo(NamedTuple): class SignalRelay(SignalInstance): """Special SignalInstance that can be used to connect to all signals in a group.""" - def __init__(self, group: SignalGroup, instance: Any = None) -> None: - self._group = group + def __init__( + self, signals: Mapping[str, SignalInstance], instance: Any = None + ) -> None: super().__init__(signature=(EmissionInfo,), instance=instance) + self._signals = signals self._sig_was_blocked: dict[str, bool] = {} # silence any warnings about failed weakrefs (will occur in compiled version) with warnings.catch_warnings(): warnings.simplefilter("ignore") - for sig in group._psygnal_instances.values(): - sig.connect(self._slot_relay, check_nargs=False, check_types=False) + for sig in signals.values(): + sig.connect( + self._slot_relay, check_nargs=False, check_types=False, unique=True + ) def _slot_relay(self, *args: Any) -> None: - emitter = Signal.current_emitter() - if emitter: + if emitter := Signal.current_emitter(): info = EmissionInfo(emitter, args) self._run_emit_loop((info,)) @@ -103,8 +108,8 @@ def connect_direct( """ def _inner(slot: Callable) -> Callable: - for sig in self._group: - self._group[sig].connect( + for sig in self._signals: + self._signals[sig].connect( slot, check_nargs=check_nargs, check_types=check_types, @@ -118,8 +123,8 @@ def _inner(slot: Callable) -> Callable: def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: """Block this signal and all emitters from emitting.""" super().block() - for k in self._group: - v = self._group[k] + for k in self._signals: + v = self._signals[k] if exclude and v in exclude or k in exclude: continue self._sig_was_blocked[k] = v._is_blocked @@ -128,9 +133,9 @@ def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: def unblock(self) -> None: """Unblock this signal and all emitters, allowing them to emit.""" super().unblock() - for k in self._group: + for k in self._signals: if not self._sig_was_blocked.pop(k, False): - self._group[k].unblock() + self._signals[k].unblock() def blocked( self, exclude: Iterable[str | SignalInstance] = () @@ -162,8 +167,8 @@ def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> N ValueError If `slot` is not connected and `missing_ok` is False. """ - for signal in self._group: - self._group[signal].disconnect(slot, missing_ok) + for signal in self._signals: + self._signals[signal].disconnect(slot, missing_ok) super().disconnect(slot, missing_ok) @@ -186,7 +191,7 @@ def __init__(self, instance: Any = None) -> None: self._psygnal_instances: dict[str, SignalInstance] = { name: signal.__get__(self, cls) for name, signal in cls._signals_.items() } - self._psygnal_relay = SignalRelay(self, instance) + self._psygnal_relay = SignalRelay(self._psygnal_instances, instance) # determine the public name of the signal relay. # by default, this is "all", but it can be overridden by the user by creating @@ -222,7 +227,14 @@ def __getattr__(self, name: str) -> Any: if name in self._psygnal_instances: return self._psygnal_instances[name] if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): - # TODO: add deprecation warning and redirect to `self.all` + warnings.warn( + f"Accessing SignalInstance attribute {name!r} on a SignalGroup is " + f"deprecated. Access it on the {self._psygnal_relay_name!r} " + f"attribute instead. e.g. `group.{self._psygnal_relay_name}.{name}`" + ". This will be an error in a future version.", + FutureWarning, + stacklevel=2, + ) return getattr(self._psygnal_relay, name) raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") @@ -251,11 +263,14 @@ def __repr__(self) -> str: @classmethod def is_uniform(cls) -> bool: """Return true if all signals in the group have the same signature.""" - # TODO: Deprecate this method + # TODO: Deprecate this method? return cls._uniform def __deepcopy__(self, memo: dict[int, Any]) -> SignalGroup: - # TODO: should we also copy connections? + # TODO: + # This really isn't a deep copy. Should we also copy connections? + # a working deepcopy is important for pydantic support, but in most cases + # it will be a group without any signals connected return type(self)(instance=self._psygnal_relay.instance) diff --git a/tests/containers/test_evented_list.py b/tests/containers/test_evented_list.py index 15426159..1aee54f6 100644 --- a/tests/containers/test_evented_list.py +++ b/tests/containers/test_evented_list.py @@ -313,7 +313,7 @@ class E: e_obj = E() root: EventedList[E] = EventedList(child_events=True) mock = Mock() - root.events.connect(mock) + root.events.all.connect(mock) root.append(e_obj) assert len(e_obj.test) == 1 assert root == [e_obj] @@ -347,7 +347,7 @@ def __init__(self): e_obj = E() root: EventedList[E] = EventedList(child_events=True) mock = Mock() - root.events.connect(mock) + root.events.all.connect(mock) root.append(e_obj) assert root == [e_obj] e_obj.events.test2.emit("hi") @@ -372,7 +372,7 @@ def __init__(self): # note that we can get back to the actual object in the list using the .instance # attribute on signal instances. - assert e_obj.events.test2.instance.instance == e_obj + assert e_obj.events.test2.instance.all.instance == e_obj mock.assert_has_calls(expected) diff --git a/tests/test_evented_model.py b/tests/test_evented_model.py index 3d822a7c..5906cbd8 100644 --- a/tests/test_evented_model.py +++ b/tests/test_evented_model.py @@ -175,7 +175,7 @@ class Config: assert model1 == model2 -def test_values_updated(): +def test_values_updated() -> None: class User(EventedModel): """Demo evented model. @@ -201,7 +201,17 @@ class User(EventedModel): user1_events = Mock() u1_id_events = Mock() u2_id_events = Mock() - user1.events.connect(user1_events) + + with pytest.warns( + FutureWarning, + match="Accessing SignalInstance attribute 'connect' on a SignalGroup " + "is deprecated", + ): + user1.events.connect(user1_events) + user1.events.connect(user1_events) + + user1.events.id.connect(u1_id_events) + user2.events.id.connect(u2_id_events) user1.events.id.connect(u1_id_events) user2.events.id.connect(u2_id_events) @@ -837,7 +847,7 @@ class Model(EventedModel): check_mock.assert_not_called() mock1.assert_not_called() - m.events.connect(mock1) + m.events.all.connect(mock1) with patch.object( model_module, "_check_field_equality", diff --git a/tests/test_group.py b/tests/test_group.py index f72471b2..78f9a9e5 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -67,11 +67,11 @@ def test_signal_group_connect(direct: bool): group = MyGroup() if direct: # the callback wants the emitted arguments directly - group.connect_direct(mock) + group.all.connect_direct(mock) else: # the callback will receive an EmissionInfo tuple # (SignalInstance, arg_tuple) - group.connect(mock) + group.all.connect(mock) group.sig1.emit(1) group.sig2.emit("hi") @@ -89,14 +89,14 @@ def test_signal_group_connect(direct: bool): def test_signal_group_connect_no_args(): - """Test that group.connect can take a callback that wants no args""" + """Test that group.all.connect can take a callback that wants no args""" group = MyGroup() count = [] def my_slot() -> None: count.append(1) - group.connect(my_slot) + group.all.connect(my_slot) group.sig1.emit(1) group.sig2.emit("hi") assert len(count) == 2 @@ -108,7 +108,7 @@ def test_group_blocked(): mock1 = Mock() mock2 = Mock() - group.connect(mock1) + group.all.connect(mock1) group.sig1.connect(mock2) group.sig1.emit(1) @@ -121,7 +121,7 @@ def test_group_blocked(): group.sig2.block() assert group.sig2._is_blocked - with group.blocked(): + with group.all.blocked(): group.sig1.emit(1) assert group.sig1._is_blocked @@ -143,7 +143,7 @@ def test_group_blocked_exclude(): group.sig1.connect(mock1) group.sig2.connect(mock2) - with group.blocked(exclude=("sig2",)): + with group.all.blocked(exclude=("sig2",)): group.sig1.emit(1) group.sig2.emit("hi") mock1.assert_not_called() @@ -160,7 +160,7 @@ def test_group_disconnect_single_slot(): group.sig1.connect(mock1) group.sig2.connect(mock2) - group.disconnect(mock1) + group.all.disconnect(mock1) group.sig1.emit() mock1.assert_not_called() @@ -178,7 +178,7 @@ def test_group_disconnect_all_slots(): group.sig1.connect(mock1) group.sig2.connect(mock2) - group.disconnect() + group.all.disconnect() group.sig1.emit() group.sig2.emit() @@ -195,10 +195,10 @@ class T: obj = T() group = MyGroup(obj) - assert group.instance is obj + assert group.all.instance is obj del obj gc.collect() - assert group.instance is None + assert group.all.instance is None def test_group_deepcopy() -> None: @@ -210,7 +210,7 @@ def method(self): group = MyGroup(obj) assert deepcopy(group) is not group # but no warning - group.connect(obj.method) + group.all.connect(obj.method) # with pytest.warns(UserWarning, match="does not copy connected weakly"): group2 = deepcopy(group) @@ -218,8 +218,8 @@ def method(self): assert not len(group2._psygnal_relay) mock = Mock() mock2 = Mock() - group.connect(mock) - group2.connect(mock2) + group.all.connect(mock) + group2.all.connect(mock2) group2.sig1.emit(1) mock.assert_not_called() diff --git a/tests/test_psygnal.py b/tests/test_psygnal.py index 699e3a8e..0cab0afe 100644 --- a/tests/test_psygnal.py +++ b/tests/test_psygnal.py @@ -391,28 +391,28 @@ def test_group_weakref(slot): class MyGroup(SignalGroup): sig1 = Signal(int) - emitter = MyGroup() + group = MyGroup() obj = MyObj() # simply by nature of being in a group, sig1 will have a callback - assert len(emitter.sig1) == 1 + assert len(group.sig1) == 1 # but the group itself doesn't have any - assert len(emitter._psygnal_relay) == 0 + assert len(group._psygnal_relay) == 0 # connecting something to the group adds to the group connections - emitter.connect( + group.all.connect( partial(obj.f_int_int, 1) if slot == "partial" else getattr(obj, slot) ) - assert len(emitter.sig1) == 1 - assert len(emitter._psygnal_relay) == 1 + assert len(group.sig1) == 1 + assert len(group._psygnal_relay) == 1 - emitter.sig1.emit(1) - assert len(emitter.sig1) == 1 + group.sig1.emit(1) + assert len(group.sig1) == 1 del obj gc.collect() - emitter.sig1.emit(1) # this should trigger deletion, so would emitter.emit() - assert len(emitter.sig1) == 1 - assert len(emitter._psygnal_relay) == 0 # it's been cleaned up + group.sig1.emit(1) # this should trigger deletion, so would emitter.emit() + assert len(group.sig1) == 1 + assert len(group._psygnal_relay) == 0 # it's been cleaned up # def test_norm_slot(): diff --git a/typesafety/test_group.yml b/typesafety/test_group.yml index 3946de9d..e0033c01 100644 --- a/typesafety/test_group.yml +++ b/typesafety/test_group.yml @@ -12,7 +12,8 @@ t = T() reveal_type(T.e) # N: Revealed type is "psygnal._group_descriptor.SignalGroupDescriptor" reveal_type(t.e) # N: Revealed type is "psygnal._group.SignalGroup" - reveal_type(t.e.x) # N: Revealed type is "psygnal._signal.SignalInstance" + reveal_type(t.e['x']) # N: Revealed type is "psygnal._signal.SignalInstance" + reveal_type(t.e.x) # N: Revealed type is "Any" @t.e.x.connect def func(x: int) -> None: From 6d2c795e956e0eb7c27d695a73795b635bc49734 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 09:02:15 -0500 Subject: [PATCH 18/43] try fix typing --- tests/test_evented_model.py | 9 ++------- tests/test_group.py | 8 +++++++- typesafety/test_group.yml | 3 ++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/test_evented_model.py b/tests/test_evented_model.py index 5906cbd8..ebc6db41 100644 --- a/tests/test_evented_model.py +++ b/tests/test_evented_model.py @@ -202,13 +202,8 @@ class User(EventedModel): u1_id_events = Mock() u2_id_events = Mock() - with pytest.warns( - FutureWarning, - match="Accessing SignalInstance attribute 'connect' on a SignalGroup " - "is deprecated", - ): - user1.events.connect(user1_events) - user1.events.connect(user1_events) + user1.events.connect(user1_events) + user1.events.connect(user1_events) user1.events.id.connect(u1_id_events) user2.events.id.connect(u2_id_events) diff --git a/tests/test_group.py b/tests/test_group.py index 78f9a9e5..2e69401e 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -67,7 +67,13 @@ def test_signal_group_connect(direct: bool): group = MyGroup() if direct: # the callback wants the emitted arguments directly - group.all.connect_direct(mock) + + with pytest.warns( + FutureWarning, + match="Accessing SignalInstance attribute 'connect_direct' on a SignalGroup" + " is deprecated", + ): + group.connect_direct(mock) else: # the callback will receive an EmissionInfo tuple # (SignalInstance, arg_tuple) diff --git a/typesafety/test_group.yml b/typesafety/test_group.yml index e0033c01..5a71124e 100644 --- a/typesafety/test_group.yml +++ b/typesafety/test_group.yml @@ -13,9 +13,10 @@ reveal_type(T.e) # N: Revealed type is "psygnal._group_descriptor.SignalGroupDescriptor" reveal_type(t.e) # N: Revealed type is "psygnal._group.SignalGroup" reveal_type(t.e['x']) # N: Revealed type is "psygnal._signal.SignalInstance" + # TODO: change after removing getattr deprecation reveal_type(t.e.x) # N: Revealed type is "Any" - @t.e.x.connect + @t.e['x'].connect def func(x: int) -> None: pass From a2e88ea6db6881bab852bede9d140c014101707c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 09:04:36 -0500 Subject: [PATCH 19/43] fix typing --- typesafety/test_group.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/typesafety/test_group.yml b/typesafety/test_group.yml index 5a71124e..f04d9137 100644 --- a/typesafety/test_group.yml +++ b/typesafety/test_group.yml @@ -13,11 +13,18 @@ reveal_type(T.e) # N: Revealed type is "psygnal._group_descriptor.SignalGroupDescriptor" reveal_type(t.e) # N: Revealed type is "psygnal._group.SignalGroup" reveal_type(t.e['x']) # N: Revealed type is "psygnal._signal.SignalInstance" - # TODO: change after removing getattr deprecation - reveal_type(t.e.x) # N: Revealed type is "Any" @t.e['x'].connect def func(x: int) -> None: pass reveal_type(func) # N: Revealed type is "def (x: builtins.int)" + + # TODO: change after removing __getattr__ deprecation + reveal_type(t.e.x) # N: Revealed type is "Any" + + @t.e.x.connect + def func2(x: int) -> None: + pass + + reveal_type(func2) # N: Revealed type is "Any" From 18d3e2559933269aa63f4b0c67ccdb9af8a327c2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 09:26:25 -0500 Subject: [PATCH 20/43] more deprecations --- src/psygnal/__init__.py | 5 +-- src/psygnal/_group.py | 59 ++++++++++++++++++++------------ src/psygnal/_group_descriptor.py | 2 +- src/psygnal/utils.py | 13 +++---- tests/test_evented_model.py | 13 +++---- tests/test_group.py | 7 ++-- tests/test_group_descriptor.py | 8 ++--- 7 files changed, 63 insertions(+), 44 deletions(-) diff --git a/src/psygnal/__init__.py b/src/psygnal/__init__.py index fd2dfe36..a37dfd86 100644 --- a/src/psygnal/__init__.py +++ b/src/psygnal/__init__.py @@ -23,8 +23,8 @@ "_compiled", "debounced", "EmissionInfo", - "EmitLoopError", "emit_queued", + "EmitLoopError", "evented", "EventedModel", "get_evented_namespace", @@ -33,6 +33,7 @@ "SignalGroup", "SignalGroupDescriptor", "SignalInstance", + "SignalRelay", "throttled", ] @@ -50,7 +51,7 @@ from ._evented_decorator import evented from ._exceptions import EmitLoopError -from ._group import EmissionInfo, SignalGroup +from ._group import EmissionInfo, SignalGroup, SignalRelay from ._group_descriptor import ( SignalGroupDescriptor, get_evented_namespace, diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 7b2470de..94cf1356 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -25,7 +25,7 @@ from psygnal._signal import Signal, SignalInstance, _SignalBlocker -__all__ = ["EmissionInfo", "SignalGroup"] +__all__ = ["EmissionInfo", "SignalGroup", "SignalRelay"] class EmissionInfo(NamedTuple): @@ -75,7 +75,7 @@ def connect_direct( ) -> Callable[[Callable], Callable] | Callable: """Connect `slot` to be called whenever *any* Signal in this group is emitted. - Params are the same as {meth}`~psygnal.SignalInstance.connect`. It's probably + Params are the same as `psygnal.SignalInstance.connect`. It's probably best to check whether `self.is_uniform()` Parameters @@ -84,14 +84,14 @@ def connect_direct( A callable to connect to this signal. If the callable accepts less arguments than the signature of this slot, then they will be discarded when calling the slot. - check_nargs : Optional[bool] + check_nargs : bool | None If `True` and the provided `slot` requires more positional arguments than the signature of this Signal, raise `TypeError`. by default `True`. - check_types : Optional[bool] + check_types : bool | None If `True`, An additional check will be performed to make sure that types declared in the slot signature are compatible with the signature declared by this signal, by default `False`. - unique : Union[bool, str] + unique : bool | str If `True`, returns without connecting if the slot has already been connected. If the literal string "raise" is passed to `unique`, then a `ValueError` will be raised if the slot is already connected. @@ -172,11 +172,23 @@ def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> N super().disconnect(slot, missing_ok) +# NOTE +# To developers. Avoid adding public names to this class, as it is intended to be +# a container for user-determined names. If names must be added, try to prefix +# with "psygnal_" to avoid conflicts with user-defined names. @mypyc_attr(allow_interpreted_subclasses=True) -# class SignalGroup(Mapping[str, SignalInstance]): class SignalGroup: - _signals_: ClassVar[Mapping[str, Signal]] - _uniform: ClassVar[bool] = False + """A collection of signals that can be connected to as a single unit. + + Parameters + ---------- + instance : Any, optional + An object to which this SignalGroup is bound, by default None + """ + + _psygnal_signals: ClassVar[Mapping[str, Signal]] + _psygnal_instances: dict[str, SignalInstance] + _psygnal_uniform: ClassVar[bool] = False # see comment in __init__. This type annotation can be overriden by subclass # to change the public name of the SignalRelay attribute @@ -184,12 +196,13 @@ class SignalGroup: def __init__(self, instance: Any = None) -> None: cls = type(self) - if not hasattr(cls, "_signals_"): # pragma: no cover + if not hasattr(cls, "_psygnal_signals"): # pragma: no cover raise TypeError( "Cannot instantiate SignalGroup directly. Use a subclass instead." ) self._psygnal_instances: dict[str, SignalInstance] = { - name: signal.__get__(self, cls) for name, signal in cls._signals_.items() + name: signal.__get__(self, cls) + for name, signal in cls._psygnal_signals.items() } self._psygnal_relay = SignalRelay(self._psygnal_instances, instance) @@ -207,14 +220,14 @@ def __init__(self, instance: Any = None) -> None: def __init_subclass__(cls, strict: bool = False) -> None: """Finds all Signal instances on the class and add them to `cls._signals_`.""" - cls._signals_ = { + cls._psygnal_signals = { k: val for k, val in getattr(cls, "__dict__", {}).items() if isinstance(val, Signal) } - cls._uniform = _is_uniform(cls._signals_.values()) - if strict and not cls._uniform: + cls._psygnal_uniform = _is_uniform(cls._psygnal_signals.values()) + if strict and not cls._psygnal_uniform: raise TypeError( "All Signals in a strict SignalGroup must have the same signature" ) @@ -241,30 +254,34 @@ def __getattr__(self, name: str) -> Any: @property def signals(self) -> Mapping[str, SignalInstance]: # TODO: deprecate this property + warnings.warn( + "Accessing the `signals` property on a SignalGroup is deprecated. " + "Use __iter__ to iterate over all signal names, and __getitem__ or getattr " + "to access signal instances. This will be an error in a future.", + FutureWarning, + stacklevel=2, + ) return self._psygnal_instances def __len__(self) -> int: - return len(self._signals_) + return len(self._psygnal_signals) def __getitem__(self, item: str) -> SignalInstance: return self._psygnal_instances[item] def __iter__(self) -> Iterator[str]: - return iter(self._signals_) + return iter(self._psygnal_signals) def __repr__(self) -> str: """Return repr(self).""" name = self.__class__.__name__ - instance = "" - nsignals = len(self) - signals = f"{nsignals} signals" - return f"" + return f"" @classmethod def is_uniform(cls) -> bool: """Return true if all signals in the group have the same signature.""" - # TODO: Deprecate this method? - return cls._uniform + # TODO: Deprecate this meth? + return cls._psygnal_uniform def __deepcopy__(self, memo: dict[int, Any]) -> SignalGroup: # TODO: diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index 682ea057..2e5fd8b1 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -447,7 +447,7 @@ def __get__( def _create_group(self, owner: type) -> type[SignalGroup]: Group = self._signal_group or _build_dataclass_signal_group(owner, self._eqop) - if self._warn_on_no_fields and not Group._signals_: + if self._warn_on_no_fields and not Group._psygnal_signals: warnings.warn( f"No mutable fields found on class {owner}: no events will be " "emitted. (Is this a dataclass, attrs, msgspec, or pydantic model?)", diff --git a/src/psygnal/utils.py b/src/psygnal/utils.py index e24ce644..f4077832 100644 --- a/src/psygnal/utils.py +++ b/src/psygnal/utils.py @@ -1,7 +1,7 @@ """These utilities may help when using signals and evented objects.""" from __future__ import annotations -from contextlib import contextmanager +from contextlib import contextmanager, suppress from functools import partial from pathlib import Path from typing import Any, Callable, Generator, Iterator @@ -104,11 +104,12 @@ def iter_signal_instances( """ for n in dir(obj): if include_private_attrs or not n.startswith("_"): - attr = getattr(obj, n) - if isinstance(attr, SignalInstance): - yield attr - if isinstance(attr, SignalGroup): - yield attr._psygnal_relay + with suppress(AttributeError, FutureWarning): + attr = getattr(obj, n) + if isinstance(attr, SignalInstance): + yield attr + if isinstance(attr, SignalGroup): + yield attr._psygnal_relay _COMPILED_EXTS = (".so", ".pyd") diff --git a/tests/test_evented_model.py b/tests/test_evented_model.py index ebc6db41..8ae4b749 100644 --- a/tests/test_evented_model.py +++ b/tests/test_evented_model.py @@ -70,11 +70,12 @@ class User(EventedModel): # test event system assert isinstance(user.events, SignalGroup) - assert "id" in user.events.signals - assert "name" in user.events.signals + with pytest.warns(FutureWarning): + assert "id" in user.events.signals + assert "name" in user.events.signals # ClassVars are excluded from events - assert "age" not in user.events.signals + assert "age" not in user.events id_mock = Mock() name_mock = Mock() @@ -202,8 +203,8 @@ class User(EventedModel): u1_id_events = Mock() u2_id_events = Mock() - user1.events.connect(user1_events) - user1.events.connect(user1_events) + user1.events.all.connect(user1_events) + user1.events.all.connect(user1_events) user1.events.id.connect(u1_id_events) user2.events.id.connect(u2_id_events) @@ -527,7 +528,7 @@ def test_evented_model_with_property_setters(): def test_evented_model_with_property_setters_events(): t = T() - assert "c" in t.events.signals # the setter has an event + assert "c" in t.events # the setter has an event mock_a = Mock() mock_b = Mock() mock_c = Mock() diff --git a/tests/test_group.py b/tests/test_group.py index 2e69401e..16f4380e 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -17,8 +17,8 @@ def test_signal_group(): assert not MyGroup.is_uniform() group = MyGroup() assert not group.is_uniform() - assert isinstance(group.signals, dict) - assert group.signals == {"sig1": group.sig1, "sig2": group.sig2} + assert list(group) == ["sig1", "sig2"] # testing __iter__ + assert group.sig1 is group["sig1"] assert repr(group) == "" @@ -33,8 +33,7 @@ class MyStrictGroup(SignalGroup, strict=True): assert MyStrictGroup.is_uniform() group = MyStrictGroup() assert group.is_uniform() - assert isinstance(group.signals, dict) - assert set(group.signals) == {"sig1", "sig2"} + assert set(group) == {"sig1", "sig2"} with pytest.raises(TypeError) as e: diff --git a/tests/test_group_descriptor.py b/tests/test_group_descriptor.py index ccf4ab39..1b36f032 100644 --- a/tests/test_group_descriptor.py +++ b/tests/test_group_descriptor.py @@ -81,10 +81,10 @@ class Bar(Foo): # the patching of __setattr__ should only happen once # and it will happen only on the first access of .events mock_decorator.assert_not_called() - assert set(base.events.signals) == {"a"} - assert set(foo.events.signals) == {"a", "b"} - assert set(bar.events.signals) == {"a", "b", "c"} - assert set(bar2.events.signals) == {"a", "b", "c"} + assert set(base.events) == {"a"} + assert set(foo.events) == {"a", "b"} + assert set(bar.events) == {"a", "b", "c"} + assert set(bar2.events) == {"a", "b", "c"} if not _compiled: # can't patch otherwise assert mock_decorator.call_count == 1 From f14646f3f69386f750c2c457220cb16bdb21b848 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 09:43:49 -0500 Subject: [PATCH 21/43] docs: more docs --- src/psygnal/_group.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 94cf1356..095c9c13 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -184,6 +184,14 @@ class SignalGroup: ---------- instance : Any, optional An object to which this SignalGroup is bound, by default None + + Attributes + ---------- + all : SignalRelay + A special SignalRelay instance that can be used to connect to all signals in + this group. The name of this attribute can be overridden by the user by + creating a new name for the SignalRelay annotation on a subclass of SignalGroup + e.g. `my_name: SignalRelay` """ _psygnal_signals: ClassVar[Mapping[str, Signal]] From 1a7e6a1dc598c3a1882703990c4b61e56def0983 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 09:53:04 -0500 Subject: [PATCH 22/43] fix: improve model setattr performance --- src/psygnal/_evented_model_v1.py | 7 ++++--- src/psygnal/_evented_model_v2.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/psygnal/_evented_model_v1.py b/src/psygnal/_evented_model_v1.py index f08fc12a..aaf40bcc 100644 --- a/src/psygnal/_evented_model_v1.py +++ b/src/psygnal/_evented_model_v1.py @@ -333,6 +333,7 @@ def __init__(_model_self_, **data: Any) -> None: # but if we don't use `ClassVar`, then the `dataclass_transform` decorator # will add _events: SignalGroup to the __init__ signature, for *all* user models _model_self_._events = Group(_model_self_) # type: ignore [misc] + _model_self_._event_names = set(_model_self_._events) # for setattr performance def _super_setattr_(self, name: str, value: Any) -> None: # pydantic will raise a ValueError if extra fields are not allowed @@ -345,9 +346,9 @@ def _super_setattr_(self, name: str, value: Any) -> None: def __setattr__(self, name: str, value: Any) -> None: if ( - name == "_events" - or not hasattr(self, "_events") # can happen on init - or name not in self._events + name == "_event_names" + or not hasattr(self, "_event_names") + or name not in self._event_names ): # fallback to default behavior return self._super_setattr_(name, value) diff --git a/src/psygnal/_evented_model_v2.py b/src/psygnal/_evented_model_v2.py index a007c6fb..7f536490 100644 --- a/src/psygnal/_evented_model_v2.py +++ b/src/psygnal/_evented_model_v2.py @@ -316,6 +316,7 @@ def __init__(_model_self_, **data: Any) -> None: # but if we don't use `ClassVar`, then the `dataclass_transform` decorator # will add _events: SignalGroup to the __init__ signature, for *all* user models _model_self_._events = Group(_model_self_) # type: ignore [misc] + _model_self_._event_names = set(_model_self_._events) # for setattr performance def _super_setattr_(self, name: str, value: Any) -> None: # pydantic will raise a ValueError if extra fields are not allowed @@ -331,9 +332,9 @@ def _super_setattr_(self, name: str, value: Any) -> None: def __setattr__(self, name: str, value: Any) -> None: if ( - name == "_events" - or not hasattr(self, "_events") - or name not in self._events + name == "_event_names" + or not hasattr(self, "_event_names") + or name not in self._event_names ): # can happen on init # fallback to default behavior return self._super_setattr_(name, value) From 25596c8b642d0916496c9d5b4fcc95793007b61d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 09:59:11 -0500 Subject: [PATCH 23/43] fix: coverage --- src/psygnal/_group.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 095c9c13..5cc12366 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -241,22 +241,26 @@ def __init_subclass__(cls, strict: bool = False) -> None: ) super().__init_subclass__() - # TODO: change type hint after completing deprecation of direct access to - # names on SignalRelay object + # TODO: change type hint to -> SignalInstance after completing deprecation of + # direct access to names on SignalRelay object def __getattr__(self, name: str) -> Any: - if name != "_psygnal_instances": - if name in self._psygnal_instances: - return self._psygnal_instances[name] - if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): - warnings.warn( - f"Accessing SignalInstance attribute {name!r} on a SignalGroup is " - f"deprecated. Access it on the {self._psygnal_relay_name!r} " - f"attribute instead. e.g. `group.{self._psygnal_relay_name}.{name}`" - ". This will be an error in a future version.", - FutureWarning, - stacklevel=2, - ) - return getattr(self._psygnal_relay, name) + if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): + warnings.warn( + f"Accessing SignalInstance attribute {name!r} on a SignalGroup is " + f"deprecated. Access it on the {self._psygnal_relay_name!r} " + f"attribute instead. e.g. `group.{self._psygnal_relay_name}.{name}`. " + "This will be an error in v0.11.", + FutureWarning, + stacklevel=2, + ) + return getattr(self._psygnal_relay, name) + # Note, these lines aren't actually needed because of the descriptor + # protocol. Accessing a name on the instance will first look in the + # instance's __dict__, and then in the class's __dict__, which + # will call Signal.__get__ and return the SignalInstance. + # these lines are here as a reminder to developers. + # if name in self._psygnal_instances: + # return self._psygnal_instances[name] raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") @property From 189c018ed9dfc158a972a5f81617b386389f4de3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 10:03:00 -0500 Subject: [PATCH 24/43] perf: try improve bench --- src/psygnal/_evented_model_v1.py | 7 ++++--- src/psygnal/_evented_model_v2.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/psygnal/_evented_model_v1.py b/src/psygnal/_evented_model_v1.py index aaf40bcc..630c0aa0 100644 --- a/src/psygnal/_evented_model_v1.py +++ b/src/psygnal/_evented_model_v1.py @@ -358,16 +358,17 @@ def __setattr__(self, name: str, value: Any) -> None: # dependent properties. # note that ALL signals will have at least one listener simply by nature of # being in the `self._events` SignalGroup. - signal_instance: SignalInstance = getattr(self._events, name) + group = self._events + signal_instance: SignalInstance = group[name] deps_with_callbacks = { dep_name for dep_name in self.__field_dependents__.get(name, ()) - if len(getattr(self._events, dep_name)) > 1 + if len(group[dep_name]) > 1 } if ( len(signal_instance) < 2 # the signal itself has no listeners and not deps_with_callbacks # no dependent properties with listeners - and not len(self._events._psygnal_relay) # no listeners on the SignalGroup + and not len(group._psygnal_relay) # no listeners on the SignalGroup ): return self._super_setattr_(name, value) diff --git a/src/psygnal/_evented_model_v2.py b/src/psygnal/_evented_model_v2.py index 7f536490..22d46206 100644 --- a/src/psygnal/_evented_model_v2.py +++ b/src/psygnal/_evented_model_v2.py @@ -344,16 +344,17 @@ def __setattr__(self, name: str, value: Any) -> None: # dependent properties. # note that ALL signals will have sat least one listener simply by nature of # being in the `self._events` SignalGroup. - signal_instance: SignalInstance = getattr(self._events, name) + group = self._events + signal_instance: SignalInstance = group[name] deps_with_callbacks = { dep_name for dep_name in self.__field_dependents__.get(name, ()) - if len(getattr(self._events, dep_name)) > 1 + if len(group[dep_name]) > 1 } if ( len(signal_instance) < 2 # the signal itself has no listeners and not deps_with_callbacks # no dependent properties with listeners - and not len(self._events._psygnal_relay) # no listeners on the SignalGroup + and not len(group._psygnal_relay) # no listeners on the SignalGroup ): return self._super_setattr_(name, value) From 5e6a65e0d44ebac6e84fba9401a090942b295a21 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 10:18:42 -0500 Subject: [PATCH 25/43] perf: change benchmark --- benchmarks/benchmarks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index 00c3a418..3eaadaa3 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -141,12 +141,13 @@ class Model(EventedModel): x: int = 1 self.model = Model + self.model_instance = Model() def time_setattr_no_connections(self, n: int) -> None: if self.model is None: return - obj = self.model() + obj = self.model_instance for i in range(n): obj.x = i @@ -154,7 +155,7 @@ def time_setattr_with_connections(self, n: int) -> None: if self.model is None: return - obj = self.model() + obj = self.model_instance obj.events.x.connect(callback) for i in range(n): obj.x = i From 56fe62040aaae51d016c13e9caea7922a40bf0b8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 10:31:48 -0500 Subject: [PATCH 26/43] perf: fix evented setattr performance --- src/psygnal/_group_descriptor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index 2e5fd8b1..80597676 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -267,7 +267,12 @@ def _setattr_and_emit_(self: object, name: str, value: Any) -> None: group: SignalGroup | None = getattr(self, signal_group_name, None) signal: SignalInstance | None = getattr(group, name, None) # don't emit if the signal doesn't exist or has no listeners - if group is None or signal is None or len(signal) < 2 and not len(group): + if ( + group is None + or signal is None + or len(signal) < 2 + and not len(group._psygnal_relay) + ): return super_setattr(self, name, value) with _changes_emitted(self, name, signal): From 8b29145405e3674ad80ebdab9846ca85295867d2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 10:35:48 -0500 Subject: [PATCH 27/43] test: fix warning in test_bench --- tests/test_bench.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_bench.py b/tests/test_bench.py index 9306f503..7d2fcdfe 100644 --- a/tests/test_bench.py +++ b/tests/test_bench.py @@ -212,7 +212,7 @@ def test_dataclass_setattr(type_: str, benchmark: Callable) -> None: Foo = _get_dataclass(type_) foo = Foo(a=1, b="hi", c=True, d=1.0, e=(1, "hi")) mock = Mock() - foo.events.connect(mock) + foo.events._psygnal_relay.connect(mock) def _doit() -> None: foo.a = 2 From 77e11ec971ce6130325a261f2487938baee6285e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 13 Feb 2024 10:51:25 -0500 Subject: [PATCH 28/43] fix: revert event_names change --- src/psygnal/_evented_model_v1.py | 7 +++---- src/psygnal/_evented_model_v2.py | 9 ++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/psygnal/_evented_model_v1.py b/src/psygnal/_evented_model_v1.py index 630c0aa0..37d39095 100644 --- a/src/psygnal/_evented_model_v1.py +++ b/src/psygnal/_evented_model_v1.py @@ -333,7 +333,6 @@ def __init__(_model_self_, **data: Any) -> None: # but if we don't use `ClassVar`, then the `dataclass_transform` decorator # will add _events: SignalGroup to the __init__ signature, for *all* user models _model_self_._events = Group(_model_self_) # type: ignore [misc] - _model_self_._event_names = set(_model_self_._events) # for setattr performance def _super_setattr_(self, name: str, value: Any) -> None: # pydantic will raise a ValueError if extra fields are not allowed @@ -346,9 +345,9 @@ def _super_setattr_(self, name: str, value: Any) -> None: def __setattr__(self, name: str, value: Any) -> None: if ( - name == "_event_names" - or not hasattr(self, "_event_names") - or name not in self._event_names + name == "_events" + or not hasattr(self, "_events") # can happen on init + or name not in self._events ): # fallback to default behavior return self._super_setattr_(name, value) diff --git a/src/psygnal/_evented_model_v2.py b/src/psygnal/_evented_model_v2.py index 22d46206..b6a36529 100644 --- a/src/psygnal/_evented_model_v2.py +++ b/src/psygnal/_evented_model_v2.py @@ -316,7 +316,6 @@ def __init__(_model_self_, **data: Any) -> None: # but if we don't use `ClassVar`, then the `dataclass_transform` decorator # will add _events: SignalGroup to the __init__ signature, for *all* user models _model_self_._events = Group(_model_self_) # type: ignore [misc] - _model_self_._event_names = set(_model_self_._events) # for setattr performance def _super_setattr_(self, name: str, value: Any) -> None: # pydantic will raise a ValueError if extra fields are not allowed @@ -332,10 +331,10 @@ def _super_setattr_(self, name: str, value: Any) -> None: def __setattr__(self, name: str, value: Any) -> None: if ( - name == "_event_names" - or not hasattr(self, "_event_names") - or name not in self._event_names - ): # can happen on init + name == "_events" + or not hasattr(self, "_events") # can happen on init + or name not in self._events + ): # fallback to default behavior return self._super_setattr_(name, value) From 5875261fe37ebc946962e8048a17a15203deda96 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 14 Feb 2024 08:54:01 -0500 Subject: [PATCH 29/43] docs: more docs --- src/psygnal/_group.py | 58 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 5cc12366..5b7d543f 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -42,7 +42,29 @@ class EmissionInfo(NamedTuple): class SignalRelay(SignalInstance): - """Special SignalInstance that can be used to connect to all signals in a group.""" + """Special SignalInstance that can be used to connect to all signals in a group. + + This class will rarely be instantiated by a user (or anything other than a + SignalGroup). But it may be imported and used as a type hint to change the + public name of the SignalRelay attribute on a SignalGroup subclass. + + Parameters + ---------- + signals : Mapping[str, SignalInstance] + A mapping of signal names to SignalInstance instances. + instance : Any, optional + An object to which this `SignalRelay` is bound, by default None + + Examples + -------- + ```python + from psygnal import Signal, SignalRelay, SignalGroup + + class MySignals(SignalGroup): + all_signals: SignalRelay # change the public name of the SignalRelay attribute + sig1 = Signal() + sig2 = Signal() + """ def __init__( self, signals: Mapping[str, SignalInstance], instance: Any = None @@ -180,10 +202,19 @@ def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> N class SignalGroup: """A collection of signals that can be connected to as a single unit. + This class is not intended to be instantiated directly. Instead, it should be + subclassed, and the subclass should define Signal instances as class attributes. + The SignalGroup will then automatically collect these signals and provide a + SignalRelay instance that can be used to connect to all of the signals in the group. + + This class is used in both the EventedModels and the evented dataclass patterns. + See also: `psygnal.SignalGroupDescriptor`, which provides convenient and explicit + way to create a SignalGroup on a dataclass-like class. + Parameters ---------- instance : Any, optional - An object to which this SignalGroup is bound, by default None + An object to which this `SignalGroup` is bound, by default None Attributes ---------- @@ -192,6 +223,23 @@ class SignalGroup: this group. The name of this attribute can be overridden by the user by creating a new name for the SignalRelay annotation on a subclass of SignalGroup e.g. `my_name: SignalRelay` + + Examples + -------- + ```python + from psygnal import Signal, SignalGroup + + class MySignals(SignalGroup): + sig1 = Signal() + sig2 = Signal() + + group = MySignals() + group.all.connect(print) # connect to all signals in the group + + list(group) # ['sig1', 'sig2'] + len(group) # 2 + group.sig1 is group['sig1'] # True + ``` """ _psygnal_signals: ClassVar[Mapping[str, Signal]] @@ -227,7 +275,7 @@ def __init__(self, instance: Any = None) -> None: setattr(self, self._psygnal_relay_name, self._psygnal_relay) def __init_subclass__(cls, strict: bool = False) -> None: - """Finds all Signal instances on the class and add them to `cls._signals_`.""" + """Collects all Signal instances on the class under `cls._psygnal_signals`.""" cls._psygnal_signals = { k: val for k, val in getattr(cls, "__dict__", {}).items() @@ -265,6 +313,7 @@ def __getattr__(self, name: str) -> Any: @property def signals(self) -> Mapping[str, SignalInstance]: + """DEPRECATED: A mapping of signal names to SignalInstance instances.""" # TODO: deprecate this property warnings.warn( "Accessing the `signals` property on a SignalGroup is deprecated. " @@ -276,12 +325,15 @@ def signals(self) -> Mapping[str, SignalInstance]: return self._psygnal_instances def __len__(self) -> int: + """Return the number of signals in the group (not including the relay).""" return len(self._psygnal_signals) def __getitem__(self, item: str) -> SignalInstance: + """Get a signal instance by name.""" return self._psygnal_instances[item] def __iter__(self) -> Iterator[str]: + """Yield the names of all signals in the group.""" return iter(self._psygnal_signals) def __repr__(self) -> str: From 8037e8a7277ad99689325b76be9519ec4694f80c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 14:33:33 -0500 Subject: [PATCH 30/43] refactor: use dict methods --- src/psygnal/_group.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 2ebea0cb..11a75eef 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -131,8 +131,8 @@ def connect_direct( """ def _inner(slot: Callable) -> Callable: - for sig in self._signals: - self._signals[sig].connect( + for sig in self._signals.values(): + sig.connect( slot, check_nargs=check_nargs, check_types=check_types, @@ -146,19 +146,18 @@ def _inner(slot: Callable) -> Callable: def block(self, exclude: Iterable[str | SignalInstance] = ()) -> None: """Block this signal and all emitters from emitting.""" super().block() - for k in self._signals: - v = self._signals[k] - if exclude and v in exclude or k in exclude: + for name, sig in self._signals.items(): + if exclude and sig in exclude or name in exclude: continue - self._sig_was_blocked[k] = v._is_blocked - v.block() + self._sig_was_blocked[name] = sig._is_blocked + sig.block() def unblock(self) -> None: """Unblock this signal and all emitters, allowing them to emit.""" super().unblock() - for k in self._signals: - if not self._sig_was_blocked.pop(k, False): - self._signals[k].unblock() + for name, sig in self._signals.items(): + if not self._sig_was_blocked.pop(name, False): + sig.unblock() def blocked( self, exclude: Iterable[str | SignalInstance] = () @@ -190,8 +189,8 @@ def disconnect(self, slot: Callable | None = None, missing_ok: bool = True) -> N ValueError If `slot` is not connected and `missing_ok` is False. """ - for signal in self._signals: - self._signals[signal].disconnect(slot, missing_ok) + for sig in self._signals.values(): + sig.disconnect(slot, missing_ok) super().disconnect(slot, missing_ok) From d9b32b43540cd55e10ae1bede294fabbae88c4df Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 14:45:10 -0500 Subject: [PATCH 31/43] refactor: use getitem in descriptor --- src/psygnal/_group_descriptor.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/psygnal/_group_descriptor.py b/src/psygnal/_group_descriptor.py index b9a266df..5bbc31c1 100644 --- a/src/psygnal/_group_descriptor.py +++ b/src/psygnal/_group_descriptor.py @@ -263,14 +263,12 @@ def _setattr_and_emit_(self: object, name: str, value: Any) -> None: return super_setattr(self, name, value) group: SignalGroup | None = getattr(self, signal_group_name, None) - signal: SignalInstance | None = getattr(group, name, None) + if not isinstance(group, SignalGroup) or name not in group: + return super_setattr(self, name, value) + # don't emit if the signal doesn't exist or has no listeners - if ( - group is None - or signal is None - or len(signal) < 2 - and not len(group._psygnal_relay) - ): + signal: SignalInstance = group[name] + if len(signal) < 2 and not len(group._psygnal_relay): return super_setattr(self, name, value) with _changes_emitted(self, name, signal): From ff7cd830f520d78c7e7c823efa5bf3a3093ecc1f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 14:52:58 -0500 Subject: [PATCH 32/43] test: add test --- tests/test_evented_decorator.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index 3310f027..4dcbe576 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -7,6 +7,8 @@ import numpy as np import pytest +from psygnal import SignalInstance + try: import pydantic.version @@ -245,3 +247,20 @@ class Foo: assert get_evented_namespace(Foo) == "my_events" assert is_evented(Foo) + + +def test_name_conflicts() -> None: + # https://github.com/pyapp-kit/psygnal/pull/269 + + @evented + @dataclass + class Foo: + name: str + + obj = Foo("foo") + assert obj.name == "foo" + group = obj.events + assert isinstance(group, SignalGroup) + assert "name" in group + assert isinstance(group.name, SignalInstance) + assert group["name"] is group.name From 25096482f078067c8d4bdbf3123eee1f8196925e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 15:13:26 -0500 Subject: [PATCH 33/43] refactor: add explicit line to getattr and contains --- src/psygnal/_group.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 11a75eef..b6d7066b 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -292,6 +292,13 @@ def __init_subclass__(cls, strict: bool = False) -> None: # TODO: change type hint to -> SignalInstance after completing deprecation of # direct access to names on SignalRelay object def __getattr__(self, name: str) -> Any: + # Note, technically these lines aren't actually needed because of the descriptor + # protocol. Accessing a name on the instance will first look in the + # instance's __dict__, and then in the class's __dict__, which + # will call Signal.__get__ and return the SignalInstance. + # these lines are here as a reminder to developers. + if name != "_psygnal_instances" and name in self._psygnal_instances: + return self._psygnal_instances[name] # pragma: no cover if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): warnings.warn( f"Accessing SignalInstance attribute {name!r} on a SignalGroup is " @@ -302,13 +309,6 @@ def __getattr__(self, name: str) -> Any: stacklevel=2, ) return getattr(self._psygnal_relay, name) - # Note, these lines aren't actually needed because of the descriptor - # protocol. Accessing a name on the instance will first look in the - # instance's __dict__, and then in the class's __dict__, which - # will call Signal.__get__ and return the SignalInstance. - # these lines are here as a reminder to developers. - # if name in self._psygnal_instances: - # return self._psygnal_instances[name] raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") @property @@ -336,6 +336,12 @@ def __iter__(self) -> Iterator[str]: """Yield the names of all signals in the group.""" return iter(self._psygnal_signals) + def __contains__(self, item: str) -> bool: + """Return True if the group contains a signal with the given name.""" + # this is redundant with __iter__ and can be removed, but only after + # removing the deprecation warning in __getattr__ + return item in self._psygnal_signals + def __repr__(self) -> str: """Return repr(self).""" name = self.__class__.__name__ From 47bf3aeb6cddba050a1da5b3619a807ef5de68a4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 19:51:03 -0500 Subject: [PATCH 34/43] feat: use name all permanently --- src/psygnal/__init__.py | 3 +- src/psygnal/_group.py | 91 +++++++++++++++++---------------- src/psygnal/_signal.py | 3 +- tests/test_evented_decorator.py | 8 ++- tests/test_group.py | 12 ----- 5 files changed, 56 insertions(+), 61 deletions(-) diff --git a/src/psygnal/__init__.py b/src/psygnal/__init__.py index a37dfd86..24dfcbd7 100644 --- a/src/psygnal/__init__.py +++ b/src/psygnal/__init__.py @@ -33,7 +33,6 @@ "SignalGroup", "SignalGroupDescriptor", "SignalInstance", - "SignalRelay", "throttled", ] @@ -51,7 +50,7 @@ from ._evented_decorator import evented from ._exceptions import EmitLoopError -from ._group import EmissionInfo, SignalGroup, SignalRelay +from ._group import EmissionInfo, SignalGroup from ._group_descriptor import ( SignalGroupDescriptor, get_evented_namespace, diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index b6d7066b..29f7feea 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -26,7 +26,7 @@ from psygnal._signal import Signal, SignalInstance, _SignalBlocker -__all__ = ["EmissionInfo", "SignalGroup", "SignalRelay"] +__all__ = ["EmissionInfo", "SignalGroup"] class EmissionInfo(NamedTuple): @@ -46,8 +46,7 @@ class SignalRelay(SignalInstance): """Special SignalInstance that can be used to connect to all signals in a group. This class will rarely be instantiated by a user (or anything other than a - SignalGroup). But it may be imported and used as a type hint to change the - public name of the SignalRelay attribute on a SignalGroup subclass. + SignalGroup). Parameters ---------- @@ -55,16 +54,6 @@ class SignalRelay(SignalInstance): A mapping of signal names to SignalInstance instances. instance : Any, optional An object to which this `SignalRelay` is bound, by default None - - Examples - -------- - ```python - from psygnal import Signal, SignalRelay, SignalGroup - - class MySignals(SignalGroup): - all_signals: SignalRelay # change the public name of the SignalRelay attribute - sig1 = Signal() - sig2 = Signal() """ def __init__( @@ -205,7 +194,8 @@ class SignalGroup: This class is not intended to be instantiated directly. Instead, it should be subclassed, and the subclass should define Signal instances as class attributes. The SignalGroup will then automatically collect these signals and provide a - SignalRelay instance that can be used to connect to all of the signals in the group. + SignalRelay instance (at `group.all`) that can be used to connect to all of the + signals in the group. This class is used in both the EventedModels and the evented dataclass patterns. See also: `psygnal.SignalGroupDescriptor`, which provides convenient and explicit @@ -220,9 +210,7 @@ class SignalGroup: ---------- all : SignalRelay A special SignalRelay instance that can be used to connect to all signals in - this group. The name of this attribute can be overridden by the user by - creating a new name for the SignalRelay annotation on a subclass of SignalGroup - e.g. `my_name: SignalRelay` + this group. Examples -------- @@ -246,34 +234,18 @@ class MySignals(SignalGroup): _psygnal_instances: dict[str, SignalInstance] _psygnal_uniform: ClassVar[bool] = False - # see comment in __init__. This type annotation can be overriden by subclass - # to change the public name of the SignalRelay attribute - all: SignalRelay - def __init__(self, instance: Any = None) -> None: cls = type(self) if not hasattr(cls, "_psygnal_signals"): # pragma: no cover raise TypeError( - "Cannot instantiate SignalGroup directly. Use a subclass instead." + "Cannot instantiate `SignalGroup` directly. Use a subclass instead." ) - self._psygnal_instances: dict[str, SignalInstance] = { - name: signal.__get__(self, cls) - for name, signal in cls._psygnal_signals.items() + + self._psygnal_instances = { + name: sig.__get__(self, cls) for name, sig in cls._psygnal_signals.items() } self._psygnal_relay = SignalRelay(self._psygnal_instances, instance) - # determine the public name of the signal relay. - # by default, this is "all", but it can be overridden by the user by creating - # a new name for the SignalRelay annotation on a subclass of SignalGroup - # e.g. `my_name: SignalRelay` - self._psygnal_relay_name = "all" - for base in cls.__mro__: - for key, val in getattr(base, "__annotations__", {}).items(): - if val is SignalRelay: - self._psygnal_relay_name = key - break - setattr(self, self._psygnal_relay_name, self._psygnal_relay) - def __init_subclass__(cls, strict: bool = False) -> None: """Collects all Signal instances on the class under `cls._psygnal_signals`.""" cls._psygnal_signals = { @@ -281,6 +253,16 @@ def __init_subclass__(cls, strict: bool = False) -> None: for k, val in getattr(cls, "__dict__", {}).items() if isinstance(val, Signal) } + # + if "all" in cls._psygnal_signals: + warnings.warn( + "Name 'all' is reserved for the SignalRelay. You cannot use this " + "name on to access a SignalInstance on a SignalGroup. (You may still " + "access it at `group['all']`).", + UserWarning, + stacklevel=2, + ) + delattr(cls, "all") cls._psygnal_uniform = _is_uniform(cls._psygnal_signals.values()) if strict and not cls._psygnal_uniform: @@ -289,27 +271,46 @@ def __init_subclass__(cls, strict: bool = False) -> None: ) super().__init_subclass__() + @property + def all(self) -> SignalRelay: + """SignalInstance that can be used to connect to all signals in this group. + + Examples + -------- + ```python + from psygnal import Signal, SignalGroup + + class MySignals(SignalGroup): + sig1 = Signal() + sig2 = Signal() + + group = MySignals() + group.sig2.connect(...) # connect to a single signal by name + group.all.connect(...) # connect to all signals in the group + """ + return self._psygnal_relay + # TODO: change type hint to -> SignalInstance after completing deprecation of # direct access to names on SignalRelay object def __getattr__(self, name: str) -> Any: - # Note, technically these lines aren't actually needed because of the descriptor - # protocol. Accessing a name on the instance will first look in the - # instance's __dict__, and then in the class's __dict__, which + # Note, technically these lines aren't actually needed because of Signal's + # descriptor protocol: Accessing a name on a group instance will first look + # the instance's __dict__, and then in the class's __dict__, which # will call Signal.__get__ and return the SignalInstance. - # these lines are here as a reminder to developers. + # these lines are here as a reminder to developers (and safeguard?). if name != "_psygnal_instances" and name in self._psygnal_instances: return self._psygnal_instances[name] # pragma: no cover + if name != "_psygnal_relay" and hasattr(self._psygnal_relay, name): warnings.warn( f"Accessing SignalInstance attribute {name!r} on a SignalGroup is " - f"deprecated. Access it on the {self._psygnal_relay_name!r} " - f"attribute instead. e.g. `group.{self._psygnal_relay_name}.{name}`. " - "This will be an error in v0.11.", + f"deprecated. Access it on the `group.all` attribute instead. e.g. " + f"`group.all.{name}`. This will be an error in v0.11.", FutureWarning, stacklevel=2, ) return getattr(self._psygnal_relay, name) - raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}") + raise AttributeError(f"{type(self).__name__!r} has no signal named {name!r}") @property def signals(self) -> Mapping[str, SignalInstance]: diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 99ad9bec..d36c02f6 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -188,7 +188,8 @@ class Emitter: # but it allows us to prevent creating a key for this instance (which may # not be hashable or weak-referenceable), and also provides a significant # speedup on attribute access (affecting everything). - setattr(instance, name, signal_instance) + with suppress(AttributeError): # in case it fails + setattr(instance, name, signal_instance) return signal_instance @classmethod diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index 4dcbe576..fad662f5 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -8,6 +8,7 @@ import pytest from psygnal import SignalInstance +from psygnal._group import SignalRelay try: import pydantic.version @@ -256,11 +257,16 @@ def test_name_conflicts() -> None: @dataclass class Foo: name: str + all: bool = False obj = Foo("foo") assert obj.name == "foo" - group = obj.events + with pytest.warns(UserWarning, match="Name 'all' is reserved"): + group = obj.events assert isinstance(group, SignalGroup) assert "name" in group assert isinstance(group.name, SignalInstance) assert group["name"] is group.name + + assert isinstance(group.all, SignalRelay) + assert isinstance(group["all"], SignalInstance) diff --git a/tests/test_group.py b/tests/test_group.py index 6ac33495..ca0bfd05 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -5,7 +5,6 @@ from typing_extensions import Annotated from psygnal import EmissionInfo, Signal, SignalGroup -from psygnal._group import SignalRelay class MyGroup(SignalGroup): @@ -232,14 +231,3 @@ def method(self): ... group.sig1.emit(1) mock.assert_called_with(EmissionInfo(group.sig1, (1,))) mock2.assert_not_called() - - -def test_group_relay_name() -> None: - class T(SignalGroup): - agg: SignalRelay - sig1 = Signal(int) - - t = T() - assert t.agg is t._psygnal_relay - # test getitem - assert t["sig1"] is t.sig1 From 837b26082ad2b4a66196b4cfbcf6f50de245adfa Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 20:03:21 -0500 Subject: [PATCH 35/43] refactor: change attr error strategy --- src/psygnal/_group.py | 12 ++++++++++++ src/psygnal/_signal.py | 3 +-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 29f7feea..df532148 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -290,6 +290,18 @@ class MySignals(SignalGroup): """ return self._psygnal_relay + @all.setter + def all(self, value: Any) -> None: + if isinstance(value, SignalInstance) and value.name == "all": + # this specific case will happen if an evented dataclass field is named + # "all". During SignalGroup.__init__, when the _psygnal_instsances are + # created, `Signal.__get__` method will attempt to set the "all" attribute + # on the group instance. 'all' is a reserved name for the SignalRelay, + # but we've already caught and warned about it in __init_subclass__. + return + # in all other cases, this will raise an AttributeError + raise AttributeError("Cannot set property 'all' on a SignalGroup") + # TODO: change type hint to -> SignalInstance after completing deprecation of # direct access to names on SignalRelay object def __getattr__(self, name: str) -> Any: diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index d36c02f6..99ad9bec 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -188,8 +188,7 @@ class Emitter: # but it allows us to prevent creating a key for this instance (which may # not be hashable or weak-referenceable), and also provides a significant # speedup on attribute access (affecting everything). - with suppress(AttributeError): # in case it fails - setattr(instance, name, signal_instance) + setattr(instance, name, signal_instance) return signal_instance @classmethod From 6d78c8fffbcaa304a058ef5839037d2028acd1e8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 20:16:21 -0500 Subject: [PATCH 36/43] test: add test --- pyproject.toml | 3 ++- src/psygnal/_group.py | 1 + tests/test_evented_decorator.py | 3 +++ tests/test_group.py | 5 ++++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f03d3d8b..864c85e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,6 +135,7 @@ HATCH_BUILD_HOOKS_ENABLE = "1" line-length = 88 target-version = "py37" src = ["src", "tests"] +[tool.ruff.lint] select = [ "E", # style errors "F", # flakes @@ -162,7 +163,7 @@ ignore = [ "RUF009", # Do not perform function call in dataclass defaults ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/*.py" = ["D", "S", "RUF012"] "benchmarks/*.py" = ["D", "RUF012"] diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index df532148..c950fdf8 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -322,6 +322,7 @@ def __getattr__(self, name: str) -> Any: stacklevel=2, ) return getattr(self._psygnal_relay, name) + raise AttributeError(f"{type(self).__name__!r} has no signal named {name!r}") @property diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index fad662f5..b03ed8d5 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -270,3 +270,6 @@ class Foo: assert isinstance(group.all, SignalRelay) assert isinstance(group["all"], SignalInstance) + + with pytest.raises(AttributeError): # it's not writeable + group.all = SignalRelay({}) diff --git a/tests/test_group.py b/tests/test_group.py index ca0bfd05..0c6b6642 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -21,6 +21,9 @@ def test_signal_group(): assert repr(group) == "" + with pytest.raises(AttributeError, match="'MyGroup' has no signal named 'sig3'"): + group.sig3 # noqa: B018 + def test_uniform_group(): """In a uniform group, all signals must have the same signature.""" @@ -217,7 +220,7 @@ def method(self): ... # with pytest.warns(UserWarning, match="does not copy connected weakly"): group2 = deepcopy(group) - assert not len(group2._psygnal_relay) + assert not len(group2.all) mock = Mock() mock2 = Mock() group.all.connect(mock) From 61b0ef8a525d7c6f3f03b2548e6b70c5e7534596 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 20:26:14 -0500 Subject: [PATCH 37/43] fix: change setattr strategy --- src/psygnal/_group.py | 12 ------------ src/psygnal/_signal.py | 20 +++++++++++++++++++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index c950fdf8..477897d6 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -290,18 +290,6 @@ class MySignals(SignalGroup): """ return self._psygnal_relay - @all.setter - def all(self, value: Any) -> None: - if isinstance(value, SignalInstance) and value.name == "all": - # this specific case will happen if an evented dataclass field is named - # "all". During SignalGroup.__init__, when the _psygnal_instsances are - # created, `Signal.__get__` method will attempt to set the "all" attribute - # on the group instance. 'all' is a reserved name for the SignalRelay, - # but we've already caught and warned about it in __init_subclass__. - return - # in all other cases, this will raise an AttributeError - raise AttributeError("Cannot set property 'all' on a SignalGroup") - # TODO: change type hint to -> SignalInstance after completing deprecation of # direct access to names on SignalRelay object def __getattr__(self, name: str) -> Any: diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index 99ad9bec..e685ea8a 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -188,7 +188,25 @@ class Emitter: # but it allows us to prevent creating a key for this instance (which may # not be hashable or weak-referenceable), and also provides a significant # speedup on attribute access (affecting everything). - setattr(instance, name, signal_instance) + # (note, this is the same mechanism used in the `cached_property` decorator) + try: + setattr(instance, name, signal_instance) + except AttributeError as e: + from ._group import SignalGroup + + if name == "all" and isinstance(instance, SignalGroup): + # this specific case will happen if an evented dataclass field is named + # "all". 'all' is a reserved name for the SignalRelay, but we've + # already caught and warned about it in SignalGroup.__init_subclass__. + pass + else: + # otherwise, + raise AttributeError( + "An attempt to cache a SignalInstance on instance " + f"{instance} failed. Please report this with your use case at " + "https://github.com/pyapp-kit/psygnal/issues." + ) from e + return signal_instance @classmethod From 5b67dc07a28b04b21d8491bd2b5d06e0299ee592 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 15 Feb 2024 20:33:28 -0500 Subject: [PATCH 38/43] refactor: pragma --- src/psygnal/_signal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/psygnal/_signal.py b/src/psygnal/_signal.py index e685ea8a..6ef38e8f 100644 --- a/src/psygnal/_signal.py +++ b/src/psygnal/_signal.py @@ -200,8 +200,8 @@ class Emitter: # already caught and warned about it in SignalGroup.__init_subclass__. pass else: - # otherwise, - raise AttributeError( + # otherwise, give an informative error message + raise AttributeError( # pragma: no cover "An attempt to cache a SignalInstance on instance " f"{instance} failed. Please report this with your use case at " "https://github.com/pyapp-kit/psygnal/issues." From 60b73d01545015fcf41451a5f10ed994401edd96 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 16 Feb 2024 08:24:06 -0500 Subject: [PATCH 39/43] feat: add conflicts warning --- src/psygnal/_group.py | 11 ++++++++++- tests/test_group.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 477897d6..7935b75a 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -253,7 +253,16 @@ def __init_subclass__(cls, strict: bool = False) -> None: for k, val in getattr(cls, "__dict__", {}).items() if isinstance(val, Signal) } - # + + if conflicts := {k for k in cls._psygnal_signals if k.startswith("_psygnal")}: + warnings.warn( + "Signal names may not begin with '_psygnal'. " + f"Skipping signals: {conflicts}", + stacklevel=2, + ) + for key in conflicts: + del cls._psygnal_signals[key] + if "all" in cls._psygnal_signals: warnings.warn( "Name 'all' is reserved for the SignalRelay. You cannot use this " diff --git a/tests/test_group.py b/tests/test_group.py index 0c6b6642..b02672d4 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -234,3 +234,14 @@ def method(self): ... group.sig1.emit(1) mock.assert_called_with(EmissionInfo(group.sig1, (1,))) mock2.assert_not_called() + + +def test_group_conflicts() -> None: + with pytest.warns(UserWarning, match="Signal names may not begin with '_psygnal'"): + + class MyGroup(SignalGroup): + _psygnal_thing = Signal(int) + other_signal = Signal(int) + + assert "_psygnal_thing" not in MyGroup._psygnal_signals + assert "other_signal" in MyGroup._psygnal_signals From 6d49dfcd9d7e53aac9b2559dcec485d4ede74864 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 16 Feb 2024 08:36:44 -0500 Subject: [PATCH 40/43] test: more tests and warnings --- src/psygnal/_group.py | 14 ++++++++++++-- tests/test_evented_decorator.py | 17 ++++++++++++++++- tests/test_group.py | 10 +++++----- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 7935b75a..97a34f65 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -88,7 +88,7 @@ def connect_direct( """Connect `slot` to be called whenever *any* Signal in this group is emitted. Params are the same as `psygnal.SignalInstance.connect`. It's probably - best to check whether `self.is_uniform()` + best to check whether all signals are uniform (i.e. have the same signature). Parameters ---------- @@ -358,10 +358,20 @@ def __repr__(self) -> str: name = self.__class__.__name__ return f"" + @classmethod + def psygnals_uniform(cls) -> bool: + """Return true if all signals in the group have the same signature.""" + return cls._psygnal_uniform + @classmethod def is_uniform(cls) -> bool: """Return true if all signals in the group have the same signature.""" - # TODO: Deprecate this meth? + warnings.warn( + "The `is_uniform` method on SignalGroup is deprecated. Use " + "`psygnals_uniform` instead. This will be an error in v0.11.", + FutureWarning, + stacklevel=2, + ) return cls._psygnal_uniform def __deepcopy__(self, memo: dict[int, Any]) -> SignalGroup: diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index b03ed8d5..5009732e 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -252,24 +252,39 @@ class Foo: def test_name_conflicts() -> None: # https://github.com/pyapp-kit/psygnal/pull/269 + from dataclasses import field @evented @dataclass class Foo: name: str all: bool = False + is_uniform: bool = True + signals: list = field(default_factory=list) + _psygnal_signals: str = "signals" obj = Foo("foo") assert obj.name == "foo" with pytest.warns(UserWarning, match="Name 'all' is reserved"): group = obj.events assert isinstance(group, SignalGroup) + assert "name" in group assert isinstance(group.name, SignalInstance) assert group["name"] is group.name + assert "is_uniform" in group and isinstance(group.is_uniform, SignalInstance) + assert "signals" in group and isinstance(group.signals, SignalInstance) + + # this one is protected and will be warned about + assert "_psygnal_signals" not in group + + # group.all is always a relay assert isinstance(group.all, SignalRelay) - assert isinstance(group["all"], SignalInstance) + # getitem returns the signal + assert "all" in group and isinstance(group["all"], SignalInstance) with pytest.raises(AttributeError): # it's not writeable group.all = SignalRelay({}) + + assert not group.psygnals_uniform() diff --git a/tests/test_group.py b/tests/test_group.py index b02672d4..16c89048 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -13,9 +13,9 @@ class MyGroup(SignalGroup): def test_signal_group(): - assert not MyGroup.is_uniform() + assert not MyGroup.psygnals_uniform() group = MyGroup() - assert not group.is_uniform() + assert not group.psygnals_uniform() assert list(group) == ["sig1", "sig2"] # testing __iter__ assert group.sig1 is group["sig1"] @@ -32,9 +32,9 @@ class MyStrictGroup(SignalGroup, strict=True): sig1 = Signal(int) sig2 = Signal(int) - assert MyStrictGroup.is_uniform() + assert MyStrictGroup.psygnals_uniform() group = MyStrictGroup() - assert group.is_uniform() + assert group.psygnals_uniform() assert set(group) == {"sig1", "sig2"} with pytest.raises(TypeError) as e: @@ -53,7 +53,7 @@ class MyGroup(SignalGroup): sig1 = Signal(Annotated[int, {"a": 1}]) # type: ignore sig2 = Signal(Annotated[float, {"b": 1}]) # type: ignore - assert not MyGroup.is_uniform() + assert not MyGroup.psygnals_uniform() with pytest.raises(TypeError): From ff0bae7831118649432247581a28261b29749282 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 16 Feb 2024 08:54:06 -0500 Subject: [PATCH 41/43] test: more tests and deprecations --- src/psygnal/_group.py | 6 ++++++ tests/test_evented_decorator.py | 14 +++++++++++++- tests/test_group.py | 4 ++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/psygnal/_group.py b/src/psygnal/_group.py index 97a34f65..ecce9c0f 100644 --- a/src/psygnal/_group.py +++ b/src/psygnal/_group.py @@ -273,6 +273,12 @@ def __init_subclass__(cls, strict: bool = False) -> None: ) delattr(cls, "all") + if "psygnals_uniform" in cls._psygnal_signals: + raise NameError( + "Name 'psygnals_uniform' is reserved. You cannot use this " + "name as a signal on a SignalGroup" + ) + cls._psygnal_uniform = _is_uniform(cls._psygnal_signals.values()) if strict and not cls._psygnal_uniform: raise TypeError( diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index 5009732e..d2f65d33 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -267,6 +267,7 @@ class Foo: assert obj.name == "foo" with pytest.warns(UserWarning, match="Name 'all' is reserved"): group = obj.events + assert isinstance(group, SignalGroup) assert "name" in group @@ -281,10 +282,21 @@ class Foo: # group.all is always a relay assert isinstance(group.all, SignalRelay) + # getitem returns the signal assert "all" in group and isinstance(group["all"], SignalInstance) + assert not isinstance(group["all"], SignalRelay) with pytest.raises(AttributeError): # it's not writeable group.all = SignalRelay({}) - assert not group.psygnals_uniform() + assert group.psygnals_uniform() is False + + @evented + @dataclass + class Foo2: + psygnals_uniform: bool = True + + obj2 = Foo2() + with pytest.raises(NameError, match="Name 'psygnals_uniform' is reserved"): + _ = obj2.events diff --git a/tests/test_group.py b/tests/test_group.py index 16c89048..74c945e2 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -14,6 +14,10 @@ class MyGroup(SignalGroup): def test_signal_group(): assert not MyGroup.psygnals_uniform() + with pytest.warns( + FutureWarning, match="The `is_uniform` method on SignalGroup is deprecated" + ): + assert not MyGroup.is_uniform() group = MyGroup() assert not group.psygnals_uniform() assert list(group) == ["sig1", "sig2"] # testing __iter__ From 6dd25bb9f171b2725eb9dde625a149f88f75a0f0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 16 Feb 2024 08:58:14 -0500 Subject: [PATCH 42/43] test: split test --- tests/test_evented_decorator.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index d2f65d33..346f054c 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -261,7 +261,6 @@ class Foo: all: bool = False is_uniform: bool = True signals: list = field(default_factory=list) - _psygnal_signals: str = "signals" obj = Foo("foo") assert obj.name == "foo" @@ -277,9 +276,6 @@ class Foo: assert "is_uniform" in group and isinstance(group.is_uniform, SignalInstance) assert "signals" in group and isinstance(group.signals, SignalInstance) - # this one is protected and will be warned about - assert "_psygnal_signals" not in group - # group.all is always a relay assert isinstance(group.all, SignalRelay) @@ -300,3 +296,14 @@ class Foo2: obj2 = Foo2() with pytest.raises(NameError, match="Name 'psygnals_uniform' is reserved"): _ = obj2.events + + @evented + @dataclass + class Foo3: + _psygnal_signals: str = "signals" + + obj3 = Foo3() + with pytest.warns(UserWarning, match="Signal names may not begin with '_psygnal'"): + group3 = obj3.events + + assert "_psygnal_signals" not in group3 From dbbd2fa473793ef0b26b2008ec68534a9d10aea7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 16 Feb 2024 09:13:16 -0500 Subject: [PATCH 43/43] test: add field --- tests/test_evented_decorator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_evented_decorator.py b/tests/test_evented_decorator.py index 346f054c..178e1560 100644 --- a/tests/test_evented_decorator.py +++ b/tests/test_evented_decorator.py @@ -300,6 +300,7 @@ class Foo2: @evented @dataclass class Foo3: + field: int = 1 _psygnal_signals: str = "signals" obj3 = Foo3()