Skip to content

Commit 7fce264

Browse files
Move object dunders from FunctionModel to ObjectModel (#2847)
Signed-off-by: Emmanuel Ferdman <[email protected]>
1 parent 2ce47a2 commit 7fce264

File tree

8 files changed

+203
-43
lines changed

8 files changed

+203
-43
lines changed

ChangeLog

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ What's New in astroid 4.1.0?
77
============================
88
Release date: TBA
99

10+
* Move object dunder methods from ``FunctionModel`` to ``ObjectModel`` to make them
11+
available on all object types, not just functions.
12+
13+
Closes #2742
14+
Closes #2741
15+
Closes pylint-dev/pylint#6094
1016

1117

1218
What's New in astroid 4.0.1?

astroid/bases.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,9 @@ def getattr(
250250
values = self._proxied.instance_attr(name, context)
251251
except AttributeInferenceError as exc:
252252
if self.special_attributes and name in self.special_attributes:
253-
return [self.special_attributes.lookup(name)]
253+
special_attr = self.special_attributes.lookup(name)
254+
if not isinstance(special_attr, nodes.Unknown):
255+
return [special_attr]
254256

255257
if lookupclass:
256258
# Class attributes not available through the instance

astroid/interpreter/objectmodel.py

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,33 @@ def attr___init__(self) -> bases.BoundMethod:
163163

164164
return bases.BoundMethod(proxy=node, bound=_get_bound_node(self))
165165

166+
# Base object attributes that return Unknown as fallback placeholders.
167+
@property
168+
def attr___ne__(self):
169+
return node_classes.Unknown(parent=self._instance)
170+
171+
attr___class__ = attr___ne__
172+
attr___delattr__ = attr___ne__
173+
attr___dir__ = attr___ne__
174+
attr___doc__ = attr___ne__
175+
attr___eq__ = attr___ne__
176+
attr___format__ = attr___ne__
177+
attr___ge__ = attr___ne__
178+
attr___getattribute__ = attr___ne__
179+
attr___getstate__ = attr___ne__
180+
attr___gt__ = attr___ne__
181+
attr___hash__ = attr___ne__
182+
attr___init_subclass__ = attr___ne__
183+
attr___le__ = attr___ne__
184+
attr___lt__ = attr___ne__
185+
attr___reduce__ = attr___ne__
186+
attr___reduce_ex__ = attr___ne__
187+
attr___repr__ = attr___ne__
188+
attr___setattr__ = attr___ne__
189+
attr___sizeof__ = attr___ne__
190+
attr___str__ = attr___ne__
191+
attr___subclasshook__ = attr___ne__
192+
166193

167194
class ModuleModel(ObjectModel):
168195
def _builtins(self):
@@ -459,30 +486,15 @@ def test(self):
459486

460487
return DescriptorBoundMethod(proxy=self._instance, bound=self._instance)
461488

462-
# These are here just for completion.
489+
# Function-specific attributes.
463490
@property
464-
def attr___ne__(self):
491+
def attr___call__(self):
465492
return node_classes.Unknown(parent=self._instance)
466493

467-
attr___subclasshook__ = attr___ne__
468-
attr___str__ = attr___ne__
469-
attr___sizeof__ = attr___ne__
470-
attr___setattr___ = attr___ne__
471-
attr___repr__ = attr___ne__
472-
attr___reduce__ = attr___ne__
473-
attr___reduce_ex__ = attr___ne__
474-
attr___lt__ = attr___ne__
475-
attr___eq__ = attr___ne__
476-
attr___gt__ = attr___ne__
477-
attr___format__ = attr___ne__
478-
attr___delattr___ = attr___ne__
479-
attr___getattribute__ = attr___ne__
480-
attr___hash__ = attr___ne__
481-
attr___dir__ = attr___ne__
482-
attr___call__ = attr___ne__
483-
attr___class__ = attr___ne__
484-
attr___closure__ = attr___ne__
485-
attr___code__ = attr___ne__
494+
attr___builtins__ = attr___call__
495+
attr___closure__ = attr___call__
496+
attr___code__ = attr___call__
497+
attr___type_params__ = attr___call__
486498

487499

488500
class ClassModel(ObjectModel):

astroid/nodes/node_classes.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4966,9 +4966,10 @@ def set_local(self, name: str, stmt: NodeNG) -> None:
49664966

49674967
class Unknown(_base_nodes.AssignTypeNode):
49684968
"""This node represents a node in a constructed AST where
4969-
introspection is not possible. At the moment, it's only used in
4970-
the args attribute of FunctionDef nodes where function signature
4971-
introspection failed.
4969+
introspection is not possible.
4970+
4971+
Used in the args attribute of FunctionDef nodes where function signature
4972+
introspection failed, and as a placeholder in ObjectModel.
49724973
"""
49734974

49744975
name = "Unknown"

astroid/nodes/scoped_nodes/scoped_nodes.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,16 @@ def infer_call_result(
16181618
yield node_classes.Const(None)
16191619
return
16201620

1621+
# Builtin dunder methods have empty bodies, return Uninferable.
1622+
if (
1623+
len(self.body) == 0
1624+
and self.name.startswith("__")
1625+
and self.name.endswith("__")
1626+
and self.root().qname() == "builtins"
1627+
):
1628+
yield util.Uninferable
1629+
return
1630+
16211631
raise InferenceError("The function does not have any return statements")
16221632

16231633
for returnnode in itertools.chain((first_return,), returns):
@@ -2346,8 +2356,10 @@ def getattr(
23462356
values += classnode.locals.get(name, [])
23472357

23482358
if name in self.special_attributes and class_context and not values:
2349-
result = [self.special_attributes.lookup(name)]
2350-
return result
2359+
special_attr = self.special_attributes.lookup(name)
2360+
if not isinstance(special_attr, node_classes.Unknown):
2361+
result = [special_attr]
2362+
return result
23512363

23522364
if class_context:
23532365
values += self._metaclass_lookup_attribute(name, context)

astroid/raw_building.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ def attach_const_node(node, name: str, value) -> None:
7878
"""create a Const node and register it in the locals of the given
7979
node with the specified name
8080
"""
81-
if name not in node.special_attributes:
81+
# Special case: __hash__ = None overrides ObjectModel for unhashable types.
82+
# See https://docs.python.org/3/reference/datamodel.html#object.__hash__
83+
if name == "__hash__" and value is None:
84+
_attach_local_node(node, nodes.const_factory(value), name)
85+
elif name not in node.special_attributes:
8286
_attach_local_node(node, nodes.const_factory(value), name)
8387

8488

@@ -507,7 +511,11 @@ def object_build(
507511
elif inspect.isdatadescriptor(member):
508512
child = object_build_datadescriptor(node, member)
509513
elif isinstance(member, tuple(node_classes.CONST_CLS)):
510-
if alias in node.special_attributes:
514+
# Special case: __hash__ = None overrides ObjectModel for unhashable types.
515+
# See https://docs.python.org/3/reference/datamodel.html#object.__hash__
516+
if alias in node.special_attributes and not (
517+
alias == "__hash__" and member is None
518+
):
511519
continue
512520
child = nodes.const_factory(member)
513521
elif inspect.isroutine(member):

tests/test_inference.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5618,8 +5618,9 @@ def test_cannot_infer_call_result_for_builtin_methods() -> None:
56185618
)
56195619
inferred = next(node.infer())
56205620
lenmeth = next(inferred.igetattr("__len__"))
5621-
with pytest.raises(InferenceError):
5622-
next(lenmeth.infer_call_result(None, None))
5621+
# Builtin dunder methods now return Uninferable instead of raising InferenceError
5622+
result = next(lenmeth.infer_call_result(None, None))
5623+
assert result is util.Uninferable
56235624

56245625

56255626
def test_unpack_dicts_in_assignment() -> None:

tests/test_object_model.py

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,13 @@ def test_module_model(self) -> None:
265265
xml.__setattr__ #@
266266
xml.__reduce_ex__ #@
267267
xml.__lt__ #@
268+
xml.__le__ #@
268269
xml.__eq__ #@
270+
xml.__ne__ #@
271+
xml.__ge__ #@
269272
xml.__gt__ #@
270273
xml.__format__ #@
271-
xml.__delattr___ #@
274+
xml.__delattr__ #@
272275
xml.__getattribute__ #@
273276
xml.__hash__ #@
274277
xml.__dir__ #@
@@ -324,9 +327,13 @@ def test_module_model(self) -> None:
324327
new_ = next(ast_nodes[10].infer())
325328
assert isinstance(new_, bases.BoundMethod)
326329

327-
# The following nodes are just here for theoretical completeness,
328-
# and they either return Uninferable or raise InferenceError.
329-
for ast_node in ast_nodes[11:28]:
330+
# Inherited attributes return Uninferable.
331+
for ast_node in ast_nodes[11:29]:
332+
inferred = next(ast_node.infer())
333+
self.assertIs(inferred, astroid.Uninferable)
334+
335+
# Attributes that don't exist on modules raise InferenceError.
336+
for ast_node in ast_nodes[29:31]:
330337
with pytest.raises(InferenceError):
331338
next(ast_node.infer())
332339

@@ -449,16 +456,23 @@ def func(a=1, b=2):
449456
450457
func.__reduce_ex__ #@
451458
func.__lt__ #@
459+
func.__le__ #@
452460
func.__eq__ #@
461+
func.__ne__ #@
462+
func.__ge__ #@
453463
func.__gt__ #@
454464
func.__format__ #@
455-
func.__delattr___ #@
465+
func.__delattr__ #@
456466
func.__getattribute__ #@
457467
func.__hash__ #@
458468
func.__dir__ #@
459469
func.__class__ #@
460470
461471
func.__setattr__ #@
472+
func.__builtins__ #@
473+
func.__getstate__ #@
474+
func.__init_subclass__ #@
475+
func.__type_params__ #@
462476
''',
463477
module_name="fake_module",
464478
)
@@ -511,16 +525,11 @@ def func(a=1, b=2):
511525
new_ = next(ast_nodes[10].infer())
512526
assert isinstance(new_, bases.BoundMethod)
513527

514-
# The following nodes are just here for theoretical completeness,
515-
# and they either return Uninferable or raise InferenceError.
516-
for ast_node in ast_nodes[11:26]:
528+
# Remaining attributes return Uninferable.
529+
for ast_node in ast_nodes[11:34]:
517530
inferred = next(ast_node.infer())
518531
assert inferred is util.Uninferable
519532

520-
for ast_node in ast_nodes[26:27]:
521-
with pytest.raises(InferenceError):
522-
inferred = next(ast_node.infer())
523-
524533
def test_empty_return_annotation(self) -> None:
525534
ast_node = builder.extract_node(
526535
"""
@@ -897,3 +906,112 @@ class Apple(TypedDict):
897906
assert next(apple.infer()) is astroid.Uninferable
898907
assert isinstance(pear, nodes.Attribute)
899908
assert next(pear.infer()) is astroid.Uninferable
909+
910+
911+
def test_object_dunder_methods_can_be_overridden() -> None:
912+
"""Test that ObjectModel dunders don't block class overrides."""
913+
# Test instance method override
914+
eq_result = builder.extract_node(
915+
"""
916+
class MyClass:
917+
def __eq__(self, other):
918+
return "custom equality"
919+
920+
MyClass().__eq__(None) #@
921+
"""
922+
)
923+
inferred = next(eq_result.infer())
924+
assert isinstance(inferred, nodes.Const)
925+
assert inferred.value == "custom equality"
926+
927+
# Test that __eq__ on instance returns a bound method
928+
eq_method = builder.extract_node(
929+
"""
930+
class MyClass:
931+
def __eq__(self, other):
932+
return True
933+
934+
MyClass().__eq__ #@
935+
"""
936+
)
937+
inferred = next(eq_method.infer())
938+
assert isinstance(inferred, astroid.BoundMethod)
939+
940+
# Test other commonly overridden dunders
941+
for dunder, return_val in (
942+
("__ne__", "not equal"),
943+
("__lt__", "less than"),
944+
("__le__", "less or equal"),
945+
("__gt__", "greater than"),
946+
("__ge__", "greater or equal"),
947+
("__str__", "string repr"),
948+
("__repr__", "repr"),
949+
("__hash__", 42),
950+
):
951+
node = builder.extract_node(
952+
f"""
953+
class MyClass:
954+
def {dunder}(self, *args):
955+
return {return_val!r}
956+
957+
MyClass().{dunder}() #@
958+
"""
959+
)
960+
inferred = next(node.infer())
961+
assert isinstance(inferred, nodes.Const), f"{dunder} failed to infer correctly"
962+
assert inferred.value == return_val, f"{dunder} returned wrong value"
963+
964+
965+
def test_unoverridden_object_dunders_return_uninferable() -> None:
966+
"""Test that un-overridden object dunders return Uninferable when called."""
967+
for dunder in (
968+
"__eq__",
969+
"__hash__",
970+
"__lt__",
971+
"__le__",
972+
"__gt__",
973+
"__ge__",
974+
"__ne__",
975+
):
976+
node = builder.extract_node(
977+
f"""
978+
class MyClass:
979+
pass
980+
981+
MyClass().{dunder}(None) if "{dunder}" != "__hash__" else MyClass().{dunder}() #@
982+
"""
983+
)
984+
result = next(node.infer())
985+
assert result is util.Uninferable
986+
987+
988+
def test_all_object_dunders_accessible() -> None:
989+
"""Test that object dunders are accessible on classes and instances."""
990+
# Use actual dunders from object in the current Python version
991+
object_dunders = [attr for attr in dir(object) if attr.startswith("__")]
992+
993+
cls, instance = builder.extract_node(
994+
"""
995+
class MyClass:
996+
pass
997+
998+
MyClass #@
999+
MyClass() #@
1000+
"""
1001+
)
1002+
cls = next(cls.infer())
1003+
instance = next(instance.infer())
1004+
1005+
for dunder in object_dunders:
1006+
assert cls.getattr(dunder)
1007+
assert instance.getattr(dunder)
1008+
1009+
1010+
def test_hash_none_for_unhashable_builtins() -> None:
1011+
"""Test that unhashable builtin types have __hash__ = None."""
1012+
for type_name in ("list", "dict", "set"):
1013+
node = builder.extract_node(f"{type_name} #@")
1014+
cls = next(node.infer())
1015+
hash_attr = cls.getattr("__hash__")[0]
1016+
assert isinstance(hash_attr, nodes.Const)
1017+
assert hash_attr.value is None

0 commit comments

Comments
 (0)