Skip to content

Commit

Permalink
Support ParamSpec variables in type aliases (#14159)
Browse files Browse the repository at this point in the history
Fixes #11855 
Fixes #7084
Fixes #10445
Should fix #4987

After thinking about this for some time, it looks like the best way to
implement this is by switching type aliases from unbound to bound type
variables. Then I can essentially simply share (or copy in one small
place, to avoid cyclic imports) all the logic that currently exists for
`ParamSpec` and `Concatenate` in `expand_type()` etc.

This will also address a big piece of tech debt, and will get some
benefits (almost) for free, such as checking bounds/values for alias
type variables, and much tighter handling of unbound type variables.

Note that in this PR I change logic for emitting some errors, I try to
avoid showing multiple errors for the same location/reason. But this is
not an essential part of this PR (it is just some test cases would
otherwise fail with even more error messages), I can reconsider if there
are objections.
  • Loading branch information
ilevkivskyi authored Nov 24, 2022
1 parent 04d44c1 commit 13bd201
Show file tree
Hide file tree
Showing 24 changed files with 554 additions and 228 deletions.
8 changes: 2 additions & 6 deletions docs/source/generics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -916,9 +916,5 @@ defeating the purpose of using aliases. Example:
OIntVec = Optional[Vec[int]]
.. note::

A type alias does not define a new type. For generic type aliases
this means that variance of type variables used for alias definition does not
apply to aliases. A parameterized generic alias is treated simply as an original
type with the corresponding type variables substituted.
Using type variable bounds or values in generic aliases, has the same effect
as in generic classes/functions.
2 changes: 1 addition & 1 deletion mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -7115,7 +7115,7 @@ def visit_uninhabited_type(self, t: UninhabitedType) -> Type:
return t

def visit_type_alias_type(self, t: TypeAliasType) -> Type:
# Target of the alias cannot by an ambiguous <nothing>, so we just
# Target of the alias cannot be an ambiguous <nothing>, so we just
# replace the arguments.
return t.copy_modified(args=[a.accept(self) for a in t.args])

Expand Down
6 changes: 2 additions & 4 deletions mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -3854,10 +3854,8 @@ def visit_type_application(self, tapp: TypeApplication) -> Type:
There are two different options here, depending on whether expr refers
to a type alias or directly to a generic class. In the first case we need
to use a dedicated function typeanal.expand_type_aliases. This
is due to the fact that currently type aliases machinery uses
unbound type variables, while normal generics use bound ones;
see TypeAlias docstring for more details.
to use a dedicated function typeanal.expand_type_alias(). This
is due to some differences in how type arguments are applied and checked.
"""
if isinstance(tapp.expr, RefExpr) and isinstance(tapp.expr.node, TypeAlias):
# Subscription of a (generic) alias in runtime context, expand the alias.
Expand Down
4 changes: 2 additions & 2 deletions mypy/erasetype.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ def visit_param_spec(self, t: ParamSpecType) -> Type:
return t

def visit_type_alias_type(self, t: TypeAliasType) -> Type:
# Type alias target can't contain bound type variables, so
# it is safe to just erase the arguments.
# Type alias target can't contain bound type variables (not bound by the type
# alias itself), so it is safe to just erase the arguments.
return t.copy_modified(args=[a.accept(self) for a in t.args])


Expand Down
34 changes: 5 additions & 29 deletions mypy/expandtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
NoneType,
Overloaded,
Parameters,
ParamSpecFlavor,
ParamSpecType,
PartialType,
ProperType,
Expand All @@ -34,6 +33,7 @@
UninhabitedType,
UnionType,
UnpackType,
expand_param_spec,
get_proper_type,
)
from mypy.typevartuples import (
Expand Down Expand Up @@ -212,32 +212,8 @@ def visit_param_spec(self, t: ParamSpecType) -> Type:
# TODO: what does prefix mean in this case?
# TODO: why does this case even happen? Instances aren't plural.
return repl
elif isinstance(repl, ParamSpecType):
return repl.copy_modified(
flavor=t.flavor,
prefix=t.prefix.copy_modified(
arg_types=t.prefix.arg_types + repl.prefix.arg_types,
arg_kinds=t.prefix.arg_kinds + repl.prefix.arg_kinds,
arg_names=t.prefix.arg_names + repl.prefix.arg_names,
),
)
elif isinstance(repl, Parameters) or isinstance(repl, CallableType):
# if the paramspec is *P.args or **P.kwargs:
if t.flavor != ParamSpecFlavor.BARE:
assert isinstance(repl, CallableType), "Should not be able to get here."
# Is this always the right thing to do?
param_spec = repl.param_spec()
if param_spec:
return param_spec.with_flavor(t.flavor)
else:
return repl
else:
return Parameters(
t.prefix.arg_types + repl.arg_types,
t.prefix.arg_kinds + repl.arg_kinds,
t.prefix.arg_names + repl.arg_names,
variables=[*t.prefix.variables, *repl.variables],
)
elif isinstance(repl, (ParamSpecType, Parameters, CallableType)):
return expand_param_spec(t, repl)
else:
# TODO: should this branch be removed? better not to fail silently
return repl
Expand Down Expand Up @@ -446,8 +422,8 @@ def visit_type_type(self, t: TypeType) -> Type:
return TypeType.make_normalized(item)

def visit_type_alias_type(self, t: TypeAliasType) -> Type:
# Target of the type alias cannot contain type variables,
# so we just expand the arguments.
# Target of the type alias cannot contain type variables (not bound by the type
# alias itself), so we just expand the arguments.
return t.copy_modified(args=self.expand_types(t.args))

def expand_types(self, types: Iterable[Type]) -> list[Type]:
Expand Down
2 changes: 2 additions & 0 deletions mypy/fixup.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ def visit_var(self, v: Var) -> None:

def visit_type_alias(self, a: TypeAlias) -> None:
a.target.accept(self.type_fixer)
for v in a.alias_tvars:
v.accept(self.type_fixer)


class TypeFixer(TypeVisitor[None]):
Expand Down
5 changes: 5 additions & 0 deletions mypy/mixedtraverser.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
class MixedTraverserVisitor(TraverserVisitor, TypeTraverserVisitor):
"""Recursive traversal of both Node and Type objects."""

def __init__(self) -> None:
self.in_type_alias_expr = False

# Symbol nodes

def visit_var(self, var: Var) -> None:
Expand All @@ -45,7 +48,9 @@ def visit_class_def(self, o: ClassDef) -> None:

def visit_type_alias_expr(self, o: TypeAliasExpr) -> None:
super().visit_type_alias_expr(o)
self.in_type_alias_expr = True
o.type.accept(self)
self.in_type_alias_expr = False

def visit_type_var_expr(self, o: TypeVarExpr) -> None:
super().visit_type_var_expr(o)
Expand Down
31 changes: 18 additions & 13 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Callable,
Dict,
Iterator,
List,
Optional,
Sequence,
Tuple,
Expand Down Expand Up @@ -2546,7 +2547,7 @@ class TypeAliasExpr(Expression):

# The target type.
type: mypy.types.Type
# Names of unbound type variables used to define the alias
# Names of type variables used to define the alias
tvars: list[str]
# Whether this alias was defined in bare form. Used to distinguish
# between
Expand All @@ -2559,7 +2560,7 @@ class TypeAliasExpr(Expression):
def __init__(self, node: TypeAlias) -> None:
super().__init__()
self.type = node.target
self.tvars = node.alias_tvars
self.tvars = [v.name for v in node.alias_tvars]
self.no_args = node.no_args
self.node = node

Expand Down Expand Up @@ -3309,10 +3310,9 @@ class TypeAlias(SymbolNode):
class-valued attributes. See SemanticAnalyzerPass2.check_and_set_up_type_alias
for details.
Aliases can be generic. Currently, mypy uses unbound type variables for
generic aliases and identifies them by name. Essentially, type aliases
work as macros that expand textually. The definition and expansion rules are
following:
Aliases can be generic. We use bound type variables for generic aliases, similar
to classes. Essentially, type aliases work as macros that expand textually.
The definition and expansion rules are following:
1. An alias targeting a generic class without explicit variables act as
the given class (this doesn't apply to TypedDict, Tuple and Callable, which
Expand Down Expand Up @@ -3363,11 +3363,11 @@ def f(x: B[T]) -> T: ... # without T, Any would be used here
Meaning of other fields:
target: The target type. For generic aliases contains unbound type variables
as nested types.
target: The target type. For generic aliases contains bound type variables
as nested types (currently TypeVar and ParamSpec are supported).
_fullname: Qualified name of this type alias. This is used in particular
to track fine grained dependencies from aliases.
alias_tvars: Names of unbound type variables used to define this alias.
alias_tvars: Type variables used to define this alias.
normalized: Used to distinguish between `A = List`, and `A = list`. Both
are internally stored using `builtins.list` (because `typing.List` is
itself an alias), while the second cannot be subscripted because of
Expand Down Expand Up @@ -3396,7 +3396,7 @@ def __init__(
line: int,
column: int,
*,
alias_tvars: list[str] | None = None,
alias_tvars: list[mypy.types.TypeVarLikeType] | None = None,
no_args: bool = False,
normalized: bool = False,
eager: bool = False,
Expand Down Expand Up @@ -3446,12 +3446,16 @@ def name(self) -> str:
def fullname(self) -> str:
return self._fullname

@property
def has_param_spec_type(self) -> bool:
return any(isinstance(v, mypy.types.ParamSpecType) for v in self.alias_tvars)

def serialize(self) -> JsonDict:
data: JsonDict = {
".class": "TypeAlias",
"fullname": self._fullname,
"target": self.target.serialize(),
"alias_tvars": self.alias_tvars,
"alias_tvars": [v.serialize() for v in self.alias_tvars],
"no_args": self.no_args,
"normalized": self.normalized,
"line": self.line,
Expand All @@ -3466,7 +3470,8 @@ def accept(self, visitor: NodeVisitor[T]) -> T:
def deserialize(cls, data: JsonDict) -> TypeAlias:
assert data[".class"] == "TypeAlias"
fullname = data["fullname"]
alias_tvars = data["alias_tvars"]
alias_tvars = [mypy.types.deserialize_type(v) for v in data["alias_tvars"]]
assert all(isinstance(t, mypy.types.TypeVarLikeType) for t in alias_tvars)
target = mypy.types.deserialize_type(data["target"])
no_args = data["no_args"]
normalized = data["normalized"]
Expand All @@ -3477,7 +3482,7 @@ def deserialize(cls, data: JsonDict) -> TypeAlias:
fullname,
line,
column,
alias_tvars=alias_tvars,
alias_tvars=cast(List[mypy.types.TypeVarLikeType], alias_tvars),
no_args=no_args,
normalized=normalized,
)
Expand Down
Loading

0 comments on commit 13bd201

Please sign in to comment.