[ty] Add precise inference for indexing, slicing and unpacking NamedTuple instances (#19560)

Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
This commit is contained in:
Alex Waygood 2025-08-13 16:19:44 +01:00 committed by GitHub
parent 11d2cb6d56
commit 9f6146a13d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 102 additions and 36 deletions

View file

@ -9,6 +9,7 @@ name, and not just by its numeric position within the tuple:
```py ```py
from typing import NamedTuple from typing import NamedTuple
from ty_extensions import static_assert, is_subtype_of, is_assignable_to
class Person(NamedTuple): class Person(NamedTuple):
id: int id: int
@ -24,10 +25,45 @@ reveal_type(alice.id) # revealed: int
reveal_type(alice.name) # revealed: str reveal_type(alice.name) # revealed: str
reveal_type(alice.age) # revealed: int | None reveal_type(alice.age) # revealed: int | None
# TODO: These should reveal the types of the fields # revealed: tuple[<class 'Person'>, <class 'tuple[int, str, int | None]'>, <class 'Sequence[int | str | None]'>, <class 'Reversible[int | str | None]'>, <class 'Collection[int | str | None]'>, <class 'Iterable[int | str | None]'>, <class 'Container[int | str | None]'>, typing.Protocol, typing.Generic, <class 'object'>]
reveal_type(alice[0]) # revealed: Unknown reveal_type(Person.__mro__)
reveal_type(alice[1]) # revealed: Unknown
reveal_type(alice[2]) # revealed: Unknown static_assert(is_subtype_of(Person, tuple[int, str, int | None]))
static_assert(is_subtype_of(Person, tuple[object, ...]))
static_assert(not is_assignable_to(Person, tuple[int, str, int]))
static_assert(not is_assignable_to(Person, tuple[int, str]))
reveal_type(len(alice)) # revealed: Literal[3]
reveal_type(bool(alice)) # revealed: Literal[True]
reveal_type(alice[0]) # revealed: int
reveal_type(alice[1]) # revealed: str
reveal_type(alice[2]) # revealed: int | None
# error: [index-out-of-bounds] "Index 3 is out of bounds for tuple `Person` with length 3"
reveal_type(alice[3]) # revealed: Unknown
reveal_type(alice[-1]) # revealed: int | None
reveal_type(alice[-2]) # revealed: str
reveal_type(alice[-3]) # revealed: int
# error: [index-out-of-bounds] "Index -4 is out of bounds for tuple `Person` with length 3"
reveal_type(alice[-4]) # revealed: Unknown
reveal_type(alice[1:]) # revealed: tuple[str, int | None]
reveal_type(alice[::-1]) # revealed: tuple[int | None, str, int]
alice_id, alice_name, alice_age = alice
reveal_type(alice_id) # revealed: int
reveal_type(alice_name) # revealed: str
reveal_type(alice_age) # revealed: int | None
# error: [invalid-assignment] "Not enough values to unpack: Expected 4"
a, b, c, d = alice
# error: [invalid-assignment] "Too many values to unpack: Expected 2"
a, b = alice
*_, age = alice
reveal_type(age) # revealed: int | None
# error: [missing-argument] # error: [missing-argument]
Person(3) Person(3)

View file

@ -1,13 +1,16 @@
Tanjun # too many iterations Tanjun # too many iterations
altair # too many iterations (uses packaging)
antidote # hangs / slow (single threaded) antidote # hangs / slow (single threaded)
artigraph # cycle panics (value_type_) artigraph # cycle panics (value_type_)
arviz # too many iterations on versions of arviz newer than https://github.com/arviz-devs/arviz/commit/3205b82bb4d6097c31f7334d7ac51a6de37002d0 arviz # too many iterations on versions of arviz newer than https://github.com/arviz-devs/arviz/commit/3205b82bb4d6097c31f7334d7ac51a6de37002d0
core # cycle panics (value_type_) core # cycle panics (value_type_)
cpython # too many cycle iterations cpython # too many cycle iterations
graphql-core # stack overflow
hydpy # too many iterations hydpy # too many iterations
ibis # too many iterations ibis # too many iterations
jax # too many iterations jax # too many iterations
mypy # too many iterations (self-recursive type alias) mypy # too many iterations (self-recursive type alias)
nox # too many iterations (uses packaging)
packaging # too many iterations packaging # too many iterations
pandas # slow (9s) pandas # slow (9s)
pandas-stubs # panics on versions of pandas-stubs newer than https://github.com/pandas-dev/pandas-stubs/commit/bf1221eb7ea0e582c30fe233d1f4f5713fce376b pandas-stubs # panics on versions of pandas-stubs newer than https://github.com/pandas-dev/pandas-stubs/commit/bf1221eb7ea0e582c30fe233d1f4f5713fce376b
@ -21,4 +24,5 @@ setuptools # vendors packaging, see above
spack # slow, success, but mypy-primer hangs processing the output spack # slow, success, but mypy-primer hangs processing the output
spark # too many iterations spark # too many iterations
steam.py # hangs (single threaded) steam.py # hangs (single threaded)
streamlit # too many iterations (uses packaging)
xarray # too many iterations xarray # too many iterations

View file

@ -10,7 +10,6 @@ aioredis
aiortc aiortc
alectryon alectryon
alerta alerta
altair
anyio anyio
apprise apprise
async-utils async-utils
@ -41,7 +40,6 @@ flake8
flake8-pyi flake8-pyi
freqtrade freqtrade
git-revise git-revise
graphql-core
httpx-caching httpx-caching
hydra-zen hydra-zen
ignite ignite
@ -64,7 +62,6 @@ more-itertools
mypy-protobuf mypy-protobuf
mypy_primer mypy_primer
nionutils nionutils
nox
openlibrary openlibrary
operator operator
optuna optuna
@ -107,7 +104,6 @@ starlette
static-frame static-frame
stone stone
strawberry strawberry
streamlit
svcs svcs
sympy sympy
tornado tornado

View file

@ -9389,7 +9389,19 @@ impl<'db> BoundSuperType<'db> {
)); ));
} }
let pivot_class = ClassBase::try_from_type(db, pivot_class_type).ok_or({ // TODO: having to get a class-literal just to pass it in here is silly.
// `BoundSuperType` should probably not be using `ClassBase::try_from_type` here;
// this also leads to false negatives in some cases. See discussion in
// <https://github.com/astral-sh/ruff/pull/19560#discussion_r2271570071>.
let pivot_class = ClassBase::try_from_type(
db,
pivot_class_type,
KnownClass::Object
.to_class_literal(db)
.into_class_literal()
.expect("`object` should always exist in typeshed"),
)
.ok_or({
BoundSuperError::InvalidPivotClassType { BoundSuperError::InvalidPivotClassType {
pivot_class: pivot_class_type, pivot_class: pivot_class_type,
} }

View file

@ -2240,7 +2240,7 @@ impl<'db> ClassLiteral<'db> {
/// y: str = "a" /// y: str = "a"
/// ``` /// ```
/// we return a map `{"x": (int, None), "y": (str, Some(Literal["a"]))}`. /// we return a map `{"x": (int, None), "y": (str, Some(Literal["a"]))}`.
fn own_fields( pub(super) fn own_fields(
self, self,
db: &'db dyn Db, db: &'db dyn Db,
specialization: Option<Specialization<'db>>, specialization: Option<Specialization<'db>>,

View file

@ -1,8 +1,9 @@
use crate::Db; use crate::Db;
use crate::types::generics::Specialization; use crate::types::generics::Specialization;
use crate::types::tuple::TupleType;
use crate::types::{ use crate::types::{
ClassType, DynamicType, KnownClass, KnownInstanceType, MroError, MroIterator, SpecialFormType, ClassLiteral, ClassType, DynamicType, KnownClass, KnownInstanceType, MroError, MroIterator,
Type, TypeMapping, TypeTransformer, todo_type, SpecialFormType, Type, TypeMapping, TypeTransformer, todo_type,
}; };
/// Enumeration of the possible kinds of types we allow in class bases. /// Enumeration of the possible kinds of types we allow in class bases.
@ -68,15 +69,28 @@ impl<'db> ClassBase<'db> {
/// Attempt to resolve `ty` into a `ClassBase`. /// Attempt to resolve `ty` into a `ClassBase`.
/// ///
/// Return `None` if `ty` is not an acceptable type for a class base. /// Return `None` if `ty` is not an acceptable type for a class base.
pub(super) fn try_from_type(db: &'db dyn Db, ty: Type<'db>) -> Option<Self> { pub(super) fn try_from_type(
db: &'db dyn Db,
ty: Type<'db>,
subclass: ClassLiteral<'db>,
) -> Option<Self> {
match ty { match ty {
Type::Dynamic(dynamic) => Some(Self::Dynamic(dynamic)), Type::Dynamic(dynamic) => Some(Self::Dynamic(dynamic)),
Type::ClassLiteral(literal) => { Type::ClassLiteral(literal) => {
if literal.is_known(db, KnownClass::Any) { if literal.is_known(db, KnownClass::Any) {
Some(Self::Dynamic(DynamicType::Any)) Some(Self::Dynamic(DynamicType::Any))
} else if literal.is_known(db, KnownClass::NamedTuple) { } else if literal.is_known(db, KnownClass::NamedTuple) {
// TODO: Figure out the tuple spec for the named tuple let fields = subclass.own_fields(db, None);
Self::try_from_type(db, KnownClass::Tuple.to_class_literal(db)) Self::try_from_type(
db,
TupleType::heterogeneous(
db,
fields.values().map(|field| field.declared_ty),
)?
.to_class_type(db)
.into(),
subclass,
)
} else { } else {
Some(Self::Class(literal.default_specialization(db))) Some(Self::Class(literal.default_specialization(db)))
} }
@ -85,7 +99,7 @@ impl<'db> ClassBase<'db> {
Type::NominalInstance(instance) Type::NominalInstance(instance)
if instance.class(db).is_known(db, KnownClass::GenericAlias) => if instance.class(db).is_known(db, KnownClass::GenericAlias) =>
{ {
Self::try_from_type(db, todo_type!("GenericAlias instance")) Self::try_from_type(db, todo_type!("GenericAlias instance"), subclass)
} }
Type::SubclassOf(subclass_of) => subclass_of Type::SubclassOf(subclass_of) => subclass_of
.subclass_of() .subclass_of()
@ -95,7 +109,7 @@ impl<'db> ClassBase<'db> {
let valid_element = inter let valid_element = inter
.positive(db) .positive(db)
.iter() .iter()
.find_map(|elem| ClassBase::try_from_type(db, *elem))?; .find_map(|elem| ClassBase::try_from_type(db, *elem, subclass))?;
if ty.is_disjoint_from(db, KnownClass::Type.to_instance(db)) { if ty.is_disjoint_from(db, KnownClass::Type.to_instance(db)) {
None None
@ -122,7 +136,7 @@ impl<'db> ClassBase<'db> {
if union if union
.elements(db) .elements(db)
.iter() .iter()
.all(|elem| ClassBase::try_from_type(db, *elem).is_some()) .all(|elem| ClassBase::try_from_type(db, *elem, subclass).is_some())
{ {
Some(ClassBase::Dynamic(*dynamic)) Some(ClassBase::Dynamic(*dynamic))
} else { } else {
@ -135,7 +149,7 @@ impl<'db> ClassBase<'db> {
// in which case we want to treat `Never` in a forgiving way and silence diagnostics // in which case we want to treat `Never` in a forgiving way and silence diagnostics
Type::Never => Some(ClassBase::unknown()), Type::Never => Some(ClassBase::unknown()),
Type::TypeAlias(alias) => Self::try_from_type(db, alias.value_type(db)), Type::TypeAlias(alias) => Self::try_from_type(db, alias.value_type(db), subclass),
Type::PropertyInstance(_) Type::PropertyInstance(_)
| Type::BooleanLiteral(_) | Type::BooleanLiteral(_)
@ -202,42 +216,44 @@ impl<'db> ClassBase<'db> {
// TODO: Classes inheriting from `typing.Type` et al. also have `Generic` in their MRO // TODO: Classes inheriting from `typing.Type` et al. also have `Generic` in their MRO
SpecialFormType::Dict => { SpecialFormType::Dict => {
Self::try_from_type(db, KnownClass::Dict.to_class_literal(db)) Self::try_from_type(db, KnownClass::Dict.to_class_literal(db), subclass)
} }
SpecialFormType::List => { SpecialFormType::List => {
Self::try_from_type(db, KnownClass::List.to_class_literal(db)) Self::try_from_type(db, KnownClass::List.to_class_literal(db), subclass)
} }
SpecialFormType::Type => { SpecialFormType::Type => {
Self::try_from_type(db, KnownClass::Type.to_class_literal(db)) Self::try_from_type(db, KnownClass::Type.to_class_literal(db), subclass)
} }
SpecialFormType::Tuple => { SpecialFormType::Tuple => {
Self::try_from_type(db, KnownClass::Tuple.to_class_literal(db)) Self::try_from_type(db, KnownClass::Tuple.to_class_literal(db), subclass)
} }
SpecialFormType::Set => { SpecialFormType::Set => {
Self::try_from_type(db, KnownClass::Set.to_class_literal(db)) Self::try_from_type(db, KnownClass::Set.to_class_literal(db), subclass)
} }
SpecialFormType::FrozenSet => { SpecialFormType::FrozenSet => {
Self::try_from_type(db, KnownClass::FrozenSet.to_class_literal(db)) Self::try_from_type(db, KnownClass::FrozenSet.to_class_literal(db), subclass)
} }
SpecialFormType::ChainMap => { SpecialFormType::ChainMap => {
Self::try_from_type(db, KnownClass::ChainMap.to_class_literal(db)) Self::try_from_type(db, KnownClass::ChainMap.to_class_literal(db), subclass)
} }
SpecialFormType::Counter => { SpecialFormType::Counter => {
Self::try_from_type(db, KnownClass::Counter.to_class_literal(db)) Self::try_from_type(db, KnownClass::Counter.to_class_literal(db), subclass)
} }
SpecialFormType::DefaultDict => { SpecialFormType::DefaultDict => {
Self::try_from_type(db, KnownClass::DefaultDict.to_class_literal(db)) Self::try_from_type(db, KnownClass::DefaultDict.to_class_literal(db), subclass)
} }
SpecialFormType::Deque => { SpecialFormType::Deque => {
Self::try_from_type(db, KnownClass::Deque.to_class_literal(db)) Self::try_from_type(db, KnownClass::Deque.to_class_literal(db), subclass)
} }
SpecialFormType::OrderedDict => { SpecialFormType::OrderedDict => {
Self::try_from_type(db, KnownClass::OrderedDict.to_class_literal(db)) Self::try_from_type(db, KnownClass::OrderedDict.to_class_literal(db), subclass)
} }
SpecialFormType::TypedDict => Some(Self::TypedDict), SpecialFormType::TypedDict => Some(Self::TypedDict),
SpecialFormType::Callable => { SpecialFormType::Callable => Self::try_from_type(
Self::try_from_type(db, todo_type!("Support for Callable as a base class")) db,
} todo_type!("Support for Callable as a base class"),
subclass,
),
}, },
} }
} }

View file

@ -151,7 +151,7 @@ impl<'db> Mro<'db> {
) )
) => ) =>
{ {
ClassBase::try_from_type(db, *single_base).map_or_else( ClassBase::try_from_type(db, *single_base, class.class_literal(db).0).map_or_else(
|| Err(MroErrorKind::InvalidBases(Box::from([(0, *single_base)]))), || Err(MroErrorKind::InvalidBases(Box::from([(0, *single_base)]))),
|single_base| { |single_base| {
if single_base.has_cyclic_mro(db) { if single_base.has_cyclic_mro(db) {
@ -186,7 +186,7 @@ impl<'db> Mro<'db> {
&original_bases[i + 1..], &original_bases[i + 1..],
); );
} else { } else {
match ClassBase::try_from_type(db, *base) { match ClassBase::try_from_type(db, *base, class.class_literal(db).0) {
Some(valid_base) => resolved_bases.push(valid_base), Some(valid_base) => resolved_bases.push(valid_base),
None => invalid_bases.push((i, *base)), None => invalid_bases.push((i, *base)),
} }
@ -253,7 +253,9 @@ impl<'db> Mro<'db> {
// `inconsistent-mro` diagnostic (which would be accurate -- but not nearly as // `inconsistent-mro` diagnostic (which would be accurate -- but not nearly as
// precise!). // precise!).
for (index, base) in original_bases.iter().enumerate() { for (index, base) in original_bases.iter().enumerate() {
let Some(base) = ClassBase::try_from_type(db, *base) else { let Some(base) =
ClassBase::try_from_type(db, *base, class.class_literal(db).0)
else {
continue; continue;
}; };
base_to_indices.entry(base).or_default().push(index); base_to_indices.entry(base).or_default().push(index);