Repository: python/mypy (Python)

Issue:
Unable to assign a function a method
For example:

```python
from typing import Any, Optional


class Foo(object):
    def __init__(self):
        # type: () -> None
        self.x = 0

    def get_x(self, a=None, b=None):
        # type: (Optional[int], Optional[int]) -> int
        return self.x + (a or 0) + (b or 0)


def get_x_patch(self, *args, **kwargs):
    #  type: (Foo, *Any, **Any) -> int
    return 42


Foo.get_x = get_x_patch
```
Results:
```
test_method_assignment.py:19: error: Cannot assign to a method
```

This assignment should be legal as any call to `get_x` will be able to call `get_x_patch`.  It looks like https://github.com/python/mypy/commit/3ce8d6a5c72f49e120b354619ab44523fee4de0e explicitly disallowed all method assignments, but there's not a ton of context behind it.

I know monkeypatching is generally frowned upon, but is unfortunately a very popular part of Python.  I can always mark those lines as ignored, but I'd rather be able to test that the patch is compatible with the underlying method with mypy.


Hints:
_I swear_, this is a duplicate, but I can't find the issue # yet...

@kirbyfan64 Yeah...I poked around and couldn't find anything.  Happy to close this if it is!

I think @sixolet might be interesting in this...

The mypy callable type representation isn't expressive enough to to check assignments to methods precisely. In particular, at least bound methods and unbound function objects should be treated differently. Consider this example:

``` python
class A:
    def f(self): pass
    def g(self): pass

def h(self): pass

A.f = h   # rvalue has one argument (unbound)
A().f()  # okay
A.f = A().g  # rvalue has no arguments (bound)
A().f()  # okay 
```

When we have value with an annotated callable type, such as `Callable[[A], None]`, mypy can't decide whether this is a bound or unbound function method/function. Static methods and class methods might complicate this further.

I'm pretty sure this is already broken in other contexts, but we may want to resolve this eventually. Some random ideas:
1. Internally keep track whether a callable is bound so that we can do more precise checking. This would work for expressions with inferred types. For values explicitly annotated with a `Callable` type, the value of the flag would be unknown and these couldn't be used for monkey patching (unless the argument types are `...`).
2. Like (1), but make some assumptions about annotated `Callable` types. Either assume that they are unbound, or optimistically assume that they have a compatible 'unboundedness' for any particular context. This is unsafe. This is basically how things work right now, though perhaps not consistently.
3. Add syntax for specifying callables that are always bound or unbound. We'd likely need three different variants: either bound or unbound (likely spelled just `Callable`), bound (for example, `BoundMethod`) and unbound (for example, `UnboundCallable`).

Option (3) doesn't seem worth the added complexity, to be honest, as it's always possible to fall back to `Callable[..., X]`.

So is the problem that when we see

``` python
class C:
    f = None  # type: Callable[[int, int], int]
```

we don't know whether that defines an instance variable or a class variable? Maybe we can use `ClassVar` (introduced by PEP 526 into the typing module)? E.g.

``` python
class C:
    f = None  # type: ClassVar[Callable[[int, int], int]]
```

and if ClassVar is not used assume `f` refers to an instance variable.

Of course initializations inside `__init__` are unambiguous. And so are method definitions (with or without `@staticmethod` or `@classmethod`).

I do think mypy ought to be fully aware of bound and unbound methods.

But perhaps the original problem is due to something else? The immediate problem seems to be that we don't try to match `*args, **kwds` against `a=None, b=None`?

Isn't this a duplicate of https://github.com/python/mypy/issues/708?

But maybe it makes sense to keep this open, since this issue contains some additional discussion.
Not really -- IIUC this seems about monkey-patching a class, whereas #708 is about assigning to function attributes.
A case where I keep running into that issue is when writing unit tests and trying to replace methods with `MagicMock()`.

Typically, class `Foo` is defined and tested somewhere and class `FooBar` uses (an instance of) `Foo`, but in order to unit test `FooBar` I don't really need/want to make actual calls to `Foo` methods (which can either take a long time to compute, or require some setup (eg, networking) that isn't here for unit test, …) So, I heavily `Mock()` the methods which allow to test that the correct calls are issued and thus test `FooBar`.

It does feel bad to add a bunch a `# type: ignore` on all these mocks :-(
I prefer `setattr` over using `# type: ignore`. Mypy won't complain about it. This is something we could discuss in the common issues section in the docs.
What's the state of this (about monkey patching a method)?  It seems like it needed discussion, has that happened offline?
The has been no progress recently. The workarounds discussed above (`setattr` or `# type: ignore`) are still the recommended ways to deal with this.
Cool, thanks for the update.  5:)
I think that I am running into this. The code that causes the mypy error is `FileDownloader.download = classmethod(lambda a, filename: open(f'tests/fixtures/{filename}', 'rb'))`
The error is `error: Cannot assign to a method`
version is `mypy==0.620`
Hi, any progress on this?
In my case I'm not even monkey-patching (at least, I don't feel like it is), I'm trying to take a function as a parameter of __init__ and use it as a wrapper. Like this (note simplified example, so it might not make entire sense):

```
Adapter = Callable[[S_co], T_contra]

class AdaptingRunner():
    adapter: Adapter
    runner: Runner[T_contra]

    def __init__(self, adapter: Adapter, runner: Runner[T_contra]):
        self.adapter = adapter  # Error here
        self.runner = runner

    def run(self, obj: S_co):
        return self.runner.run(self.adapter(ob))
```

If I remove `adapter: Adapter`, everything is fine, but if I declare it, then I get the referenced error.
> A case where I keep running into that issue is when writing unit tests and trying to replace methods with `MagicMock()`.
> 
> Typically, class `Foo` is defined and tested somewhere and class `FooBar` uses (an instance of) `Foo`, but in order to unit test `FooBar` I don't really need/want to make actual calls to `Foo` methods (which can either take a long time to compute, or require some setup (eg, networking) that isn't here for unit test, …) So, I heavily `Mock()` the methods which allow to test that the correct calls are issued and thus test `FooBar`.
> 
> It does feel bad to add a bunch a `# type: ignore` on all these mocks :-(

@Jym77 
I ran into the same thing, and went with context managers instead, following [the example here](https://github.com/python/mypy/issues/6713#issuecomment-620600319).
This also affects the stdlib when trying to assign to `unittest.TestLoader.sortTestMethodsUsing`
Here is a MWE that shows that the bug can be caused by adding type hints to the inputs of `__init__`:

<table>
<tr>
<th>error: Cannot assign to a method</th>
<th>Throws no error</th>
</tr>
<tr>
<td>

```python
from typing import Callable

class A:
    f: Callable[..., None]

    def __init__(self, x: int):
        super().__init__()
        self.f = lambda: print(x)
```

</td>
<td>

```python
from typing import Callable

class A:
    f: Callable[..., None]

    def __init__(self, x):
        super().__init__()
        self.f = lambda: print(x)
```

</td>
</tr>
</table>

This is quite annoying because in certain contexts it is quite common to assign `callable` attributes in `__init__` (for example: machine learning / pytorch nn.Modules)
That's just because un-annotated functions (like the `__init__` on the right) are not analyzed at all. It has nothing to do with the underlying problem, which is that mypy doesn't quite understanding the difference between a class variable with a callable type and a method.
> That's just because un-annotated functions (like the `__init__` on the right) are not analyzed at all. It has nothing to do with the underlying problem, which is that mypy doesn't quite understanding the difference between a class variable with a callable type and a method.

Oh! I wasn't aware of that. And I find this quite strange to be honest, since, even when the input types are unknown there could be things happening inside the function body where `mypy` could be useful. Why doesn't `mypy` simply assume `Any` type on all inputs in this case?

For example, consider

```python
def f(x):
    a: str = "abc"
    b: int = 3
    c = a + b
    return x
```

Imo it would be super useful if `mypy` raised an error here, even if `x` is not typed.

It would be more productive to discuss that here: https://github.com/python/typing/discussions .
> I prefer `setattr` over using `# type: ignore`. Mypy won't complain about it. This is something we could discuss in the common issues section in the docs.

It might silence mypy, but it's one of flakeheaven's bugbears.

> `B010 Do not call setattr with a constant attribute value, it is not any safer than normal property access. [flake8-bugbear]`

So I still prefer to use `type:ignore` with a comment about what is being ignored. For example, mypy also more usefully points out when the callable signatures don't match.

Patch:
diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py
--- a/mypy/errorcodes.py
+++ b/mypy/errorcodes.py
@@ -5,19 +5,30 @@
 
 from __future__ import annotations
 
+from collections import defaultdict
 from typing_extensions import Final
 
 error_codes: dict[str, ErrorCode] = {}
+sub_code_map: dict[str, set[str]] = defaultdict(set)
 
 
 class ErrorCode:
     def __init__(
-        self, code: str, description: str, category: str, default_enabled: bool = True
+        self,
+        code: str,
+        description: str,
+        category: str,
+        default_enabled: bool = True,
+        sub_code_of: ErrorCode | None = None,
     ) -> None:
         self.code = code
         self.description = description
         self.category = category
         self.default_enabled = default_enabled
+        self.sub_code_of = sub_code_of
+        if sub_code_of is not None:
+            assert sub_code_of.sub_code_of is None, "Nested subcategories are not supported"
+            sub_code_map[sub_code_of.code].add(code)
         error_codes[code] = self
 
     def __str__(self) -> str:
@@ -51,6 +62,12 @@ def __str__(self) -> str:
 ASSIGNMENT: Final[ErrorCode] = ErrorCode(
     "assignment", "Check that assigned value is compatible with target", "General"
 )
+METHOD_ASSIGN: Final[ErrorCode] = ErrorCode(
+    "method-assign",
+    "Check that assignment target is not a method",
+    "General",
+    sub_code_of=ASSIGNMENT,
+)
 TYPE_ARG: Final = ErrorCode("type-arg", "Check that generic type arguments are present", "General")
 TYPE_VAR: Final = ErrorCode("type-var", "Check that type variable values are valid", "General")
 UNION_ATTR: Final = ErrorCode(
diff --git a/mypy/errors.py b/mypy/errors.py
--- a/mypy/errors.py
+++ b/mypy/errors.py
@@ -586,7 +586,11 @@ def is_ignored_error(self, line: int, info: ErrorInfo, ignores: dict[int, list[s
             # Empty list means that we ignore all errors
             return True
         if info.code and self.is_error_code_enabled(info.code):
-            return info.code.code in ignores[line]
+            return (
+                info.code.code in ignores[line]
+                or info.code.sub_code_of is not None
+                and info.code.sub_code_of.code in ignores[line]
+            )
         return False
 
     def is_error_code_enabled(self, error_code: ErrorCode) -> bool:
@@ -601,6 +605,8 @@ def is_error_code_enabled(self, error_code: ErrorCode) -> bool:
             return False
         elif error_code in current_mod_enabled:
             return True
+        elif error_code.sub_code_of is not None and error_code.sub_code_of in current_mod_disabled:
+            return False
         else:
             return error_code.default_enabled
 
@@ -641,6 +647,10 @@ def generate_unused_ignore_errors(self, file: str) -> None:
             if len(ignored_codes) > 1 and len(unused_ignored_codes) > 0:
                 unused_codes_message = f"[{', '.join(sorted(unused_ignored_codes))}]"
             message = f'Unused "type: ignore{unused_codes_message}" comment'
+            for unused in unused_ignored_codes:
+                narrower = set(used_ignored_codes) & codes.sub_code_map[unused]
+                if narrower:
+                    message += f", use narrower [{', '.join(narrower)}] instead of [{unused}]"
             # Don't use report since add_error_info will ignore the error!
             info = ErrorInfo(
                 self.import_context(),
diff --git a/mypy/messages.py b/mypy/messages.py
--- a/mypy/messages.py
+++ b/mypy/messages.py
@@ -1402,7 +1402,7 @@ def base_class_definitions_incompatible(
         )
 
     def cant_assign_to_method(self, context: Context) -> None:
-        self.fail(message_registry.CANNOT_ASSIGN_TO_METHOD, context, code=codes.ASSIGNMENT)
+        self.fail(message_registry.CANNOT_ASSIGN_TO_METHOD, context, code=codes.METHOD_ASSIGN)
 
     def cant_assign_to_classvar(self, name: str, context: Context) -> None:
         self.fail(f'Cannot assign to class variable "{name}" via instance', context)


Test patch:
diff --git a/test-data/unit/check-errorcodes.test b/test-data/unit/check-errorcodes.test
--- a/test-data/unit/check-errorcodes.test
+++ b/test-data/unit/check-errorcodes.test
@@ -672,7 +672,7 @@ class A:
 
 def g(self: A) -> None: pass
 
-A.f = g  # E: Cannot assign to a method  [assignment]
+A.f = g  # E: Cannot assign to a method  [method-assign]
 
 [case testErrorCodeDefinedHereNoteIgnore]
 import m
@@ -1006,3 +1006,35 @@ def f():
 # flags: --disable-error-code=annotation-unchecked
 def f():
     x: int = "no"  # No warning here
+
+[case testMethodAssignmentSuppressed]
+# flags: --disable-error-code=method-assign
+class A:
+    def f(self) -> None: pass
+    def g(self) -> None: pass
+
+def h(self: A) -> None: pass
+
+A.f = h
+# This actually works at runtime, but there is no way to express this in current type system
+A.f = A().g  # E: Incompatible types in assignment (expression has type "Callable[[], None]", variable has type "Callable[[A], None]")  [assignment]
+
+[case testMethodAssignCoveredByAssignmentIgnore]
+class A:
+    def f(self) -> None: pass
+def h(self: A) -> None: pass
+A.f = h  # type: ignore[assignment]
+
+[case testMethodAssignCoveredByAssignmentFlag]
+# flags: --disable-error-code=assignment
+class A:
+    def f(self) -> None: pass
+def h(self: A) -> None: pass
+A.f = h  # OK
+
+[case testMethodAssignCoveredByAssignmentUnused]
+# flags: --warn-unused-ignores
+class A:
+    def f(self) -> None: pass
+def h(self: A) -> None: pass
+A.f = h  # type: ignore[assignment]  # E: Unused "type: ignore" comment, use narrower [method-assign] instead of [assignment]