From 03bd0619e9adf90c9aee976b0fac10089da777c3 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Tue, 11 Nov 2025 19:30:01 +0000 Subject: [PATCH] [ty] Silence false-positive diagnostics when using `typing.Dict` or `typing.Callable` as the second argument to `isinstance()` (#21386) --- .../resources/mdtest/call/builtins.md | 35 +++++++++++++ crates/ty_python_semantic/src/types.rs | 7 +++ .../ty_python_semantic/src/types/call/bind.rs | 25 +++++++++ .../ty_python_semantic/src/types/function.rs | 15 +++++- .../src/types/special_form.rs | 52 +++++++++++++++++++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/call/builtins.md b/crates/ty_python_semantic/resources/mdtest/call/builtins.md index 0eac021d1a..8de3e77d77 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/builtins.md +++ b/crates/ty_python_semantic/resources/mdtest/call/builtins.md @@ -162,3 +162,38 @@ def _(x: A | B, y: list[int]): reveal_type(x) # revealed: B & ~A reveal_type(isinstance(x, B)) # revealed: Literal[True] ``` + +Certain special forms in the typing module are not instances of `type`, so are strictly-speaking +disallowed as the second argument to `isinstance()` according to typeshed's annotations. However, at +runtime they work fine as the second argument, and we implement that special case in ty: + +```py +import typing as t + +# no errors emitted for any of these: +isinstance("", t.Dict) +isinstance("", t.List) +isinstance("", t.Set) +isinstance("", t.FrozenSet) +isinstance("", t.Tuple) +isinstance("", t.ChainMap) +isinstance("", t.Counter) +isinstance("", t.Deque) +isinstance("", t.OrderedDict) +isinstance("", t.Callable) +isinstance("", t.Type) +isinstance("", t.Callable | t.Deque) + +# `Any` is valid in `issubclass()` calls but not `isinstance()` calls +issubclass(list, t.Any) +issubclass(list, t.Any | t.Dict) +``` + +But for other special forms that are not permitted as the second argument, we still emit an error: + +```py +isinstance("", t.TypeGuard) # error: [invalid-argument-type] +isinstance("", t.ClassVar) # error: [invalid-argument-type] +isinstance("", t.Final) # error: [invalid-argument-type] +isinstance("", t.Any) # error: [invalid-argument-type] +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 2b3bf1c0d9..7f950f7b77 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -1028,6 +1028,13 @@ impl<'db> Type<'db> { any_over_type(db, self, &|ty| matches!(ty, Type::TypeVar(_)), false) } + pub(crate) const fn as_special_form(self) -> Option { + match self { + Type::SpecialForm(special_form) => Some(special_form), + _ => None, + } + } + pub(crate) const fn as_class_literal(self) -> Option> { match self { Type::ClassLiteral(class_type) => Some(class_type), diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 423783b420..ef2f892200 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -3647,6 +3647,31 @@ impl<'db> BindingError<'db> { expected_ty, provided_ty, } => { + // Certain special forms in the typing module are aliases for classes + // elsewhere in the standard library. These special forms are not instances of `type`, + // and you cannot use them in place of their aliased classes in *all* situations: + // for example, `dict()` succeeds at runtime, but `typing.Dict()` fails. However, + // they *can* all be used as the second argument to `isinstance` and `issubclass`. + // We model that specific aspect of their behaviour here. + // + // This is implemented as a special case in call-binding machinery because overriding + // typeshed's signatures for `isinstance()` and `issubclass()` would be complex and + // error-prone, due to the fact that they are annotated with recursive type aliases. + if parameter.index == 1 + && *argument_index == Some(1) + && matches!( + callable_ty + .as_function_literal() + .and_then(|function| function.known(context.db())), + Some(KnownFunction::IsInstance | KnownFunction::IsSubclass) + ) + && provided_ty + .as_special_form() + .is_some_and(SpecialFormType::is_valid_isinstance_target) + { + return; + } + // TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments // here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have // silenced diagnostics during overload evaluation, and rely on the assignability diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 98a86f48df..737a5218e4 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -1764,6 +1764,7 @@ impl KnownFunction { Type::KnownInstance(KnownInstanceType::UnionType(_)) => { fn find_invalid_elements<'db>( db: &'db dyn Db, + function: KnownFunction, ty: Type<'db>, invalid_elements: &mut Vec>, ) { @@ -1771,9 +1772,19 @@ impl KnownFunction { Type::ClassLiteral(_) => {} Type::NominalInstance(instance) if instance.has_known_class(db, KnownClass::NoneType) => {} + Type::SpecialForm(special_form) + if special_form.is_valid_isinstance_target() => {} + // `Any` can be used in `issubclass()` calls but not `isinstance()` calls + Type::SpecialForm(SpecialFormType::Any) + if function == KnownFunction::IsSubclass => {} Type::KnownInstance(KnownInstanceType::UnionType(union)) => { for element in union.elements(db) { - find_invalid_elements(db, *element, invalid_elements); + find_invalid_elements( + db, + function, + *element, + invalid_elements, + ); } } _ => invalid_elements.push(ty), @@ -1781,7 +1792,7 @@ impl KnownFunction { } let mut invalid_elements = vec![]; - find_invalid_elements(db, *second_argument, &mut invalid_elements); + find_invalid_elements(db, self, *second_argument, &mut invalid_elements); let Some((first_invalid_element, other_invalid_elements)) = invalid_elements.split_first() diff --git a/crates/ty_python_semantic/src/types/special_form.rs b/crates/ty_python_semantic/src/types/special_form.rs index 721def0dee..c8e37d5143 100644 --- a/crates/ty_python_semantic/src/types/special_form.rs +++ b/crates/ty_python_semantic/src/types/special_form.rs @@ -328,6 +328,58 @@ impl SpecialFormType { } } + /// Return `true` if this special form is valid as the second argument + /// to `issubclass()` and `isinstance()` calls. + pub(super) const fn is_valid_isinstance_target(self) -> bool { + match self { + Self::Callable + | Self::ChainMap + | Self::Counter + | Self::DefaultDict + | Self::Deque + | Self::FrozenSet + | Self::Dict + | Self::List + | Self::OrderedDict + | Self::Set + | Self::Tuple + | Self::Type + | Self::Protocol + | Self::Generic => true, + + 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 // can be used in `issubclass()` but not `isinstance()`. + | Self::Unpack => false, + } + } + /// Return the repr of the symbol at runtime pub(super) const fn repr(self) -> &'static str { match self {