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"] 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() diff --git a/doc/source/global-dispatcher.rst b/doc/source/global-dispatcher.rst new file mode 100644 index 0000000..81b744d --- /dev/null +++ b/doc/source/global-dispatcher.rst @@ -0,0 +1,108 @@ +.. _global-dispatcher: + +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/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 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 ========== 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 new file mode 100644 index 0000000..e42b9d2 --- /dev/null +++ b/doc/source/reference/decorators.rst @@ -0,0 +1,7 @@ +:mod:`pydispatch.decorators` +============================ + +.. versionadded:: 0.2.2 + +.. automodule:: pydispatch.decorators + :members: 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 f522d1f..f59a56f 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -4,7 +4,4 @@ Reference .. toctree:: :maxdepth: 3 - dispatch - properties - utils - aioutils + 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 diff --git a/pydispatch/__init__.py b/pydispatch/__init__.py index 7ff4b3d..0ee83c0 100644 --- a/pydispatch/__init__.py +++ b/pydispatch/__init__.py @@ -16,4 +16,58 @@ UserWarning) 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): + """Register event (or events) on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.Dispatcher.register_event` + .. versionadded:: 0.2.2 + """ + _GLOBAL_DISPATCHER.register_event(*names) + decorators._post_register_hook(*names) + +def bind(**kwargs): + """Subscribe callbacks to events on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.Dispatcher.bind` + .. versionadded:: 0.2.2 + """ + _GLOBAL_DISPATCHER.bind(**kwargs) + +def unbind(*args): + """Unbind callbacks from events on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.Dispatcher.unbind` + .. versionadded:: 0.2.2 + """ + _GLOBAL_DISPATCHER.unbind(*args) + +def bind_async(loop, **kwargs): + """Bind async callbacks to events on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.Dispatcher.bind_async` + .. versionadded:: 0.2.2 + """ + _GLOBAL_DISPATCHER.bind_async(loop, **kwargs) + +def emit(name, *args, **kwargs): + """Dispatch the event with the given *name* on the :ref:`global-dispatcher` + + .. seealso:: :meth:`.Dispatcher.emit` + .. versionadded:: 0.2.2 + """ + return _GLOBAL_DISPATCHER.emit(name, *args, **kwargs) + +def get_dispatcher_event(name): + """Retrieve the :class:`~.dispatch.Event` object by the given name + from the :ref:`global-dispatcher` + + .. seealso:: :meth:`.Dispatcher.get_dispatcher_event` + .. versionadded:: 0.2.2 + """ + return _GLOBAL_DISPATCHER.get_dispatcher_event(name) 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: diff --git a/pydispatch/decorators.py b/pydispatch/decorators.py new file mode 100644 index 0000000..cde8fbe --- /dev/null +++ b/pydispatch/decorators.py @@ -0,0 +1,135 @@ +from typing import Union, Iterable, Iterator, Callable +import asyncio + +import pydispatch +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: Union[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(['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: + 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``) + + .. versionadded:: 0.2.2 + """ + 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} + if auto_register or cache: + for name in event_names: + if not _GLOBAL_DISPATCHER._has_event(name): + if auto_register: + pydispatch.register_event(name) + elif cache: + _CACHED_CALLBACKS.add(name, func) + 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 + + +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: + loop = asyncio.get_event_loop() + pydispatch.bind_async(loop, **bind_kwargs) + else: + pydispatch.bind(**bind_kwargs) diff --git a/pydispatch/dispatch.py b/pydispatch/dispatch.py index fce58ff..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'] @@ -377,3 +379,15 @@ def emission_lock(self, name): """ e = self.get_dispatcher_event(name) return e.emission_lock + + +class _GlobalDispatcher(Dispatcher): + def _has_event(self, name): + try: + self.get_dispatcher_event(name) + except KeyError: + return False + return True + + +_GLOBAL_DISPATCHER = _GlobalDispatcher() diff --git a/pydispatch/utils.py b/pydispatch/utils.py index 9984f1d..2668f4b 100644 --- a/pydispatch/utils.py +++ b/pydispatch/utils.py @@ -9,6 +9,9 @@ def get_method_vars(m): obj = m.__self__ return f, obj +def isfunction(m): + return isinstance(m, types.FunctionType) + def iscoroutinefunction(obj): return asyncio.iscoroutinefunction(obj) @@ -28,7 +31,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 +43,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) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3eaa900..d1bdf06 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -pytest +pytest<=7.3.1 pytest-asyncio pytest-cov pytest-doctestplus diff --git a/tests/test_global_dispatcher.py b/tests/test_global_dispatcher.py new file mode 100644 index 0000000..7b4cf8c --- /dev/null +++ b/tests/test_global_dispatcher.py @@ -0,0 +1,151 @@ +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') + + 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,), {})] + +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 = [] + 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,), {})]