diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md b/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md new file mode 100644 index 0000000000..2a231802da --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md @@ -0,0 +1,26 @@ +# Narrowing using `hasattr()` + +The builtin function `hasattr()` can be used to narrow nominal and structural types. This is +accomplished using an intersection with a synthesized protocol: + +```py +from typing import final + +class Foo: ... + +@final +class Bar: ... + +def f(x: Foo): + if hasattr(x, "spam"): + reveal_type(x) # revealed: Foo & + reveal_type(x.spam) # revealed: object + + if hasattr(x, "not-an-identifier"): + reveal_type(x) # revealed: Foo + +def y(x: Bar): + if hasattr(x, "spam"): + reveal_type(x) # revealed: Never + reveal_type(x.spam) # revealed: Never +``` diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 754f607512..cd48fa9877 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -28,7 +28,7 @@ pub(crate) use self::infer::{ infer_deferred_types, infer_definition_types, infer_expression_type, infer_expression_types, infer_scope_types, }; -pub(crate) use self::narrow::KnownConstraintFunction; +pub(crate) use self::narrow::ClassInfoConstraintFunction; pub(crate) use self::signatures::{CallableSignature, Signature, Signatures}; pub(crate) use self::subclass_of::{SubclassOfInner, SubclassOfType}; use crate::module_name::ModuleName; @@ -6939,6 +6939,9 @@ pub enum KnownFunction { /// `builtins.issubclass` #[strum(serialize = "issubclass")] IsSubclass, + /// `builtins.hasattr` + #[strum(serialize = "hasattr")] + HasAttr, /// `builtins.reveal_type`, `typing.reveal_type` or `typing_extensions.reveal_type` RevealType, /// `builtins.len` @@ -7005,10 +7008,10 @@ pub enum KnownFunction { } impl KnownFunction { - pub fn into_constraint_function(self) -> Option { + pub fn into_classinfo_constraint_function(self) -> Option { match self { - Self::IsInstance => Some(KnownConstraintFunction::IsInstance), - Self::IsSubclass => Some(KnownConstraintFunction::IsSubclass), + Self::IsInstance => Some(ClassInfoConstraintFunction::IsInstance), + Self::IsSubclass => Some(ClassInfoConstraintFunction::IsSubclass), _ => None, } } @@ -7027,7 +7030,9 @@ impl KnownFunction { /// Return `true` if `self` is defined in `module` at runtime. const fn check_module(self, module: KnownModule) -> bool { match self { - Self::IsInstance | Self::IsSubclass | Self::Len | Self::Repr => module.is_builtins(), + Self::IsInstance | Self::IsSubclass | Self::HasAttr | Self::Len | Self::Repr => { + module.is_builtins() + } Self::AssertType | Self::AssertNever | Self::Cast @@ -8423,6 +8428,7 @@ pub(crate) mod tests { KnownFunction::Len | KnownFunction::Repr | KnownFunction::IsInstance + | KnownFunction::HasAttr | KnownFunction::IsSubclass => KnownModule::Builtins, KnownFunction::AbstractMethod => KnownModule::Abc, diff --git a/crates/ty_python_semantic/src/types/instance.rs b/crates/ty_python_semantic/src/types/instance.rs index 59de727467..b492a55952 100644 --- a/crates/ty_python_semantic/src/types/instance.rs +++ b/crates/ty_python_semantic/src/types/instance.rs @@ -25,6 +25,15 @@ impl<'db> Type<'db> { } } + pub(super) fn synthesized_protocol<'a, M>(db: &'db dyn Db, members: M) -> Self + where + M: IntoIterator)>, + { + Self::ProtocolInstance(ProtocolInstanceType(Protocol::Synthesized( + SynthesizedProtocolType::new(db, ProtocolInterface::with_members(db, members)), + ))) + } + /// Return `true` if `self` conforms to the interface described by `protocol`. /// /// TODO: we may need to split this into two methods in the future, once we start diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index e42dcfa32f..175e787cbf 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -11,13 +11,16 @@ use crate::types::{ UnionBuilder, }; use crate::Db; + +use ruff_python_stdlib::identifiers::is_identifier; + use itertools::Itertools; use ruff_python_ast as ast; use ruff_python_ast::{BoolOp, ExprBoolOp}; use rustc_hash::FxHashMap; use std::collections::hash_map::Entry; -use super::UnionType; +use super::{KnownFunction, UnionType}; /// Return the type constraint that `test` (if true) would place on `symbol`, if any. /// @@ -138,23 +141,27 @@ fn negative_constraints_for_expression_cycle_initial<'db>( None } +/// Functions that can be used to narrow the type of a first argument using a "classinfo" second argument. +/// +/// A "classinfo" argument is either a class or a tuple of classes, or a tuple of tuples of classes +/// (etc. for arbitrary levels of recursion) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum KnownConstraintFunction { +pub enum ClassInfoConstraintFunction { /// `builtins.isinstance` IsInstance, /// `builtins.issubclass` IsSubclass, } -impl KnownConstraintFunction { +impl ClassInfoConstraintFunction { /// Generate a constraint from the type of a `classinfo` argument to `isinstance` or `issubclass`. /// /// The `classinfo` argument can be a class literal, a tuple of (tuples of) class literals. PEP 604 /// union types are not yet supported. Returns `None` if the `classinfo` argument has a wrong type. fn generate_constraint<'db>(self, db: &'db dyn Db, classinfo: Type<'db>) -> Option> { let constraint_fn = |class| match self { - KnownConstraintFunction::IsInstance => Type::instance(db, class), - KnownConstraintFunction::IsSubclass => SubclassOfType::from(db, class), + ClassInfoConstraintFunction::IsInstance => Type::instance(db, class), + ClassInfoConstraintFunction::IsSubclass => SubclassOfType::from(db, class), }; match classinfo { @@ -704,20 +711,38 @@ impl<'db> NarrowingConstraintsBuilder<'db> { // and `issubclass`, for example `isinstance(x, str | (int | float))`. match callable_ty { Type::FunctionLiteral(function_type) if expr_call.arguments.keywords.is_empty() => { - let function = function_type.known(self.db)?.into_constraint_function()?; - - let (id, class_info) = match &*expr_call.arguments.args { - [first, class_info] => match expr_name(first) { - Some(id) => (id, class_info), - None => return None, - }, - _ => return None, + let [first_arg, second_arg] = &*expr_call.arguments.args else { + return None; }; + let first_arg = expr_name(first_arg)?; + let function = function_type.known(self.db)?; + let symbol = self.expect_expr_name_symbol(first_arg); - let symbol = self.expect_expr_name_symbol(id); + if function == KnownFunction::HasAttr { + let attr = inference + .expression_type(second_arg.scoped_expression_id(self.db, scope)) + .into_string_literal()? + .value(self.db); + + if !is_identifier(attr) { + return None; + } + + let constraint = Type::synthesized_protocol( + self.db, + [(attr, KnownClass::Object.to_instance(self.db))], + ); + + return Some(NarrowingConstraints::from_iter([( + symbol, + constraint.negate_if(self.db, !is_positive), + )])); + } + + let function = function.into_classinfo_constraint_function()?; let class_info_ty = - inference.expression_type(class_info.scoped_expression_id(self.db, scope)); + inference.expression_type(second_arg.scoped_expression_id(self.db, scope)); function .generate_constraint(self.db, class_info_ty) diff --git a/crates/ty_python_semantic/src/types/protocol_class.rs b/crates/ty_python_semantic/src/types/protocol_class.rs index 871b6d8351..cf7a05ebf9 100644 --- a/crates/ty_python_semantic/src/types/protocol_class.rs +++ b/crates/ty_python_semantic/src/types/protocol_class.rs @@ -70,6 +70,25 @@ pub(super) enum ProtocolInterface<'db> { } impl<'db> ProtocolInterface<'db> { + pub(super) fn with_members<'a, M>(db: &'db dyn Db, members: M) -> Self + where + M: IntoIterator)>, + { + let members: BTreeMap<_, _> = members + .into_iter() + .map(|(name, ty)| { + ( + Name::new(name), + ProtocolMemberData { + ty: ty.normalized(db), + qualifiers: TypeQualifiers::default(), + }, + ) + }) + .collect(); + Self::Members(ProtocolInterfaceMembers::new(db, members)) + } + fn empty(db: &'db dyn Db) -> Self { Self::Members(ProtocolInterfaceMembers::new(db, BTreeMap::default())) }