Skip to content

Commit 7733e66

Browse files
committed
Maintain contextvars.Context in fixtures and tests
The approach I've taken here is to maintain a contextvars.Context instance in a contextvars.ContextVar, copying it from the ambient context whenever we create a new event loop. The fixture setup and teardown run within that context, and each test function gets a copy (as if it were created as a new asyncio.Task from within the fixture task). Fixes pytest-dev#127.
1 parent a1cd861 commit 7733e66

File tree

2 files changed

+102
-7
lines changed

2 files changed

+102
-7
lines changed

pytest_asyncio/plugin.py

+66-7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import contextlib
7+
import contextvars
78
import enum
89
import functools
910
import inspect
@@ -54,6 +55,7 @@
5455
_ScopeName = Literal["session", "package", "module", "class", "function"]
5556
_T = TypeVar("_T")
5657

58+
5759
SimpleFixtureFunction = TypeVar(
5860
"SimpleFixtureFunction", bound=Callable[..., Awaitable[object]]
5961
)
@@ -318,6 +320,8 @@ def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any):
318320
kwargs.pop(event_loop_fixture_id, None)
319321
gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request))
320322

323+
context = _event_loop_context.get(None)
324+
321325
async def setup():
322326
res = await gen_obj.__anext__() # type: ignore[union-attr]
323327
return res
@@ -335,9 +339,11 @@ async def async_finalizer() -> None:
335339
msg += "Yield only once."
336340
raise ValueError(msg)
337341

338-
event_loop.run_until_complete(async_finalizer())
342+
task = _create_task_in_context(event_loop, async_finalizer(), context)
343+
event_loop.run_until_complete(task)
339344

340-
result = event_loop.run_until_complete(setup())
345+
setup_task = _create_task_in_context(event_loop, setup(), context)
346+
result = event_loop.run_until_complete(setup_task)
341347
request.addfinalizer(finalizer)
342348
return result
343349

@@ -360,7 +366,10 @@ async def setup():
360366
res = await func(**_add_kwargs(func, kwargs, event_loop, request))
361367
return res
362368

363-
return event_loop.run_until_complete(setup())
369+
task = _create_task_in_context(
370+
event_loop, setup(), _event_loop_context.get(None)
371+
)
372+
return event_loop.run_until_complete(task)
364373

365374
fixturedef.func = _async_fixture_wrapper # type: ignore[misc]
366375

@@ -584,6 +593,46 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
584593
Session: "session",
585594
}
586595

596+
# _event_loop_context stores the Context in which asyncio tasks on the fixture
597+
# event loop should be run. After fixture setup, individual async test functions
598+
# are run on copies of this context.
599+
_event_loop_context: contextvars.ContextVar[contextvars.Context] = (
600+
contextvars.ContextVar("pytest_asyncio_event_loop_context")
601+
)
602+
603+
604+
@contextlib.contextmanager
605+
def _set_event_loop_context():
606+
"""Set event_loop_context to a copy of the calling thread's current context."""
607+
context = contextvars.copy_context()
608+
token = _event_loop_context.set(context)
609+
try:
610+
yield
611+
finally:
612+
_event_loop_context.reset(token)
613+
614+
615+
def _create_task_in_context(loop, coro, context):
616+
"""
617+
Return an asyncio task that runs the coro in the specified context,
618+
if possible.
619+
620+
This allows fixture setup and teardown to be run as separate asyncio tasks,
621+
while still being able to use context-manager idioms to maintain context
622+
variables and make those variables visible to test functions.
623+
624+
This is only fully supported on Python 3.11 and newer, as it requires
625+
the API added for https://github.com/python/cpython/issues/91150.
626+
On earlier versions, the returned task will use the default context instead.
627+
"""
628+
if context is not None:
629+
try:
630+
return loop.create_task(coro, context=context)
631+
except TypeError:
632+
pass
633+
return loop.create_task(coro)
634+
635+
587636
# A stack used to push package-scoped loops during collection of a package
588637
# and pop those loops during collection of a Module
589638
__package_loop_stack: list[FixtureFunctionMarker | FixtureFunction] = []
@@ -631,7 +680,8 @@ def scoped_event_loop(
631680
loop = asyncio.new_event_loop()
632681
loop.__pytest_asyncio = True # type: ignore[attr-defined]
633682
asyncio.set_event_loop(loop)
634-
yield loop
683+
with _set_event_loop_context():
684+
yield loop
635685
loop.close()
636686

637687
# @pytest.fixture does not register the fixture anywhere, so pytest doesn't
@@ -938,9 +988,16 @@ def wrap_in_sync(
938988

939989
@functools.wraps(func)
940990
def inner(*args, **kwargs):
991+
# Give each test its own context based on the loop's main context.
992+
context = _event_loop_context.get(None)
993+
if context is not None:
994+
# We are using our own event loop fixture, so make a new copy of the
995+
# fixture context so that the test won't pollute it.
996+
context = context.copy()
997+
941998
coro = func(*args, **kwargs)
942999
_loop = _get_event_loop_no_warn()
943-
task = asyncio.ensure_future(coro, loop=_loop)
1000+
task = _create_task_in_context(_loop, coro, context)
9441001
try:
9451002
_loop.run_until_complete(task)
9461003
except BaseException:
@@ -1049,7 +1106,8 @@ def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]:
10491106
# The magic value must be set as part of the function definition, because pytest
10501107
# seems to have multiple instances of the same FixtureDef or fixture function
10511108
loop.__original_fixture_loop = True # type: ignore[attr-defined]
1052-
yield loop
1109+
with _set_event_loop_context():
1110+
yield loop
10531111
loop.close()
10541112

10551113

@@ -1062,7 +1120,8 @@ def _session_event_loop(
10621120
loop = asyncio.new_event_loop()
10631121
loop.__pytest_asyncio = True # type: ignore[attr-defined]
10641122
asyncio.set_event_loop(loop)
1065-
yield loop
1123+
with _set_event_loop_context():
1124+
yield loop
10661125
loop.close()
10671126

10681127

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""
2+
Regression test for https://github.com/pytest-dev/pytest-asyncio/issues/127:
3+
contextvars were not properly maintained among fixtures and tests.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import sys
9+
from contextlib import asynccontextmanager
10+
from contextvars import ContextVar
11+
12+
import pytest
13+
14+
15+
@asynccontextmanager
16+
async def context_var_manager():
17+
context_var = ContextVar("context_var")
18+
token = context_var.set("value")
19+
try:
20+
yield context_var
21+
finally:
22+
context_var.reset(token)
23+
24+
25+
@pytest.fixture(scope="function")
26+
async def context_var():
27+
async with context_var_manager() as v:
28+
yield v
29+
30+
31+
@pytest.mark.asyncio
32+
@pytest.mark.xfail(
33+
sys.version_info < (3, 11), reason="requires asyncio Task context support"
34+
)
35+
async def test(context_var):
36+
assert context_var.get() == "value"

0 commit comments

Comments
 (0)