diff --git a/crates/ty_python_semantic/resources/mdtest/slots.md b/crates/ty_python_semantic/resources/mdtest/slots.md new file mode 100644 index 0000000000..f3198d10f8 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/slots.md @@ -0,0 +1,62 @@ +# `__slots__` + +## Basic slot access + +```py +class A: + __slots__ = ("foo", "bar") + + def __init__(self, foo: int, bar: str): + self.foo = foo + self.bar = bar + +a = A(1, "zip") +a.foo = 2 +a.bar = "woo" +a.baz = 3 # error: [unresolved-attribute] +``` + +## Accessing undefined attributes + +```py +class A: + __slots__ = ("x",) + +a = A() +a.y = 1 # error: [unresolved-attribute] +``` + +## Empty slots + +```py +class A: + __slots__ = () + +a = A() +a.x = 1 # error: [unresolved-attribute] +``` + +## Single character string + +```py +class A: + __slots__ = "x" + +a = A() +a.x = 1 # error: [possibly-missing-attribute] +a.y = 2 # error: [unresolved-attribute] +``` + +## Multi-character string + +```py +class A: + __slots__ = "xyz" + +a = A() +a.x = 1 # error: [possibly-missing-attribute] +a.y = 2 # error: [possibly-missing-attribute] +a.z = 3 # error: [possibly-missing-attribute] +a.xyz = 4 # error: [unresolved-attribute] +a.q = 5 # error: [unresolved-attribute] +``` diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs index 61ee82e030..8fee89c060 100644 --- a/crates/ty_python_semantic/src/types/class.rs +++ b/crates/ty_python_semantic/src/types/class.rs @@ -3773,14 +3773,17 @@ impl<'db> ClassLiteral<'db> { // The attribute is not *declared* in the class body. It could still be declared/bound // in a method. - Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) + let result = + Self::implicit_attribute(db, body_scope, name, MethodDecorator::None); + self.apply_slots_constraints(db, name, result.inner) } } } else { // This attribute is neither declared nor bound in the class body. // It could still be implicitly defined in a method. - Self::implicit_attribute(db, body_scope, name, MethodDecorator::None) + let result = Self::implicit_attribute(db, body_scope, name, MethodDecorator::None); + self.apply_slots_constraints(db, name, result.inner) } } @@ -5957,6 +5960,92 @@ impl SlotsKind { } } +/// Helper functions for __slots__ support +impl<'db> ClassLiteral<'db> { + /// Extract the names of attributes defined in __slots__ as a set of strings. + /// Returns None if __slots__ is not defined, empty, or dynamic. + pub(super) fn slots_members(self, db: &'db dyn Db) -> Option> { + let Place::Defined(slots_ty, _, definedness) = self + .own_class_member(db, self.generic_context(db), None, "__slots__") + .inner + .place + else { + return None; + }; + + if matches!(definedness, Definedness::PossiblyUndefined) { + return None; + } + + match slots_ty { + // __slots__ = ("a", "b") + Type::NominalInstance(nominal) => { + if let Some(tuple_spec) = nominal.tuple_spec(db) { + let mut slots = FxHashSet::default(); + for element in tuple_spec.all_elements() { + if let Type::StringLiteral(string_literal) = element { + slots.insert(string_literal.value(db).to_string()); + } else { + // Non-string element, consider it dynamic + return None; + } + } + Some(slots) + } else { + None + } + } + + // __slots__ = "abc" # Expands to slots "a", "b", "c" + Type::StringLiteral(string_literal) => { + let mut slots = FxHashSet::default(); + let slot_value = string_literal.value(db); + + // Python treats a bare string as a sequence of slot names (one per character) + for ch in slot_value.chars() { + slots.insert(ch.to_string()); + } + Some(slots) + } + + _ => None, + } + } + + /// Apply __slots__ constraints to attribute access. + fn apply_slots_constraints( + self, + db: &'db dyn Db, + name: &str, + result: PlaceAndQualifiers<'db>, + ) -> Member<'db> { + // TODO: This function will be extended to support: + // - Inheritance: Check slots across the MRO chain + // - `__dict__` special case: Allow dynamic attributes when `__dict__` is in slots + + if let Some(slots) = self.slots_members(db) { + if slots.contains(name) { + // Attribute is in __slots__, so it's allowed even if not found elsewhere + if result.place.is_undefined() { + // Return as possibly unbound since it's declared but not necessarily initialized + return Member { + inner: Place::Defined( + Type::unknown(), + TypeOrigin::Inferred, + Definedness::PossiblyUndefined, + ) + .into(), + }; + } + return Member { inner: result }; + } + // Attribute is not in __slots__ + return Member::unbound(); + } + Member { inner: result } + } +} + #[cfg(test)] mod tests { use super::*;