Repository: quora/pyanalyze
Pull request #336 (2021-12-12 22:14:08)

Fix typeshed bases with incorrect __qualname__
urllib.parse._ParseResultBase.__qualname__ is ParseResult.


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 11341e3..ad32005 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+- Fix handling of classes in stubs that have an incorrect `__qualname__`
+  at runtime (#336)
 - Fix type compatibility with generic functions (#335)
 - Support function calls in annotations (#334)
 - Better support for `TypeVar` bounds and constraints in stubs (#333)
diff --git a/pyanalyze/typeshed.py b/pyanalyze/typeshed.py
index e59c082..9b19c73 100644
--- a/pyanalyze/typeshed.py
+++ b/pyanalyze/typeshed.py
@@ -525,18 +525,18 @@ class TypeshedFinder:
             if obj is Sized:
                 return "typing.Sized"
         try:
-            module = obj.__module__
-            if module is None:
-                module = "builtins"
+            module_name = obj.__module__
+            if module_name is None:
+                module_name = "builtins"
             # Objects like io.BytesIO are technically in the _io module,
             # but typeshed puts them in io, which at runtime just re-exports
             # them.
-            if module == "_io":
-                module = "io"
-            fq_name = ".".join([module, obj.__qualname__])
+            if module_name == "_io":
+                module_name = "io"
+            fq_name = ".".join([module_name, obj.__qualname__])
             # Avoid looking for stubs we won't find anyway.
-            if any(not part.isidentifier() for part in fq_name.split(".")):
-                self.log("Ignoring non-identifier name", fq_name)
+            if not _obj_from_qualname_is(module_name, obj.__qualname__, obj):
+                self.log("Ignoring invalid name", fq_name)
                 return None
             return _TYPING_ALIASES.get(fq_name, fq_name)
         except (AttributeError, TypeError):
@@ -781,3 +781,15 @@ class TypeshedFinder:
         else:
             self.log("Ignoring info", info)
             return AnyValue(AnySource.inference)
+
+
+def _obj_from_qualname_is(module_name: str, qualname: str, obj: object) -> bool:
+    try:
+        __import__(module_name)
+        mod = sys.modules[module_name]
+        actual = mod
+        for piece in qualname.split("."):
+            actual = getattr(actual, piece)
+        return obj is actual
+    except Exception:
+        return False


Test patch:
diff --git a/pyanalyze/test_typeshed.py b/pyanalyze/test_typeshed.py
index 9aba670..1f5b69a 100644
--- a/pyanalyze/test_typeshed.py
+++ b/pyanalyze/test_typeshed.py
@@ -10,8 +10,9 @@ import tempfile
 import time
 from typeshed_client import Resolver, get_search_context
 import typing
-from typing import Dict, Generic, List, TypeVar, NewType, Union
+from typing import Container, Dict, Generic, List, TypeVar, NewType, Union
 from urllib.error import HTTPError
+import urllib.parse
 
 from .test_config import TestConfig
 from .test_name_check_visitor import TestNameCheckVisitorBase
@@ -377,6 +378,26 @@ class TestGetGenericBases:
             io.BytesIO,
         )
 
+    def test_parse_result(self):
+        self.check(
+            {
+                collections.abc.Iterable: [AnyValue(AnySource.generic_argument)],
+                collections.abc.Reversible: [AnyValue(AnySource.generic_argument)],
+                collections.abc.Container: [AnyValue(AnySource.generic_argument)],
+                collections.abc.Collection: [AnyValue(AnySource.generic_argument)],
+                collections.abc.Sequence: [AnyValue(AnySource.generic_argument)],
+                urllib.parse.ParseResult: [],
+                urllib.parse._ParseResultBase: [],
+                "urllib.parse._ResultMixinBase": [TypedValue(str)],
+                tuple: [AnyValue(AnySource.generic_argument)],
+                urllib.parse._ResultMixinStr: [],
+                urllib.parse._NetlocResultMixinBase: [TypedValue(str)],
+                urllib.parse._NetlocResultMixinStr: [],
+                urllib.parse._ResultMixinStr: [],
+            },
+            urllib.parse.ParseResult,
+        )
+
 
 class TestAttribute:
     def test_basic(self) -> None: