diff --git a/pyrefly/lib/alt/call.rs b/pyrefly/lib/alt/call.rs index 566a007de4..c971e9cf54 100644 --- a/pyrefly/lib/alt/call.rs +++ b/pyrefly/lib/alt/call.rs @@ -2500,6 +2500,23 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { errors, ) } + Some(CalleeKind::Function(FunctionKind::Def(func))) + if func.has_toplevel_qname("builtins", "getattr") + && (x.arguments.args.len() == 2 || x.arguments.args.len() == 3) + && x.arguments.keywords.is_empty() + && x.arguments.args.iter().all(|arg| !matches!(arg, Expr::Starred(_))) => + { + self.call_getattr( + &x.arguments.args, + &args, + ty.clone(), + &kws, + x.func.range(), + x.arguments.range(), + hint, + errors, + ) + } // `f.register(C)(impl)`: applying the tagged factory decorator by call. _ if let Type::KwCall(kw) = ty && matches!(&kw.func_metadata.kind, FunctionKind::SingleDispatchRegister(_)) diff --git a/pyrefly/lib/alt/special_calls.rs b/pyrefly/lib/alt/special_calls.rs index d7cfcf9315..9c605f0ce8 100644 --- a/pyrefly/lib/alt/special_calls.rs +++ b/pyrefly/lib/alt/special_calls.rs @@ -153,6 +153,60 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { default } + /// `getattr(obj, "name", default?)`: keep the normal builtin call validation, but when the + /// attribute name is a string literal and the receiver is a class instance with a known + /// attribute path, return that attribute's type instead of typeshed's `Any`. + pub fn call_getattr( + &self, + raw_args: &[Expr], + args: &[CallArg], + callee_ty: Type, + keywords: &[CallKeyword], + func_range: TextRange, + arguments_range: TextRange, + hint: Option, + errors: &ErrorCollector, + ) -> Type { + let default = self.freeform_call_infer( + callee_ty, + args, + keywords, + func_range, + arguments_range, + hint, + errors, + ); + let [_, attr_expr, ..] = raw_args else { + unreachable!("getattr special-casing requires 2 or 3 positional arguments") + }; + let Expr::StringLiteral(attr_literal) = attr_expr else { + return default; + }; + let CallArg::Arg(obj_arg) = &args[0] else { + unreachable!("starred getattr receiver is excluded by the caller") + }; + let suppress_errors = self.error_swallower(); + let obj_ty = obj_arg.infer(self, &suppress_errors); + if !matches!(obj_ty, Type::ClassType(_)) { + return default; + } + let attr_name = Name::new(attr_literal.value.to_string()); + if !self.has_attr(&obj_ty, &attr_name) { + return default; + } + let attr_ty = + self.attr_infer_for_type(&obj_ty, &attr_name, attr_expr.range(), errors, None); + if args.len() == 2 { + attr_ty + } else { + let CallArg::Arg(default_arg) = &args[2] else { + unreachable!("starred getattr default is excluded by the caller") + }; + let default_ty = default_arg.infer(self, &suppress_errors); + self.unions(vec![attr_ty, default_ty]) + } + } + pub fn call_reveal_type( &self, args: &[Expr], diff --git a/pyrefly/lib/test/attributes.rs b/pyrefly/lib/test/attributes.rs index c5c58dcb3c..6fffc8354b 100644 --- a/pyrefly/lib/test/attributes.rs +++ b/pyrefly/lib/test/attributes.rs @@ -915,6 +915,43 @@ def test(foo: Foo) -> None: "#, ); +testcase!( + test_builtin_getattr_literal_attribute_name, + r#" +from typing import Any, assert_type + +class Foo: + x: int + + def __init__(self) -> None: + pass + +def test(foo: Foo, attr_name: str) -> None: + assert_type(getattr(foo, "x"), int) + assert_type(getattr(foo, "x", None), int | None) + assert_type(getattr(foo, "y"), Any) + assert_type(getattr(foo, "y", None), Any | None) + assert_type(getattr(foo, attr_name), Any) + assert_type(getattr(foo, attr_name, None), Any | None) + "#, +); + +testcase!( + test_builtin_getattr_non_special_call_shapes, + r#" +from typing import Any, assert_type + +class Foo: + x: int + +def test(foo: Foo) -> None: + assert_type(getattr(*(foo, "x")), Any) + assert_type(getattr(*[foo], "x"), Any) + assert_type(getattr(foo, "x", *(None,)), Any | None) + getattr(foo, "x", None, None) # E: + "#, +); + testcase!( test_object_setattr, r#"