#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Created by pat on 3/25/18
"""
.. currentmodule:: evenz.events
.. moduleauthor:: Pat Daburu <pat@daburu.net>
Import this module to make event handling a little simpler.
"""
import inspect
from typing import Any, Callable, Iterable, List, NamedTuple, Tuple
import sys
from functools import partial, wraps
[docs]class Args(NamedTuple):
"""
Extend this named tuple to provide easy-to-understand arguments for your
events.
"""
sender: Any #: the originator of the event
[docs]class Event(object):
"""
An event object wraps a function and notifies a set of handlers when the
function is called.
"""
[docs] def __init__(self, f: Callable, sender: Any = None):
"""
:param f: the function that triggers the event
:param sender: the sender of the event
"""
self._f: Callable = f
self._handlers: List[Callable] = []
self._sender = sender
@property
def handlers(self) -> Iterable[Callable]:
"""
Get the handlers for this function.
:return: an iteration of the handlers.
"""
return iter(self._handlers)
[docs] def subscribe(self, handler: Callable):
"""
Subscribe a handler function to this event.
:param handler: the handler
.. note::
You can also use the += operator.
"""
# Sanity check: The handler parameter should be a handler function.
if not isinstance(handler, Callable):
raise ValueError(f'{type(handler)} is not callable.')
self._handlers.append(handler)
return self
[docs] def unsubscribe(self, handler: Callable):
"""
Unsubscribe a handler function from this event.
:param handler: the handler
.. note::
You can also use the -= operator.
"""
self._handlers.remove(handler)
return self
def __iadd__(self, other):
# Subscribe to the handler.
return self.subscribe(other)
def __isub__(self, other):
# Unsubscribe from the handler.
return self.unsubscribe(other)
def __and__(self, other):
a = set(self._handlers)
b = set(other)
ab = a & b
self._handlers = [h for h in self._handlers if h in ab]
return self
def __or__(self, other):
a = set(self._handlers)
b = set(other)
ab = a | b
self._handlers = [h for h in self._handlers if h in ab]
return self
[docs] def trigger(self, *args, **kwargs):
"""
Trigger the event.
"""
# Just call all the handlers.
for h in self._handlers:
if self._sender is not None:
h(self._sender, *args, **kwargs)
else:
h(*args, **kwargs)
def __call__(self, *args, **kwargs):
# The `Event` is callable so that it can be called like a function.
# When that happens it will first call the function for which it was
# created...
self._f(*args, **kwargs)
# ...then trigger all the handlers.
self.trigger(*args, **kwargs)
[docs]def observable(cls):
"""
Use this decorator to mark a class that exposes events.
:param cls: the class
:return: the class
.. seealso::
If you are using this decorator, you probably also want to use
:py:func:`event` on some of the methods.
"""
# For starters, we need the class' original __init__ method.
cls_init = cls.__init__
@wraps(cls.__init__)
def init(self, *args, **kwargs):
"""
This is the replacement for the class' initializer.
"""
# Call the class' original __init__ method.
cls_init(self, *args, **kwargs)
# Retrieve all the methods marked as events.
event_members: List[Tuple[str, Event]] = [
member for member in inspect.getmembers(self)
if hasattr(member[1], '__is_event__')
and member[1].__is_event__
]
for event_member in event_members:
# Get the attribute name and bound method.
name_, event_method = event_member
# If the method we found is a bound method...
if event_method.__self__ is not None:
# ...create a new event with a new function that passes this
# instance in as the first positional (i.e. the "self"
# parameter).
setattr(
self,
name_,
Event(
f=partial(event_method.__func__.__func__, self),
sender=self
)
)
else:
# Otherwise, we don't need to supply the 'self' parameter to
# the function wrapped by the Event object.
setattr(
self,
name_,
Event(
f=event_method.__func__.__func__,
sender=cls
)
)
# Replace the class' original __init__ method with our own.
cls.__init__ = init
# The caller gets back the original class.
return cls
[docs]def event(f: Callable) -> Event:
"""
Decorate a function or method to create an :py:class:`Event`.
:param f: the function.
:return: the event
.. seealso::
If you are decorating a method within a class, you'll need to use the
:py:func:`observable` class decorator on the class as well.
"""
# Create an event object to wrap the function.
e = Event(f=f)
@wraps(f)
def _f(*args, **kwargs):
e.trigger(*args, **kwargs)
# Inject some extra doc stuff into the docstring.
_f.__doc__ = f'⚡ :py:class:`evenz.events.Event`\n{f.__doc__}'
# Supply the function with some meta information. (This will mostly be used
# by the @observable decorator.)
setattr(_f, 'event', e)
setattr(_f, '__is_event__', True)
setattr(_f, '__func__', f)
# If the function is defined directly within a module...
if f in [_ for _ in inspect.getmembers(f.__module__, inspect.isfunction)]:
# ...go get the module.
module_ = sys.modules[f.__module__]
# Construct some new documentation and append it to the module's
# documentation.
doc_parts = [
module_.__doc__,
f'⚡ :py:class:`evenz.events.Event` :py:func:`{ f.__name__ }`',
]
doc_parts = filter(lambda p: p is not None, doc_parts)
module_.__doc__ = '\n'.join(doc_parts)
# Return the new function.
return _f