Skip to content

Commit

Permalink
Adds support for __slots__ assignment (#10864)
Browse files Browse the repository at this point in the history
### Description

Fixes #10801 

We can now detect assignment that are not matching defined `__slots__`.

Example:

```python
class A:
   __slots__ = ('a',)

class B(A):
   __slots__ = ('b',)
   def __init__(self) -> None:
       self.a = 1  # ok
       self.b = 2  # ok
       self.c = 3  # error

b: B
reveal_type(b.c)
```
  • Loading branch information
sobolevn authored Sep 21, 2021
1 parent b3ff2a6 commit 8ac0dc2
Show file tree
Hide file tree
Showing 7 changed files with 640 additions and 3 deletions.
54 changes: 54 additions & 0 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2228,6 +2228,7 @@ def check_assignment(self, lvalue: Lvalue, rvalue: Expression, infer_lvalue_type
if not inferred.is_final:
rvalue_type = remove_instance_last_known_values(rvalue_type)
self.infer_variable_type(inferred, lvalue, rvalue_type, rvalue)
self.check_assignment_to_slots(lvalue)

# (type, operator) tuples for augmented assignments supported with partial types
partial_type_augmented_ops: Final = {
Expand Down Expand Up @@ -2557,6 +2558,59 @@ def check_final(self,
if lv.node.is_final and not is_final_decl:
self.msg.cant_assign_to_final(name, lv.node.info is None, s)

def check_assignment_to_slots(self, lvalue: Lvalue) -> None:
if not isinstance(lvalue, MemberExpr):
return

inst = get_proper_type(self.expr_checker.accept(lvalue.expr))
if not isinstance(inst, Instance):
return
if inst.type.slots is None:
return # Slots do not exist, we can allow any assignment
if lvalue.name in inst.type.slots:
return # We are assigning to an existing slot
for base_info in inst.type.mro[:-1]:
if base_info.names.get('__setattr__') is not None:
# When type has `__setattr__` defined,
# we can assign any dynamic value.
# We exclude object, because it always has `__setattr__`.
return

definition = inst.type.get(lvalue.name)
if definition is None:
# We don't want to duplicate
# `"SomeType" has no attribute "some_attr"`
# error twice.
return
if self.is_assignable_slot(lvalue, definition.type):
return

self.fail(
'Trying to assign name "{}" that is not in "__slots__" of type "{}"'.format(
lvalue.name, inst.type.fullname,
),
lvalue,
)

def is_assignable_slot(self, lvalue: Lvalue, typ: Optional[Type]) -> bool:
if getattr(lvalue, 'node', None):
return False # This is a definition

typ = get_proper_type(typ)
if typ is None or isinstance(typ, AnyType):
return True # Any can be literally anything, like `@propery`
if isinstance(typ, Instance):
# When working with instances, we need to know if they contain
# `__set__` special method. Like `@property` does.
# This makes assigning to properties possible,
# even without extra slot spec.
return typ.type.get('__set__') is not None
if isinstance(typ, FunctionLike):
return True # Can be a property, or some other magic
if isinstance(typ, UnionType):
return all(self.is_assignable_slot(lvalue, u) for u in typ.items)
return False

def check_assignment_to_multiple_lvalues(self, lvalues: List[Lvalue], rvalue: Expression,
context: Context,
infer_lvalue_type: bool = True) -> None:
Expand Down
5 changes: 5 additions & 0 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2298,6 +2298,10 @@ class is generic then it will be a type constructor of higher kind.
runtime_protocol = False # Does this protocol support isinstance checks?
abstract_attributes: List[str]
deletable_attributes: List[str] # Used by mypyc only
# Does this type have concrete `__slots__` defined?
# If class does not have `__slots__` defined then it is `None`,
# if it has empty `__slots__` then it is an empty set.
slots: Optional[Set[str]]

# The attributes 'assuming' and 'assuming_proper' represent structural subtype matrices.
#
Expand Down Expand Up @@ -2401,6 +2405,7 @@ def __init__(self, names: 'SymbolTable', defn: ClassDef, module_name: str) -> No
self.is_abstract = False
self.abstract_attributes = []
self.deletable_attributes = []
self.slots = None
self.assuming = []
self.assuming_proper = []
self.inferring = []
Expand Down
57 changes: 57 additions & 0 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2048,6 +2048,7 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
self.process_module_assignment(s.lvalues, s.rvalue, s)
self.process__all__(s)
self.process__deletable__(s)
self.process__slots__(s)

def analyze_identity_global_assignment(self, s: AssignmentStmt) -> bool:
"""Special case 'X = X' in global scope.
Expand Down Expand Up @@ -3365,6 +3366,62 @@ def process__deletable__(self, s: AssignmentStmt) -> None:
assert self.type
self.type.deletable_attributes = attrs

def process__slots__(self, s: AssignmentStmt) -> None:
"""
Processing ``__slots__`` if defined in type.
See: https://docs.python.org/3/reference/datamodel.html#slots
"""
# Later we can support `__slots__` defined as `__slots__ = other = ('a', 'b')`
if (isinstance(self.type, TypeInfo) and
len(s.lvalues) == 1 and isinstance(s.lvalues[0], NameExpr) and
s.lvalues[0].name == '__slots__' and s.lvalues[0].kind == MDEF):

# We understand `__slots__` defined as string, tuple, list, set, and dict:
if not isinstance(s.rvalue, (StrExpr, ListExpr, TupleExpr, SetExpr, DictExpr)):
# For example, `__slots__` can be defined as a variable,
# we don't support it for now.
return

if any(p.slots is None for p in self.type.mro[1:-1]):
# At least one type in mro (excluding `self` and `object`)
# does not have concrete `__slots__` defined. Ignoring.
return

concrete_slots = True
rvalue: List[Expression] = []
if isinstance(s.rvalue, StrExpr):
rvalue.append(s.rvalue)
elif isinstance(s.rvalue, (ListExpr, TupleExpr, SetExpr)):
rvalue.extend(s.rvalue.items)
else:
# We have a special treatment of `dict` with possible `{**kwargs}` usage.
# In this case we consider all `__slots__` to be non-concrete.
for key, _ in s.rvalue.items:
if concrete_slots and key is not None:
rvalue.append(key)
else:
concrete_slots = False

slots = []
for item in rvalue:
# Special case for `'__dict__'` value:
# when specified it will still allow any attribute assignment.
if isinstance(item, StrExpr) and item.value != '__dict__':
slots.append(item.value)
else:
concrete_slots = False
if not concrete_slots:
# Some slot items are dynamic, we don't want any false positives,
# so, we just pretend that this type does not have any slots at all.
return

# We need to copy all slots from super types:
for super_type in self.type.mro[1:-1]:
assert super_type.slots is not None
slots.extend(super_type.slots)
self.type.slots = set(slots)

#
# Misc statements
#
Expand Down
1 change: 1 addition & 0 deletions mypy/test/testcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
'check-typeguard.test',
'check-functools.test',
'check-singledispatch.test',
'check-slots.test',
]

# Tests that use Python 3.8-only AST features (like expression-scoped ignores):
Expand Down
Loading

0 comments on commit 8ac0dc2

Please sign in to comment.