Python Frozen Nessted Dataclass, with __call__ = replace

52 Views Asked by At

I am trying to write an immutable datastructure with the following charicteristics.

  1. Immutable
  2. Easy to produce immutable copies with altered fields
  3. Composable, so that updateing nested data is as easy as updateing un-nessted data

The API im trying to implement is this

a0 = Person(name = 'Jhon', occupation = {'title': 'junear', 'sallary': 30})
a1 = a(name = a0.name + ' Smith')
a2 = a1(occupation = {'title': 'seanear'})
a3 = a2(occupation = {'sallary': 50})

I have writen an implementation like so


from dataclasses import dataclass, replace, field

@dataclass(frozen=True)
class Occupation:
    __call__ = replace
    title: str
    sallary: int

@dataclass(frozen=True)
class Person:
    __call__ = replace
    name: str
    occupation: Occupation
    
    @property
    def occupation(self):
        return self._occupation

    @occupation.setter
    def occupation(self, value):
        if '_occupation' not in self.__dict__:
            print('initalising occupation')
            occ = Occupation
        else:
            print('updating occupation')
            occ = self.occupation

        if isinstance(value, tuple):
            object.__setattr__(self,'_occupation', occ(*value))
        elif isinstance(value, dict):
            object.__setattr__(self,'_occupation', occ(**value))
        elif isinstance(value, Occupation):
            object.__setattr(self,'_occupation', value)

    

However, Im having problems here. a0 works fine, but the rest fail. I belive the issue is with copying over/ updating the _occupation unmannaged field.

Questions:

  1. Is there a more simple solution to this that I'm over looking
  2. How can I accsess the data from the previouse object within the occupation.setter?
  3. It would be nice if there was a way to generate the boiler plate Ive written, generated when one of the parameters of a frozen dataclass is a frozen dataclass, or even to inline the class definition of the sub-property

Thank you.

Nb:

  1. in writing this code I have pulled from this thread and I have read this documentation
2

There are 2 best solutions below

2
matszwecja On BEST ANSWER

Defining setter for a property kinda breaks your immutability assumption. You need to construct new Occupation, and then create new Person using it.


from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Occupation:
    __call__ = replace
    title: str
    salary: int

@dataclass(frozen=True)
class Person:
    name: str
    occupation: Occupation


    def __call__(self, **kwargs):
        try:
            occupation = kwargs['occupation']
            if isinstance(occupation, tuple):
                occ = self.occupation(*occupation)
            elif isinstance(occupation, dict):
                occ = self.occupation(**occupation)
            elif isinstance(occupation, Occupation):
                occ = occupation
            kwargs['occupation'] = occ
        except KeyError:
            pass
        return replace(self, **kwargs)



a0 = Person(name = 'John', occupation = Occupation(title= 'junior', salary= 30))
a1 = a0(name = a0.name + ' Smith')
a2 = a1(occupation = {'title': 'senior'})
a3 = a2(occupation = {'salary': 50})
0
forifone On

Here is a full working solution that I think is neat. I used the idea (thanks @matswecja) of using a custom replace function.

def myreplace(self,kwargs_= {}, **kwargs):
    kwargs = kwargs_ | kwargs
    current_data = self.__dict__
    updated_data = {}
    sig = self.__annotations__
    for var, arg in kwargs.items():
        if var not in sig:
            raise TypeError(type(self),var)
        typ = sig[var]
        if isinstance(arg, typ):
            updated_data[var] = arg
        elif is_dataclass(typ):
            updated_data[var] = current_data[var](**arg)
        elif callable(arg):
            updated_data[var] = arg(current_data[var])
        else:
            raise TypeError(var, typ, arg, type(arg))
        
    return replace(self, **updated_data)

@dataclass(frozen=True)
class Money:
    __call__ = myreplace
    currency: str
    amount: int
    unit:str = field(default='k')
        

@dataclass(frozen=True)
class Role:
    __call__ = myreplace
    title: str
    salary: Money
    

@dataclass(frozen=True)
class Person:
    __call__ = myreplace
    name: str
    age: int
    role: Role
    

a0 = Person('jhon smith', 26, Role('Jnr',Money('£',20)))
a1 = a0(name=str.title)
a2 = a1(
    {'role': {
        'title' : 'Snr',
        'salary': {
            'amount': lambda a: a + 10
        }}
    })
print(a0)
print(a1)
print(a2)

Printing:

Person(name='jhon smith', age=26, role=Role(title='Jnr', salary=Money(currency='£', amount=20, unit='k')))
Person(name='Jhon Smith', age=26, role=Role(title='Jnr', salary=Money(currency='£', amount=20, unit='k')))
Person(name='Jhon Smith', age=26, role=Role(title='Snr', salary=Money(currency='£', amount=30, unit='k')))