Skip to content

Commit a6b5b1e

Browse files
authored
[dataclass_transform] fix frozen behavior for base classes with direct metaclasses (#14878)
Fixes #14857. The initial implementation overlooked this statement in [PEP 681](https://peps.python.org/pep-0681/#dataclass-semantics): > Similarly, a class that directly specifies a metaclass that is decorated with dataclass_transform is considered neither frozen nor non-frozen. As far as I can tell, this is a special case that _only_ applies to classes that directly specify a `dataclass_transform` metaclass. This is import for projects like Pydantic, which requires clients to use a base class but still supports a `frozen` parameter. Note that this allows mixed frozen and non-frozen behavior if the base class _does_ have fields: ``` @dataclass_transform() class Meta(type): ... class Base(metaclass=Meta, frozen=False): base: int = 0 class Foo(Base, frozen=True): foo: int = 0 foo = Foo() foo.foo = 1 # an error because Foo is frozen foo.base = 1 # NOT an error — Base is neither frozen nor non-frozen ``` While this is probably surprising behavior, it seems to match the text of the PEP as well as the behavior of Pyright.
1 parent 8d59d31 commit a6b5b1e

File tree

2 files changed

+56
-2
lines changed

2 files changed

+56
-2
lines changed

mypy/plugins/dataclasses.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def __init__(
8989
type: Type | None,
9090
info: TypeInfo,
9191
kw_only: bool,
92+
is_neither_frozen_nor_nonfrozen: bool,
9293
) -> None:
9394
self.name = name
9495
self.alias = alias
@@ -100,6 +101,7 @@ def __init__(
100101
self.type = type
101102
self.info = info
102103
self.kw_only = kw_only
104+
self.is_neither_frozen_nor_nonfrozen = is_neither_frozen_nor_nonfrozen
103105

104106
def to_argument(self, current_info: TypeInfo) -> Argument:
105107
arg_kind = ARG_POS
@@ -140,6 +142,7 @@ def serialize(self) -> JsonDict:
140142
"column": self.column,
141143
"type": self.type.serialize(),
142144
"kw_only": self.kw_only,
145+
"is_neither_frozen_nor_nonfrozen": self.is_neither_frozen_nor_nonfrozen,
143146
}
144147

145148
@classmethod
@@ -292,7 +295,11 @@ def transform(self) -> bool:
292295
parent_decorator_arguments = []
293296
for parent in info.mro[1:-1]:
294297
parent_args = parent.metadata.get("dataclass")
295-
if parent_args:
298+
299+
# Ignore parent classes that directly specify a dataclass transform-decorated metaclass
300+
# when searching for usage of the frozen parameter. PEP 681 states that a class that
301+
# directly specifies such a metaclass must be treated as neither frozen nor non-frozen.
302+
if parent_args and not _has_direct_dataclass_transform_metaclass(parent):
296303
parent_decorator_arguments.append(parent_args)
297304

298305
if decorator_arguments["frozen"]:
@@ -582,6 +589,9 @@ def collect_attributes(self) -> list[DataclassAttribute] | None:
582589
type=sym.type,
583590
info=cls.info,
584591
kw_only=is_kw_only,
592+
is_neither_frozen_nor_nonfrozen=_has_direct_dataclass_transform_metaclass(
593+
cls.info
594+
),
585595
)
586596

587597
all_attrs = list(found_attrs.values())
@@ -624,6 +634,13 @@ def _freeze(self, attributes: list[DataclassAttribute]) -> None:
624634
"""
625635
info = self._cls.info
626636
for attr in attributes:
637+
# Classes that directly specify a dataclass_transform metaclass must be neither frozen
638+
# non non-frozen per PEP681. Though it is surprising, this means that attributes from
639+
# such a class must be writable even if the rest of the class heirarchy is frozen. This
640+
# matches the behavior of Pyright (the reference implementation).
641+
if attr.is_neither_frozen_nor_nonfrozen:
642+
continue
643+
627644
sym_node = info.names.get(attr.name)
628645
if sym_node is not None:
629646
var = sym_node.node
@@ -787,3 +804,10 @@ def _is_dataclasses_decorator(node: Node) -> bool:
787804
if isinstance(node, RefExpr):
788805
return node.fullname in dataclass_makers
789806
return False
807+
808+
809+
def _has_direct_dataclass_transform_metaclass(info: TypeInfo) -> bool:
810+
return (
811+
info.declared_metaclass is not None
812+
and info.declared_metaclass.type.dataclass_transform_spec is not None
813+
)

test-data/unit/check-dataclass-transform.test

+31-1
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,11 @@ from typing import dataclass_transform
415415
@dataclass_transform(frozen_default=True)
416416
class Dataclass(type): ...
417417

418-
class Person(metaclass=Dataclass, kw_only=True):
418+
# Note that PEP 681 states that a class that directly specifies a dataclass_transform-decorated
419+
# metaclass should be treated as neither frozen nor unfrozen. For Person to have frozen semantics,
420+
# it may not directly specify the metaclass.
421+
class BaseDataclass(metaclass=Dataclass): ...
422+
class Person(BaseDataclass, kw_only=True):
419423
name: str
420424
age: int
421425

@@ -777,3 +781,29 @@ FunctionModel(x=1, y=2, z1=3, z2=4)
777781

778782
[typing fixtures/typing-full.pyi]
779783
[builtins fixtures/dataclasses.pyi]
784+
785+
[case testDataclassTransformDirectMetaclassNeitherFrozenNorNotFrozen]
786+
# flags: --python-version 3.11
787+
from typing import dataclass_transform, Type
788+
789+
@dataclass_transform()
790+
class Meta(type): ...
791+
class Base(metaclass=Meta):
792+
base: int
793+
class Foo(Base, frozen=True):
794+
foo: int
795+
class Bar(Base, frozen=False):
796+
bar: int
797+
798+
799+
foo = Foo(0, 1)
800+
foo.foo = 5 # E: Property "foo" defined in "Foo" is read-only
801+
foo.base = 6
802+
reveal_type(foo.base) # N: Revealed type is "builtins.int"
803+
bar = Bar(0, 1)
804+
bar.bar = 5
805+
bar.base = 6
806+
reveal_type(bar.base) # N: Revealed type is "builtins.int"
807+
808+
[typing fixtures/typing-full.pyi]
809+
[builtins fixtures/dataclasses.pyi]

0 commit comments

Comments
 (0)