Search code examples
pythondescriptor

Tracking change of variable in Python


I would like to create a data structure containing several settings, these settings will be used to calculate register values of a hardware device. To avoid reconfiguring all settings of the hardware device, I would like to have each variable inside of the data structure remember if it has been changed or not. Then later I would call upon all variables to see which ones are changed to then only write to the connected registers. I can create a class that remembers if any change has occurred to it's internally stored value, I am however experiencing difficulties with returning and resetting the has_changed variable. This due to the overloading of the __get__ function prohibiting the usage of other functions inside of the class.

In the simplified example I have made a class called Table (which should contain variables such as: height, width, length, ...) The current implementation has the class TrackedValidatedInteger which checks if the change is valid. I would like the variable property has_changed to be obtainable and resettable from inside of the class Table.

class TrackedValidatedInteger():
    def __init__(self, min_value=None, max_value=None):
        self.min_value = min_value
        self.max_value = max_value
        self.has_changed = False
        self.value = None
        
    def __get__(self, obj, objecttype=None):
        return self.value
    
    def __set__(self, obj, value):
        if self.validate_set(value):
            self.value = value
            self.has_changed = True
            return 1
        return 0

    def get_has_changed(self):
        return self.has_changed
    
    def reset_has_changed(self):
        self.has_changed = False
        
    def validate_set(self, value):
        if self.min_value:
            if self.min_value > value:
                print("Value should be between " + str(self.min_value) + " and " + str(self.max_value))
                return 0
        if self.max_value:
            if self.max_value < value:
                print("Value should be between " + str(self.min_value) + " and " + str(self.max_value))
                return 0
        return 1

class Table():
    length = TrackedValidatedInteger(min_value=0, max_value=3)
    height = TrackedValidatedInteger(min_value=0, max_value=6)
    width = TrackedValidatedInteger(min_value=0, max_value=7)
    
    def __init__(self, length=0, height=0, width=0):
        self.length = length
        self.height = height
        self.width = width 

    def reset_has_changed_1(self):
        self.length.has_changed = False
        self.height.has_changed = False
        self.width.has_changed = False
        
    def reset_has_changed_2(self):
        self.length.reset_has_changed()
        self.height.reset_has_changed()
        self.width.reset_has_changed()


p = Table()
p.length = 3 # will set the variable
p.length = 9 # will not set the variable

# p.length.get_has_changed() # This does not work as the p.length will call __get__ resulting in an integer which does not have get_has_changed()
# p.reset_has_changed_1() # This does not work for the same reason
# p.reset_has_changed_2() # This does not work for the same reason

The problem I find is that the __get__ function gets automatically called whenever I try to access any other part of the TrackedValidatedInteger class. Can I access the other variables and functions in any other way? If there are any suggestions on how achieve the same result in another way, I would be glad to hear it. I would personally like to keep the simple setting of the variables (p.length = 3), if not possible this can be changed.

Any help would be greatly appreciated.


Solution

  • I like the idea of doing this from a descriptor. You can take advantage of the fact that a descriptor can know the name of the attribute to which it is bound via the __set_name__ method, and use that to maintain attributes on the target object:

    class TrackedValidatedInteger:
        def __init__(self, min_value=None, max_value=None):
            self.min_value = min_value
            self.max_value = max_value
            self.has_changed = False
            self.value = None
    
        def __set_name__(self, obj, name):
            self.name = name
            setattr(obj, f"{self.name}_changed", False)
    
        def __get__(self, obj, objecttype=None):
            return self.value
    
        def __set__(self, obj, value):
            if (self.min_value is not None and value < self.min_value) or (
                self.max_value is not None and value > self.max_value
            ):
                raise ValueError(
                    f"{value} must be >= {self.min_value} and <= {self.max_value}"
                )
            self.value = value
            setattr(obj, f"{self.name}_changed", True)
    

    Given the above implementation, we can create a class Example like this:

    class Example:
        v1 = TrackedValidatedInteger()
        v2 = TrackedValidatedInteger()
    

    And then observe the following behavior:

    >>> e = Example()
    >>> e.v1_changed
    False
    >>> e.v1 = 42
    >>> e.v1_changed
    True
    >>> e.v2_changed
    False
    >>> e.v2 = 0
    >>> e.v2_changed
    True
    

    Instead of maintaining a per-attribute <name>_changed variable, you could instead maintain a set of changed attributes:

    class TrackedValidatedInteger:
        def __init__(self, min_value=None, max_value=None):
            self.min_value = min_value
            self.max_value = max_value
            self.has_changed = False
            self.value = None
    
        def __set_name__(self, obj, name):
            self.name = name
            if not hasattr(obj, "_changed_attributes"):
                setattr(obj, "_changed_attributes", set())
    
        def __get__(self, obj, objecttype=None):
            return self.value
    
        def __set__(self, obj, value):
            if (self.min_value is not None and value < self.min_value) or (
                self.max_value is not None and value > self.max_value
            ):
                raise ValueError(
                    f"{value} must be >= {self.min_value} and <= {self.max_value}"
                )
            self.value = value
            obj._changed_attributes.add(self.name)
    

    In that case, we get:

    >>> e = Example()
    >>> e._changed_attributes
    set()
    >>> e.v1 = 1
    >>> e._changed_attributes
    {'v1'}
    >>> e.v2 = 1
    >>> e._changed_attributes
    {'v1', 'v2'}
    

    This is nice because you can iterate over e._changed_attributes if you need to record all your changed values.