Tracking change of variable in Python

50 Views Asked by At

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.

3

There are 3 best solutions below

0
larsks On BEST ANSWER

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.

0
AKX On

I'd lift the validation and change tracking logic up to the Table, like so:

class Table:
    _fields = {}

    def __init__(self, **kwargs):
        self._data = {}
        self._changed = set()
        for name, value in kwargs.items():
            setattr(self, name, value)
        self._changed.clear()  # do not consider the initial values as changed

    def __repr__(self):
        return f"<{self.__class__.__name__} {self._data}>"

    def __getattr__(self, item):
        return self._data[item]

    def __setattr__(self, key, value):
        rules = self._fields.get(key)
        if rules is not None:  # existing field
            if "min_value" in rules and value < rules["min_value"]:
                raise ValueError(f'{key} should be greater than {rules["min_value"]}')
            if "max_value" in rules and value > rules["max_value"]:
                raise ValueError(f'{key} should be less than {rules["max_value"]}')
            self._data[key] = value
            self._changed.add(key)
            return
        if not key.startswith("_"):
            raise AttributeError(f"Cannot set attribute {key}")
        super().__setattr__(key, value)

    def reset_has_changed(self):
        self._changed.clear()

    @property
    def changed(self):
        return tuple(self._changed)


class HardwareTable(Table):
    _fields = {
        "reg1": {"min_value": 0, "max_value": 100},
        "reg2": {"min_value": 0, "max_value": 100},
    }


p = HardwareTable(reg1=30)
print(f"{p=} / {p.changed=}")
p.reg2 = 13
print(f"{p=} / {p.changed=}")

This prints out

p=<HardwareTable {'reg1': 30}> / p.changed=()
p=<HardwareTable {'reg1': 30, 'reg2': 13}> / p.changed=('reg2',)
0
Jasmijn On

There are a number of issues to adress:

  • First, instances of TrackedValidatedInteger are coupled to Table, not to its instances, so you should not store value on TrackedValidatedInteger instances. (Try creating two Table instances, setting one of these properties on the one, and seeing it reflected on the other instance.)
  • If validation fails, you should raise an exception, not print a value and continue on.
  • If min_value or max_value is 0, you don't do the validation. Use if self.min_value is not None instead.
  • If one of them is None, you print messages like Value should be between None and 7
class TrackedValidatedInteger:
    def __init__(self, min_value=None, max_value=None):
        self.min_value = min_value
        self.max_value = max_value
        
    def __get__(self, obj, objecttype=None):
        return getattr(obj, self.private_name)
    
    def __set__(self, obj, value):
        self.validate(value)
        obj.has_changed.add(self.public_name)
        setattr(obj, self.private_name, value)

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def validate(self, 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):
            if self.min_value is None:
                comparison = f'at most {self.max_value}'
            elif self.max_value is None:
                comparison = f'at least {self.min_value}'
            else:
                comparison = f'between {self.min_value} and {self.max_value}'
            raise ValueError(f'{self.public_name} should be {comparison}')

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):
        # this needs to happen first:
        self.reset_has_changed()
        self.length = length
        self.height = height
        self.width = width 

    def reset_has_changed(self):
        self.has_changed = set()

p = Table()
p.length = 3 # will set the variable
try:
    p.length = 9 # will not set the variable
except ValueError as e:
    print(e)

print('length' in p.has_changed)