Repository: python/mypy (Python)

Issue:
`Cannot inherit frozen dataclass from a non-frozen one` error when using pydantic `frozen=True`
<!--
If you're not sure whether what you're experiencing is a mypy bug, please see the "Question and Help" form instead.
Please consider:

- checking our common issues page: https://mypy.readthedocs.io/en/stable/common_issues.html
- searching our issue tracker: https://github.com/python/mypy/issues to see if it's already been reported
- asking on gitter chat: https://gitter.im/python/typing
-->

**Bug Report**

<!--
If you're reporting a problem with a specific library function, the typeshed tracker is better suited for this report: https://github.com/python/typeshed/issues

If the project you encountered the issue in is open source, please provide a link to the project.
-->

Specifying `frozen=True` as model class kwargs for pydantic models causes the following error to be emitted from `mypy`:
```
Cannot inherit frozen dataclass from a non-frozen one
```

**To Reproduce**
1. Install pydantic and mypy: `pip install pydantic mypy`
2. Create `test.py` with the following content:
```python
from pydantic import BaseModel

class MyModel(BaseModel, frozen=True):
    my_val: int
```
3. Run `mypy` on the test file: `mypy test.py`

**Expected Behavior**
No errors reported (as was the case with mypy 1.0.1):
```
> mypy test.py
Success: no issues found in 1 source file
```

**Actual Behavior**
Errors reported:
```
> mypy test.py
test.py:3: error: Cannot inherit frozen dataclass from a non-frozen one  [misc]
Found 1 error in 1 file (checked 1 source file)
```

**Your Environment**

<!-- Include as many relevant details about the environment you experienced the bug in -->

- Mypy version used: 1.1.1
- Python version used: 3.10

<!-- You can freely edit this text, please remove all the lines you believe are unnecessary. -->



Hints:
Similar to in #14850, I believe this is actually an issue with the Pydantic library's use of `dataclass_transform`. From looking at the [code](https://github.com/pydantic/pydantic/blob/main/pydantic/main.py#L147), it seems that `BaseModel` is itself using the transform and should be treated as a dataclass, so per the standard, frozen dataclasses cannot subclass it.

You may be able to use the same workaround as suggested in #14850 (using the latest version of the Pydantic plugin). 
After double checking the spec (whoops), there is actually a [provision](https://peps.python.org/pep-0681/#dataclass-semantics) for this:

> Frozen dataclasses cannot inherit from non-frozen dataclasses. A class that has been decorated with dataclass_transform is considered neither frozen nor non-frozen, thus allowing frozen classes to inherit from it. Similarly, a class that directly specifies a metaclass that is decorated with dataclass_transform is considered neither frozen nor non-frozen.

This seems to be the only way to declare a class that has dataclass semantics but which is treated as neither frozen nor non-frozen. This is definitely a bug in MyPy; I will try to create a PR to fix it.

Patch:
diff --git a/mypy/plugins/dataclasses.py b/mypy/plugins/dataclasses.py
--- 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
+    )


Test patch:
diff --git a/test-data/unit/check-dataclass-transform.test b/test-data/unit/check-dataclass-transform.test
--- 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]