Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion astroid/bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question as above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same pattern as the ClassDef.getattr() check - when special_attr is Unknown or Uninferable, we skip the early return and continue to the if lookupclass: block which searches the class for the attribute. This ensures we find actual implementations in classes rather than stopping at the Unknown placeholder.

if not isinstance(special_attr, nodes.Unknown):
return [special_attr]

if lookupclass:
# Class attributes not available through the instance
Expand Down
54 changes: 33 additions & 21 deletions astroid/interpreter/objectmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -459,30 +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___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):
Expand Down
16 changes: 14 additions & 2 deletions astroid/nodes/scoped_nodes/scoped_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,16 @@ def infer_call_result(
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):
Expand Down Expand Up @@ -2346,8 +2356,10 @@ 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, node_classes.Unknown):
result = [special_attr]
return result

if class_context:
values += self._metaclass_lookup_attribute(name, context)
Expand Down
12 changes: 10 additions & 2 deletions astroid/raw_building.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ 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.
# 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)


Expand Down Expand Up @@ -507,7 +511,11 @@ 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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning as the previous hash question - hash = None is the only attribute where None has special semantic meaning in Python (marks unhashable types). Other None values don't need this override behavior.

# 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):
Expand Down
5 changes: 3 additions & 2 deletions tests/test_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +5621 to +5623
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I think the changes above now make more sense to me.

pylint works correctly with this change? Thanks for testing that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - pylint works correctly (verified by running pylint test suite with astroid changes). pylint handles Uninferable in two ways: (1) explicit isinstance(value, util.UninferableBase) checks (e.g., pylint/checkers/typecheck.py:1365-1369, and (2) Uninferable is falsy like None, so if inferred: checks skip both. The behavior is identical - when builtin dunders can't be inferred, pylint skips type checking whether it gets None from an exception or Uninferable from the return value.



def test_unpack_dicts_in_assignment() -> None:
Expand Down
142 changes: 130 additions & 12 deletions tests/test_object_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ #@
Expand Down Expand Up @@ -324,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: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())

Expand Down Expand Up @@ -449,16 +456,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",
)
Expand Down Expand Up @@ -511,16 +525,11 @@ 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.
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(
"""
Expand Down Expand Up @@ -897,3 +906,112 @@ 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"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!


# 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