Repository: quora/pyanalyze
Pull request #732 (2024-02-27 18:46:15)

Fix some secondary method signatures


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index cec20c4..ae48358 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## Unreleased
+
+- Fix inference of signature for certain secondary methods (#732)
+
 ## Version 0.12.0 (February 25, 2024)
 
 ### New features
diff --git a/pyanalyze/arg_spec.py b/pyanalyze/arg_spec.py
index d8f6f41..c654429 100644
--- a/pyanalyze/arg_spec.py
+++ b/pyanalyze/arg_spec.py
@@ -646,14 +646,18 @@ class ArgSpecCache:
                     unbound, impl, is_asynq, in_overload_resolution
                 )
                 if sig is not None:
-                    return make_bound_method(sig, Composite(KnownValue(obj.__self__)))
+                    return make_bound_method(
+                        sig, Composite(KnownValue(obj.__self__)), ctx=self.ctx
+                    )
 
         # for bound methods, see if we have an argspec for the unbound method
         if inspect.ismethod(obj) and obj.__self__ is not None:
             argspec = self._cached_get_argspec(
                 obj.__func__, impl, is_asynq, in_overload_resolution
             )
-            return make_bound_method(argspec, Composite(KnownValue(obj.__self__)))
+            return make_bound_method(
+                argspec, Composite(KnownValue(obj.__self__)), ctx=self.ctx
+            )
 
         # Must be after the check for bound methods, because otherwise we
         # won't bind self correctly.
@@ -704,7 +708,9 @@ class ArgSpecCache:
                 _ENUM_CALL, impl, is_asynq, in_overload_resolution
             )
             self_value = SubclassValue(TypedValue(obj))
-            bound_sig = make_bound_method(signature, Composite(self_value))
+            bound_sig = make_bound_method(
+                signature, Composite(self_value), ctx=self.ctx
+            )
             if bound_sig is None:
                 return None
             sig = bound_sig.get_signature(
@@ -793,7 +799,9 @@ class ArgSpecCache:
             )
             # wrap if it's a bound method
             if obj.instance is not None and argspec is not None:
-                return make_bound_method(argspec, Composite(KnownValue(obj.instance)))
+                return make_bound_method(
+                    argspec, Composite(KnownValue(obj.instance)), ctx=self.ctx
+                )
             return argspec
 
         if inspect.isclass(obj):
@@ -856,7 +864,9 @@ class ArgSpecCache:
                     returns=return_type,
                     allow_call=allow_call,
                 )
-            bound_sig = make_bound_method(signature, Composite(TypedValue(obj)))
+            bound_sig = make_bound_method(
+                signature, Composite(TypedValue(obj)), ctx=self.ctx
+            )
             if bound_sig is None:
                 return None
             if is_dunder_new:
@@ -894,7 +904,9 @@ class ArgSpecCache:
                 argspec = self._cached_get_argspec(
                     method, impl, is_asynq, in_overload_resolution
                 )
-                return make_bound_method(argspec, Composite(KnownValue(obj.__self__)))
+                return make_bound_method(
+                    argspec, Composite(KnownValue(obj.__self__)), ctx=self.ctx
+                )
 
         if hasattr_static(obj, "__call__"):
             # we could get an argspec here in some cases, but it's impossible to figure out
diff --git a/pyanalyze/attributes.py b/pyanalyze/attributes.py
index d50bd55..95f4160 100644
--- a/pyanalyze/attributes.py
+++ b/pyanalyze/attributes.py
@@ -448,8 +448,8 @@ def _get_attribute_from_known(obj: object, ctx: AttrContext) -> Value:
         )
         and isinstance(ctx.root_value, AnnotatedValue)
     ):
-        result = UnboundMethodValue(ctx.attr, ctx.root_composite)
-    if safe_isinstance(obj, type):
+        result = set_self(result, ctx.root_value)
+    elif safe_isinstance(obj, type):
         result = set_self(result, TypedValue(obj))
     if isinstance(obj, (types.ModuleType, type)):
         ctx.record_usage(obj, result)
diff --git a/pyanalyze/checker.py b/pyanalyze/checker.py
index 577bbb4..97aa8ba 100644
--- a/pyanalyze/checker.py
+++ b/pyanalyze/checker.py
@@ -320,9 +320,12 @@ class Checker:
                 sig = self.arg_spec_cache.get_argspec(method)
                 if sig is None:
                     # TODO return None here and figure out when the signature is missing
+                    # Probably because of cythonized methods
                     return ANY_SIGNATURE
                 return_override = get_return_override(sig)
-                bound = make_bound_method(sig, value.composite, return_override)
+                bound = make_bound_method(
+                    sig, value.composite, return_override, ctx=self
+                )
                 if bound is not None and value.typevars is not None:
                     bound = bound.substitute_typevars(value.typevars)
                 return bound
@@ -352,7 +355,9 @@ class Checker:
             call_fn = typ.__call__
             sig = self.arg_spec_cache.get_argspec(call_fn)
             return_override = get_return_override(sig)
-            bound_method = make_bound_method(sig, Composite(value), return_override)
+            bound_method = make_bound_method(
+                sig, Composite(value), return_override, ctx=self
+            )
             if bound_method is None:
                 return None
             return bound_method.get_signature(ctx=self)
diff --git a/pyanalyze/signature.py b/pyanalyze/signature.py
index ffa30f6..d7f30c1 100644
--- a/pyanalyze/signature.py
+++ b/pyanalyze/signature.py
@@ -2570,6 +2570,8 @@ def make_bound_method(
     argspec: MaybeSignature,
     self_composite: Composite,
     return_override: Optional[Value] = None,
+    *,
+    ctx: CanAssignContext,
 ) -> Optional[BoundMethodSignature]:
     if argspec is None:
         return None
@@ -2578,7 +2580,10 @@ def make_bound_method(
     elif isinstance(argspec, BoundMethodSignature):
         if return_override is None:
             return_override = argspec.return_override
-        return BoundMethodSignature(argspec.signature, self_composite, return_override)
+        sig = argspec.get_signature(ctx=ctx)
+        if sig is None:
+            return None
+        return BoundMethodSignature(sig, self_composite, return_override)
     else:
         assert_never(argspec)
 
diff --git a/pyanalyze/typeshed.py b/pyanalyze/typeshed.py
index 9dc1dac..593a506 100644
--- a/pyanalyze/typeshed.py
+++ b/pyanalyze/typeshed.py
@@ -233,7 +233,9 @@ class TypeshedFinder:
                 sig = self._get_sig_from_method_descriptor(method, allow_call)
                 if sig is None:
                     return None
-                bound = make_bound_method(sig, Composite(TypedValue(obj.__self__)))
+                bound = make_bound_method(
+                    sig, Composite(TypedValue(obj.__self__)), ctx=self.ctx
+                )
                 if bound is None:
                     return None
                 return bound.get_signature(ctx=self.ctx)
@@ -806,7 +808,9 @@ class TypeshedFinder:
                         self_annotation_value = self_val
                     else:
                         self_annotation_value = SubclassValue(self_val)
-                    bound_sig = make_bound_method(sig, Composite(self_val))
+                    bound_sig = make_bound_method(
+                        sig, Composite(self_val), ctx=self.ctx
+                    )
                     if bound_sig is None:
                         return None
                     sig = bound_sig.get_signature(
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 6afc2f3..376ba11 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -608,6 +608,9 @@ class KnownValueWithTypeVars(KnownValue):
     typevars: TypeVarMap = field(compare=False)
     """TypeVars substituted on this value."""
 
+    def __str__(self) -> str:
+        return super().__str__() + f" with typevars {self.typevars}"
+
 
 @dataclass(frozen=True)
 class SyntheticModuleValue(Value):


Test patch:
diff --git a/pyanalyze/test_asynq.py b/pyanalyze/test_asynq.py
index 3c33208..36bd147 100644
--- a/pyanalyze/test_asynq.py
+++ b/pyanalyze/test_asynq.py
@@ -225,3 +225,51 @@ class TestReturn(TestNameCheckVisitorBase):
             if cond:
                 return 3
             yield capybara.asynq(False)
+
+
+class TestBindDecorator(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_l0(self):
+        import qcore
+        from asynq import asynq
+
+        class L0CacheDecoratorBinder(qcore.decorators.DecoratorBinder):
+            def dirty(self, *args, **kwargs):
+                pass
+
+        class L0AsyncCacheDecoratorBinder(L0CacheDecoratorBinder):
+            def asynq(self, *args, **kwargs):
+                pass
+
+        class L0CacheDecorator(qcore.decorators.DecoratorBase):
+            binder_cls = L0CacheDecoratorBinder
+
+            def dirty(self, *args, **kwargs):
+                pass
+
+        class L0AsyncCacheDecorator(L0CacheDecorator):
+            binder_cls = L0AsyncCacheDecoratorBinder
+
+            def __call__(self, *args, **kwargs):
+                pass
+
+            def asynq(self, *args, **kwargs):
+                pass
+
+            def is_pure_async_fn(self):
+                return False
+
+        def cached():
+            def decorate(fn):
+                return qcore.decorators.decorate(L0AsyncCacheDecorator)(fn)
+
+            return decorate
+
+        class A:
+            @cached()
+            @asynq()
+            def f1(self):
+                return 100
+
+            def f2(self):
+                self.f1.dirty()  # should be ok
diff --git a/pyanalyze/test_attributes.py b/pyanalyze/test_attributes.py
index a2063ca..883f574 100644
--- a/pyanalyze/test_attributes.py
+++ b/pyanalyze/test_attributes.py
@@ -11,7 +11,6 @@ from .value import (
     KnownValue,
     MultiValuedValue,
     TypedValue,
-    UnboundMethodValue,
     assert_is_value,
 )
 
@@ -208,29 +207,22 @@ class TestAttributes(TestNameCheckVisitorBase):
 
     @assert_passes()
     def test_annotated_known(self):
-        from typing import Any, cast
-        from unittest.mock import ANY
-
         from typing_extensions import Annotated, Literal
 
         from pyanalyze.extensions import LiteralOnly
-        from pyanalyze.stacked_scopes import Composite, VarnameWithOrigin
-        from pyanalyze.value import CustomCheckExtension
-
-        origin = VarnameWithOrigin("encoding", cast(Any, ANY))
+        from pyanalyze.value import CustomCheckExtension, KnownValueWithTypeVars, SelfT
 
         def capybara():
             encoding: Annotated[Literal["ascii"], LiteralOnly()] = "ascii"
             assert_is_value(
                 encoding.encode,
-                UnboundMethodValue(
-                    "encode",
-                    Composite(
-                        AnnotatedValue(
+                KnownValueWithTypeVars(
+                    encoding.encode,
+                    {
+                        SelfT: AnnotatedValue(
                             KnownValue("ascii"), [CustomCheckExtension(LiteralOnly())]
-                        ),
-                        origin,
-                    ),
+                        )
+                    },
                 ),
             )