Decorating a class to monitor attribute changes
Asked Answered
P

2

5

I want to have classes that automatically send notifications to subscribers whenever one of their attributes change. So if I would write this code:

@ChangeMonitor
class ChangingClass(object):

    def __init__(self, x):
        self.x = x


changer = ChangingClass(5)
print("Going to change x.")
changer.x = 6
print("Going to not change x.")
changer.x = 6
print("End of program")

The output would be:

Going to change x
Old x = 5, new x = 6
Going to not change x.
End of program.

My question is how to implement the ChangeMonitor decorator class. In the above example I assume it will print a line indicating the changes of an attribute, but for useful purposes it could send notifications to subscribed objects.

Pavla answered 15/9, 2013 at 12:26 Comment(0)
M
6

You'd have to add a __setattr__() method:

def ChangeMonitor(cls):
    _sentinel = object()
    old_setattr = getattr(cls, '__setattr__', None)
    def __setattr__(self, name, value):
        old = getattr(self, name, _sentinel)
        if old not is _sentinel and old != value:
            print "Old {0} = {1!r}, new {0} = {2!r}".format(name, old, value)
        if old_setattr:
            old_setattr(self, name, value)
        else:
            # Old-style class
            self.__dict__[name] = value

    cls.__setattr__ = __setattr__

    return cls

This should handle existing __setattr__ hooks as well. The _sentinel is used to allow None as the old value too.

Demo:

>>> changer = ChangingClass(5)
>>> changer.x = 6
Old x = 5, new x = 6
>>> changer.x = 6
>>> # nothing printed
...
>>> changer.x = None
Old x = 6, new x = None
>>> changer.x = 6
Old x = None, new x = 6
Medieval answered 15/9, 2013 at 12:31 Comment(3)
I do not see the use of _sentinel or the second if statement. If I leave _sentinel out and replace the "if old_setattr" statement with "old_setattr(self, name, value)", the behavior is the same. Could you explain this? Thanks for your help anyway, you've been a great help!Pavla
If you replace the sentinel with None you won't be able to tell if an old value of None was replaced by a new value. Instead it'll be indistinguishable from the 'no old value at all' case. I believe old-style classes (not inheriting from object) have no default __setattr__ hook; I am not in a position to test that right now though; it may be redundant to test if a hook was foundMedieval
I see your point regarding sentinel. I checked the code without inheriting from object and the examples give the same output, so I think you're right that the test for setattr is redundant.Pavla
B
1

This answer is based on the Answer by Martijn Pieters, I found it useful, but I updated it to fit better with Python3.

Like the accepted answer I've overridden the __setattr__ special method, but we save most of the complexity by just using super to keep the original behavior of the method.

def ChangeMonitor(cls):
    def __setattr__(self, name, value):
        try:
            original_value = getattr(self, name) 
            if original_value == value:
                print(f"No Change for Value {name}")
            else:
                print(f"Value {name} has changed from {original_value} to {value}")
        except AttributeError: 
            print(f"Initializing Value {name} with {value}")
        super(cls, self).__setattr__(name, value)
    cls.__setattr__ = __setattr__
    return cls

Demo:

>>> changer = ChangingClass(5)
Initializing Value x with 5
>>> changer.x = 6
Value x has changed from 5 to 6
>>> changer.x = None
Value x has changed from 6 to None
>>> changer.x = 2
Value x has changed from None to 2
>>> changer.x += 8
Value x has changed from 2 to 10

There are a few edge cases that if you're relying on this you should consider.

If your datatype is mutable you can still change the underlying data without triggering setattr.

>>> changer = ChangingClass([5])
Initializing Value x with [5]
>>> changer.x.append(10)
>>> changer.x = []
Value x has changed from [5, 10] to []

In addition class variables changing will not trigger your setattr method. And if you override the class variable with an instance variable, it won't register as initializing.

>>> changer = ChangingClass(1)
Initializing Value x with 1
>>> changer.z = 9
Initializing Value z with 9
>>> ChangingClass.y = 2
>>> changer.y = 6
Value y has changed from 2 to 6
Bragg answered 2/8, 2022 at 23:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.