Static-analysis checker for four common asyncio footguns — built because these bite NiceGUI apps particularly hard (one blocking call in a page handler stalls the event loop for every connected client, not just the caller), but the checks are generic asyncio, not NiceGUI-specific.
Loose end #6 (
agent-asyncio footgun linter for NiceGUI, conv #363, 2026-06-02): "I'll roll this into its own artifact later, useful for NiceGUI even." This is that artifact.
| Code | Catches | Example |
|---|---|---|
NGA001 |
time.sleep(...) inside async def |
async def f(): time.sleep(2) |
NGA002 |
Known-blocking sync I/O inside async def — requests.*, urllib.request.urlopen, subprocess.run/call/check_call/check_output, os.system, builtin input() (builtin open() is not in the default set — too common to fail CI on) |
async def f(): requests.get(url) |
NGA003 |
asyncio.create_task() / asyncio.ensure_future() whose return value is discarded (not assigned to anything) |
async def f(): asyncio.create_task(bg()) |
NGA004 |
A call to a function/method defined async def in the same file, used as a bare statement without await |
async def f(): some_local_coro() |
NGA003 and NGA004 are two faces of the same asyncio trap class the CPython docs warn about
explicitly: a coroutine or Task object that's created and then has no strong reference held to
it. For NGA004 the coroutine object is simply never scheduled at all (silently does nothing —
no error, no warning, just a RuntimeWarning: coroutine '...' was never awaited if you're lucky
enough to have warnings visible). For NGA003 the Task is scheduled, but asyncio only holds a
weak reference to it once you drop the strong one, so it can be garbage-collected mid-flight —
see the "Important" box in the asyncio.create_task docs.
cd nicegui-asyncio-lint # this directory
uv venv .venv && source .venv/bin/activate
uv pip install -e ".[test]"
# scan a file or a whole tree
nicegui-asyncio-lint path/to/app.py
nicegui-asyncio-lint path/to/project/
# restrict to specific rules
nicegui-asyncio-lint --select NGA001,NGA003 path/to/project/Exit code is 1 if anything was flagged (CI-friendly), 0 otherwise.
Walking a directory prunes dot-directories (.venv, .git, .tox, .mypy_cache, …) and the
names in DEFAULT_EXCLUDE_DIRS (venv, node_modules, __pycache__, __pypackages__,
site-packages, dist, _build, buck-out) — the subset of ruff's default exclude list that
can hold .py files. Without this, nicegui-asyncio-lint . on a fresh NiceGUI project reports
findings from anyio/engineio/nicegui inside the project's own .venv — third-party code
the user cannot fix — and exits 1.
Only discovered directories are pruned; a path named on the command line is always scanned, so
nicegui-asyncio-lint .venv/lib/python3.12/site-packages still works. That is also the escape
hatch for code you do own inside a dot-directory — .github/scripts/ is skipped by the walk
unless you name it.
nicegui-asyncio-lint --exclude vendor,migrations . # prune extra directory names
nicegui-asyncio-lint --no-default-excludes . # scan everything, venv included# .pre-commit-config.yaml
- repo: local
hooks:
- id: nicegui-asyncio-lint
name: nicegui-asyncio-lint
entry: nicegui-asyncio-lint
language: system
types: [python]from nicegui_asyncio_lint import check_paths
findings = list(check_paths([Path("my_app_dir")]))
for f in findings:
print(f.format())pytest tests/ -v→ all passed (one test per rule's true-positive + true-negative boundary, plus a directory-walk + syntax-error-tolerance test; 8 at the time of writing, 28 today).- Ran the built CLI against real code on this machine, read-only:
~/nicegui/nicegui(243.pyfiles, NiceGUI's own core library) → 0 findings — plausible for a mature, reviewed core lib; the checks target app-level mistakes.~/niceguiincluding its.venvand test suite (trio,starlettevendored source + NiceGUI's own tests) → 15 findings, including real, independently-known patterns:trio's own test suite intentionally calls blockingtime.sleep()/open()insideasync deftest helpers (expected — those are deliberately testing blocking-thread behaviour, not app code), and one genuine false positive surfaced and is written up below because it directly demonstrates the documented scoping limitation.
- Single-file, flat, unscoped name resolution for
NGA004. The checker does not do real scope analysis — it collects everyasync def name(...)in a file into one flat set, then flags any barename(...)call as a suspected missing-await. If a file defines multiple functions with the same name in different local scopes (e.g. several test functions each locally defining a differently-implementednumber_ui), and any one of those same-named functions isasync def, a bare call to a different, non-async, same-named function elsewhere in the file will be misflagged. This is not hypothetical — it fired for real againstnicegui/tests/test_refreshable.py, which definesnumber_uithree separate times in three separate test functions (lines 11, 39, 258), only one of which (line 39) isasync def. The checker flagged the unrelated syncnumber_ui()call at line 16 asNGA004. A real implementation shipped as a NiceGUI contrib module or PR should either scope symbol collection per-enclosing-function, or (more robustly) keepNGA004low-noise, it only fires on names that areasync defin this file and never also defined as a plaindef(ambiguous names are skipped), so an ordinary syncself.refresh()is never flagged just because some other class has an asyncrefresh. - Import-aware, single file.
NGA001/NGA002resolveimport x as y,from m import n, andfrom m import n as pto the canonical name before matching (sofrom time import sleep; sleep(1)andimport requests as r; r.get(...)are caught), and a parameter that shadows a module (async def f(time): time.sleep(1)) is correctly not flagged. Cross-file resolution is still out of scope — a missingawaiton an imported coroutine is not caught. NGA003/NGA004catch the discarded-as-a-statement andreturned forms, but not "assigned to a local that is then never used" (t = create_task(...)withtnever referenced). That needs use/flow analysis; it's the natural next step, not done here.NGA002's blocking-call list is a fixed, conservative allowlist-of-known-badness, not a type checker. Arbitrary third-party blocking calls (a blocking driver's.execute(), a synchronous SDK method, etc.) are invisible to it by design — extending the list is the natural next step (see TODO below).- No cross-function blocking-call propagation. If
async def a()calls a sync helperdef b()that itself callstime.sleep(), onlyb's body is outside an async-def in the AST's eyes and is not flagged, even thougha()transitively blocks. Full callgraph analysis is out of scope for a fast, dependency-free single-pass AST checker.
Both are viable from this same code, unmodified:
- Standalone pip-installable tool (what's built here) —
pip install nicegui-asyncio-lint(once actually published), run via CLI or pre-commit, works on any asyncio codebase. - NiceGUI contrib module / PR — copy
nicegui_asyncio_lint/intonicegui/testing/asyncio_lint.pyor a newscripts/asyncio_lint.pyin the NiceGUI repo, wire it intodocs/CONTRIBUTING.mdas an optional dev-mode check, and/or add a pytest marker that runs it overexamples/in CI to keep NiceGUI's own example gallery footgun-free. Given theNGA004false-positive found against NiceGUI's own test suite above, step 1 of any such PR should be tighteningNGA004scope-resolution first — shipping it against NiceGUI's codebase as-is would immediately self-flag. - A draft Claude Code skill wrapper (
SKILL.mdin this directory) is included as a third option so an agent session can reach for this tool by trigger phrase without a human first publishing it anywhere.
- Fix the
NGA004false positive class (scope-qualify the symbol table by enclosing function, not just by file) — or ship it as a lower-severity warning until fixed. - Decide: publish to PyPI standalone, or open a NiceGUI PR (or both).
- If NiceGUI PR: pick the actual target path/module name inside the NiceGUI repo, write it
as a
git worktreebranch (wt new <branch>), add it to CI, run it overexamples/. - Expand the
NGA002blocking-call table based on real NiceGUI-app postmortems if any surface (this list was built from general Python-asyncio knowledge, not a NiceGUI-specific incident corpus).