Skip to content

Commit 3004bb7

Browse files
bcmillsseifertm
authored andcommitted
Simplify contextvars support
Instead of storing a Context in a context variable, just copy out the changes from the setup task's context into the ambient context, and reset the changes after running the finalizer task.
1 parent 746c114 commit 3004bb7

File tree

2 files changed

+88
-74
lines changed

2 files changed

+88
-74
lines changed

pytest_asyncio/plugin.py

+53-62
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,27 @@ def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any):
319319
kwargs.pop(event_loop_fixture_id, None)
320320
gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request))
321321

322-
context = _event_loop_context.get(None)
323-
324322
async def setup():
325323
res = await gen_obj.__anext__() # type: ignore[union-attr]
326324
return res
327325

326+
context = contextvars.copy_context()
327+
setup_task = _create_task_in_context(event_loop, setup(), context)
328+
result = event_loop.run_until_complete(setup_task)
329+
330+
# Copy the context vars set by the setup task back into the ambient
331+
# context for the test.
332+
context_tokens = []
333+
for var in context:
334+
try:
335+
if var.get() is context.get(var):
336+
# Not modified by the fixture, so leave it as-is.
337+
continue
338+
except LookupError:
339+
pass
340+
token = var.set(context.get(var))
341+
context_tokens.append((var, token))
342+
328343
def finalizer() -> None:
329344
"""Yield again, to finalize."""
330345

@@ -341,14 +356,39 @@ async def async_finalizer() -> None:
341356
task = _create_task_in_context(event_loop, async_finalizer(), context)
342357
event_loop.run_until_complete(task)
343358

344-
setup_task = _create_task_in_context(event_loop, setup(), context)
345-
result = event_loop.run_until_complete(setup_task)
359+
# Since the fixture is now complete, restore any context variables
360+
# it had set back to their original values.
361+
while context_tokens:
362+
(var, token) = context_tokens.pop()
363+
var.reset(token)
364+
346365
request.addfinalizer(finalizer)
347366
return result
348367

349368
fixturedef.func = _asyncgen_fixture_wrapper # type: ignore[misc]
350369

351370

371+
def _create_task_in_context(loop, coro, context):
372+
"""
373+
Return an asyncio task that runs the coro in the specified context,
374+
if possible.
375+
376+
This allows fixture setup and teardown to be run as separate asyncio tasks,
377+
while still being able to use context-manager idioms to maintain context
378+
variables and make those variables visible to test functions.
379+
380+
This is only fully supported on Python 3.11 and newer, as it requires
381+
the API added for https://github.com/python/cpython/issues/91150.
382+
On earlier versions, the returned task will use the default context instead.
383+
"""
384+
if context is not None:
385+
try:
386+
return loop.create_task(coro, context=context)
387+
except TypeError:
388+
pass
389+
return loop.create_task(coro)
390+
391+
352392
def _wrap_async_fixture(fixturedef: FixtureDef) -> None:
353393
fixture = fixturedef.func
354394

@@ -365,10 +405,11 @@ async def setup():
365405
res = await func(**_add_kwargs(func, kwargs, event_loop, request))
366406
return res
367407

368-
task = _create_task_in_context(
369-
event_loop, setup(), _event_loop_context.get(None)
370-
)
371-
return event_loop.run_until_complete(task)
408+
# Since the fixture doesn't have a cleanup phase, if it set any context
409+
# variables we don't have a good way to clear them again.
410+
# Instead, treat this fixture like an asyncio.Task, which has its own
411+
# independent Context that doesn't affect the caller.
412+
return event_loop.run_until_complete(setup())
372413

373414
fixturedef.func = _async_fixture_wrapper # type: ignore[misc]
374415

@@ -592,46 +633,6 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
592633
Session: "session",
593634
}
594635

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

686686
# @pytest.fixture does not register the fixture anywhere, so pytest doesn't
@@ -987,16 +987,9 @@ def wrap_in_sync(
987987

988988
@functools.wraps(func)
989989
def inner(*args, **kwargs):
990-
# Give each test its own context based on the loop's main context.
991-
context = _event_loop_context.get(None)
992-
if context is not None:
993-
# We are using our own event loop fixture, so make a new copy of the
994-
# fixture context so that the test won't pollute it.
995-
context = context.copy()
996-
997990
coro = func(*args, **kwargs)
998991
_loop = _get_event_loop_no_warn()
999-
task = _create_task_in_context(_loop, coro, context)
992+
task = asyncio.ensure_future(coro, loop=_loop)
1000993
try:
1001994
_loop.run_until_complete(task)
1002995
except BaseException:
@@ -1105,8 +1098,7 @@ def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]:
11051098
# The magic value must be set as part of the function definition, because pytest
11061099
# seems to have multiple instances of the same FixtureDef or fixture function
11071100
loop.__original_fixture_loop = True # type: ignore[attr-defined]
1108-
with _set_event_loop_context():
1109-
yield loop
1101+
yield loop
11101102
loop.close()
11111103

11121104

@@ -1119,8 +1111,7 @@ def _session_event_loop(
11191111
loop = asyncio.new_event_loop()
11201112
loop.__pytest_asyncio = True # type: ignore[attr-defined]
11211113
asyncio.set_event_loop(loop)
1122-
with _set_event_loop_context():
1123-
yield loop
1114+
yield loop
11241115
loop.close()
11251116

11261117

tests/async_fixtures/test_async_fixtures_contextvars.py

+35-12
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,54 @@
66
from __future__ import annotations
77

88
import sys
9-
from contextlib import asynccontextmanager
9+
from contextlib import contextmanager
1010
from contextvars import ContextVar
1111

1212
import pytest
1313

14+
_context_var = ContextVar("context_var")
1415

15-
@asynccontextmanager
16-
async def context_var_manager():
17-
context_var = ContextVar("context_var")
18-
token = context_var.set("value")
16+
17+
@contextmanager
18+
def context_var_manager(value):
19+
token = _context_var.set(value)
1920
try:
20-
yield context_var
21+
yield
2122
finally:
22-
context_var.reset(token)
23+
_context_var.reset(token)
24+
25+
26+
@pytest.fixture(scope="function")
27+
async def no_var_fixture():
28+
with pytest.raises(LookupError):
29+
_context_var.get()
30+
yield
31+
with pytest.raises(LookupError):
32+
_context_var.get()
33+
34+
35+
@pytest.fixture(scope="function")
36+
async def var_fixture(no_var_fixture):
37+
with context_var_manager("value"):
38+
yield
39+
40+
41+
@pytest.fixture(scope="function")
42+
async def var_nop_fixture(var_fixture):
43+
with context_var_manager(_context_var.get()):
44+
yield
2345

2446

2547
@pytest.fixture(scope="function")
26-
async def context_var():
27-
async with context_var_manager() as v:
28-
yield v
48+
def inner_var_fixture(var_nop_fixture):
49+
assert _context_var.get() == "value"
50+
with context_var_manager("value2"):
51+
yield
2952

3053

3154
@pytest.mark.asyncio
3255
@pytest.mark.xfail(
3356
sys.version_info < (3, 11), reason="requires asyncio Task context support"
3457
)
35-
async def test(context_var):
36-
assert context_var.get() == "value"
58+
async def test(inner_var_fixture):
59+
assert _context_var.get() == "value2"

0 commit comments

Comments
 (0)