From d7d718429174db3a95acf672c7e9e99aa43d6f5b Mon Sep 17 00:00:00 2001 From: Ananthr16 <138586672+Ananthr16@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:02:30 -0700 Subject: [PATCH] Fix false positive for super() call with constrained type variable expand_typevars checks a method with a value-restricted type variable once per constraint value, substituting the value into a copy of the function body. super() calls inside that body were still resolved against the original, unsubstituted self type, since the checker scope always holds the original function, not the substituted copy. This made the expected argument type for an inherited member show the literal type variable instead of the value being checked for this pass, producing a spurious argument-type mismatch on every call. expand_typevars now also returns the substitution mapping for each copy, and check_func_def uses it to compute the correctly substituted self/cls type while checking that copy's body. super() consults this type instead of recomputing an unsubstituted one from the class. Fixes #17757. Fixes #14774. --- mypy/checker.py | 92 +++++++++++++++++++++--------- mypy/checkexpr.py | 11 +++- test-data/unit/check-generics.test | 52 ++++++++++++++++- 3 files changed, 124 insertions(+), 31 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 33ed5387554d8..505badc82f13a 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -540,6 +540,14 @@ def __init__( # example when type-checking class decorators. self.allow_abstract_call = False + # When checking one of the copies `expand_typevars` makes of a method for a + # class with a value-restricted type variable, this holds the self/cls type + # with that substitution already applied. `super()` (see `_super_arg_types` + # in checkexpr.py) consults this instead of recomputing an unsubstituted self + # type from the class's TypeInfo, so inherited members are checked against + # the same value substitution as the rest of the expanded copy. + self.expanding_self_type: ProperType | None = None + # Child checker objects for specific AST node types self._expr_checker = mypy.checkexpr.ExpressionChecker( self, self.msg, self.plugin, per_line_checking_time_ns @@ -1454,7 +1462,7 @@ def check_func_def( self.check_typevar_defaults(typ.variables) expanded = self.expand_typevars(defn, typ) original_typ = typ - for item, typ in expanded: + for item, typ, typevar_mapping in expanded: old_binder = self.binder self.binder = ConditionalTypeBinder(self.options) with self.binder.top_frame_context(): @@ -1541,29 +1549,33 @@ def check_func_def( n.node = v self.binder.assign_type(n, v.type, v.type) - with self.scope.push_function(defn): - # We suppress reachability warnings for empty generator functions - # (return; yield) which have a "yield" that's unreachable by definition - # since it's only there to promote the function into a generator function. - # - # We also suppress reachability warnings when we use TypeVars with value - # restrictions: we only want to report a warning if a certain statement is - # marked as being suppressed in *all* of the expansions, but we currently - # have no good way of doing this. - # - # TODO: Find a way of working around this limitation - if _is_empty_generator_function(item) or len(expanded) >= 2: - self.binder.suppress_unreachable_warnings() - # When checking a third-party library, we can skip function body, - # if during semantic analysis we found that there are no attributes - # defined via self here. - if ( - not self.can_skip_diagnostics - or self.options.preserve_asts - or not isinstance(defn, FuncDef) - or defn.def_or_infer_vars - ): - self.accept(item.body) + self.expanding_self_type = self.self_type_for_expansion(defn, typevar_mapping) + try: + with self.scope.push_function(defn): + # We suppress reachability warnings for empty generator functions + # (return; yield) which have a "yield" that's unreachable by definition + # since it's only there to promote the function into a generator function. + # + # We also suppress reachability warnings when we use TypeVars with value + # restrictions: we only want to report a warning if a certain statement is + # marked as being suppressed in *all* of the expansions, but we currently + # have no good way of doing this. + # + # TODO: Find a way of working around this limitation + if _is_empty_generator_function(item) or len(expanded) >= 2: + self.binder.suppress_unreachable_warnings() + # When checking a third-party library, we can skip function body, + # if during semantic analysis we found that there are no attributes + # defined via self here. + if ( + not self.can_skip_diagnostics + or self.options.preserve_asts + or not isinstance(defn, FuncDef) + or defn.def_or_infer_vars + ): + self.accept(item.body) + finally: + self.expanding_self_type = None unreachable = self.binder.is_unreachable() if new_frame is not None: self.binder.pop_frame(True, 0) @@ -2275,7 +2287,7 @@ def check_match_args(self, var: Var, typ: Type, context: Context) -> None: def expand_typevars( self, defn: FuncItem, typ: CallableType - ) -> list[tuple[FuncItem, CallableType]]: + ) -> list[tuple[FuncItem, CallableType, dict[TypeVarId, Type]]]: # TODO use generator subst: list[list[tuple[TypeVarId, Type]]] = [] tvars = list(typ.variables) or [] @@ -2289,13 +2301,37 @@ def expand_typevars( # value restricted type variables. (Except when running mypyc, # where we need one canonical version of the function.) if subst and not (self.options.mypyc or self.options.inspections): - result: list[tuple[FuncItem, CallableType]] = [] + result: list[tuple[FuncItem, CallableType, dict[TypeVarId, Type]]] = [] for substitutions in itertools.product(*subst): mapping = dict(substitutions) - result.append((expand_func(defn, mapping), expand_type(typ, mapping))) + result.append((expand_func(defn, mapping), expand_type(typ, mapping), mapping)) return result else: - return [(defn, typ)] + return [(defn, typ, {})] + + def self_type_for_expansion( + self, defn: FuncItem, mapping: dict[TypeVarId, Type] + ) -> ProperType | None: + """Compute self/cls's type for one of `expand_typevars`'s substituted copies. + + `expand_func` only rewrites types already present somewhere in `defn`'s + AST, so an implicit (unannotated) self/cls argument is left with no + type at all, and even an explicitly annotated one is read from `defn` + -- not the substituted copy -- by callers like `_super_arg_types` in + checkexpr.py, since `check_func_def` pushes `defn`, not the copy, onto + the checker scope. Returns `None` when there's no substitution to + apply (`mapping` empty) or no self/cls argument to compute a type for. + """ + if not mapping or not defn.info or not defn.has_self_or_cls_argument: + return None + if not defn.arguments: + return None + self_type: ProperType | None = get_proper_type(defn.arguments[0].variable.type) + if self_type is None: + self_type = fill_typevars(defn.info) + if defn.is_class or defn.name == "__new__": + self_type = TypeType.make_normalized(self_type) + return expand_type(self_type, mapping) def check_explicit_override_decorator( self, diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 1cd40d4749667..8ce0463d84d8f 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -5850,7 +5850,16 @@ def _super_arg_types(self, e: SuperExpr) -> Type | tuple[Type, Type]: method = self.chk.scope.current_function() assert method is not None if method.arguments: - instance_type: Type = method.arguments[0].variable.type or current_type + # If we're currently checking one of the copies `expand_typevars` + # makes for a class with a value-restricted type variable, use the + # already-substituted self type computed for this copy -- the + # scope always holds the original (unexpanded) function, so its + # own self argument's type (annotated or not) is never substituted. + instance_type: Type = ( + self.chk.expanding_self_type + or method.arguments[0].variable.type + or current_type + ) else: self.chk.fail(message_registry.SUPER_ENCLOSING_POSITIONAL_ARGS_REQUIRED, e) return AnyType(TypeOfAny.from_error) diff --git a/test-data/unit/check-generics.test b/test-data/unit/check-generics.test index b8f7a5699e199..b103fc50b06f7 100644 --- a/test-data/unit/check-generics.test +++ b/test-data/unit/check-generics.test @@ -2631,8 +2631,56 @@ class Bar(Foo[AnyStr]): def method1(self, s: AnyStr, t: AnyStr) -> None: super().method1('x', b'y') # Should be an error [out] -main:10: error: Argument 1 to "method1" of "Foo" has incompatible type "str"; expected "AnyStr" -main:10: error: Argument 2 to "method1" of "Foo" has incompatible type "bytes"; expected "AnyStr" +main:10: error: Argument 1 to "method1" of "Foo" has incompatible type "str"; expected "bytes" +main:10: error: Argument 2 to "method1" of "Foo" has incompatible type "bytes"; expected "str" + +[case testConstrainedGenericSuperNoFalsePositiveSameTypeVar] +# https://github.com/python/mypy/issues/14774 +from typing import Generic, TypeVar + +T = TypeVar("T", float, int) + +class C(Generic[T]): + def __init__(self, i: T) -> None: + self.i: T = i + +class B(C[T]): + def __init__(self, i: T) -> None: + super().__init__(i) +[out] + +[case testConstrainedGenericSuperNoFalsePositiveDistinctTypeVar] +# https://github.com/python/mypy/issues/17757 +from typing import Generic, TypeVar + +T = TypeVar("T") +N = TypeVar("N", int, float) + +class C(Generic[T]): + def __init__(self, c: T): + self.c = c + +class C2(C[N]): + def __init__(self, c: N): + super().__init__(c) +[out] + +[case testConstrainedGenericSuperClassmethodNoFalsePositive] +from typing import Generic, TypeVar + +N = TypeVar("N", int, float) + +class C(Generic[N]): + @classmethod + def make(cls, c: N) -> None: + pass + +class C2(C[N]): + @classmethod + def make(cls, c: N) -> None: + super().make(c) +[builtins fixtures/classmethod.pyi] +[out] [case testTypeVariableClashVar] from typing import Generic, TypeVar, Callable