From b0c84c9a85bfdc467d5a826499212d224ac5cb3f Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 12 May 2025 15:09:20 -0400 Subject: [PATCH 1/2] [ty] Narrowing for `hasattr()` --- crates/ty_python_semantic/src/types.rs | 16 +++++-- .../ty_python_semantic/src/types/instance.rs | 9 ++++ crates/ty_python_semantic/src/types/narrow.rs | 47 +++++++++++++------ .../src/types/protocol_class.rs | 19 ++++++++ 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index fdbd76539d1b5..48399858ab9a6 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; @@ -6969,6 +6969,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` @@ -7035,10 +7038,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, } } @@ -7057,7 +7060,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 @@ -8452,6 +8457,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 59de7274672dc..b492a55952a98 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 e42dcfa32f467..e95d4378e19d2 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -17,7 +17,7 @@ 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 +138,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 +708,33 @@ 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); + + if function == KnownFunction::HasAttr { + let attr = inference + .expression_type(second_arg.scoped_expression_id(self.db, scope)) + .into_string_literal()?; + + let constraint = Type::synthesized_protocol( + self.db, + [(attr.value(self.db), KnownClass::Object.to_instance(self.db))], + ); + + return Some(NarrowingConstraints::from_iter([( + symbol, + constraint.negate_if(self.db, !is_positive), + )])); + } - let symbol = self.expect_expr_name_symbol(id); + 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 871b6d83516f9..cf7a05ebf9310 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())) } From e4501a8b3517805f95915c94c482bd5bdd934e37 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 12 May 2025 15:29:56 -0400 Subject: [PATCH 2/2] handle invalid identifiers --- .../resources/mdtest/narrow/hasattr.md | 26 +++++++++++++++++++ crates/ty_python_semantic/src/types/narrow.rs | 12 +++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md 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 0000000000000..2a231802da213 --- /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/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index e95d4378e19d2..175e787cbf680 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -11,6 +11,9 @@ 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}; @@ -718,11 +721,16 @@ impl<'db> NarrowingConstraintsBuilder<'db> { if function == KnownFunction::HasAttr { let attr = inference .expression_type(second_arg.scoped_expression_id(self.db, scope)) - .into_string_literal()?; + .into_string_literal()? + .value(self.db); + + if !is_identifier(attr) { + return None; + } let constraint = Type::synthesized_protocol( self.db, - [(attr.value(self.db), KnownClass::Object.to_instance(self.db))], + [(attr, KnownClass::Object.to_instance(self.db))], ); return Some(NarrowingConstraints::from_iter([(