Skip to content
Closed
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
22 changes: 22 additions & 0 deletions conformance/third_party/conformance.exp
Original file line number Diff line number Diff line change
Expand Up @@ -3583,6 +3583,28 @@
"stop_column": 10,
"stop_line": 11
},
{
"code": -2,
"column": 9,
"concise_description": "Cannot assign to attribute `y`: not listed in `__slots__` of class `DC2`",
"description": "Cannot assign to attribute `y`: not listed in `__slots__` of class `DC2`",
"line": 25,
"name": "missing-attribute",
"severity": "error",
"stop_column": 15,
"stop_line": 25
},
{
"code": -2,
"column": 9,
"concise_description": "Cannot assign to attribute `y`: not listed in `__slots__` of class `DC3`",
"description": "Cannot assign to attribute `y`: not listed in `__slots__` of class `DC3`",
"line": 38,
"name": "missing-attribute",
"severity": "error",
"stop_column": 15,
"stop_line": 38
},
{
"code": -2,
"column": 1,
Expand Down
5 changes: 1 addition & 4 deletions conformance/third_party/conformance.result
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,7 @@
"dataclasses_match_args.py": [],
"dataclasses_order.py": [],
"dataclasses_postinit.py": [],
"dataclasses_slots.py": [
"Line 25: Expected 1 errors",
"Line 38: Expected 1 errors"
],
"dataclasses_slots.py": [],
"dataclasses_transform_class.py": [],
"dataclasses_transform_converter.py": [],
"dataclasses_transform_field.py": [],
Expand Down
10 changes: 5 additions & 5 deletions conformance/third_party/results.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"total": 138,
"pass": 106,
"fail": 32,
"pass_rate": 0.77,
"differences": 132,
"pass": 107,
"fail": 31,
"pass_rate": 0.78,
"differences": 131,
"passing": [
"aliases_explicit.py",
"aliases_newtype.py",
Expand All @@ -28,6 +28,7 @@
"dataclasses_match_args.py",
"dataclasses_order.py",
"dataclasses_postinit.py",
"dataclasses_slots.py",
"dataclasses_transform_class.py",
"dataclasses_transform_converter.py",
"dataclasses_transform_field.py",
Expand Down Expand Up @@ -123,7 +124,6 @@
"constructors_call_init.py": 1,
"constructors_call_type.py": 4,
"constructors_callable.py": 9,
"dataclasses_slots.py": 2,
"enums_members.py": 1,
"exceptions_context_managers.py": 2,
"generics_base_class.py": 1,
Expand Down
124 changes: 124 additions & 0 deletions pyrefly/lib/alt/attr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,21 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
errors: &ErrorCollector,
context: Option<&dyn Fn() -> ErrorContext>,
) {
// Check if this is a slots violation FIRST (before __setattr__ lookup).
// This is important because object.__setattr__ is always found, but we still
// want to emit a more specific error for slots violations.
if let NotFoundOn::ClassInstance(class, _) = &not_found {
if let Some(msg) = self.check_attr_violates_slots(class, attr_name) {
self.error(
errors,
range,
ErrorInfo::new(ErrorKind::MissingAttribute, context),
msg,
);
return;
}
}

let (setattr_found, setattr_not_found, setattr_error) = self
.lookup_magic_dunder_attr(attr_base, &dunder::SETATTR)
.decompose();
Expand Down Expand Up @@ -801,6 +816,85 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
}
}

/// Check if assigning to an attribute violates `__slots__` restrictions.
///
/// Returns `Some(error_message)` if the attribute is not allowed by slots,
/// `None` if the attribute is allowed or no slots restrictions apply.
fn check_attr_violates_slots(&self, class: &Class, attr_name: &Name) -> Option<String> {
// Collect slots from the class and all ancestors.
// According to CPython semantics:
// - If any class in MRO has `__dict__` in slots, allow everything
// - If any class in MRO is missing `__slots__`, it has implicit `__dict__`, allow everything
// - Otherwise, the attribute must be in the union of all slots in the MRO

let mut all_slots: SmallSet<Name> = SmallSet::new();
let mut has_any_slots = false;

// Check current class - compute slots metadata on-demand to ensure fields are solved
let metadata = self.get_metadata_for_class(class);
let slots = self.compute_slots_metadata(class, metadata.dataclass_metadata());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we stored the slots metadata in the class, why are we computing it here as well? More generally, why is recomputing needed/can we find a way to compute this only once for each class?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I actually tried switching to use the stored metadata.slots_metadata() but it broke the tests. I believe the issue is timing: slots_metadata gets computed during class_metadata_of() which happens early in the solving process, before the __slots__ field type is fully resolved. At that point the type might still be Any or not have the literal strings extracted yet. If we want to avoid recomputation in the future, we'd probably need some kind of lazy/deferred computation pattern (compute on first access rather than during class metadata construction). Could potentially be a followup discussion?

match slots {
Some(slots) => {
if slots.has_dict_slot {
return None; // __dict__ in slots allows any attribute
}
has_any_slots = true;
all_slots.extend(slots.slots.iter().cloned());
}
None => {
// Current class has no __slots__ definition, so it has implicit __dict__.
// This means the instance can have arbitrary attributes.
return None;
}
}

// Check ancestors - compute slots metadata on-demand for each
let mro = self.get_mro_for_class(class);
for ancestor in mro.ancestors(self.stdlib) {
// Special case: `object` is the root of all classes and implicitly allows
// __slots__ to work. Don't treat object's lack of __slots__ as having __dict__.
if ancestor.is_builtin("object") {
continue;
}

let anc_class = ancestor.class_object();
let anc_metadata = self.get_metadata_for_class(anc_class);
let anc_slots =
self.compute_slots_metadata(anc_class, anc_metadata.dataclass_metadata());
match anc_slots {
Some(slots) => {
if slots.has_dict_slot {
return None; // __dict__ in ancestor's slots allows any attribute
}
has_any_slots = true;
all_slots.extend(slots.slots.iter().cloned());
}
None => {
// Ancestor has no __slots__ definition, so it has implicit __dict__.
// This means the class can have arbitrary attributes.
return None;
}
}
}

// If no class in the hierarchy has __slots__, there are no restrictions.
if !has_any_slots {
return None;
}

// Check if the attribute is in the combined slots.
if all_slots.contains(attr_name) {
return None;
}

// Attribute is not in slots - this is a violation.
Some(format!(
"Cannot assign to attribute `{}`: not listed in `__slots__` of class `{}`",
attr_name,
class.name()
))
}

fn check_delattr(
&self,
attr_base: AttributeBase,
Expand Down Expand Up @@ -926,6 +1020,36 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
);
}
Attribute::ClassAttribute(class_attr) => {
// Only check slots for regular instance attributes (ReadWrite/ReadOnly).
// Properties and Descriptors are class-level attributes that don't consume slots.
// Python's __slots__ only restricts instance attributes, not descriptors.
let should_check_slots = matches!(
&class_attr,
ClassAttribute::ReadWrite(_) | ClassAttribute::ReadOnly(_, _)
);

if should_check_slots {
// Check slots restriction before allowing the write.
// This covers instance attribute assignments like `self.y = 3`.
let instance_class_for_slots = match &found_on {
AttributeBase1::ClassInstance(cls) => Some(cls.class_object()),
AttributeBase1::SelfType(cls) => Some(cls.class_object()),
_ => None,
};
if let Some(class) = instance_class_for_slots {
if let Some(msg) = self.check_attr_violates_slots(class, attr_name) {
self.error(
errors,
range,
ErrorInfo::new(ErrorKind::MissingAttribute, context),
msg,
);
should_narrow = false;
continue;
}
}
}

// If we are writing to an instance, we may need access to
// the class to special-case dataclass converters.
let instance_class = match &found_on {
Expand Down
86 changes: 86 additions & 0 deletions pyrefly/lib/alt/class/class_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use dupe::Dupe;
use itertools::Either;
use itertools::Itertools;
use pyrefly_graph::index::Idx;
use pyrefly_python::dunder;
use pyrefly_python::module_name::ModuleName;
use pyrefly_python::short_identifier::ShortIdentifier;
use pyrefly_types::annotation::Annotation;
Expand Down Expand Up @@ -44,6 +45,7 @@ use crate::alt::types::class_metadata::InitDefaults;
use crate::alt::types::class_metadata::Metaclass;
use crate::alt::types::class_metadata::NamedTupleMetadata;
use crate::alt::types::class_metadata::ProtocolMetadata;
use crate::alt::types::class_metadata::SlotsMetadata;
use crate::alt::types::class_metadata::TotalOrderingMetadata;
use crate::alt::types::class_metadata::TypedDictMetadata;
use crate::alt::types::decorated_function::Decorator;
Expand All @@ -69,6 +71,7 @@ use crate::types::keywords::DataclassKeywords;
use crate::types::keywords::DataclassTransformMetadata;
use crate::types::keywords::TypeMap;
use crate::types::literal::Lit;
use crate::types::tuple::Tuple;
use crate::types::types::CalleeKind;
use crate::types::types::Type;

Expand Down Expand Up @@ -339,6 +342,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
.as_ref()
.map(|m| m.pydantic_model_kind.clone());

// Compute slots metadata from __slots__ or dataclass(slots=True)
let slots_metadata = self.compute_slots_metadata(cls, dataclass_metadata.as_ref());

ClassMetadata::new(
bases,
calculated_metaclass,
Expand All @@ -359,6 +365,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
dataclass_transform_metadata,
pydantic_model_kind,
django_model_metadata,
slots_metadata,
)
}

Expand Down Expand Up @@ -1254,4 +1261,83 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
}
false
}

/// Compute slots metadata for a class.
///
/// Returns `Some(SlotsMetadata)` if:
/// - The class has a manual `__slots__` definition, OR
/// - The class is a `@dataclass(slots=True)`
///
/// Returns `None` if the class has no slots (has implicit `__dict__`).
///
/// Note: This function should be called on-demand during slots checks
/// to ensure the `__slots__` field is fully solved.
pub fn compute_slots_metadata(
&self,
cls: &Class,
dataclass_metadata: Option<&DataclassMetadata>,
) -> Option<SlotsMetadata> {
// For dataclass(slots=True), use the dataclass fields as slots.
// Note: The synthesized __slots__ field is added by dataclass.rs, but we compute
// the SlotsMetadata here based on the dataclass fields.
if let Some(dc) = dataclass_metadata
&& dc.kws.slots
{
return Some(SlotsMetadata {
slots: dc.fields.clone(),
has_dict_slot: false,
});
}

// Check for manual __slots__ definition on this class.
// We only look at the current class, not ancestors (handled during validation).
if !cls.contains(&dunder::SLOTS) {
return None;
}

let slots_field = self.get_field_from_current_class_only(cls, &dunder::SLOTS)?;
let slots_ty = slots_field.ty();

// Extract string literals from the slots type.
let mut slots = SmallSet::new();
let mut has_dict_slot = false;

match &slots_ty {
Type::Tuple(Tuple::Concrete(elts)) => {
for elt in elts.iter() {
if let Type::Literal(Lit::Str(s)) = elt {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am seeing various new errors coming from mypy primer. Curious if there is any sense on whether those are legitimate?

Also, do we know that at this point, pyrefly is inferring the literal types we expect for slots?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into these, most are false positives. The slots check was running for all attribute types including properties/descriptors, but Python's __slots__ only restricts instance attributes. For example, dulwich's Blob.data and many discord.py attributes are properties, not instance slots.

Fixed by only checking slots for ReadWrite/ReadOnly class attributes, skipping Property and Descriptor variants.

For the literal type inference, the code extracts literal strings from the tuple (the Type::Tuple(Tuple::Concrete(elts)) match).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!
Are the current mypy primer results meant to be the latest results? I am still seeing a lot of false positives.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, those are the latest results. I'm not completely sure myself which ones are still false positives (after adding the descriptor/property fix), but I believe they appear because slots checking didn't exist before this PR, so they're "new" in the sense that we're now checking something we weren't before.

Open to more feedback for the primer results.

let name = Name::new(s.as_str());
if name.as_str() == "__dict__" {
has_dict_slot = true;
} else {
slots.insert(name);
}
}
}
}
// Empty tuple: __slots__ = ()
Type::Tuple(Tuple::Unbounded(_)) => {
// No slots defined, but __slots__ exists (restricts to no dynamic attrs)
}
// Single string literal: __slots__ = "x" (rare but valid)
Type::Literal(Lit::Str(s)) => {
let name = Name::new(s.as_str());
if name.as_str() == "__dict__" {
has_dict_slot = true;
} else {
slots.insert(name);
}
}
_ => {
// Cannot determine slots statically (e.g., __slots__ = get_slots()).
// Treat as having __dict__ to avoid false positives.
has_dict_slot = true;
}
}

Some(SlotsMetadata {
slots,
has_dict_slot,
})
}
}
Loading
Loading