From 8181dc9691df9a09cc7cc2e2f3e3821edd03fec3 Mon Sep 17 00:00:00 2001 From: Andrew Haigh Date: Sun, 16 May 2021 06:38:31 +1000 Subject: [PATCH] Inferring property fields in a class context when metaclass is present (#941) * Add tests of failing property inference Ref #940 * Fix inference of properties in a class context Ref #940. If we are accessing an attribute and it is found on type(A).__dict__ *and* it is a data descriptor, then we resolve that descriptor. For the case of inferring a property which is accessed as a class attribute, this equates to the property being defined on the metaclass (which may be anywhere in the class hierarchy). * Fix inference of Enum.__members__ property for subclasses Ref PyCQA/pylint#3535. Ref PyCQA/pylint#4358. This updates the namedtuple/enum brain to add a dictionary for __members__ --- ChangeLog | 9 ++ astroid/brain/brain_namedtuple_enum.py | 10 ++ astroid/scoped_nodes.py | 11 +- tests/unittest_scoped_nodes.py | 147 +++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 4 deletions(-) diff --git a/ChangeLog b/ChangeLog index 4343be3fee..9d7b945fde 100644 --- a/ChangeLog +++ b/ChangeLog @@ -28,6 +28,15 @@ Release Date: TBA Closes #898 +* Fix property inference in class contexts for properties defined on the metaclass + + Closes #940 + +* Update enum brain to fix definition of __members__ for subclass-defined Enums + + Closes PyCQA/pylint#3535 + Closes PyCQA/pylint#4358 + What's New in astroid 2.5.6? ============================ diff --git a/astroid/brain/brain_namedtuple_enum.py b/astroid/brain/brain_namedtuple_enum.py index 1af5ba2bb4..9a9cc98981 100644 --- a/astroid/brain/brain_namedtuple_enum.py +++ b/astroid/brain/brain_namedtuple_enum.py @@ -315,6 +315,7 @@ def infer_enum_class(node): if node.root().name == "enum": # Skip if the class is directly from enum module. break + dunder_members = {} for local, values in node.locals.items(): if any(not isinstance(value, nodes.AssignName) for value in values): continue @@ -372,7 +373,16 @@ def name(self): for method in node.mymethods(): fake.locals[method.name] = [method] new_targets.append(fake.instantiate_class()) + dunder_members[local] = fake node.locals[local] = new_targets + members = nodes.Dict(parent=node) + members.postinit( + [ + (nodes.Const(k, parent=members), nodes.Name(v.name, parent=members)) + for k, v in dunder_members.items() + ] + ) + node.locals["__members__"] = [members] break return node diff --git a/astroid/scoped_nodes.py b/astroid/scoped_nodes.py index 333f42fe55..27237e8296 100644 --- a/astroid/scoped_nodes.py +++ b/astroid/scoped_nodes.py @@ -2554,7 +2554,7 @@ def igetattr(self, name, context=None, class_context=True): context = contextmod.copy_context(context) context.lookupname = name - metaclass = self.declared_metaclass(context=context) + metaclass = self.metaclass(context=context) try: attributes = self.getattr(name, context, class_context=class_context) # If we have more than one attribute, make sure that those starting from @@ -2587,9 +2587,12 @@ def igetattr(self, name, context=None, class_context=True): yield from function.infer_call_result( caller=self, context=context ) - # If we have a metaclass, we're accessing this attribute through - # the class itself, which means we can solve the property - elif metaclass: + # If we're in a class context, we need to determine if the property + # was defined in the metaclass (a derived class must be a subclass of + # the metaclass of all its bases), in which case we can resolve the + # property. If not, i.e. the property is defined in some base class + # instead, then we return the property object + elif metaclass and function.parent.scope() is metaclass: # Resolve a property as long as it is not accessed through # the class itself. yield from function.infer_call_result( diff --git a/tests/unittest_scoped_nodes.py b/tests/unittest_scoped_nodes.py index 9303f1c7eb..7fe537b2fb 100644 --- a/tests/unittest_scoped_nodes.py +++ b/tests/unittest_scoped_nodes.py @@ -1923,6 +1923,153 @@ def update(self): builder.parse(data) +def test_issue940_metaclass_subclass_property(): + node = builder.extract_node( + """ + class BaseMeta(type): + @property + def __members__(cls): + return ['a', 'property'] + class Parent(metaclass=BaseMeta): + pass + class Derived(Parent): + pass + Derived.__members__ + """ + ) + inferred = next(node.infer()) + assert isinstance(inferred, nodes.List) + assert [c.value for c in inferred.elts] == ["a", "property"] + + +def test_issue940_property_grandchild(): + node = builder.extract_node( + """ + class Grandparent: + @property + def __members__(self): + return ['a', 'property'] + class Parent(Grandparent): + pass + class Child(Parent): + pass + Child().__members__ + """ + ) + inferred = next(node.infer()) + assert isinstance(inferred, nodes.List) + assert [c.value for c in inferred.elts] == ["a", "property"] + + +def test_issue940_metaclass_property(): + node = builder.extract_node( + """ + class BaseMeta(type): + @property + def __members__(cls): + return ['a', 'property'] + class Parent(metaclass=BaseMeta): + pass + Parent.__members__ + """ + ) + inferred = next(node.infer()) + assert isinstance(inferred, nodes.List) + assert [c.value for c in inferred.elts] == ["a", "property"] + + +def test_issue940_with_metaclass_class_context_property(): + node = builder.extract_node( + """ + class BaseMeta(type): + pass + class Parent(metaclass=BaseMeta): + @property + def __members__(self): + return ['a', 'property'] + class Derived(Parent): + pass + Derived.__members__ + """ + ) + inferred = next(node.infer()) + assert not isinstance(inferred, nodes.List) + assert isinstance(inferred, objects.Property) + + +def test_issue940_metaclass_values_funcdef(): + node = builder.extract_node( + """ + class BaseMeta(type): + def __members__(cls): + return ['a', 'func'] + class Parent(metaclass=BaseMeta): + pass + Parent.__members__() + """ + ) + inferred = next(node.infer()) + assert isinstance(inferred, nodes.List) + assert [c.value for c in inferred.elts] == ["a", "func"] + + +def test_issue940_metaclass_derived_funcdef(): + node = builder.extract_node( + """ + class BaseMeta(type): + def __members__(cls): + return ['a', 'func'] + class Parent(metaclass=BaseMeta): + pass + class Derived(Parent): + pass + Derived.__members__() + """ + ) + inferred_result = next(node.infer()) + assert isinstance(inferred_result, nodes.List) + assert [c.value for c in inferred_result.elts] == ["a", "func"] + + +def test_issue940_metaclass_funcdef_is_not_datadescriptor(): + node = builder.extract_node( + """ + class BaseMeta(type): + def __members__(cls): + return ['a', 'property'] + class Parent(metaclass=BaseMeta): + @property + def __members__(cls): + return BaseMeta.__members__() + class Derived(Parent): + pass + Derived.__members__ + """ + ) + # Here the function is defined on the metaclass, but the property + # is defined on the base class. When loading the attribute in a + # class context, this should return the property object instead of + # resolving the data descriptor + inferred = next(node.infer()) + assert isinstance(inferred, objects.Property) + + +def test_issue940_enums_as_a_real_world_usecase(): + node = builder.extract_node( + """ + from enum import Enum + class Sounds(Enum): + bee = "buzz" + cat = "meow" + Sounds.__members__ + """ + ) + inferred_result = next(node.infer()) + assert isinstance(inferred_result, nodes.Dict) + actual = [k.value for k, _ in inferred_result.items] + assert sorted(actual) == ["bee", "cat"] + + def test_metaclass_cannot_infer_call_yields_an_instance(): node = builder.extract_node( """