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
17 changes: 17 additions & 0 deletions pyrefly/lib/alt/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(_))
Expand Down
54 changes: 54 additions & 0 deletions pyrefly/lib/alt/special_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HintRef>,
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],
Expand Down
37 changes: 37 additions & 0 deletions pyrefly/lib/test/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"#,
Comment thread
asukaminato0721 marked this conversation as resolved.
);

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#"
Expand Down
Loading