Skip to content

Fix regression in dataclass narrowing for Python >= 3.13#21675

Open
ygale wants to merge 1 commit into
python:masterfrom
ygale:fix-21635-dunder-replace-narrowing
Open

Fix regression in dataclass narrowing for Python >= 3.13#21675
ygale wants to merge 1 commit into
python:masterfrom
ygale:fix-21635-dunder-replace-narrowing

Conversation

@ygale

@ygale ygale commented Jul 5, 2026

Copy link
Copy Markdown

Fixes #21635.

What's going wrong

On Python >= 3.13, @dataclass synthesizes a __replace__(self, ...) -> Self
method to support copy.replace(). When mypy tries to narrow a type[A]
expression via issubclass(cls, M) (or isinstance) against a second,
unrelated dataclass M, it builds an ad-hoc <subclass of A and M> type to
check whether that narrowing is sound (intersect_instances in
checker.py). Building that ad-hoc type runs check_multiple_inheritance,
which sees A's synthesized __replace__ returning A and M's
returning M, and flags them as incompatible.

That's a false positive: a real subclass of both (e.g. class C(M, A),
itself decorated with @dataclass) gets its own freshly synthesized,
mutually compatible __replace__.

The result

In an if statement where there should have been type narrowing, the type resolves as Never, and so the block is ignored by the type checker. Type checking always silently succeeds without actually checking.

In a comprehension, the if clause is assigned the type Never and so is ignored by the type checker. The object whose type should have been narrowed retains its original type while type checking the comprehension expression.

The fix

check_compatibility already exempts __init__, __new__, and
__init_subclass__ from this cross-base check, because those are expected
to vary safely per concrete subclass. __replace__ has the same character
when it's plugin-generated (Self-returning, regenerated fresh per
concrete class), so this adds it to that exemption -- but only when the
method is plugin_generated on both sides, so hand-written __replace__
overrides with genuine incompatibilities are still caught.

Not included in this PR

  • The TypeIs failure mentioned in the description of Dataclasses fail to narrow with issubclass() in Python >= 3.13 #21635 turns out to be a completely different failure mode. I will open a separate issue for that.
  • There is a similar failure for NamedTuple, but that also turns out to be a completely different failure mode. I will also open a separate issue for that.

Tests

  • New test cases in check-dataclasses.test with the reproduction from the description of Dataclasses fail to narrow with issubclass() in Python >= 3.13 #21635
  • New test cases in check-dataclasses.test with confirming that hand-written incompatible
    __replace__ methods are still flagged.
  • Ran the full dataclass/isinstance/narrow/typeguard/comprehension/
    multiple-inheritance test suites locally; all pass.
  • black, ruff check, and codespell all clean on the changed file.
  • mypy's own self-check on checker.py is clean.

LLM Disclosure

I was assisted by Claude (Sonnet 5), under my close guidance and supervision. The work is mine.

…ases

Fixes python#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.
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Diff from mypy_primer, showing the effect of this PR on open source code:

steam.py (https://github.com/Gobot1234/steam.py)
- steam/profile.py:450: error: Definition of "__replace__" in base class "ProfileInfo" is incompatible with definition in base class "EquippedProfileItems"  [misc]
- steam/profile.py:461: error: Definition of "__replace__" in base class "ProfileInfo" is incompatible with definition in base class "EquippedProfileItems"  [misc]

dd-trace-py (https://github.com/DataDog/dd-trace-py)
- ddtrace/debugging/_signal/model.py:92: error: Subclass of "Probe" and "TimingMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_signal/model.py:93: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_signal/model.py:96: error: Cannot determine type of "_timing"  [has-type]
- ddtrace/debugging/_signal/model.py:101: error: Subclass of "Probe" and "ProbeConditionMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_signal/model.py:105: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_signal/model.py:127: error: Subclass of "Probe" and "RateLimitMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_signal/model.py:131: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_signal/model.py:182: error: Cannot determine type of "_timing"  [has-type]
- ddtrace/debugging/_signal/model.py:215: error: Cannot determine type of "_timing"  [has-type]
- ddtrace/debugging/_signal/log.py:41: error: Subclass of "Probe" and "LineLocationMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_signal/log.py:42: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_signal/log.py:46: error: Subclass of "Probe" and "FunctionLocationMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_signal/log.py:47: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_signal/log.py:54: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_signal/snapshot.py:204: error: Need type annotation for "captures" (hint: "captures: dict[<type>, <type>] = ...")  [var-annotated]
- ddtrace/debugging/_signal/snapshot.py:205: error: Subclass of "Probe" and "LogProbeMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_signal/snapshot.py:205: error: Right operand of "and" is never evaluated  [unreachable]
- ddtrace/debugging/_signal/snapshot.py:206: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_probe/registry.py:47: error: Subclass of "Probe" and "ProbeLocationMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_probe/registry.py:48: error: Statement is unreachable  [unreachable]
+ ddtrace/debugging/_debugger.py:536: error: Redundant cast to "FunctionType"  [redundant-cast]
- ddtrace/debugging/_debugger.py:374: error: Subclass of "Probe" and "LineLocationMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_debugger.py:376: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_debugger.py:500: error: Subclass of "Probe" and "FunctionLocationMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_debugger.py:503: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_debugger.py:619: error: Subclass of "Probe" and "LineLocationMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_debugger.py:620: error: Statement is unreachable  [unreachable]
- ddtrace/debugging/_debugger.py:621: error: Subclass of "Probe" and "FunctionLocationMixin" cannot exist: would have incompatible method signatures  [unreachable]
- ddtrace/debugging/_debugger.py:622: error: Statement is unreachable  [unreachable]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dataclasses fail to narrow with issubclass() in Python >= 3.13

1 participant