DEV Community

Cover image for The Bug That Hid Behind Its Own Comment: Fixing Inconsistent Inference in astroid
Kartikey
Kartikey

Posted on

The Bug That Hid Behind Its Own Comment: Fixing Inconsistent Inference in astroid

Summer Bug Smash: Clear the Lineup 🐛🛹

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 a BoundMethod, which walks normally into Base.run()'s body and evaluates cast() correctly.
  • self.separator() resolves to the Instance itself, routed through BaseInstance.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):
    ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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