From 966efe1fecbbb3372909b496d6c109ebf97c4457 Mon Sep 17 00:00:00 2001 From: Yitzchak Gale Date: Sun, 5 Jul 2026 12:14:37 +0000 Subject: [PATCH 1/2] Don't flag synthesized __replace__ as incompatible across unrelated bases Fixes #21635. On Python 3.13+, @dataclass synthesizes a __replace__(self, ...) -> Self method to support copy.replace(). When mypy builds an ad-hoc intersection type to narrow an expression via issubclass()/isinstance() against two unrelated dataclasses, check_multiple_inheritance sees each dataclass's synthesized __replace__ returning its own concrete type (e.g. A vs M) and flags them as incompatible, causing intersect_instances to fail and the narrowed type to collapse to Never -- even when a real subclass of both (itself decorated with @dataclass, and so getting its own compatible synthesized __replace__) already exists in the code. This exempts __replace__ from the cross-base compatibility check the same way __init__/__new__/__init_subclass__ already are, but only when the method was generated by a plugin (plugin_generated=True) on both sides -- hand-written __replace__ overrides with genuine incompatibilities are still caught. --- mypy/checker.py | 10 ++++++++ test-data/unit/check-dataclasses.test | 34 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/mypy/checker.py b/mypy/checker.py index d13b927b28f2..1316eaee6209 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -3138,6 +3138,16 @@ class C(B, A[int]): ... # this is unsafe because... return first = base1.names[name] second = base2.names[name] + if name == "__replace__" and first.plugin_generated and second.plugin_generated: + # Plugin-synthesized __replace__ methods (e.g. those added by the + # @dataclass plugin on Python 3.13+ to support copy.replace()) + # return Self and are regenerated fresh for every concrete + # subclass, so they can safely differ across unrelated bases -- + # same reasoning as __init__ and friends above. We only skip this + # for methods a plugin generated, not ones the user wrote by + # hand, so real incompatible __replace__ overrides are still + # caught. + return # Specify current_class explicitly as this function is called after leaving the class. first_type, _ = self.node_type_from_base(name, base1, ctx, current_class=ctx) second_type, _ = self.node_type_from_base(name, base2, ctx, current_class=ctx) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index 54b3afadc8b3..d744da44ec57 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2609,6 +2609,40 @@ class Y(X): [builtins fixtures/tuple.pyi] +[case testDunderReplaceDoesNotBlockAdHocIntersectionNarrowing] +# https://github.com/python/mypy/issues/21635 +# flags: --python-version 3.13 +from dataclasses import dataclass + +@dataclass +class A: ... +@dataclass +class M: ... +@dataclass +class B(A): ... +@dataclass +class C(M, A): ... + +alist: list[type[A]] = [B, C] +mlist: list[type[M]] = [cls for cls in alist if issubclass(cls, M)] +reveal_type(mlist) # N: Revealed type is "builtins.list[type[__main__.M]]" +[builtins fixtures/isinstancelist.pyi] + +[case testDunderReplaceHandwrittenStillCheckedForCompatibility] +# flags: --python-version 3.13 +class A: + def __replace__(self) -> "A": + return A() + +class M: + def __replace__(self) -> "M": + return M() + +class C(M, A): # E: Definition of "__replace__" in base class "M" is incompatible with definition in base class "A" + pass +[builtins fixtures/tuple.pyi] + + [case testFrozenWithFinal] from dataclasses import dataclass from typing import Final From ef2cba9fd19b9d1e72fd5d32446b1ece879db967 Mon Sep 17 00:00:00 2001 From: Yitzchak Gale Date: Sun, 5 Jul 2026 17:03:29 +0000 Subject: [PATCH 2/2] Add regression test for plain issubclass() narrowing (no comprehension) Follow-up to the previous commit on this branch. Same underlying bug (#21635): the false impossible-intersection error from a plugin-synthesized __replace__ isn't specific to list comprehensions. In a plain if-statement, issubclass() narrowing hits the identical false Never, but the failure mode is silent instead of loud -- mypy marks the branch unreachable and skips type-checking it entirely, so no reveal_type note appears and a genuinely broken assignment inside the branch goes unreported. This adds a regression test for that minimal, comprehension-free reproduction alongside the existing ones. --- test-data/unit/check-dataclasses.test | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test-data/unit/check-dataclasses.test b/test-data/unit/check-dataclasses.test index d744da44ec57..28d824bae20d 100644 --- a/test-data/unit/check-dataclasses.test +++ b/test-data/unit/check-dataclasses.test @@ -2628,6 +2628,31 @@ mlist: list[type[M]] = [cls for cls in alist if issubclass(cls, M)] reveal_type(mlist) # N: Revealed type is "builtins.list[type[__main__.M]]" [builtins fixtures/isinstancelist.pyi] +[case testDunderReplaceDoesNotBlockPlainIssubclassNarrowing] +# https://github.com/python/mypy/issues/21635 +# Same underlying bug as testDunderReplaceDoesNotBlockAdHocIntersectionNarrowing, +# but without a list/comprehension: plain issubclass() narrowing in an +# ordinary if-statement hit the exact same false "impossible intersection" +# and silently marked the branch as unreachable instead of narrowing (no +# reveal_type note, and the type error below went unreported). +# flags: --python-version 3.13 +from dataclasses import dataclass + +@dataclass +class A: ... +@dataclass +class M: ... +@dataclass +class B(A): ... +@dataclass +class C(M, A): ... + +cls: type[A] = C +if issubclass(cls, M): + reveal_type(cls) # N: Revealed type is "type[__main__.]" + n: int = 'foo' # E: Incompatible types in assignment (expression has type "str", variable has type "int") +[builtins fixtures/isinstancelist.pyi] + [case testDunderReplaceHandwrittenStillCheckedForCompatibility] # flags: --python-version 3.13 class A: