Repository: quora/pyanalyze
Pull request #600 (2023-03-13 23:17:19)

Fix bug with *args on __call__


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 026f4cc..8abcb06 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -4,6 +4,11 @@
 
 # Changelog
 
+## Unreleased
+
+- Fix bug where objects with a `__call__` method that takes `*args` instead
+  of `self` was not considered callable (#600)
+
 ## Version 0.9.0 (January 16, 2023)
 
 Release highlights:
diff --git a/pyanalyze/signature.py b/pyanalyze/signature.py
index d314a4f..8f8a75f 100644
--- a/pyanalyze/signature.py
+++ b/pyanalyze/signature.py
@@ -1892,21 +1892,30 @@ class Signature:
         if not params:
             return None
         kind = params[0].kind
-        if self_value is not None:
-            tv_map = get_tv_map(params[0].annotation, self_value, ctx)
-            if isinstance(tv_map, CanAssignError):
-                return None
-        else:
-            tv_map = {}
-        if kind is ParameterKind.ELLIPSIS:
+        if kind in (ParameterKind.ELLIPSIS, ParameterKind.VAR_POSITIONAL):
             new_params = params
+            self_tuple_annotation = params[0].annotation
+            if (
+                isinstance(self_tuple_annotation, GenericValue)
+                and self_tuple_annotation.typ is tuple
+            ):
+                self_annotation = self_tuple_annotation.args[0]
+            else:
+                self_annotation = AnyValue(AnySource.inference)
         elif kind in (
             ParameterKind.POSITIONAL_ONLY,
             ParameterKind.POSITIONAL_OR_KEYWORD,
         ):
             new_params = params[1:]
+            self_annotation = params[0].annotation
         else:
             return None
+        if self_value is not None:
+            tv_map = get_tv_map(self_annotation, self_value, ctx)
+            if isinstance(tv_map, CanAssignError):
+                return None
+        else:
+            tv_map = {}
         if tv_map:
             new_params = {
                 param.name: param.substitute_typevars(tv_map) for param in new_params


Test patch:
diff --git a/pyanalyze/test_signature.py b/pyanalyze/test_signature.py
index aa515d4..25b2a57 100644
--- a/pyanalyze/test_signature.py
+++ b/pyanalyze/test_signature.py
@@ -524,6 +524,37 @@ class TestCalls(TestNameCheckVisitorBase):
             assert_is_value(obj, TypedValue(WithCall))
             assert_is_value(obj(x), AnyValue(AnySource.unannotated))
 
+    @assert_passes()
+    def test__call__annotated(self):
+        class WithCall(object):
+            def __call__(self, arg: int) -> int:
+                return arg * 2
+
+        def capybara(x: int):
+            obj = WithCall()
+            assert_is_value(obj, TypedValue(WithCall))
+            assert_is_value(obj(x), TypedValue(int))
+            obj("x")  # E: incompatible_argument
+
+    @assert_passes()
+    def test__call__starargs(self):
+        class WithCall(object):
+            def __call__(*args) -> int:  # E: method_first_arg
+                return 42
+
+        class WithWrongAnnotation(object):
+            def __call__(*args: int) -> int:  # E: method_first_arg
+                return 42
+
+        def capybara(x: int):
+            obj = WithCall()
+            assert_is_value(obj, TypedValue(WithCall))
+            assert_is_value(obj(x), TypedValue(int))
+            assert_is_value(obj("x"), TypedValue(int))
+
+            obj2 = WithWrongAnnotation()
+            obj2(1)  # E: not_callable
+
     @assert_passes()
     def test_unbound_method(self):
         class Capybara(object):