From edaa5730960fc22e2e41d70265481f480c47e6d7 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Wed, 28 Oct 2026 02:56:14 +0000 Subject: [PATCH 1/3] fix(binding): treat object.__setattr__/setattr as instance init Frozen dataclasses often initialize fields with object.__setattr__ in __init__. Count those (and setattr with a literal name) the same as self.attr = value for uninitialized-instance-variable. Fixes #5326 --- pyrefly/lib/binding/scope.rs | 21 ++++++++++--- pyrefly/lib/binding/stmt.rs | 30 +++++++++++++++++++ .../test/uninitialized_instance_variable.rs | 25 ++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/pyrefly/lib/binding/scope.rs b/pyrefly/lib/binding/scope.rs index 1e3b75eeaf..0a08a92421 100644 --- a/pyrefly/lib/binding/scope.rs +++ b/pyrefly/lib/binding/scope.rs @@ -2232,13 +2232,26 @@ 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) + } + + /// Like `record_self_attr_assign`, but for initialization via `object.__setattr__` + /// / `setattr(self, "attr", value)` where the attribute name is a string literal. + pub fn record_self_attr_assign_named( + &mut self, + receiver: &Expr, + attr_name: &Name, + attr_range: TextRange, + value: ExprOrBinding, + annotation: Option>, ) -> bool { for scope in self.iter_rev_mut() { if let ScopeKind::Method(method_scope) = &mut scope.kind && 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. @@ -2248,11 +2261,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 392eebc8c8..d5495481c4 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -2216,6 +2216,36 @@ 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() { + let is_object_setattr = matches!( + call.func.as_ref(), + Expr::Attribute(func) + if func.attr.id.as_str() == "__setattr__" + && matches!( + func.value.as_ref(), + Expr::Name(base) if base.id.as_str() == "object" + ) + ); + let is_builtin_setattr = matches!( + call.func.as_ref(), + Expr::Name(name) if name.id.as_str() == "setattr" + ); + if (is_object_setattr || is_builtin_setattr) + && call.arguments.args.len() >= 3 + && let Expr::StringLiteral(attr_name) = &call.arguments.args[1] + { + self.scopes.record_self_attr_assign_named( + &call.arguments.args[0], + &Name::new(attr_name.value.to_str()), + attr_name.range, + ExprOrBinding::Expr(call.arguments.args[2].clone()), + None, + ); + } + } let special_export = if let Expr::Call(ExprCall { func, .. }) = &*x.value { self.as_special_export(func) } else { diff --git a/pyrefly/lib/test/uninitialized_instance_variable.rs b/pyrefly/lib/test/uninitialized_instance_variable.rs index f223181b02..fef27d4fa2 100644 --- a/pyrefly/lib/test/uninitialized_instance_variable.rs +++ b/pyrefly/lib/test/uninitialized_instance_variable.rs @@ -149,3 +149,28 @@ class SetOnOtherObject: return super().__new__(cls) "#, ); + +// https://github.com/facebook/pyrefly/issues/5326 +testcase!( + test_uninitialized_instance_variable_frozen_dataclass_setattr, + TestEnv::new().enable_uninitialized_instance_variable_error(), + r#" +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 + def __init__(self, x: int) -> None: + setattr(self, "x", x) +@dataclass(frozen=True, init=False) +class StillUninit: + y: int # E: Instance attribute `y` is declared but never initialized + def __init__(self) -> None: + pass +"#, +); From 36af5423fa6b252112d00e644726b165a80ce723 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Mon, 2 Nov 2026 16:52:23 +0530 Subject: [PATCH 2/3] Address review feedback on setattr instance initialization --- pyrefly/lib/binding/stmt.rs | 36 ++++++------ pyrefly/lib/export/special.rs | 6 ++ .../test/uninitialized_instance_variable.rs | 57 +++++++++++++++++++ 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/pyrefly/lib/binding/stmt.rs b/pyrefly/lib/binding/stmt.rs index 4e96d94e9c..1736338892 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -2318,26 +2318,28 @@ impl<'a> BindingsBuilder<'a> { call.func.as_ref(), Expr::Attribute(func) if func.attr.id.as_str() == "__setattr__" - && matches!( - func.value.as_ref(), - Expr::Name(base) if base.id.as_str() == "object" - ) - ); - let is_builtin_setattr = matches!( - call.func.as_ref(), - Expr::Name(name) if name.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) - && call.arguments.args.len() >= 3 - && let Expr::StringLiteral(attr_name) = &call.arguments.args[1] + && call.arguments.keywords.is_empty() + && let [receiver, Expr::StringLiteral(attr_name), value] = + &*call.arguments.args + && !receiver.is_starred_expr() + && !value.is_starred_expr() { - self.scopes.record_self_attr_assign_named( - &call.arguments.args[0], - &Name::new(attr_name.value.to_str()), - attr_name.range, - ExprOrBinding::Expr(call.arguments.args[2].clone()), - None, - ); + 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, + ); + } } } let special_export = if let Expr::Call(ExprCall { func, .. }) = &*x.value { 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 c6890593a0..0ab2769374 100644 --- a/pyrefly/lib/test/uninitialized_instance_variable.rs +++ b/pyrefly/lib/test/uninitialized_instance_variable.rs @@ -194,6 +194,7 @@ 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) @@ -204,12 +205,68 @@ class File: @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 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) "#, ); From 1ebf82153e5991a24f449684fa441abac6d14282 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Tue, 3 Nov 2026 01:43:23 +0530 Subject: [PATCH 3/3] Avoid classmethod receiver inference and lazily check builtin setattr Filtering setattr call arguments before checking special exports prevents unnecessary export queries on external functions during binding. Additionally, restricting setattr-based attribute initialization to instance method receivers prevents erroneously fabricating instance attributes from classmethod setattr calls. --- pyrefly/lib/binding/scope.rs | 12 ++++++++++- pyrefly/lib/binding/stmt.rs | 16 +++++++-------- .../test/uninitialized_instance_variable.rs | 20 +++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/pyrefly/lib/binding/scope.rs b/pyrefly/lib/binding/scope.rs index 2ebae61ea1..7219f7a1a0 100644 --- a/pyrefly/lib/binding/scope.rs +++ b/pyrefly/lib/binding/scope.rs @@ -2352,11 +2352,19 @@ impl Scopes { value: ExprOrBinding, annotation: Option>, ) -> bool { - self.record_self_attr_assign_named(&x.value, &x.attr.id, x.attr.range(), value, annotation) + 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, @@ -2364,9 +2372,11 @@ impl Scopes { 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!(receiver, Expr::Name(name) if name.id == self_name.id) { diff --git a/pyrefly/lib/binding/stmt.rs b/pyrefly/lib/binding/stmt.rs index 1736338892..0785b164a2 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -2313,7 +2313,12 @@ 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() { + 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) @@ -2323,13 +2328,7 @@ impl<'a> BindingsBuilder<'a> { ); let is_builtin_setattr = self.as_special_export(&call.func) == Some(SpecialExport::SetAttr); - if (is_object_setattr || is_builtin_setattr) - && call.arguments.keywords.is_empty() - && let [receiver, Expr::StringLiteral(attr_name), value] = - &*call.arguments.args - && !receiver.is_starred_expr() - && !value.is_starred_expr() - { + 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( @@ -2338,6 +2337,7 @@ impl<'a> BindingsBuilder<'a> { attr_name.range, ExprOrBinding::Expr(value.clone()), None, + true, ); } } diff --git a/pyrefly/lib/test/uninitialized_instance_variable.rs b/pyrefly/lib/test/uninitialized_instance_variable.rs index 0ab2769374..d4c097e6b2 100644 --- a/pyrefly/lib/test/uninitialized_instance_variable.rs +++ b/pyrefly/lib/test/uninitialized_instance_variable.rs @@ -236,6 +236,12 @@ class StarredName: 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 @@ -270,3 +276,17 @@ class ShadowedObject: 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` +"#, +);