Home > Software engineering >  Python - Class Property.Setter does not validate a direct set on dictionary key
Python - Class Property.Setter does not validate a direct set on dictionary key

Time:10-03

Please refer to the function main(): the results are mentioned as comments. *General note: When Class() has an attribute containing e.g. str -> the @propery.setter will validate once set.

However, when I am storing a dictionary in the Class.attribute, My @property.setter is not working if I directly set a key : value

class CoinJar():

    def __init__(self):
        self._priceDict = {'current' : 0.0, 'high' : 0.0, 'low' : 0.0}
        self.klines = {}
        self.volume = {}

    def __str__(self):
        return f'\n\U0001F36A'

    @property
    def priceDict(self):
        return self._priceDict

    @priceDict.setter
    def priceDict(self, priceDict):

        print('setting price')

        try:
            newPrice = float(priceDict.get('current'))
        except ValueError as e:
            print(f'Input cannot be converted to float {e}')

        # Exceptions
        if len(priceDict.keys()) == 0 or newPrice == None or type(newPrice) not in [int, float]:
            raise ValueError('setting illegal value')

        #setting high, low, current
        self._priceDict['current'] = newPrice

        if newPrice > self._priceDict['high']:
            self._priceDict['high'] = newPrice
        if newPrice < self._priceDict['low'] or self._priceDict['low'] == 0.0:
            self._priceDict['low'] = newPrice


def main():
    btc = CoinJar()
    btc.priceDict = {'current' : 500}           # This is calling priceDict.setter => accepts and performs the .setter expressions
    btc.priceDict['flamingo'] = 20              # This is not calling priceDict.setter => can do what i want, with 'current, high, flamingo, *'
    btc.priceDict = {'flamingo' : 500}          # This is calling priceDict.setter => raises Exception (as expected)
    print(btc)

def retrieveData():
    ...

def analyseData():
    ...

if __name__ == '__main__':
    main()

CodePudding user response:

Have a look at this simplified example:

class AClass:
    def __init__(self):
        self._some_dict = None

    @property
    def some_dict(self):
        print('getting')
        return self._some_dict

    @some_dict.setter
    def some_dict(self, value):
        print('setting')
        self._some_dict = value


an_obj = AClass()
an_obj.some_dict = {}  # setter gets called
an_obj.some_dict['a_key'] = 1  # getter gets called, as dict is being accessed

A property setter for an attribute gets called when the value of the attribute itself needs to be set. I.e. when you assign a new dictionary to the attribute for a dict attribute.

A property getter gets called when the attribute is accessed ('read' / 'gotten').

The setter does not get called when you manipulate the attribute otherwise, like setting a key or value for the dictionary. You could trigger on that, but you'd have to override the dictionary.

Something like this:

class MyDict(dict):
    def __setitem__(self, key, *args, **kwargs):
        print('setting a dictionary value')
        super().__setitem__(key, *args, **kwargs)


class AClass:
    def __init__(self):
        self._some_dict = None

    @property
    def some_dict(self):
        print('getting')
        return self._some_dict

    @some_dict.setter
    def some_dict(self, value):
        print('setting')
        self._some_dict = MyDict(value)


an_obj = AClass()
an_obj.some_dict = {}  # setter gets called
an_obj.some_dict['a_key'] = 1  # getter gets called, as well as item setter

# Note: this just calls setter, as it just directly sets the attribute to the new dict:
an_obj.some_dict = {'a_key': 1}

Another thing to note is that the above doesn't automatically work recursively. That is, if your dictionary contains further dictionaries, they are not automatically turned into MyDict, so this happens:

an_obj = AClass()
an_obj.some_dict = {}  # setter
an_obj.some_dict['a_key'] = {}  # getter and item setter
an_obj.some_dict['a_key']['another_key'] = 1  # only getter

You can keep adding functionality, by having the MyDict turn any dict value into a MyDict, but there's further issues to consider - adjust as needed:

class MyDict(dict):
    def __setitem__(self, key, value, *args, **kwargs):
        print('setting a dictionary value')
        if isinstance(value, dict) and not isinstance(value, MyDict):
            value = MyDict(value)
        super().__setitem__(key, value, *args, **kwargs)

CodePudding user response:

One option could be to use a custom dict implementation such as a FrozenDict, as shown below.

Note that this is similar to how dataclasses does it, when you pass in frozen=True to create a frozen dataclass (attribute values can't be updated after object instantiation).

# Raised when an attempt is made to modify a frozen dict.
class FrozenDictError(KeyError): pass


class FrozenDict(dict):

    def __setitem__(self, key, value):
        raise FrozenDictError(f'cannot assign to key {key!r}')

    def update(self, *args, **kwargs):
        raise FrozenDictError(f'cannot assign to {self!r}')

Usage:

class CoinJar:

    def __init__(self):
        self._priceDict = FrozenDict({'current': 0.0, 'high' : 0.0, 'low' : 0.0})

    @property
    def priceDict(self):
        return self._priceDict

    @priceDict.setter
    def priceDict(self, priceDict):

        print('setting price')

        try:
            newPrice = float(priceDict.get('current'))
        except ValueError as e:
            print(f'Input cannot be converted to float {e}')

        # Exceptions
        if len(priceDict.keys()) == 0 or newPrice == None or type(newPrice) not in [int, float]:
            raise ValueError('setting illegal value')

        #setting high, low, current
        _priceDict = self._priceDict.copy()
        _priceDict['current'] = newPrice

        if newPrice > _priceDict['high']:
            _priceDict['high'] = newPrice
        if newPrice < _priceDict['low'] or _priceDict['low'] == 0.0:
            _priceDict['low'] = newPrice

        self._priceDict = FrozenDict(_priceDict)


def main():
    btc = CoinJar()
    btc.priceDict = {'current' : 500}           # This is calling priceDict.setter => accepts and performs the .setter expressions
    btc.priceDict['flamingo'] = 20              # This is not calling priceDict.setter => can do what i want, with 'current, high, flamingo, *'
    btc.priceDict = {'flamingo' : 500}          # This is calling priceDict.setter => raises Exception (as expected)
    print(btc)


if __name__ == '__main__':
    main()

Output:

setting price
Traceback (most recent call last):
  File "C:\Users\<user>\path\to\script.py", line 62, in <module>
    main()
  File "C:\Users\<user>\path\to\script.py", line 56, in main
    btc.priceDict['flamingo'] = 20              # This is not calling priceDict.setter => can do what i want, with 'current, high, flamingo, *'
  File "C:\Users\<user>\path\to\script.py", line 8, in __setitem__
    raise FrozenDictError(f'cannot assign to key {key!r}')
__main__.FrozenDictError: cannot assign to key 'flamingo'

Using dataclass(frozen=True)

For a more robust implementation, you might consider refactoring to use a frozen dataclass instead, along with replace() to update a frozen instance:

from dataclasses import dataclass, replace


@dataclass(frozen=True)
class Price:
    current: float = 0.0
    high: float = 0.0
    low: float = 0.0


class CoinJar:

    def __init__(self):
        self._priceDict = Price()

    @property
    def priceDict(self):
        return self._priceDict

    @priceDict.setter
    def priceDict(self, priceDict):

        print('setting price')

        try:
            newPrice = float(priceDict.get('current'))
        except ValueError as e:
            print(f'Input cannot be converted to float {e}')

        # Exceptions
        if len(priceDict.keys()) == 0 or newPrice == None or type(newPrice) not in [int, float]:
            raise ValueError('setting illegal value')

        #setting high, low, current
        changes = {'current': newPrice}

        if newPrice > self._priceDict.high:
            changes['high'] = newPrice

        if newPrice < self._priceDict.low or self._priceDict.low == 0.0:
            changes['low'] = newPrice

        self._priceDict = replace(self._priceDict, **changes)


def main():
    btc = CoinJar()
    print(btc.priceDict)

    btc.priceDict = {'current': 500}
    print(btc.priceDict)

    btc.priceDict = {'current': 1000, 'high': 200}
    print(btc.priceDict)

    btc.priceDict.flamingo = 20              # This is not calling priceDict.setter => can do what i want, with 'current, high, flamingo, *'
    btc.priceDict = {'flamingo': 500}          # This is calling priceDict.setter => raises Exception (as expected)
    print(btc)


if __name__ == '__main__':
    main()

Output:

Price(current=0.0, high=0.0, low=0.0)
setting price
Price(current=500.0, high=500.0, low=500.0)
setting price
Price(current=1000.0, high=1000.0, low=500.0)
Traceback (most recent call last):
  File "C:\Users\<usr>\path\to\script.py", line 62, in <module>
    main()
  File "C:\Users\<usr>\path\to\script.py", line 56, in main
    btc.priceDict.flamingo = 20              # This is not calling priceDict.setter => can do what i want, with 'current, high, flamingo, *'
  File "<string>", line 4, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'flamingo'
  • Related