{"slug": "the-bug-that-hid-behind-its-own-comment-fixing-inconsistent-inference-in-astroid", "title": "The Bug That Hid Behind Its Own Comment: Fixing Inconsistent Inference in astroid", "summary": "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.", "body_md": "*This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.*\n\n[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.\n\nI picked up [astroid issue #3077](https://github.com/pylint-dev/astroid/issues/3077): identical `typing.cast(T, self)`\n\nexpressions were being inferred *differently* depending only on how the surrounding call was written — even when the code was structurally symmetric.\n\nIn a class like this:\n\n``` php\nclass Base:\n    def __call__(self) -> str:\n        return cast(str, self)\n    def run(self) -> str:\n        return cast(str, self)\n\nclass IrJoin:\n    separator: Base\n    def __call__(self, items):\n        sep: str = self.separator()       # implicit __call__ sugar\n        return sep.join(items)\n    def run(self, items):\n        sep: str = self.separator.run()   # explicit method call\n        return sep.join(items)\n```\n\nBoth `self.separator()`\n\nand `self.separator.run()`\n\ndo the exact same thing at runtime — I verified this by actually running the file. But pylint only flagged one of them:\n\n``` bash\n$ python -m pylint t5.py\nt5.py:35:15: E1101: Instance of 'Base' has no 'join' member (no-member)\n```\n\nThe explicit `.run()`\n\npath got a false positive; the equivalent implicit `__call__`\n\npath did not, even though `sep`\n\nis a plain `str`\n\nin both cases at runtime.\n\n**PR:** [https://github.com/pylint-dev/astroid/pull/3242](https://github.com/pylint-dev/astroid/pull/3242)\n\nMy first hypothesis was `infer_typing_cast`\n\n, the function that handles `typing.cast()`\n\nitself — 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()`\n\nentirely.\n\nI 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**:\n\n`self.separator.run()`\n\nresolves to a `BoundMethod`\n\n, which walks normally into `Base.run()`\n\n's body and evaluates `cast()`\n\ncorrectly.`self.separator()`\n\nresolves to the `Instance`\n\nitself, routed through `BaseInstance.infer_call_result()`\n\n— the code path specifically responsible for resolving implicit `__call__`\n\ndunder calls.Inside `BaseInstance.infer_call_result`\n\n(`astroid/bases.py`\n\n), there's an *optional* first step that tries to resolve the call as if it were a plain attribute lookup on the callee:\n\n```\nif isinstance(caller, nodes.Call) and isinstance(caller.func, nodes.Attribute):\n    for res in self.igetattr(caller.func.attrname, context):\n        inferred = True\n        yield res\n\n# Otherwise we infer the call to the __call__ dunder normally\nfor node in self._proxied.igetattr(\"__call__\", context):\n    ...\n```\n\nFor `self.separator()`\n\n, that first branch tries to look up an attribute literally named `\"separator\"`\n\n— on the `Base`\n\ninstance, which obviously has no such attribute. That lookup raises an `InferenceError`\n\n. 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__`\n\ncorrectly.\n\nThe 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.\n\nA small, surgical change: wrap that first branch in `try/except InferenceError: pass`\n\n, so a failed attribute lookup no longer aborts the whole function — it simply falls through to the `__call__`\n\nresolution below, exactly as the existing comment always promised.\n\n```\n if isinstance(caller, nodes.Call) and isinstance(caller.func, nodes.Attribute):\n-    for res in self.igetattr(caller.func.attrname, context):\n-        inferred = True\n-        yield res\n+    try:\n+        for res in self.igetattr(caller.func.attrname, context):\n+            inferred = True\n+            yield res\n+    except InferenceError:\n+        pass\n```\n\n**Result** — both call styles now consistently resolve the same way:\n\n```\nt5.py:31:15: E1101: Instance of 'Base' has no 'join' member (no-member)\nt5.py:35:15: E1101: Instance of 'Base' has no 'join' member (no-member)\n```\n\n(As the original issue notes, `Instance of <enclosing class>`\n\nisn't necessarily the *most precise* answer `cast()`\n\ncould give — but consistency is what the bug was actually about, and this fix delivers it cleanly.)\n\nI added a regression test, `test_infer_call_result_dunder_call_consistent_with_attribute_call`\n\n, reproducing the minimal case directly in astroid's own suite (`tests/test_inference.py`\n\n), 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`\n\nfailure — confirmed via `git stash`\n\nto also occur on unmodified `main`\n\n, ruling out any regression from this change.", "url": "https://wpnews.pro/news/the-bug-that-hid-behind-its-own-comment-fixing-inconsistent-inference-in-astroid", "canonical_source": "https://dev.to/kartikey_d47d5d0a50247b86/the-bug-that-hid-behind-its-own-comment-fixing-inconsistent-inference-in-astroid-10io", "published_at": "2026-08-22 06:20:26+00:00", "updated_at": "2026-08-22 06:44:06.243828+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["astroid", "pylint", "GitHub", "BaseInstance", "typing.cast", "PR #3242"], "alternates": {"html": "https://wpnews.pro/news/the-bug-that-hid-behind-its-own-comment-fixing-inconsistent-inference-in-astroid", "markdown": "https://wpnews.pro/news/the-bug-that-hid-behind-its-own-comment-fixing-inconsistent-inference-in-astroid.md", "text": "https://wpnews.pro/news/the-bug-that-hid-behind-its-own-comment-fixing-inconsistent-inference-in-astroid.txt", "jsonld": "https://wpnews.pro/news/the-bug-that-hid-behind-its-own-comment-fixing-inconsistent-inference-in-astroid.jsonld"}}