So I'm trying to implement something akin to C# events in Python as a decorator for methods:
from __future__ import annotations
from typing import *
import functools
def event(function: Callable) -> EventDispatcher:
return EventDispatcher(function)
class EventDispatcher:
def __init__(self, function: Callable) -> None:
functools.update_wrapper(self, function)
self._instance: Any | None = None
self._function: Callable = function
self._callbacks: Set[Callable] = set()
def __get__(self, instance: Any, _: Any) -> EventDispatcher:
self._instance = instance
return self
def __iadd__(self, callback: Callable) -> EventDispatcher:
self._callbacks.add(callback)
return self
def __isub__(self, callback: Callable) -> EventDispatcher:
self._callbacks.remove(callback)
return self
def __call__(self, *args: Any, **kwargs: Any) -> Any:
for callback in self._callbacks:
callback(*args, **kwargs)
return self._function(self._instance, *args, **kwargs)
But when I decorate a class method with @event
and later on call the decorated method, the method will be invoked on the incorrect instance in some cases.
Good Case:
class A:
@event
def my_event(self) -> None:
print(self)
class B:
def __init__(self, a: A) -> None:
a.my_event += self.my_callback
def my_callback(self) -> None:
pass
a0 = A()
a1 = A()
# b = B(a0)
a1.my_event()
a0.my_event()
The above code will result in the output:
<__main__.A object at 0x00000170AA15FCA0>
<__main__.A object at 0x00000170AA15FC70>
Evidently the function my_event()
is called twice, each time with a different instance as expected.
Bad Case:
Taking the code from the good case and commenting in the line # b = B(a0)
results in the output:
<__main__.A object at 0x000002067650FCA0>
<__main__.A object at 0x000002067650FCA0>
Now the method my_event()
is called twice, too. But on the same instance.
Question:
I think the issue boils down to EventDispatcher.__get__()
not being called in the bad case. So my question is, why is EventDispatcher.__get__()
not called and how do I fix my implementation?
The problem lies in the initializer of B
:
a.my_event += self.my_callback
Note that the +=
operator is not just a simple call to __iadd__
, but actually equivalent to:
a.my_event = a.my_event.__iadd__(self.my_callback)
This is also the reason why your __iadd__
method needs to return self.
Because the class EventDispatcher
has only __get__
but no __set__
, the result will be written to the instance's attribute during assignment, so the above statement is equivalent to:
a.__dict__['my_event'] = A.__dict__['my_event'].__get__(a, A).__iadd__(self.my_callback)
Simple detection:
print(a0.__dict__)
b = B(a0)
print(a0.__dict__)
Output:
{}
{'my_event': <__main__.EventDispatcher object at 0x00000195218B3FD0>}
When a0
calls my_event
on the last line, it only takes the instance of EventDispatcher
from a0.__dict__
(instance attribute access takes precedence over non data descriptors, refer to invoking descriptor), and does not trigger the __get__
method. Therefore, A.__dict__['my_event']._instance
will not be updated.
The simplest repair way is to add an empty __set__
method to the definition of EventDispatcher
:
class EventDispatcher:
...
def __set__(self, instance, value):
pass
...
Output:
<__main__.A object at 0x000002A36B9B3D30>
<__main__.A object at 0x000002A36B9B3D00>