How to set the value of dataclass field in __post_init__ when frozen=True?
PythonPython 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
.