From 4d6b6d1ca38854e01d4ec45084e46b7df2bbd7de Mon Sep 17 00:00:00 2001 From: Allan Lewis Date: Fri, 29 May 2026 17:22:32 +0100 Subject: [PATCH] Ignore plugin-generated members when inferring PEP 695 variance infer_variance walked every member of a class, including methods and attributes synthesized by plugins. Those synthesized members reuse the class's own type in positions that don't reflect how the user uses the type variable, which corrupted the inferred variance: - attrs generates ordering methods (__lt__/__le__/__gt__/__ge__) whose ``other`` parameter is typed as the class's own Self[T], plus an __attrs_attrs__ tuple of the invariant Attribute[T]. This made @attrs.define/@attrs.frozen generic classes invariant (and empty ones contravariant) even when T was used only covariantly. - On Python 3.13+ the dataclass plugin generates __replace__, whose keyword parameters reuse the field types and made otherwise covariant frozen dataclasses invariant. User-written declarations are never flagged plugin_generated, so skipping plugin-generated members during variance inference leaves real fields and methods in control while ignoring synthesized ones. This generalizes the existing __mypy-replace special case. Tests: - testPEP695InferVarianceWithAttrsFrozen (check-python312.test) - testPEP695InferVarianceInFrozenDataclass (check-python313.test) Relates to #17623. Assistant-Model: Claude Code --- mypy/subtypes.py | 12 ++++++++++ test-data/unit/check-python312.test | 36 +++++++++++++++++++++++++++++ test-data/unit/check-python313.test | 16 +++++++++++++ 3 files changed, 64 insertions(+) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 305cfa9de5f2..2d048ae2ee54 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -2274,6 +2274,18 @@ def infer_variance(info: TypeInfo, i: int) -> bool: if member in ("__init__", "__new__", "__mypy-replace"): continue + # Members synthesized by plugins must not influence variance + # inference. attrs, for example, generates ordering methods whose + # "other" parameter is typed as the class's own Self[T], plus an + # __attrs_attrs__ tuple of (invariant) Attribute[T]; dataclasses + # generate __replace__. These mention the type variable only because + # they are derived from the user's own declarations -- and those + # declarations are not plugin-generated, so they still drive the + # inferred variance. + sym = info.get(member) + if sym is not None and sym.plugin_generated: + continue + if isinstance(self_type, TupleType): self_type = mypy.typeops.tuple_fallback(self_type) flags = get_member_flags(member, self_type) diff --git a/test-data/unit/check-python312.test b/test-data/unit/check-python312.test index c4a232079746..2beefa82cc0d 100644 --- a/test-data/unit/check-python312.test +++ b/test-data/unit/check-python312.test @@ -248,6 +248,42 @@ inv2: Invariant[int] = Invariant[float]([1]) # E: Incompatible types in assignm [builtins fixtures/tuple.pyi] [typing fixtures/typing-full.pyi] +[case testPEP695InferVarianceWithAttrsFrozen] +import attrs + +# attrs synthesizes ordering dunders (__lt__/__le__/__gt__/__ge__) whose +# ``other`` parameter is typed as the class's own ``Self[T]``. Those methods +# are plugin-generated and must not drag T into a contravariant position +# during PEP 695 variance inference. A frozen class using T only covariantly +# should be inferred covariant. +@attrs.frozen +class Covariant[T]: + x: T + def get(self) -> T: + return self.x + +cov1: Covariant[object] = Covariant[int](1) +cov2: Covariant[int] = Covariant[object](1) # E: Incompatible types in assignment (expression has type "Covariant[object]", variable has type "Covariant[int]") + +# A mutable attribute still makes the class invariant. +@attrs.define +class Invariant[T]: + x: T + +inv1: Invariant[object] = Invariant[int](1) # E: Incompatible types in assignment (expression has type "Invariant[int]", variable has type "Invariant[object]") +inv2: Invariant[int] = Invariant[object](1) # E: Incompatible types in assignment (expression has type "Invariant[object]", variable has type "Invariant[int]") + +# A user-written method with T in a parameter must still be honored: only +# plugin-generated methods are skipped, so this class stays contravariant. +@attrs.frozen +class Contravariant[T]: + def feed(self, x: T) -> None: ... + +con1: Contravariant[int] = Contravariant[object]() +con2: Contravariant[object] = Contravariant[int]() # E: Incompatible types in assignment (expression has type "Contravariant[int]", variable has type "Contravariant[object]") +[builtins fixtures/plugin_attrs.pyi] +[typing fixtures/typing-full.pyi] + [case testPEP695InferVarianceCalculateOnDemand] class Covariant[T]: def __init__(self) -> None: diff --git a/test-data/unit/check-python313.test b/test-data/unit/check-python313.test index d52cb575bc91..9b08b1eed6eb 100644 --- a/test-data/unit/check-python313.test +++ b/test-data/unit/check-python313.test @@ -480,3 +480,19 @@ reveal_type(x) # N: Revealed type is "builtins.list[tuple[()]]" reveal_type(y) # N: Revealed type is "builtins.list[tuple[()]]" reveal_type(z) # N: Revealed type is "builtins.list[tuple[()]]" [builtins fixtures/tuple.pyi] + +[case testPEP695InferVarianceInFrozenDataclass] +# On Python 3.13+ the dataclass plugin synthesizes a __replace__ method whose +# keyword parameters reuse the field types. Being plugin-generated, it must not +# drag the type variable into a contravariant position and make an otherwise +# covariant frozen dataclass invariant. +from dataclasses import dataclass + +@dataclass(frozen=True) +class Covariant[T]: + x: T + +cov1: Covariant[float] = Covariant[int](1) +cov2: Covariant[int] = Covariant[float](1) # E: Incompatible types in assignment (expression has type "Covariant[float]", variable has type "Covariant[int]") +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi]