From 29e9c43d348ca24f1eefd21512fd1392dac7107e Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Fri, 26 Jun 2026 17:00:48 +0500 Subject: [PATCH 1/3] docs: clarify get_method_signature_hook fullname uses call-site class --- mypy/plugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mypy/plugin.py b/mypy/plugin.py index 383b07af87c0d..a44ad57f61821 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -647,7 +647,7 @@ def get_method_signature_hook( may infer a better type for the method. The hook is also called for special Python dunder methods except __init__ and __new__ (use get_function_hook to customize class instantiation). This function is called with the method full name using - the class where it was _defined_. For example, in this code: + the class of the object on which the method is called. For example, in this code: from lib import Special @@ -663,7 +663,7 @@ class Derived(Base): x: Special y = x[0] - this method is called with '__main__.Base.method', and then with + this method is called with '__main__.Derived.method', and then with 'lib.Special.__getitem__'. """ return None @@ -672,7 +672,7 @@ def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | No """Adjust return type of a method call. This is the same as get_function_hook(), but is called with the - method full name (again, using the class where the method is defined). + method full name (using the class of the object on which the method is called). """ return None From b4aa788aedde51ecfd5a5bc0fc800b5d05d2dd1d Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Sun, 28 Jun 2026 23:41:24 +0500 Subject: [PATCH 2/3] Fix method plugin hooks to use defining class fullname method_fullname() now resolves the class where a method was defined via get_containing_type_info(), matching the documented plugin hook semantics (Base.method rather than Derived.method for inherited calls). Fixes #19181 --- mypy/checkexpr.py | 11 +++++--- mypy/plugin.py | 6 ++--- test-data/unit/check-custom-plugin.test | 16 ++++++++++++ test-data/unit/plugins/arg_names.py | 4 +-- .../plugins/method_hook_defining_class.py | 25 +++++++++++++++++++ 5 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 test-data/unit/plugins/method_hook_defining_class.py diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 44855f49afaf9..5b9c420c0600a 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -679,8 +679,8 @@ def check_str_format_call(self, e: CallExpr) -> None: self.strfrm_checker.check_str_format_call(e, format_value) def method_fullname(self, object_type: Type, method_name: str) -> str | None: - """Convert a method name to a fully qualified name, based on the type of the object that - it is invoked on. Return `None` if the name of `object_type` cannot be determined. + """Convert a method name to a fully qualified name, based on the class where the + method was defined. Return `None` if the name of `object_type` cannot be determined. """ object_type = get_proper_type(object_type) @@ -694,12 +694,15 @@ def method_fullname(self, object_type: Type, method_name: str) -> str | None: type_name = None if isinstance(object_type, Instance): - type_name = object_type.type.fullname + info = object_type.type.get_containing_type_info(method_name) + type_name = info.fullname if info is not None else object_type.type.fullname elif isinstance(object_type, (TypedDictType, LiteralType)): info = object_type.fallback.type.get_containing_type_info(method_name) type_name = info.fullname if info is not None else None elif isinstance(object_type, TupleType): - type_name = tuple_fallback(object_type).type.fullname + fallback = tuple_fallback(object_type) + info = fallback.type.get_containing_type_info(method_name) + type_name = info.fullname if info is not None else fallback.type.fullname if type_name: return f"{type_name}.{method_name}" diff --git a/mypy/plugin.py b/mypy/plugin.py index a44ad57f61821..383b07af87c0d 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -647,7 +647,7 @@ def get_method_signature_hook( may infer a better type for the method. The hook is also called for special Python dunder methods except __init__ and __new__ (use get_function_hook to customize class instantiation). This function is called with the method full name using - the class of the object on which the method is called. For example, in this code: + the class where it was _defined_. For example, in this code: from lib import Special @@ -663,7 +663,7 @@ class Derived(Base): x: Special y = x[0] - this method is called with '__main__.Derived.method', and then with + this method is called with '__main__.Base.method', and then with 'lib.Special.__getitem__'. """ return None @@ -672,7 +672,7 @@ def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | No """Adjust return type of a method call. This is the same as get_function_hook(), but is called with the - method full name (using the class of the object on which the method is called). + method full name (again, using the class where the method is defined). """ return None diff --git a/test-data/unit/check-custom-plugin.test b/test-data/unit/check-custom-plugin.test index dd1de1265b599..50956d2d35d68 100644 --- a/test-data/unit/check-custom-plugin.test +++ b/test-data/unit/check-custom-plugin.test @@ -602,6 +602,22 @@ plugins=/test-data/unit/plugins/fully_qualified_test_hook.py [builtins fixtures/classmethod.pyi] [typing fixtures/typing-typeddict.pyi] +[case testMethodSignatureHookUsesDefiningClass] +# flags: --config-file tmp/mypy.ini +from typing import Any + +class Base: + def method(self, arg: Any) -> Any: ... + +class Derived(Base): ... + +var: Derived +reveal_type(var.method(42)) # N: Revealed type is "builtins.int" + +[file mypy.ini] +\[mypy] +plugins=/test-data/unit/plugins/method_hook_defining_class.py + [case testDynamicClassPlugin] # flags: --config-file tmp/mypy.ini from mod import declarative_base, Column, Instr diff --git a/test-data/unit/plugins/arg_names.py b/test-data/unit/plugins/arg_names.py index 981c1a2eb12db..9a423038b551f 100644 --- a/test-data/unit/plugins/arg_names.py +++ b/test-data/unit/plugins/arg_names.py @@ -26,8 +26,8 @@ def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | No "mod.Class.mystaticmethod", "mod.ClassUnfilled.method", "mod.ClassStarExpr.method", - "mod.ClassChild.method", - "mod.ClassChild.myclassmethod", + "mod.Base.method", + "mod.Base.myclassmethod", }: return extract_classname_and_set_as_return_type_method return None diff --git a/test-data/unit/plugins/method_hook_defining_class.py b/test-data/unit/plugins/method_hook_defining_class.py new file mode 100644 index 0000000000000..b6c81e5dffe89 --- /dev/null +++ b/test-data/unit/plugins/method_hook_defining_class.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Callable + +from mypy.plugin import MethodSigContext, Plugin +from mypy.types import CallableType + + +class DefiningClassPlugin(Plugin): + def get_method_signature_hook( + self, fullname: str + ) -> Callable[[MethodSigContext], CallableType] | None: + if fullname == "__main__.Base.method": + return defining_class_hook + return None + + +def defining_class_hook(ctx: MethodSigContext) -> CallableType: + return ctx.default_signature.copy_modified( + ret_type=ctx.api.named_generic_type("builtins.int", []) + ) + + +def plugin(version: str) -> type[DefiningClassPlugin]: + return DefiningClassPlugin From 6e4654d0b96bfb3250444978a14b273b0b930fa4 Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Sun, 28 Jun 2026 23:59:31 +0500 Subject: [PATCH 3/3] Add compatibility flag for method hook fullname behavior Keep call-site class names as the default for plugin method hooks, and add an opt-in flag to use defining-class names so plugins can migrate gradually. Fixes #19181 --- mypy/checkexpr.py | 21 +++++++++++++++------ mypy/main.py | 6 ++++++ mypy/options.py | 5 +++++ mypy/plugin.py | 9 ++++++--- test-data/unit/check-custom-plugin.test | 1 + test-data/unit/plugins/arg_names.py | 4 ++-- 6 files changed, 35 insertions(+), 11 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 5b9c420c0600a..7eb9ff178a7dc 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -679,8 +679,11 @@ def check_str_format_call(self, e: CallExpr) -> None: self.strfrm_checker.check_str_format_call(e, format_value) def method_fullname(self, object_type: Type, method_name: str) -> str | None: - """Convert a method name to a fully qualified name, based on the class where the - method was defined. Return `None` if the name of `object_type` cannot be determined. + """Convert a method name to a fully qualified name. + + By default this uses the class of the object where the method is called. + If the use_method_hook_defining_class option is enabled, this uses the class + where the method was defined. """ object_type = get_proper_type(object_type) @@ -694,15 +697,21 @@ def method_fullname(self, object_type: Type, method_name: str) -> str | None: type_name = None if isinstance(object_type, Instance): - info = object_type.type.get_containing_type_info(method_name) - type_name = info.fullname if info is not None else object_type.type.fullname + if self.chk.options.use_method_hook_defining_class: + info = object_type.type.get_containing_type_info(method_name) + type_name = info.fullname if info is not None else object_type.type.fullname + else: + type_name = object_type.type.fullname elif isinstance(object_type, (TypedDictType, LiteralType)): info = object_type.fallback.type.get_containing_type_info(method_name) type_name = info.fullname if info is not None else None elif isinstance(object_type, TupleType): fallback = tuple_fallback(object_type) - info = fallback.type.get_containing_type_info(method_name) - type_name = info.fullname if info is not None else fallback.type.fullname + if self.chk.options.use_method_hook_defining_class: + info = fallback.type.get_containing_type_info(method_name) + type_name = info.fullname if info is not None else fallback.type.fullname + else: + type_name = fallback.type.fullname if type_name: return f"{type_name}.{method_name}" diff --git a/mypy/main.py b/mypy/main.py index 0cf624a3a5d7b..725d35e5032a1 100644 --- a/mypy/main.py +++ b/mypy/main.py @@ -1177,6 +1177,12 @@ def add_invertible_flag( ) # This undocumented feature exports limited line-level dependency information. internals_group.add_argument("--export-ref-info", action="store_true", help=argparse.SUPPRESS) + add_invertible_flag( + "--use-method-hook-defining-class", + default=False, + help=argparse.SUPPRESS, + group=internals_group, + ) # Experimental parallel type-checking support. internals_group.add_argument( diff --git a/mypy/options.py b/mypy/options.py index 2f03cd3eab5b7..1de68a55d087e 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -78,6 +78,7 @@ class BuildType: "strict_bytes", "fixed_format_cache", "untyped_calls_exclude", + "use_method_hook_defining_class", "enable_incomplete_feature", "install_types", } @@ -340,6 +341,10 @@ def __init__(self) -> None: # Paths of user plugins self.plugins: list[str] = [] + # Temporary opt-in compatibility flag for plugin method hook fullnames. + # If True, inherited calls use the defining class (Base.method). If False, + # they use the call-site class (Derived.method). + self.use_method_hook_defining_class = False # Per-module options (raw) self.per_module_options: dict[str, dict[str, object]] = {} diff --git a/mypy/plugin.py b/mypy/plugin.py index 383b07af87c0d..2690117c06496 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -647,7 +647,8 @@ def get_method_signature_hook( may infer a better type for the method. The hook is also called for special Python dunder methods except __init__ and __new__ (use get_function_hook to customize class instantiation). This function is called with the method full name using - the class where it was _defined_. For example, in this code: + the class of the object on which the method is called, unless + use_method_hook_defining_class is enabled. For example, in this code: from lib import Special @@ -663,7 +664,8 @@ class Derived(Base): x: Special y = x[0] - this method is called with '__main__.Base.method', and then with + this method is called with '__main__.Derived.method' (or '__main__.Base.method' + with use_method_hook_defining_class), and then with 'lib.Special.__getitem__'. """ return None @@ -672,7 +674,8 @@ def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | No """Adjust return type of a method call. This is the same as get_function_hook(), but is called with the - method full name (again, using the class where the method is defined). + method full name (with the same class selection behavior as + get_method_signature_hook()). """ return None diff --git a/test-data/unit/check-custom-plugin.test b/test-data/unit/check-custom-plugin.test index 50956d2d35d68..eade39341a4b7 100644 --- a/test-data/unit/check-custom-plugin.test +++ b/test-data/unit/check-custom-plugin.test @@ -617,6 +617,7 @@ reveal_type(var.method(42)) # N: Revealed type is "builtins.int" [file mypy.ini] \[mypy] plugins=/test-data/unit/plugins/method_hook_defining_class.py +use_method_hook_defining_class = True [case testDynamicClassPlugin] # flags: --config-file tmp/mypy.ini diff --git a/test-data/unit/plugins/arg_names.py b/test-data/unit/plugins/arg_names.py index 9a423038b551f..981c1a2eb12db 100644 --- a/test-data/unit/plugins/arg_names.py +++ b/test-data/unit/plugins/arg_names.py @@ -26,8 +26,8 @@ def get_method_hook(self, fullname: str) -> Callable[[MethodContext], Type] | No "mod.Class.mystaticmethod", "mod.ClassUnfilled.method", "mod.ClassStarExpr.method", - "mod.Base.method", - "mod.Base.myclassmethod", + "mod.ClassChild.method", + "mod.ClassChild.myclassmethod", }: return extract_classname_and_set_as_return_type_method return None