Python dataclass with readonly attributes
(I wrote this: https://stackoverflow.com/a/79341515/20891286)
Python 3.10+
Uses Wizard.Ritvik’s answer and modified it so that the API is closer to normal dataclass, and supports all the parameters of field()
The signature_from
function is just a utility for static type checkers and can be omitted.
Note that PyCharm, as of time of writing, has quite a few static type checking bugs and thus you may need to turn off “Incorrect call arguments” or it will complain about unfilled parameters in frozen_field()
from dataclasses import dataclass, field, fields
from typing import Callable, Generic, TypeVar, ParamSpec, Any
T = TypeVar("T")
P = ParamSpec("P")
class FrozenField(Generic[T]):
"""A descriptor that makes an attribute immutable after it has been set."""
__slots__ = ("private_name",)
def __init__(self, name: str) -> None:
self.private_name = "_" + name
def __get__(self, instance: object | None, owner: type[object] | None = None) -> T:
value = getattr(instance, self.private_name)
return value
def __set__(self, instance: object, value: T) -> None:
if hasattr(instance, self.private_name):
msg = f"Attribute `{self.private_name[1:]}` is immutable!"
raise TypeError(msg) from None
setattr(instance, self.private_name, value)
# https://stackoverflow.com/questions/74714300/paramspec-for-a-pre-defined-function-without-using-generic-callablep
def signature_from(_original: Callable[P, T]) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Copies the signature of a function to another function."""
def _fnc(fnc: Callable[P, T]) -> Callable[P, T]:
return fnc
return _fnc
@signature_from(field)
def frozen_field(**kwargs: Any):
"""A field that is immutable after it has been set. See `dataclasses.field` for more information."""
metadata = kwargs.pop("metadata", {}) | {"frozen": True}
return field(**kwargs, metadata=metadata)
def freeze_fields(cls: type[T]) -> type[T]:
"""
A decorator that makes fields of a dataclass immutable, if they have the `frozen` metadata set to True.
This is done by replacing the fields with FrozenField descriptors.
Args:
cls: The class to make immutable, must be a dataclass.
Raises:
TypeError: If cls is not a dataclass
"""
cls_fields = getattr(cls, "__dataclass_fields__", None)
if cls_fields is None:
raise TypeError(f"{cls} is not a dataclass")
params = getattr(cls, "__dataclass_params__")
# _DataclassParams(init=True,repr=True,eq=True,order=True,unsafe_hash=False,
# frozen=True,match_args=True,kw_only=False,slots=False,
# weakref_slot=False)
if params.frozen:
return cls
for f in fields(cls): # type: ignore
if "frozen" in f.metadata:
setattr(cls, f.name, FrozenField(f.name))
return cls
@freeze_fields
@dataclass(order=True)
class DC:
stuff: int = frozen_field()
def main():
dc = DC(stuff=3)
print(repr(dc)) # DC(stuff=3)
dc.stuff = 4 # TypeError: Attribute `stuff` is immutable!
if __name__ == "__main__":
main()