[ty] Support isinstance() and issubclass() narrowing when the second argument is a typing.py stdlib alias (#21391)

## Summary

A followup to https://github.com/astral-sh/ruff/pull/21386

## Test Plan

New mdtests added
This commit is contained in:
Alex Waygood 2025-11-11 21:09:24 +00:00 committed by GitHub
parent 4373974dd9
commit 43297d3455
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 89 additions and 4 deletions

View file

@ -147,6 +147,25 @@ def _(x: int | str | bytes):
reveal_type(x) # revealed: (int & Unknown) | (str & Unknown) | (bytes & Unknown) reveal_type(x) # revealed: (int & Unknown) | (str & Unknown) | (bytes & Unknown)
``` ```
## `classinfo` is a `typing.py` special form
Certain special forms in `typing.py` are aliases to classes elsewhere in the standard library; these
can be used in `isinstance()` and `issubclass()` checks. We support narrowing using them:
```py
import typing as t
def f(x: dict[str, int] | list[str], y: object):
if isinstance(x, t.Dict):
reveal_type(x) # revealed: dict[str, int]
else:
reveal_type(x) # revealed: list[str]
if isinstance(y, t.Callable):
# TODO: a better top-materialization for `Callable`s (https://github.com/astral-sh/ty/issues/1426)
reveal_type(y) # revealed: () -> object
```
## Class types ## Class types
```py ```py

View file

@ -11,9 +11,9 @@ use crate::types::enums::{enum_member_literals, enum_metadata};
use crate::types::function::KnownFunction; use crate::types::function::KnownFunction;
use crate::types::infer::infer_same_file_expression_type; use crate::types::infer::infer_same_file_expression_type;
use crate::types::{ use crate::types::{
ClassLiteral, ClassType, IntersectionBuilder, KnownClass, KnownInstanceType, SpecialFormType, CallableType, ClassLiteral, ClassType, IntersectionBuilder, KnownClass, KnownInstanceType,
SubclassOfInner, SubclassOfType, Truthiness, Type, TypeContext, TypeVarBoundOrConstraints, SpecialFormType, SubclassOfInner, SubclassOfType, Truthiness, Type, TypeContext,
UnionBuilder, infer_expression_types, TypeVarBoundOrConstraints, UnionBuilder, infer_expression_types,
}; };
use ruff_db::parsed::{ParsedModuleRef, parsed_module}; use ruff_db::parsed::{ParsedModuleRef, parsed_module};
@ -229,6 +229,18 @@ impl ClassInfoConstraintFunction {
) )
} }
// We don't have a good meta-type for `Callable`s right now,
// so only apply `isinstance()` narrowing, not `issubclass()`
Type::SpecialForm(SpecialFormType::Callable)
if self == ClassInfoConstraintFunction::IsInstance =>
{
Some(CallableType::unknown(db).top_materialization(db))
}
Type::SpecialForm(special_form) => special_form
.aliased_stdlib_class()
.and_then(|class| self.generate_constraint(db, class.to_class_literal(db))),
Type::AlwaysFalsy Type::AlwaysFalsy
| Type::AlwaysTruthy | Type::AlwaysTruthy
| Type::BooleanLiteral(_) | Type::BooleanLiteral(_)
@ -244,7 +256,6 @@ impl ClassInfoConstraintFunction {
| Type::FunctionLiteral(_) | Type::FunctionLiteral(_)
| Type::ProtocolInstance(_) | Type::ProtocolInstance(_)
| Type::PropertyInstance(_) | Type::PropertyInstance(_)
| Type::SpecialForm(_)
| Type::LiteralString | Type::LiteralString
| Type::StringLiteral(_) | Type::StringLiteral(_)
| Type::IntLiteral(_) | Type::IntLiteral(_)

View file

@ -328,6 +328,61 @@ impl SpecialFormType {
} }
} }
/// Return `Some(KnownClass)` if this special form is an alias
/// to a standard library class.
pub(super) const fn aliased_stdlib_class(self) -> Option<KnownClass> {
match self {
Self::List => Some(KnownClass::List),
Self::Dict => Some(KnownClass::Dict),
Self::Set => Some(KnownClass::Set),
Self::FrozenSet => Some(KnownClass::FrozenSet),
Self::ChainMap => Some(KnownClass::ChainMap),
Self::Counter => Some(KnownClass::Counter),
Self::DefaultDict => Some(KnownClass::DefaultDict),
Self::Deque => Some(KnownClass::Deque),
Self::OrderedDict => Some(KnownClass::OrderedDict),
Self::Tuple => Some(KnownClass::Tuple),
Self::Type => Some(KnownClass::Type),
Self::AlwaysFalsy
| Self::AlwaysTruthy
| Self::Annotated
| Self::Bottom
| Self::CallableTypeOf
| Self::ClassVar
| Self::Concatenate
| Self::Final
| Self::Intersection
| Self::Literal
| Self::LiteralString
| Self::Never
| Self::NoReturn
| Self::Not
| Self::ReadOnly
| Self::Required
| Self::TypeAlias
| Self::TypeGuard
| Self::NamedTuple
| Self::NotRequired
| Self::Optional
| Self::Top
| Self::TypeIs
| Self::TypedDict
| Self::TypingSelf
| Self::Union
| Self::Unknown
| Self::TypeOf
| Self::Any
// `typing.Callable` is an alias to `collections.abc.Callable`,
// but they're both the same `SpecialFormType` in our model,
// and neither is a class in typeshed (even though the `collections.abc` one is at runtime)
| Self::Callable
| Self::Protocol
| Self::Generic
| Self::Unpack => None,
}
}
/// Return `true` if this special form is valid as the second argument /// Return `true` if this special form is valid as the second argument
/// to `issubclass()` and `isinstance()` calls. /// to `issubclass()` and `isinstance()` calls.
pub(super) const fn is_valid_isinstance_target(self) -> bool { pub(super) const fn is_valid_isinstance_target(self) -> bool {