Repository: quora/pyanalyze
Pull request #640 (2023-05-28 02:27:43)

Infer Callable as Callable[..., Any]


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index b4dcf3e..8bdd21e 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+- Fix compatibility between stub-only callable classes
+  and the bare `Callable` annotation (#640)
 - Add new error code `missing_generic_parameters` (off by
   default) that flags missing parameters to generic types
   such as `list` (#637)
diff --git a/pyanalyze/annotations.py b/pyanalyze/annotations.py
index 069cfbf..c3edd76 100644
--- a/pyanalyze/annotations.py
+++ b/pyanalyze/annotations.py
@@ -69,6 +69,7 @@ from .functions import FunctionDefNode
 from .node_visitor import ErrorContext
 from .safe import is_instance_of_typing_name, is_typing_name
 from .signature import (
+    ANY_SIGNATURE,
     ELLIPSIS_PARAM,
     InvalidSignature,
     ParameterKind,
@@ -1104,7 +1105,7 @@ def _value_of_origin_args(
         return unite_values(*[_type_from_runtime(arg, ctx) for arg in args])
     elif origin is Callable or is_typing_name(origin, "Callable"):
         if len(args) == 0:
-            return TypedValue(Callable)
+            return CallableValue(ANY_SIGNATURE)
         *arg_types, return_type = args
         if len(arg_types) == 1 and isinstance(arg_types[0], list):
             arg_types = arg_types[0]
@@ -1186,6 +1187,8 @@ def _maybe_typed_value(val: Union[type, str]) -> Value:
         return KnownValue(None)
     elif val is Hashable:
         return _HashableValue(val)
+    elif val is Callable or is_typing_name(val, "Callable"):
+        return CallableValue(ANY_SIGNATURE)
     return TypedValue(val)
 
 


Test patch:
diff --git a/pyanalyze/stubs/_pyanalyze_tests-stubs/callable.pyi b/pyanalyze/stubs/_pyanalyze_tests-stubs/callable.pyi
new file mode 100644
index 0000000..ab5fc19
--- /dev/null
+++ b/pyanalyze/stubs/_pyanalyze_tests-stubs/callable.pyi
@@ -0,0 +1,6 @@
+from typing import Any
+
+from typing import Any
+
+class StubCallable:
+    def __call__(self, *args: Any, **kwds: Any) -> Any: ...
diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py
index d0f8c59..2ca78a6 100644
--- a/pyanalyze/test_annotations.py
+++ b/pyanalyze/test_annotations.py
@@ -946,6 +946,33 @@ class TestCallable(TestNameCheckVisitorBase):
                 (yield amap.asynq(mapper, [1])), GenericValue(list, [TypedValue(str)])
             )
 
+    @assert_passes()
+    def test_bare_callable(self):
+        import typing
+        import collections.abc
+
+        def want_typing(c: typing.Callable) -> None:
+            pass
+
+        def want_abc(c: collections.abc.Callable) -> None:
+            pass
+
+        class MyCallable:
+            def __call__(self) -> None:
+                pass
+
+        def capybara(m: MyCallable) -> None:
+            want_typing(m)
+            want_abc(m)
+            want_typing(1)  # E: incompatible_argument
+            want_abc(1)  # E: incompatible_argument
+
+            from _pyanalyze_tests.callable import StubCallable
+
+            def inner(x: StubCallable) -> None:
+                want_typing(x)
+                want_abc(x)
+
 
 class TestTypeVar(TestNameCheckVisitorBase):
     @assert_passes()