Skip to content

Commit

Permalink
Allow slots added dynamically to a class to still be inferred
Browse files Browse the repository at this point in the history
In 2aa27e9 `ClassDef.igetattr`
was modified to only grab the first item from the result of `getattr`,
in order to avoid looking up attributes in the ancestors path when
inferring attributes for a given class. This had the side effect
that we'd omit attribute definitions happening in the same scope,
such as augmented assignments, which in turn might have affected
other capabilities, such as slots inference.

This commit changes the approach a bit and keeps all attributes
as long as all of them are from the same class (be it current
or an ancestor)

Close pylint-dev/pylint#2334
  • Loading branch information
PCManticore committed Mar 13, 2020
1 parent 252dd19 commit ab9d147
Show file tree
Hide file tree
Showing 3 changed files with 35 additions and 3 deletions.
5 changes: 5 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ Release Date: TBA
source code as a string and return the corresponding astroid object

Closes PyCQA/astroid#725

* Allow slots added dynamically to a class to still be inferred

Close PyCQA/pylint#2334

* Infer qualified ``classmethod`` as a classmethod.

Close PyCQA/pylint#3417
Expand Down
18 changes: 15 additions & 3 deletions astroid/scoped_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2514,8 +2514,20 @@ def igetattr(self, name, context=None, class_context=True):

metaclass = self.declared_metaclass(context=context)
try:
attr = self.getattr(name, context, class_context=class_context)[0]
for inferred in bases._infer_stmts([attr], context, frame=self):
attributes = self.getattr(name, context, class_context=class_context)
# If we have more than one attribute, make sure that those starting from
# the second one are from the same scope. This is to account for modifications
# to the attribute happening *after* the attribute's definition (e.g. AugAssigns on lists)
if len(attributes) > 1:
first_attr, attributes = attributes[0], attributes[1:]
first_scope = first_attr.scope()
attributes = [first_attr] + [
attr
for attr in attributes
if attr.parent and attr.parent.scope() == first_scope
]

for inferred in bases._infer_stmts(attributes, context, frame=self):
# yield Uninferable object instead of descriptors when necessary
if not isinstance(inferred, node_classes.Const) and isinstance(
inferred, bases.Instance
Expand Down Expand Up @@ -2815,7 +2827,7 @@ def grouped_slots():
if not all(slot is not None for slot in slots):
return None

return sorted(slots, key=lambda item: item.value)
return sorted(set(slots), key=lambda item: item.value)

def _inferred_bases(self, context=None):
# Similar with .ancestors, but the difference is when one base is inferred,
Expand Down
15 changes: 15 additions & 0 deletions tests/unittest_scoped_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,21 @@ class C(B):
cls = module["B"]
self.assertIsNone(cls.slots())

def test_slots_added_dynamically_still_inferred(self):
code = """
class NodeBase(object):
__slots__ = "a", "b"
if Options.isFullCompat():
__slots__ += ("c",)
"""
node = builder.extract_node(code)
inferred = next(node.infer())
slots = inferred.slots()
assert len(slots) == 3, slots
assert [slot.value for slot in slots] == ["a", "b", "c"]

def assertEqualMro(self, klass, expected_mro):
self.assertEqual([member.name for member in klass.mro()], expected_mro)

Expand Down

0 comments on commit ab9d147

Please sign in to comment.