Repository: pylint-dev/astroid

Issue:
@property members defined in metaclasses of a base class are not correctly inferred
Ref https://github.com/PyCQA/astroid/issues/927#issuecomment-817244963

Inference works on the parent class but not the child in the following example:

```python
class BaseMeta(type):
    @property
    def __members__(cls):
        return ['a', 'property']
class Parent(metaclass=BaseMeta):
    pass
class Derived(Parent):
    pass
Parent.__members__  # [<Set.set l.10 at 0x...>]
Derived.__members__  # [<Property.__members__ l.8 at 0x...>]
```


Discussion:
Looks like this is caused by https://github.com/PyCQA/astroid/blob/f2b197a4f8af0ceeddf435747a5c937c8632872a/astroid/scoped_nodes.py#L2590-L2603. When we are inferring an attribute on a derived class then `class_context` is `True` but `metaclass` is `False` so the property itself is returned.

Patch:
diff --git a/astroid/brain/brain_namedtuple_enum.py b/astroid/brain/brain_namedtuple_enum.py
--- 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
--- 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(


Test patch:
diff --git a/tests/unittest_scoped_nodes.py b/tests/unittest_scoped_nodes.py
--- 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(
         """