5

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
tamuhey
  • 2,904
  • 3
  • 21
  • 50
  • The desire to do this implies serious design issues. If you want to mutate instances, why was the dataclass frozen in the first place? If you want to protect them, why wasn't it? – Karl Knechtel Jan 20 '23 at 02:01

4 Answers4

6

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)
Conchylicultor
  • 4,631
  • 2
  • 37
  • 40
4

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)
user2357112
  • 260,549
  • 28
  • 431
  • 505
3

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
FMc
  • 41,963
  • 13
  • 79
  • 132
  • `attrs` may be more flexible, but there's nothing here you couldn't do with `dataclass`. For example, the inheritance would have worked with `dataclass` too; I just find it clearer to have separate classes. (Also, `asdict` is dangerous due to confusing recursion and copying behavior.) – user2357112 May 10 '19 at 01:31
  • 2
    @user2357112 Frozen dataclasses cannot inherit from non-frozen, and vice versa, unless the experiment I just ran had a flaw in it. – FMc May 10 '19 at 01:36
  • 1
    Huh. Looks like you're right. The check doesn't seem to be applied if the parent has no fields, which threw off my first test. – user2357112 May 10 '19 at 01:39
  • @FMc very cool! What do you think about implementing tree structure with `attrs`? – tamuhey May 10 '19 at 02:07
  • @Yohei Sounds fine. Remember, that the classes generated by `attrs` are regular Python classes. – FMc May 10 '19 at 03:13
  • I used attrs and found it is very good package, but it is incompatible with linter and typecheckers,,, – tamuhey May 22 '19 at 02:23
0

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?

Dvir Berebi
  • 1,406
  • 14
  • 25