Skip to content

Commit

Permalink
Stop errors on use of TypeVar defaults (#791)
Browse files Browse the repository at this point in the history
  • Loading branch information
JelleZijlstra authored Jul 12, 2024
1 parent a93dcb0 commit 7eb656d
Show file tree
Hide file tree
Showing 7 changed files with 52 additions and 9 deletions.
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions pyanalyze/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]
Expand All @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions pyanalyze/name_check_visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions pyanalyze/stubs/_pyanalyze_tests-stubs/typevar.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
8 changes: 8 additions & 0 deletions pyanalyze/test_typeshed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions pyanalyze/value.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down

0 comments on commit 7eb656d

Please sign in to comment.