Skip to content

Commit 21e5a57

Browse files
authored
[ty] Support typevar-specialized dynamic types in generic type aliases (#21730)
## Summary For a type alias like the one below, where `UnknownClass` is something with a dynamic type, we previously lost track of the fact that this dynamic type was explicitly specialized *with a type variable*. If that alias is then later explicitly specialized itself (`MyAlias[int]`), we would miscount the number of legacy type variables and emit a `invalid-type-arguments` diagnostic ([playground](https://play.ty.dev/886ae6cc-86c3-4304-a365-510d29211f85)). ```py T = TypeVar("T") MyAlias: TypeAlias = UnknownClass[T] | None ``` The solution implemented here is not pretty, but we can hopefully get rid of it via astral-sh/ty#1711. Also, once we properly support `ParamSpec` and `Concatenate`, we should be able to remove some of this code. This addresses many of the `invalid-type-arguments` false-positives in astral-sh/ty#1685. With this change, there are still some diagnostics of this type left. Instead of implementing even more (rather sophisticated) workarounds for these cases as well, it might be much easier to wait for full `ParamSpec`/`Concatenate` support and then try again. A disadvantage of this implementation is that we lose track of some `@Todo` types and replace them with `Unknown`. We could spend more effort and try to preserve them, but I'm unsure if this is the best use of our time right now. ## Test Plan New Markdown tests.
1 parent f4e4229 commit 21e5a57

File tree

9 files changed

+285
-49
lines changed

9 files changed

+285
-49
lines changed

crates/ty_python_semantic/resources/mdtest/pep613_type_aliases.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,101 @@ def _(x: MyAlias):
149149
reveal_type(x) # revealed: int | list[str] | set[str]
150150
```
151151

152+
## Typevar-specialized dynamic types
153+
154+
We still recognize type aliases as being generic if a symbol of a dynamic type is explicitly
155+
specialized with a type variable:
156+
157+
```py
158+
from typing import TypeVar, TypeAlias
159+
160+
from unknown_module import UnknownClass # type: ignore
161+
162+
T = TypeVar("T")
163+
164+
MyAlias1: TypeAlias = UnknownClass[T] | None
165+
166+
def _(a: MyAlias1[int]):
167+
reveal_type(a) # revealed: Unknown | None
168+
```
169+
170+
This also works with multiple type arguments:
171+
172+
```py
173+
U = TypeVar("U")
174+
V = TypeVar("V")
175+
176+
MyAlias2: TypeAlias = UnknownClass[T, U, V] | int
177+
178+
def _(a: MyAlias2[int, str, bytes]):
179+
reveal_type(a) # revealed: Unknown | int
180+
```
181+
182+
If we specialize with fewer or more type arguments than expected, we emit an error:
183+
184+
```py
185+
def _(
186+
# error: [invalid-type-arguments] "No type argument provided for required type variable `V`"
187+
too_few: MyAlias2[int, str],
188+
# error: [invalid-type-arguments] "Too many type arguments: expected 3, got 4"
189+
too_many: MyAlias2[int, str, bytes, float],
190+
): ...
191+
```
192+
193+
We can also reference these type aliases from other type aliases:
194+
195+
```py
196+
MyAlias3: TypeAlias = MyAlias1[str] | MyAlias2[int, str, bytes]
197+
198+
def _(c: MyAlias3):
199+
reveal_type(c) # revealed: Unknown | None | int
200+
```
201+
202+
Here, we test some other cases that might involve `@Todo` types, which also need special handling:
203+
204+
```py
205+
from typing_extensions import Callable, Concatenate, TypeAliasType
206+
207+
MyAlias4: TypeAlias = Callable[Concatenate[dict[str, T], ...], list[U]]
208+
209+
def _(c: MyAlias4[int, str]):
210+
# TODO: should be (int, / ...) -> str
211+
reveal_type(c) # revealed: Unknown
212+
213+
T = TypeVar("T")
214+
215+
MyList = TypeAliasType("MyList", list[T], type_params=(T,))
216+
217+
MyAlias5 = Callable[[MyList[T]], int]
218+
219+
def _(c: MyAlias5[int]):
220+
# TODO: should be (list[int], /) -> int
221+
reveal_type(c) # revealed: (Unknown, /) -> int
222+
223+
K = TypeVar("K")
224+
V = TypeVar("V")
225+
226+
MyDict = TypeAliasType("MyDict", dict[K, V], type_params=(K, V))
227+
228+
MyAlias6 = Callable[[MyDict[K, V]], int]
229+
230+
def _(c: MyAlias6[str, bytes]):
231+
# TODO: should be (dict[str, bytes], /) -> int
232+
reveal_type(c) # revealed: (Unknown, /) -> int
233+
234+
ListOrDict: TypeAlias = MyList[T] | dict[str, T]
235+
236+
def _(x: ListOrDict[int]):
237+
# TODO: should be list[int] | dict[str, int]
238+
reveal_type(x) # revealed: Unknown | dict[str, int]
239+
240+
MyAlias7: TypeAlias = Callable[Concatenate[T, ...], None]
241+
242+
def _(c: MyAlias7[int]):
243+
# TODO: should be (int, / ...) -> None
244+
reveal_type(c) # revealed: Unknown
245+
```
246+
152247
## Imported
153248

154249
`alias.py`:

crates/ty_python_semantic/resources/mdtest/pep695_type_aliases.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,8 @@ T = TypeVar("T")
223223
IntAndT = TypeAliasType("IntAndT", tuple[int, T], type_params=(T,))
224224

225225
def f(x: IntAndT[str]) -> None:
226-
reveal_type(x) # revealed: @Todo(Generic manual PEP-695 type alias)
226+
# TODO: This should be `tuple[int, str]`
227+
reveal_type(x) # revealed: Unknown
227228
```
228229

229230
### Error cases

crates/ty_python_semantic/src/types.rs

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,7 @@ impl<'db> DataclassParams<'db> {
763763
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, salsa::Update, get_size2::GetSize)]
764764
pub enum Type<'db> {
765765
/// The dynamic type: a statically unknown set of values
766-
Dynamic(DynamicType),
766+
Dynamic(DynamicType<'db>),
767767
/// The empty set of values
768768
Never,
769769
/// A specific function object
@@ -889,7 +889,10 @@ impl<'db> Type<'db> {
889889
}
890890

891891
pub const fn is_unknown(&self) -> bool {
892-
matches!(self, Type::Dynamic(DynamicType::Unknown))
892+
matches!(
893+
self,
894+
Type::Dynamic(DynamicType::Unknown | DynamicType::UnknownGeneric(_))
895+
)
893896
}
894897

895898
pub(crate) const fn is_never(&self) -> bool {
@@ -959,7 +962,10 @@ impl<'db> Type<'db> {
959962

960963
pub(crate) fn is_todo(&self) -> bool {
961964
self.as_dynamic().is_some_and(|dynamic| match dynamic {
962-
DynamicType::Any | DynamicType::Unknown | DynamicType::Divergent(_) => false,
965+
DynamicType::Any
966+
| DynamicType::Unknown
967+
| DynamicType::UnknownGeneric(_)
968+
| DynamicType::Divergent(_) => false,
963969
DynamicType::Todo(_) | DynamicType::TodoStarredExpression | DynamicType::TodoUnpack => {
964970
true
965971
}
@@ -1146,7 +1152,7 @@ impl<'db> Type<'db> {
11461152
}
11471153
}
11481154

1149-
pub(crate) const fn as_dynamic(self) -> Option<DynamicType> {
1155+
pub(crate) const fn as_dynamic(self) -> Option<DynamicType<'db>> {
11501156
match self {
11511157
Type::Dynamic(dynamic_type) => Some(dynamic_type),
11521158
_ => None,
@@ -1160,7 +1166,7 @@ impl<'db> Type<'db> {
11601166
}
11611167
}
11621168

1163-
pub(crate) const fn expect_dynamic(self) -> DynamicType {
1169+
pub(crate) const fn expect_dynamic(self) -> DynamicType<'db> {
11641170
self.as_dynamic().expect("Expected a Type::Dynamic variant")
11651171
}
11661172

@@ -7851,14 +7857,18 @@ impl<'db> Type<'db> {
78517857
typevars: &mut FxOrderSet<BoundTypeVarInstance<'db>>,
78527858
visitor: &FindLegacyTypeVarsVisitor<'db>,
78537859
) {
7860+
let is_matching_typevar = |bound_typevar: &BoundTypeVarInstance<'db>| {
7861+
matches!(
7862+
bound_typevar.typevar(db).kind(db),
7863+
TypeVarKind::Legacy | TypeVarKind::TypingSelf | TypeVarKind::ParamSpec
7864+
) && binding_context.is_none_or(|binding_context| {
7865+
bound_typevar.binding_context(db) == BindingContext::Definition(binding_context)
7866+
})
7867+
};
7868+
78547869
match self {
78557870
Type::TypeVar(bound_typevar) => {
7856-
if matches!(
7857-
bound_typevar.typevar(db).kind(db),
7858-
TypeVarKind::Legacy | TypeVarKind::TypingSelf | TypeVarKind::ParamSpec
7859-
) && binding_context.is_none_or(|binding_context| {
7860-
bound_typevar.binding_context(db) == BindingContext::Definition(binding_context)
7861-
}) {
7871+
if is_matching_typevar(&bound_typevar) {
78627872
typevars.insert(bound_typevar);
78637873
}
78647874
}
@@ -7998,6 +8008,14 @@ impl<'db> Type<'db> {
79988008
}
79998009
},
80008010

8011+
Type::Dynamic(DynamicType::UnknownGeneric(generic_context)) => {
8012+
for variable in generic_context.variables(db) {
8013+
if is_matching_typevar(&variable) {
8014+
typevars.insert(variable);
8015+
}
8016+
}
8017+
}
8018+
80018019
Type::Dynamic(_)
80028020
| Type::Never
80038021
| Type::AlwaysTruthy
@@ -8029,6 +8047,26 @@ impl<'db> Type<'db> {
80298047
}
80308048
}
80318049

8050+
/// Bind all unbound legacy type variables to the given context and then
8051+
/// add all legacy typevars to the provided set.
8052+
pub(crate) fn bind_and_find_all_legacy_typevars(
8053+
self,
8054+
db: &'db dyn Db,
8055+
binding_context: Option<Definition<'db>>,
8056+
variables: &mut FxOrderSet<BoundTypeVarInstance<'db>>,
8057+
) {
8058+
self.apply_type_mapping(
8059+
db,
8060+
&TypeMapping::BindLegacyTypevars(
8061+
binding_context
8062+
.map(BindingContext::Definition)
8063+
.unwrap_or(BindingContext::Synthetic),
8064+
),
8065+
TypeContext::default(),
8066+
)
8067+
.find_legacy_typevars(db, None, variables);
8068+
}
8069+
80328070
/// Replace default types in parameters of callables with `Unknown`.
80338071
pub(crate) fn replace_parameter_defaults(self, db: &'db dyn Db) -> Type<'db> {
80348072
self.apply_type_mapping(
@@ -8177,7 +8215,7 @@ impl<'db> Type<'db> {
81778215
Self::SpecialForm(special_form) => special_form.definition(db),
81788216
Self::Never => Type::SpecialForm(SpecialFormType::Never).definition(db),
81798217
Self::Dynamic(DynamicType::Any) => Type::SpecialForm(SpecialFormType::Any).definition(db),
8180-
Self::Dynamic(DynamicType::Unknown) => Type::SpecialForm(SpecialFormType::Unknown).definition(db),
8218+
Self::Dynamic(DynamicType::Unknown | DynamicType::UnknownGeneric(_)) => Type::SpecialForm(SpecialFormType::Unknown).definition(db),
81818219
Self::AlwaysTruthy => Type::SpecialForm(SpecialFormType::AlwaysTruthy).definition(db),
81828220
Self::AlwaysFalsy => Type::SpecialForm(SpecialFormType::AlwaysFalsy).definition(db),
81838221

@@ -8839,11 +8877,18 @@ pub struct DivergentType {
88398877
impl get_size2::GetSize for DivergentType {}
88408878

88418879
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)]
8842-
pub enum DynamicType {
8880+
pub enum DynamicType<'db> {
88438881
/// An explicitly annotated `typing.Any`
88448882
Any,
88458883
/// An unannotated value, or a dynamic type resulting from an error
88468884
Unknown,
8885+
/// Similar to `Unknown`, this represents a dynamic type that has been explicitly specialized
8886+
/// with legacy typevars, e.g. `UnknownClass[T]`, where `T` is a legacy typevar. We keep track
8887+
/// of the type variables in the generic context in case this type is later specialized again.
8888+
///
8889+
/// TODO: Once we implement <https://github.com/astral-sh/ty/issues/1711>, this variant might
8890+
/// not be needed anymore.
8891+
UnknownGeneric(GenericContext<'db>),
88478892
/// Temporary type for symbols that can't be inferred yet because of missing implementations.
88488893
///
88498894
/// This variant should eventually be removed once ty is spec-compliant.
@@ -8862,7 +8907,7 @@ pub enum DynamicType {
88628907
Divergent(DivergentType),
88638908
}
88648909

8865-
impl DynamicType {
8910+
impl DynamicType<'_> {
88668911
fn normalized(self) -> Self {
88678912
if matches!(self, Self::Divergent(_)) {
88688913
self
@@ -8880,11 +8925,11 @@ impl DynamicType {
88808925
}
88818926
}
88828927

8883-
impl std::fmt::Display for DynamicType {
8928+
impl std::fmt::Display for DynamicType<'_> {
88848929
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88858930
match self {
88868931
DynamicType::Any => f.write_str("Any"),
8887-
DynamicType::Unknown => f.write_str("Unknown"),
8932+
DynamicType::Unknown | DynamicType::UnknownGeneric(_) => f.write_str("Unknown"),
88888933
// `DynamicType::Todo`'s display should be explicit that is not a valid display of
88898934
// any other type
88908935
DynamicType::Todo(todo) => write!(f, "@Todo{todo}"),

crates/ty_python_semantic/src/types/bound_super.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ impl<'db> BoundSuperError<'db> {
172172

173173
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, get_size2::GetSize)]
174174
pub enum SuperOwnerKind<'db> {
175-
Dynamic(DynamicType),
175+
Dynamic(DynamicType<'db>),
176176
Class(ClassType<'db>),
177177
Instance(NominalInstanceType<'db>),
178178
}

crates/ty_python_semantic/src/types/class_base.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use crate::types::{
1818
/// automatically construct the default specialization for that class.
1919
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, salsa::Update, get_size2::GetSize)]
2020
pub enum ClassBase<'db> {
21-
Dynamic(DynamicType),
21+
Dynamic(DynamicType<'db>),
2222
Class(ClassType<'db>),
2323
/// Although `Protocol` is not a class in typeshed's stubs, it is at runtime,
2424
/// and can appear in the MRO of a class.
@@ -62,7 +62,7 @@ impl<'db> ClassBase<'db> {
6262
match self {
6363
ClassBase::Class(class) => class.name(db),
6464
ClassBase::Dynamic(DynamicType::Any) => "Any",
65-
ClassBase::Dynamic(DynamicType::Unknown) => "Unknown",
65+
ClassBase::Dynamic(DynamicType::Unknown | DynamicType::UnknownGeneric(_)) => "Unknown",
6666
ClassBase::Dynamic(
6767
DynamicType::Todo(_) | DynamicType::TodoUnpack | DynamicType::TodoStarredExpression,
6868
) => "@Todo",

0 commit comments

Comments
 (0)