Repository: quora/pyanalyze
Pull request #689 (2023-09-24 03:11:42)

Hacky hashability fix


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 9f2b7bd..468ca2e 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- Add hacky fix for bugs with hashability on type objects (#689)
 - Show an error on calls to `typing.Any` (#688)
 - Add command-line option `-c`/`--code` to typecheck code from
   the command line (#685)
diff --git a/pyanalyze/type_object.py b/pyanalyze/type_object.py
index ca471e2..2f349ef 100644
--- a/pyanalyze/type_object.py
+++ b/pyanalyze/type_object.py
@@ -10,6 +10,9 @@ from unittest import mock
 
 from .safe import safe_in, safe_isinstance, safe_issubclass
 from .value import (
+    AnnotatedValue,
+    AnySource,
+    AnyValue,
     BoundsMap,
     CanAssign,
     CanAssignContext,
@@ -161,6 +164,18 @@ class TypeObject:
             # For __call__, we check compatibility with the other object itself.
             if member == "__call__":
                 actual = other_val
+            # Hack to allow types to be hashable. This avoids a bug where type objects
+            # don't match the Hashable protocol if they define a __hash__ method themselves:
+            # we compare against the __hash__ instance method, but compared to the protocol
+            # it has an extra parameter (self).
+            # It's a little unclear to me how this is supposed to work on protocols in
+            # general: should they match against the type or the instance? PEP 544 suggests
+            # that we should perhaps have a special case for matching against class objects
+            # and modules, but that feels odd.
+            # A better solution probably first requires a rewrite of the attribute fetching
+            # system to make it more robust.
+            elif member == "__hash__" and _should_use_permissive_dunder_hash(other_val):
+                actual = AnyValue(AnySource.inference)
             else:
                 actual = ctx.get_attribute_from_value(other_val, member)
             if actual is UNINITIALIZED_VALUE:
@@ -213,3 +228,13 @@ class TypeObject:
                 f" {', '.join(map(repr, self.protocol_members))})"
             )
         return base
+
+
+def _should_use_permissive_dunder_hash(val: Value) -> bool:
+    if isinstance(val, AnnotatedValue):
+        val = val.value
+    if isinstance(val, SubclassValue):
+        return True
+    elif isinstance(val, KnownValue) and safe_isinstance(val.val, type):
+        return True
+    return False


Test patch:
diff --git a/pyanalyze/test_type_object.py b/pyanalyze/test_type_object.py
index f4af01d..f096773 100644
--- a/pyanalyze/test_type_object.py
+++ b/pyanalyze/test_type_object.py
@@ -355,17 +355,22 @@ class TestHashable(TestNameCheckVisitorBase):
         class A:
             pass
 
+        class B:
+            def __hash__(self) -> int:
+                return 42
+
         def capybara(t1: Type[int], t2: type):
             want_hash(t1)
             want_hash(t2)
             want_hash(int)
             want_hash(A)
+            want_hash(B)
 
-            # TODO these errors are wrong
             want_myhash(t1)
             want_myhash(t2)
             want_myhash(int)
-            want_myhash(A)  # E: incompatible_argument
+            want_myhash(A)
+            want_myhash(B)
 
             {t1: 0}
             {t2: 0}