From 879c87679fc72c9a0a9f89a771953fc3a38d7976 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 21:22:08 +0000 Subject: [PATCH 1/3] Fix ASYNC200 regression on `foo("x").bar` patterns (#26.4.2) The canonical-qualname resolver introduced in 26.4.1 recursed through ast.Call/cst.Call unconditionally, which collapsed nested calls inside an attribute chain into a dotted name (e.g. `read_session("a").get` resolved to `read_session.get`). This caused user-configured ASYNC200 patterns like `*session.get` to flag method calls on the return value of an unrelated function -- a false positive that did not exist in 25.5.3. Only unwrap Call at the outermost position; calls inside an attribute chain now make the whole expression unresolvable (None), since the target of the trailing attribute is a return value we can't determine statically. Top-level shapes like `trio.open_nursery()` still resolve as before. Adds: - eval-file regression case in tests/eval_files/async200.py - direct unit test for resolve_canonical_ast / resolve_canonical_cst - changelog entry for 26.4.2 and version bump https://claude.ai/code/session_01BWheK2fZPMDhbfS1LCR2zc --- docs/changelog.rst | 4 +++ docs/usage.rst | 2 +- flake8_async/__init__.py | 2 +- flake8_async/visitors/_canonical.py | 31 ++++++++++++++----- tests/eval_files/async200.py | 7 ++++- tests/test_flake8_async.py | 48 +++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 79fa8851..48be2015 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,10 @@ Changelog `CalVer, YY.month.patch `_ +26.4.2 +====== +- Fixed a regression in canonical-qualname resolution where a call nested inside an attribute chain (e.g. ``foo("x").bar``) was silently elided into a dotted name (``"foo.bar"``). This caused :ref:`ASYNC200 ` false alarms for patterns like ``*session.get`` matching ``read_session("a").get(...)``, where ``.get`` is a method on the *return value* of ``read_session()``. + 26.4.1 ====== - Rules resolve function/class references via the canonical qualname, so checks fire regardless of import style (``import trio``, ``import trio as t``, ``from trio import open_nursery [as on]``, …). Only module-level imports are tracked. `(issue #132) `_ diff --git a/docs/usage.rst b/docs/usage.rst index dbad5ea2..7b71ee65 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -33,7 +33,7 @@ adding the following to your ``.pre-commit-config.yaml``: minimum_pre_commit_version: '2.9.0' repos: - repo: https://github.com/python-trio/flake8-async - rev: 26.4.1 + rev: 26.4.2 hooks: - id: flake8-async # args: ["--enable=ASYNC100,ASYNC112", "--disable=", "--autofix=ASYNC"] diff --git a/flake8_async/__init__.py b/flake8_async/__init__.py index 26722f89..fc296ebc 100644 --- a/flake8_async/__init__.py +++ b/flake8_async/__init__.py @@ -38,7 +38,7 @@ # CalVer: YY.month.patch, e.g. first release of July 2022 == "22.7.1" -__version__ = "26.4.1" +__version__ = "26.4.2" # taken from https://github.com/Zac-HD/shed diff --git a/flake8_async/visitors/_canonical.py b/flake8_async/visitors/_canonical.py index 0a37a243..d98d14e7 100644 --- a/flake8_async/visitors/_canonical.py +++ b/flake8_async/visitors/_canonical.py @@ -19,24 +19,41 @@ # Resolve a Name/Attribute/Call node to a dotted qualname via `imports` # (local-name -> canonical dotted qualname). The root Name falls back to its own # identifier, so `trio.open_nursery()` resolves to "trio.open_nursery" even when -# nothing was imported. Returns None for shapes we can't resolve (subscripts, etc.). +# nothing was imported. Returns None for shapes we can't resolve (subscripts, +# Calls nested inside an Attribute chain like `foo("x").bar`, etc.). def resolve_canonical_ast(node: ast.AST, imports: Mapping[str, str]) -> str | None: + # A Call only collapses to its callee at the *outermost* position. Inside + # an Attribute chain (e.g. `foo("x").bar`), the call's return value is what + # `.bar` is bound on, and we can't determine that statically — so don't + # silently elide the call into a dotted name like "foo.bar". + if isinstance(node, ast.Call): + return resolve_canonical_ast(node.func, imports) + return _resolve_attr_chain_ast(node, imports) + + +def _resolve_attr_chain_ast( + node: ast.AST, imports: Mapping[str, str] +) -> str | None: if isinstance(node, ast.Name): return imports.get(node.id, node.id) if isinstance(node, ast.Attribute): - prefix = resolve_canonical_ast(node.value, imports) + prefix = _resolve_attr_chain_ast(node.value, imports) return None if prefix is None else f"{prefix}.{node.attr}" - if isinstance(node, ast.Call): - return resolve_canonical_ast(node.func, imports) return None def resolve_canonical_cst(node: cst.CSTNode, imports: Mapping[str, str]) -> str | None: + if isinstance(node, cst.Call): + return resolve_canonical_cst(node.func, imports) + return _resolve_attr_chain_cst(node, imports) + + +def _resolve_attr_chain_cst( + node: cst.CSTNode, imports: Mapping[str, str] +) -> str | None: if isinstance(node, cst.Name): return imports.get(node.value, node.value) if isinstance(node, cst.Attribute): - prefix = resolve_canonical_cst(node.value, imports) + prefix = _resolve_attr_chain_cst(node.value, imports) return None if prefix is None else f"{prefix}.{node.attr.value}" - if isinstance(node, cst.Call): - return resolve_canonical_cst(node.func, imports) return None diff --git a/tests/eval_files/async200.py b/tests/eval_files/async200.py index 419a8baf..61d535d9 100644 --- a/tests/eval_files/async200.py +++ b/tests/eval_files/async200.py @@ -2,7 +2,7 @@ # specify command-line arguments to be used when testing this file. # Test spaces in options, and trailing comma # Cannot test newlines, since argparse splits on those if passed on the CLI -# ARG --async200-blocking-calls=bar -> BAR, bee-> SHOULD_NOT_BE_PRINTED,bonnet ->SHOULD_NOT_BE_PRINTED,bee.bonnet->BEEBONNET,*.postwild->POSTWILD,prewild.*->PREWILD,*.*.*->TRIPLEDOT, +# ARG --async200-blocking-calls=bar -> BAR, bee-> SHOULD_NOT_BE_PRINTED,bonnet ->SHOULD_NOT_BE_PRINTED,bee.bonnet->BEEBONNET,*.postwild->POSTWILD,prewild.*->PREWILD,*.*.*->TRIPLEDOT,*session.get->SESSIONGET, # don't error in sync function @@ -63,3 +63,8 @@ async def bar3(): # check that errors are enabled again bar() # ASYNC200: 4, "bar", "BAR" + + # `foo("x").bar` calls a method on the *return value* of foo(), so the + # canonical name should not collapse to "foo.bar". + session.get("k") # ASYNC200: 4, "*session.get", "SESSIONGET" + read_session("a").get("k") diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py index c85e564c..d12cce69 100644 --- a/tests/test_flake8_async.py +++ b/tests/test_flake8_async.py @@ -891,6 +891,54 @@ def test_async400_excgroup_attributes(): assert attr in EXCGROUP_ATTRS +def test_resolve_canonical_does_not_elide_nested_calls(): + """A Call inside an Attribute chain must not collapse to a dotted name. + + Regression test: previously `foo("x").bar` resolved to "foo.bar" because + the recursive case for ``ast.Call`` / ``cst.Call`` fired on calls nested + inside an Attribute chain, not just at the outermost position. That made + ``*session.get`` fnmatch ``read_session("a").get`` in ASYNC200. + """ + from flake8_async.visitors._canonical import ( + resolve_canonical_ast, + resolve_canonical_cst, + ) + + imports: dict[str, str] = {} + + # outermost-Call unwrapping is preserved: trio.open_nursery() -> "trio.open_nursery" + ast_call = ast.parse("trio.open_nursery()", mode="eval").body + assert isinstance(ast_call, ast.Call) + assert resolve_canonical_ast(ast_call, imports) == "trio.open_nursery" + + # plain attribute chain still resolves + ast_attr = ast.parse("session.get", mode="eval").body + assert resolve_canonical_ast(ast_attr, imports) == "session.get" + + # the regression: Call nested inside Attribute should NOT collapse + ast_nested = ast.parse('foo("x").bar', mode="eval").body + assert resolve_canonical_ast(ast_nested, imports) is None + + # also when the outer node is itself a call (`foo("x").bar()`): + # outer call unwraps to `foo("x").bar`, which then can't resolve. + ast_nested_call = ast.parse('foo("x").bar()', mode="eval").body + assert isinstance(ast_nested_call, ast.Call) + assert resolve_canonical_ast(ast_nested_call, imports) is None + + # cst variant: same expectations + cst_call = cst.parse_expression("trio.open_nursery()") + assert resolve_canonical_cst(cst_call, imports) == "trio.open_nursery" + + cst_attr = cst.parse_expression("session.get") + assert resolve_canonical_cst(cst_attr, imports) == "session.get" + + cst_nested = cst.parse_expression('foo("x").bar') + assert resolve_canonical_cst(cst_nested, imports) is None + + cst_nested_call = cst.parse_expression('foo("x").bar()') + assert resolve_canonical_cst(cst_nested_call, imports) is None + + # from https://docs.python.org/3/library/itertools.html#itertools-recipes def consume(iterator: Iterable[Any]): deque(iterator, maxlen=0) From b6dbfe955063d27ba0c9fcfc091da9a0d2f84f43 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 20:27:33 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- flake8_async/visitors/_canonical.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flake8_async/visitors/_canonical.py b/flake8_async/visitors/_canonical.py index d98d14e7..06484084 100644 --- a/flake8_async/visitors/_canonical.py +++ b/flake8_async/visitors/_canonical.py @@ -31,9 +31,7 @@ def resolve_canonical_ast(node: ast.AST, imports: Mapping[str, str]) -> str | No return _resolve_attr_chain_ast(node, imports) -def _resolve_attr_chain_ast( - node: ast.AST, imports: Mapping[str, str] -) -> str | None: +def _resolve_attr_chain_ast(node: ast.AST, imports: Mapping[str, str]) -> str | None: if isinstance(node, ast.Name): return imports.get(node.id, node.id) if isinstance(node, ast.Attribute): From 347a933c82eef7c18b8611add2557eacd6688dd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 20:31:49 +0000 Subject: [PATCH 3/3] Satisfy pre-commit: hoist import + black reformat - Move `resolve_canonical_ast` / `resolve_canonical_cst` import to the top of tests/test_flake8_async.py (ruff PLC0415 / import-not-at-top). - Black reformat of `_resolve_attr_chain_ast` signature. https://claude.ai/code/session_01BWheK2fZPMDhbfS1LCR2zc --- tests/test_flake8_async.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_flake8_async.py b/tests/test_flake8_async.py index d12cce69..2b8a365b 100644 --- a/tests/test_flake8_async.py +++ b/tests/test_flake8_async.py @@ -26,6 +26,10 @@ from flake8_async import Plugin from flake8_async.base import Error, Statement from flake8_async.visitors import ERROR_CLASSES, ERROR_CLASSES_CST +from flake8_async.visitors._canonical import ( + resolve_canonical_ast, + resolve_canonical_cst, +) from flake8_async.visitors.visitor4xx import EXCGROUP_ATTRS if sys.version_info < (3, 11): @@ -899,11 +903,6 @@ def test_resolve_canonical_does_not_elide_nested_calls(): inside an Attribute chain, not just at the outermost position. That made ``*session.get`` fnmatch ``read_session("a").get`` in ASYNC200. """ - from flake8_async.visitors._canonical import ( - resolve_canonical_ast, - resolve_canonical_cst, - ) - imports: dict[str, str] = {} # outermost-Call unwrapping is preserved: trio.open_nursery() -> "trio.open_nursery"