Repository: quora/pyanalyze
Pull request #791 (2024-07-12 23:05:25)

Stop errors on use of TypeVar defaults


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index fa105f3..543b01d 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,9 @@
 
 ## Unreleased
 
+- Fix error on use of TypeVar defaults in stubs (PEP 696). The
+  default is still ignored, but now the TypeVar is treated as
+  if it has no default. (#791)
 - Add new error code `unsafe_comparison`, which gets triggered
   when two values are compared that can never be equal. (#784)
 - Improve representation of known module, function, and type objects
diff --git a/pyanalyze/annotations.py b/pyanalyze/annotations.py
index a4939fa..09a7c5a 100644
--- a/pyanalyze/annotations.py
+++ b/pyanalyze/annotations.py
@@ -50,7 +50,14 @@ from typing import (
 
 import qcore
 import typing_extensions
-from typing_extensions import Literal, ParamSpec, TypedDict, get_args, get_origin
+from typing_extensions import (
+    Literal,
+    NoDefault,
+    ParamSpec,
+    TypedDict,
+    get_args,
+    get_origin,
+)
 
 from pyanalyze.annotated_types import get_annotated_types_extension
 
@@ -561,7 +568,11 @@ def make_type_var_value(tv: TypeVarLike, ctx: Context) -> TypeVarValue:
         )
     else:
         constraints = ()
-    return TypeVarValue(tv, bound=bound, constraints=constraints)
+    if hasattr(tv, "__default__") and tv.__default__ is not NoDefault:
+        default = _type_from_runtime(tv.__default__, ctx)
+    else:
+        default = None
+    return TypeVarValue(tv, bound=bound, constraints=constraints, default=default)
 
 
 def _callable_args_from_runtime(
@@ -1087,17 +1098,21 @@ class _Visitor(ast.NodeVisitor):
             constraints = []
             for arg_value in arg_values[1:]:
                 constraints.append(_type_from_value(arg_value, self.ctx))
-            bound = None
+            bound = default = None
             for name, kwarg_value in kwarg_values:
-                if name in ("covariant", "contravariant"):
+                if name in ("covariant", "contravariant", "infer_variance"):
                     continue
                 elif name == "bound":
                     bound = _type_from_value(kwarg_value, self.ctx)
+                elif name == "default":
+                    default = _type_from_value(kwarg_value, self.ctx)
                 else:
                     self.ctx.show_error(f"Unrecognized TypeVar kwarg {name}", node=node)
                     return None
             tv = TypeVar(name_val.val)
-            return TypeVarValue(tv, bound, tuple(constraints))
+            return TypeVarValue(
+                tv, bound=bound, constraints=tuple(constraints), default=default
+            )
         elif is_typing_name(func.val, "ParamSpec"):
             arg_values = [self.visit(arg) for arg in node.args]
             kwarg_values = [(kw.arg, self.visit(kw.value)) for kw in node.keywords]
@@ -1113,6 +1128,7 @@ class _Visitor(ast.NodeVisitor):
                 )
                 return None
             for name, _ in kwarg_values:
+                # TODO support defaults
                 self.ctx.show_error(f"Unrecognized ParamSpec kwarg {name}", node=node)
                 return None
             tv = ParamSpec(name_val.val)
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index 205829d..20ad1ef 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -4834,21 +4834,29 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
             return set_value
 
         def visit_TypeVar(self, node: ast.TypeVar) -> Value:
-            bound = constraints = None
+            bound = constraints = default = None
             if node.bound is not None:
                 if isinstance(node.bound, ast.Tuple):
                     constraints = [self.visit(elt) for elt in node.bound.elts]
                 else:
                     bound = self.visit(node.bound)
+            if sys.version_info >= (3, 13):
+                if node.default is not None:
+                    default = self.visit(node.default)
             tv = TypeVar(node.name)
             typevar = TypeVarValue(
                 tv,
-                type_from_value(bound, self, node) if bound is not None else None,
-                (
+                bound=type_from_value(bound, self, node) if bound is not None else None,
+                constraints=(
                     tuple(type_from_value(c, self, node) for c in constraints)
                     if constraints is not None
                     else ()
                 ),
+                default=(
+                    type_from_value(default, self, node)
+                    if default is not None
+                    else None
+                ),
             )
             self._set_name_in_scope(node.name, node, typevar)
             return typevar
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 985726a..82d4c18 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -2189,6 +2189,7 @@ class TypeVarValue(Value):
 
     typevar: TypeVarLike
     bound: Optional[Value] = None
+    default: Optional[Value] = None  # unsupported
     constraints: Sequence[Value] = ()
     is_paramspec: bool = False
     is_typevartuple: bool = False  # unsupported
diff --git a/setup.py b/setup.py
index 3337557..eb0d1a8 100644
--- a/setup.py
+++ b/setup.py
@@ -34,7 +34,7 @@ if __name__ == "__main__":
             "qcore>=0.5.1",
             "ast_decompiler>=0.4.0",
             "typeshed_client>=2.1.0",
-            "typing_extensions>=4.1.0",
+            "typing_extensions>=4.12.0",
             "codemod",
             "tomli>=1.1.0",
         ],


Test patch:
diff --git a/pyanalyze/stubs/_pyanalyze_tests-stubs/typevar.pyi b/pyanalyze/stubs/_pyanalyze_tests-stubs/typevar.pyi
new file mode 100644
index 0000000..aaa743e
--- /dev/null
+++ b/pyanalyze/stubs/_pyanalyze_tests-stubs/typevar.pyi
@@ -0,0 +1,7 @@
+from typing import TypeVar
+
+# Just testing that the presence of a default doesn't
+# completely break type checking.
+_T = TypeVar("_T", default=None)
+
+def f(x: _T) -> _T: ...
diff --git a/pyanalyze/test_typeshed.py b/pyanalyze/test_typeshed.py
index 017e89f..f02f146 100644
--- a/pyanalyze/test_typeshed.py
+++ b/pyanalyze/test_typeshed.py
@@ -273,6 +273,14 @@ class TestBundledStubs(TestNameCheckVisitorBase):
             two_pos_only(x=1)  # E: incompatible_call
             two_pos_only(1, y="x")  # E: incompatible_call
 
+    @assert_passes()
+    def test_typevar_with_default(self):
+        def capybara(x: int):
+            from _pyanalyze_tests.typevar import f
+            from typing_extensions import assert_type
+
+            assert_type(f(x), int)
+
     def test_typeddict(self):
         tsf = TypeshedFinder.make(Checker(), TEST_OPTIONS, verbose=True)
         mod = "_pyanalyze_tests.typeddict"