This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Project Overview
astroid is the static-analysis engine that powers pylint — one of the most widely used linters in the Python ecosystem. Instead of running your code, astroid builds a model of what your code would do (a process called "inference") so pylint can catch real bugs before you ever hit run. That means astroid's inference logic has to be extremely consistent: if it gets confused about what a piece of code returns, pylint either misses real bugs, or — as in this case — flags perfectly correct code as broken.
Bug Fix or Performance Improvement
I picked up astroid issue #3077: identical typing.cast(T, self) expressions were being inferred differently depending only on how the surrounding call was written — even when the code was structurally symmetric.
In a class like this:
class Base:
def __call__(self) -> str:
return cast(str, self)
def run(self) -> str:
return cast(str, self)
class IrJoin:
separator: Base
def __call__(self, items):
sep: str = self.separator() # implicit __call__ sugar
return sep.join(items)
def run(self, items):
sep: str = self.separator.run() # explicit method call
return sep.join(items)
Both self.separator() and self.separator.run() do the exact same thing at runtime — I verified this by actually running the file. But pylint only flagged one of them:
$ python -m pylint t5.py
t5.py:35:15: E1101: Instance of 'Base' has no 'join' member (no-member)
The explicit .run() path got a false positive; the equivalent implicit __call__ path did not, even though sep is a plain str in both cases at runtime.
Code
PR: https://github.com/pylint-dev/astroid/pull/3242
My Improvements
Ruling out the obvious suspect
My first hypothesis was infer_typing_cast, the function that handles typing.cast() itself — it seemed like the natural place for a cast-related inconsistency to live. Tested in isolation, though, it behaves identically for both call styles. Dead end — but a useful one, because it told me the bug lived somewhere upstream of cast() entirely.
Tracing the real divergence
I live-patched pylint's own inference calls with a small monkey-patching script (rather than editing installed files directly, so I could observe astroid's real behavior without risking my environment) and found the two call styles actually go through completely different astroid code paths:
-
self.separator.run()resolves to aBoundMethod, which walks normally intoBase.run()'s body and evaluatescast()correctly. -
self.separator()resolves to theInstanceitself, routed throughBaseInstance.infer_call_result()— the code path specifically responsible for resolving implicit__call__dunder calls.
Finding the actual bug
Inside BaseInstance.infer_call_result (astroid/bases.py), there's an optional first step that tries to resolve the call as if it were a plain attribute lookup on the callee:
if isinstance(caller, nodes.Call) and isinstance(caller.func, nodes.Attribute):
for res in self.igetattr(caller.func.attrname, context):
inferred = True
yield res
# Otherwise we infer the call to the __call__ dunder normally
for node in self._proxied.igetattr("__call__", context):
...
For self.separator(), that first branch tries to look up an attribute literally named "separator" — on the Base instance, which obviously has no such attribute. That lookup raises an InferenceError. Because this is a generator function, an unhandled exception anywhere inside it terminates the entire function immediately — including the second loop just below, which is the code that actually resolves __call__ correctly.
The comment right there in the source literally says "Otherwise we infer the call to the __call__ dunder normally" — but the code never got the chance to reach it. The bug was hiding directly behind its own explanation.
The fix
A small, surgical change: wrap that first branch in try/except InferenceError: pass, so a failed attribute lookup no longer aborts the whole function — it simply falls through to the __call__ resolution below, exactly as the existing comment always promised.
if isinstance(caller, nodes.Call) and isinstance(caller.func, nodes.Attribute):
- for res in self.igetattr(caller.func.attrname, context):
- inferred = True
- yield res
+ try:
+ for res in self.igetattr(caller.func.attrname, context):
+ inferred = True
+ yield res
+ except InferenceError:
+ pass
Result — both call styles now consistently resolve the same way:
t5.py:31:15: E1101: Instance of 'Base' has no 'join' member (no-member)
t5.py:35:15: E1101: Instance of 'Base' has no 'join' member (no-member)
(As the original issue notes, Instance of <enclosing class> isn't necessarily the most precise answer cast() could give — but consistency is what the bug was actually about, and this fix delivers it cleanly.)
Testing
I added a regression test, test_infer_call_result_dunder_call_consistent_with_attribute_call, reproducing the minimal case directly in astroid's own suite (tests/test_inference.py), and ran the entire existing test suite (2,000+ tests) to confirm nothing else broke. The only failures present were pre-existing, unrelated Windows-environment issues (symlink permissions, missing fixtures) and one unrelated TypedDict failure — confirmed via git stash to also occur on unmodified main, ruling out any regression from this change.
Top comments (0)