How to set the value of dataclass field in __post_init__ when frozen=True?

Python

Python Problem Overview


I'm trying to create a frozen dataclass but I'm having issues with setting a value from __post_init__. Is there a way to set a field value based on values from an init param in a dataclass when using the frozen=True setting?

RANKS = '2,3,4,5,6,7,8,9,10,J,Q,K,A'.split(',')
SUITS = 'H,D,C,S'.split(',')


@dataclass(order=True, frozen=True)
class Card:
    rank: str = field(compare=False)
    suit: str = field(compare=False)
    value: int = field(init=False)
    def __post_init__(self):
        self.value = RANKS.index(self.rank) + 1
    def __add__(self, other):
        if isinstance(other, Card):
            return self.value + other.value
        return self.value + other
    def __str__(self):
        return f'{self.rank} of {self.suit}'

and this is the trace

 File "C:/Users/user/.PyCharm2018.3/config/scratches/scratch_5.py", line 17, in __post_init__
    self.value = RANKS.index(self.rank) + 1
  File "<string>", line 3, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'value'

Python Solutions


Solution 1 - Python

Use the same thing the generated __init__ method does: object.__setattr__.

def __post_init__(self):
    object.__setattr__(self, 'value', RANKS.index(self.rank) + 1)

Solution 2 - Python

Using mutation

Frozen objects should not be changed. But once in a while the need may arise. The accepted answer works perfectly for that. Here is another way of approaching this: return a new instance with the changed values. This may be overkill for some cases, but it's an option.

from copy import deepcopy

@dataclass(frozen=True)
class A:
    a: str = ''
    b: int = 0

    def mutate(self, **options):
        new_config = deepcopy(self.__dict__)
        # some validation here
        new_config.update(options)
        return self.__class__(**new_config)

Another approach

If you want to set all or many of the values, you can call __init__ again inside __post_init__. Though there are not many use cases.

The following example is not practical, only for demonstrating the possibility.

from dataclasses import dataclass, InitVar


@dataclass(frozen=True)
class A:
    a: str = ''
    b: int = 0
    config: InitVar[dict] = None

    def __post_init__(self, config: dict):
        if config:
            self.__init__(**config)

The following call

A(config={'a':'a', 'b':1})

will yield

A(a='a', b=1)

without throwing error. This is tested on python 3.7 and 3.9.

Of course, you can directly construct using A(a='hi', b=1), but there maybe other uses, e.g. loading configs from a json file.

Bonus: an even crazier usage
A(config={'a':'a', 'b':1, 'config':{'a':'b'}})

will yield

A(a='b', b=1)

Solution 3 - Python

A solution I use in almost all of my classes is to define additional constructors as classmethods.

Based on the given example, one could rewrite it as follows:

@dataclass(order=True, frozen=True)
class Card:
    rank: str = field(compare=False)
    suit: str = field(compare=False)
    value: int

    @classmethod
    def from_rank_and_suite(cls, rank: str, suit: str) -> "Card":
        value = RANKS.index(self.rank) + 1
        return cls(rank=rank, suit=suit, value=value)

By this one has all the freedom one requires without having to resort to __setattr__ hacks and without having to give up desired strictness like frozen=True.

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionnicholishenView Question on Stackoverflow
Solution 1 - Pythonuser2357112View Answer on Stackoverflow
Solution 2 - PythonTimView Answer on Stackoverflow
Solution 3 - PythonMax GörnerView Answer on Stackoverflow