Skip to content

Commit 97c682f

Browse files
bcmillsseifertm
authored andcommitted
Copy context variables from non-generator fixtures
1 parent 62ab185 commit 97c682f

File tree

2 files changed

+80
-44
lines changed

2 files changed

+80
-44
lines changed

Diff for: pytest_asyncio/plugin.py

+71-42
Original file line numberDiff line numberDiff line change
@@ -327,18 +327,7 @@ async def setup():
327327
setup_task = _create_task_in_context(event_loop, setup(), context)
328328
result = event_loop.run_until_complete(setup_task)
329329

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))
330+
reset_contextvars = _apply_contextvar_changes(context)
342331

343332
def finalizer() -> None:
344333
"""Yield again, to finalize."""
@@ -355,38 +344,15 @@ async def async_finalizer() -> None:
355344

356345
task = _create_task_in_context(event_loop, async_finalizer(), context)
357346
event_loop.run_until_complete(task)
358-
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)
347+
if reset_contextvars is not None:
348+
reset_contextvars()
364349

365350
request.addfinalizer(finalizer)
366351
return result
367352

368353
fixturedef.func = _asyncgen_fixture_wrapper # type: ignore[misc]
369354

370355

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-
try:
385-
return loop.create_task(coro, context=context)
386-
except TypeError:
387-
return loop.create_task(coro)
388-
389-
390356
def _wrap_async_fixture(fixturedef: FixtureDef) -> None:
391357
fixture = fixturedef.func
392358

@@ -403,11 +369,23 @@ async def setup():
403369
res = await func(**_add_kwargs(func, kwargs, event_loop, request))
404370
return res
405371

406-
# Since the fixture doesn't have a cleanup phase, if it set any context
407-
# variables we don't have a good way to clear them again.
408-
# Instead, treat this fixture like an asyncio.Task, which has its own
409-
# independent Context that doesn't affect the caller.
410-
return event_loop.run_until_complete(setup())
372+
context = contextvars.copy_context()
373+
setup_task = _create_task_in_context(event_loop, setup(), context)
374+
result = event_loop.run_until_complete(setup_task)
375+
376+
# Copy the context vars modified by the setup task into the current
377+
# context, and (if needed) add a finalizer to reset them.
378+
#
379+
# Note that this is slightly different from the behavior of a non-async
380+
# fixture, which would rely on the fixture author to add a finalizer
381+
# to reset the variables. In this case, the author of the fixture can't
382+
# write such a finalizer because they have no way to capture the Context
383+
# in which the setup function was run, so we need to do it for them.
384+
reset_contextvars = _apply_contextvar_changes(context)
385+
if reset_contextvars is not None:
386+
request.addfinalizer(reset_contextvars)
387+
388+
return result
411389

412390
fixturedef.func = _async_fixture_wrapper # type: ignore[misc]
413391

@@ -432,6 +410,57 @@ def _get_event_loop_fixture_id_for_async_fixture(
432410
return event_loop_fixture_id
433411

434412

413+
def _create_task_in_context(loop, coro, context):
414+
"""
415+
Return an asyncio task that runs the coro in the specified context,
416+
if possible.
417+
418+
This allows fixture setup and teardown to be run as separate asyncio tasks,
419+
while still being able to use context-manager idioms to maintain context
420+
variables and make those variables visible to test functions.
421+
422+
This is only fully supported on Python 3.11 and newer, as it requires
423+
the API added for https://github.com/python/cpython/issues/91150.
424+
On earlier versions, the returned task will use the default context instead.
425+
"""
426+
try:
427+
return loop.create_task(coro, context=context)
428+
except TypeError:
429+
return loop.create_task(coro)
430+
431+
432+
def _apply_contextvar_changes(
433+
context: contextvars.Context,
434+
) -> Callable[[], None] | None:
435+
"""
436+
Copy contextvar changes from the given context to the current context.
437+
438+
If any contextvars were modified by the fixture, return a finalizer that
439+
will restore them.
440+
"""
441+
context_tokens = []
442+
for var in context:
443+
try:
444+
if var.get() is context.get(var):
445+
# This variable is not modified, so leave it as-is.
446+
continue
447+
except LookupError:
448+
# This variable isn't yet set in the current context at all.
449+
pass
450+
token = var.set(context.get(var))
451+
context_tokens.append((var, token))
452+
453+
if not context_tokens:
454+
return None
455+
456+
def restore_contextvars():
457+
while context_tokens:
458+
(var, token) = context_tokens.pop()
459+
var.reset(token)
460+
461+
return restore_contextvars
462+
463+
435464
class PytestAsyncioFunction(Function):
436465
"""Base class for all test functions managed by pytest-asyncio."""
437466

Diff for: tests/async_fixtures/test_async_fixtures_contextvars.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,16 @@ async def var_fixture_3(var_fixture_2):
5858
yield
5959

6060

61+
@pytest.fixture(scope="function")
62+
async def var_fixture_4(var_fixture_3, request):
63+
assert _context_var.get() == "value3"
64+
_context_var.set("value4")
65+
# Rely on fixture teardown to reset the context var.
66+
67+
6168
@pytest.mark.asyncio
6269
@pytest.mark.xfail(
6370
sys.version_info < (3, 11), reason="requires asyncio Task context support"
6471
)
65-
async def test(var_fixture_3):
66-
assert _context_var.get() == "value3"
72+
async def test(var_fixture_4):
73+
assert _context_var.get() == "value4"

0 commit comments

Comments
 (0)