Skip to content

[dataclass_transform] fix frozen behavior for base classes with direct metaclasses #14878

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion mypy/plugins/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
32 changes: 31 additions & 1 deletion test-data/unit/check-dataclass-transform.test
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]