Search code examples
pythonpython-decorators

Why is my Python decorator class's __get__ method not called in all cases?


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?


Solution

  • 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>