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