ko_00
ko_00

Reputation: 118

Python - Class Property.Setter does not validate a direct set on dictionary key

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

Upvotes: 0

Views: 687

Answers (2)

Wizard.Ritvik
Wizard.Ritvik

Reputation: 11642

Sorry for any initial confusion due to me not understanding the ask initially. Now, if I understand the problem statement correctly as below:

[...] however, if I directly set a key: value, the @property.setter is not being called.

To enable any updates to the object self.proxy_dict - such as key assignment, d['key'] = ... - to trigger the property setter or @price_dict.setter method:

One option can be to define and use a ProxyDict, a custom dict implementation which can internally call the desired setter method when __setitem__() is called, as indicated above.

class ProxyDict(dict):

    # noinspection PyMissingConstructor, PyArgumentList
    def __init__(self, obj, prop: property, d: dict, _init=dict.__init__):
        # calls super().__init__()
        _init(self, d)
        # `prop` is a property object, and `prop.fset` is the setter function
        self._setter = lambda value, _set=prop.fset: _set(obj, value)

    def __setitem__(self, key, value):
        print(f'__setitem__() is called with key={key!r}, value={value!r}')
        self._setter({key: value})

    def update(self, *args, **kwargs) -> None:
        if args:
            kwargs.update(*args)
        print(f'update() is called with {kwargs!r}')
        self._setter(kwargs)

Usage would then be as follows:

class CoinJar:

    def __init__(self):
        self._price_dict = ProxyDict(
            self,
            CoinJar.price_dict,  # or: self.__class__.price_dict
            {'current': 0.0, 'high': 0.0, 'low': 0.0},
        )

    @property
    def price_dict(self):
        return self._price_dict

    @price_dict.setter
    def price_dict(self, pd: dict):

        print('setting price')

        try:
            new_price = float(pd.get('current'))
            price_is_set = True
        except ValueError as e:
            price_is_set = False
            print(f'Input cannot be converted to float {e}')

        # Exceptions
        if (not pd  # equivalent to: len(priceDict.keys()) == 0
                or not price_is_set):
            raise ValueError('setting illegal value')

        # setting high, low, current
        # noinspection PyUnboundLocalVariable
        changes = {'current': new_price}

        if new_price > self._price_dict['high']:
            changes['high'] = new_price
        curr_low = self._price_dict['low']
        if new_price < curr_low or curr_low == 0.0:
            changes['low'] = new_price

        # call `dict.update`, so we don't call this setter method again
        dict.update(self._price_dict, changes)


def main():
    btc = CoinJar()

    btc.price_dict = {'current': 500}  # This is calling priceDict.setter => accepts and performs the .setter expressions
    print(btc.price_dict)
    print()

    btc.price_dict['current'] = 250  # This is calling priceDict.setter
    print(btc.price_dict)
    print()

    btc.price_dict['current'] = 1000  # This is calling priceDict.setter
    print(btc.price_dict)
    print()

    btc.price_dict['flamingo'] = '500'  # This is calling priceDict.setter => raises Exception (as expected)
    print(btc.price_dict)


if __name__ == '__main__':
    main()

Output:

setting price
{'current': 500.0, 'high': 500.0, 'low': 500.0}

__setitem__() is called with key='current', value=250
setting price
{'current': 250.0, 'high': 500.0, 'low': 250.0}

__setitem__() is called with key='current', value=1000
setting price
{'current': 1000.0, 'high': 1000.0, 'low': 250.0}

__setitem__() is called with key='flamingo', value='500'
setting price
Traceback (most recent call last):
  File "C:\Users\<user>\path\to\script.py", line 85, in <module>
    main()
  File "C:\Users\<user>\path\to\script.py", line 80, in main
    btc.price_dict['flamingo'] = '500'  # This is calling priceDict.setter => raises Exception (as expected)
  File "C:\Users\<user>\path\to\script.py", line 12, in __setitem__
    self._setter({key: value})
  File "C:\Users\<user>\path\to\script.py", line 8, in <lambda>
    self._setter = lambda value, _set=prop.fset: _set(obj, value)
  File "C:\Users\<user>\path\to\script.py", line 40, in price_dict
    new_price = float(pd.get('current'))
TypeError: float() argument must be a string or a real number, not 'NoneType'

Original Answer

This is my original answer, which was a direct result of me not understanding the problem or ask in its entirety.

Here, I understand the problem statement being defined as:

I am changing the dictionary by accessing the class priceDict.__setitem__. In this way, using a dictionary in a @setter way has not much use if someone can screw the Obj <values>.

So to clarify, the ask as I understood it was an approach to raise an error when priceDict.__setitem__ is called, as we only want the priceDict object to be mutable when the @setter method is called.

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'

Upvotes: 1

Grismar
Grismar

Reputation: 31354

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)

Upvotes: 6

Related Questions