Repository: python/mypy

Issue:
Plugin hooks are not called or receive incorrect names for some dunder methods
(Taken from #4869)

When certain dunder methods are called implicitly through language syntax, the plugin function/method hooks are called incorrectly or not at all. Specifically, the following cases cause problems:

* [ ] For some dunder methods (`__setitem__`, `__iter__`, `__next__`), `get_function_hook` is incorrectly called instead of `get_method_hook`, and the passed in method name is not fully qualified
* [ ] `get_method_signature_hook` is not called for some dunder methods (`__getitem__`, `__setitem__`, `__iter__`, `__next__`)
* [x] When a `Type[...]` expression is called (that is not a simple class name), the class name passed to `get_function_hook` is not fully qualified (this is not strictly related to dunder methods, it might be a separate problem, I'm not sure) (fixed in #5379)

To test this, I've added `print`s to `mypy.plugin.DefaultPlugin`'s methods `get_function_hook`, `get_method_signature_hook`, `get_method_hook`, and `get_attribute_hook` to show when each method is called with what argument. Then I ran `mypy` on the following code:

```python
# fixed
# * class name is not fully qualified ('list') (fixed in #5379)
seq = (list,)[0](("hello",))

# works
# * class name is fully qualified ('builtins.list')
seq = list(("hi",))

# buggy
# * name is not fully qualified ('__iter__ of list')
# * get_function_hook is called instead of get_method_hook
# * get_method_signature_hook is not called
for obj in seq:
    print(obj)

# buggy
# * get_method_signature_hook is not called
print(seq[0])

# buggy
# * name is not fully qualified ('__setitem__ of list')
# * get_function_hook is called instead of get_method_hook
# * get_method_signature_hook is not called
seq[0] = "bye"

# works
# * name is fully qualified ('builtins.list.clear')
# * get_method_signature_hook is called
seq.clear()
```

Output:

```
get_function_hook('builtins.list')
get_function_hook('builtins.list')
get_function_hook('__iter__ of list')
get_attribute_hook('typing.Iterator.__next__')
get_function_hook('__next__ of Iterator')
get_function_hook('builtins.print')
get_method_hook('builtins.list.__getitem__')
get_function_hook('builtins.print')
get_function_hook('__setitem__ of list')
get_method_signature_hook('builtins.list.clear')
get_method_hook('builtins.list.clear')
```


Discussion:
Setting priority to high because this may block some plugins work.
I've run into the one for Type[] as well.


Resolving patch:
diff --git a/mypy/checker.py b/mypy/checker.py
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -2566,9 +2566,9 @@ def check_indexed_assignment(self, lvalue: IndexExpr,
             method_type = self.expr_checker.analyze_external_member_access(
                 '__setitem__', basetype, context)
         lvalue.method_type = method_type
-        self.expr_checker.check_call(method_type, [lvalue.index, rvalue],
-                                     [nodes.ARG_POS, nodes.ARG_POS],
-                                     context)
+        self.expr_checker.check_method_call(
+            '__setitem__', basetype, method_type, [lvalue.index, rvalue],
+            [nodes.ARG_POS, nodes.ARG_POS], context)
 
     def try_infer_partial_type_from_indexed_assignment(
             self, lvalue: IndexExpr, rvalue: Expression) -> None:
@@ -2939,10 +2939,8 @@ def analyze_async_iterable_item_type(self, expr: Expression) -> Tuple[Type, Type
         """Analyse async iterable expression and return iterator and iterator item types."""
         echk = self.expr_checker
         iterable = echk.accept(expr)
-        method = echk.analyze_external_member_access('__aiter__', iterable, expr)
-        iterator = echk.check_call(method, [], [], expr)[0]
-        method = echk.analyze_external_member_access('__anext__', iterator, expr)
-        awaitable = echk.check_call(method, [], [], expr)[0]
+        iterator = echk.check_method_call_by_name('__aiter__', iterable, [], [], expr)[0]
+        awaitable = echk.check_method_call_by_name('__anext__', iterator, [], [], expr)[0]
         item_type = echk.check_awaitable_expr(awaitable, expr,
                                               messages.INCOMPATIBLE_TYPES_IN_ASYNC_FOR)
         return iterator, item_type
@@ -2951,8 +2949,7 @@ def analyze_iterable_item_type(self, expr: Expression) -> Tuple[Type, Type]:
         """Analyse iterable expression and return iterator and iterator item types."""
         echk = self.expr_checker
         iterable = echk.accept(expr)
-        method = echk.analyze_external_member_access('__iter__', iterable, expr)
-        iterator = echk.check_call(method, [], [], expr)[0]
+        iterator = echk.check_method_call_by_name('__iter__', iterable, [], [], expr)[0]
 
         if isinstance(iterable, TupleType):
             joined = UninhabitedType()  # type: Type
@@ -2965,9 +2962,7 @@ def analyze_iterable_item_type(self, expr: Expression) -> Tuple[Type, Type]:
                 nextmethod = '__next__'
             else:
                 nextmethod = 'next'
-            method = echk.analyze_external_member_access(nextmethod, iterator,
-                                                         expr)
-            return iterator, echk.check_call(method, [], [], expr)[0]
+            return iterator, echk.check_method_call_by_name(nextmethod, iterator, [], [], expr)[0]
 
     def analyze_index_variables(self, index: Expression, item_type: Type,
                                 infer_lvalue_type: bool, context: Context) -> None:
@@ -3067,15 +3062,14 @@ def check_async_with_item(self, expr: Expression, target: Optional[Expression],
                               infer_lvalue_type: bool) -> None:
         echk = self.expr_checker
         ctx = echk.accept(expr)
-        enter = echk.analyze_external_member_access('__aenter__', ctx, expr)
-        obj = echk.check_call(enter, [], [], expr)[0]
+        obj = echk.check_method_call_by_name('__aenter__', ctx, [], [], expr)[0]
         obj = echk.check_awaitable_expr(
             obj, expr, messages.INCOMPATIBLE_TYPES_IN_ASYNC_WITH_AENTER)
         if target:
             self.check_assignment(target, self.temp_node(obj, expr), infer_lvalue_type)
-        exit = echk.analyze_external_member_access('__aexit__', ctx, expr)
         arg = self.temp_node(AnyType(TypeOfAny.special_form), expr)
-        res = echk.check_call(exit, [arg] * 3, [nodes.ARG_POS] * 3, expr)[0]
+        res = echk.check_method_call_by_name(
+            '__aexit__', ctx, [arg] * 3, [nodes.ARG_POS] * 3, expr)[0]
         echk.check_awaitable_expr(
             res, expr, messages.INCOMPATIBLE_TYPES_IN_ASYNC_WITH_AEXIT)
 
@@ -3083,13 +3077,11 @@ def check_with_item(self, expr: Expression, target: Optional[Expression],
                         infer_lvalue_type: bool) -> None:
         echk = self.expr_checker
         ctx = echk.accept(expr)
-        enter = echk.analyze_external_member_access('__enter__', ctx, expr)
-        obj = echk.check_call(enter, [], [], expr)[0]
+        obj = echk.check_method_call_by_name('__enter__', ctx, [], [], expr)[0]
         if target:
             self.check_assignment(target, self.temp_node(obj, expr), infer_lvalue_type)
-        exit = echk.analyze_external_member_access('__exit__', ctx, expr)
         arg = self.temp_node(AnyType(TypeOfAny.special_form), expr)
-        echk.check_call(exit, [arg] * 3, [nodes.ARG_POS] * 3, expr)
+        echk.check_method_call_by_name('__exit__', ctx, [arg] * 3, [nodes.ARG_POS] * 3, expr)
 
     def visit_print_stmt(self, s: PrintStmt) -> None:
         for arg in s.args:
diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py
--- a/mypy/checkexpr.py
+++ b/mypy/checkexpr.py
@@ -285,25 +285,14 @@ def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) ->
                 fullname = e.callee.node.target.type.fullname()
             if (fullname is None
                     and isinstance(e.callee, MemberExpr)
+                    and e.callee.expr in self.chk.type_map
                     and isinstance(callee_type, FunctionLike)):
                 # For method calls we include the defining class for the method
                 # in the full name (example: 'typing.Mapping.get').
-                callee_expr_type = self.chk.type_map.get(e.callee.expr)
-                info = None
-                # TODO: Support fallbacks of other kinds of types as well?
-                if isinstance(callee_expr_type, Instance):
-                    info = callee_expr_type.type
-                elif isinstance(callee_expr_type, TypedDictType):
-                    info = callee_expr_type.fallback.type.get_containing_type_info(e.callee.name)
-                if info:
-                    fullname = '{}.{}'.format(info.fullname(), e.callee.name)
+                callee_expr_type = self.chk.type_map[e.callee.expr]
+                fullname = self.method_fullname(callee_expr_type, e.callee.name)
+                if fullname is not None:
                     object_type = callee_expr_type
-                    # Apply plugin signature hook that may generate a better signature.
-                    signature_hook = self.plugin.get_method_signature_hook(fullname)
-                    if signature_hook:
-                        assert object_type is not None
-                        callee_type = self.apply_method_signature_hook(
-                            e, callee_type, object_type, signature_hook)
         ret_type = self.check_call_expr_with_callee_type(callee_type, e, fullname, object_type)
         if isinstance(e.callee, RefExpr) and len(e.args) == 2:
             if e.callee.fullname in ('builtins.isinstance', 'builtins.issubclass'):
@@ -321,6 +310,30 @@ def visit_call_expr_inner(self, e: CallExpr, allow_none_return: bool = False) ->
             return AnyType(TypeOfAny.from_error)
         return ret_type
 
+    def method_fullname(self, object_type: Type, method_name: str) -> Optional[str]:
+        """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.
+        """
+        if isinstance(object_type, CallableType) and object_type.is_type_obj():
+            # For class method calls, object_type is a callable representing the class object.
+            # We "unwrap" it to a regular type, as the class/instance method difference doesn't
+            # affect the fully qualified name.
+            object_type = object_type.ret_type
+
+        type_name = None
+        if isinstance(object_type, Instance):
+            type_name = object_type.type.fullname()
+        elif isinstance(object_type, TypedDictType):
+            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 = object_type.fallback.type.fullname()
+
+        if type_name is not None:
+            return '{}.{}'.format(type_name, method_name)
+        else:
+            return None
+
     def always_returns_none(self, node: Expression) -> bool:
         """Check if `node` refers to something explicitly annotated as only returning None."""
         if isinstance(node, RefExpr):
@@ -544,13 +557,12 @@ def apply_function_plugin(self,
                               context, self.chk))
 
     def apply_method_signature_hook(
-            self, e: CallExpr, callee: FunctionLike, object_type: Type,
+            self, callee: FunctionLike, args: List[Expression],
+            arg_kinds: List[int], context: Context,
+            arg_names: Optional[Sequence[Optional[str]]], object_type: Type,
             signature_hook: Callable[[MethodSigContext], CallableType]) -> FunctionLike:
         """Apply a plugin hook that may infer a more precise signature for a method."""
         if isinstance(callee, CallableType):
-            arg_kinds = e.arg_kinds
-            arg_names = e.arg_names
-            args = e.args
             num_formals = len(callee.arg_kinds)
             formal_to_actual = map_actuals_to_formals(
                 arg_kinds, arg_names,
@@ -561,16 +573,46 @@ def apply_method_signature_hook(
                 for actual in actuals:
                     formal_arg_exprs[formal].append(args[actual])
             return signature_hook(
-                MethodSigContext(object_type, formal_arg_exprs, callee, e, self.chk))
+                MethodSigContext(object_type, formal_arg_exprs, callee, context, self.chk))
         else:
             assert isinstance(callee, Overloaded)
             items = []
             for item in callee.items():
-                adjusted = self.apply_method_signature_hook(e, item, object_type, signature_hook)
+                adjusted = self.apply_method_signature_hook(
+                    item, args, arg_kinds, context, arg_names, object_type, signature_hook)
                 assert isinstance(adjusted, CallableType)
                 items.append(adjusted)
             return Overloaded(items)
 
+    def transform_callee_type(
+            self, callable_name: Optional[str], callee: Type, args: List[Expression],
+            arg_kinds: List[int], context: Context,
+            arg_names: Optional[Sequence[Optional[str]]] = None,
+            object_type: Optional[Type] = None) -> Type:
+        """Attempt to determine a more accurate signature for a method call.
+
+        This is done by looking up and applying a method signature hook (if one exists for the
+        given method name).
+
+        If no matching method signature hook is found, callee is returned unmodified. The same
+        happens if the arguments refer to a non-method callable (this is allowed so that the code
+        calling transform_callee_type needs to perform fewer boilerplate checks).
+
+        Note: this method is *not* called automatically as part of check_call, because in some
+        cases check_call is called multiple times while checking a single call (for example when
+        dealing with overloads). Instead, this method needs to be called explicitly
+        (if appropriate) before the signature is passed to check_call.
+        """
+        if (callable_name is not None
+                and object_type is not None
+                and isinstance(callee, FunctionLike)):
+            signature_hook = self.plugin.get_method_signature_hook(callable_name)
+            if signature_hook:
+                return self.apply_method_signature_hook(
+                    callee, args, arg_kinds, context, arg_names, object_type, signature_hook)
+
+        return callee
+
     def check_call_expr_with_callee_type(self,
                                          callee_type: Type,
                                          e: CallExpr,
@@ -581,6 +623,10 @@ def check_call_expr_with_callee_type(self,
         The given callee type overrides the type of the callee
         expression.
         """
+        # Try to refine the call signature using plugin hooks before checking the call.
+        callee_type = self.transform_callee_type(
+            callable_name, callee_type, e.args, e.arg_kinds, e, e.arg_names, object_type)
+
         return self.check_call(callee_type, e.args, e.arg_kinds, e,
                                e.arg_names, callable_node=e.callee,
                                callable_name=callable_name,
@@ -614,6 +660,7 @@ def check_call(self, callee: Type, args: List[Expression],
                 on which the method is being called
         """
         arg_messages = arg_messages or self.msg
+
         if isinstance(callee, CallableType):
             if callable_name is None and callee.name:
                 callable_name = callee.name
@@ -1750,8 +1797,8 @@ def visit_comparison_expr(self, e: ComparisonExpr) -> Type:
                 # are just to verify whether something is valid typing wise).
                 local_errors = self.msg.copy()
                 local_errors.disable_count = 0
-                sub_result, method_type = self.check_op_local_by_name('__contains__', right_type,
-                                                                      left, e, local_errors)
+                sub_result, method_type = self.check_method_call_by_name(
+                    '__contains__', right_type, [left], [ARG_POS], e, local_errors)
                 if isinstance(right_type, PartialType):
                     # We don't really know if this is an error or not, so just shut up.
                     pass
@@ -1802,40 +1849,45 @@ def get_operator_method(self, op: str) -> str:
         else:
             return nodes.op_methods[op]
 
-    def check_op_local_by_name(self,
-                               method: str,
-                               base_type: Type,
-                               arg: Expression,
-                               context: Context,
-                               local_errors: MessageBuilder) -> Tuple[Type, Type]:
-        """Type check a binary operation which maps to a method call.
+    def check_method_call_by_name(self,
+                                  method: str,
+                                  base_type: Type,
+                                  args: List[Expression],
+                                  arg_kinds: List[int],
+                                  context: Context,
+                                  local_errors: Optional[MessageBuilder] = None,
+                                  ) -> Tuple[Type, Type]:
+        """Type check a call to a named method on an object.
 
-        Return tuple (result type, inferred operator method type).
+        Return tuple (result type, inferred method type).
         """
+        local_errors = local_errors or self.msg
         method_type = analyze_member_access(method, base_type, context, False, False, True,
                                             self.named_type, self.not_ready_callback, local_errors,
                                             original_type=base_type, chk=self.chk)
-        return self.check_op_local(method, method_type, base_type, arg, context, local_errors)
+        return self.check_method_call(
+            method, base_type, method_type, args, arg_kinds, context, local_errors)
+
+    def check_method_call(self,
+                          method_name: str,
+                          base_type: Type,
+                          method_type: Type,
+                          args: List[Expression],
+                          arg_kinds: List[int],
+                          context: Context,
+                          local_errors: Optional[MessageBuilder] = None) -> Tuple[Type, Type]:
+        """Type check a call to a method with the given name and type on an object.
+
+        Return tuple (result type, inferred method type).
+        """
+        callable_name = self.method_fullname(base_type, method_name)
+        object_type = base_type if callable_name is not None else None
 
-    def check_op_local(self,
-                       method_name: str,
-                       method_type: Type,
-                       base_type: Type,
-                       arg: Expression,
-                       context: Context,
-                       local_errors: MessageBuilder) -> Tuple[Type, Type]:
-        """Type check a binary operation using the (assumed) type of the operator method.
+        # Try to refine the method signature using plugin hooks before checking the call.
+        method_type = self.transform_callee_type(
+            callable_name, method_type, args, arg_kinds, context, object_type=object_type)
 
-        Return tuple (result type, inferred operator method type).
-        """
-        callable_name = None
-        object_type = None
-        if isinstance(base_type, Instance):
-            # TODO: Find out in which class the method was defined originally?
-            # TODO: Support non-Instance types.
-            callable_name = '{}.{}'.format(base_type.type.fullname(), method_name)
-            object_type = base_type
-        return self.check_call(method_type, [arg], [nodes.ARG_POS],
+        return self.check_call(method_type, args, arg_kinds,
                                context, arg_messages=local_errors,
                                callable_name=callable_name, object_type=object_type)
 
@@ -1994,7 +2046,8 @@ def lookup_definer(typ: Instance, attr_name: str) -> Optional[str]:
         results = []
         for method, obj, arg in variants:
             local_errors = make_local_errors()
-            result = self.check_op_local(op_name, method, obj, arg, context, local_errors)
+            result = self.check_method_call(
+                op_name, obj, method, [arg], [ARG_POS], context, local_errors)
             if local_errors.is_errors():
                 errors.append(local_errors)
                 results.append(result)
@@ -2007,8 +2060,8 @@ def lookup_definer(typ: Instance, attr_name: str) -> Optional[str]:
 
         if not variants:
             local_errors = make_local_errors()
-            result = self.check_op_local_by_name(
-                op_name, left_type, right_expr, context, local_errors)
+            result = self.check_method_call_by_name(
+                op_name, left_type, [right_expr], [ARG_POS], context, local_errors)
 
             if local_errors.is_errors():
                 errors.append(local_errors)
@@ -2120,10 +2173,11 @@ def check_op(self, method: str, base_type: Type,
             inferred_final = self.combine_function_signatures(all_inferred)
             return results_final, inferred_final
         else:
-            return self.check_op_local_by_name(
+            return self.check_method_call_by_name(
                 method=method,
                 base_type=base_type,
-                arg=arg,
+                args=[arg],
+                arg_kinds=[ARG_POS],
                 context=context,
                 local_errors=self.msg,
             )
@@ -2215,8 +2269,7 @@ def visit_unary_expr(self, e: UnaryExpr) -> Type:
             result = self.bool_type()  # type: Type
         else:
             method = nodes.unary_op_methods[op]
-            method_type = self.analyze_external_member_access(method, operand_type, e)
-            result, method_type = self.check_call(method_type, [], [], e)
+            result, method_type = self.check_method_call_by_name(method, operand_type, [], [], e)
             e.method_type = method_type
         return result
 
@@ -2257,7 +2310,8 @@ def visit_index_expr_helper(self, e: IndexExpr) -> Type:
               and left_type.is_type_obj() and left_type.type_object().is_enum):
             return self.visit_enum_index_expr(left_type.type_object(), e.index, e)
         else:
-            result, method_type = self.check_op('__getitem__', left_type, e.index, e)
+            result, method_type = self.check_method_call_by_name(
+                '__getitem__', left_type, [e.index], [ARG_POS], e)
             e.method_type = method_type
             return result
 
@@ -2632,8 +2686,7 @@ def visit_dict_expr(self, e: DictExpr) -> Type:
                         variables=[ktdef, vtdef])
                     rv = self.check_call(constructor, [arg], [nodes.ARG_POS], arg)[0]
                 else:
-                    method = self.analyze_external_member_access('update', rv, arg)
-                    self.check_call(method, [arg], [nodes.ARG_POS], arg)
+                    self.check_method_call_by_name('update', rv, [arg], [nodes.ARG_POS], arg)
         assert rv is not None
         return rv
 
@@ -3128,8 +3181,7 @@ def check_awaitable_expr(self, t: Type, ctx: Context, msg: str) -> Type:
                                       msg, 'actual type', 'expected type'):
             return AnyType(TypeOfAny.special_form)
         else:
-            method = self.analyze_external_member_access('__await__', t, ctx)
-            generator = self.check_call(method, [], [], ctx)[0]
+            generator = self.check_method_call_by_name('__await__', t, [], [], ctx)[0]
             return self.chk.get_generator_return_type(generator, False)
 
     def visit_yield_from_expr(self, e: YieldFromExpr, allow_none_return: bool = False) -> Type:
@@ -3152,16 +3204,12 @@ def visit_yield_from_expr(self, e: YieldFromExpr, allow_none_return: bool = Fals
         elif self.chk.type_is_iterable(subexpr_type):
             if is_async_def(subexpr_type) and not has_coroutine_decorator(return_type):
                 self.chk.msg.yield_from_invalid_operand_type(subexpr_type, e)
-            iter_method_type = self.analyze_external_member_access(
-                '__iter__',
-                subexpr_type,
-                AnyType(TypeOfAny.special_form))
 
             any_type = AnyType(TypeOfAny.special_form)
             generic_generator_type = self.chk.named_generic_type('typing.Generator',
                                                                  [any_type, any_type, any_type])
-            iter_type, _ = self.check_call(iter_method_type, [], [],
-                                           context=generic_generator_type)
+            iter_type, _ = self.check_method_call_by_name(
+                '__iter__', subexpr_type, [], [], context=generic_generator_type)
         else:
             if not (is_async_def(subexpr_type) and has_coroutine_decorator(return_type)):
                 self.chk.msg.yield_from_invalid_operand_type(subexpr_type, e)
diff --git a/test-data/unit/plugins/method_sig_hook.py b/test-data/unit/plugins/method_sig_hook.py
new file mode 100644
--- /dev/null
+++ b/test-data/unit/plugins/method_sig_hook.py
@@ -0,0 +1,30 @@
+from mypy.plugin import CallableType, CheckerPluginInterface, MethodSigContext, Plugin
+from mypy.types import Instance, Type
+
+class MethodSigPlugin(Plugin):
+    def get_method_signature_hook(self, fullname):
+        # Ensure that all names are fully qualified
+        assert not fullname.endswith(' of Foo')
+
+        if fullname.startswith('__main__.Foo.'):
+            return my_hook
+
+        return None
+
+def _str_to_int(api: CheckerPluginInterface, typ: Type) -> Type:
+    if isinstance(typ, Instance):
+        if typ.type.fullname() == 'builtins.str':
+            return api.named_generic_type('builtins.int', [])
+        elif typ.args:
+            return typ.copy_modified(args=[_str_to_int(api, t) for t in typ.args])
+
+    return typ
+
+def my_hook(ctx: MethodSigContext) -> CallableType:
+    return ctx.default_signature.copy_modified(
+        arg_types=[_str_to_int(ctx.api, t) for t in ctx.default_signature.arg_types],
+        ret_type=_str_to_int(ctx.api, ctx.default_signature.ret_type),
+    )
+
+def plugin(version):
+    return MethodSigPlugin


Test patch:
diff --git a/test-data/unit/check-custom-plugin.test b/test-data/unit/check-custom-plugin.test
--- a/test-data/unit/check-custom-plugin.test
+++ b/test-data/unit/check-custom-plugin.test
@@ -229,3 +229,54 @@ def decorator2() -> Callable[..., Callable[..., int]]: pass
 [file mypy.ini]
 [[mypy]
 plugins=<ROOT>/test-data/unit/plugins/named_callable.py
+
+[case testMethodSignatureHook]
+# flags: --config-file tmp/mypy.ini
+from typing import Iterator
+
+class Foo:
+    # Test that method signature hooks are applied in various cases: explicit method calls, and
+    # implicit dunder method calls through language syntax.
+    # The plugin's method signature hook should turn all str occurrences into int.
+    def __init__(self) -> None: ...
+    def __getitem__(self, index: str) -> str: ...
+    def __setitem__(self, index: str, value: str) -> None: ...
+    def __iter__(self) -> Iterator[str]: ...
+    def __next__(self) -> str: ...
+    def m(self, arg: str) -> str: ...
+
+foo = Foo()
+reveal_type(foo.m(2)) # E: Revealed type is 'builtins.int'
+reveal_type(foo[3]) # E: Revealed type is 'builtins.int'
+foo[4] = 5
+for x in foo:
+    reveal_type(x) # E: Revealed type is 'builtins.int*'
+
+[file mypy.ini]
+[[mypy]
+plugins=<ROOT>/test-data/unit/plugins/method_sig_hook.py
+
+[case testMethodSignatureHookNamesFullyQualified]
+# flags: --config-file tmp/mypy.ini
+from mypy_extensions import TypedDict
+from typing import NamedTuple
+
+class FullyQualifiedTestClass:
+    @classmethod
+    def class_method(self) -> str: ...
+    def instance_method(self) -> str: ...
+
+class FullyQualifiedTestTypedDict(TypedDict):
+    foo: str
+
+FullyQualifiedTestNamedTuple = NamedTuple('FullyQualifiedTestNamedTuple', [('foo', str)])
+
+# Check the return types to ensure that the method signature hook is called in each case
+reveal_type(FullyQualifiedTestClass.class_method()) # E: Revealed type is 'builtins.int'
+reveal_type(FullyQualifiedTestClass().instance_method()) # E: Revealed type is 'builtins.int'
+reveal_type(FullyQualifiedTestNamedTuple('')._asdict()) # E: Revealed type is 'builtins.int'
+
+[file mypy.ini]
+[[mypy]
+plugins=<ROOT>/test-data/unit/plugins/fully_qualified_test_hook.py
+[builtins fixtures/classmethod.pyi]
diff --git a/test-data/unit/plugins/fully_qualified_test_hook.py b/test-data/unit/plugins/fully_qualified_test_hook.py
new file mode 100644
--- /dev/null
+++ b/test-data/unit/plugins/fully_qualified_test_hook.py
@@ -0,0 +1,16 @@
+from mypy.plugin import CallableType, MethodSigContext, Plugin
+
+class FullyQualifiedTestPlugin(Plugin):
+    def get_method_signature_hook(self, fullname):
+        # Ensure that all names are fully qualified
+        if 'FullyQualifiedTest' in fullname:
+            assert fullname.startswith('__main__.') and not ' of ' in fullname, fullname
+            return my_hook
+    
+        return None
+
+def my_hook(ctx: MethodSigContext) -> CallableType:
+    return ctx.default_signature.copy_modified(ret_type=ctx.api.named_generic_type('builtins.int', []))
+
+def plugin(version):
+    return FullyQualifiedTestPlugin