A step-by-step, incremental path from asyncio to anyio (and optionally on to trio) that keeps the code
shippable after every commit. Each manual rule reported by scripts/migrate_async.py has a recipe below;
the self-contained ones run as-is on anyio 4.x and print the output shown.
Contents
- Phase 0: decide and inventory
- Phase 1: tests first
- Phase 2: mechanical rewrites
- Phase 3: manual items, in this order
- Phase 4: cancellation review
- Phase 5: second backend and CI gate
- Mixed codebases
- Recipes for every manual rule
Phase 0: decide and inventory
- Write down the reason for migrating: correct cancellation, no orphaned tasks, a library that must run
under trio as well, or FastAPI code that is already on anyio. "It's nicer" is not enough on its own: on 3.11+
asyncio.TaskGroup+asyncio.timeoutgive most of the structure. - List dependencies by backend (see the compatibility table in the SKILL.md). asyncio-only dependencies (asyncpg, aiohttp, aioboto3, redis.asyncio, motor) mean "anyio on the asyncio backend" is the realistic target for those modules.
- Inventory:
python scripts/migrate_async.py src/ --json > migration.json
python scripts/migrate_async.py src/ | tail -1 # "N item(s) ...: X mechanical, Y manual"
python scripts/migrate_async.py src/ --select MIG014,MIG020,MIG021,MIG030 # the structural onesFiles whose items are all mechanical are cheap. Free tasks (MIG014), futures (MIG020), callbacks (MIG021) and external cancellation (MIG030) are where the time goes, because they need a new ownership structure.
Phase 1: tests first
Switch the test runner before touching production code, so every later step is checked:
# pyproject.toml
[tool.pytest.ini_options]
anyio_mode = "auto" # recent anyio 4.x: every async test runs under the anyio plugin
# asyncio_mode = "auto" # remove pytest-asyncio's auto mode: the two plugins must not both claim testsPin the backend to asyncio first (anyio_backend fixture returning "asyncio"). Tests that use
pytest-asyncio fixtures need @pytest.mark.anyio and their async fixtures will then run under anyio.
Details: testing-with-anyio-and-trio.md. The suite must stay green on the
asyncio backend before you change anything else.
Phase 2: mechanical rewrites
python scripts/migrate_async.py src/pkg/module.py --diff | less # review
python scripts/migrate_async.py src/pkg/module.py --write # needs zero manual items
python scripts/migrate_async.py src/pkg/ --write --force # or: partial, keeps import asyncio
pytest -q # still green on asyncioWhat the rewriter guarantees: each rewrite is behaviour-preserving at that call site (it refuses otherwise
and reports the reason), the output parses, and import asyncio disappears only when nothing uses it. What
it cannot guarantee: semantics outside the call site, which is why Phase 4 exists. Notable fine print:
| Rewrite | Behaviour change to know about |
|---|---|
asyncio.to_thread -> anyio.to_thread.run_sync | cancellation waits for the thread (asyncio abandons it); threads come from anyio's limiter (40), not the loop executor |
asyncio.timeout -> anyio.fail_after | identical on the asyncio backend; on trio trio.fail_after raises TooSlowError |
asyncio.Lock -> anyio.Lock | releasing from another task raises RuntimeError (the tool refuses when .release() is called) |
asyncio.TimeoutError -> TimeoutError | identical on 3.11+; on 3.9/3.10 correct only once the raiser is anyio too |
except asyncio.CancelledError -> get_cancelled_exc_class() | identical class on asyncio; a handler that does not re-raise is flagged MIG031 |
Commit each file separately; review is easier and a bisect stays useful.
Phase 3: manual items, in this order
- MIG014 / MIG030 / MIG021: give every task an owner. Find the scope that should outlive the task (the request, the job, the app lifespan) and open a task group there.
- MIG011 / MIG012 / MIG022 / MIG023: fan-out shapes. gather, wait and as_completed become task groups writing into lists or memory streams.
- MIG015 / MIG016: queues -> memory object streams.
- MIG013 / MIG024 / MIG032: timeouts and shielding -> cancel scopes.
- MIG017 / MIG028 / MIG019 / MIG018: threads and loop access -> to_thread / from_thread / portals.
- MIG025 / MIG026 / MIG027: subprocess, sockets, signals.
- MIG020 / MIG029 / MIG033 / MIG099: futures, introspection, odds and ends.
Phase 4: cancellation review
For every file, check:
- every
except get_cancelled_exc_class()ends withraise(MIG031 finds the obvious ones); - no
except BaseException:or bareexcept:without re-raise around awaits; - every
awaitinsidefinally/exceptthat must run during cancellation is insidewith anyio.CancelScope(shield=True):and bounded bymove_on_after; - timeouts wrap the smallest unit that can hang (one model call, one tool call), and an outer budget wraps the whole request;
- long CPU loops have a checkpoint or run in a thread/process;
-
to_thread.run_synccalls that must stop promptly useabandon_on_cancel=Trueand are idempotent.
Deep dive: cancellation-and-exception-groups.md.
Phase 5: second backend and CI gate
# .github/workflows/ci.yml (excerpt)
- run: python skills/.../scripts/migrate_async.py src/ --check # exit 1 while asyncio remains
- run: pytest -q # anyio_backend over asyncio + trioKeep intentional asyncio code (an asyncpg adapter, a uvloop tweak) behind # noqa: MIG018-style comments
with a reason, so --check stays meaningful. Add trio to the backend matrix only for packages free of
asyncio-only dependencies; for applications it is fine to stay on the asyncio backend forever.
Mixed codebases
anyio on the asyncio backend is asyncio, so the two styles coexist:
import asyncio
import anyio
async def legacy_fetch(n: int) -> int: # untouched asyncio code (or an asyncpg call)
await asyncio.sleep(0.01)
return n * 2
async def main() -> None:
results = {}
async def run_one(n: int) -> None:
with anyio.fail_after(1): # anyio scope around native asyncio awaits
results[n] = await legacy_fetch(n)
async with anyio.create_task_group() as tg:
for n in range(3):
tg.start_soon(run_one, n)
print(sorted(results.items()))
anyio.run(main) # backend="asyncio" (default)
# Output: [(0, 0), (1, 2), (2, 4)]Rules for mixed code:
- anyio code may call asyncio code; under
backend="trio"that call fails, so mark those modules asyncio-only (and keep them out of the trio test matrix). - asyncio code may call anyio functions as long as it is running on an asyncio loop (anyio detects it).
asyncio.run(main())wheremainuses anyio task groups works. - Tasks created by asyncio libraries are not owned by your task groups; wrap the library call so the task group sees a single awaited call.
- Do not mix
asyncio.Lockandanyio.Lockfor the same resource; pick one per resource.
Bridging trio and asyncio-only libraries (trio-asyncio):
# pip install trio trio-asyncio (not run here: trio is not installed in this environment)
import trio
import trio_asyncio
async def main():
async with trio_asyncio.open_loop():
rows = await trio_asyncio.aio_as_trio(fetch_rows_with_asyncpg)() # asyncio coroutine fn
print(len(rows))
trio.run(main)Treat this as a transitional measure: check trio-asyncio's support for your Python version, and plan to replace the asyncio-only dependency.
Recipes for every manual rule
MIG011 gather (fail-fast, ordered results)
import anyio
async def call_llm(prompt: str) -> str:
await anyio.sleep(0.01 * len(prompt))
return prompt.upper()
async def fan_out(prompts: list) -> list:
results: list = [None] * len(prompts)
async def run_one(i: int, prompt: str) -> None:
results[i] = await call_llm(prompt)
async with anyio.create_task_group() as tg:
for i, prompt in enumerate(prompts):
tg.start_soon(run_one, i, prompt)
return results
print(anyio.run(fan_out, ["ccc", "a", "bb"]))
# Output: ['CCC', 'A', 'BB']Difference from gather: when one call fails, the others are cancelled and an ExceptionGroup is raised.
gather (without return_exceptions) raises the first error and lets the rest keep running unobserved.
MIG012 gather(return_exceptions=True) (partial success)
import anyio
async def score(sample: str) -> float:
await anyio.sleep(0)
if sample == "bad":
raise ValueError("unparseable judge output")
return len(sample) / 10
async def eval_all(samples: list) -> list:
results: list = [None] * len(samples)
async def run_one(i: int, sample: str) -> None:
try:
results[i] = await score(sample)
except Exception as exc: # Exception only: cancellation still propagates
results[i] = exc
async with anyio.create_task_group() as tg:
for i, sample in enumerate(samples):
tg.start_soon(run_one, i, sample)
return results
print(anyio.run(eval_all, ["good", "bad", "great"]))
# Output: [0.4, ValueError('unparseable judge output'), 0.5]MIG013 wait_for / MIG032 advanced timeouts
import anyio
async def slow_model() -> str:
await anyio.sleep(1)
return "late"
async def main() -> None:
try:
with anyio.fail_after(0.05): # was: await asyncio.wait_for(slow_model(), 0.05)
reply = await slow_model()
except TimeoutError:
reply = "fallback"
print(reply)
with anyio.move_on_after(0.05) as scope: # give up quietly
await slow_model()
print("gave up:", scope.cancelled_caught)
with anyio.fail_after(0.05) as scope: # was: cm.reschedule(...)
scope.deadline = anyio.current_time() + 2
print(await slow_model())
anyio.run(main)
# Output:
# fallback
# gave up: True
# lateMIG014 create_task: free tasks get an owner
import anyio
async def refresh_index(stop_after: int) -> None:
for i in range(stop_after):
await anyio.sleep(0.01)
print("index refreshed", stop_after, "times")
async def handle_requests(tg) -> None:
tg.start_soon(refresh_index, 3) # was: asyncio.create_task(refresh_index(3))
print("request handled")
async def main() -> None:
async with anyio.create_task_group() as tg: # the owner: app/job lifetime
await handle_requests(tg)
anyio.run(main)
# Output:
# request handled
# index refreshed 3 timesWhen a result is needed, the child writes it somewhere (results[key] = ...) or sends it into a memory
stream. On anyio 4.14+ tg.create_task(coro) returns a TaskHandle you can await; use it only if you do
not need trio or older anyio.
MIG015 / MIG016 Queue, join and task_done
import anyio
from anyio.streams.memory import MemoryObjectReceiveStream
async def embed_worker(name: str, docs: MemoryObjectReceiveStream, out: list) -> None:
async with docs:
async for doc in docs: # ends when every send clone is closed
await anyio.sleep(0.001)
out.append((doc, name))
async def main() -> None:
out: list = []
send, receive = anyio.create_memory_object_stream[str](max_buffer_size=4)
async with anyio.create_task_group() as tg:
for n in range(3):
tg.start_soon(embed_worker, f"w{n}", receive.clone(), out)
receive.close() # only the workers' clones remain
async with send: # closing == "no more work"
for i in range(10):
await send.send(f"doc{i}") # blocks while 4 are buffered: backpressure
# leaving the task group == queue.join(): every doc has been processed
print(len(out), sorted({w for _, w in out}) <= ["w0", "w1", "w2"])
anyio.run(main)
# Output: 10 TruePriorityQueue has no stream equivalent; keep a heapq guarded by an anyio.Condition, or sort batches
before sending.
MIG017 run_in_executor
import time
import anyio
def ocr_page(page: int) -> str:
time.sleep(0.01) # blocking SDK / CPU-light C code
return f"text of page {page}"
async def main() -> None:
limiter = anyio.CapacityLimiter(4) # was: ThreadPoolExecutor(max_workers=4)
pages: dict = {}
async def run_one(p: int) -> None:
pages[p] = await anyio.to_thread.run_sync(ocr_page, p, limiter=limiter)
async with anyio.create_task_group() as tg:
for p in range(8):
tg.start_soon(run_one, p)
print(len(pages), pages[7])
anyio.run(main)
# Output: 8 text of page 7MIG018 event loop access and MIG028 run_coroutine_threadsafe
import threading
import anyio
from anyio.from_thread import start_blocking_portal
async def embed(text: str) -> int:
await anyio.sleep(0)
return len(text)
def sync_callback_from_worker(text: str) -> int:
# inside a to_thread worker: call back into the event loop
return anyio.from_thread.run(embed, text)
async def main() -> None:
print(await anyio.to_thread.run_sync(sync_callback_from_worker, "from worker"))
anyio.run(main)
# From a thread anyio did not start (Celery task, Streamlit callback, CLI):
with start_blocking_portal() as portal:
print(portal.call(embed, "from portal"), threading.current_thread().name)
# Output:
# 11
# 11 MainThreadNever call portal.call or from_thread.run from inside the event loop thread: they block waiting for
the loop they are blocking.
MIG019 call_soon / call_later
import anyio
async def later(delay: float, fn, *args) -> None:
await anyio.sleep(delay)
fn(*args)
async def main() -> None:
async with anyio.create_task_group() as tg:
tg.start_soon(later, 0.02, print, "flush metrics") # was: loop.call_later(0.02, ...)
tg.start_soon(later, 10, print, "never printed")
await anyio.sleep(0.05)
tg.cancel_scope.cancel() # cancel the remaining timer
anyio.run(main)
# Output: flush metricsTo cancel one timer without the others, start it through a helper that owns its own scope (see MIG030).
MIG020 Future and MIG021 add_done_callback
import anyio
class OneShot:
"""Replacement for an asyncio.Future set by one task and awaited by another."""
def __init__(self) -> None:
self._event = anyio.Event()
self._value = None
self._error = None
def set_result(self, value) -> None:
self._value = value
self._event.set()
def set_exception(self, error: BaseException) -> None:
self._error = error
self._event.set()
async def wait(self):
await self._event.wait()
if self._error is not None:
raise self._error
return self._value
async def tool_runner(result: OneShot) -> None:
try:
await anyio.sleep(0.01)
result.set_result({"tool": "search", "hits": 3})
finally:
print("tool finished") # was: task.add_done_callback(...)
async def main() -> None:
result = OneShot()
async with anyio.create_task_group() as tg:
tg.start_soon(tool_runner, result)
print(await result.wait())
anyio.run(main)
# Output:
# tool finished
# {'tool': 'search', 'hits': 3}MIG022 wait(FIRST_COMPLETED) and MIG023 as_completed
import anyio
async def retriever(name: str, delay: float) -> str:
await anyio.sleep(delay)
return name
async def first_completed() -> str:
winner = None
async with anyio.create_task_group() as tg:
async def run(name: str, delay: float) -> None:
nonlocal winner
result = await retriever(name, delay)
if winner is None:
winner = result
tg.cancel_scope.cancel() # cancel the losers
for name, delay in [("dense", 0.03), ("bm25", 0.01), ("hybrid", 0.05)]:
tg.start_soon(run, name, delay)
return winner
async def in_completion_order() -> list:
order = []
send, receive = anyio.create_memory_object_stream[str](3)
async with anyio.create_task_group() as tg:
async def run(name: str, delay: float, out) -> None:
async with out:
await out.send(await retriever(name, delay))
async with send:
for name, delay in [("dense", 0.03), ("bm25", 0.01), ("hybrid", 0.02)]:
tg.start_soon(run, name, delay, send.clone())
async with receive:
async for name in receive:
order.append(name)
return order
async def main() -> None:
print(await first_completed())
print(await in_completion_order())
anyio.run(main)
# Output:
# bm25
# ['bm25', 'hybrid', 'dense']MIG024 shield
import anyio
async def flush(buffer: list) -> None:
await anyio.sleep(0.01)
print("flushed", len(buffer))
async def main() -> None:
buffer = [1, 2, 3]
with anyio.move_on_after(0.005):
try:
await anyio.sleep(1)
finally:
with anyio.CancelScope(shield=True), anyio.move_on_after(2):
await flush(buffer) # was: await asyncio.shield(flush(buffer))
anyio.run(main)
# Output: flushed 3Unlike asyncio.shield, the shielded code is part of the current task: the caller waits for it, and an
error inside it propagates normally.
MIG025 subprocess
import sys
import anyio
async def main() -> None:
result = await anyio.run_process([sys.executable, "-c", "print(6 * 7)"]) # check=True default
print(result.returncode, result.stdout.decode().strip())
async with await anyio.open_process([sys.executable, "-c", "print('a'); print('b')"]) as proc:
chunks = [chunk async for chunk in proc.stdout]
print(b"".join(chunks).decode().split())
anyio.run(main)
# Output:
# 0 42
# ['a', 'b']MIG026 sockets and streams
import anyio
from anyio.streams.buffered import BufferedByteReceiveStream
async def handler(client) -> None:
async with client:
line = await BufferedByteReceiveStream(client).receive_until(b"\n", 1024)
await client.send(line.upper() + b"\n")
async def main() -> None:
listener = await anyio.create_tcp_listener(local_host="127.0.0.1", local_port=0)
port = listener.extra(anyio.abc.SocketAttribute.local_port)
async with anyio.create_task_group() as tg:
tg.start_soon(listener.serve, handler)
async with await anyio.connect_tcp("127.0.0.1", port) as stream:
await stream.send(b"ping\n")
print(await BufferedByteReceiveStream(stream).receive_until(b"\n", 1024))
tg.cancel_scope.cancel()
anyio.run(main)
# Output: b'PING'For TLS: await anyio.connect_tcp(host, 443, tls=True) (uses ssl.create_default_context() unless you
pass ssl_context=).
MIG027 signals
import os
import signal
import anyio
async def serve_forever() -> None:
await anyio.sleep_forever()
async def main() -> None:
async with anyio.create_task_group() as tg:
tg.start_soon(serve_forever)
with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals:
os.kill(os.getpid(), signal.SIGTERM) # simulate `kill <pid>` (Unix only)
async for signum in signals:
print("shutting down on", signal.Signals(signum).name)
tg.cancel_scope.cancel()
break
anyio.run(main)
# Output: shutting down on SIGTERMMIG029 introspection, MIG033 coroutine checks
import inspect
import anyio
async def tool() -> None:
print("running in", anyio.get_current_task().name)
async def main() -> None:
print(inspect.iscoroutinefunction(tool)) # was: asyncio.iscoroutinefunction
async with anyio.create_task_group() as tg:
tg.start_soon(tool, name="tool:search")
anyio.run(main)
# Output:
# True
# running in tool:searchMIG030 cancel one task from outside
import anyio
async def cancellable(job, *, task_status=anyio.TASK_STATUS_IGNORED) -> None:
with anyio.CancelScope() as scope:
task_status.started(scope) # hand the scope to the parent
await job()
if scope.cancelled_caught:
print("job cancelled")
async def crawl() -> None:
await anyio.sleep(10)
async def main() -> None:
async with anyio.create_task_group() as tg:
scope = await tg.start(cancellable, crawl)
await anyio.sleep(0.01)
scope.cancel() # was: task.cancel()
anyio.run(main)
# Output: job cancelledMIG031 swallowed cancellation
import anyio
async def stream_tokens(log: list) -> None:
try:
await anyio.sleep(10)
except anyio.get_cancelled_exc_class():
log.append("client went away")
raise # without this the scope cannot finish cleanly
async def main() -> None:
log: list = []
with anyio.move_on_after(0.01):
await stream_tokens(log)
print(log)
anyio.run(main)
# Output: ['client went away']MIG099 everything else
asyncio.Barrier -> counter + Event:
import anyio
class Barrier:
def __init__(self, parties: int) -> None:
self.parties, self.count, self.event = parties, 0, anyio.Event()
async def wait(self) -> None:
self.count += 1
if self.count == self.parties:
self.event.set()
await self.event.wait()
async def worker(barrier: Barrier, name: str, out: list) -> None:
out.append(f"{name} loaded model")
await barrier.wait()
out.append(f"{name} serving")
async def main() -> None:
barrier, out = Barrier(2), []
async with anyio.create_task_group() as tg:
tg.start_soon(worker, barrier, "gpu0", out)
tg.start_soon(worker, barrier, "gpu1", out)
print(out[:2], len(out))
anyio.run(main)
# Output: ['gpu0 loaded model', 'gpu1 loaded model'] 4This barrier is single-use (anyio Events cannot be reset); create a new one per phase.
python-async-deadlock-debugger
Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.
python-asyncio-pattern-advisor
Recommend, implement and review Python asyncio patterns: async vs threads vs processes (IO vs CPU-bound), coroutines vs tasks vs futures, TaskGroup, gather and return_exceptions, wait and as_completed, Semaphore and Queue worker pools with backpressure, asyncio.timeout vs wait_for, cancellation, shield and cleanup, token-bucket RPM/TPM rate limits for LLM APIs, retries with backoff and jitter, async generators and SSE token streaming, to_thread/run_in_executor, background task references, eager tasks, asyncio.run/Runner, uvloop, Jupyter, FastAPI async vs def routes, httpx.AsyncClient lifespan, async DB/vector-store clients, debug mode and pytest-asyncio.
python-beginner-concept-tutor
Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.
python-comprehension-refactorer
Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.
python-context-manager-writer
Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.
python-dataclass-converter
Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.