From 143ea273703538291949a18ef58b6be312cef6af Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:19:04 +0000 Subject: [PATCH 01/23] Add top-level functions for "global-dispatcher" --- doc/source/reference/index.rst | 3 +++ pydispatch/__init__.py | 27 +++++++++++++++++++++++++++ pydispatch/dispatch.py | 6 ++++++ 3 files changed, 36 insertions(+) diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst index f522d1f..edc3796 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -8,3 +8,6 @@ Reference properties utils aioutils + +.. automodule:: pydispatch + :members: diff --git a/pydispatch/__init__.py b/pydispatch/__init__.py index 7ff4b3d..fcc963c 100644 --- a/pydispatch/__init__.py +++ b/pydispatch/__init__.py @@ -16,4 +16,31 @@ UserWarning) from pydispatch.dispatch import * +from pydispatch.dispatch import _GLOBAL_DISPATCHER from pydispatch.properties import * + + +def register_event(*names): + _GLOBAL_DISPATCHER.register_event(*names) + +register_event.__doc__ = Dispatcher.register_event.__doc__ + +def bind(**kwargs): + _GLOBAL_DISPATCHER.bind(**kwargs) +bind.__doc__ = Dispatcher.bind.__doc__ + +def unbind(*args): + _GLOBAL_DISPATCHER.unbind(*args) +unbind.__doc__ = Dispatcher.unbind.__doc__ + +def bind_async(loop, **kwargs): + _GLOBAL_DISPATCHER.bind_async(loop, **kwargs) +bind_async.__doc__ = Dispatcher.bind_async.__doc__ + +def emit(name, *args, **kwargs): + return _GLOBAL_DISPATCHER.emit(name, *args, **kwargs) +emit.__doc__ = Dispatcher.emit.__doc__ + +def get_dispatcher_event(name): + return _GLOBAL_DISPATCHER.get_dispatcher_event(name) +get_dispatcher_event.__doc__ = Dispatcher.get_dispatcher_event.__doc__ diff --git a/pydispatch/dispatch.py b/pydispatch/dispatch.py index fce58ff..3c154b7 100644 --- a/pydispatch/dispatch.py +++ b/pydispatch/dispatch.py @@ -377,3 +377,9 @@ def emission_lock(self, name): """ e = self.get_dispatcher_event(name) return e.emission_lock + + +class _GlobalDispatcher(Dispatcher): + pass + +_GLOBAL_DISPATCHER = _GlobalDispatcher() From ade3fd9f5868607f30f1c2ab085fb225e62bac0a Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:23:57 +0000 Subject: [PATCH 02/23] Add `@receiver` decorator --- doc/source/reference/decorators.rst | 5 + doc/source/reference/index.rst | 1 + pydispatch/__init__.py | 3 + pydispatch/decorators.py | 138 ++++++++++++++++++++++++++++ pydispatch/dispatch.py | 8 +- 5 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 doc/source/reference/decorators.rst create mode 100644 pydispatch/decorators.py diff --git a/doc/source/reference/decorators.rst b/doc/source/reference/decorators.rst new file mode 100644 index 0000000..4cd2b52 --- /dev/null +++ b/doc/source/reference/decorators.rst @@ -0,0 +1,5 @@ +pydispatch.decorators module +============================ + +.. automodule:: pydispatch.decorators + :members: diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst index edc3796..d15bc26 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -6,6 +6,7 @@ Reference dispatch properties + decorators utils aioutils diff --git a/pydispatch/__init__.py b/pydispatch/__init__.py index fcc963c..7471be9 100644 --- a/pydispatch/__init__.py +++ b/pydispatch/__init__.py @@ -18,10 +18,13 @@ from pydispatch.dispatch import * from pydispatch.dispatch import _GLOBAL_DISPATCHER from pydispatch.properties import * +from pydispatch import decorators +from pydispatch.decorators import * def register_event(*names): _GLOBAL_DISPATCHER.register_event(*names) + decorators._post_register_hook(*names) register_event.__doc__ = Dispatcher.register_event.__doc__ diff --git a/pydispatch/decorators.py b/pydispatch/decorators.py new file mode 100644 index 0000000..48af3ee --- /dev/null +++ b/pydispatch/decorators.py @@ -0,0 +1,138 @@ +from __future__ import annotations +from typing import Iterable, Iterator, Callable +import asyncio + +from .dispatch import DoesNotExistError, _GLOBAL_DISPATCHER +from .utils import iscoroutinefunction, WeakMethodContainer + +__all__ = ('receiver',) + +class CallbackCache: + def __init__(self): + self.cache = {} + + def add(self, name: str, func: Callable): + wr_contain = self.cache.get(name) + if wr_contain is None: + wr_contain = WeakMethodContainer() + self.cache[name] = wr_contain + wr_contain.add_method(func) + + def get(self, name: str) -> Iterator[Callable]: + wr_contain = self.cache.get(name) + if wr_contain is not None: + del self.cache[name] + yield from wr_contain.iter_methods() + yield from [] + + def __contains__(self, name: str) -> bool: + return name in self.cache + + +_CACHED_CALLBACKS = CallbackCache() + + +def receiver( + event_name: str|Iterable[str], + cache: bool = False, + auto_register: bool = False +): + """Decorator to bind a function or method to the :ref:`global-dispatcher` + + Examples: + + >>> import pydispatch + + >>> pydispatch.register_event('foo') + + >>> @pydispatch.receiver('foo') + ... def on_foo(value, **kwargs): + ... print(f'on_foo: "{value}"') + + >>> pydispatch.emit('foo', 'spam') + on_foo: "spam" + + Using the *cache* argument + + >>> @pydispatch.receiver('bar', cache=True) + ... def on_bar(value, **kwargs): + ... print(f'on_bar: "{value}"') + + >>> pydispatch.register_event('bar') + >>> pydispatch.emit('bar', 'eggs') + on_bar: "eggs" + + Using *auto_register* + + >>> @pydispatch.receiver('baz', auto_register=True) + ... def on_baz(value, **kwargs): + ... print(f'on_baz: "{value}"') + + >>> pydispatch.emit('baz', 'ham') + on_baz: "ham" + + Receiving multiple events + + >>> @pydispatch.receiver(['foo', 'bar', 'baz']) + ... def on_foobarbaz(value, **kwargs): + ... print(f'on_foobarbaz: "{value}"') + + >>> pydispatch.emit('foo', 1) + on_foo: "1" + on_foobarbaz: "1" + + >>> pydispatch.emit('bar', 2) + on_bar: "2" + on_foobarbaz: "2" + + + Arguments: + event_name: Event name (or names) to bind the callback to. + Can be a string or an :term:`iterable` of strings + cache: If the event does not exist yet and this is ``True``, + the callback will be held in a cache until it has been registered. + If ``False``, a :class:`~.DoesNotExistError` will be raised. + (Default is ``False``) + auto_register: If the event does not exist and this is ``True``, + it will be registered on the :ref:`global-dispatcher`. + (Default is ``False``) + + """ + def _decorator(func: Callable): + is_async = iscoroutinefunction(func) + + if isinstance(event_name, str): + event_names = [event_name] + else: + event_names = event_name + bind_kwargs = {ev:func for ev in event_names} + try: + if is_async: + bind_kwargs['__aio_loop__'] = asyncio.get_event_loop() + _GLOBAL_DISPATCHER.bind(**bind_kwargs) + except DoesNotExistError: + if not any([cache, auto_register]): + raise + for name in event_names: + if not _GLOBAL_DISPATCHER._has_event(name): + if auto_register: + _GLOBAL_DISPATCHER.register_event(name) + elif cache: + _CACHED_CALLBACKS.add(name, func) + if auto_register: + _GLOBAL_DISPATCHER.bind(**bind_kwargs) + return func + return _decorator + + +def _post_register_hook(*names): + """Called in :func:`pydispatch.register_event` to search for cached callbacks + """ + for name in names: + if name not in _CACHED_CALLBACKS: + continue + bind_kwargs = {name:cb for cb in _CACHED_CALLBACKS.get(name)} + is_async = any((iscoroutinefunction(cb) for cb in bind_kwargs.values())) + if is_async: + bind_kwargs['__aio_loop__'] = asyncio.get_event_loop() + _GLOBAL_DISPATCHER.bind(**bind_kwargs) diff --git a/pydispatch/dispatch.py b/pydispatch/dispatch.py index 3c154b7..c14ffac 100644 --- a/pydispatch/dispatch.py +++ b/pydispatch/dispatch.py @@ -380,6 +380,12 @@ def emission_lock(self, name): class _GlobalDispatcher(Dispatcher): - pass + def _has_event(self, name): + try: + self.get_dispatcher_event(name) + except KeyError: + return False + return True + _GLOBAL_DISPATCHER = _GlobalDispatcher() From 23d3c4e6986693243437317d1cab8485d70e30d8 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:30:21 +0000 Subject: [PATCH 03/23] Add tests for global dispatcher --- tests/test_global_dispatcher.py | 116 ++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/test_global_dispatcher.py diff --git a/tests/test_global_dispatcher.py b/tests/test_global_dispatcher.py new file mode 100644 index 0000000..56ab93b --- /dev/null +++ b/tests/test_global_dispatcher.py @@ -0,0 +1,116 @@ +import asyncio + +import pydispatch +from pydispatch import receiver + +import pytest + +@pytest.fixture +def dispatcher_cleanup(): + yield + pydispatch._GLOBAL_DISPATCHER._Dispatcher__events.clear() + pydispatch.decorators._CACHED_CALLBACKS.cache.clear() + +def test_receiver_decorator(dispatcher_cleanup): + + pydispatch.register_event('foo', 'bar') + print(f'{pydispatch._GLOBAL_DISPATCHER._Dispatcher__events=}') + + results = {'foo':[], 'bar':[], 'foo_bar':[]} + expected = {'foo':[], 'bar':[], 'foo_bar':[]} + + @receiver('foo') + def on_foo(*args, **kwargs): + results['foo'].append((args, kwargs)) + + @receiver('bar') + def on_bar(*args, **kwargs): + results['bar'].append((args, kwargs)) + + @receiver(['foo', 'bar']) + def on_foo_bar(*args, **kwargs): + results['foo_bar'].append((args, kwargs)) + + for i in range(10): + pydispatch.emit('foo', i) + expected['foo'].append(((i,), {})) + expected['foo_bar'].append(((i,), {})) + + for c in 'abcdef': + pydispatch.emit('bar', c) + expected['bar'].append(((c,), {})) + expected['foo_bar'].append(((c,), {})) + + assert results == expected + + +def test_receiver_decorator_unregistered(dispatcher_cleanup): + with pytest.raises(pydispatch.DoesNotExistError): + @receiver('foo') + def on_foo(*args, **kwargs): + pass + +def test_receiver_decorator_unregistered_cache(dispatcher_cleanup): + results = [] + + @receiver('foo', cache=True) + def on_foo(*args, **kwargs): + results.append((args, kwargs)) + + with pytest.raises(pydispatch.DoesNotExistError): + pydispatch.emit('foo', 47) + + pydispatch.register_event('foo') + + pydispatch.emit('foo', 1) + + assert results == [((1,), {})] + + +def test_receiver_decorator_unregistered_auto_register(dispatcher_cleanup): + results = [] + + @receiver('foo', auto_register=True) + def on_foo(*args, **kwargs): + results.append((args, kwargs)) + + pydispatch.emit('foo', 1) + + assert results == [((1,), {})] + +@pytest.mark.asyncio +async def test_decorator_async_cache(dispatcher_cleanup): + results = [] + result_ev = asyncio.Event() + + @receiver('foo', cache=True) + async def on_foo(*args, **kwargs): + results.append((args, kwargs)) + result_ev.set() + + with pytest.raises(pydispatch.DoesNotExistError): + pydispatch.emit('foo', 47) + + pydispatch.register_event('foo') + + pydispatch.emit('foo', 1) + await result_ev.wait() + + assert results == [((1,), {})] + + +@pytest.mark.asyncio +async def test_decorator_async_auto_register(dispatcher_cleanup): + + results = [] + result_ev = asyncio.Event() + + @receiver('foo', auto_register=True) + async def on_foo(*args, **kwargs): + results.append((args, kwargs)) + result_ev.set() + + pydispatch.emit('foo', 1) + await result_ev.wait() + + assert results == [((1,), {})] From 6229188ae461b24a44a4ef386aaeed537c61232e Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:31:08 +0000 Subject: [PATCH 04/23] Add missing `asyncio` import to doctest The doctest block is being picked up multiple times because of the `__doc__` attribute copied into the top-level functions where asyncio does not exist. --- pydispatch/dispatch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pydispatch/dispatch.py b/pydispatch/dispatch.py index c14ffac..df5c95b 100644 --- a/pydispatch/dispatch.py +++ b/pydispatch/dispatch.py @@ -195,6 +195,8 @@ class Foo(Dispatcher): argument ``"__aio_loop__"`` (an instance of :class:`asyncio.BaseEventLoop`) + >>> import asyncio + >>> class Foo(Dispatcher): ... _events_ = ['test_event'] From c18815b84186f8e2749faa5e2fe2ab351c5d4e95 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:35:12 +0000 Subject: [PATCH 05/23] Allow coroutine functions in `WeakMethodContainer` --- pydispatch/utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pydispatch/utils.py b/pydispatch/utils.py index 9984f1d..1aa0d4c 100644 --- a/pydispatch/utils.py +++ b/pydispatch/utils.py @@ -9,6 +9,12 @@ def get_method_vars(m): obj = m.__self__ return f, obj +def isfunction(m): + return isinstance(m, types.FunctionType) + +def ismethod(m): + return isinstance(m, types.MethodType) + def iscoroutinefunction(obj): return asyncio.iscoroutinefunction(obj) @@ -28,7 +34,7 @@ def add_method(self, m, **kwargs): Args: m: The instance method or function to store """ - if isinstance(m, types.FunctionType): + if isfunction(m): self['function', id(m)] = m else: f, obj = get_method_vars(m) @@ -40,7 +46,7 @@ def del_method(self, m): Args: m: The instance method or function to remove """ - if isinstance(m, types.FunctionType) and not iscoroutinefunction(m): + if isfunction(m): wrkey = ('function', id(m)) else: f, obj = get_method_vars(m) From df4fc8a6929aee0cd7bd88d4151e829ff8b72d09 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:37:04 +0000 Subject: [PATCH 06/23] Handle coroutine functions in `AioWeakMethodContainer` Previously only `async def` instance methods were supported --- pydispatch/aioutils.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pydispatch/aioutils.py b/pydispatch/aioutils.py index 7a84ea0..8620154 100644 --- a/pydispatch/aioutils.py +++ b/pydispatch/aioutils.py @@ -5,6 +5,7 @@ from pydispatch.utils import ( WeakMethodContainer, + isfunction, get_method_vars, _remove_dead_weakref, ) @@ -200,9 +201,13 @@ def add_method(self, loop, callback): on which to schedule callbacks callback: The :term:`coroutine function` to add """ - f, obj = get_method_vars(callback) - wrkey = (f, id(obj)) - self[wrkey] = obj + if isfunction(callback): + wrkey = ('function', id(callback)) + self[wrkey] = callback + else: + f, obj = get_method_vars(callback) + wrkey = (f, id(obj)) + self[wrkey] = obj self.event_loop_map[wrkey] = loop def iter_instances(self): """Iterate over the stored objects @@ -221,8 +226,11 @@ def iter_methods(self): """ for wrkey, obj in self.iter_instances(): f, obj_id = wrkey + if f == 'function': + m = self[wrkey] + else: + m = getattr(obj, f.__name__) loop = self.event_loop_map[wrkey] - m = getattr(obj, f.__name__) yield loop, m def _on_weakref_fin(self, key): if key in self.event_loop_map: From 8d61df88d9cd3f57ae0c5dea2517c597c2687512 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:39:27 +0000 Subject: [PATCH 07/23] Add empty doc for global dispatcher (needs content) --- doc/source/global-dispatcher.rst | 5 +++++ doc/source/overview.rst | 1 + 2 files changed, 6 insertions(+) create mode 100644 doc/source/global-dispatcher.rst diff --git a/doc/source/global-dispatcher.rst b/doc/source/global-dispatcher.rst new file mode 100644 index 0000000..e34363d --- /dev/null +++ b/doc/source/global-dispatcher.rst @@ -0,0 +1,5 @@ +.. _global-dispatcher: + +Global Dispatcher +================= + diff --git a/doc/source/overview.rst b/doc/source/overview.rst index f36dd79..70ecdb9 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -7,3 +7,4 @@ Overview dispatcher properties async + global-dispatcher From 1f448a9856a54fc53fccf8880ff1e45192c22d9c Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:46:20 +0000 Subject: [PATCH 08/23] Use explicit `typing.Union` --- pydispatch/decorators.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pydispatch/decorators.py b/pydispatch/decorators.py index 48af3ee..3c3e7d8 100644 --- a/pydispatch/decorators.py +++ b/pydispatch/decorators.py @@ -1,5 +1,4 @@ -from __future__ import annotations -from typing import Iterable, Iterator, Callable +from typing import Union, Iterable, Iterator, Callable import asyncio from .dispatch import DoesNotExistError, _GLOBAL_DISPATCHER @@ -33,7 +32,7 @@ def __contains__(self, name: str) -> bool: def receiver( - event_name: str|Iterable[str], + event_name: Union[str, Iterable[str]], cache: bool = False, auto_register: bool = False ): From 2c9c5eda5f42c5f5140ecff5e4021742b19b85d2 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 18:49:08 +0000 Subject: [PATCH 09/23] Remove unused print call --- tests/test_global_dispatcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_global_dispatcher.py b/tests/test_global_dispatcher.py index 56ab93b..b629c84 100644 --- a/tests/test_global_dispatcher.py +++ b/tests/test_global_dispatcher.py @@ -14,7 +14,6 @@ def dispatcher_cleanup(): def test_receiver_decorator(dispatcher_cleanup): pydispatch.register_event('foo', 'bar') - print(f'{pydispatch._GLOBAL_DISPATCHER._Dispatcher__events=}') results = {'foo':[], 'bar':[], 'foo_bar':[]} expected = {'foo':[], 'bar':[], 'foo_bar':[]} From 13c7026ce1dd5a00e4c8465c0c98a2e74f00dce6 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Tue, 30 May 2023 19:04:24 +0000 Subject: [PATCH 10/23] Rework doctest to avoid callback ordering issues --- pydispatch/decorators.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pydispatch/decorators.py b/pydispatch/decorators.py index 3c3e7d8..f648145 100644 --- a/pydispatch/decorators.py +++ b/pydispatch/decorators.py @@ -72,17 +72,14 @@ def receiver( Receiving multiple events - >>> @pydispatch.receiver(['foo', 'bar', 'baz']) - ... def on_foobarbaz(value, **kwargs): - ... print(f'on_foobarbaz: "{value}"') - - >>> pydispatch.emit('foo', 1) - on_foo: "1" - on_foobarbaz: "1" - - >>> pydispatch.emit('bar', 2) - on_bar: "2" - on_foobarbaz: "2" + >>> @pydispatch.receiver(['event_one', 'event_two'], auto_register=True) + ... def on_event_one_or_two(value, **kwargs): + ... print(value) + + >>> pydispatch.emit('event_one', 1) + 1 + >>> pydispatch.emit('event_two', 2) + 2 Arguments: From 4c6b6bcd0411684c24f155910aadbfce40e58a38 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Fri, 2 Jun 2023 15:12:15 -0500 Subject: [PATCH 11/23] Add versionadded directive to `register` decorator --- pydispatch/decorators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pydispatch/decorators.py b/pydispatch/decorators.py index f648145..726a35b 100644 --- a/pydispatch/decorators.py +++ b/pydispatch/decorators.py @@ -93,6 +93,7 @@ def receiver( it will be registered on the :ref:`global-dispatcher`. (Default is ``False``) + .. versionadded:: 0.2.2 """ def _decorator(func: Callable): is_async = iscoroutinefunction(func) From 8e5474519ea711d101a4f0afa21d6ab5fa85df79 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Fri, 2 Jun 2023 15:13:09 -0500 Subject: [PATCH 12/23] Add short docstrings to module functions instead of copying --- pydispatch/__init__.py | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/pydispatch/__init__.py b/pydispatch/__init__.py index 7471be9..f03bc0b 100644 --- a/pydispatch/__init__.py +++ b/pydispatch/__init__.py @@ -23,27 +23,51 @@ def register_event(*names): + """Register event (or events) on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.dispatcher.Dispatcher.register_event` + .. versionadded:: 0.2.2 + """ _GLOBAL_DISPATCHER.register_event(*names) decorators._post_register_hook(*names) -register_event.__doc__ = Dispatcher.register_event.__doc__ - def bind(**kwargs): + """Subscribe callbacks to events on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.dispatcher.Dispatcher.bind` + .. versionadded:: 0.2.2 + """ _GLOBAL_DISPATCHER.bind(**kwargs) -bind.__doc__ = Dispatcher.bind.__doc__ def unbind(*args): + """Unbind callbacks from events on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.dispatcher.Dispatcher.unbind` + .. versionadded:: 0.2.2 + """ _GLOBAL_DISPATCHER.unbind(*args) -unbind.__doc__ = Dispatcher.unbind.__doc__ def bind_async(loop, **kwargs): + """Bind async callbacks to events on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.dispatcher.Dispatcher.bind_async` + .. versionadded:: 0.2.2 + """ _GLOBAL_DISPATCHER.bind_async(loop, **kwargs) -bind_async.__doc__ = Dispatcher.bind_async.__doc__ def emit(name, *args, **kwargs): + """Dispatch the event with the given *name* on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.dispatcher.Dispatcher.emit` + .. versionadded:: 0.2.2 + """ return _GLOBAL_DISPATCHER.emit(name, *args, **kwargs) -emit.__doc__ = Dispatcher.emit.__doc__ def get_dispatcher_event(name): + """Retrieve the :class:`~.dispatch.Event` object by the given name + from the :ref:`global-dispatcher` + + .. seealso:: :meth:`.dispatcher.Dispatcher.get_dispatcher_event` + .. versionadded:: 0.2.2 + """ return _GLOBAL_DISPATCHER.get_dispatcher_event(name) -get_dispatcher_event.__doc__ = Dispatcher.get_dispatcher_event.__doc__ From cbe66ad9ba6c81d265aae43b2f89da318e1add4f Mon Sep 17 00:00:00 2001 From: nocarryr Date: Fri, 2 Jun 2023 15:15:53 -0500 Subject: [PATCH 13/23] Add automodule directive with separate toctree for package docs --- doc/source/reference/aioutils.rst | 2 +- doc/source/reference/decorators.rst | 2 +- doc/source/reference/dispatch.rst | 2 +- doc/source/reference/index.rst | 9 +-------- doc/source/reference/properties.rst | 2 +- doc/source/reference/pydispatch-package.rst | 15 +++++++++++++++ doc/source/reference/utils.rst | 2 +- 7 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 doc/source/reference/pydispatch-package.rst diff --git a/doc/source/reference/aioutils.rst b/doc/source/reference/aioutils.rst index 20cd260..29ab1aa 100644 --- a/doc/source/reference/aioutils.rst +++ b/doc/source/reference/aioutils.rst @@ -1,4 +1,4 @@ -pydispatch.aioutils module +:mod:`pydispatch.aioutils` ========================== .. automodule:: pydispatch.aioutils diff --git a/doc/source/reference/decorators.rst b/doc/source/reference/decorators.rst index 4cd2b52..e472449 100644 --- a/doc/source/reference/decorators.rst +++ b/doc/source/reference/decorators.rst @@ -1,4 +1,4 @@ -pydispatch.decorators module +:mod:`pydispatch.decorators` ============================ .. automodule:: pydispatch.decorators diff --git a/doc/source/reference/dispatch.rst b/doc/source/reference/dispatch.rst index ad127ef..a692322 100644 --- a/doc/source/reference/dispatch.rst +++ b/doc/source/reference/dispatch.rst @@ -1,4 +1,4 @@ -pydispatch.dispatch module +:mod:`pydispatch.dispatch` ========================== .. automodule:: pydispatch.dispatch diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst index d15bc26..f59a56f 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -4,11 +4,4 @@ Reference .. toctree:: :maxdepth: 3 - dispatch - properties - decorators - utils - aioutils - -.. automodule:: pydispatch - :members: + pydispatch-package diff --git a/doc/source/reference/properties.rst b/doc/source/reference/properties.rst index 1f75b93..a55c02d 100644 --- a/doc/source/reference/properties.rst +++ b/doc/source/reference/properties.rst @@ -1,4 +1,4 @@ -pydispatch.properties module +:mod:`pydispatch.properties` ============================ .. automodule:: pydispatch.properties diff --git a/doc/source/reference/pydispatch-package.rst b/doc/source/reference/pydispatch-package.rst new file mode 100644 index 0000000..b1fa420 --- /dev/null +++ b/doc/source/reference/pydispatch-package.rst @@ -0,0 +1,15 @@ +:mod:`pydispatch` +================= + +.. automodule:: pydispatch + :members: + + +.. toctree:: + :maxdepth: 3 + + dispatch + properties + decorators + utils + aioutils diff --git a/doc/source/reference/utils.rst b/doc/source/reference/utils.rst index d85d392..cca825d 100644 --- a/doc/source/reference/utils.rst +++ b/doc/source/reference/utils.rst @@ -1,4 +1,4 @@ -pydispatch.utils module +:mod:`pydispatch.utils` ======================= .. automodule:: pydispatch.utils From eb5bc0cf1599bf56db67c41a106301e66b2f99fb Mon Sep 17 00:00:00 2001 From: nocarryr Date: Fri, 2 Jun 2023 15:17:25 -0500 Subject: [PATCH 14/23] Add basic usage docs for global dispatcher --- doc/source/global-dispatcher.rst | 103 +++++++++++++++++++++++++++++++ doc/source/properties.rst | 2 + 2 files changed, 105 insertions(+) diff --git a/doc/source/global-dispatcher.rst b/doc/source/global-dispatcher.rst index e34363d..81b744d 100644 --- a/doc/source/global-dispatcher.rst +++ b/doc/source/global-dispatcher.rst @@ -3,3 +3,106 @@ Global Dispatcher ================= +.. currentmodule:: pydispatch + +.. versionadded:: 0.2.2 + +At the module-level, most of the functionality of :class:`~.dispatch.Dispatcher` +can be used directly as a `singleton`_ instance. Note that this interface only +supports event dispatching (not :ref:`properties`). + +When used this way, the concept is similar to the `Signals Framework`_ found in Django. + +Basic Usage +----------- + +Events can be registered using :func:`register_event`, connected to callbacks +using :func:`bind` and dispatched with the :func:`emit` function. + +>>> import pydispatch + +>>> def my_callback(message, **kwargs): +... print(f'my_callback: "{message}"') + +>>> # register 'event_a' as an event and bind it to my_callback +>>> pydispatch.register_event('event_a') +>>> pydispatch.bind(event_a=my_callback) + +>>> # emit the event +>>> pydispatch.emit('event_a', 'hello') +my_callback: "hello" + +>>> # unbind the callback +>>> pydispatch.unbind(my_callback) +>>> pydispatch.emit('event_a', 'still there?') + +>>> # (Nothing printed) + + +The @receiver Decorator +----------------------- + +To simplify binding callbacks to events, the :func:`~decorators.receiver` decorator may be used. + +>>> from pydispatch import receiver + +>>> @receiver('event_a') +... def my_callback(message, **kwargs): +... print(f'my_callback: "{message}"') + +>>> pydispatch.emit('event_a', 'hello again!') +my_callback: "hello again!" + +Note that there is currently no way to :func:`unbind` a callback defined in this way. + + +Arguments +^^^^^^^^^ + +If the event name has not been registered beforehand, the ``cache`` and ``auto_register`` +arguments to :func:`~decorators.receiver` may be used + +cache +""""" + +The ``cache`` argument stores the callback and will bind it to the event +once it is registered. + +>>> # No event named 'foo' exists yet +>>> @receiver('foo', cache=True) +... def on_foo(message, **kwargs): +... print(f'on_foo: "{message}"') + +>>> # on_foo will be connected after the call to register_event +>>> pydispatch.register_event('foo') +>>> pydispatch.emit('foo', 'bar') +on_foo: "bar" + +auto_register +""""""""""""" + +The ``auto_register`` argument will immediately register the event if it does not exist + +>>> @receiver('bar', auto_register=True) +... def on_bar(message, **kwargs): +... print(f'on_bar: "{message}"') + +>>> pydispatch.emit('bar', 'baz') +on_bar: "baz" + + +Async Support +^^^^^^^^^^^^^ + +If the decorated callback is a :term:`coroutine function` or method, +the :class:`EventLoop ` returned by +:func:`asyncio.get_event_loop` will be used as the ``loop`` argument to +:func:`bind_async`. + +This will in most cases be the desired behavior unless multiple event loops +(in separate threads) are being used. + + + +.. _singleton: https://en.wikipedia.org/wiki/Singleton_pattern +.. _Signals Framework: https://docs.djangoproject.com/en/4.2/topics/signals/ diff --git a/doc/source/properties.rst b/doc/source/properties.rst index 6041218..de8afa1 100644 --- a/doc/source/properties.rst +++ b/doc/source/properties.rst @@ -1,3 +1,5 @@ +.. _properties: + Properties ========== From bd35c30a5259335272c0345a18ee9a235d6e8759 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Fri, 2 Jun 2023 15:55:26 -0500 Subject: [PATCH 15/23] Add test setup/teardown for global-dispatcher doctest --- doc/source/conftest.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 doc/source/conftest.py diff --git a/doc/source/conftest.py b/doc/source/conftest.py new file mode 100644 index 0000000..0b8d2f6 --- /dev/null +++ b/doc/source/conftest.py @@ -0,0 +1,10 @@ +import pytest + +@pytest.fixture(autouse=True) +def dispatcher_cleanup(): + import pydispatch + pydispatch._GLOBAL_DISPATCHER._Dispatcher__events.clear() + pydispatch.decorators._CACHED_CALLBACKS.cache.clear() + yield + pydispatch._GLOBAL_DISPATCHER._Dispatcher__events.clear() + pydispatch.decorators._CACHED_CALLBACKS.cache.clear() From 31d56a067e18e96050eb72a4e38d771434851b0a Mon Sep 17 00:00:00 2001 From: nocarryr Date: Fri, 2 Jun 2023 16:41:59 -0500 Subject: [PATCH 16/23] Use module functions wherever possible in decorator --- pydispatch/decorators.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pydispatch/decorators.py b/pydispatch/decorators.py index 726a35b..cde8fbe 100644 --- a/pydispatch/decorators.py +++ b/pydispatch/decorators.py @@ -1,6 +1,7 @@ from typing import Union, Iterable, Iterator, Callable import asyncio +import pydispatch from .dispatch import DoesNotExistError, _GLOBAL_DISPATCHER from .utils import iscoroutinefunction, WeakMethodContainer @@ -103,21 +104,18 @@ def _decorator(func: Callable): else: event_names = event_name bind_kwargs = {ev:func for ev in event_names} - try: - if is_async: - bind_kwargs['__aio_loop__'] = asyncio.get_event_loop() - _GLOBAL_DISPATCHER.bind(**bind_kwargs) - except DoesNotExistError: - if not any([cache, auto_register]): - raise + if auto_register or cache: for name in event_names: if not _GLOBAL_DISPATCHER._has_event(name): if auto_register: - _GLOBAL_DISPATCHER.register_event(name) + pydispatch.register_event(name) elif cache: _CACHED_CALLBACKS.add(name, func) - if auto_register: - _GLOBAL_DISPATCHER.bind(**bind_kwargs) + del bind_kwargs[name] + if len(bind_kwargs): + if is_async: + bind_kwargs['__aio_loop__'] = asyncio.get_event_loop() + pydispatch.bind(**bind_kwargs) return func return _decorator @@ -131,5 +129,7 @@ def _post_register_hook(*names): bind_kwargs = {name:cb for cb in _CACHED_CALLBACKS.get(name)} is_async = any((iscoroutinefunction(cb) for cb in bind_kwargs.values())) if is_async: - bind_kwargs['__aio_loop__'] = asyncio.get_event_loop() - _GLOBAL_DISPATCHER.bind(**bind_kwargs) + loop = asyncio.get_event_loop() + pydispatch.bind_async(loop, **bind_kwargs) + else: + pydispatch.bind(**bind_kwargs) From 856a5c237e24460d18f1a3c75917d1c3fbb028d0 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Fri, 2 Jun 2023 16:42:44 -0500 Subject: [PATCH 17/23] Test for existing events with cache/auto_register --- tests/test_global_dispatcher.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_global_dispatcher.py b/tests/test_global_dispatcher.py index b629c84..7b4cf8c 100644 --- a/tests/test_global_dispatcher.py +++ b/tests/test_global_dispatcher.py @@ -77,6 +77,42 @@ def on_foo(*args, **kwargs): assert results == [((1,), {})] +def test_receiver_decorator_registered_auto_register(dispatcher_cleanup): + + with pytest.raises(pydispatch.DoesNotExistError): + pydispatch.get_dispatcher_event('foo') + + pydispatch.register_event('foo') + + assert pydispatch.get_dispatcher_event('foo') is not None + + results = [] + + @receiver('foo', auto_register=True) + def on_foo(*args, **kwargs): + results.append((args, kwargs)) + + pydispatch.emit('foo', 2) + + assert results == [((2,), {})] + +def test_receiver_decorator_registered_cache(dispatcher_cleanup): + with pytest.raises(pydispatch.DoesNotExistError): + pydispatch.get_dispatcher_event('foo') + + pydispatch.register_event('foo') + assert pydispatch.get_dispatcher_event('foo') is not None + + results = [] + + @receiver('foo', cache=True) + def on_foo(*args, **kwargs): + results.append((args, kwargs)) + + pydispatch.emit('foo', 3) + + assert results == [((3,), {})] + @pytest.mark.asyncio async def test_decorator_async_cache(dispatcher_cleanup): results = [] From f91d96632ede7b44ed79c01fc0f96a36da88075f Mon Sep 17 00:00:00 2001 From: nocarryr Date: Fri, 2 Jun 2023 16:46:40 -0500 Subject: [PATCH 18/23] Remove unused function --- pydispatch/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pydispatch/utils.py b/pydispatch/utils.py index 1aa0d4c..2668f4b 100644 --- a/pydispatch/utils.py +++ b/pydispatch/utils.py @@ -12,9 +12,6 @@ def get_method_vars(m): def isfunction(m): return isinstance(m, types.FunctionType) -def ismethod(m): - return isinstance(m, types.MethodType) - def iscoroutinefunction(obj): return asyncio.iscoroutinefunction(obj) From 11ecfefdfec5bcfcd3aea60afd73879f30bb3103 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Thu, 22 Jun 2023 15:53:19 +0000 Subject: [PATCH 19/23] Add version directive to decorators module --- doc/source/reference/decorators.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/source/reference/decorators.rst b/doc/source/reference/decorators.rst index e472449..e42b9d2 100644 --- a/doc/source/reference/decorators.rst +++ b/doc/source/reference/decorators.rst @@ -1,5 +1,7 @@ :mod:`pydispatch.decorators` ============================ +.. versionadded:: 0.2.2 + .. automodule:: pydispatch.decorators :members: From 934fd95fad772471154ec9e38d88d1795072d156 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Thu, 22 Jun 2023 11:23:34 -0500 Subject: [PATCH 20/23] Pin pytest to 7.3.1 Issue related to `testpaths` option and nested conftest modules causes ImportErrors in the sphinx plugin tests. Needs further investigation, but just pin it for now Related changes: https://github.com/pytest-dev/pytest/pull/10988 --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3eaa900..c2ce3ce 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -pytest +pytest==7.3.1 pytest-asyncio pytest-cov pytest-doctestplus From 8d33e205bcd7f1db89239585f24531ecf8829a50 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Thu, 22 Jun 2023 11:32:22 -0500 Subject: [PATCH 21/23] Disable `fail-fast` on ci build matrix --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d42031..b7dc9b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: runs-on: ubuntu-20.04 strategy: + fail-fast: false matrix: python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11"] From 3fd513086d8abb11e0f0cf33c870a625b620a357 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Thu, 22 Jun 2023 11:34:18 -0500 Subject: [PATCH 22/23] Use pytest<=7.3.1 --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c2ce3ce..d1bdf06 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -pytest==7.3.1 +pytest<=7.3.1 pytest-asyncio pytest-cov pytest-doctestplus From e33b432f9ce0ae15c01247e0eda5f88a66d441f8 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Thu, 22 Jun 2023 11:37:35 -0500 Subject: [PATCH 23/23] Correct `Dispatcher` method refs --- pydispatch/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pydispatch/__init__.py b/pydispatch/__init__.py index f03bc0b..0ee83c0 100644 --- a/pydispatch/__init__.py +++ b/pydispatch/__init__.py @@ -25,7 +25,7 @@ def register_event(*names): """Register event (or events) on the :ref:`global-dispatcher` - .. seealso:: :meth:`.dispatcher.Dispatcher.register_event` + .. seealso:: :meth:`.Dispatcher.register_event` .. versionadded:: 0.2.2 """ _GLOBAL_DISPATCHER.register_event(*names) @@ -34,7 +34,7 @@ def register_event(*names): def bind(**kwargs): """Subscribe callbacks to events on the :ref:`global-dispatcher` - .. seealso:: :meth:`.dispatcher.Dispatcher.bind` + .. seealso:: :meth:`.Dispatcher.bind` .. versionadded:: 0.2.2 """ _GLOBAL_DISPATCHER.bind(**kwargs) @@ -42,7 +42,7 @@ def bind(**kwargs): def unbind(*args): """Unbind callbacks from events on the :ref:`global-dispatcher` - .. seealso:: :meth:`.dispatcher.Dispatcher.unbind` + .. seealso:: :meth:`.Dispatcher.unbind` .. versionadded:: 0.2.2 """ _GLOBAL_DISPATCHER.unbind(*args) @@ -50,7 +50,7 @@ def unbind(*args): def bind_async(loop, **kwargs): """Bind async callbacks to events on the :ref:`global-dispatcher` - .. seealso:: :meth:`.dispatcher.Dispatcher.bind_async` + .. seealso:: :meth:`.Dispatcher.bind_async` .. versionadded:: 0.2.2 """ _GLOBAL_DISPATCHER.bind_async(loop, **kwargs) @@ -58,7 +58,7 @@ def bind_async(loop, **kwargs): def emit(name, *args, **kwargs): """Dispatch the event with the given *name* on the :ref:`global-dispatcher` - .. seealso:: :meth:`.dispatcher.Dispatcher.emit` + .. seealso:: :meth:`.Dispatcher.emit` .. versionadded:: 0.2.2 """ return _GLOBAL_DISPATCHER.emit(name, *args, **kwargs) @@ -67,7 +67,7 @@ def get_dispatcher_event(name): """Retrieve the :class:`~.dispatch.Event` object by the given name from the :ref:`global-dispatcher` - .. seealso:: :meth:`.dispatcher.Dispatcher.get_dispatcher_event` + .. seealso:: :meth:`.Dispatcher.get_dispatcher_event` .. versionadded:: 0.2.2 """ return _GLOBAL_DISPATCHER.get_dispatcher_event(name)