Skip to content

Instantly share code, notes, and snippets.

@blink1073
Last active July 30, 2026 00:41
Show Gist options
  • Select an option

  • Save blink1073/a9fe43fbb2643b12ab4472836e77ee40 to your computer and use it in GitHub Desktop.

Select an option

Save blink1073/a9fe43fbb2643b12ab4472836e77ee40 to your computer and use it in GitHub Desktop.
Using ty's unused-awaitable rule to find missing awaits in pymongo

Using ty's unused-awaitable rule to find missing awaits in pymongo

Context

Investigated whether Astral's ty type checker's unused-awaitable rule catches missing await calls that mypy strict misses.

ty isn't a dependency of this repo, but runs with no install step using uvx ty (v0.0.65 as of 2026-07-29).

Ran uvx ty check --ignore all --error unused-awaitable, which isolates just this rule. An unscoped ty check produces 100+ unrelated diagnostics from type-resolution gaps with mypy/pyright and is too noisy to use as-is.

Results, scoped to pymongo/, gridfs/, bson/, test/:

  • 0 hits in source (pymongo/, gridfs/, bson/).
  • 16 hits, all in test/asynchronous/ (test_client.py, test_cursor.py, test_retryable_writes.py).

14 of the 16 are expected false positives. All are inside methods decorated with @async_client_context.require_sync: plain def test methods that live in the async source file but only run in the generated sync test file (just synchro strips async/await). They're intentionally written in sync style and never actually execute under the async test suite. Confirmed by checking require_sync's implementation (test/asynchronous/__init__.py:792), which skips the test unless _IS_SYNC is true.

2 of the 16 are real bugs, tracked in PYTHON-5975:

  • test/asynchronous/test_client.py (~line 1875): inside test_reset_during_update_pool, a local ResetPoolThread(threading.Thread) class defines async def _run(self), but its def run(self): self._run() override never awaits it. The coroutine is created and discarded, so the thread's pool-reset loop never runs. The test still passes since it doesn't assert the reset happened concurrently, but it's silently not testing what it claims to.
  • test/asynchronous/test_retryable_writes.py (~line 86): InsertEventListener.succeeded() (a plain sync callback) calls async_client_context.client.admin.command(...) to set a configureFailPoint without awaiting it, so the fail point is silently never configured.

Why mypy doesn't already catch these: mypy strict has an equivalent check, unused-coroutine, enabled for test.asynchronous.* (mypy_test.ini). A minimal repro confirmed mypy catches this bug shape when methods are fully annotated. But mypy_test.ini disables no-untyped-def for test.*, and a second repro confirmed that once the same methods are left unannotated (the norm for test code here), mypy silently stops checking their bodies, missing the bug entirely. ty's unused-awaitable check isn't blocked by missing annotations, so it catches what mypy's config currently misses in test code.

Follow-up work: two tickets

  • PYTHON-5975: fix the 2 real missing-await bugs above.
  • Follow-up ticket ("Catch missing awaits in async code with ty"): wire up ty check --ignore all --error unused-awaitable as an ongoing check, so future missing-awaits are caught automatically.

Definition of done

PYTHON-5975:

  • Both real bugs are fixed, editing only test/asynchronous/* (never the mirrored test/*.py), with just synchro re-run afterward.
  • Each affected test genuinely exercises its intended scenario.

Follow-up ticket:

  • A just recipe (new, or folded into just typing) runs ty check --ignore all --error unused-awaitable scoped to pymongo, gridfs, bson, test.
  • The 14 known @require_sync false positives are suppressed (e.g. # ty: ignore[unused-awaitable]) without changing their intentionally sync-style bodies.
  • The check is wired into CI/pre-commit so future missing-awaits are caught automatically.
  • ty check --ignore all --error unused-awaitable passes clean (0 unsuppressed diagnostics).

Pitfalls

  • ty isn't a project dependency yet. Decide between uvx ty (no pin) or adding it to a dependency group before wiring into CI.
  • Keep the check scoped to --ignore all --error unused-awaitable. A related rule, invalid-await, also fired (15 hits, including 2 in real source: pymongo/asynchronous/bulk.py and cursor_base.py), but those overlap with existing # type: ignore[misc] suppressions and need separate, closer verification. Out of scope for both tickets.
  • Don't "fix" the 14 @require_sync hits by adding await. That's intentional dead code matching the generated sync test file; adding await would break just synchro output.
  • The ResetPoolThread.run() fix isn't just "add await": Thread.run() executes in a plain OS thread with no running event loop, so it needs a real fix (e.g. run the coroutine on a dedicated loop in that thread, or drop the raw Thread for something loop-aware).
  • Once the failpoint in InsertEventListener.succeeded() actually gets configured, test_retryable_writes.py may exercise a new code path, so confirm it still passes and still tests what it intends.
  • Per AGENTS.md: never edit pymongo/synchronous/* or mirrored test/*.py files directly. Edit pymongo/asynchronous/* / test/asynchronous/* and regenerate with just synchro.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment