Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/narrow/hasattr.md
Original file line number Diff line number Diff line change
@@ -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 & <Protocol with members 'spam'>
reveal_type(x.spam) # revealed: object
Comment on lines +15 to +17
Copy link
Contributor

@sharkdp sharkdp May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also add a test for the negated constraint (with a TODO, if needed)? This doesn't seem to work yet?

    else:
        reveal_type(x)  # revealed: Foo & ~<Protocol with members 'spam'>
        reveal_type(x.spam)  # no error?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The false negative here appears to be due to some pre-existing TODOs for intersection types. I can reproduce it using isinstance() narrowing with else branches as well as with hasattr() narrowing:

from typing import reveal_type

class Foo:
    y: int

def f(x: object):
    if isinstance(x, Foo):
        reveal_type(x.y)  # revealed: int
    else:
        reveal_type(x.y)  # revealed: @Todo(map with boundness: intersections with negative contributions)

    if hasattr(x, "foo"):
        reveal_type(x.foo)  # revealed: int
    else:
        reveal_type(x.foo)  # revealed: @Todo(map with boundness: intersections with negative contributions)

But you're right, I should have added a test for the else branch! I'll make a followup PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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
```
16 changes: 11 additions & 5 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -7035,10 +7038,10 @@ pub enum KnownFunction {
}

impl KnownFunction {
pub fn into_constraint_function(self) -> Option<KnownConstraintFunction> {
pub fn into_classinfo_constraint_function(self) -> Option<ClassInfoConstraintFunction> {
match self {
Self::IsInstance => Some(KnownConstraintFunction::IsInstance),
Self::IsSubclass => Some(KnownConstraintFunction::IsSubclass),
Self::IsInstance => Some(ClassInfoConstraintFunction::IsInstance),
Self::IsSubclass => Some(ClassInfoConstraintFunction::IsSubclass),
_ => None,
}
}
Expand All @@ -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
Expand Down Expand Up @@ -8452,6 +8457,7 @@ pub(crate) mod tests {
KnownFunction::Len
| KnownFunction::Repr
| KnownFunction::IsInstance
| KnownFunction::HasAttr
| KnownFunction::IsSubclass => KnownModule::Builtins,

KnownFunction::AbstractMethod => KnownModule::Abc,
Expand Down
9 changes: 9 additions & 0 deletions crates/ty_python_semantic/src/types/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = (&'a str, Type<'db>)>,
{
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
Expand Down
55 changes: 40 additions & 15 deletions crates/ty_python_semantic/src/types/narrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<Type<'db>> {
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 {
Expand Down Expand Up @@ -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);

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 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)
Expand Down
19 changes: 19 additions & 0 deletions crates/ty_python_semantic/src/types/protocol_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = (&'a str, Type<'db>)>,
{
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()))
}
Expand Down
Loading