diff --git a/pyrefly/lib/alt/expr.rs b/pyrefly/lib/alt/expr.rs index be18b8eab4..76db512864 100644 --- a/pyrefly/lib/alt/expr.rs +++ b/pyrefly/lib/alt/expr.rs @@ -1974,9 +1974,13 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } } ty.as_bool().or_else(|| { - // If the object defines `__bool__`, we can check if it returns a statically known value + // If the object defines `__bool__`, we can check if it returns a statically known value. + // Implicit dunder lookups (like the `__bool__` used for truthiness) are resolved on the + // type and do not go through `__getattr__`, so we disable the `__getattr__` fallback here. + // Otherwise a class with a `__getattr__` returning a non-callable type would be treated as + // defining a non-callable `__bool__` and wrongly reported as `not-callable` (see #4467). if self - .type_of_magic_dunder_attr(ty, &dunder::BOOL, range, errors, None, "as_bool", true)? + .type_of_magic_dunder_attr(ty, &dunder::BOOL, range, errors, None, "as_bool", false)? .is_never() { return None; diff --git a/pyrefly/lib/test/attributes.rs b/pyrefly/lib/test/attributes.rs index 6c19c98ca9..8df0bf5e74 100644 --- a/pyrefly/lib/test/attributes.rs +++ b/pyrefly/lib/test/attributes.rs @@ -944,6 +944,22 @@ def test(foo: Foo) -> None: "#, ); +testcase!( + test_getattr_does_not_provide_dunder_bool, + r#" +# Implicit dunders like `__bool__` are looked up on the type and are not +# resolved through `__getattr__`, so a `__getattr__` returning a non-callable +# type must not make an instance look like it has a non-callable `__bool__`. +# Regression test for https://github.com/facebook/pyrefly/issues/4467 +class Tensor: ... +class M: + def __getattr__(self, name: str) -> "Tensor | M": ... + +def f(x: M | None) -> M: + return x or M() + "#, +); + testcase!( test_object_setattr_wrong_signature, r#"