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

> Source: <https://dev.to/kartikey_d47d5d0a50247b86/the-bug-that-hid-behind-its-own-comment-fixing-inconsistent-inference-in-astroid-10io>
> Published: 2026-08-22 06:20:26+00:00

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

[astroid](https://github.com/pylint-dev/astroid) is the static-analysis engine that powers [pylint](https://pylint.org/) — 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](https://github.com/pylint-dev/astroid/issues/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:

``` php
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:

``` bash
$ 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](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

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

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.
