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
31 changes: 27 additions & 4 deletions pyrefly/lib/binding/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2351,13 +2351,36 @@ impl Scopes {
x: &ExprAttribute,
value: ExprOrBinding,
annotation: Option<Idx<KeyAnnotation>>,
) -> 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<Idx<KeyAnnotation>>,
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.
Expand All @@ -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,
),
);
Expand Down
32 changes: 32 additions & 0 deletions pyrefly/lib/binding/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions pyrefly/lib/export/special.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub enum SpecialExport {
Range,
Bool,
BuiltinsType,
BuiltinsObject,
TypingType,
NoTypeCheck,
NotImplemented,
Expand All @@ -64,6 +65,7 @@ pub enum SpecialExport {
PydanticToSnake,
HasAttr,
GetAttr,
SetAttr,
Callable,
BuiltinsDict,
TypingDict,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions pyrefly/lib/test/uninitialized_instance_variable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
"#,
);
Loading