Dataclasses freezing specific fields should be possible

it is common in python to make some of our class fields read only after initialization commonly by using
the property decorator

class Person:
    def __init__(self, name: str, age: int) -> None:
        self._name = name
        self.age = age

    @property
    def name(self) -> str:
        return name

but writing many class by hand is tedious, so we switch to dataclasses but the problem with dataclasses is that they don’t support specific frozen field, in dataclasses its all frozen or nothing is frozen, because we can define frozen only on the class level

@dataclass(frozen=True)  # makes all fields in class frozen
class Person:
    name: str
    age: int

if we want to freeze specific fields in our class using dataclass we need to write a bit of code while the whole point of dataclasses is to make it easy to write classes, but EVEN THEN the fields won’t be safe from manipulation!

@dataclass(repr=True)
class Person:
    name: InitVar[str]
    _name: str = field(init=False)
    age: int

    @property
    def name_(self) -> str:
        self._name

    def __post_init__(self, name: str) -> None:
        self._name = name

a lot of name variables, hard to read and remember that this is only for 1 field

  • field name - the init variable when people initilize the class they will write Person(name=...,...)
  • field _name - the actual field that will store the value, we mark it as private
  • property name_ - a way for people to get the field name, but we had to suffix it with _ so it won’t collide with the name field annotation

ugh… so much code for a simple functionality, and even after all of that, someone can just come and manipulate the field

p = Person(name=..., age=...)
p._name = "foo"

like kw_only can be define on the class level or per-field, it make sense to do it for frozen, since freezing specific fields is common.

this is how it will look like if we were able to freeze specific fields

@dataclass(repr=True)
class Person:
    name: str = field(frozen=True)
    age: int

much cleaner, more readable and simpler then the current solution we have.
also they are actually safe from manipulation

p = Person(name=..., age=....)
p.name = "foo"

dataclass_test.FrozenInstanceError: cannot assign to field 'name'

limitations

now because frozen is per field, it make sense to put to it some limitations where is doesn’t make sense using frozen

shouldn’t be possible to set field frozen=False on a frozen class

@dataclass(frozen=True)
class Person:
   name: str
   age: int = field(frozen=False)  # should raise error

ValueError: field `name` marked as not frozen on a frozen class `Person`

dosn’t make sense to make ClassVar and InitVar fields frozen since they are not attached to the running instance, THIS IS NOT BACKWARD COMPATIBLE

@dataclass
class Foo:
    x: InitVar[int] = field(frozen=True)

TypeError: field `x` cannot be frozen

please let me know if this make sense and if it should be implemented differently, I think a lot of people actually want it.

3 Likes

Good proposal.
I think I found a workaround for this, using the metadata parameter of field, together with a classmethod and property:

from dataclasses import dataclass, field, fields


@dataclass
class Person:
    name: str
    age: int = field(metadata={"frozen": True})

    def __post_init__(self):
        self.__set_fields_frozen(self)

    @classmethod
    def __set_fields_frozen(cls, self):
        flds = fields(cls)
        for fld in flds:
            if fld.metadata.get("frozen"):
                field_name = fld.name
                field_value = getattr(self, fld.name)
                setattr(self, f"_{fld.name}", field_value)

                def local_getter(self):
                    return getattr(self, f"_{field_name}")

                def frozen(name):
                    def local_setter(self, value):
                        raise RuntimeError(f"Field '{name}' is frozen!")

                    return local_setter

                setattr(cls, field_name, property(local_getter, frozen(field_name)))


person = Person("John", 36)

print(person)  # prints: Person(name='John', age=36)
print(person.age)  # prints: 36
person.age = 56  # raise: RuntimeError: Field 'age' is frozen!