From aeedd503b5379890282b19390bc82f753e7cb467 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Sun, 5 Oct 2025 00:45:09 +0300 Subject: [PATCH 1/5] Add missing operators in FunctionModel Signed-off-by: Emmanuel Ferdman --- astroid/interpreter/objectmodel.py | 10 ++++++++-- tests/test_object_model.py | 22 ++++++++++++++-------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/astroid/interpreter/objectmodel.py b/astroid/interpreter/objectmodel.py index eac9e4308..7d3fa56cf 100644 --- a/astroid/interpreter/objectmodel.py +++ b/astroid/interpreter/objectmodel.py @@ -467,15 +467,17 @@ def attr___ne__(self): attr___subclasshook__ = attr___ne__ attr___str__ = attr___ne__ attr___sizeof__ = attr___ne__ - attr___setattr___ = attr___ne__ + attr___setattr__ = attr___ne__ attr___repr__ = attr___ne__ attr___reduce__ = attr___ne__ attr___reduce_ex__ = attr___ne__ attr___lt__ = attr___ne__ + attr___le__ = attr___ne__ attr___eq__ = attr___ne__ + attr___ge__ = attr___ne__ attr___gt__ = attr___ne__ attr___format__ = attr___ne__ - attr___delattr___ = attr___ne__ + attr___delattr__ = attr___ne__ attr___getattribute__ = attr___ne__ attr___hash__ = attr___ne__ attr___dir__ = attr___ne__ @@ -483,6 +485,10 @@ def attr___ne__(self): attr___class__ = attr___ne__ attr___closure__ = attr___ne__ attr___code__ = attr___ne__ + attr___builtins__ = attr___ne__ + attr___getstate__ = attr___ne__ + attr___init_subclass__ = attr___ne__ + attr___type_params__ = attr___ne__ class ClassModel(ObjectModel): diff --git a/tests/test_object_model.py b/tests/test_object_model.py index f3015db9c..f65adced3 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py @@ -265,10 +265,13 @@ def test_module_model(self) -> None: 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__ #@ @@ -326,7 +329,7 @@ def test_module_model(self) -> None: # 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]: + for ast_node in ast_nodes[11:31]: with pytest.raises(InferenceError): next(ast_node.infer()) @@ -449,16 +452,23 @@ def func(a=1, b=2): 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", ) @@ -513,14 +523,10 @@ def func(a=1, b=2): # 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]: + 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( """ From 50a56f8aaf977136a78e5a9ff67d22db61621e63 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Mon, 6 Oct 2025 02:48:16 +0300 Subject: [PATCH 2/5] Move object dunders from `FunctionModel` to `ObjectModel` Signed-off-by: Emmanuel Ferdman --- astroid/bases.py | 38 ++++-- astroid/interpreter/objectmodel.py | 60 +++++---- astroid/nodes/scoped_nodes/scoped_nodes.py | 19 ++- astroid/raw_building.py | 10 +- tests/test_inference.py | 5 +- tests/test_object_model.py | 138 ++++++++++++++++++++- 6 files changed, 223 insertions(+), 47 deletions(-) diff --git a/astroid/bases.py b/astroid/bases.py index a029da6d4..f75603465 100644 --- a/astroid/bases.py +++ b/astroid/bases.py @@ -250,7 +250,9 @@ def getattr( 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, (UninferableBase, nodes.Unknown)): + return [special_attr] if lookupclass: # Class attributes not available through the instance @@ -571,10 +573,14 @@ def _infer_type_new_call( raise InferenceError(context=context) from e if not isinstance(mcs, nodes.ClassDef): # Not a valid first argument. - return None + raise InferenceError( + "type.__new__() requires a class for metaclass", context=context + ) if not mcs.is_subtype_of("builtins.type"): # Not a valid metaclass. - return None + raise InferenceError( + "type.__new__() metaclass must be a subclass of type", context=context + ) # Verify the name try: @@ -583,10 +589,14 @@ def _infer_type_new_call( raise InferenceError(context=context) from e if not isinstance(name, nodes.Const): # Not a valid name, needs to be a const. - return None + raise InferenceError( + "type.__new__() requires a constant for name", context=context + ) if not isinstance(name.value, str): # Needs to be a string. - return None + raise InferenceError( + "type.__new__() requires a string for name", context=context + ) # Verify the bases try: @@ -595,14 +605,18 @@ def _infer_type_new_call( raise InferenceError(context=context) from e if not isinstance(bases, nodes.Tuple): # Needs to be a tuple. - return None + raise InferenceError( + "type.__new__() requires a tuple for bases", context=context + ) try: inferred_bases = [next(elt.infer(context=context)) for elt in bases.elts] except StopIteration as e: raise InferenceError(context=context) from e if any(not isinstance(base, nodes.ClassDef) for base in inferred_bases): # All the bases needs to be Classes - return None + raise InferenceError( + "type.__new__() requires classes for bases", context=context + ) # Verify the attributes. try: @@ -611,7 +625,9 @@ def _infer_type_new_call( raise InferenceError(context=context) from e if not isinstance(attrs, nodes.Dict): # Needs to be a dictionary. - return None + raise InferenceError( + "type.__new__() requires a dict for attrs", context=context + ) cls_locals: dict[str, list[InferenceResult]] = collections.defaultdict(list) for key, value in attrs.items: try: @@ -664,9 +680,13 @@ def infer_call_result( and self.bound.name == "type" and self.name == "__new__" and isinstance(caller, nodes.Call) - and len(caller.args) == 4 ): # Check if we have a ``type.__new__(mcs, name, bases, attrs)`` call. + if len(caller.args) != 4: + raise InferenceError( + f"type.__new__() requires 4 arguments, got {len(caller.args)}", + context=context, + ) new_cls = self._infer_type_new_call(caller, context) if new_cls: return iter((new_cls,)) diff --git a/astroid/interpreter/objectmodel.py b/astroid/interpreter/objectmodel.py index 7d3fa56cf..3745107fb 100644 --- a/astroid/interpreter/objectmodel.py +++ b/astroid/interpreter/objectmodel.py @@ -163,6 +163,33 @@ def attr___init__(self) -> bases.BoundMethod: 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,36 +486,15 @@ def test(self): 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___le__ = attr___ne__ - attr___eq__ = attr___ne__ - attr___ge__ = 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___ne__ - attr___getstate__ = attr___ne__ - attr___init_subclass__ = attr___ne__ - attr___type_params__ = 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/scoped_nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes/scoped_nodes.py index 885562393..1dd1d65c0 100644 --- a/astroid/nodes/scoped_nodes/scoped_nodes.py +++ b/astroid/nodes/scoped_nodes/scoped_nodes.py @@ -1618,6 +1618,17 @@ def infer_call_result( yield node_classes.Const(None) return + # Builtin dunder methods have empty bodies, return Uninferable. + if ( + self.root().qname() == "builtins" + and self.name.startswith("__") + and self.name.endswith("__") + and self.parent + and self.parent.__class__.__name__ == "ClassDef" + ): + yield util.Uninferable + return + raise InferenceError("The function does not have any return statements") for returnnode in itertools.chain((first_return,), returns): @@ -2346,8 +2357,12 @@ def getattr( 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, (util.UninferableBase, 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 d1bbbd556..837768760 100644 --- a/astroid/raw_building.py +++ b/astroid/raw_building.py @@ -78,7 +78,10 @@ def attach_const_node(node, name: str, value) -> None: """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. + 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 +510,10 @@ def object_build( 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. + 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 bf41e8cf5..47c82a98f 100644 --- a/tests/test_inference.py +++ b/tests/test_inference.py @@ -5618,8 +5618,9 @@ def test_cannot_infer_call_result_for_builtin_methods() -> None: ) 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 f65adced3..c31d4372f 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py @@ -327,9 +327,13 @@ def test_module_model(self) -> None: 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:31]: + # 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()) @@ -521,8 +525,7 @@ def func(a=1, b=2): 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. + # Remaining attributes return Uninferable. for ast_node in ast_nodes[11:34]: inferred = next(ast_node.infer()) assert inferred is util.Uninferable @@ -903,3 +906,128 @@ class Apple(TypedDict): 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.""" + object_dunders = [ + "__class__", + "__delattr__", + "__dir__", + "__doc__", + "__eq__", + "__format__", + "__ge__", + "__getattribute__", + "__getstate__", + "__gt__", + "__hash__", + "__init__", + "__init_subclass__", + "__le__", + "__lt__", + "__ne__", + "__new__", + "__reduce__", + "__reduce_ex__", + "__repr__", + "__setattr__", + "__sizeof__", + "__str__", + "__subclasshook__", + ] + + 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 From b691e28d315c9515b5ed6112a4666233eca5ae31 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 5 Oct 2025 23:48:53 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_object_model.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_object_model.py b/tests/test_object_model.py index c31d4372f..e336d8652 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py @@ -964,7 +964,15 @@ def {dunder}(self, *args): 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__"): + for dunder in ( + "__eq__", + "__hash__", + "__lt__", + "__le__", + "__gt__", + "__ge__", + "__ne__", + ): node = builder.extract_node( f""" class MyClass: From 1bc57c5a0f39df2579116c1f510ebac4fe236d98 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Mon, 6 Oct 2025 02:56:05 +0300 Subject: [PATCH 4/5] Move object dunders from `FunctionModel` to `ObjectModel` Signed-off-by: Emmanuel Ferdman --- tests/test_object_model.py | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/tests/test_object_model.py b/tests/test_object_model.py index e336d8652..f36a0d53d 100644 --- a/tests/test_object_model.py +++ b/tests/test_object_model.py @@ -987,32 +987,8 @@ class MyClass: def test_all_object_dunders_accessible() -> None: """Test that object dunders are accessible on classes and instances.""" - object_dunders = [ - "__class__", - "__delattr__", - "__dir__", - "__doc__", - "__eq__", - "__format__", - "__ge__", - "__getattribute__", - "__getstate__", - "__gt__", - "__hash__", - "__init__", - "__init_subclass__", - "__le__", - "__lt__", - "__ne__", - "__new__", - "__reduce__", - "__reduce_ex__", - "__repr__", - "__setattr__", - "__sizeof__", - "__str__", - "__subclasshook__", - ] + # 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( """ From 2762ac91093a12299799e580f50bafc7f70d5213 Mon Sep 17 00:00:00 2001 From: Emmanuel Ferdman Date: Tue, 7 Oct 2025 17:49:53 +0300 Subject: [PATCH 5/5] Move object dunders from `FunctionModel` to `ObjectModel` Signed-off-by: Emmanuel Ferdman --- astroid/nodes/scoped_nodes/scoped_nodes.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/astroid/nodes/scoped_nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes/scoped_nodes.py index 1dd1d65c0..ac06424e7 100644 --- a/astroid/nodes/scoped_nodes/scoped_nodes.py +++ b/astroid/nodes/scoped_nodes/scoped_nodes.py @@ -1620,11 +1620,10 @@ def infer_call_result( # Builtin dunder methods have empty bodies, return Uninferable. if ( - self.root().qname() == "builtins" + len(self.body) == 0 and self.name.startswith("__") and self.name.endswith("__") - and self.parent - and self.parent.__class__.__name__ == "ClassDef" + and self.root().qname() == "builtins" ): yield util.Uninferable return