How can I update an attribute that's added to a method via a decorator?

Question:

I’ve created a decorator that wraps tkinter’s after() method to make looping functions easier (i.e., having a function call itself periodically)

import tkinter as tk
from functools import wraps


# decorator
def after_loop(interval: int):
    """Loop the decorated method at the specified `interval` using `after()`"""
    def _after_loop(function):
        @wraps(function)
        def wrapper(self, *args, **kwargs):
            value = function(self, *args, **kwargs)
            self.after(
                interval,
                lambda s=self, a=args, k=kwargs: wrapper(s, *a, **k)
            )
            return value
        return wrapper
    return _after_loop

This works perfectly fine as written above…

# example tkinter application
class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title('After Loop Test')
        self.label_val = 0
        self.label = tk.Label(self, text=self.label_val)
        self.label.pack()
        self.label_update  # begin updates
    
    @after_loop(1000)  # update this label every second
    def label_update(self):
        self.label_val += 1
        self.label.config(text=self.label_val)
        
if __name__ == '__main__':
    app = App()
    app.mainloop()

however, I’m struggling to implement an abstraction around after_cancel() so the user (I) can cancel the loop if desired. I’ve tried the following:

def after_loop(interval: int):
    """Loop the decorated method at the specified `interval` using `after()`"""
    def _after_loop(function):
        function.loop_cancel = False  # add attribute to decorated function
        @wraps(function)
        def wrapper(self, *args, **kwargs):
            value = function(self, *args, **kwargs)
            _loop_id = self.after(
                interval,
                lambda s=self, a=args, k=kwargs: wrapper(s, *a, **k)
            )
            if wrapper.loop_cancel:  # break out of the 'after' loop when set
                self.after_cancel(_loop_id)
            return value
        wrapper.loop_cancel = False  # add attribute to the wrapper function
        return wrapper
    return _after_loop

But when I attempt to write to the loop_cancel attribute flag, I’m greeted with AttributeError: 'method' object has no attribute 'loop_cancel' even though it shows up in label_update‘s __dict__ and I can read the attribute’s value just fine with print(label_update.after_cancel)

@after_loop(1000)  # update this label every second
def label_update(self):
    self.label_val += 1
    self.label.config(text=self.label_val)

    # test reading the 'loop_cancel' attribute (works as expected)
    print(self.label_update.loop_cancel)  # => False
    print(self.label_update.__dict__)  # => {'loop_cancel': False, ...

    # attempt to set the 'loop_cancel' flag and break out of the loop (no dice!)
    if self.label_val >= 5:
        self.label_update.loop_cancel = True  # raises AttributeError
        # setattr(self.label_update, 'loop_cancel', True)  has the same problem

Are attributes added via a decorator inherently read-only, or am I implementing the decorator incorrectly somehow? Any help is much appreciated, as ever.


Edit – to say that if I don’t add the loop_cancel attribute to the function immediately inside _after_loop the AttributeError is raised immediately upon trying to access self.label_update.loop_cancel, which isn’t entirely surprising.

def _after_loop(function):
    function.loop_cancel = False  # add attribute to decorated function
Asked By: JRiggles

||

Answers:

self.label_update produces a new method object every time it is evaluated, so the attribute you set is only for that bound method. You need to set the attribute on the underlying function object itself.

@after_loop(1000)  # update this label every second
def label_update(self):
    self.label_val += 1
    self.label.config(text=self.label_val)

    if self.label_val >= 5:
        self.label_update.__func__.loop_cancel = True

This shouldn’t be a problem as long as you have only one instance of App in your program. If that’s not the case, you can always store a dict of flags (one flag per wrapped method) on the instance itself and have the wrapper look there, instead of on the wrapper, for the flag.

def after_loop(interval: int, tag: str):
    def _after_loop(function):
        @wraps(function)
        def wrapper(self, *args, **kwargs):
            value = function(self, *args, **kwargs)
            _loop_id = self.after(
                interval,
                lambda s=self, a=args, k=kwargs: wrapper(s, *a, **k)
            )
            if self._loop_tags.get(tag):
                self.after_cancel(_loop_id)
            return value
        return wrapper
    return _after_loop

Then

@after_loop(1000, "foo")
def label_update(self):
    self.label_val += 1
    self.label.config(text=self.label_val)

    if self.label_val >= 5:
        self._loop_tags["foo"] = True
Answered By: chepner