How to make non-frozen dataclass frozen, and vice versa?

Question:

I want to know a simple way to make a dataclass bar frozen.

@dataclass
class Bar:
    foo: int
bar = Bar(foo=1)

In other words, I want a function like the following some_fn_to_freeze

frozen_bar = some_fn_to_freeze(bar)
frozen_bar.foo = 2 # Error

And, an inverse function some_fn_to_unfreeze

bar = som_fn_to_unfrozen(frozen_bar)
bar.foo = 3 # not Error
Asked By: tamuhey

||

Answers:

dataclass doesn’t have built-in support for that. Frozen-ness is tracked on a class-wide basis, not per-instance, and there’s no support for automatically generating frozen or unfrozen equivalents of dataclasses.

While you could try to do something to generate new dataclasses on the fly, it’d interact very poorly with isinstance, ==, and other things you’d want to work. It’s probably safer to just write two dataclasses and converter methods:

@dataclass
class Bar:
    foo: int
    def as_frozen(self):
        return FrozenBar(self.foo)

@dataclass(frozen=True)
class FrozenBar:
    foo: int
    def as_unfrozen(self):
        return Bar(self.foo)
Answered By: user2357112

Python dataclasses are great, but the attrs package is a more flexible alternative, if you are able to use a third-party library. For example:

import attr

# Your class of interest.
@attr.s()
class Bar(object):
   val = attr.ib()

# A frozen variant of it.
@attr.s(frozen = True)
class FrozenBar(Bar):
   pass

# Three instances:
# - Bar.
# - FrozenBar based on that Bar.
# - Bar based on that FrozenBar.
b1 = Bar(123)
fb = FrozenBar(**attr.asdict(b1))
b2 = Bar(**attr.asdict(fb))

# We can modify the Bar instances.
b1.val = 777
b2.val = 888

# Check current vals.
for x in (b1, fb, b2):
    print(x)

# But we cannot modify the FrozenBar instance.
try:
    fb.val = 999
except attr.exceptions.FrozenInstanceError:
    print(fb, 'unchanged')

Output:

Bar(val=888)
FrozenBar(val=123)
Bar(val=999)
FrozenBar(val=123) unchanged
Answered By: FMc

The standard way to mutate a frozen dataclass is to use dataclasses.replace:

old_bar = Bar(foo=123)
new_bar = dataclasses.replace(old_bar, foo=456)
assert new_bar.foo == 456

For more complex use-cases, you can use the dataclass utils module from: https://github.com/google/etils

It add a my_dataclass = my_dataclass.unfrozen() member, which allow to mutate frozen dataclasses directly

# pip install etils[edc]
from etils import edc

@edc.dataclass(allow_unfrozen=True)  # Add the `unfrozen()`/`frozen` method
@dataclasses.dataclass(frozen=True)
class A:
  x: Any = None
  y: Any = None


old_a = A(x=A(x=A()))

# After a is unfrozen, the updates on nested attributes will be propagated
# to the top-level parent.
a = old_a.unfrozen()
a.x.x.x = 123
a.x.y = 'abc'
a = a.frozen()  # `frozen()` recursively call `dataclasses.replace`

# Only the `unfrozen` object is mutated. Not the original one.
assert a == A(x=A(x=A(x = 123), y='abc'))
assert old_a == A(x=A(x=A()))

As seen in the example, you can return unfrozen/frozen copies of the dataclass, which was explicitly designed to mutate nested dataclasses.

@edc.dataclass also add a a.replace(**kwargs) method to the dataclass (alias of dataclasses.dataclass)

a = A()
a = a.replace(x=123, y=456)
assert a == A(x=123, y=456)
Answered By: Conchylicultor

I’m using the following code to get a frozen copy of a dataclass class or instance:

import dataclasses 
from dataclasses import dataclass, fields, asdict
import typing
from typing import TypeVar


FDC_SELF = TypeVar('FDC_SELF', bound='FreezableDataClass')

@dataclass
class FreezableDataClass:
    @classmethod
    def get_frozen_dataclass(cls: Type[FDC_SELF]) -> Type[FDC_SELF]:
        """
        @return: a generated frozen dataclass definition, compatible with the calling class
        """
        cls_fields = fields(cls)
        frozen_cls_name = 'Frozen' + cls.__name__

        frozen_dc_namespace = {
            '__name__': frozen_cls_name,
            '__module__': __name__,
        }
        excluded_from_freezing = cls.attrs_excluded_from_freezing()
        for attr in dir(cls):
            if attr.startswith('__') or attr in excluded_from_freezing:
                continue
            attr_def = getattr(cls, attr)
            if hasattr(attr_def, '__func__'):
                attr_def = classmethod(getattr(attr_def, '__func__'))
            frozen_dc_namespace[attr] = attr_def

        frozen_dc = dataclasses.make_dataclass(
            cls_name=frozen_cls_name,
            fields=[(f.name, f.type, f) for f in cls_fields],
            bases=(),
            namespace=frozen_dc_namespace,
            frozen=True,
        )
        globals()[frozen_dc.__name__] = frozen_dc
        return frozen_dc

    @classmethod
    def attrs_excluded_from_freezing(cls) -> typing.Iterable[str]:
        return tuple()

    def get_frozen_instance(self: FDC_SELF) -> FDC_SELF:
        """
        @return: an instance of a generated frozen dataclass, compatible with the current dataclass, with copied values
        """
        cls = type(self)
        frozen_dc = cls.get_frozen_dataclass()
        # noinspection PyArgumentList
        return frozen_dc(**asdict(self))

Derived classes could overwrite attrs_excluded_from_freezing to exclude methods which wouldn’t work on a frozen dataclass.

Why didn’t I prefer other existing answers?

Answered By: Dev
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.