Getting Started¶
Pythons: Python 3.5,3.6
PyPI package name: metapensiero.signal
dependencies: weakreflist
Installation¶
To install the package execute the following command:
$ pip install metapensiero.signal
Usage¶
The most significant component provided by this package is the class
Signal which is very simple to use. It has three main operations:
connect(), disconnect() and
notify().
The first two are used to manage the subscriptions of handlers to the signal
and the latter is used to actually execute all the handlers in the order that
they had been connected, passing the arguments in the
notify() call to each one, while collecting the result of
the execution that will returned to the notify()’s
caller. Let’s see a simple example:
from metapensiero.signal import Signal
asignal = Signal()
called = {
'handler1': False,
'handler2': False
}
def handler1(arg, kw):
called['handler1'] = (arg, kw)
return 'result1'
def handler2(arg, kw):
called['handler2'] = (arg, kw)
return 'result2'
asignal.connect(handler1)
asignal.connect(handler2)
result = asignal.notify(1, kw='a')
>>> called == {'handler1': (1, 'a'), 'handler2': (1, 'a')}
True
As you can see, to have a function or method called when a signal is fired
you just have to call the connect() method of the signal
instance. To remove that same method you can use the
disconnect() method.
As you can see above, the way to fire an event is by calling the
notify() method and any argument or keyword argument passed
to that function will be passed on each handler execution.
A Signal has its __call__() method defined as an alias to
its notify() method so it can also be called as:
>>> result = asignal(2, kw='b')
>>> called == {'handler1': (2, 'b'), 'handler2': (2, 'b')}
True
When a notification is executed, the return values from the handlers are
collected and are available inside notify()’s return value,
which is always an instance of the utility class
MultipleResults.
>>> type(result)
<class 'metapensiero.signal.utils.MultipleResults'>
>>> result.results
('result1', 'result2')
The signal keeps a weak reference to each handler so you don’t have to worry about dangling references:
>>> len(asignal.subscribers)
2
It’s possible to remove all the connected handlers by invoking the
clear() method.
>>> asignal.clear()
>>> len(asignal.subscribers)
0
Asynchronous signal handlers¶
Not only you can have synchronous handlers, but you can have asynchronous handlers as well:
import asyncio
from metapensiero.signal import Signal
called = {
'handler1': False,
'handler2': False
}
asignal = Signal()
async def handler1(arg, kw):
called['handler1'] = (arg, kw)
return 'result1'
def handler2(arg, kw):
called['handler2'] = (arg, kw)
return 'result2'
asignal.connect(handler1)
asignal.connect(handler2)
result = asignal.notify(1, kw='a')
What will be the result this time? As you may immagine, at this point
called variable is:
>>> called == {'handler1': False, 'handler2': (1, 'a')}
True
This is because handler1() which is a coroutine function doesn’t execute
immediately, but instead it returns a coroutine which needs to be driven by
the event loop to be executed. How to understand if the results are ready or
not when the presence of coroutines among the subscribers is unknown? The
result value (which is an instance of MultipleResults) can
help with that:
>>> print('done?', result.done)
done? False
>>> print('any result?', result.results)
any result? None
>>> print('any coroutine?', result.has_async)
any coroutine? True
As you can see, even if one of the handlers is a normal callable, its result isn’t available until all the handlers are executed. But to do that, we need a loop, and something to “pull” the asynchronous results!
But we are lucky, as the result object is also an awaitable object:
>>> import inspect
>>> inspect.isawaitable(result)
True
So we can just do:
>>> loop = asyncio.get_event_loop()
>>> awaited_result = loop.run_until_complete(result)
Let’s verify what we got:
>>> print('done?', result.done)
done? True
>>> print('any result?', result.results)
any result? ('result1', 'result2')
>>> print('any coroutine?', result.has_async)
any coroutine? False
When awaited, the result object will return its results attribute so
that you always can code notifications like:
# inside your coroutine...
result = await mysignal.notify(foo, bar)
and you will not have to deal with MultipleResults, here
result will be always a simple tuple. Just to verify in out case:
>>> type(awaited_result)
<class 'tuple'>
>>> awaited_result is result.results
True
And finally, as it is expected:
>>> called == {'handler1': (1, 'a'), 'handler2': (1, 'a')}
True
Use with classes¶
A Signal instance can also be used as a member of another
class and when that class or one of its ancestors has
SignalAndHandlerInitMeta as metaclass it’s possible to have
class-defined handlers by using the handler decorator that takes the name
of a signal as its unique parameter:
from metapensiero.signal import Signal, SignalAndHandlerInitMeta, handler
class A(metaclass=SignalAndHandlerInitMeta):
click = Signal()
def __init__(self):
self.called = False
@handler('click')
def onclick(self, arg, kw):
self.called = (arg, kw)
>>> a = A()
>>> a.called
False
>>> a.click.notify(1, kw='a')
<metapensiero.signal.utils.MultipleResults ...>
>>> a.called
(1, 'a')
As you can see, handlers are declare at class-definition time, but the handler
receive a as self when the click.notify() is called on the
instance. The use of an helper metaclass ensures also that when the name
specified to handler doesn’t match any signal name defined in the class or
any of its ancestor, the class definition will result in an error:
def class_define_failure():
class Wrong(metaclass=SignalAndHandlerInitMeta):
@handler('click')
def onclick2(self):
pass
return Wrong
def class_define_pass():
class Right(A):
@handler('click')
def onclick2(self):
pass
return Right
>>> class_define_pass()
<class 'Right'>
>>> class_define_failure()
Traceback (most recent call last):
...
metapensiero.signal.utils.SignalError: Cannot find a signal named 'click'
Of course a class-level handler can be async:
import asyncio
class B(metaclass=SignalAndHandlerInitMeta):
click = Signal()
def __init__(self):
self.called = False
self.called2 = False
@handler('click')
def onclick(self, arg, kw):
self.called = (arg, kw)
@handler('click')
async def click2(self, arg, kw):
self.called2 = (arg, kw)
b = B()
>>> b.called
False
>>> b.called2
False
>>> result = b.click.notify(1, kw='a')
>>> b.called
(1, 'a')
>>> b.called2
False
>>> loop = asyncio.get_event_loop()
>>> loop.run_until_complete(result)
(None, None)
>>> b.called2
(1, 'a')
You can use the Signal class without user class instrumentation, but you
will have to do per-instance subscriptions by yourself, by connecting them in
the __init__() body, like:
class C:
# the name here is needed for classes that don't explicitly support
# signals
click = Signal(name='click')
def __init__(self):
self.called = False
self.click.connect(self.onclick)
def onclick(self, arg, kw):
self.called = (arg, kw)
>>> c = C()
>>> c.called
False
>>> c.click.notify(1, kw='c')
<metapensiero.signal.utils.MultipleResults ...>
>>> c.called
(1, 'c')
Extensibility¶
Signals support two way to extend their functionality. The first is
global and is intended as a way to plug in signals into other event
systems. Please have a look at the code in external.py and the
corresponding tests to learn how to use those abstract classes, they
give you a way to tap into signal’s machinery to do your stuff.
The second way is per-signal and allows you to define three functions
to wrap around notify(), connect(), disconnect() and
attach them to each instance of signal via decorators, much like with
builtins properties.
Each of these functions will receive all the relevant arguments to customize the behavior of the internal signal methods and will receive another argument that every function has to call in order to trigger the default behavior. The return value of your wrapper function will be returned to the calling context instead of default return values.
Here is an example, pay attention to the signature of each wrapper beacuse you have to respect that:
from metapensiero.signal import Signal, SignalAndHandlerInitMeta, handler
c = dict(called=0, connnect_handler=None, handler_called=0, handler_args=None,
disconnnect_handler=None, handler2_called=0, handler2_args=None)
class A(metaclass=SignalAndHandlerInitMeta):
@Signal
def click(self, subscribers, notify, *args, **kwargs):
c['called'] += 1
c['wrap_args'] = (args, kwargs)
assert len(subscribers) == 2
assert isinstance(self, A)
notify('foo', k=2)
return 'mynotify'
@click.on_connect
def click(self, handler, subscribers, connect, notify):
c['called'] += 1
c['connect_handler'] = handler
assert len(subscribers) == 0
connect(handler)
return 'myconnect'
@click.on_disconnect
def click(self, handler, subscribers, disconnect, notify):
c['called'] += 1
c['disconnect_handler'] = handler
assert len(subscribers) == 1
disconnect(handler)
return 'mydisconnect'
@handler('click')
def handler(self, *args, **kwargs):
c['handler_called'] += 1
c['handler_args'] = (args, kwargs)
a = A()
def handler2(*args, **kwargs):
c['handler2_called'] += 1
c['handler2_args'] = (args, kwargs)
res = a.click.connect(handler2)
assert res == 'myconnect'
res = a.click.notify('bar', k=1)
assert res == 'mynotify'
res = a.click.disconnect(handler2)
assert res == 'mydisconnect'
assert c['called'] == 3
assert c['wrap_args'] == (('bar',), {'k': 1})
assert c['handler_called'] == 1
assert c['handler_args'] == (('foo',), {'k': 2})
assert c['handler2_called'] == 1
assert c['handler2_args'] == (('foo',), {'k': 2})
assert c['disconnect_handler'] == handler2
assert c['connect_handler'] == handler2
As you can see, with this way of managing wrappers to default behaviors, you can modify arguments, return customized values or even avoid triggering the default behavior.
There are cases when you want to notify the callback during
on_connect or on_disconnect wrappers, for example when your
handler has the chance of being connected too late to the signal,
where a notification has been delivered already. In such cases you may
want to check for this situation in the wrapper and immediately notify
the late callback if it’s the case.
The connect and disconnect wrappers parameter of the preceding example
will be called with one more parameter, a function notify() that will take
the callback as first argument, and then any other argument that will be
passed to the handler. So, let’s see and example:
class A(metaclass=SignalAndHandlerInitMeta):
click = Signal()
@click.on_connect
def click(self, handler, subscribers, connect, notify):
if self.clicked:
notify(handler)
connect(handler)
def __init__(self):
self.clicked = False
@handler
def click_handler(self):
self.clicked = True