diff --git a/CHANGES.rst b/CHANGES.rst index 3d260d0..58bb3a7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -34,50 +34,55 @@ Released 2023-11-01 - Fixed messages printed to standard error about unraisable exceptions during signal cleanup, typically during interpreter shutdown. :pr:`123` -- Allow the Signal set_class to be customised, to allow calling of receivers - in registration order. :pr:`116`. +- Allow the Signal ``set_class`` to be customised, to allow calling of + receivers in registration order. :pr:`116`. - Drop Python 3.7 and support Python 3.12. :pr:`126` + Version 1.6.3 ------------- Released 2023-09-23 -- Fix `SyncWrapperType` and `AsyncWrapperType` :pr:`108` -- Fixed issue where ``signal.connected_to`` would not disconnect the - receiver if an instance of ``BaseException`` was raised. :pr:`114` +- Fix ``SyncWrapperType`` and ``AsyncWrapperType`` :pr:`108` +- Fixed issue where ``connected_to`` would not disconnect the receiver if an + instance of ``BaseException`` was raised. :pr:`114` + Version 1.6.2 ------------- Released 2023-04-12 -- Type annotations are not evaluated at runtime. typing-extensions is not a runtime - dependency. :pr:`94` +- Type annotations are not evaluated at runtime. typing-extensions is not a + runtime dependency. :pr:`94` + Version 1.6.1 ------------- Released 2023-04-09 -- Ensure that py.typed is present in the distributions (to enable other - projects to use blinker's typing). -- Require typing-extensions > 4.2 to ensure it includes - ParamSpec. :issue:`90` +- Ensure that ``py.typed`` is present in the distributions (to enable other + projects to use Blinker's typing). +- Require typing-extensions > 4.2 to ensure it includes ``ParamSpec``. + :issue:`90` + Version 1.6 ----------- Released 2023-04-02 -- Add a muted context manager to temporarily turn off a - signal. :pr:`84` -- Allow int senders (alongside existing string senders). :pr:`83` -- Add a send_async method to the Signal to allow signals to send to - coroutine receivers. :pr:`76` -- Update and modernise the project structure to match that used by the - pallets projects. :pr:`77` -- Add an intial set of type hints for the project. +- Add a ``muted`` context manager to temporarily turn off a signal. :pr:`84` +- ``int`` instances with the same value will be treated as the same sender, + the same as ``str`` instances. :pr:`83` +- Add a ``send_async`` method to allow signals to send to coroutine receivers. + :pr:`76` +- Update and modernise the project structure to match that used by the Pallets + projects. :pr:`77` +- Add an initial set of type hints for the project. + Version 1.5 ----------- diff --git a/docs/conf.py b/docs/conf.py index eb82103..b0a5582 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,6 @@ "sphinxcontrib.log_cabinet", "pallets_sphinx_themes", ] -autoclass_content = "both" autodoc_member_order = "groupwise" autodoc_typehints = "description" autodoc_preserve_defaults = True diff --git a/docs/index.rst b/docs/index.rst index 59b959f..2637dd0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,6 +6,8 @@ Blinker Documentation .. image:: _static/blinker-named.png :align: center +.. currentmodule:: blinker + Blinker provides fast & simple object-to-object and broadcast signaling for Python objects. @@ -357,27 +359,38 @@ All public API members can (and should) be imported from ``blinker``:: from blinker import ANY, signal -.. currentmodule:: blinker.base - Basic Signals +++++++++++++ -.. autodata:: blinker.base.ANY +.. data:: ANY + + Symbol for "any sender". .. autoclass:: Signal :members: - :undoc-members: Named Signals +++++++++++++ -.. autofunction:: signal +.. function:: signal(name, doc=None) + + Return a :class:`NamedSignal` in :data:`default_namespace` for the given + name, creating it if required. Repeated calls with the same name return the + same signal. -.. autodata:: default_namespace + :param name: The name of the signal. + :type name: str + :param doc: The docstring of the signal. + :type doc: str | None + :rtype: NamedSignal + +.. data:: default_namespace + + A default :class:`Namespace` for creating named signals. :func:`signal` + creates a :class:`NamedSignal` in this namespace. .. autoclass:: NamedSignal :show-inheritance: - :members: .. autoclass:: Namespace :show-inheritance: diff --git a/src/blinker/base.py b/src/blinker/base.py index 5d001af..a68efa2 100644 --- a/src/blinker/base.py +++ b/src/blinker/base.py @@ -1,12 +1,3 @@ -"""Signals and events. - -A small implementation of signals, inspired by a snippet of Django signal -API client code seen in a blog post. Signals are first-class objects and -each manages its own receivers and message emission. - -The :func:`signal` function provides singleton behavior for named signals. -""" - from __future__ import annotations import collections.abc as c @@ -39,25 +30,34 @@ def __call__(self, f: c.Callable[P, T]) -> c.Callable[P, c.Awaitable[T]]: ... ANY = Symbol("ANY") -"""Token for "any sender".""" +"""Symbol for "any sender".""" + ANY_ID = 0 class Signal: - """A notification emitter.""" + """A notification emitter. + + :param doc: The docstring for the signal. + """ - #: An :obj:`ANY` convenience synonym, allows ``Signal.ANY`` - #: without an additional import. ANY = ANY + """An alias for the :data:`~blinker.ANY` sender symbol.""" set_class: type[set[t.Any]] = set + """The set class to use for tracking connected receivers and senders. + Python's ``set`` is unordered. If receivers must be dispatched in the order + they were connected, an ordered set implementation can be used. + + .. versionadded:: 1.7 + """ @cached_property def receiver_connected(self) -> Signal: - """Emitted after each :meth:`connect`. + """Emitted at the end of each :meth:`connect` call. The signal sender is the signal instance, and the :meth:`connect` - arguments are passed through: *receiver*, *sender*, and *weak*. + arguments are passed through: ``receiver``, ``sender``, and ``weak``. .. versionadded:: 1.2 """ @@ -65,18 +65,16 @@ def receiver_connected(self) -> Signal: @cached_property def receiver_disconnected(self) -> Signal: - """Emitted after :meth:`disconnect`. + """Emitted at the end of each :meth:`disconnect` call. The sender is the signal instance, and the :meth:`disconnect` arguments - are passed through: *receiver* and *sender*. + are passed through: ``receiver`` and ``sender``. - Note, this signal is emitted **only** when :meth:`disconnect` is - called explicitly. - - The disconnect signal can not be emitted by an automatic disconnect - (due to a weakly referenced receiver or sender going out of scope), - as the receiver and/or sender instances are no longer available for - use at the time this signal would be emitted. + This signal is emitted **only** when :meth:`disconnect` is called + explicitly. This signal cannot be emitted by an automatic disconnect + when a weakly referenced receiver or sender goes out of scope, as the + instance is no longer be available to be used as the sender for this + signal. An alternative approach is available by subscribing to :attr:`receiver_connected` and setting up a custom weakref cleanup @@ -87,43 +85,38 @@ def receiver_disconnected(self) -> Signal: return Signal(doc="Emitted after a receiver disconnects.") def __init__(self, doc: str | None = None) -> None: - """ - :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 - #: internal :class:`Signal` implementation, however the boolean value - #: of the mapping is useful as an extremely efficient check to see if - #: any receivers are connected to the signal. self.receivers: dict[ t.Any, weakref.ref[c.Callable[..., t.Any]] | c.Callable[..., t.Any] ] = {} + """The map of connected receivers. Useful to quickly check if any + receivers are connected to the signal: ``if s.receivers:``. The + structure and data is not part of the public API, but checking its + boolean value is. + """ + 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: 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 - `sender=` as a single positional argument and any ``kwargs`` that - were provided to a call to :meth:`send`. - - :param sender: Any object or :obj:`ANY`, defaults to ``ANY``. - Restricts notifications delivered to *receiver* to only those - :meth:`send` emissions sent by *sender*. If ``ANY``, the receiver - will always be notified. A *receiver* may be connected to - multiple *sender* values on the same Signal through multiple calls - to :meth:`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. + """Connect ``receiver`` to be called when the signal is sent by + ``sender``. + + :param receiver: The callable to call when :meth:`send` is called with + the given ``sender``, passing ``sender`` as a positional argument + along with any extra keyword arguments. + :param sender: Any object or :data:`ANY`. ``receiver`` will only be + called when :meth:`send` is called with this sender. If ``ANY``, the + receiver will be called for any sender. A receiver may be connected + to multiple senders by calling :meth:`connect` multiple times. + :param weak: Track the receiver with a :mod:`weakref`. The receiver will + be automatically disconnected when it is garbage collected. When + connecting a receiver defined within a function, set to ``False``, + otherwise it will be disconnected when the function scope ends. """ receiver_id = make_id(receiver) sender_id = ANY_ID if sender is ANY else make_id(sender) @@ -169,22 +162,21 @@ def connect(self, receiver: F, sender: t.Any = ANY, weak: bool = True) -> F: return receiver def connect_via(self, sender: t.Any, weak: bool = False) -> c.Callable[[F], F]: - """Connect the decorated function as a receiver for *sender*. - - :param sender: Any object or :obj:`ANY`. The decorated function - will only receive :meth:`send` emissions sent by *sender*. If - ``ANY``, the receiver will always be notified. A function may be - decorated multiple times with differing *sender* values. - - :param weak: If true, the Signal will hold a weakref to the - decorated function and automatically disconnect when *receiver* - goes out of scope or is garbage collected. Unlike - :meth:`connect`, this defaults to False. - - The decorated function will be invoked by :meth:`send` with - `sender=` as a single positional argument and any ``kwargs`` that - were provided to the call to :meth:`send`. - + """Connect the decorated function to be called when the signal is sent + by ``sender``. + + The decorated function will be called when :meth:`send` is called with + the given ``sender``, passing ``sender`` as a positional argument along + with any extra keyword arguments. + + :param sender: Any object or :data:`ANY`. ``receiver`` will only be + called when :meth:`send` is called with this sender. If ``ANY``, the + receiver will be called for any sender. A receiver may be connected + to multiple senders by calling :meth:`connect` multiple times. + :param weak: Track the receiver with a :mod:`weakref`. The receiver will + be automatically disconnected when it is garbage collected. When + connecting a receiver defined within a function, set to ``False``, + otherwise it will be disconnected when the function scope ends.= .. versionadded:: 1.1 """ @@ -199,24 +191,18 @@ def decorator(fn: F) -> F: def connected_to( self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY ) -> c.Generator[None, None, None]: - """Execute a block with the signal temporarily connected to *receiver*. + """A context manager that temporarily connects ``receiver`` to the + signal while a ``with`` block executes. When the block exits, the + receiver is disconnected. Useful for tests. - :param receiver: a receiver callable - :param sender: optional, a sender to filter on - - This is a context manager for use in the ``with`` statement. It can - be useful in unit tests. *receiver* is connected to the signal for - the duration of the ``with`` block, and will be disconnected - automatically when exiting the block: - - .. code-block:: python - - with on_ready.connected_to(receiver): - # do stuff - on_ready.send(123) + :param receiver: The callable to call when :meth:`send` is called with + the given ``sender``, passing ``sender`` as a positional argument + along with any extra keyword arguments. + :param sender: Any object or :data:`ANY`. ``receiver`` will only be + called when :meth:`send` is called with this sender. If ``ANY``, the + receiver will be called for any sender. .. versionadded:: 1.1 - """ self.connect(receiver, sender=sender, weak=False) @@ -227,8 +213,9 @@ def connected_to( @contextmanager def muted(self) -> c.Generator[None, None, None]: - """Context manager for temporarily disabling signal. - Useful for test purposes. + """A context manager that temporarily disables the signal. No receivers + will be called if the signal is sent, until the ``with`` block exits. + Useful for tests. """ self.is_muted = True @@ -240,15 +227,12 @@ def muted(self) -> c.Generator[None, None, None]: def temporarily_connected_to( self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY ) -> AbstractContextManager[None]: - """An alias for :meth:`connected_to`. - - :param receiver: a receiver callable - :param sender: optional, a sender to filter on - - .. versionadded:: 0.9 + """Deprecated alias for :meth:`connected_to`. .. deprecated:: 1.1 Renamed to ``connected_to``. Will be removed in Blinker 1.9. + + .. versionadded:: 0.9 """ warnings.warn( "'temporarily_connected_to' is renamed to 'connected_to'. The old name is" @@ -266,17 +250,27 @@ def send( _async_wrapper: PAsyncWrapper | None = None, **kwargs: t.Any, ) -> list[tuple[c.Callable[..., t.Any], t.Any]]: - """Emit this signal on behalf of *sender*, passing on ``kwargs``. - - Returns a list of 2-tuples, pairing receivers with their return - value. The ordering of receiver notification is undefined. - - :param sender: Any object or ``None``. If omitted, synonymous - with ``None``. Only accepts one positional argument. - :param _async_wrapper: A callable that should wrap a coroutine - receiver and run it when called synchronously. - - :param kwargs: Data to be sent to receivers. + """Call all receivers that are connected to the given ``sender`` + or :data:`ANY`. Each receiver is called with ``sender`` as a positional + argument along with any extra keyword arguments. Return a list of + ``(receiver, return value)`` tuples. + + The order receivers are called is undefined, but can be influenced by + setting :attr:`set_class`. + + If a receiver raises an exception, that exception will propagate up. + This makes debugging straightforward, with an assumption that correctly + implemented receivers will not raise. + + :param sender: Call receivers connected to this sender, in addition to + those connected to :data:`ANY`. + :param _async_wrapper: Will be called on any receivers that are async + coroutines to turn them into sync callables. For example, could run + the receiver with an event loop. + :param kwargs: Extra keyword arguments to pass to each receiver. + + .. versionchanged:: 1.7 + Added the ``_async_wrapper`` argument. """ if self.is_muted: return [] @@ -304,17 +298,26 @@ async def send_async( _sync_wrapper: PSyncWrapper | None = None, **kwargs: t.Any, ) -> list[tuple[c.Callable[..., t.Any], t.Any]]: - """Emit this signal on behalf of *sender*, passing on ``kwargs``. - - Returns a list of 2-tuples, pairing receivers with their return - value. The ordering of receiver notification is undefined. - - :param sender: Any object or ``None``. If omitted, synonymous - with ``None``. Only accepts one positional argument. - :param _sync_wrapper: A callable that should wrap a synchronous - receiver and run it when awaited. - - :param kwargs: Data to be sent to receivers. + """Await all receivers that are connected to the given ``sender`` + or :data:`ANY`. Each receiver is called with ``sender`` as a positional + argument along with any extra keyword arguments. Return a list of + ``(receiver, return value)`` tuples. + + The order receivers are called is undefined, but can be influenced by + setting :attr:`set_class`. + + If a receiver raises an exception, that exception will propagate up. + This makes debugging straightforward, with an assumption that correctly + implemented receivers will not raise. + + :param sender: Call receivers connected to this sender, in addition to + those connected to :data:`ANY`. + :param _sync_wrapper: Will be called on any receivers that are sync + callables to turn them into async coroutines. For example, + could call the receiver in a thread. + :param kwargs: Extra keyword arguments to pass to each receiver. + + .. versionadded:: 1.7 """ if self.is_muted: return [] @@ -335,12 +338,14 @@ async def send_async( return results def has_receivers_for(self, sender: t.Any) -> bool: - """True if there is probably a receiver for *sender*. - - Performs an optimistic check only. Does not guarantee that all - weakly referenced receivers are still alive. See - :meth:`receivers_for` for a stronger search. - + """Check if there is at least one receiver that will be called with the + given ``sender``. A receiver connected to :data:`ANY` will always be + called, regardless of sender. Does not check if weakly referenced + receivers are still live. See :meth:`receivers_for` for a stronger + search. + + :param sender: Check for receivers connected to this sender, in addition + to those connected to :data:`ANY`. """ if not self.receivers: return False @@ -356,7 +361,13 @@ def has_receivers_for(self, sender: t.Any) -> bool: def receivers_for( self, sender: t.Any ) -> c.Generator[c.Callable[..., t.Any], None, None]: - """Iterate all live receivers listening for *sender*.""" + """Yield each receiver to be called for ``sender``, in addition to those + to be called for :data:`ANY`. Weakly referenced receivers that are not + live will be disconnected and skipped. + + :param sender: Yield receivers connected to this sender, in addition + to those connected to :data:`ANY`. + """ # TODO: test receivers_for(ANY) if not self.receivers: return @@ -386,13 +397,12 @@ def receivers_for( yield receiver def disconnect(self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY) -> None: - """Disconnect *receiver* from this signal's events. - - :param receiver: a previously :meth:`connected` callable - - :param sender: a specific sender to disconnect from, or :obj:`ANY` - to disconnect from all senders. Defaults to ``ANY``. + """Disconnect ``receiver`` from being called when the signal is sent by + ``sender``. + :param receiver: A connected receiver callable. + :param sender: Disconnect from only this sender. By default, disconnect + from all senders. """ sender_id: c.Hashable @@ -424,7 +434,9 @@ def _disconnect(self, receiver_id: c.Hashable, sender_id: c.Hashable) -> None: def _make_cleanup_receiver( self, receiver_id: c.Hashable ) -> c.Callable[[weakref.ref[c.Callable[..., t.Any]]], None]: - """Disconnect a receiver from all senders.""" + """Create a callback function to disconnect a weakly referenced + receiver when it is garbage collected. + """ def cleanup(ref: weakref.ref[c.Callable[..., t.Any]]) -> None: self._disconnect(receiver_id, ANY_ID) @@ -434,7 +446,9 @@ def cleanup(ref: weakref.ref[c.Callable[..., t.Any]]) -> None: def _make_cleanup_sender( self, sender_id: c.Hashable ) -> c.Callable[[weakref.ref[t.Any]], None]: - """Disconnect all receivers from a sender.""" + """Create a callback function to disconnect all receivers for a weakly + referenced sender when it is garbage collected. + """ assert sender_id != ANY_ID def cleanup(ref: weakref.ref[t.Any]) -> None: @@ -448,23 +462,23 @@ def cleanup(ref: weakref.ref[t.Any]) -> None: def _cleanup_bookkeeping(self) -> None: """Prune unused sender/receiver bookkeeping. Not threadsafe. - Connecting & disconnecting leave behind a small amount of bookkeeping - for the receiver and sender values. Typical workloads using Blinker, - for example in most web apps, Flask, CLI scripts, etc., are not - adversely affected by this bookkeeping. + Connecting & disconnecting leaves behind a small amount of bookkeeping + data. Typical workloads using Blinker, for example in most web apps, + Flask, CLI scripts, etc., are not adversely affected by this + bookkeeping. - With a long-running Python process performing dynamic signal routing - with high volume- e.g. connecting to function closures, "senders" are - all unique object instances, and doing all of this over and over- you - may see memory usage will grow due to extraneous bookkeeping. (An empty - set() for each stale sender/receiver pair.) + With a long-running process performing dynamic signal routing with high + volume, e.g. connecting to function closures, senders are all unique + object instances. Doing all of this over and over may cause memory usage + to grow due to extraneous bookkeeping. (An empty ``set`` for each stale + sender/receiver pair.) This method will prune that bookkeeping away, with the caveat that such pruning is not threadsafe. The risk is that cleanup of a fully disconnected receiver/sender pair occurs while another thread is connecting that same pair. If you are in the highly dynamic, unique - receiver/sender situation that has lead you to this method, that - failure mode is perhaps not a big deal for you. + receiver/sender situation that has lead you to this method, that failure + mode is perhaps not a big deal for you. """ for mapping in (self._by_sender, self._by_receiver): for ident, bucket in list(mapping.items()): @@ -472,7 +486,7 @@ def _cleanup_bookkeeping(self) -> None: mapping.pop(ident, None) def _clear_state(self) -> None: - """Throw away all signal state. Useful for unit tests.""" + """Disconnect all receivers and senders. Useful for tests.""" self._weak_senders.clear() self.receivers.clear() self._by_sender.clear() @@ -497,7 +511,12 @@ def _clear_state(self) -> None: class NamedSignal(Signal): - """A named generic notification emitter.""" + """A named generic notification emitter. The name is not used by the signal + itself, but matches the key in the :class:`Namespace` that it belongs to. + + :param name: The name of the signal within the namespace. + :param doc: The docstring for the signal. + """ def __init__(self, name: str, doc: str | None = None) -> None: super().__init__(doc) @@ -511,12 +530,14 @@ def __repr__(self) -> str: class Namespace(dict): # type: ignore[type-arg] - """A mapping of signal names to signals.""" + """A dict mapping names to signals.""" def signal(self, name: str, doc: str | None = None) -> NamedSignal: - """Return the :class:`NamedSignal` *name*, creating it if required. + """Return the :class:`NamedSignal` for the given ``name``, creating it + if required. Repeated calls with the same name return the same signal. - Repeated calls to this function will return the same signal object. + :param name: The name of the signal. + :param doc: The docstring of the signal. """ try: return self[name] # type: ignore[no-any-return] @@ -526,16 +547,16 @@ def signal(self, name: str, doc: str | None = None) -> NamedSignal: class _WeakNamespace(WeakValueDictionary): # type: ignore[type-arg] - """A weak mapping of signal names to signals. - - Automatically cleans up unused Signals when the last reference goes out - of scope. This namespace implementation exists for a measure of legacy - compatibility with Blinker <= 1.2, and may be dropped in the future. + """A weak mapping of names to signals. - .. versionadded:: 1.3 + Automatically cleans up unused signals when the last reference goes out + of scope. This namespace implementation provides similar behavior to Blinker + <= 1.2. .. deprecated:: 1.3 Will be removed in Blinker 1.9. + + .. versionadded:: 1.3 """ def __init__(self) -> None: @@ -548,10 +569,11 @@ def __init__(self) -> None: super().__init__() 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. + """Return the :class:`NamedSignal` for the given ``name``, creating it + if required. Repeated calls with the same name return the same signal. + :param name: The name of the signal. + :param doc: The docstring of the signal. """ try: return self[name] # type: ignore[no-any-return] @@ -561,13 +583,14 @@ def signal(self, name: str, doc: str | None = None) -> NamedSignal: default_namespace = Namespace() -"""A default namespace for creating named signals. :func:`signal` creates a -:class:`NamedSignal` in this namespace. +"""A default :class:`Namespace` for creating named signals. :func:`signal` +creates a :class:`NamedSignal` in this namespace. """ signal = default_namespace.signal -"""Create a :class:`NamedSignal` in :data:`default_namespace`. Repeated calls -with the same name will return the same signal. +"""Return a :class:`NamedSignal` in :data:`default_namespace` with the given +``name``, creating it if required. Repeated calls with the same name return the +same signal. """