Repository: quora/pyanalyze
Pull request #302 (2021-12-03 02:15:03)

Fix overly eager hasattr impl


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 2cacc5d..a897394 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- Fix bug where pyanalyze incorrectly inferred that an attribute always exists (#302)
 - Fix compatibility of signatures with extra parameters (#301)
 - Enhance `reveal_type()` output for `UnboundMethodValue` (#300)
 - Fix handling of `async for` (#298)
diff --git a/pyanalyze/implementation.py b/pyanalyze/implementation.py
index 2c82fc1..fa83f7d 100644
--- a/pyanalyze/implementation.py
+++ b/pyanalyze/implementation.py
@@ -180,7 +180,9 @@ def _hasattr_impl(ctx: CallContext) -> Value:
     # if the value exists on the type or instance, hasattr should return True
     # don't interpret the opposite to mean it should return False, as the attribute may
     # exist on a child class or get assigned at runtime
-    if isinstance(obj, TypedValue) and safe_hasattr(obj.typ, name.val):
+    if isinstance(obj, TypedValue) and obj.get_type_object(ctx.visitor).has_attribute(
+        name.val, ctx.visitor
+    ):
         return_value = KnownValue(True)
     elif isinstance(obj, KnownValue) and safe_hasattr(obj.val, name.val):
         return_value = KnownValue(True)
diff --git a/pyanalyze/type_object.py b/pyanalyze/type_object.py
index 4a34eb0..ff44c13 100644
--- a/pyanalyze/type_object.py
+++ b/pyanalyze/type_object.py
@@ -146,5 +146,19 @@ class TypeObject:
             # TODO handle this for synthetic types (if necessary)
             return False
 
+    def has_attribute(self, attr: str, ctx: CanAssignContext) -> bool:
+        """Whether this type definitely has this attribute."""
+        if self.is_protocol:
+            return attr in self.protocol_members
+        # We don't use ctx.get_attribute because that may have false positives.
+        for base in self.base_classes:
+            try:
+                present = attr in base.__dict__
+            except Exception:
+                present = False
+            if present:
+                return True
+        return False
+
     def __str__(self) -> str:
         return stringify_object(self.typ)


Test patch:
diff --git a/pyanalyze/test_signature.py b/pyanalyze/test_signature.py
index 8e1d729..03e5c35 100644
--- a/pyanalyze/test_signature.py
+++ b/pyanalyze/test_signature.py
@@ -559,20 +559,34 @@ class TestCalls(TestNameCheckVisitorBase):
 
     @assert_passes()
     def test_hasattr(self):
+        from pyanalyze.value import HasAttrGuardExtension
+
         class Quemisia(object):
             def gravis(self):
                 if hasattr(self, "xaymaca"):
                     print(self.xaymaca)
 
-    @assert_fails(ErrorCode.incompatible_call)
-    def test_hasattr_wrong_args(self):
-        def run():
-            hasattr()
+        def wrong_args():
+            hasattr()  # E: incompatible_call
 
-    @assert_fails(ErrorCode.incompatible_argument)
-    def test_hasattr_mistyped_args(self):
-        def run():
-            hasattr(True, False)
+        def mistyped_args():
+            hasattr(True, False)  # E: incompatible_argument
+
+        def only_on_class(o: object):
+            val = hasattr(o, "__qualname__")
+            assert_is_value(
+                val,
+                AnnotatedValue(
+                    TypedValue(bool),
+                    [
+                        HasAttrGuardExtension(
+                            "object",
+                            KnownValue("__qualname__"),
+                            AnyValue(AnySource.inference),
+                        )
+                    ],
+                ),
+            )
 
     @assert_fails(ErrorCode.incompatible_call)
     def test_keyword_only_args(self):