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