[{"data":1,"prerenderedAt":1327},["ShallowReactive",2],{"page-\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Fporting-a-pytest-asyncio-suite-to-anyio\u002F":3},{"id":4,"title":5,"body":6,"description":1290,"extension":1291,"meta":1292,"navigation":143,"path":1323,"seo":1324,"stem":1325,"__hash__":1326},"content\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Fporting-a-pytest-asyncio-suite-to-anyio\u002Findex.md","Porting a pytest-asyncio Suite to AnyIO",{"type":7,"value":8,"toc":1279},"minimark",[9,13,18,55,59,62,122,168,286,396,542,546,557,564,568,634,638,641,699,790,793,800,912,916,919,929,939,945,955,961,1045,1048,1052,1055,1062,1076,1082,1088,1182,1186,1198,1204,1233,1237,1270,1275],[10,11,12],"p",{},"Porting an asyncio test suite to AnyIO is a mechanical change with a handful of sharp edges, and doing it module by module rather than all at once is what keeps it reviewable. The order matters: isolate the plugins first, replace primitives second, restructure fixtures third, and only enable the second backend once the module is green on the first. Skipping straight to the backend matrix produces failures whose cause is ambiguous between the port and the runtime.",[14,15,17],"h2",{"id":16},"prerequisites","Prerequisites",[19,20,21,33,43,52],"ul",{},[22,23,24,28,29,32],"li",{},[25,26,27],"code",{},"anyio[trio] >= 4.0"," alongside the existing ",[25,30,31],{},"pytest-asyncio",", which stays installed until the migration finishes.",[22,34,35,38,39,42],{},[25,36,37],{},"pytest >= 8.0",", Python 3.10+ (3.11+ removes the ",[25,40,41],{},"TimeoutError"," alias problem described below).",[22,44,45,46,51],{},"The collection-mode rules from ",[47,48,50],"a",{"href":49},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Fconfiguring-asyncio-mode-auto-versus-strict\u002F","configuring asyncio_mode",", since both plugins will be active at once.",[22,53,54],{},"A green suite before you start; porting on top of existing failures makes every diagnosis ambiguous.",[14,56,58],{"id":57},"solution","Solution",[10,60,61],{},"Move one directory, keeping the two plugins apart by invocation rather than by hope.",[63,64,69],"pre",{"className":65,"code":66,"language":67,"meta":68,"style":68},"language-bash shiki shiki-themes github-light github-dark","# Two invocations, one repository. Neither plugin sees the other's tests.\npytest tests\u002Flegacy -q -p no:anyio          # still pytest-asyncio\npytest tests\u002Fported -q -p no:asyncio        # now AnyIO\n","bash","",[25,70,71,80,104],{"__ignoreMap":68},[72,73,76],"span",{"class":74,"line":75},"line",1,[72,77,79],{"class":78},"sJ8bj","# Two invocations, one repository. Neither plugin sees the other's tests.\n",[72,81,83,87,91,95,98,101],{"class":74,"line":82},2,[72,84,86],{"class":85},"sScJk","pytest",[72,88,90],{"class":89},"sZZnC"," tests\u002Flegacy",[72,92,94],{"class":93},"sj4cs"," -q",[72,96,97],{"class":93}," -p",[72,99,100],{"class":89}," no:anyio",[72,102,103],{"class":78},"          # still pytest-asyncio\n",[72,105,107,109,112,114,116,119],{"class":74,"line":106},3,[72,108,86],{"class":85},[72,110,111],{"class":89}," tests\u002Fported",[72,113,94],{"class":93},[72,115,97],{"class":93},[72,117,118],{"class":89}," no:asyncio",[72,120,121],{"class":78},"        # now AnyIO\n",[63,123,127],{"className":124,"code":125,"language":126,"meta":68,"style":68},"language-python shiki shiki-themes github-light github-dark","# tests\u002Fported\u002Fconftest.py\nimport pytest\n\n\n@pytest.fixture(params=[\"asyncio\"])          # trio added at the very end\ndef anyio_backend(request):\n    return request.param\n","python",[25,128,129,134,139,145,150,156,162],{"__ignoreMap":68},[72,130,131],{"class":74,"line":75},[72,132,133],{},"# tests\u002Fported\u002Fconftest.py\n",[72,135,136],{"class":74,"line":82},[72,137,138],{},"import pytest\n",[72,140,141],{"class":74,"line":106},[72,142,144],{"emptyLinePlaceholder":143},true,"\n",[72,146,148],{"class":74,"line":147},4,[72,149,144],{"emptyLinePlaceholder":143},[72,151,153],{"class":74,"line":152},5,[72,154,155],{},"@pytest.fixture(params=[\"asyncio\"])          # trio added at the very end\n",[72,157,159],{"class":74,"line":158},6,[72,160,161],{},"def anyio_backend(request):\n",[72,163,165],{"class":74,"line":164},7,[72,166,167],{},"    return request.param\n",[63,169,171],{"className":124,"code":170,"language":126,"meta":68,"style":68},"# BEFORE — tests\u002Flegacy\u002Ftest_worker.py\nimport asyncio\n\nimport pytest\n\n\n@pytest.mark.asyncio\nasync def test_worker_drains_the_queue():\n    queue = asyncio.Queue()\n    done = asyncio.Event()\n    await queue.put({\"id\": 1})\n\n    async def worker():\n        item = await queue.get()\n        processed.append(item)\n        done.set()\n\n    task = asyncio.create_task(worker())\n    await asyncio.wait_for(done.wait(), timeout=1.0)\n    await task\n    assert processed == [{\"id\": 1}]\n",[25,172,173,178,183,187,191,195,199,204,210,216,222,228,233,239,245,251,257,262,268,274,280],{"__ignoreMap":68},[72,174,175],{"class":74,"line":75},[72,176,177],{},"# BEFORE — tests\u002Flegacy\u002Ftest_worker.py\n",[72,179,180],{"class":74,"line":82},[72,181,182],{},"import asyncio\n",[72,184,185],{"class":74,"line":106},[72,186,144],{"emptyLinePlaceholder":143},[72,188,189],{"class":74,"line":147},[72,190,138],{},[72,192,193],{"class":74,"line":152},[72,194,144],{"emptyLinePlaceholder":143},[72,196,197],{"class":74,"line":158},[72,198,144],{"emptyLinePlaceholder":143},[72,200,201],{"class":74,"line":164},[72,202,203],{},"@pytest.mark.asyncio\n",[72,205,207],{"class":74,"line":206},8,[72,208,209],{},"async def test_worker_drains_the_queue():\n",[72,211,213],{"class":74,"line":212},9,[72,214,215],{},"    queue = asyncio.Queue()\n",[72,217,219],{"class":74,"line":218},10,[72,220,221],{},"    done = asyncio.Event()\n",[72,223,225],{"class":74,"line":224},11,[72,226,227],{},"    await queue.put({\"id\": 1})\n",[72,229,231],{"class":74,"line":230},12,[72,232,144],{"emptyLinePlaceholder":143},[72,234,236],{"class":74,"line":235},13,[72,237,238],{},"    async def worker():\n",[72,240,242],{"class":74,"line":241},14,[72,243,244],{},"        item = await queue.get()\n",[72,246,248],{"class":74,"line":247},15,[72,249,250],{},"        processed.append(item)\n",[72,252,254],{"class":74,"line":253},16,[72,255,256],{},"        done.set()\n",[72,258,260],{"class":74,"line":259},17,[72,261,144],{"emptyLinePlaceholder":143},[72,263,265],{"class":74,"line":264},18,[72,266,267],{},"    task = asyncio.create_task(worker())\n",[72,269,271],{"class":74,"line":270},19,[72,272,273],{},"    await asyncio.wait_for(done.wait(), timeout=1.0)\n",[72,275,277],{"class":74,"line":276},20,[72,278,279],{},"    await task\n",[72,281,283],{"class":74,"line":282},21,[72,284,285],{},"    assert processed == [{\"id\": 1}]\n",[63,287,289],{"className":124,"code":288,"language":126,"meta":68,"style":68},"# AFTER — tests\u002Fported\u002Ftest_worker.py\nimport anyio\nimport pytest\n\npytestmark = pytest.mark.anyio\n\n\nasync def test_worker_drains_the_queue():\n    send, receive = anyio.create_memory_object_stream[dict](max_buffer_size=1)\n    done = anyio.Event()\n    await send.send({\"id\": 1})\n\n    async def worker():\n        item = await receive.receive()\n        processed.append(item)\n        done.set()\n\n    async with anyio.create_task_group() as tg:\n        tg.start_soon(worker)                 # the group owns the task\n        with anyio.fail_after(1.0):           # a cancel scope, not a wrapper\n            await done.wait()\n\n    assert processed == [{\"id\": 1}]\n",[25,290,291,296,301,305,309,314,318,322,326,331,336,341,345,349,354,358,362,366,371,376,381,386,391],{"__ignoreMap":68},[72,292,293],{"class":74,"line":75},[72,294,295],{},"# AFTER — tests\u002Fported\u002Ftest_worker.py\n",[72,297,298],{"class":74,"line":82},[72,299,300],{},"import anyio\n",[72,302,303],{"class":74,"line":106},[72,304,138],{},[72,306,307],{"class":74,"line":147},[72,308,144],{"emptyLinePlaceholder":143},[72,310,311],{"class":74,"line":152},[72,312,313],{},"pytestmark = pytest.mark.anyio\n",[72,315,316],{"class":74,"line":158},[72,317,144],{"emptyLinePlaceholder":143},[72,319,320],{"class":74,"line":164},[72,321,144],{"emptyLinePlaceholder":143},[72,323,324],{"class":74,"line":206},[72,325,209],{},[72,327,328],{"class":74,"line":212},[72,329,330],{},"    send, receive = anyio.create_memory_object_stream[dict](max_buffer_size=1)\n",[72,332,333],{"class":74,"line":218},[72,334,335],{},"    done = anyio.Event()\n",[72,337,338],{"class":74,"line":224},[72,339,340],{},"    await send.send({\"id\": 1})\n",[72,342,343],{"class":74,"line":230},[72,344,144],{"emptyLinePlaceholder":143},[72,346,347],{"class":74,"line":235},[72,348,238],{},[72,350,351],{"class":74,"line":241},[72,352,353],{},"        item = await receive.receive()\n",[72,355,356],{"class":74,"line":247},[72,357,250],{},[72,359,360],{"class":74,"line":253},[72,361,256],{},[72,363,364],{"class":74,"line":259},[72,365,144],{"emptyLinePlaceholder":143},[72,367,368],{"class":74,"line":264},[72,369,370],{},"    async with anyio.create_task_group() as tg:\n",[72,372,373],{"class":74,"line":270},[72,374,375],{},"        tg.start_soon(worker)                 # the group owns the task\n",[72,377,378],{"class":74,"line":276},[72,379,380],{},"        with anyio.fail_after(1.0):           # a cancel scope, not a wrapper\n",[72,382,383],{"class":74,"line":282},[72,384,385],{},"            await done.wait()\n",[72,387,389],{"class":74,"line":388},22,[72,390,144],{"emptyLinePlaceholder":143},[72,392,394],{"class":74,"line":393},23,[72,395,285],{},[397,398,401,538],"figure",{"className":399},[400],"diagram",[402,403,410,411,410,415,410,419,410,427,410,437,410,446,410,452,410,457,410,461,410,467,410,471,410,477,410,481,410,486,410,490,410,493,410,496,410,500,410,503,410,506,410,510,410,513,410,517,410,523,410,526,410,529,410,534],"svg",{"viewBox":404,"role":405,"ariaLabelledBy":406,"xmlns":409},"0 0 820 276","img",[407,408],"port-t","port-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[412,413,414],"title",{"id":407},"The four substitutions that make up most of a port",[416,417,418],"desc",{"id":408},"Four rows pairing an asyncio construct with its AnyIO replacement: asyncio.Event becomes anyio.Event, asyncio.Queue becomes a memory object stream, asyncio.wait_for becomes fail_after or move_on_after, and create_task plus gather becomes a task group. Each row notes what changes in behaviour rather than only in spelling.",[420,421],"rect",{"x":422,"y":422,"width":423,"height":424,"rx":425,"fill":426},"0","820","276","14","#fffdf8",[428,429,436],"text",{"x":430,"y":431,"textAnchor":432,"fontSize":433,"fontWeight":434,"fill":435},"410","28","middle","16","700","#3d405b","Most of the diff is these four lines, repeated",[420,438],{"x":439,"y":440,"width":441,"height":442,"rx":443,"fill":444,"stroke":435,"strokeWidth":445},"26","50","240","38","9","#f4f1de","1.5",[428,447,451],{"x":448,"y":449,"textAnchor":432,"fontSize":450,"fontWeight":434,"fill":435},"146","74","12","asyncio",[420,453],{"x":424,"y":440,"width":441,"height":442,"rx":443,"fill":454,"stroke":455,"strokeWidth":456},"#e6f0ea","#81b29a","2",[428,458,460],{"x":459,"y":449,"textAnchor":432,"fontSize":450,"fontWeight":434,"fill":435},"396","anyio",[420,462],{"x":463,"y":440,"width":464,"height":442,"rx":443,"fill":465,"stroke":466,"strokeWidth":456},"526","268","#f7f0da","#f2cc8f",[428,468,470],{"x":469,"y":449,"textAnchor":432,"fontSize":450,"fontWeight":434,"fill":435},"660","what changes",[428,472,476],{"x":473,"y":474,"fontSize":475,"fill":435},"42","118","11.5","asyncio.Event()",[428,478,480],{"x":479,"y":474,"fontSize":475,"fill":435},"292","anyio.Event()",[428,482,485],{"x":483,"y":474,"fontSize":484,"fill":435},"542","11","nothing; no clear() in anyio",[428,487,489],{"x":473,"y":488,"fontSize":475,"fill":435},"154","asyncio.Queue()",[428,491,492],{"x":479,"y":488,"fontSize":475,"fill":435},"memory object stream",[428,494,495],{"x":483,"y":488,"fontSize":484,"fill":435},"closing is observable",[428,497,499],{"x":473,"y":498,"fontSize":475,"fill":435},"190","asyncio.wait_for(c, t)",[428,501,502],{"x":479,"y":498,"fontSize":475,"fill":435},"with fail_after(t):",[428,504,505],{"x":483,"y":498,"fontSize":484,"fill":435},"scopes a block, not one await",[428,507,509],{"x":473,"y":508,"fontSize":475,"fill":435},"226","create_task + gather",[428,511,512],{"x":479,"y":508,"fontSize":475,"fill":435},"create_task_group()",[428,514,516],{"x":483,"y":508,"fontSize":484,"fill":515},"#8a5a00","raises ExceptionGroup",[74,518],{"x1":439,"y1":519,"x2":520,"y2":519,"stroke":521,"strokeWidth":522},"132","794","rgba(61,64,91,0.14)","1.4",[74,524],{"x1":439,"y1":525,"x2":520,"y2":525,"stroke":521,"strokeWidth":522},"168",[74,527],{"x1":439,"y1":528,"x2":520,"y2":528,"stroke":521,"strokeWidth":522},"204",[420,530],{"x":439,"y":441,"width":531,"height":439,"rx":532,"fill":426,"stroke":533,"strokeWidth":522},"768","8","rgba(61,64,91,0.35)",[428,535,537],{"x":430,"y":536,"textAnchor":432,"fontSize":484,"fill":435},"258","Only the last row changes what a test asserts; the first three are spelling.",[539,540,541],"figcaption",{},"Three of the four substitutions are mechanical. The fourth changes the exception type that reaches the test, which is where a careless port silently stops asserting.",[14,543,545],{"id":544},"why-this-works","Why this works",[10,547,548,549,552,553,556],{},"AnyIO implements its primitives on top of whichever runtime is active, so the replacements are behaviourally equivalent where equivalence is possible and explicit where it is not. ",[25,550,551],{},"anyio.Event"," has no ",[25,554,555],{},"clear()"," because Trio's does not, and an event that can be cleared is a source of races anyway; a memory object stream has closing semantics because both runtimes can express them and a queue cannot.",[10,558,559,560,563],{},"Keeping the two plugins in separate invocations during the migration matters because both implement ",[25,561,562],{},"pytest_pyfunc_call",". Within one invocation the registration order decides which claims a coroutine test, and that order is not something a repository should depend on. Two commands cost nothing and remove the ambiguity entirely.",[14,565,567],{"id":566},"edge-cases-and-failure-modes","Edge cases and failure modes",[19,569,570,594,610,616,625],{},[22,571,572,582,583,586,587,590,591,593],{},[573,574,575,578,579,581],"strong",{},[25,576,577],{},"asyncio.TimeoutError"," versus ",[25,580,41],{},"."," They are the same class from Python 3.11 and different before it, so a test written as ",[25,584,585],{},"pytest.raises(asyncio.TimeoutError)"," fails against ",[25,588,589],{},"anyio.fail_after"," on 3.10. Match ",[25,592,41],{}," and require 3.11, or catch both during the transition.",[22,595,596,602,603,605,606,609],{},[573,597,598,601],{},[25,599,600],{},"gather"," semantics assumed in assertions."," ",[25,604,600],{}," returns the first exception and leaves siblings running; a task group cancels siblings and raises a group. Any ",[25,607,608],{},"pytest.raises(ValueError)"," around ported concurrency code needs updating.",[22,611,612,615],{},[573,613,614],{},"Session-scoped async fixtures."," They have no single runtime under AnyIO. Split them, as below.",[22,617,618,624],{},[573,619,620,623],{},[25,621,622],{},"loop.call_later"," and friends."," There is no portable equivalent; code relying on them must stay pinned to asyncio or be restructured around a task and a sleep.",[22,626,627,633],{},[573,628,629,630,581],{},"Tests that assert on ",[25,631,632],{},"asyncio.all_tasks()"," No portable equivalent exists, by design — task groups are meant to make the check unnecessary. Replace with an assertion on resource balance.",[14,635,637],{"id":636},"restructuring-the-fixtures","Restructuring the fixtures",[10,639,640],{},"The fixture change is the part that needs thought rather than search-and-replace, because AnyIO gives each test item its own runtime.",[63,642,644],{"className":124,"code":643,"language":126,"meta":68,"style":68},"# BEFORE — one pool for the whole session, on one loop\nimport pytest_asyncio\n\n\n@pytest_asyncio.fixture(scope=\"session\", loop_scope=\"session\")\nasync def pool():\n    pool = await create_pool(DSN)\n    try:\n        yield pool\n    finally:\n        await pool.close()\n",[25,645,646,651,656,660,664,669,674,679,684,689,694],{"__ignoreMap":68},[72,647,648],{"class":74,"line":75},[72,649,650],{},"# BEFORE — one pool for the whole session, on one loop\n",[72,652,653],{"class":74,"line":82},[72,654,655],{},"import pytest_asyncio\n",[72,657,658],{"class":74,"line":106},[72,659,144],{"emptyLinePlaceholder":143},[72,661,662],{"class":74,"line":147},[72,663,144],{"emptyLinePlaceholder":143},[72,665,666],{"class":74,"line":152},[72,667,668],{},"@pytest_asyncio.fixture(scope=\"session\", loop_scope=\"session\")\n",[72,670,671],{"class":74,"line":158},[72,672,673],{},"async def pool():\n",[72,675,676],{"class":74,"line":164},[72,677,678],{},"    pool = await create_pool(DSN)\n",[72,680,681],{"class":74,"line":206},[72,682,683],{},"    try:\n",[72,685,686],{"class":74,"line":212},[72,687,688],{},"        yield pool\n",[72,690,691],{"class":74,"line":218},[72,692,693],{},"    finally:\n",[72,695,696],{"class":74,"line":224},[72,697,698],{},"        await pool.close()\n",[63,700,702],{"className":124,"code":701,"language":126,"meta":68,"style":68},"# AFTER — expensive discovery synchronous and shared; the handle per test\nimport pytest\n\n\n@pytest.fixture(scope=\"session\")\ndef dsn():\n    # Synchronous: no runtime, so it can genuinely be session-scoped.\n    with PostgresContainer(\"postgres:16-alpine\") as container:\n        yield container.get_connection_url()\n\n\n@pytest.fixture\nasync def pool(dsn):\n    # Per item, on that item's runtime. Cheap because the server is already up.\n    pool = await create_pool(dsn)\n    try:\n        yield pool\n    finally:\n        await pool.close()\n",[25,703,704,709,713,717,721,726,731,736,741,746,750,754,759,764,769,774,778,782,786],{"__ignoreMap":68},[72,705,706],{"class":74,"line":75},[72,707,708],{},"# AFTER — expensive discovery synchronous and shared; the handle per test\n",[72,710,711],{"class":74,"line":82},[72,712,138],{},[72,714,715],{"class":74,"line":106},[72,716,144],{"emptyLinePlaceholder":143},[72,718,719],{"class":74,"line":147},[72,720,144],{"emptyLinePlaceholder":143},[72,722,723],{"class":74,"line":152},[72,724,725],{},"@pytest.fixture(scope=\"session\")\n",[72,727,728],{"class":74,"line":158},[72,729,730],{},"def dsn():\n",[72,732,733],{"class":74,"line":164},[72,734,735],{},"    # Synchronous: no runtime, so it can genuinely be session-scoped.\n",[72,737,738],{"class":74,"line":206},[72,739,740],{},"    with PostgresContainer(\"postgres:16-alpine\") as container:\n",[72,742,743],{"class":74,"line":212},[72,744,745],{},"        yield container.get_connection_url()\n",[72,747,748],{"class":74,"line":218},[72,749,144],{"emptyLinePlaceholder":143},[72,751,752],{"class":74,"line":224},[72,753,144],{"emptyLinePlaceholder":143},[72,755,756],{"class":74,"line":230},[72,757,758],{},"@pytest.fixture\n",[72,760,761],{"class":74,"line":235},[72,762,763],{},"async def pool(dsn):\n",[72,765,766],{"class":74,"line":241},[72,767,768],{},"    # Per item, on that item's runtime. Cheap because the server is already up.\n",[72,770,771],{"class":74,"line":247},[72,772,773],{},"    pool = await create_pool(dsn)\n",[72,775,776],{"class":74,"line":253},[72,777,683],{},[72,779,780],{"class":74,"line":259},[72,781,688],{},[72,783,784],{"class":74,"line":264},[72,785,693],{},[72,787,788],{"class":74,"line":270},[72,789,698],{},[10,791,792],{},"The cost of this change is one pool creation per test rather than one per session, which for an in-process pool against an already-running server is single-digit milliseconds. The benefit is that nothing is shared across runtimes, so the whole class of cross-loop failures disappears rather than being managed.",[10,794,795,796,799],{},"Where per-test pool creation genuinely is too slow — a TLS handshake to a remote service, say — the escape hatch is ",[25,797,798],{},"anyio.from_thread.start_blocking_portal()",", which gives a session-scoped portal that synchronous code can use to run coroutines. It works, it is supported, and it is one more moving part; reach for it after measuring rather than in anticipation.",[397,801,803,909],{"className":802},[400],[402,804,410,809,410,812,410,815,410,831,410,835,410,840,410,844,410,849,410,855,410,858,410,862,410,866,410,870,410,874,410,879,410,883,410,887,410,892,410,897,410,900,410,904],{"viewBox":805,"role":405,"ariaLabelledBy":806,"xmlns":409},"0 0 800 250",[807,808],"fix-t","fix-d",[412,810,811],{"id":807},"Splitting a session-scoped async fixture for AnyIO",[416,813,814],{"id":808},"Before the port, a single session-scoped async fixture creates a pool on the session loop and every test uses it. After the port, a synchronous session fixture holds the container and yields a connection string, and each test item builds its own pool from that string on its own runtime.",[816,817,818,819,410],"defs",{},"\n    ",[820,821,827],"marker",{"id":822,"viewBox":823,"refX":443,"refY":824,"markerWidth":825,"markerHeight":825,"orient":826},"fix-a","0 0 10 10","5","7","auto-start-reverse",[828,829],"path",{"d":830,"fill":435},"M0 0 L10 5 L0 10 z",[420,832],{"x":422,"y":422,"width":833,"height":834,"rx":425,"fill":426},"800","250",[428,836,839],{"x":837,"y":431,"textAnchor":432,"fontSize":838,"fontWeight":434,"fill":435},"400","15.5","One runtime per item changes where the pool lives",[420,841],{"x":439,"y":440,"width":842,"height":843,"rx":450,"fill":465,"stroke":466,"strokeWidth":456},"346","176",[428,845,848],{"x":846,"y":847,"textAnchor":432,"fontSize":450,"fontWeight":434,"fill":435},"199","76","before",[420,850],{"x":851,"y":852,"width":853,"height":854,"rx":443,"fill":426,"stroke":533,"strokeWidth":445},"48","90","302","46",[428,856,857],{"x":846,"y":474,"textAnchor":432,"fontSize":484,"fill":435},"async pool fixture, session + session loop",[428,859,861],{"x":846,"y":860,"textAnchor":432,"fontSize":484,"fill":435},"158","every test shares one pool",[428,863,865],{"x":846,"y":864,"textAnchor":432,"fontSize":484,"fill":515},"182","and one loop it must not outlive",[420,867],{"x":868,"y":440,"width":869,"height":843,"rx":450,"fill":454,"stroke":455,"strokeWidth":456},"414","360",[428,871,873],{"x":872,"y":847,"textAnchor":432,"fontSize":450,"fontWeight":434,"fill":435},"594","after",[420,875],{"x":876,"y":852,"width":877,"height":878,"rx":443,"fill":426,"stroke":533,"strokeWidth":445},"436","316","40",[428,880,882],{"x":872,"y":881,"textAnchor":432,"fontSize":484,"fill":435},"115","sync dsn fixture, session scope",[74,884],{"x1":872,"y1":519,"x2":872,"y2":885,"stroke":435,"strokeWidth":445,"markerEnd":886},"148","url(#fix-a)",[420,888],{"x":876,"y":889,"width":890,"height":878,"rx":443,"fill":426,"stroke":455,"strokeWidth":891},"152","150","1.6",[428,893,896],{"x":894,"y":895,"textAnchor":432,"fontSize":484,"fill":435},"511","177","[asyncio] pool",[420,898],{"x":899,"y":889,"width":890,"height":878,"rx":443,"fill":426,"stroke":455,"strokeWidth":891},"602",[428,901,903],{"x":902,"y":895,"textAnchor":432,"fontSize":484,"fill":435},"677","[trio] pool",[428,905,908],{"x":872,"y":906,"textAnchor":432,"fontSize":484,"fill":907},"212","#2a5f49","nothing crosses a runtime boundary",[539,910,911],{},"The container start — the genuinely slow part — is still paid once. Only the cheap connection is duplicated, which is what makes the arrangement affordable.",[14,913,915],{"id":914},"sequencing-the-migration","Sequencing the migration",[10,917,918],{},"Do it directory by directory, and in this order within each directory.",[10,920,921,924,925,928],{},[573,922,923],{},"Move the files."," Create the target directory, move one module, and add the ",[25,926,927],{},"pytestmark",". Nothing else. Run it; it should pass on asyncio immediately if the module used no asyncio-specific primitives.",[10,930,931,934,935,938],{},[573,932,933],{},"Replace primitives."," Work through the four substitutions above. The compiler will not help here, so a grep for ",[25,936,937],{},"asyncio\\."," in the ported directory is the checklist, and it should end empty.",[10,940,941,944],{},[573,942,943],{},"Restructure fixtures."," Split the session-scoped async ones. This is where a module most often needs a design decision rather than an edit.",[10,946,947,950,951,954],{},[573,948,949],{},"Fix the assertions."," Update timeout types and group matching. ",[25,952,953],{},"pytest -q"," names them.",[10,956,957,960],{},[573,958,959],{},"Add Trio."," Only now. Failures at this point are genuine portability findings — a scheduling assumption, a cancellation difference — rather than porting mistakes, and keeping the two categories separate is what makes the second category worth reading.",[63,962,964],{"className":65,"code":963,"language":67,"meta":68,"style":68},"# The per-directory checklist, mechanically\ngrep -rn \"asyncio\\.\" tests\u002Fported\u002F | grep -v \"^Binary\"     # should be empty\npytest tests\u002Fported -q -p no:asyncio                        # green on asyncio\nsed -i 's\u002Fparams=\\[\"asyncio\"\\]\u002Fparams=[\"asyncio\", \"trio\"]\u002F' tests\u002Fported\u002Fconftest.py\npytest tests\u002Fported -q -p no:asyncio                        # now both\n",[25,965,966,971,1001,1016,1030],{"__ignoreMap":68},[72,967,968],{"class":74,"line":75},[72,969,970],{"class":78},"# The per-directory checklist, mechanically\n",[72,972,973,976,979,982,985,989,992,995,998],{"class":74,"line":82},[72,974,975],{"class":85},"grep",[72,977,978],{"class":93}," -rn",[72,980,981],{"class":89}," \"asyncio\\.\"",[72,983,984],{"class":89}," tests\u002Fported\u002F",[72,986,988],{"class":987},"szBVR"," |",[72,990,991],{"class":85}," grep",[72,993,994],{"class":93}," -v",[72,996,997],{"class":89}," \"^Binary\"",[72,999,1000],{"class":78},"     # should be empty\n",[72,1002,1003,1005,1007,1009,1011,1013],{"class":74,"line":106},[72,1004,86],{"class":85},[72,1006,111],{"class":89},[72,1008,94],{"class":93},[72,1010,97],{"class":93},[72,1012,118],{"class":89},[72,1014,1015],{"class":78},"                        # green on asyncio\n",[72,1017,1018,1021,1024,1027],{"class":74,"line":147},[72,1019,1020],{"class":85},"sed",[72,1022,1023],{"class":93}," -i",[72,1025,1026],{"class":89}," 's\u002Fparams=\\[\"asyncio\"\\]\u002Fparams=[\"asyncio\", \"trio\"]\u002F'",[72,1028,1029],{"class":89}," tests\u002Fported\u002Fconftest.py\n",[72,1031,1032,1034,1036,1038,1040,1042],{"class":74,"line":152},[72,1033,86],{"class":85},[72,1035,111],{"class":89},[72,1037,94],{"class":93},[72,1039,97],{"class":93},[72,1041,118],{"class":89},[72,1043,1044],{"class":78},"                        # now both\n",[10,1046,1047],{},"Keeping each directory's port as its own change makes the review tractable and the revert cheap. A single change porting forty modules is one nobody reads carefully, and the portability findings in it are indistinguishable from the porting mistakes.",[14,1049,1051],{"id":1050},"what-the-port-is-actually-worth","What the port is actually worth",[10,1053,1054],{},"It is worth being honest about the benefit before spending a week on it, because the answer differs sharply by project.",[10,1056,1057,1058,1061],{},"For a ",[573,1059,1060],{},"library that must support Trio",", the port is not optional. The alternative is a second test suite, which drifts, or no coverage of the second runtime at all, which means the support claim is untested.",[10,1063,1064,1065,1068,1069,1072,1073,1075],{},"For an ",[573,1066,1067],{},"application that will only ever run on asyncio",", the case rests entirely on the API. Cancel scopes are genuinely better than ",[25,1070,1071],{},"wait_for"," for anything with nested deadlines; task groups are better than ",[25,1074,600],{}," for anything where a sibling must not outlive its peers; memory object streams are better than queues because closing is observable. If the suite already fights those three things, the port pays. If it does not, the port is churn.",[10,1077,1078,1079,1081],{},"Two costs belong on the other side of that ledger. Every engineer who touches the suite has to learn a second vocabulary, which is small but real, and the ",[25,1080,460],{}," dependency joins the test requirements permanently. Neither is a reason to avoid the port, but both are reasons to decide deliberately rather than because a blog post recommended it. Writing the decision down, with the reason, saves the argument being had again in six months when somebody notices two async idioms in one repository.",[10,1083,1057,1084,1087],{},[573,1085,1086],{},"codebase in the middle"," — an internal service with some concurrency and no Trio requirement — a useful compromise is to adopt AnyIO's primitives in new tests without parametrising the backend. The API improves, the runtime stays pinned to asyncio, and the door to the second backend stays open at the cost of one line in a fixture.",[397,1089,1091,1179],{"className":1090},[400],[402,1092,410,1097,410,1100,410,1103,410,1106,410,1109,410,1113,410,1116,410,1121,410,1125,410,1129,410,1132,410,1136,410,1139,410,1141,410,1144,410,1148,410,1151,410,1154,410,1157,410,1160,410,1162,410,1166,410,1170,410,1173,410,1176],{"viewBox":1093,"role":405,"ariaLabelledBy":1094,"xmlns":409},"0 0 800 228",[1095,1096],"worth-t","worth-d",[412,1098,1099],{"id":1095},"When the port pays for itself",[416,1101,1102],{"id":1096},"Three project types. A library that must support Trio has to port, since the alternative is an untested support claim. An asyncio-only application ports only if it is already fighting timeouts, gather semantics or queue closing. A middle case adopts AnyIO primitives without parametrising the backend, keeping the option open.",[420,1104],{"x":422,"y":422,"width":833,"height":1105,"rx":425,"fill":426},"228",[428,1107,1108],{"x":837,"y":431,"textAnchor":432,"fontSize":838,"fontWeight":434,"fill":435},"Three answers, depending on what ships",[420,1110],{"x":1111,"y":440,"width":441,"height":1112,"rx":450,"fill":454,"stroke":455,"strokeWidth":456},"24","160",[420,1114],{"x":1111,"y":440,"width":441,"height":1115,"rx":450,"fill":435},"30",[428,1117,1120],{"x":1118,"y":1119,"textAnchor":432,"fontSize":475,"fontWeight":434,"fill":426},"144","70","Trio must work",[428,1122,1124],{"x":878,"y":1123,"fontSize":484,"fill":435},"104","the matrix is the proof",[428,1126,1128],{"x":878,"y":1127,"fontSize":484,"fill":435},"128","of the support claim",[428,1130,1131],{"x":878,"y":1112,"fontSize":484,"fontWeight":434,"fill":907},"port fully",[428,1133,1135],{"x":878,"y":1134,"fontSize":484,"fill":435},"184","both backends everywhere",[420,1137],{"x":1138,"y":440,"width":441,"height":1112,"rx":450,"fill":465,"stroke":466,"strokeWidth":456},"280",[420,1140],{"x":1138,"y":440,"width":441,"height":1115,"rx":450,"fill":435},[428,1142,1143],{"x":837,"y":1119,"textAnchor":432,"fontSize":475,"fontWeight":434,"fill":426},"asyncio only",[428,1145,1147],{"x":1146,"y":1123,"fontSize":484,"fill":435},"296","port if nested deadlines",[428,1149,1150],{"x":1146,"y":1127,"fontSize":484,"fill":435},"or gather semantics hurt",[428,1152,1153],{"x":1146,"y":1112,"fontSize":484,"fontWeight":434,"fill":515},"otherwise churn",[428,1155,1156],{"x":1146,"y":1134,"fontSize":484,"fill":435},"measure the pain first",[420,1158],{"x":1159,"y":440,"width":441,"height":1112,"rx":450,"fill":454,"stroke":455,"strokeWidth":456},"536",[420,1161],{"x":1159,"y":440,"width":441,"height":1115,"rx":450,"fill":435},[428,1163,1165],{"x":1164,"y":1119,"textAnchor":432,"fontSize":475,"fontWeight":434,"fill":426},"656","undecided",[428,1167,1169],{"x":1168,"y":1123,"fontSize":484,"fill":435},"552","use anyio primitives",[428,1171,1172],{"x":1168,"y":1127,"fontSize":484,"fill":435},"pin the backend",[428,1174,1175],{"x":1168,"y":1112,"fontSize":484,"fontWeight":434,"fill":907},"option stays open",[428,1177,1178],{"x":1168,"y":1134,"fontSize":484,"fill":435},"one line to enable trio",[539,1180,1181],{},"The right-hand option is underused. It takes the API improvement immediately and defers the runtime decision to whenever it actually arises.",[14,1183,1185],{"id":1184},"frequently-asked-questions","Frequently Asked Questions",[10,1187,1188,1191,1192,1194,1195,1197],{},[573,1189,1190],{},"Can both plugins stay installed during the migration?","\nYes, and they usually must. Keep ",[25,1193,31],{}," governing the unported directories and AnyIO governing the ported ones, running them as separate invocations so neither claims the other's tests. Remove ",[25,1196,31],{}," only once the last module has moved.",[10,1199,1200,1203],{},[573,1201,1202],{},"What replaces a session-scoped async fixture?","\nA synchronous session-scoped fixture that performs the expensive discovery, plus a per-test async fixture that builds a cheap handle from it. AnyIO gives each test item its own runtime, so a session-scoped async fixture has no single runtime to live on.",[10,1205,1206,1209,1210,1213,1214,1216,1217,1213,1219,1221,1222,1224,1225,1228,1229,1232],{},[573,1207,1208],{},"Do assertions change meaning during the port?","\nTwo do. ",[25,1211,1212],{},"asyncio.wait_for"," raises ",[25,1215,577],{}," while ",[25,1218,589],{},[25,1220,41],{},", which are the same class from Python 3.11 but not before. And ",[25,1223,600],{},"'s first-exception behaviour becomes a task group's ",[25,1226,1227],{},"ExceptionGroup",", so ",[25,1230,1231],{},"pytest.raises"," on a leaf type stops matching.",[14,1234,1236],{"id":1235},"related","Related",[19,1238,1239,1246,1253,1263],{},[22,1240,1241,1245],{},[47,1242,1244],{"href":1243},"\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002F","Testing with AnyIO & Trio"," — the primitives and cancel-scope semantics the port targets.",[22,1247,1248,1252],{},[47,1249,1251],{"href":1250},"\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Frunning-one-test-on-asyncio-and-trio\u002F","Running One Test on asyncio and Trio"," — the final step, once a module is green.",[22,1254,1255,1259,1260,1262],{},[47,1256,1258],{"href":1257},"\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Ftesting-code-that-uses-task-groups\u002F","Testing Code That Uses Task Groups"," — the assertions that change when ",[25,1261,600],{}," goes away.",[22,1264,1265,1269],{},[47,1266,1268],{"href":1267},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002F","pytest-asyncio in Depth"," — what the suite is moving away from, and why its scopes existed.",[10,1271,1272,1273],{},"← Back to ",[47,1274,1244],{"href":1243},[1276,1277,1278],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":68,"searchDepth":82,"depth":82,"links":1280},[1281,1282,1283,1284,1285,1286,1287,1288,1289],{"id":16,"depth":82,"text":17},{"id":57,"depth":82,"text":58},{"id":544,"depth":82,"text":545},{"id":566,"depth":82,"text":567},{"id":636,"depth":82,"text":637},{"id":914,"depth":82,"text":915},{"id":1050,"depth":82,"text":1051},{"id":1184,"depth":82,"text":1185},{"id":1235,"depth":82,"text":1236},"Migrate an asyncio test suite to AnyIO in stages: primitive replacements, fixture scope changes, plugin isolation, and the assertions that change meaning on the way.","md",{"slug":1293,"type":1294,"breadcrumb":1295,"datePublished":1296,"dateModified":1296,"faq":1297,"howto":1304},"porting-a-pytest-asyncio-suite-to-anyio","article","Porting to AnyIO","2026-09-18",[1298,1300,1302],{"q":1190,"a":1299},"Yes, and they usually must. Keep pytest-asyncio governing the unported directories and AnyIO governing the ported ones, running them as separate invocations so neither claims the other's tests. Remove pytest-asyncio only once the last module has moved.",{"q":1202,"a":1301},"A synchronous session-scoped fixture that performs the expensive discovery, plus a per-test async fixture that builds a cheap handle from it. AnyIO gives each test item its own runtime, so a session-scoped async fixture has no single runtime to live on.",{"q":1208,"a":1303},"Two do. asyncio.wait_for raises asyncio.TimeoutError while anyio.fail_after raises TimeoutError, which are the same class from Python 3.11 but not before. And gather's first-exception behaviour becomes a task group's ExceptionGroup, so pytest.raises on a leaf type stops matching.",{"name":1305,"description":1306,"steps":1307},"How to port a pytest-asyncio suite to AnyIO","Move one directory at a time, replacing primitives and fixture scopes, with both plugins isolated until the last module lands.",[1308,1311,1314,1317,1320],{"name":1309,"text":1310},"Isolate the plugins","Give the ported tests their own directory and invocation so pytest-asyncio and AnyIO never collect the same item.",{"name":1312,"text":1313},"Replace the primitives","Swap asyncio.Event, Queue, wait_for and gather for anyio.Event, memory object streams, fail_after and task groups.",{"name":1315,"text":1316},"Restructure the fixtures","Split expensive async session fixtures into a synchronous discovery half and a per-test async half.",{"name":1318,"text":1319},"Fix the assertions that changed","Update timeout exception types and replace leaf-type raises with ExceptionGroup matching.",{"name":1321,"text":1322},"Turn on the second backend last","Add trio to the anyio_backend parameters only once the ported module passes on asyncio alone.","\u002Ftesting-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Fporting-a-pytest-asyncio-suite-to-anyio",{"title":5,"description":1290},"testing-async-and-concurrent-python\u002Ftesting-with-anyio-and-trio\u002Fporting-a-pytest-asyncio-suite-to-anyio\u002Findex","Yrmz0jBXnPn86obzJLlqxCeIAj2_R54BvtOYQNo0bnw",1789718769518]