diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py index a68410765367..16b1595e3cb8 100644 --- a/mypy/plugins/dataclasses.py +++ b/mypy/plugins/dataclasses.py @@ -89,6 +89,7 @@ def __init__( type: Type | None, info: TypeInfo, kw_only: bool, + is_neither_frozen_nor_nonfrozen: bool, ) -> None: self.name = name self.alias = alias @@ -100,6 +101,7 @@ def __init__( self.type = type self.info = info self.kw_only = kw_only + self.is_neither_frozen_nor_nonfrozen = is_neither_frozen_nor_nonfrozen def to_argument(self, current_info: TypeInfo) -> Argument: arg_kind = ARG_POS @@ -140,6 +142,7 @@ def serialize(self) -> JsonDict: "column": self.column, "type": self.type.serialize(), "kw_only": self.kw_only, + "is_neither_frozen_nor_nonfrozen": self.is_neither_frozen_nor_nonfrozen, } @classmethod @@ -292,7 +295,11 @@ def transform(self) -> bool: parent_decorator_arguments = [] for parent in info.mro[1:-1]: parent_args = parent.metadata.get("dataclass") - if parent_args: + + # Ignore parent classes that directly specify a dataclass transform-decorated metaclass + # when searching for usage of the frozen parameter. PEP 681 states that a class that + # directly specifies such a metaclass must be treated as neither frozen nor non-frozen. + if parent_args and not _has_direct_dataclass_transform_metaclass(parent): parent_decorator_arguments.append(parent_args) if decorator_arguments["frozen"]: @@ -582,6 +589,9 @@ def collect_attributes(self) -> list[DataclassAttribute] | None: type=sym.type, info=cls.info, kw_only=is_kw_only, + is_neither_frozen_nor_nonfrozen=_has_direct_dataclass_transform_metaclass( + cls.info + ), ) all_attrs = list(found_attrs.values()) @@ -624,6 +634,13 @@ def _freeze(self, attributes: list[DataclassAttribute]) -> None: """ info = self._cls.info for attr in attributes: + # Classes that directly specify a dataclass_transform metaclass must be neither frozen + # non non-frozen per PEP681. Though it is surprising, this means that attributes from + # such a class must be writable even if the rest of the class heirarchy is frozen. This + # matches the behavior of Pyright (the reference implementation). + if attr.is_neither_frozen_nor_nonfrozen: + continue + sym_node = info.names.get(attr.name) if sym_node is not None: var = sym_node.node @@ -787,3 +804,10 @@ def _is_dataclasses_decorator(node: Node) -> bool: if isinstance(node, RefExpr): return node.fullname in dataclass_makers return False + + +def _has_direct_dataclass_transform_metaclass(info: TypeInfo) -> bool: + return ( + info.declared_metaclass is not None + and info.declared_metaclass.type.dataclass_transform_spec is not None + ) diff --git a/test-data/unit/check-dataclass-transform.test b/test-data/unit/check-dataclass-transform.test index b0c1cdf56097..8d8e38997582 100644 --- a/test-data/unit/check-dataclass-transform.test +++ b/test-data/unit/check-dataclass-transform.test @@ -415,7 +415,11 @@ from typing import dataclass_transform @dataclass_transform(frozen_default=True) class Dataclass(type): ... -class Person(metaclass=Dataclass, kw_only=True): +# Note that PEP 681 states that a class that directly specifies a dataclass_transform-decorated +# metaclass should be treated as neither frozen nor unfrozen. For Person to have frozen semantics, +# it may not directly specify the metaclass. +class BaseDataclass(metaclass=Dataclass): ... +class Person(BaseDataclass, kw_only=True): name: str age: int @@ -777,3 +781,29 @@ FunctionModel(x=1, y=2, z1=3, z2=4) [typing fixtures/typing-full.pyi] [builtins fixtures/dataclasses.pyi] + +[case testDataclassTransformDirectMetaclassNeitherFrozenNorNotFrozen] +# flags: --python-version 3.11 +from typing import dataclass_transform, Type + +@dataclass_transform() +class Meta(type): ... +class Base(metaclass=Meta): + base: int +class Foo(Base, frozen=True): + foo: int +class Bar(Base, frozen=False): + bar: int + + +foo = Foo(0, 1) +foo.foo = 5 # E: Property "foo" defined in "Foo" is read-only +foo.base = 6 +reveal_type(foo.base) # N: Revealed type is "builtins.int" +bar = Bar(0, 1) +bar.bar = 5 +bar.base = 6 +reveal_type(bar.base) # N: Revealed type is "builtins.int" + +[typing fixtures/typing-full.pyi] +[builtins fixtures/dataclasses.pyi]