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

Question:

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?

Asked By: Tuntenfisch

||

Answers:

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>
Answered By: Mechanic Pig
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.