From 9bc4890b7b54f6f32dd64669b9c026f2da7de00e Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 26 Apr 2024 17:12:38 -0700 Subject: [PATCH] simplify weakref handling Use the built-in `WeakMethod` and `inspect.ismethod` instead of a custom implementation. Build a cleanup callback from an id, rather than storing the id on an `annnotatable_weakref` subclass. Simplify the private `Symbol` class implementation. Use the built-in `cached_property` instead of a custom implementation. Use positional-only argument for `send(sender)` instead of `*args` hack. This simplified the type annotations, which pass with pyright as well now. --- CHANGES.rst | 2 + src/blinker/__init__.py | 12 +- src/blinker/_saferef.py | 247 ------------------ src/blinker/_utilities.py | 117 ++------- src/blinker/base.py | 273 ++++++++++---------- tests/test_saferef.py | 118 --------- tests/{test_utilities.py => test_symbol.py} | 10 +- tox.ini | 4 +- 8 files changed, 175 insertions(+), 608 deletions(-) delete mode 100644 src/blinker/_saferef.py delete mode 100644 tests/test_saferef.py rename tests/{test_utilities.py => test_symbol.py} (71%) diff --git a/CHANGES.rst b/CHANGES.rst index 30a4994..a02b792 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,8 @@ Unreleased the next version. - Show a deprecation warning for the deprecated global ``receiver_connected`` signal and specify that it will be removed in the next version. +- Greatly simplify how the library uses weakrefs. This is a significant change + internally but should not affect any public API. :pr:`144` Version 1.7.0 diff --git a/src/blinker/__init__.py b/src/blinker/__init__.py index 41bf33a..a279972 100644 --- a/src/blinker/__init__.py +++ b/src/blinker/__init__.py @@ -2,12 +2,12 @@ import typing as t -from blinker.base import ANY -from blinker.base import NamedSignal -from blinker.base import Namespace -from blinker.base import Signal -from blinker.base import signal -from blinker.base import WeakNamespace +from .base import ANY +from .base import NamedSignal +from .base import Namespace +from .base import Signal +from .base import signal +from .base import WeakNamespace __all__ = [ "ANY", diff --git a/src/blinker/_saferef.py b/src/blinker/_saferef.py deleted file mode 100644 index 8a74100..0000000 --- a/src/blinker/_saferef.py +++ /dev/null @@ -1,247 +0,0 @@ -# extracted from Louie, http://pylouie.org/ -# updated for Python 3 -# -# Copyright (c) 2006 Patrick K. O'Brien, Mike C. Fletcher, -# Matthew R. Scott -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are -# met: -# -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# * Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials provided -# with the distribution. -# -# * Neither the name of the nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -from __future__ import annotations - -import operator -import sys -import traceback -import typing as t -import weakref -from types import MethodType - -get_self = operator.attrgetter("__self__") -get_func = operator.attrgetter("__func__") - -if t.TYPE_CHECKING: - import typing_extensions as te - - -def safe_ref( - target: t.Callable[..., t.Any], - on_delete: t.Callable[[weakref.ref[t.Any]], None] | None = None, -) -> weakref.ref[t.Any] | BoundMethodWeakref | None: - """Return a *safe* weak reference to a callable target. - - - ``target``: The object to be weakly referenced, if it's a bound - method reference, will create a BoundMethodWeakref, otherwise - creates a simple weakref. - - - ``on_delete``: If provided, will have a hard reference stored to - the callable to be called after the safe reference goes out of - scope with the reference object, (either a weakref or a - BoundMethodWeakref) as argument. - """ - try: - im_self = get_self(target) - except AttributeError: - if on_delete is not None: - return weakref.ref(target, on_delete) - - return weakref.ref(target) - - if im_self is not None: - # Turn a bound method into a BoundMethodWeakref instance. - # Keep track of these instances for lookup by disconnect(). - assert hasattr(target, "im_func") or hasattr(target, "__func__"), ( - f"safe_ref target {target!r} has im_self, but no im_func, " - "don't know how to create reference" - ) - return BoundMethodWeakref( - target=target, # type: ignore[arg-type] - on_delete=on_delete, # type: ignore[arg-type] - ) - - return None - - -class BoundMethodWeakref: - """'Safe' and reusable weak references to instance methods. - - BoundMethodWeakref objects provide a mechanism for referencing a - bound method without requiring that the method object itself - (which is normally a transient object) is kept alive. Instead, - the BoundMethodWeakref object keeps weak references to both the - object and the function which together define the instance method. - - Attributes: - - - ``key``: The identity key for the reference, calculated by the - class's calculate_key method applied to the target instance method. - - - ``deletion_methods``: Sequence of callable objects taking single - argument, a reference to this object which will be called when - *either* the target object or target function is garbage - collected (i.e. when this object becomes invalid). These are - specified as the on_delete parameters of safe_ref calls. - - - ``weak_self``: Weak reference to the target object. - - - ``weak_func``: Weak reference to the target function. - - Class Attributes: - - - ``_all_instances``: Class attribute pointing to all live - BoundMethodWeakref objects indexed by the class's - calculate_key(target) method applied to the target objects. - This weak value dictionary is used to short-circuit creation so - that multiple references to the same (object, function) pair - produce the same BoundMethodWeakref instance. - """ - - _all_instances: weakref.WeakValueDictionary[tuple[int, int], BoundMethodWeakref] = ( - weakref.WeakValueDictionary() - ) - - def __new__( - cls, - target: MethodType, - on_delete: t.Callable[[BoundMethodWeakref], None] | None = None, - *arguments: t.Any, - **named: t.Any, - ) -> BoundMethodWeakref: - """Create new instance or return current instance. - - Basically this method of construction allows us to - short-circuit creation of references to already-referenced - instance methods. The key corresponding to the target is - calculated, and if there is already an existing reference, - that is returned, with its deletion_methods attribute updated. - Otherwise the new instance is created and registered in the - table of already-referenced methods. - """ - key = cls.calculate_key(target) - current = cls._all_instances.get(key) - if current is not None: - current.deletion_methods.append(on_delete) - return current - else: - base = super().__new__(cls) - cls._all_instances[key] = base - return base - - def __init__( - self, target: MethodType, on_delete: t.Callable[[te.Self], None] | None = None - ) -> None: - """Return a weak-reference-like instance for a bound method. - - - ``target``: The instance-method target for the weak reference, - must have im_self and im_func attributes and be - reconstructable via the following, which is true of built-in - instance methods:: - - target.im_func.__get__( target.im_self ) - - - ``on_delete``: Optional callback which will be called when - this weak reference ceases to be valid (i.e. either the - object or the function is garbage collected). Should take a - single argument, which will be passed a pointer to this - object. - """ - - def remove(weak: weakref.ref[t.Any]) -> None: - """Set self.isDead to True when method or instance is destroyed.""" - methods = self.deletion_methods[:] - del self.deletion_methods[:] - try: - del self.__class__._all_instances[self.key] - except KeyError: - pass - for function in methods: - try: - if callable(function): - function(self) - except Exception: - try: - traceback.print_exc() - except AttributeError: - e = sys.exc_info()[1] - print( - f"Exception during saferef {self} " - f"cleanup function {function}: {e}" - ) - - self.deletion_methods = [on_delete] - self.key = self.calculate_key(target) - im_self = get_self(target) - im_func = get_func(target) - self.weak_self = weakref.ref(im_self, remove) - self.weak_func = weakref.ref(im_func, remove) - self.self_name = str(im_self) - self.func_name = str(im_func.__name__) - - @classmethod - def calculate_key(cls, target: t.Callable[..., t.Any]) -> tuple[int, int]: - """Calculate the reference key for this reference. - - Currently this is a two-tuple of the id()'s of the target - object and the target function respectively. - """ - return id(get_self(target)), id(get_func(target)) - - def __str__(self) -> str: - """Give a friendly representation of the object.""" - return f"{self.__class__.__name__}({self.self_name}.{self.func_name})" - - __repr__ = __str__ - - def __hash__(self) -> int: - return hash((self.self_name, self.key)) - - def __bool__(self) -> bool: - """Whether we are still a valid reference.""" - return self() is not None - - def __eq__(self, other: t.Any) -> bool: - """Compare with another reference.""" - if not isinstance(other, type(self)): - return type(self) == type(other) - return self.key == other.key - - def __call__(self) -> MethodType | None: - """Return a strong reference to the bound method. - - If the target cannot be retrieved, then will return None, - otherwise returns a bound instance method for our object and - function. - - Note: You may call this method any number of times, as it does - not invalidate the reference. - """ - target = self.weak_self() - if target is not None: - function = self.weak_func() - if function is not None: - return function.__get__(target) # type: ignore[no-any-return] - return None diff --git a/src/blinker/_utilities.py b/src/blinker/_utilities.py index 9a1c82a..784ba4e 100644 --- a/src/blinker/_utilities.py +++ b/src/blinker/_utilities.py @@ -1,115 +1,52 @@ from __future__ import annotations +import inspect import typing as t from weakref import ref +from weakref import WeakMethod -from blinker._saferef import BoundMethodWeakref +T = t.TypeVar("T") -if t.TYPE_CHECKING: - pass -IdentityType = t.Union[t.Tuple[int, int], str, int] +class Symbol: + """A constant symbol, nicer than ``object()``. Repeated calls return the + same instance. - -class _symbol: - def __init__(self, name: str) -> None: - """Construct a new named symbol.""" - self.__name__ = name - self.name = name - - def __reduce__(self) -> tuple[t.Any, ...]: - return symbol, (self.name,) - - def __repr__(self) -> str: - return self.name - - -_symbol.__name__ = "symbol" - - -class symbol: - """A constant symbol. - - >>> symbol('foo') is symbol('foo') + >>> Symbol('foo') is Symbol('foo') True - >>> symbol('foo') + >>> Symbol('foo') foo - - A slight refinement of the MAGICCOOKIE=object() pattern. The primary - advantage of symbol() is its repr(). They are also singletons. - - Repeated calls of symbol('name') will all return the same instance. - """ - symbols: dict[str, _symbol] = {} - name: str + symbols: t.ClassVar[dict[str, Symbol]] = {} - def __new__(cls, name: str) -> _symbol: # type: ignore[misc] - try: + def __new__(cls, name: str) -> Symbol: + if name in cls.symbols: return cls.symbols[name] - except KeyError: - return cls.symbols.setdefault(name, _symbol(name)) - -def hashable_identity(obj: object) -> IdentityType: - if hasattr(obj, "__func__"): - return (id(obj.__func__), id(obj.__self__)) # type: ignore[attr-defined] - elif hasattr(obj, "im_func"): - return (id(obj.im_func), id(obj.im_self)) # type: ignore[attr-defined] - elif isinstance(obj, (int, str)): + obj = super().__new__(cls) + cls.symbols[name] = obj return obj - else: - return id(obj) - - -WeakTypes = (ref, BoundMethodWeakref) + def __init__(self, name: str) -> None: + self.name = name -class annotatable_weakref(ref): # type: ignore[type-arg] - """A weakref.ref that supports custom instance attributes.""" - - receiver_id: IdentityType | None - sender_id: IdentityType | None - + def __repr__(self) -> str: + return self.name -def reference( - object: t.Any, - callback: t.Callable[[annotatable_weakref], None] | None = None, - **annotations: t.Any, -) -> annotatable_weakref: - """Return an annotated weak ref.""" - if callable(object): - weak = callable_reference(object, callback) - else: - weak = annotatable_weakref(object, callback) # type: ignore[arg-type] - for key, value in annotations.items(): - setattr(weak, key, value) - return weak + def __getnewargs__(self) -> tuple[t.Any]: + return (self.name,) -def callable_reference( - object: t.Callable[..., t.Any], - callback: t.Callable[[annotatable_weakref], None] | None = None, -) -> annotatable_weakref: - """Return an annotated weak ref, supporting bound instance methods.""" - if hasattr(object, "im_self") and object.im_self is not None: - return BoundMethodWeakref(target=object, on_delete=callback) # type: ignore[arg-type, return-value] - elif hasattr(object, "__self__") and object.__self__ is not None: - return BoundMethodWeakref(target=object, on_delete=callback) # type: ignore[arg-type, return-value] - return annotatable_weakref(object, callback) # type: ignore[arg-type] +def make_id(obj: object) -> t.Hashable: + if inspect.ismethod(obj): + return id(obj.__func__), id(obj.__self__) + return id(obj) -class lazy_property: - """A @property that is only evaluated once.""" - def __init__(self, deferred: t.Callable[[t.Any], t.Any]) -> None: - self._deferred = deferred - self.__doc__ = deferred.__doc__ +def make_ref(obj: T, callback: t.Callable[[ref[T]], None] | None = None) -> ref[T]: + if inspect.ismethod(obj): + return WeakMethod(obj, callback) # type: ignore[arg-type, return-value] - def __get__(self, obj: t.Any | None, cls: type[t.Any]) -> t.Any: - if obj is None: - return self - value = self._deferred(obj) - setattr(obj, self._deferred.__name__, value) - return value + return ref(obj, callback) diff --git a/src/blinker/base.py b/src/blinker/base.py index b028051..933cd00 100644 --- a/src/blinker/base.py +++ b/src/blinker/base.py @@ -5,44 +5,40 @@ each manages its own receivers and message emission. The :func:`signal` function provides singleton behavior for named signals. - """ from __future__ import annotations import typing as t import warnings +import weakref from collections import defaultdict from contextlib import contextmanager +from functools import cached_property from inspect import iscoroutinefunction from weakref import WeakValueDictionary -from blinker._utilities import annotatable_weakref -from blinker._utilities import hashable_identity -from blinker._utilities import IdentityType -from blinker._utilities import lazy_property -from blinker._utilities import reference -from blinker._utilities import symbol -from blinker._utilities import WeakTypes +from ._utilities import make_id +from ._utilities import make_ref +from ._utilities import Symbol if t.TYPE_CHECKING: import typing_extensions as te - T_callable = t.TypeVar("T_callable", bound=t.Callable[..., t.Any]) - + F = t.TypeVar("F", bound=t.Callable[..., t.Any]) T = t.TypeVar("T") P = te.ParamSpec("P") - AsyncWrapperType = t.Callable[[t.Callable[P, t.Awaitable[T]]], t.Callable[P, T]] - SyncWrapperType = t.Callable[[t.Callable[P, T]], t.Callable[P, t.Awaitable[T]]] + class PAsyncWrapper(t.Protocol): + def __call__(self, f: t.Callable[P, t.Awaitable[T]]) -> t.Callable[P, T]: ... -ANY = symbol("ANY") -ANY.__doc__ = 'Token for "any sender".' -ANY_ID = 0 + class PSyncWrapper(t.Protocol): + def __call__(self, f: t.Callable[P, T]) -> t.Callable[P, t.Awaitable[T]]: ... -# NOTE: We need a reference to cast for use in weakref callbacks otherwise -# t.cast may have already been set to None during finalization. -cast = t.cast + +ANY = Symbol("ANY") +"""Token for "any sender".""" +ANY_ID = 0 class Signal: @@ -52,9 +48,9 @@ class Signal: #: without an additional import. ANY = ANY - set_class: type[set[IdentityType]] = set + set_class: type[set[t.Any]] = set - @lazy_property + @cached_property def receiver_connected(self) -> Signal: """Emitted after each :meth:`connect`. @@ -62,11 +58,10 @@ def receiver_connected(self) -> Signal: arguments are passed through: *receiver*, *sender*, and *weak*. .. versionadded:: 1.2 - """ return Signal(doc="Emitted after a receiver connects.") - @lazy_property + @cached_property def receiver_disconnected(self) -> Signal: """Emitted after :meth:`disconnect`. @@ -86,18 +81,16 @@ def receiver_disconnected(self) -> Signal: callback on weak receivers and senders. .. versionadded:: 1.2 - """ return Signal(doc="Emitted after a receiver disconnects.") def __init__(self, doc: str | None = None) -> None: """ - :param doc: optional. If provided, will be assigned to the signal's - __doc__ attribute. - + :param doc: Set the instance's ``__doc__`` attribute for documentation. """ if doc: self.__doc__ = doc + #: A mapping of connected receivers. #: #: The values of this mapping are not meaningful outside of the @@ -105,20 +98,14 @@ def __init__(self, doc: str | None = None) -> None: #: of the mapping is useful as an extremely efficient check to see if #: any receivers are connected to the signal. self.receivers: dict[ - IdentityType, t.Callable[[], t.Any] | annotatable_weakref + t.Any, weakref.ref[t.Callable[..., t.Any]] | t.Callable[..., t.Any] ] = {} - self.is_muted = False - self._by_receiver: dict[IdentityType, set[IdentityType]] = defaultdict( - self.set_class - ) - self._by_sender: dict[IdentityType, set[IdentityType]] = defaultdict( - self.set_class - ) - self._weak_senders: dict[IdentityType, annotatable_weakref] = {} + self.is_muted: bool = False + self._by_receiver: dict[t.Any, set[t.Any]] = defaultdict(self.set_class) + self._by_sender: dict[t.Any, set[t.Any]] = defaultdict(self.set_class) + self._weak_senders: dict[t.Any, weakref.ref[t.Any]] = {} - def connect( - self, receiver: T_callable, sender: t.Any = ANY, weak: bool = True - ) -> T_callable: + def connect(self, receiver: F, sender: t.Any = ANY, weak: bool = True) -> F: """Connect *receiver* to signal events sent by *sender*. :param receiver: A callable. Will be invoked by :meth:`send` with @@ -135,60 +122,51 @@ def connect( :param weak: If true, the Signal will hold a weakref to *receiver* and automatically disconnect when *receiver* goes out of scope or is garbage collected. Defaults to True. - """ - receiver_id = hashable_identity(receiver) - receiver_ref: T_callable | annotatable_weakref + receiver_id = make_id(receiver) + sender_id = ANY_ID if sender is ANY else make_id(sender) if weak: - receiver_ref = reference(receiver, self._cleanup_receiver) - receiver_ref.receiver_id = receiver_id - else: - receiver_ref = receiver - sender_id: IdentityType - if sender is ANY: - sender_id = ANY_ID + self.receivers[receiver_id] = make_ref( + receiver, self._make_cleanup_receiver(receiver_id) + ) else: - sender_id = hashable_identity(sender) + self.receivers[receiver_id] = receiver - self.receivers.setdefault(receiver_id, receiver_ref) self._by_sender[sender_id].add(receiver_id) self._by_receiver[receiver_id].add(sender_id) - del receiver_ref if sender is not ANY and sender_id not in self._weak_senders: - # wire together a cleanup for weakref-able senders + # store a cleanup for weakref-able senders try: - sender_ref = reference(sender, self._cleanup_sender) - sender_ref.sender_id = sender_id + self._weak_senders[sender_id] = make_ref( + sender, self._make_cleanup_sender(sender_id) + ) except TypeError: pass - else: - self._weak_senders.setdefault(sender_id, sender_ref) - del sender_ref - # broadcast this connection. if receivers raise, disconnect. if "receiver_connected" in self.__dict__ and self.receiver_connected.receivers: try: self.receiver_connected.send( self, receiver=receiver, sender=sender, weak=weak ) - except TypeError as e: + except TypeError: + # TODO no explanation or test for this self.disconnect(receiver, sender) - raise e + raise + if _receiver_connected.receivers and self is not _receiver_connected: try: _receiver_connected.send( self, receiver_arg=receiver, sender_arg=sender, weak_arg=weak ) - except TypeError as e: + except TypeError: self.disconnect(receiver, sender) - raise e + raise + return receiver - def connect_via( - self, sender: t.Any, weak: bool = False - ) -> t.Callable[[T_callable], T_callable]: + def connect_via(self, sender: t.Any, weak: bool = False) -> t.Callable[[F], F]: """Connect the decorated function as a receiver for *sender*. :param sender: Any object or :obj:`ANY`. The decorated function @@ -207,10 +185,9 @@ def connect_via( .. versionadded:: 1.1 - """ - def decorator(fn: T_callable) -> T_callable: + def decorator(fn: F) -> F: self.connect(fn, sender, weak) return fn @@ -240,6 +217,7 @@ def connected_to( """ self.connect(receiver, sender=sender, weak=False) + try: yield None finally: @@ -251,10 +229,9 @@ def muted(self) -> t.Generator[None, None, None]: Useful for test purposes. """ self.is_muted = True + try: yield None - except Exception as e: - raise e finally: self.is_muted = False @@ -281,8 +258,10 @@ def temporarily_connected_to( def send( self, - *sender: t.Any, - _async_wrapper: AsyncWrapperType[t.Any, t.Any] | None = None, + sender: t.Any | None = None, + /, + *, + _async_wrapper: PAsyncWrapper | None = None, **kwargs: t.Any, ) -> list[tuple[t.Callable[..., t.Any], t.Any]]: """Emit this signal on behalf of *sender*, passing on ``kwargs``. @@ -300,21 +279,27 @@ def send( if self.is_muted: return [] - sender = self._extract_sender(sender) results = [] + for receiver in self.receivers_for(sender): if iscoroutinefunction(receiver): if _async_wrapper is None: - raise RuntimeError("Cannot send to a coroutine function") - receiver = _async_wrapper(receiver) - result = receiver(sender, **kwargs) + raise RuntimeError("Cannot send to a coroutine function.") + + result = _async_wrapper(receiver)(sender, **kwargs) + else: + result = receiver(sender, **kwargs) + results.append((receiver, result)) + return results async def send_async( self, - *sender: t.Any, - _sync_wrapper: SyncWrapperType[t.Any, t.Any] | None = None, + sender: t.Any | None = None, + /, + *, + _sync_wrapper: PSyncWrapper | None = None, **kwargs: t.Any, ) -> list[tuple[t.Callable[..., t.Any], t.Any]]: """Emit this signal on behalf of *sender*, passing on ``kwargs``. @@ -332,39 +317,20 @@ async def send_async( if self.is_muted: return [] - sender = self._extract_sender(sender) results = [] + for receiver in self.receivers_for(sender): if not iscoroutinefunction(receiver): if _sync_wrapper is None: - raise RuntimeError("Cannot send to a non-coroutine function") - receiver = _sync_wrapper(receiver) - result = await receiver(sender, **kwargs) - results.append((receiver, result)) - return results + raise RuntimeError("Cannot send to a non-coroutine function.") - def _extract_sender(self, sender: t.Any) -> t.Any: - if not self.receivers: - # Ensure correct signature even on no-op sends, disable with -O - # for lowest possible cost. - if __debug__ and sender and len(sender) > 1: - raise TypeError( - f"send() accepts only one positional argument, {len(sender)} given" - ) - return [] + result = await _sync_wrapper(receiver)(sender, **kwargs) + else: + result = await receiver(sender, **kwargs) - # Using '*sender' rather than 'sender=None' allows 'sender' to be - # used as a keyword argument- i.e. it's an invisible name in the - # function signature. - if len(sender) == 0: - sender = None - elif len(sender) > 1: - raise TypeError( - f"send() accepts only one positional argument, {len(sender)} given" - ) - else: - sender = sender[0] - return sender + results.append((receiver, result)) + + return results def has_receivers_for(self, sender: t.Any) -> bool: """True if there is probably a receiver for *sender*. @@ -376,34 +342,46 @@ def has_receivers_for(self, sender: t.Any) -> bool: """ if not self.receivers: return False + if self._by_sender[ANY_ID]: return True + if sender is ANY: return False - return hashable_identity(sender) in self._by_sender + + return make_id(sender) in self._by_sender def receivers_for( self, sender: t.Any - ) -> t.Generator[t.Callable[[t.Any], t.Any], None, None]: + ) -> t.Generator[t.Callable[..., t.Any], None, None]: """Iterate all live receivers listening for *sender*.""" # TODO: test receivers_for(ANY) - if self.receivers: - sender_id = hashable_identity(sender) - if sender_id in self._by_sender: - ids = self._by_sender[ANY_ID] | self._by_sender[sender_id] - else: - ids = self._by_sender[ANY_ID].copy() - for receiver_id in ids: - receiver = self.receivers.get(receiver_id) - if receiver is None: + if not self.receivers: + return + + sender_id = make_id(sender) + + if sender_id in self._by_sender: + ids = self._by_sender[ANY_ID] | self._by_sender[sender_id] + else: + ids = self._by_sender[ANY_ID].copy() + + for receiver_id in ids: + receiver = self.receivers.get(receiver_id) + + if receiver is None: + continue + + if isinstance(receiver, weakref.ref): + strong = receiver() + + if strong is None: + self._disconnect(receiver_id, ANY_ID) continue - if isinstance(receiver, WeakTypes): - strong = receiver() - if strong is None: - self._disconnect(receiver_id, ANY_ID) - continue - receiver = strong - yield receiver # type: ignore[misc] + + yield strong + else: + yield receiver def disconnect(self, receiver: t.Callable[..., t.Any], sender: t.Any = ANY) -> None: """Disconnect *receiver* from this signal's events. @@ -414,12 +392,14 @@ def disconnect(self, receiver: t.Callable[..., t.Any], sender: t.Any = ANY) -> N to disconnect from all senders. Defaults to ``ANY``. """ - sender_id: IdentityType + sender_id: t.Hashable + if sender is ANY: sender_id = ANY_ID else: - sender_id = hashable_identity(sender) - receiver_id = hashable_identity(receiver) + sender_id = make_id(sender) + + receiver_id = make_id(receiver) self._disconnect(receiver_id, sender_id) if ( @@ -428,27 +408,40 @@ def disconnect(self, receiver: t.Callable[..., t.Any], sender: t.Any = ANY) -> N ): self.receiver_disconnected.send(self, receiver=receiver, sender=sender) - def _disconnect(self, receiver_id: IdentityType, sender_id: IdentityType) -> None: + def _disconnect(self, receiver_id: t.Hashable, sender_id: t.Hashable) -> None: if sender_id == ANY_ID: - if self._by_receiver.pop(receiver_id, False): + if self._by_receiver.pop(receiver_id, None) is not None: for bucket in self._by_sender.values(): bucket.discard(receiver_id) + self.receivers.pop(receiver_id, None) else: self._by_sender[sender_id].discard(receiver_id) self._by_receiver[receiver_id].discard(sender_id) - def _cleanup_receiver(self, receiver_ref: annotatable_weakref) -> None: + def _make_cleanup_receiver( + self, receiver_id: t.Hashable + ) -> t.Callable[[weakref.ref[t.Callable[..., t.Any]]], None]: """Disconnect a receiver from all senders.""" - self._disconnect(cast(IdentityType, receiver_ref.receiver_id), ANY_ID) - def _cleanup_sender(self, sender_ref: annotatable_weakref) -> None: + def cleanup(ref: weakref.ref[t.Callable[..., t.Any]]) -> None: + self._disconnect(receiver_id, ANY_ID) + + return cleanup + + def _make_cleanup_sender( + self, sender_id: t.Hashable + ) -> t.Callable[[weakref.ref[t.Any]], None]: """Disconnect all receivers from a sender.""" - sender_id = cast(IdentityType, sender_ref.sender_id) assert sender_id != ANY_ID - self._weak_senders.pop(sender_id, None) - for receiver_id in self._by_sender.pop(sender_id, ()): - self._by_receiver[receiver_id].discard(sender_id) + + def cleanup(ref: weakref.ref[t.Any]) -> None: + self._weak_senders.pop(sender_id, None) + + for receiver_id in self._by_sender.pop(sender_id, ()): + self._by_receiver[receiver_id].discard(sender_id) + + return cleanup def _cleanup_bookkeeping(self) -> None: """Prune unused sender/receiver bookkeeping. Not threadsafe. @@ -472,9 +465,9 @@ def _cleanup_bookkeeping(self) -> None: failure mode is perhaps not a big deal for you. """ for mapping in (self._by_sender, self._by_receiver): - for _id, bucket in list(mapping.items()): + for ident, bucket in list(mapping.items()): if not bucket: - mapping.pop(_id, None) + mapping.pop(ident, None) def _clear_state(self) -> None: """Throw away all signal state. Useful for unit tests.""" @@ -505,13 +498,13 @@ class NamedSignal(Signal): """A named generic notification emitter.""" def __init__(self, name: str, doc: str | None = None) -> None: - Signal.__init__(self, doc) + super().__init__(doc) #: The name of this signal. - self.name = name + self.name: str = name def __repr__(self) -> str: - base = Signal.__repr__(self) + base = super().__repr__() return f"{base[:-1]}; {self.name!r}>" # noqa: E702 @@ -522,7 +515,6 @@ def signal(self, name: str, doc: str | None = None) -> NamedSignal: """Return the :class:`NamedSignal` *name*, creating it if required. Repeated calls to this function will return the same signal object. - """ try: return self[name] # type: ignore[no-any-return] @@ -539,7 +531,6 @@ class WeakNamespace(WeakValueDictionary): # type: ignore[type-arg] compatibility with Blinker <= 1.2, and may be dropped in the future. .. versionadded:: 1.3 - """ def signal(self, name: str, doc: str | None = None) -> NamedSignal: diff --git a/tests/test_saferef.py b/tests/test_saferef.py deleted file mode 100644 index 1912bf0..0000000 --- a/tests/test_saferef.py +++ /dev/null @@ -1,118 +0,0 @@ -# extracted from Louie, http://pylouie.org/ -# updated for Python 3 -# -# Copyright (c) 2006 Patrick K. O'Brien, Mike C. Fletcher, -# Matthew R. Scott -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are -# met: -# -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# * Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials provided -# with the distribution. -# -# * Neither the name of the nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -from __future__ import annotations - -import typing as t -import unittest - -from blinker._saferef import safe_ref - - -class _Sample1: - def x(self) -> None: - pass - - -def _sample2(obj: t.Any) -> None: - pass - - -class _Sample3: - def __call__(self, obj: t.Any) -> None: - pass - - -class TestSaferef(unittest.TestCase): - # XXX: The original tests had a test for closure, and it had an - # off-by-one problem, perhaps due to scope issues. It has been - # removed from this test suite. - - def setUp(self) -> None: - samples: list[t.Any] = [] - refs: list[t.Any] = [] - for _ in range(100): - sample: t.Any = _Sample1() - samples.append(sample) - ref = safe_ref(sample.x, self._closure) - refs.append(ref) - samples.append(_sample2) - refs.append(safe_ref(_sample2, self._closure)) - for _ in range(30): - sample = _Sample3() - samples.append(sample) - ref = safe_ref(sample, self._closure) - refs.append(ref) - self.ts = samples - self.ss = refs - self.closure_count = 0 - - def tearDown(self) -> None: - if hasattr(self, "ts"): - del self.ts - if hasattr(self, "ss"): - del self.ss - - def test_In(self) -> None: - """Test the `in` operator for safe references (cmp)""" - for sample in self.ts[:50]: - assert safe_ref(sample.x) in self.ss - - def test_Valid(self) -> None: - """Test that the references are valid (return instance methods)""" - for s in self.ss: - assert s() - - def test_ShortCircuit(self) -> None: - """Test that creation short-circuits to reuse existing references""" - sd = {} - for s in self.ss: - sd[s] = 1 - for sample in self.ts: - if hasattr(sample, "x"): - assert safe_ref(sample.x) in sd - else: - assert safe_ref(sample) in sd - - def test_Representation(self) -> None: - """Test that the reference object's representation works - - XXX Doesn't currently check the results, just that no error - is raised - """ - repr(self.ss[-1]) - - def _closure(self, ref: t.Any) -> None: - """Dumb utility mechanism to increment deletion counter""" - self.closure_count += 1 diff --git a/tests/test_utilities.py b/tests/test_symbol.py similarity index 71% rename from tests/test_utilities.py rename to tests/test_symbol.py index 4827a26..d8c87e7 100644 --- a/tests/test_utilities.py +++ b/tests/test_symbol.py @@ -2,15 +2,15 @@ import pickle -from blinker._utilities import symbol +from blinker._utilities import Symbol def test_symbols() -> None: - foo = symbol("foo") + foo = Symbol("foo") assert foo.name == "foo" - assert foo is symbol("foo") + assert foo is Symbol("foo") - bar = symbol("bar") + bar = Symbol("bar") assert foo is not bar assert foo != bar assert not foo == bar @@ -19,7 +19,7 @@ def test_symbols() -> None: def test_pickled_symbols() -> None: - foo = symbol("foo") + foo = Symbol("foo") for _ in 0, 1, 2: roundtrip = pickle.loads(pickle.dumps(foo)) diff --git a/tox.ini b/tox.ini index 6b0a51f..6de8000 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,9 @@ commands = pre-commit run --all-files [testenv:typing] deps = -r requirements/typing.txt -commands = mypy +commands = + mypy + pyright [testenv:docs] deps = -r requirements/docs.txt