cd /news/developer-tools/the-bug-that-hid-behind-its-own-comm… · home topics developer-tools article
[ARTICLE · art-106850] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer fixed a bug in astroid, the static-analysis engine behind pylint, where identical typing.cast expressions were inferred differently depending on the call style, causing false positives. The issue was traced to an unhandled InferenceError in BaseInstance.infer_call_result that prematurely terminated the generator, skipping the correct __call__ resolution. The fix, submitted in PR #3242, ensures consistent inference for implicit and explicit method calls.

read4 min views2 publishedAug 22, 2026

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

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.

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.

PR: https://github.com/pylint-dev/astroid/pull/3242

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.

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.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

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.

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

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @astroid 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-bug-that-hid-beh…] indexed:0 read:4min 2026-08-22 ·