pythonevent-handlingpython-decoratorsclass-method

Decorator with arguments: dealing with class methods in Python 3


Event bus:

# eventbus.py
EventKey = Union[int, str]
listeners: Dict[EventKey, Set[Callable]] = defaultdict(set)


def on(event: EventKey):
    def decorator(callback):
        listeners[event].add(callback)
        
        @functools.wraps(callback)
        def wrapper(*args, **kwargs):
            return callback(*args, **kwargs)
        
        return wrapper
    return decorator


def emit(event: EventKey, *args, **kwargs):
    for listener in listeners[event]:
        listener(*args, **kwargs)

Example of a class that needs to listen to an event:

class Ticking(Referencable):
    
    def __init__(self, id_: int):
        super().__init__(id_)
    
    @eventbus.on(StandardObjects.E_TIMER)
    def on_clock(self, clock: Clock):
        match clock.id:
            case StandardObjects.TIME_TICK:
                self.on_time_tick(clock)
    
    def on_time_tick(self, clock: Clock):
        pass

Example of invoking the related event:

eventbus.emit(StandardObjects.E_TIMER, clock)  # clock is an instance of Clock

I'm trying to write a relatively simple global event bus in Python 3.11, however, I would like to register listeners to the bus via a decorator. The implementation below works fine when decorating functions, but falls over when a class method is decorated because of the "self" argument being missed when called:

Ticking.on_clock() missing 1 required positional argument: 'clock'

(I can confirm it's to do with "self" because modifying listener(*args, **kwargs) in emit() to listener('dummy', *args, **kwargs) throws the expected AttributeError: 'str' object has no attribute 'on_time_tick'.)

Then I explored ways to have the decorator somehow get a reference to the callback's class instance, but in Python 3, Callable objects longer have a means to access metadata about the class instance they belong to outside of unstable implementation-specific reflection hacks that I would certainly like to avoid.


Solution

  • The problem with your approach is that the decorator is first being called when the class is read rather than when you create the instance of the class, thus you do not have a reference to any instance you create later on. This This is the normal use case of decorators.

    Nevertheless, if you want to have a reference of an instantiated object rather than the class definition, you need to decorate the method of the class while you are creating the instance of the class, that is to say, when you are calling you __init__ method.

    The following modification to your code should solve your problem

    
    class Ticking(Referencable):
        
        def __init__(self, id_: int):
            super().__init__(id_)
            # decorating the method self.on_clock after class intanciated
            self.on_clock = eventbus.on(StandardObjects.E_TIMER)(self.on_clock)
        
        def on_clock(self, clock: Clock):
            match clock.id:
                case StandardObjects.TIME_TICK:
                    self.on_time_tick(clock)
        
        def on_time_tick(self, clock: Clock):
            pass