Skip to content

Commit 112e892

Browse files
Zac-HDclaudepre-commit-ci[bot]
authored
Track imports so we can disable ASYNC106 (#450)
* Match rules against canonical qualnames across all import styles Previously the linter only recognised trio/anyio/asyncio-related calls when they appeared exactly as `trio.open_nursery`, `anyio.create_task_group`, etc. Aliased imports (`import trio as t`), `from` imports (`from trio import open_nursery`), and aliased-from imports (`from trio import open_nursery as on`) silently escaped detection. This adds a pair of utility visitors (VisitorImportTracker / _cst) that build a local-name -> canonical-dotted-qualname map, a pair of helpers (resolve_canonical_ast / _cst) and base-class shortcut `canonical_name()`, and threads an `imports=` keyword through the existing matcher helpers (get_matching_call[_cst], fnmatch_qualified_name[_cst], with_has_call, calls_any_of, critical_except). The tracker only records module-level imports, so function-local imports don't leak into sibling scopes. Existing visitors are updated to pass `self.imports` to those helpers, and ASYNC105/ASYNC115/ASYNC118/ASYNC2xx/ASYNC300 etc. now match via canonical qualname instead of the literal spelling. ASYNC106 was a workaround for the old limitation; it's now disabled by default but left in place for projects that still want to enforce the `import trio` style. Closes #132. https://claude.ai/code/session_018Hc9rcA31SnXcN8Ee5vVwH * Tighten canonical-qualname code and comments - Drop redundant canonical_name() docstrings. - Simplify fnmatch_qualified_name[_cst] to build a candidate set inline. - Fold the resolve_canonical_ast recursive arm into one-liners. - Drop ASYNC21X's bespoke urllib3-import set -- consult the shared imports map. - Collapse ASYNC22X's raw_name/canonical/func_name triplet into two locals. - Simplify with_has_call's canonical fallback to a startswith + suffix check. - Consolidate the CST scope-tracker's three visit/leave pairs into shared helpers. - Rewrite the narrative "this change" comments as reader-facing rationale, both in the code and in the eval-file annotations. https://claude.ai/code/session_018Hc9rcA31SnXcN8Ee5vVwH * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Appease ruff and mypy - Move resolve_canonical_ast/cst into a dedicated _canonical module so the base-class methods can import them at the top level (PLC0415). - Flatten nested isinstance chain in get_matching_call_cst (SIM102). - Reformat the import-tracker example table so ruff stops flagging the continuation line as commented-out code (ERA001). - Inline the isinstance(ast.Call) check in critical_except so mypy's narrowing kicks in (attr-defined on "expr"). - Drop the now-unused identifier_to_string import from visitor91x. https://claude.ai/code/session_018Hc9rcA31SnXcN8Ee5vVwH * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add label to ASYNC106 docs so changelog cross-reference resolves The changelog entry `:ref:`ASYNC106 <async106>`` targets a rule that didn't have a Sphinx label, which made readthedocs fail with `undefined label: 'async106'` under `-W`. https://claude.ai/code/session_018Hc9rcA31SnXcN8Ee5vVwH --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent f78b772 commit 112e892

29 files changed

Lines changed: 510 additions & 133 deletions

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Changelog
66

77
Unreleased
88
==========
9+
- 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) <https://github.com/python-trio/flake8-async/issues/132>`_
10+
- :ref:`ASYNC106 <async106>` is now disabled by default; re-enable it to enforce the ``import trio`` style.
911
- Autofix for :ref:`ASYNC910 <async910>` / :ref:`ASYNC911 <async911>` no longer inserts checkpoints inside ``except`` clauses (which would trigger :ref:`ASYNC120 <async120>`); instead the checkpoint is added at the top of the function or of the enclosing loop. `(issue #403) <https://github.com/python-trio/flake8-async/issues/403>`_
1012
- :ref:`ASYNC910 <async910>` and :ref:`ASYNC911 <async911>` now accept ``__aenter__`` / ``__aexit__`` methods when the partner method provides the checkpoint, or when only one of the two is defined on a class that inherits from another class (charitably assuming the partner is inherited and contains a checkpoint). `(issue #441) <https://github.com/python-trio/flake8-async/issues/441>`_
1113
- :ref:`ASYNC300 <async300>` no longer triggers when the result of ``asyncio.create_task()`` is returned from a function. `(issue #398) <https://github.com/python-trio/flake8-async/issues/398>`_

docs/rules.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ ASYNC105 : missing-await
4141
async trio function called without using ``await``.
4242
This is only supported with trio functions, but you can get similar functionality with a type-checker.
4343

44-
ASYNC106 : bad-async-library-import
45-
trio/anyio/asyncio must be imported with ``import xxx`` for the linter to work.
44+
_`ASYNC106` : bad-async-library-import
45+
trio/anyio/asyncio should be imported with ``import xxx`` for consistency.
46+
Opt-in style check; the linter resolves other import styles correctly.
4647

4748
ASYNC109 : async-function-with-timeout
4849
Async function definition with a ``timeout`` parameter.

flake8_async/runner.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ class SharedState:
3737
library: tuple[str, ...] = ()
3838
typed_calls: dict[str, str] = field(default_factory=dict[str, str])
3939
variables: dict[str, str] = field(default_factory=dict[str, str])
40+
# Local name -> canonical dotted qualname, populated by VisitorImportTracker[_cst].
41+
# Helpers consult this so rules can match the canonical qualname regardless of
42+
# how a symbol was imported (`import x`, `import x as y`, `from x import y`,
43+
# `from x import y as z`).
44+
imports: dict[str, str] = field(default_factory=dict[str, str])
4045

4146

4247
class __CommonRunner:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Canonical-qualname resolution for ast / cst nodes.
2+
3+
Kept in its own module to avoid circular imports between
4+
``flake8asyncvisitor`` (which exposes ``canonical_name`` on the base classes)
5+
and ``helpers`` (which accepts an ``imports`` mapping for matcher functions).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import ast
11+
from typing import TYPE_CHECKING
12+
13+
import libcst as cst
14+
15+
if TYPE_CHECKING:
16+
from collections.abc import Mapping
17+
18+
19+
# Resolve a Name/Attribute/Call node to a dotted qualname via `imports`
20+
# (local-name -> canonical dotted qualname). The root Name falls back to its own
21+
# identifier, so `trio.open_nursery()` resolves to "trio.open_nursery" even when
22+
# nothing was imported. Returns None for shapes we can't resolve (subscripts, etc.).
23+
def resolve_canonical_ast(node: ast.AST, imports: Mapping[str, str]) -> str | None:
24+
if isinstance(node, ast.Name):
25+
return imports.get(node.id, node.id)
26+
if isinstance(node, ast.Attribute):
27+
prefix = resolve_canonical_ast(node.value, imports)
28+
return None if prefix is None else f"{prefix}.{node.attr}"
29+
if isinstance(node, ast.Call):
30+
return resolve_canonical_ast(node.func, imports)
31+
return None
32+
33+
34+
def resolve_canonical_cst(node: cst.CSTNode, imports: Mapping[str, str]) -> str | None:
35+
if isinstance(node, cst.Name):
36+
return imports.get(node.value, node.value)
37+
if isinstance(node, cst.Attribute):
38+
prefix = resolve_canonical_cst(node.value, imports)
39+
return None if prefix is None else f"{prefix}.{node.attr.value}"
40+
if isinstance(node, cst.Call):
41+
return resolve_canonical_cst(node.func, imports)
42+
return None

flake8_async/visitors/flake8asyncvisitor.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from libcst.metadata import PositionProvider
1111

1212
from ..base import Error, Statement, strip_error_subidentifier
13+
from ._canonical import resolve_canonical_ast, resolve_canonical_cst
1314

1415
if TYPE_CHECKING:
1516
from collections.abc import Iterable, Mapping
@@ -53,6 +54,13 @@ def variables(self, value: dict[str, str]) -> None:
5354
self.__state.variables.clear()
5455
self.__state.variables.update(value)
5556

57+
@property
58+
def imports(self) -> dict[str, str]:
59+
return self.__state.imports
60+
61+
def canonical_name(self, node: ast.AST) -> str | None:
62+
return resolve_canonical_ast(node, self.__state.imports)
63+
5664
def visit(self, node: ast.AST):
5765
"""Visit a node."""
5866
# construct visitor for this node type
@@ -170,6 +178,13 @@ def __init__(self, shared_state: SharedState):
170178
self.options = self.__state.options
171179
self.noqas = self.__state.noqas
172180

181+
@property
182+
def imports(self) -> dict[str, str]:
183+
return self.__state.imports
184+
185+
def canonical_name(self, node: cst.CSTNode) -> str | None:
186+
return resolve_canonical_cst(node, self.__state.imports)
187+
173188
def get_state(self, *attrs: str, copy: bool = False) -> dict[str, Any]:
174189
# require attrs, since we inherit a *ton* of stuff which we don't want to copy
175190
assert attrs

flake8_async/visitors/helpers.py

Lines changed: 99 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
utility_visitors,
2525
utility_visitors_cst,
2626
)
27+
from ._canonical import resolve_canonical_ast, resolve_canonical_cst
2728

2829
if TYPE_CHECKING:
29-
from collections.abc import Iterable, Iterator, Sequence
30+
from collections.abc import Iterable, Iterator, Mapping, Sequence
3031

3132
from .flake8asyncvisitor import (
3233
Flake8AsyncVisitor,
@@ -101,29 +102,44 @@ def has_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef, *names: str):
101102
# matches the fully qualified name against fnmatch pattern
102103
# used to match decorators and methods to user-supplied patterns
103104
# used in 910/911 and 200
104-
def fnmatch_qualified_name(name_list: list[ast.expr], *patterns: str) -> str | None:
105+
def fnmatch_qualified_name(
106+
name_list: Iterable[ast.expr],
107+
*patterns: str,
108+
imports: Mapping[str, str] | None = None,
109+
) -> str | None:
105110
for name in name_list:
106111
if isinstance(name, ast.Call):
107112
name = name.func
108-
qualified_name = ast.unparse(name)
109-
113+
candidates = {ast.unparse(name)}
114+
if imports is not None and (canonical := resolve_canonical_ast(name, imports)):
115+
candidates.add(canonical)
110116
for pattern in patterns:
111117
# strip leading "@"s for when we're working with decorators
112-
if fnmatch(qualified_name, pattern.lstrip("@")):
118+
stripped = pattern.lstrip("@")
119+
if any(fnmatch(c, stripped) for c in candidates):
113120
return pattern
114121
return None
115122

116123

117124
def fnmatch_qualified_name_cst(
118125
name_list: Iterable[cst.Decorator | cst.Call | cst.Attribute | cst.Name],
119126
*patterns: str,
127+
imports: Mapping[str, str] | None = None,
120128
) -> str | None:
121129
for name in name_list:
122-
qualified_name = get_full_name_for_node_or_raise(name)
123-
130+
candidates = {get_full_name_for_node_or_raise(name)}
131+
if imports is not None:
132+
inner: cst.CSTNode = name
133+
if isinstance(inner, cst.Decorator):
134+
inner = inner.decorator
135+
if isinstance(inner, cst.Call):
136+
inner = inner.func
137+
if (canonical := resolve_canonical_cst(inner, imports)) is not None:
138+
candidates.add(canonical)
124139
for pattern in patterns:
125140
# strip leading "@"s for when we're working with decorators
126-
if fnmatch(qualified_name, pattern.lstrip("@")):
141+
stripped = pattern.lstrip("@")
142+
if any(fnmatch(c, stripped) for c in candidates):
127143
return pattern
128144
return None
129145

@@ -240,7 +256,9 @@ def iter_guaranteed_once_cst(iterable: cst.BaseExpression) -> bool:
240256

241257

242258
# used in 102, 103 and 104
243-
def critical_except(node: ast.ExceptHandler) -> Statement | None:
259+
def critical_except(
260+
node: ast.ExceptHandler, imports: Mapping[str, str] | None = None
261+
) -> Statement | None:
244262
def has_exception(node: ast.expr) -> str | None:
245263
name = ast.unparse(node)
246264
if name in (
@@ -253,6 +271,27 @@ def has_exception(node: ast.expr) -> str | None:
253271
"CancelledError",
254272
):
255273
return name
274+
if imports is None:
275+
return None
276+
# Resolve via canonical qualname for aliased / `from`-imported forms.
277+
# The non-call spellings (`except anyio.get_cancelled_exc_class:`, or a
278+
# Call with arguments) are type-errors that critical_except intentionally
279+
# ignores, so only zero-arg calls count for get_cancelled_exc_class.
280+
if isinstance(node, ast.Call):
281+
if node.args or node.keywords:
282+
return None
283+
canonical = resolve_canonical_ast(node.func, imports)
284+
if canonical == "anyio.get_cancelled_exc_class":
285+
return "anyio.get_cancelled_exc_class()"
286+
return None
287+
canonical = resolve_canonical_ast(node, imports)
288+
if canonical == "trio.Cancelled":
289+
return "trio.Cancelled"
290+
if canonical in (
291+
"asyncio.exceptions.CancelledError",
292+
"asyncio.CancelledError",
293+
):
294+
return "asyncio.exceptions.CancelledError"
256295
return None
257296

258297
name: str | None = None
@@ -302,36 +341,56 @@ def __str__(self) -> str:
302341

303342
# convenience function used in a lot of visitors
304343
def get_matching_call(
305-
node: ast.AST, *names: str, base: Iterable[str] = ("trio", "anyio")
344+
node: ast.AST,
345+
*names: str,
346+
base: Iterable[str] = ("trio", "anyio"),
347+
imports: Mapping[str, str] | None = None,
306348
) -> MatchingCall[ast.Call] | None:
307349
if isinstance(base, str):
308350
base = (base,)
351+
if not isinstance(node, ast.Call):
352+
return None
309353
if (
310-
isinstance(node, ast.Call)
311-
and isinstance(node.func, ast.Attribute)
354+
isinstance(node.func, ast.Attribute)
312355
and isinstance(node.func.value, ast.Name)
313356
and node.func.value.id in base
314357
and node.func.attr in names
315358
):
316359
return MatchingCall(node, node.func.attr, node.func.value.id)
360+
if imports is not None:
361+
canonical = resolve_canonical_ast(node.func, imports)
362+
for b in base:
363+
for n in names:
364+
if canonical == f"{b}.{n}":
365+
return MatchingCall(node, n, b)
317366
return None
318367

319368

320369
# ___ CST helpers ___
321370
def get_matching_call_cst(
322-
node: cst.CSTNode, *names: str, base: Iterable[str] = ("trio", "anyio")
371+
node: cst.CSTNode,
372+
*names: str,
373+
base: Iterable[str] = ("trio", "anyio"),
374+
imports: Mapping[str, str] | None = None,
323375
) -> MatchingCall[cst.Call] | None:
324376
if isinstance(base, str):
325377
base = (base,)
378+
if not isinstance(node, cst.Call):
379+
return None
326380
if (
327-
isinstance(node, cst.Call)
328-
and isinstance(node.func, cst.Attribute)
381+
isinstance(node.func, cst.Attribute)
329382
and node.func.attr.value in names
330383
and isinstance(node.func.value, (cst.Name, cst.Attribute))
331384
):
332385
attr_base = identifier_to_string(node.func.value)
333386
if attr_base is not None and attr_base in base:
334387
return MatchingCall(node, node.func.attr.value, attr_base)
388+
if imports is not None:
389+
canonical = resolve_canonical_cst(node.func, imports)
390+
for b in base:
391+
for n in names:
392+
if canonical == f"{b}.{n}":
393+
return MatchingCall(node, n, b)
335394
return None
336395

337396

@@ -377,12 +436,17 @@ def identifier_to_string(node: cst.CSTNode) -> str | None:
377436

378437

379438
def with_has_call(
380-
node: cst.With, *names: str, base: Iterable[str] | str = ("trio", "anyio")
439+
node: cst.With,
440+
*names: str,
441+
base: Iterable[str] | str = ("trio", "anyio"),
442+
imports: Mapping[str, str] | None = None,
381443
) -> list[MatchingCall[cst.Call]]:
382444
"""Check if a with statement has a matching call, returning a list with matches.
383445
384446
`names` specify the names of functions to match, `base` specifies the
385-
library/module(s) the function must be in.
447+
library/module(s) the function must be in. If `imports` is given, matches
448+
are also made against the canonical qualname so aliased / `from`-imports
449+
are detected.
386450
The list elements in the return value are named tuples with the matched node,
387451
base and function.
388452
@@ -393,19 +457,15 @@ def with_has_call(
393457
`foo.bar`, `foo.bee`, `a.b.c.bar`, and `a.b.c.bee`.
394458
395459
"""
396-
if isinstance(base, str):
397-
base = (base,)
460+
base_tuple = (base,) if isinstance(base, str) else tuple(base)
398461

399462
# build matcher, using SaveMatchedNode to save the base and the function name.
400463
matcher = m.Call(
401464
func=m.Attribute(
402465
value=m.SaveMatchedNode(
403-
m.OneOf(*(build_cst_matcher(b) for b in base)), name="base"
404-
),
405-
attr=m.SaveMatchedNode(
406-
oneof_names(*names),
407-
name="function",
466+
m.OneOf(*(build_cst_matcher(b) for b in base_tuple)), name="base"
408467
),
468+
attr=m.SaveMatchedNode(oneof_names(*names), name="function"),
409469
)
410470
)
411471

@@ -422,10 +482,22 @@ def with_has_call(
422482
node=item.item, base=base_string, name=res["function"].value
423483
)
424484
)
485+
continue
486+
if imports is None or not isinstance(item.item, cst.Call):
487+
continue
488+
canonical = resolve_canonical_cst(item.item.func, imports)
489+
for b in base_tuple:
490+
if canonical is not None and canonical.startswith(f"{b}."):
491+
suffix = canonical[len(b) + 1 :]
492+
if suffix in names:
493+
res_list.append(MatchingCall(node=item.item, base=b, name=suffix))
494+
break
425495
return res_list
426496

427497

428-
def calls_any_of(node: cst.With, *qualnames: str) -> bool:
498+
def calls_any_of(
499+
node: cst.With, *qualnames: str, imports: Mapping[str, str] | None = None
500+
) -> bool:
429501
"""Return True if `node` contains a withitem matching any of `qualnames`.
430502
431503
Each `qualname` is a dotted string like ``"trio.open_nursery"`` or
@@ -439,7 +511,8 @@ def calls_any_of(node: cst.With, *qualnames: str) -> bool:
439511
assert name, f"{qn!r} is not a dotted qualname"
440512
by_base[base].append(name)
441513
return any(
442-
with_has_call(node, *names, base=base) for base, names in by_base.items()
514+
with_has_call(node, *names, base=base, imports=imports)
515+
for base, names in by_base.items()
443516
)
444517

445518

flake8_async/visitors/visitor101.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def visit_With(self, node: cst.With):
7676
self._yield_is_error = (
7777
not self._safe_decorator
7878
and not self._yield_is_error
79-
and calls_any_of(node, *_CANCEL_SCOPE_CMS)
79+
and calls_any_of(node, *_CANCEL_SCOPE_CMS, imports=self.imports)
8080
)
8181

8282
def leave_With(

0 commit comments

Comments
 (0)