From 94e9a09ecfb585fe4fb2be747211b77a5c82c98f Mon Sep 17 00:00:00 2001 From: arose26 Date: Mon, 17 Aug 2026 14:30:55 -0400 Subject: [PATCH] Fix stubtest false positive for an alias of a classmethod A stub assignment such as d = c refers to the classmethod object and keeps its cls argument, while reading the same name off the class at runtime yields a method already bound to it. --- mypy/stubtest.py | 37 +++++++++++++++++++++++++++++++++---- mypy/test/teststubtest.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/mypy/stubtest.py b/mypy/stubtest.py index 4a38939a03907..ecf55511cb685 100644 --- a/mypy/stubtest.py +++ b/mypy/stubtest.py @@ -1313,6 +1313,34 @@ def verify_missing( yield Error(object_path, "is not present in stub", stub, runtime) +def _unbind_classmethod_alias( + stub_type: mypy.types.Type | None, runtime: MaybeMissing[Any] +) -> mypy.types.Type | None: + """Drop the implicit first argument of a stub alias that points at a classmethod. + + An assignment such as ``d = c`` in a stub refers to the classmethod object, so its + type keeps the ``cls`` argument, whereas reading the same name off the class at + runtime gives a method already bound to it. + """ + if stub_type is None: + return None + if not (inspect.ismethod(runtime) and isinstance(runtime.__self__, type)): + return stub_type + proper_type = mypy.types.get_proper_type(stub_type) + if not isinstance(proper_type, mypy.types.CallableType) or not proper_type.arg_types: + return stub_type + if not ( + isinstance(mypy.types.get_proper_type(proper_type.arg_types[0]), mypy.types.TypeType) + or proper_type.arg_names[0] in ("cls", "mcls", "metacls") + ): + return stub_type + return proper_type.copy_modified( + arg_types=proper_type.arg_types[1:], + arg_kinds=proper_type.arg_kinds[1:], + arg_names=proper_type.arg_names[1:], + ) + + @verify.register(nodes.Var) def verify_var( stub: nodes.Var, runtime: MaybeMissing[Any], object_path: list[str] @@ -1331,23 +1359,24 @@ def verify_var( yield Error(object_path, "is read-only at runtime but not in the stub", stub, runtime) runtime_type = get_mypy_type_of_runtime_value(runtime, type_context=stub.type) + stub_type = _unbind_classmethod_alias(stub.type, runtime) note = "" if ( runtime_type is not None - and stub.type is not None - and not is_subtype_helper(runtime_type, stub.type) + and stub_type is not None + and not is_subtype_helper(runtime_type, stub_type) ): should_error = True # Avoid errors when defining enums, since runtime_type is the enum itself, but we'd # annotate it with the type of runtime.value if isinstance(runtime, enum.Enum): runtime_type = get_mypy_type_of_runtime_value(runtime.value) - if runtime_type is not None and is_subtype_helper(runtime_type, stub.type): + if runtime_type is not None and is_subtype_helper(runtime_type, stub_type): should_error = False # We always allow setting the stub value to Ellipsis (...), but use # _value_ type as a fallback if given. If a member is ... and _value_ # type is given, all runtime types should be assignable to _value_. - proper_type = mypy.types.get_proper_type(stub.type) + proper_type = mypy.types.get_proper_type(stub_type) if ( isinstance(proper_type, mypy.types.Instance) and proper_type.type.fullname in mypy.types.ELLIPSIS_TYPE_NAMES diff --git a/mypy/test/teststubtest.py b/mypy/test/teststubtest.py index 2db149ce65c97..626ab39817788 100644 --- a/mypy/test/teststubtest.py +++ b/mypy/test/teststubtest.py @@ -682,6 +682,43 @@ def __new__(cls, *args, **kwargs): pass """, error=None, ) + # An alias in the stub refers to the classmethod object, which keeps its "cls" + # argument, while reading the same name off the class at runtime gives a bound + # method. + yield Case( + stub=""" + class GoodAlias: + def m(self) -> None: ... + m_alias = m + @classmethod + def c(cls) -> None: ... + c_alias = c + """, + runtime=""" + class GoodAlias: + def m(self): pass + m_alias = m + @classmethod + def c(cls): pass + c_alias = c + """, + error=None, + ) + yield Case( + stub=""" + class BadAlias: + @classmethod + def c(cls) -> None: ... + c_alias: int + """, + runtime=""" + class BadAlias: + @classmethod + def c(cls): pass + c_alias = c + """, + error="BadAlias.c_alias", + ) @collect_cases def test_arg_mismatch(self) -> Iterator[Case]: