diff --git a/pyrefly/lib/binding/scope.rs b/pyrefly/lib/binding/scope.rs index 17eeb50ca4..7219f7a1a0 100644 --- a/pyrefly/lib/binding/scope.rs +++ b/pyrefly/lib/binding/scope.rs @@ -2351,13 +2351,36 @@ impl Scopes { x: &ExprAttribute, value: ExprOrBinding, annotation: Option>, + ) -> bool { + self.record_self_attr_assign_named( + &x.value, + &x.attr.id, + x.attr.range(), + value, + annotation, + false, + ) + } + + /// Like `record_self_attr_assign`, but for initialization via `object.__setattr__` + /// / `setattr(self, "attr", value)` where the attribute name is a string literal. + /// When `instance_only` is true, classmethod receivers (e.g. `cls`) are ignored. + pub fn record_self_attr_assign_named( + &mut self, + receiver: &Expr, + attr_name: &Name, + attr_range: TextRange, + value: ExprOrBinding, + annotation: Option>, + instance_only: bool, ) -> bool { for scope in self.iter_rev_mut() { if let ScopeKind::Method(method_scope) = &mut scope.kind + && (!instance_only || method_scope.receiver_kind == MethodSelfKind::Instance) && let Some(self_name) = &method_scope.self_name - && matches!(&*x.value, Expr::Name(name) if name.id == self_name.id) + && matches!(receiver, Expr::Name(name) if name.id == self_name.id) { - if let Some(attr) = method_scope.instance_attributes.get_mut(&x.attr.id) { + if let Some(attr) = method_scope.instance_attributes.get_mut(attr_name) { // Accumulate subsequent assignments in the method. attr.0.push(value); // Keep the first type annotation encountered in the method. @@ -2367,11 +2390,11 @@ impl Scopes { } else { // First time seeing this attribute in this method: record it. method_scope.instance_attributes.insert( - x.attr.id.clone(), + attr_name.clone(), InstanceAttribute( vec![value], annotation, - x.attr.range(), // Keep the range of the first assignment as the definition location. + attr_range, // Keep the range of the first assignment as the definition location. method_scope.receiver_kind, ), ); diff --git a/pyrefly/lib/binding/stmt.rs b/pyrefly/lib/binding/stmt.rs index fd2e62d8ed..0785b164a2 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -2310,6 +2310,38 @@ impl<'a> BindingsBuilder<'a> { ); } } + // `object.__setattr__(self, "attr", value)` and `setattr(self, "attr", value)` + // initialize instance attributes the same way `self.attr = value` does. + // Frozen dataclasses commonly use this pattern in `__init__`. + if let Expr::Call(call) = x.value.as_ref() + && call.arguments.keywords.is_empty() + && let [receiver, Expr::StringLiteral(attr_name), value] = &*call.arguments.args + && !receiver.is_starred_expr() + && !value.is_starred_expr() + { + let is_object_setattr = matches!( + call.func.as_ref(), + Expr::Attribute(func) + if func.attr.id.as_str() == "__setattr__" + && self.as_special_export(&func.value) + == Some(SpecialExport::BuiltinsObject) + ); + let is_builtin_setattr = + self.as_special_export(&call.func) == Some(SpecialExport::SetAttr); + if is_object_setattr || is_builtin_setattr { + let name = Name::new(attr_name.value.to_str()); + if !Ast::is_mangled_attr(&name) { + self.scopes.record_self_attr_assign_named( + receiver, + &name, + attr_name.range, + ExprOrBinding::Expr(value.clone()), + None, + true, + ); + } + } + } let special_export = if let Expr::Call(ExprCall { func, .. }) = &*x.value { self.as_special_export(func) } else { diff --git a/pyrefly/lib/export/special.rs b/pyrefly/lib/export/special.rs index e9917dfa9a..d0da065663 100644 --- a/pyrefly/lib/export/special.rs +++ b/pyrefly/lib/export/special.rs @@ -47,6 +47,7 @@ pub enum SpecialExport { Range, Bool, BuiltinsType, + BuiltinsObject, TypingType, NoTypeCheck, NotImplemented, @@ -64,6 +65,7 @@ pub enum SpecialExport { PydanticToSnake, HasAttr, GetAttr, + SetAttr, Callable, BuiltinsDict, TypingDict, @@ -155,6 +157,8 @@ impl SpecialExport { "to_snake" => Some(Self::PydanticToSnake), "hasattr" => Some(Self::HasAttr), "getattr" => Some(Self::GetAttr), + "setattr" => Some(Self::SetAttr), + "object" => Some(Self::BuiltinsObject), "TypeAliasType" => Some(Self::TypeAliasType), "Callable" => Some(Self::Callable), "dict" => Some(Self::BuiltinsDict), @@ -241,8 +245,10 @@ impl SpecialExport { | Self::Quit | Self::Bool | Self::BuiltinsType + | Self::BuiltinsObject | Self::HasAttr | Self::GetAttr + | Self::SetAttr | Self::ClassMethod | Self::StaticMethod | Self::BuiltinsDict diff --git a/pyrefly/lib/test/uninitialized_instance_variable.rs b/pyrefly/lib/test/uninitialized_instance_variable.rs index 39fee77b40..d4c097e6b2 100644 --- a/pyrefly/lib/test/uninitialized_instance_variable.rs +++ b/pyrefly/lib/test/uninitialized_instance_variable.rs @@ -188,3 +188,105 @@ class NotAnEnum: _value_: str # E: Instance attribute `_value_` is declared but never initialized "#, ); + +// https://github.com/facebook/pyrefly/issues/5326 +testcase!( + test_uninitialized_instance_variable_frozen_dataclass_setattr, + TestEnv::new().enable_uninitialized_instance_variable_error(), + r#" +import builtins +from dataclasses import dataclass +from pathlib import PurePosixPath +@dataclass(frozen=True, slots=True, init=False) +class File: + inner: tuple[PurePosixPath, int] + def __init__(self, path: str, /, *, size: int) -> None: + object.__setattr__(self, "inner", (PurePosixPath(path), size)) +@dataclass(frozen=True, init=False) +class ViaBuiltin: + x: int + _protected: int + def __init__(self, x: int) -> None: + setattr(self, "x", x) + object.__setattr__(self, "_protected", 1) +@dataclass(frozen=True, init=False) +class ViaImportedBuiltins: + x: int + y: int + def __init__(self) -> None: + builtins.object.__setattr__(self, "x", 1) + builtins.setattr(self, "y", 2) +@dataclass(frozen=True, init=False) +class StillUninit: + y: int # E: Instance attribute `y` is declared but never initialized + def __init__(self) -> None: + pass +@dataclass(frozen=True, init=False) +class StarredArg: + x: int # E: Instance attribute `x` is declared but never initialized + def __init__(self) -> None: + object.__setattr__(self, "x", *[1]) +class PlainStarred: + def __init__(self) -> None: + object.__setattr__(self, "x", *[1]) + setattr(self, "x", *[1]) +@dataclass(frozen=True, init=False) +class StarredName: + x: int # E: Instance attribute `x` is declared but never initialized + def __init__(self) -> None: + object.__setattr__(self, *["x", 1]) +@dataclass(frozen=True, init=False) +class ClassmethodReceiver: + x: int # E: Instance attribute `x` is declared but never initialized + @classmethod + def init_class(cls) -> None: + setattr(cls, "x", 1) +@dataclass(frozen=True, init=False) +class MangledPrivate: + __x: int # E: Instance attribute `__x` is declared but never initialized + __y: int # E: Instance attribute `__y` is declared but never initialized + def __init__(self) -> None: + object.__setattr__(self, "__x", 1) + setattr(self, "__y", 1) +"#, +); + +testcase!( + test_uninitialized_instance_variable_setattr_shadowed, + TestEnv::new().enable_uninitialized_instance_variable_error(), + r#" +from dataclasses import dataclass +from typing import Any + +def setattr(obj: object, name: str, value: object) -> None: + pass + +@dataclass(frozen=True, init=False) +class ShadowedSetattr: + x: int # E: Instance attribute `x` is declared but never initialized + def __init__(self) -> None: + setattr(self, "x", 1) + +object: Any = None + +@dataclass(frozen=True, init=False) +class ShadowedObject: + y: int # E: Instance attribute `y` is declared but never initialized + def __init__(self) -> None: + object.__setattr__(self, "y", 1) +"#, +); + +testcase!( + test_setattr_on_classmethod_receiver_ignored, + TestEnv::new().enable_implicitly_defined_attribute_error(), + r#" +class ClsSetattr: + @classmethod + def setup(cls) -> None: + setattr(cls, "x", 1) + +def check(c: ClsSetattr) -> None: + _ = c.x # E: Object of class `ClsSetattr` has no attribute `x` +"#, +);