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

Question:

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'
Asked By: nicholishen

||

Answers:

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

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

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)
Answered By: Tim

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.

Answered By: Max Görner

This feels a little bit like ‘hacking’ the intent of a frozen dataclass, but works well and is clean for making modifications to a frozen dataclass within the post_init method. Note that this decorator could be used for any method (which feels scary, given that you expect the dataclass to be frozen), thus I compensated by asserting the function name this decorator attaches to must be ‘post_init‘.

Separate from the class, write a decorator that you’ll use in the class:

def _defrost(cls):
    cls.stash_setattr = cls.__setattr__
    cls.stash_delattr = cls.__delattr__
    cls.__setattr__ = object.__setattr__
    cls.__delattr__ = object.__delattr__

def _refreeze(cls):
    cls.__setattr__ = cls.stash_setattr
    cls.__delattr__ = cls.stash_delattr
    del cls.stash_setattr
    del cls.stash_delattr

def temp_unfreeze_for_postinit(func):
    assert func.__name__ == '__post_init__'
    def wrapper(self, *args, **kwargs):
        _defrost(self.__class__)
        func(self, *args, **kwargs)
        _refreeze(self.__class__)
    return wrapper

Then, within your frozen dataclass, simply decorate your post_init method!

@dataclasses.dataclass(frozen=True)
class SimpleClass:
    a: int

    @temp_unfreeze_for_postinit
    def __post_init__(self, adder):
        self.b = self.a + adder
Answered By: bayesIan

Commenting with my own solution as I stumbled upon this with the same question but found none of the solutions suited my application.

Here the property that, much like OP, I tried to create in a post_init method initially is the bit_mask property.

I got it to work the cached_property decorator in functools; since I wanted the property to be static/immutable much like the other properties in the dataclass.

The function create_bitmask is defined elsewhere in my code, but you can see that it depends on the other properties of the dataclass instantance.

Hopefully, someone else might find this helpful.

from dataclasses import dataclass
from functools import cached_property

@dataclass(frozen=True)
class Register:
    subsection: str
    name: str
    abbreviation: str
    address: int
    n_bits: int
    _get_method: Callable[[int], int]
    _set_method: Callable[[int, int], None]
    _save_method: Callable[[int, int], None]

    @cached_property
    def bit_mask(self) -> int:
        # The cache is used to avoid recalculating since this is a static value
        # (hence max_size = 1)
        return create_bitmask(
            n_bits=self.n_bits,
            start_bit=0,
            size=self.n_bits,
            set_val=True
            )

    def get(self) -> int:
        raw_value = self._get_method(self.address)
        return raw_value & self.bit_mask

    def set(self, value: int) -> None:
        self._set_method(
            self.address,
            value & self.bit_mask
            )

    def save(self, value: int) -> None:
        self._save_method(
            self.address,
            value & self.bit_mask
            )
Answered By: Anna Giasson

Solution avoiding object mutation using cached property

This is a simplified Version of @Anna Giasson answer.

Frozen dataclasses work well together with caching from the functools module. Instead of using a dataclass field, you can define a @functools.cached_property annotated method that gets evaluated only upon the first lookup of the attribute. Here is a minimal version of the original example:

from dataclasses import dataclass
import functools

@dataclass(frozen=True)
class Card:
    rank: str

    @functools.cached_property
    def value(self):
        # just for demonstration:
        # this gets printed only once per Card instance
        print("Evaluate value")
        return len(self.rank)

card = Card(rank="foo")

assert card.value == 3
assert card.value == 3

In practice, if the evaluation is cheap, you can also use a non-cached @property decorator.

Answered By: Peter Barmettler

Avoiding mutation as proposed by Peter Barmettler is what I tend to do in such cases. It feels much more consistent with the frozen=True feature. As a side note, order=True and the __add__ method made me think you would like to sort and compute a score based on a list of cards.

This might be a possible approach:

from __future__ import annotations
from dataclasses import dataclass

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


@dataclass(frozen=True)
class Card:
    rank: str
    suit: str

    @property
    def value(self) -> int:
        return RANKS.index(self.rank) + 1

    def __lt__(self, __o: Card) -> bool:
        return self.value < __o.value

    def __str__(self) -> str:
        return f'{self.rank} of {self.suit}'

    @classmethod
    def score(cls, cards: list[Card]) -> int: 
        return sum(card.value for card in cards)


c1 = Card('A', 'H')
c2 = Card('3', 'D')

cards = [c1, c2]

Card.score(cards) # -> 15
sorted(cards) # -> [Card(rank='3', suit='D'), Card(rank='A', suit='H')]

The scoring logic does not need to be a class method, but this feels ok since the logic determining the value of a card is inside the class as well.

Answered By: raffaele
Categories: questions Tags:
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.