Move object dunders from `FunctionModel` to `ObjectModel` (#2847)

Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
diff --git a/ChangeLog b/ChangeLog
index 85fca74..08b9605 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -7,6 +7,12 @@
 ============================
 Release date: TBA
 
+* Move object dunder methods from ``FunctionModel`` to ``ObjectModel`` to make them
+  available on all object types, not just functions.
+
+  Closes #2742
+  Closes #2741
+  Closes pylint-dev/pylint#6094
 
 
 What's New in astroid 4.0.1?
diff --git a/astroid/bases.py b/astroid/bases.py
index 625da31..7fb3da2 100644
--- a/astroid/bases.py
+++ b/astroid/bases.py
@@ -250,7 +250,9 @@
             values = self._proxied.instance_attr(name, context)
         except AttributeInferenceError as exc:
             if self.special_attributes and name in self.special_attributes:
-                return [self.special_attributes.lookup(name)]
+                special_attr = self.special_attributes.lookup(name)
+                if not isinstance(special_attr, nodes.Unknown):
+                    return [special_attr]
 
             if lookupclass:
                 # Class attributes not available through the instance
diff --git a/astroid/interpreter/objectmodel.py b/astroid/interpreter/objectmodel.py
index eac9e43..3745107 100644
--- a/astroid/interpreter/objectmodel.py
+++ b/astroid/interpreter/objectmodel.py
@@ -163,6 +163,33 @@
 
         return bases.BoundMethod(proxy=node, bound=_get_bound_node(self))
 
+    # Base object attributes that return Unknown as fallback placeholders.
+    @property
+    def attr___ne__(self):
+        return node_classes.Unknown(parent=self._instance)
+
+    attr___class__ = attr___ne__
+    attr___delattr__ = attr___ne__
+    attr___dir__ = attr___ne__
+    attr___doc__ = attr___ne__
+    attr___eq__ = attr___ne__
+    attr___format__ = attr___ne__
+    attr___ge__ = attr___ne__
+    attr___getattribute__ = attr___ne__
+    attr___getstate__ = attr___ne__
+    attr___gt__ = attr___ne__
+    attr___hash__ = attr___ne__
+    attr___init_subclass__ = attr___ne__
+    attr___le__ = attr___ne__
+    attr___lt__ = attr___ne__
+    attr___reduce__ = attr___ne__
+    attr___reduce_ex__ = attr___ne__
+    attr___repr__ = attr___ne__
+    attr___setattr__ = attr___ne__
+    attr___sizeof__ = attr___ne__
+    attr___str__ = attr___ne__
+    attr___subclasshook__ = attr___ne__
+
 
 class ModuleModel(ObjectModel):
     def _builtins(self):
@@ -459,30 +486,15 @@
 
         return DescriptorBoundMethod(proxy=self._instance, bound=self._instance)
 
-    # These are here just for completion.
+    # Function-specific attributes.
     @property
-    def attr___ne__(self):
+    def attr___call__(self):
         return node_classes.Unknown(parent=self._instance)
 
-    attr___subclasshook__ = attr___ne__
-    attr___str__ = attr___ne__
-    attr___sizeof__ = attr___ne__
-    attr___setattr___ = attr___ne__
-    attr___repr__ = attr___ne__
-    attr___reduce__ = attr___ne__
-    attr___reduce_ex__ = attr___ne__
-    attr___lt__ = attr___ne__
-    attr___eq__ = attr___ne__
-    attr___gt__ = attr___ne__
-    attr___format__ = attr___ne__
-    attr___delattr___ = attr___ne__
-    attr___getattribute__ = attr___ne__
-    attr___hash__ = attr___ne__
-    attr___dir__ = attr___ne__
-    attr___call__ = attr___ne__
-    attr___class__ = attr___ne__
-    attr___closure__ = attr___ne__
-    attr___code__ = attr___ne__
+    attr___builtins__ = attr___call__
+    attr___closure__ = attr___call__
+    attr___code__ = attr___call__
+    attr___type_params__ = attr___call__
 
 
 class ClassModel(ObjectModel):
diff --git a/astroid/nodes/node_classes.py b/astroid/nodes/node_classes.py
index 0d3a425..5da7887 100644
--- a/astroid/nodes/node_classes.py
+++ b/astroid/nodes/node_classes.py
@@ -4966,9 +4966,10 @@
 
 class Unknown(_base_nodes.AssignTypeNode):
     """This node represents a node in a constructed AST where
-    introspection is not possible.  At the moment, it's only used in
-    the args attribute of FunctionDef nodes where function signature
-    introspection failed.
+    introspection is not possible.
+
+    Used in the args attribute of FunctionDef nodes where function signature
+    introspection failed, and as a placeholder in ObjectModel.
     """
 
     name = "Unknown"
diff --git a/astroid/nodes/scoped_nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes/scoped_nodes.py
index 8855623..8035008 100644
--- a/astroid/nodes/scoped_nodes/scoped_nodes.py
+++ b/astroid/nodes/scoped_nodes/scoped_nodes.py
@@ -1618,6 +1618,16 @@
                     yield node_classes.Const(None)
                 return
 
+            # Builtin dunder methods have empty bodies, return Uninferable.
+            if (
+                len(self.body) == 0
+                and self.name.startswith("__")
+                and self.name.endswith("__")
+                and self.root().qname() == "builtins"
+            ):
+                yield util.Uninferable
+                return
+
             raise InferenceError("The function does not have any return statements")
 
         for returnnode in itertools.chain((first_return,), returns):
@@ -2346,8 +2356,10 @@
             values += classnode.locals.get(name, [])
 
         if name in self.special_attributes and class_context and not values:
-            result = [self.special_attributes.lookup(name)]
-            return result
+            special_attr = self.special_attributes.lookup(name)
+            if not isinstance(special_attr, node_classes.Unknown):
+                result = [special_attr]
+                return result
 
         if class_context:
             values += self._metaclass_lookup_attribute(name, context)
diff --git a/astroid/raw_building.py b/astroid/raw_building.py
index d1bbbd5..e7c5562 100644
--- a/astroid/raw_building.py
+++ b/astroid/raw_building.py
@@ -78,7 +78,11 @@
     """create a Const node and register it in the locals of the given
     node with the specified name
     """
-    if name not in node.special_attributes:
+    # Special case: __hash__ = None overrides ObjectModel for unhashable types.
+    # See https://docs.python.org/3/reference/datamodel.html#object.__hash__
+    if name == "__hash__" and value is None:
+        _attach_local_node(node, nodes.const_factory(value), name)
+    elif name not in node.special_attributes:
         _attach_local_node(node, nodes.const_factory(value), name)
 
 
@@ -507,7 +511,11 @@
             elif inspect.isdatadescriptor(member):
                 child = object_build_datadescriptor(node, member)
             elif isinstance(member, tuple(node_classes.CONST_CLS)):
-                if alias in node.special_attributes:
+                # Special case: __hash__ = None overrides ObjectModel for unhashable types.
+                # See https://docs.python.org/3/reference/datamodel.html#object.__hash__
+                if alias in node.special_attributes and not (
+                    alias == "__hash__" and member is None
+                ):
                     continue
                 child = nodes.const_factory(member)
             elif inspect.isroutine(member):
diff --git a/tests/test_inference.py b/tests/test_inference.py
index bf41e8c..47c82a9 100644
--- a/tests/test_inference.py
+++ b/tests/test_inference.py
@@ -5618,8 +5618,9 @@
     )
     inferred = next(node.infer())
     lenmeth = next(inferred.igetattr("__len__"))
-    with pytest.raises(InferenceError):
-        next(lenmeth.infer_call_result(None, None))
+    # Builtin dunder methods now return Uninferable instead of raising InferenceError
+    result = next(lenmeth.infer_call_result(None, None))
+    assert result is util.Uninferable
 
 
 def test_unpack_dicts_in_assignment() -> None:
diff --git a/tests/test_object_model.py b/tests/test_object_model.py
index f3015db..f36a0d5 100644
--- a/tests/test_object_model.py
+++ b/tests/test_object_model.py
@@ -265,10 +265,13 @@
         xml.__setattr__ #@
         xml.__reduce_ex__ #@
         xml.__lt__ #@
+        xml.__le__ #@
         xml.__eq__ #@
+        xml.__ne__ #@
+        xml.__ge__ #@
         xml.__gt__ #@
         xml.__format__ #@
-        xml.__delattr___ #@
+        xml.__delattr__ #@
         xml.__getattribute__ #@
         xml.__hash__ #@
         xml.__dir__ #@
@@ -324,9 +327,13 @@
         new_ = next(ast_nodes[10].infer())
         assert isinstance(new_, bases.BoundMethod)
 
-        # The following nodes are just here for theoretical completeness,
-        # and they either return Uninferable or raise InferenceError.
-        for ast_node in ast_nodes[11:28]:
+        # Inherited attributes return Uninferable.
+        for ast_node in ast_nodes[11:29]:
+            inferred = next(ast_node.infer())
+            self.assertIs(inferred, astroid.Uninferable)
+
+        # Attributes that don't exist on modules raise InferenceError.
+        for ast_node in ast_nodes[29:31]:
             with pytest.raises(InferenceError):
                 next(ast_node.infer())
 
@@ -449,16 +456,23 @@
 
         func.__reduce_ex__ #@
         func.__lt__ #@
+        func.__le__ #@
         func.__eq__ #@
+        func.__ne__ #@
+        func.__ge__ #@
         func.__gt__ #@
         func.__format__ #@
-        func.__delattr___ #@
+        func.__delattr__ #@
         func.__getattribute__ #@
         func.__hash__ #@
         func.__dir__ #@
         func.__class__ #@
 
         func.__setattr__ #@
+        func.__builtins__ #@
+        func.__getstate__ #@
+        func.__init_subclass__ #@
+        func.__type_params__ #@
         ''',
             module_name="fake_module",
         )
@@ -511,16 +525,11 @@
         new_ = next(ast_nodes[10].infer())
         assert isinstance(new_, bases.BoundMethod)
 
-        # The following nodes are just here for theoretical completeness,
-        # and they either return Uninferable or raise InferenceError.
-        for ast_node in ast_nodes[11:26]:
+        # Remaining attributes return Uninferable.
+        for ast_node in ast_nodes[11:34]:
             inferred = next(ast_node.infer())
             assert inferred is util.Uninferable
 
-        for ast_node in ast_nodes[26:27]:
-            with pytest.raises(InferenceError):
-                inferred = next(ast_node.infer())
-
     def test_empty_return_annotation(self) -> None:
         ast_node = builder.extract_node(
             """
@@ -897,3 +906,112 @@
     assert next(apple.infer()) is astroid.Uninferable
     assert isinstance(pear, nodes.Attribute)
     assert next(pear.infer()) is astroid.Uninferable
+
+
+def test_object_dunder_methods_can_be_overridden() -> None:
+    """Test that ObjectModel dunders don't block class overrides."""
+    # Test instance method override
+    eq_result = builder.extract_node(
+        """
+        class MyClass:
+            def __eq__(self, other):
+                return "custom equality"
+
+        MyClass().__eq__(None)  #@
+        """
+    )
+    inferred = next(eq_result.infer())
+    assert isinstance(inferred, nodes.Const)
+    assert inferred.value == "custom equality"
+
+    # Test that __eq__ on instance returns a bound method
+    eq_method = builder.extract_node(
+        """
+        class MyClass:
+            def __eq__(self, other):
+                return True
+
+        MyClass().__eq__  #@
+        """
+    )
+    inferred = next(eq_method.infer())
+    assert isinstance(inferred, astroid.BoundMethod)
+
+    # Test other commonly overridden dunders
+    for dunder, return_val in (
+        ("__ne__", "not equal"),
+        ("__lt__", "less than"),
+        ("__le__", "less or equal"),
+        ("__gt__", "greater than"),
+        ("__ge__", "greater or equal"),
+        ("__str__", "string repr"),
+        ("__repr__", "repr"),
+        ("__hash__", 42),
+    ):
+        node = builder.extract_node(
+            f"""
+            class MyClass:
+                def {dunder}(self, *args):
+                    return {return_val!r}
+
+            MyClass().{dunder}()  #@
+            """
+        )
+        inferred = next(node.infer())
+        assert isinstance(inferred, nodes.Const), f"{dunder} failed to infer correctly"
+        assert inferred.value == return_val, f"{dunder} returned wrong value"
+
+
+def test_unoverridden_object_dunders_return_uninferable() -> None:
+    """Test that un-overridden object dunders return Uninferable when called."""
+    for dunder in (
+        "__eq__",
+        "__hash__",
+        "__lt__",
+        "__le__",
+        "__gt__",
+        "__ge__",
+        "__ne__",
+    ):
+        node = builder.extract_node(
+            f"""
+            class MyClass:
+                pass
+
+            MyClass().{dunder}(None) if "{dunder}" != "__hash__" else MyClass().{dunder}()  #@
+            """
+        )
+        result = next(node.infer())
+        assert result is util.Uninferable
+
+
+def test_all_object_dunders_accessible() -> None:
+    """Test that object dunders are accessible on classes and instances."""
+    # Use actual dunders from object in the current Python version
+    object_dunders = [attr for attr in dir(object) if attr.startswith("__")]
+
+    cls, instance = builder.extract_node(
+        """
+        class MyClass:
+            pass
+
+        MyClass  #@
+        MyClass()  #@
+        """
+    )
+    cls = next(cls.infer())
+    instance = next(instance.infer())
+
+    for dunder in object_dunders:
+        assert cls.getattr(dunder)
+        assert instance.getattr(dunder)
+
+
+def test_hash_none_for_unhashable_builtins() -> None:
+    """Test that unhashable builtin types have __hash__ = None."""
+    for type_name in ("list", "dict", "set"):
+        node = builder.extract_node(f"{type_name}  #@")
+        cls = next(node.infer())
+        hash_attr = cls.getattr("__hash__")[0]
+        assert isinstance(hash_attr, nodes.Const)
+        assert hash_attr.value is None