Repository: python/mypy

Issue:
Inconsistent Treatment of Class Constructor by Protocol
In the code below we define one protocol which, when called, returns another. In effect we're describing a "factory". However, while MyPy is able to recognize incorrect protocol implementations when given a function, it fails to do the same for class constructors.

Playground link: https://mypy-play.net/?mypy=latest&python=3.9&gist=3b9250b17032b659fc36746e659bcdb5

```python
from typing import Protocol

class Factory(Protocol):
    def __call__(self) -> Result:
        ...

class Result(Protocol):
    a: int
    
class BadImplementation:
    ...

def bad_implementation() -> BadImplementation:
    ...

something: Factory = BadImplementation  # no error
something_else: Factory = bad_implementation  # yes error
```

We would expect MyPy to give an error in both cases given they result in the same type, but it only complains when given a standard function.


Discussion:
Hm, this is because in typeshed `type.__call__` is annotated as returning `(*Any, **Any) -> Any`, making all class objects implementing arbitrary callback protocols. Mypy uses metaclass as a fallback for protocol implementation if implementation is a class object, so we will need to either tighten definition in typeshed, or ignore metaclass `__call__` if constructor didn't match.

TBH I am not sure which one is better.
Typeshed is technically right there: `type.__call__` takes arbitrary arguments (https://github.com/python/cpython/blob/5cfb7d19f5242c9b8ffd2fe30a24569e85a99e1d/Objects/typeobject.c#L1235). So it's probably best to handle this case specially in mypy.
Problem is not much in what it takes, but in what it returns, it should be `object`, not `Any`. I guess this will fix 95% of relevant use cases. But maybe we can try ignoring `__call__`, it is just that it is a bit of pain, `is_(proper)_subtype()` already have like a dozen random flags, this will need one more. Unless, we just want to _always_ skip `__call__` on a metaclass (or only on `builtins.type`?)
I see, opened python/typeshed#9215 to change it. Let's see if there's any negative effects.
Btw funny thing is that we have exactly a test like the original repro, but it passes, because we use test fixtures for most protocol tests, not full typeshed stubs.
@JelleZijlstra Hm, it looks like it didn't work (see `mypy_primer`). People often want to instantiate items in list of unrelated class objects. I guess we need to come up with some skipping/ignoring rules.
The return type being a protocol isn't important. I think that we should compare the signature of `__init__` from the actual type object against the callable protocol, instead of `type.__call__` from typeshed. I don't think that we can fix this in typeshed.

Another example:
```py
from typing import Protocol

class P(Protocol):
    def __call__(self, x: int) -> C: ...

class C:
    def __init__(self, x: str) -> None: ...

def f(x: str) -> C:
    return C(x)

x: P = C  # no error
y: P = f  # yes error
```

Resolving patch:
diff --git a/mypy/messages.py b/mypy/messages.py
--- a/mypy/messages.py
+++ b/mypy/messages.py
@@ -1899,7 +1899,7 @@ def report_protocol_problems(
         missing = get_missing_protocol_members(subtype, supertype, skip=skip)
         if (
             missing
-            and len(missing) < len(supertype.type.protocol_members)
+            and (len(missing) < len(supertype.type.protocol_members) or missing == ["__call__"])
             and len(missing) <= MAX_ITEMS
         ):
             if missing == ["__call__"] and class_obj:
diff --git a/mypy/subtypes.py b/mypy/subtypes.py
--- a/mypy/subtypes.py
+++ b/mypy/subtypes.py
@@ -1005,6 +1005,10 @@ def named_type(fullname: str) -> Instance:
                 subtype: ProperType | None = mypy.checkmember.type_object_type(
                     left.type, named_type
                 )
+            elif member == "__call__" and left.type.is_metaclass():
+                # Special case: we want to avoid falling back to metaclass __call__
+                # if constructor signature didn't match, this can cause many false negatives.
+                subtype = None
             else:
                 subtype = get_proper_type(find_member(member, left, left, class_obj=class_obj))
             # Useful for debugging:


Test patch:
diff --git a/test-data/unit/check-protocols.test b/test-data/unit/check-protocols.test
--- a/test-data/unit/check-protocols.test
+++ b/test-data/unit/check-protocols.test
@@ -3526,6 +3526,23 @@ test(B)  # OK
 test(C)  # E: Argument 1 to "test" has incompatible type "Type[C]"; expected "P" \
          # N: "C" has constructor incompatible with "__call__" of "P"
 
+[case testProtocolClassObjectPureCallback]
+from typing import Any, ClassVar, Protocol
+
+class P(Protocol):
+    def __call__(self, x: int, y: int) -> Any: ...
+
+class B:
+    def __init__(self, x: int, y: int) -> None: ...
+class C:
+    def __init__(self, x: int, y: str) -> None: ...
+
+def test(arg: P) -> None: ...
+test(B)  # OK
+test(C)  # E: Argument 1 to "test" has incompatible type "Type[C]"; expected "P" \
+         # N: "C" has constructor incompatible with "__call__" of "P"
+[builtins fixtures/type.pyi]
+
 [case testProtocolTypeTypeAttribute]
 from typing import ClassVar, Protocol, Type