Skip to content

Conversation

emmanuel-ferdman
Copy link
Contributor

Type of Changes

Type
βœ“ πŸ› Bug fix

Description

Based on the Python docs, this PR does:

  • Fix __setattr___ and __delattr___ typos (Current code uses 3 underscores but should be 2) - I belive its by mistake. Try to reporduce:
def f(): pass
x = f.__setattr__
  • Add missing attributes: __le__, __ge__, __builtins__, __getstate__, __init_subclass__, __type_params__
  • Update tests for complete coverage of function attributes

Fixes #2742 #2741.

Copy link

codecov bot commented Oct 4, 2025

Codecov Report

❌ Patch coverage is 94.23077% with 3 lines in your changes missing coverage. Please review.
βœ… Project coverage is 93.33%. Comparing base (0562ce3) to head (2762ac9).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
astroid/raw_building.py 25.00% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2847      +/-   ##
==========================================
- Coverage   93.37%   93.33%   -0.04%     
==========================================
  Files          92       92              
  Lines       11148    11168      +20     
==========================================
+ Hits        10409    10424      +15     
- Misses        739      744       +5     
Flag Coverage Ξ”
linux 93.20% <94.23%> (-0.04%) ⬇️
pypy 93.33% <94.23%> (-0.04%) ⬇️
windows 93.32% <94.23%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Ξ”
astroid/bases.py 89.74% <100.00%> (+0.10%) ⬆️
astroid/interpreter/objectmodel.py 96.33% <100.00%> (+0.06%) ⬆️
astroid/nodes/scoped_nodes/scoped_nodes.py 93.61% <100.00%> (+0.02%) ⬆️
astroid/raw_building.py 93.98% <25.00%> (-0.60%) ⬇️

... and 1 file with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Pierre-Sassoulas Pierre-Sassoulas added the Enhancement ✨ Improvement to a component label Oct 5, 2025
@Pierre-Sassoulas Pierre-Sassoulas added this to the 4.1.0 milestone Oct 5, 2025
Copy link
Collaborator

@DanielNoord DanielNoord left a comment

Choose a reason for hiding this comment

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

As can be seen from the docs you link, most of these should live on ObjectModel instead as they come from object.

We tried to do so before in #1519 but failed. Perhaps you want to have another look at that PR and see if you can fix the issue we faced there?

@emmanuel-ferdman
Copy link
Contributor Author

@DanielNoord Thanks! I’ll take a look at this. From that thread, it seems you were close to a solution, except for one test case that didn’t pass. Do you remember which test it was?

@DanielNoord
Copy link
Collaborator

@emmanuel-ferdman I believe it was an issue in the numpy brain, which overwrites __eq__. That overwrite doesn't work well with the changes to ObjectModel.

@emmanuel-ferdman
Copy link
Contributor Author

@DanielNoord I've put together an initial solution for moving object dunders from FunctionModel to ObjectModel. I've tested it against both the astroid test suite and the pylint test suite (all passing). Since I'm still learning the codebase, I'd appreciate any guidance on whether this approach is correct.

The solution uses a placeholder pattern: ObjectModel provides Unknown placeholders for all 24 object dunders, and the lookup logic skips these placeholders to find actual implementations.
Key changes:

  • ObjectModel now has all object dunders as Unknown placeholders that act as fallbacks when no actual implementation exists
  • Lookup logic in BaseInstance.getattr() and ClassDef.getattr() skips Unknown placeholders and continues searching
  • Builtin dunders (from the builtins module) return Uninferable instead of raising InferenceError since we can't infer their result without executing C code
  • Special case for __hash__ = None to ensure unhashable types (list, dict, set) properly override object's __hash__
  • Enhanced type.__new__() validation to raise descriptive InferenceError instead of silently returning None

How it works:

  • When a class overrides a dunder: lookup finds the actual implementation and returns the bound method
  • When a class doesn't override: lookup finds the Unknown placeholder, skips it, continues searching, eventually returns the placeholder which yields Uninferable when called
  • For unhashable builtin types (list, dict, set): __hash__ is explicitly set to None in locals.

Thanks for any feedback πŸ™Œ

Copy link
Collaborator

@DanielNoord DanielNoord left a comment

Choose a reason for hiding this comment

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

This is amazing, very well written PR and a nice set of tests.

Well done on getting this to pass all tests. I have left some comments, but would really like to help you push this over the line :)

Comment on lines 1621 to 1630
# 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think I sort of understand this change, but don't understand why we need to special case it like this.

Can we replace the checks with a look up in special_attributes? Or doesn't that work?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need this case because infer_call_result() has to decide what a call returns, and for built-in dunder methods there is no Python body to inspect (since they're implemented in C). If we tried to parse return nodes we would either fail or produce incorrect results.

special_attributes only tells us that an attribute exists (a placeholder), not whether there is a Python body to infer a return value from. Therefore it cannot replace the empty-body/builtins check used here.

But on second thought, I simplified the case by explicitly checking len(self.body) == 0.

Comment on lines +2360 to +2365
special_attr = self.special_attributes.lookup(name)
if not isinstance(
special_attr, (util.UninferableBase, node_classes.Unknown)
):
result = [special_attr]
return result
Copy link
Collaborator

Choose a reason for hiding this comment

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

What is the effect of this? What will we eventually return if the if is not True?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This block is a short-circuit used when there are no concrete locals/ancestor definitions: originally it always returned special_attributes.lookup(name) (even if that was Unknown or Uninferable). The new behavior only returns the special_attr when it is a concrete value (not node_classes.Unknown or util.UninferableBase), preventing a placeholder from being returned prematurely and masking an override in a metaclass/base class. If the if is not true we continue the normal lookup (metaclass lookup, collect/filter locals/ancestors) and ultimately return any real definitions found - otherwise an AttributeInferenceError is raised. Placeholders (Unknown/Uninferable) therefore mean β€œkeep looking,” not β€œreturn this as the final result.”

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(mcs, nodes.ClassDef):
# Not a valid first argument.
return None
raise InferenceError(
Copy link
Collaborator

Choose a reason for hiding this comment

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

This feels like it could be merged on its own, is that True? In that case I'd probably split this out to ensure we don't do too much in a single PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change is independent of the object dunder work. It's about improving type.new() validation error messages - changing silent None returns to descriptive InferenceError exceptions. The existing tests already expect InferenceError, so it's fully compatible. I'm happy to split it into a separate PR to keep this one focused on the object dunder changes. Should I remove it from this PR and create a new one, or would you prefer I keep it here as a small related improvement?

Comment on lines +81 to +83
# 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)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be the case for all attributes where value is None? Or only hash?

Copy link
Contributor Author

@emmanuel-ferdman emmanuel-ferdman Oct 7, 2025

Choose a reason for hiding this comment

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

I believe only for __hash__. In Python __hash__ = None has a special semantic meaning - it explicitly marks a type as unhashable and must override the inherited object.__hash__. Other attributes set to None do not carry this override semantics and should not implicitly replace special_attributes entries, because that could hide real implementations or metadata. So I belive we should have this special-case restricted to __hash__ only.

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.

Comment on lines +5621 to +5623
# Builtin dunder methods now return Uninferable instead of raising InferenceError
result = next(lenmeth.infer_call_result(None, None))
assert result is util.Uninferable
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.

)
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!

@emmanuel-ferdman emmanuel-ferdman changed the title Add missing operators in FunctionModel Move object dunders from FunctionModel to ObjectModel Oct 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Enhancement ✨ Improvement to a component
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add __le__, __ge__ and other missing attributes to FunctionModel and ObjectModel
3 participants