Skip to content

Commit 966efe1

Browse files
committed
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.
1 parent 1462b4e commit 966efe1

2 files changed

Lines changed: 44 additions & 0 deletions

File tree

mypy/checker.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3138,6 +3138,16 @@ class C(B, A[int]): ... # this is unsafe because...
31383138
return
31393139
first = base1.names[name]
31403140
second = base2.names[name]
3141+
if name == "__replace__" and first.plugin_generated and second.plugin_generated:
3142+
# Plugin-synthesized __replace__ methods (e.g. those added by the
3143+
# @dataclass plugin on Python 3.13+ to support copy.replace())
3144+
# return Self and are regenerated fresh for every concrete
3145+
# subclass, so they can safely differ across unrelated bases --
3146+
# same reasoning as __init__ and friends above. We only skip this
3147+
# for methods a plugin generated, not ones the user wrote by
3148+
# hand, so real incompatible __replace__ overrides are still
3149+
# caught.
3150+
return
31413151
# Specify current_class explicitly as this function is called after leaving the class.
31423152
first_type, _ = self.node_type_from_base(name, base1, ctx, current_class=ctx)
31433153
second_type, _ = self.node_type_from_base(name, base2, ctx, current_class=ctx)

test-data/unit/check-dataclasses.test

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2609,6 +2609,40 @@ class Y(X):
26092609
[builtins fixtures/tuple.pyi]
26102610

26112611

2612+
[case testDunderReplaceDoesNotBlockAdHocIntersectionNarrowing]
2613+
# https://github.com/python/mypy/issues/21635
2614+
# flags: --python-version 3.13
2615+
from dataclasses import dataclass
2616+
2617+
@dataclass
2618+
class A: ...
2619+
@dataclass
2620+
class M: ...
2621+
@dataclass
2622+
class B(A): ...
2623+
@dataclass
2624+
class C(M, A): ...
2625+
2626+
alist: list[type[A]] = [B, C]
2627+
mlist: list[type[M]] = [cls for cls in alist if issubclass(cls, M)]
2628+
reveal_type(mlist) # N: Revealed type is "builtins.list[type[__main__.M]]"
2629+
[builtins fixtures/isinstancelist.pyi]
2630+
2631+
[case testDunderReplaceHandwrittenStillCheckedForCompatibility]
2632+
# flags: --python-version 3.13
2633+
class A:
2634+
def __replace__(self) -> "A":
2635+
return A()
2636+
2637+
class M:
2638+
def __replace__(self) -> "M":
2639+
return M()
2640+
2641+
class C(M, A): # E: Definition of "__replace__" in base class "M" is incompatible with definition in base class "A"
2642+
pass
2643+
[builtins fixtures/tuple.pyi]
2644+
2645+
26122646
[case testFrozenWithFinal]
26132647
from dataclasses import dataclass
26142648
from typing import Final

0 commit comments

Comments
 (0)