Skip to content
Open
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
12 changes: 12 additions & 0 deletions pyrefly/lib/alt/narrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use num_traits::ToPrimitive;
use pyrefly_config::error_kind::ErrorKind;
use pyrefly_graph::index::Idx;
use pyrefly_python::ast::Ast;
use pyrefly_python::dunder;
use pyrefly_types::class::Class;
use pyrefly_types::display::TypeDisplayContext;
use pyrefly_types::facet::FacetChain;
Expand Down Expand Up @@ -506,6 +507,17 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
range: TextRange,
errors: &ErrorCollector,
) -> Option<Type> {
if let FacetKind::Attribute(attr) = facet
&& *attr == dunder::CLASS
{
match op {
AtomicNarrowOp::Is(v) | AtomicNarrowOp::Eq(v) => {
let right = self.expr_infer(v, errors);
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The implementation should validate that the right-hand side of the comparison is a valid class object, similar to how isinstance validates its second argument via check_type_is_class_object. Without validation, invalid comparisons like x.__class__ is 42 won't produce helpful error messages.

Consider adding validation by calling check_type_is_class_object with appropriate parameters, similar to how it's done in call_isinstance at special_calls.rs:287. This would ensure users get clear error messages for invalid usage.

Suggested change
let right = self.expr_infer(v, errors);
let right = self.expr_infer(v, errors);
// Validate that the right-hand side is a valid class object,
// consistent with how `isinstance` validates its second argument.
self.check_type_is_class_object(&right, range, errors);

Copilot uses AI. Check for mistakes.
return Some(self.narrow_isinstance(base, &right));
}
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

The implementation handles positive narrowing for __class__ (using is and == operators) but doesn't handle negative narrowing (using is not and != operators). For consistency with how isinstance narrowing works (which supports both isinstance(x, T) and not isinstance(x, T)), this implementation should also support negative cases.

For example, if x: int | str and x.__class__ is not int, then x should be narrowed to str. Similarly for the != operator. This would be analogous to how narrow_is_not_instance is used for IsNotInstance operations (see line 1026).

Suggested change
}
}
AtomicNarrowOp::IsNot(v) | AtomicNarrowOp::NotEq(v) => {
let right = self.expr_infer(v, errors);
return Some(self.narrow_is_not_instance(base, &right));
}

Copilot uses AI. Check for mistakes.
_ => {}
}
}
match op {
AtomicNarrowOp::Is(v) => {
let right = self.expr_infer(v, errors);
Expand Down
17 changes: 17 additions & 0 deletions pyrefly/lib/test/attribute_narrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ def f(foo: Foo):
"#,
);

testcase!(
test_dunder_class_attribute_narrow,
r#"
from typing import assert_type
def f(x: int | str):
if x.__class__ is int:
assert_type(x, int)
else:
assert_type(x, int | str)
if x.__class__ == int:
assert_type(x, int)
def g(x: int | str):
assert x.__class__ is int
assert_type(x, int)
"#,
);

// The expected behavior when narrowing an invalid attribute chain is to produce
// type errors at the narrow site, but apply the narrowing downstream
// (motivation: being noisy downstream could be quite frustrating for gradually
Expand Down