diff --git a/docs/changelog.md b/docs/changelog.md index fa105f39..543b01dd 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 a4939fa2..09a7c5a6 100644 --- a/pyanalyze/annotations.py +++ b/pyanalyze/annotations.py @@ -50,7 +50,14 @@ 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 @@ def visit_Call(self, node: ast.Call) -> Optional[Value]: 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 @@ def visit_Call(self, node: ast.Call) -> Optional[Value]: ) 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 205829dc..20ad1ef1 100644 --- a/pyanalyze/name_check_visitor.py +++ b/pyanalyze/name_check_visitor.py @@ -4834,21 +4834,29 @@ def visit_TypeAlias(self, node: ast.TypeAlias) -> Value: 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/stubs/_pyanalyze_tests-stubs/typevar.pyi b/pyanalyze/stubs/_pyanalyze_tests-stubs/typevar.pyi new file mode 100644 index 00000000..aaa743e9 --- /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 017e89fb..f02f1464 100644 --- a/pyanalyze/test_typeshed.py +++ b/pyanalyze/test_typeshed.py @@ -273,6 +273,14 @@ def capybara(): 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" diff --git a/pyanalyze/value.py b/pyanalyze/value.py index 985726a4..82d4c185 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 33375570..eb0d1a88 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ "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", ],