Skip to content

Commit 192c37d

Browse files
authored
[ty] tighten up handling of subscripts in type expressions (#21503)
## Summary Get rid of the catch-all todo type from subscripting a base type we haven't implemented handling for yet in a type expression, and turn it into a diagnostic instead. Handle a few more cases explicitly, to avoid false positives from the above change: 1. Subscripting any dynamic type (not just a todo type) in a type expression should just result in that same dynamic type. This is important for gradual guarantee, and matches other type checkers. 2. Subscripting a generic alias may be an error or not, depending whether the specialization itself contains typevars. Don't try to handle this yet (it should be handled in a later PR for specializing generic non-PEP695 type aliases), just use a dedicated todo type for it. 3. Add a temporary todo branch to avoid false positives from string PEP 613 type aliases. This can be removed in the next PR, with PEP 613 type alias support. ## Test Plan Adjusted mdtests, ecosystem. All new diagnostics in conformance suite are supposed to be diagnostics, so this PR is a strict improvement there. New diagnostics in the ecosystem are surfacing cases where we already don't understand an annotation, but now we emit a diagnostic about it. They are mostly intentional choices. Analysis of particular cases: * `attrs`, `bokeh`, `django-stubs`, `dulwich`, `ibis`, `kornia`, `mitmproxy`, `mongo-python-driver`, `mypy`, `pandas`, `poetry`, `prefect`, `pydantic`, `pytest`, `scrapy`, `trio`, `werkzeug`, and `xarray` are all cases where under `from __future__ import annotations` or Python 3.14 deferred-annotations semantics, we follow normal name-scoping rules, whereas some other type checkers prefer global names over local names. This means we don't like it if e.g. you have a class with a method or attribute named `type` or `tuple`, and you also try to use `type` or `tuple` in method/attribute annotations of that class. This PR isn't changing those semantics, just revealing them in more cases where previously we just silently fell back to `Unknown`. I think failing with a diagnostic (so authors can alias names as needed to avoid relying on scoping rules that differ between type checkers) is better than failing silently here. * `beartype` assumes we support `TypeForm` (because it only supports mypy and pyright, it uses `if MYPY:` to hide the `TypeForm` from mypy, and pyright supports `TypeForm`), and we don't yet. * `graphql-core` likes to use a `try: ... except ImportError: ...` pattern for importing special forms from `typing` with fallback to `typing_extensions`, instead of using `sys.version_info` checks. We don't handle this well when type checking under an older Python version (where the import from `typing` is not found); we see the imported name as of type e.g. `Unknown | SpecialFormType(...)`, and because of the union with `Unknown` we fail to handle it as the special form type. Mypy and pyright also don't seem to support this pattern. They don't complain about subscripting such special forms, but they do silently fail to treat them as the desired special form. Again here, if we are going to fail I'd rather fail with a diagnostic rather than silently. * `ibis` is [trying to use](https://github.com/ibis-project/ibis/blob/main/ibis/common/collections.py#L372) `frozendict: type[FrozenDict]` as a way to create a "type alias" to `FrozenDict`, but this is wrong: that means `frozendict: type[FrozenDict[Any, Any]]`. * `mypy` has some errors due to the fact that type-checking `typing.pyi` itself (without knowing that it's the real `typing.pyi`) doesn't work very well. * `mypy-protobuf` imports some types from the protobufs library that end up unioned with `Unknown` for some reason, and so we don't allow explicit-specialization of them. Depending on the reason they end up unioned with `Unknown`, we might want to better support this? But it's orthogonal to this PR -- we aren't failing any worse here, just alerting the author that we didn't understand their annotation. * `pwndbg` has unresolved references due to star-importing from a dependency that isn't installed, and uses un-imported names like `Dict` in annotation expressions. Some of the unresolved references were hidden by https://github.com/astral-sh/ruff/blob/main/crates/ty_python_semantic/src/types/infer/builder.rs#L7223-L7228 when some annotations previously resolved to a Todo type that no longer do.
1 parent 0645418 commit 192c37d

File tree

6 files changed

+58
-12
lines changed

6 files changed

+58
-12
lines changed

crates/ruff_benchmark/benches/ty.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ fn attrs(criterion: &mut Criterion) {
667667
max_dep_date: "2025-06-17",
668668
python_version: PythonVersion::PY313,
669669
},
670-
110,
670+
120,
671671
);
672672

673673
bench_project(&benchmark, criterion);

crates/ty_python_semantic/resources/mdtest/annotations/invalid.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ async def outer_async(): # avoid unrelated syntax errors on `yield` and `await`
9898
n: 1 < 2, # error: [invalid-type-form] "Comparison expressions are not allowed in type expressions"
9999
o: bar(), # error: [invalid-type-form] "Function calls are not allowed in type expressions"
100100
p: int | f"foo", # error: [invalid-type-form] "F-strings are not allowed in type expressions"
101-
q: [1, 2, 3][1:2], # error: [invalid-type-form] "Slices are not allowed in type expressions"
101+
# error: [invalid-type-form] "Slices are not allowed in type expressions"
102+
# error: [invalid-type-form] "Invalid subscript"
103+
q: [1, 2, 3][1:2],
102104
):
103105
reveal_type(a) # revealed: Unknown
104106
reveal_type(b) # revealed: Unknown
@@ -116,7 +118,7 @@ async def outer_async(): # avoid unrelated syntax errors on `yield` and `await`
116118
reveal_type(n) # revealed: Unknown
117119
reveal_type(o) # revealed: Unknown
118120
reveal_type(p) # revealed: int | Unknown
119-
reveal_type(q) # revealed: @Todo(unknown type subscript)
121+
reveal_type(q) # revealed: Unknown
120122

121123
class Mat:
122124
def __init__(self, value: int):

crates/ty_python_semantic/resources/mdtest/annotations/literal.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,11 @@ from other import Literal
330330
# ?
331331
#
332332
# error: [invalid-type-form] "Int literals are not allowed in this context in a type expression"
333+
# error: [invalid-type-form] "Invalid subscript of object of type `_SpecialForm` in type expression"
333334
a1: Literal[26]
334335

335336
def f():
336-
reveal_type(a1) # revealed: @Todo(unknown type subscript)
337+
reveal_type(a1) # revealed: Unknown
337338
```
338339

339340
## Detecting typing_extensions.Literal

crates/ty_python_semantic/resources/mdtest/implicit_type_aliases.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ g(None)
3333
We also support unions in type aliases:
3434

3535
```py
36-
from typing_extensions import Any, Never, Literal, LiteralString, Tuple, Annotated, Optional, Union, Callable
36+
from typing_extensions import Any, Never, Literal, LiteralString, Tuple, Annotated, Optional, Union, Callable, TypeVar
3737
from ty_extensions import Unknown
3838

39+
T = TypeVar("T")
40+
3941
IntOrStr = int | str
4042
IntOrStrOrBytes1 = int | str | bytes
4143
IntOrStrOrBytes2 = (int | str) | bytes
@@ -70,6 +72,10 @@ IntOrTypeOfStr = int | type[str]
7072
TypeOfStrOrInt = type[str] | int
7173
IntOrCallable = int | Callable[[str], bytes]
7274
CallableOrInt = Callable[[str], bytes] | int
75+
TypeVarOrInt = T | int
76+
IntOrTypeVar = int | T
77+
TypeVarOrNone = T | None
78+
NoneOrTypeVar = None | T
7379

7480
reveal_type(IntOrStr) # revealed: types.UnionType
7581
reveal_type(IntOrStrOrBytes1) # revealed: types.UnionType
@@ -105,6 +111,10 @@ reveal_type(IntOrTypeOfStr) # revealed: types.UnionType
105111
reveal_type(TypeOfStrOrInt) # revealed: types.UnionType
106112
reveal_type(IntOrCallable) # revealed: types.UnionType
107113
reveal_type(CallableOrInt) # revealed: types.UnionType
114+
reveal_type(TypeVarOrInt) # revealed: types.UnionType
115+
reveal_type(IntOrTypeVar) # revealed: types.UnionType
116+
reveal_type(TypeVarOrNone) # revealed: types.UnionType
117+
reveal_type(NoneOrTypeVar) # revealed: types.UnionType
108118

109119
def _(
110120
int_or_str: IntOrStr,
@@ -141,6 +151,10 @@ def _(
141151
type_of_str_or_int: TypeOfStrOrInt,
142152
int_or_callable: IntOrCallable,
143153
callable_or_int: CallableOrInt,
154+
type_var_or_int: TypeVarOrInt,
155+
int_or_type_var: IntOrTypeVar,
156+
type_var_or_none: TypeVarOrNone,
157+
none_or_type_var: NoneOrTypeVar,
144158
):
145159
reveal_type(int_or_str) # revealed: int | str
146160
reveal_type(int_or_str_or_bytes1) # revealed: int | str | bytes
@@ -176,6 +190,14 @@ def _(
176190
reveal_type(type_of_str_or_int) # revealed: type[str] | int
177191
reveal_type(int_or_callable) # revealed: int | ((str, /) -> bytes)
178192
reveal_type(callable_or_int) # revealed: ((str, /) -> bytes) | int
193+
# TODO should be Unknown | int
194+
reveal_type(type_var_or_int) # revealed: T@_ | int
195+
# TODO should be int | Unknown
196+
reveal_type(int_or_type_var) # revealed: int | T@_
197+
# TODO should be Unknown | None
198+
reveal_type(type_var_or_none) # revealed: T@_ | None
199+
# TODO should be None | Unknown
200+
reveal_type(none_or_type_var) # revealed: None | T@_
179201
```
180202

181203
If a type is unioned with itself in a value expression, the result is just that type. No
@@ -357,7 +379,7 @@ MyList = list[T]
357379

358380
def _(my_list: MyList[int]):
359381
# TODO: This should be `list[int]`
360-
reveal_type(my_list) # revealed: @Todo(unknown type subscript)
382+
reveal_type(my_list) # revealed: @Todo(specialized generic alias in type expression)
361383

362384
ListOrTuple = list[T] | tuple[T, ...]
363385

crates/ty_python_semantic/src/types/infer/builder.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9507,7 +9507,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
95079507
| KnownInstanceType::Literal(_)
95089508
| KnownInstanceType::Annotated(_)
95099509
| KnownInstanceType::TypeGenericAlias(_)
9510-
| KnownInstanceType::Callable(_),
9510+
| KnownInstanceType::Callable(_)
9511+
| KnownInstanceType::TypeVar(_),
95119512
),
95129513
Type::ClassLiteral(..)
95139514
| Type::SubclassOf(..)
@@ -9518,7 +9519,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
95189519
| KnownInstanceType::Literal(_)
95199520
| KnownInstanceType::Annotated(_)
95209521
| KnownInstanceType::TypeGenericAlias(_)
9521-
| KnownInstanceType::Callable(_),
9522+
| KnownInstanceType::Callable(_)
9523+
| KnownInstanceType::TypeVar(_),
95229524
),
95239525
ast::Operator::BitOr,
95249526
) if pep_604_unions_allowed() => {
@@ -10926,6 +10928,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
1092610928
.map(Type::from)
1092710929
.unwrap_or_else(Type::unknown);
1092810930
}
10931+
Type::KnownInstance(KnownInstanceType::UnionType(_)) => {
10932+
return todo_type!("Specialization of union type alias");
10933+
}
1092910934
_ => {}
1093010935
}
1093110936

crates/ty_python_semantic/src/types/infer/builder/type_expression.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -858,7 +858,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
858858
Type::unknown()
859859
}
860860
},
861-
Type::Dynamic(DynamicType::Todo(_)) => {
861+
Type::Dynamic(_) => {
862862
self.infer_type_expression(slice);
863863
value_ty
864864
}
@@ -887,11 +887,27 @@ impl<'db> TypeInferenceBuilder<'db, '_> {
887887
}
888888
}
889889
}
890+
Type::GenericAlias(_) => {
891+
self.infer_type_expression(slice);
892+
// If the generic alias is already fully specialized, this is an error. But it
893+
// could have been specialized with another typevar (e.g. a type alias like `MyList
894+
// = list[T]`), in which case it's later valid to do `MyList[int]`.
895+
todo_type!("specialized generic alias in type expression")
896+
}
897+
Type::StringLiteral(_) => {
898+
self.infer_type_expression(slice);
899+
// For stringified TypeAlias; remove once properly supported
900+
todo_type!("string literal subscripted in type expression")
901+
}
890902
_ => {
891-
// TODO: Emit a diagnostic once we've implemented all valid subscript type
892-
// expressions.
893903
self.infer_type_expression(slice);
894-
todo_type!("unknown type subscript")
904+
if let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) {
905+
builder.into_diagnostic(format_args!(
906+
"Invalid subscript of object of type `{}` in type expression",
907+
value_ty.display(self.db())
908+
));
909+
}
910+
Type::unknown()
895911
}
896912
}
897913
}

0 commit comments

Comments
 (0)