From aec8eb471309f937e8dfc0e1f78e2e972c26a7e3 Mon Sep 17 00:00:00 2001 From: "Bryan C. Mills" Date: Fri, 6 Dec 2024 12:35:29 -0500 Subject: [PATCH 1/6] 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/pytest-asyncio#127. --- pytest_asyncio/plugin.py | 72 +++++++++++++++++-- .../test_async_fixtures_contextvars.py | 36 ++++++++++ 2 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 tests/async_fixtures/test_async_fixtures_contextvars.py diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index d3e006d8..762c58ae 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -4,6 +4,7 @@ import asyncio import contextlib +import contextvars import enum import functools import inspect @@ -318,6 +319,8 @@ def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any): kwargs.pop(event_loop_fixture_id, None) gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) + context = _event_loop_context.get(None) + async def setup(): res = await gen_obj.__anext__() # type: ignore[union-attr] return res @@ -335,9 +338,11 @@ async def async_finalizer() -> None: msg += "Yield only once." raise ValueError(msg) - event_loop.run_until_complete(async_finalizer()) + task = _create_task_in_context(event_loop, async_finalizer(), context) + event_loop.run_until_complete(task) - result = event_loop.run_until_complete(setup()) + setup_task = _create_task_in_context(event_loop, setup(), context) + result = event_loop.run_until_complete(setup_task) request.addfinalizer(finalizer) return result @@ -360,7 +365,10 @@ async def setup(): res = await func(**_add_kwargs(func, kwargs, event_loop, request)) return res - return event_loop.run_until_complete(setup()) + task = _create_task_in_context( + event_loop, setup(), _event_loop_context.get(None) + ) + return event_loop.run_until_complete(task) fixturedef.func = _async_fixture_wrapper # type: ignore[misc] @@ -584,6 +592,46 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( Session: "session", } +# _event_loop_context stores the Context in which asyncio tasks on the fixture +# event loop should be run. After fixture setup, individual async test functions +# are run on copies of this context. +_event_loop_context: contextvars.ContextVar[contextvars.Context] = ( + contextvars.ContextVar("pytest_asyncio_event_loop_context") +) + + +@contextlib.contextmanager +def _set_event_loop_context(): + """Set event_loop_context to a copy of the calling thread's current context.""" + context = contextvars.copy_context() + token = _event_loop_context.set(context) + try: + yield + finally: + _event_loop_context.reset(token) + + +def _create_task_in_context(loop, coro, context): + """ + Return an asyncio task that runs the coro in the specified context, + if possible. + + This allows fixture setup and teardown to be run as separate asyncio tasks, + while still being able to use context-manager idioms to maintain context + variables and make those variables visible to test functions. + + This is only fully supported on Python 3.11 and newer, as it requires + the API added for https://github.com/python/cpython/issues/91150. + On earlier versions, the returned task will use the default context instead. + """ + if context is not None: + try: + return loop.create_task(coro, context=context) + except TypeError: + pass + return loop.create_task(coro) + + # A stack used to push package-scoped loops during collection of a package # and pop those loops during collection of a Module __package_loop_stack: list[FixtureFunctionMarker | FixtureFunction] = [] @@ -631,7 +679,8 @@ def scoped_event_loop( loop = asyncio.new_event_loop() loop.__pytest_asyncio = True # type: ignore[attr-defined] asyncio.set_event_loop(loop) - yield loop + with _set_event_loop_context(): + yield loop loop.close() # @pytest.fixture does not register the fixture anywhere, so pytest doesn't @@ -938,9 +987,16 @@ def wrap_in_sync( @functools.wraps(func) def inner(*args, **kwargs): + # Give each test its own context based on the loop's main context. + context = _event_loop_context.get(None) + if context is not None: + # We are using our own event loop fixture, so make a new copy of the + # fixture context so that the test won't pollute it. + context = context.copy() + coro = func(*args, **kwargs) _loop = _get_event_loop_no_warn() - task = asyncio.ensure_future(coro, loop=_loop) + task = _create_task_in_context(_loop, coro, context) try: _loop.run_until_complete(task) except BaseException: @@ -1049,7 +1105,8 @@ def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: # The magic value must be set as part of the function definition, because pytest # seems to have multiple instances of the same FixtureDef or fixture function loop.__original_fixture_loop = True # type: ignore[attr-defined] - yield loop + with _set_event_loop_context(): + yield loop loop.close() @@ -1062,7 +1119,8 @@ def _session_event_loop( loop = asyncio.new_event_loop() loop.__pytest_asyncio = True # type: ignore[attr-defined] asyncio.set_event_loop(loop) - yield loop + with _set_event_loop_context(): + yield loop loop.close() diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py new file mode 100644 index 00000000..25bb8106 --- /dev/null +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -0,0 +1,36 @@ +""" +Regression test for https://github.com/pytest-dev/pytest-asyncio/issues/127: +contextvars were not properly maintained among fixtures and tests. +""" + +from __future__ import annotations + +import sys +from contextlib import asynccontextmanager +from contextvars import ContextVar + +import pytest + + +@asynccontextmanager +async def context_var_manager(): + context_var = ContextVar("context_var") + token = context_var.set("value") + try: + yield context_var + finally: + context_var.reset(token) + + +@pytest.fixture(scope="function") +async def context_var(): + async with context_var_manager() as v: + yield v + + +@pytest.mark.asyncio +@pytest.mark.xfail( + sys.version_info < (3, 11), reason="requires asyncio Task context support" +) +async def test(context_var): + assert context_var.get() == "value" From d50effbda55c888798ea0d562e550422c1a67634 Mon Sep 17 00:00:00 2001 From: "Bryan C. Mills" Date: Fri, 6 Dec 2024 23:26:38 -0500 Subject: [PATCH 2/6] 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. --- pytest_asyncio/plugin.py | 115 ++++++++---------- .../test_async_fixtures_contextvars.py | 47 +++++-- 2 files changed, 88 insertions(+), 74 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 762c58ae..ac756a2a 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -319,12 +319,27 @@ def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any): kwargs.pop(event_loop_fixture_id, None) gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) - context = _event_loop_context.get(None) - async def setup(): res = await gen_obj.__anext__() # type: ignore[union-attr] return res + context = contextvars.copy_context() + setup_task = _create_task_in_context(event_loop, setup(), context) + result = event_loop.run_until_complete(setup_task) + + # Copy the context vars set by the setup task back into the ambient + # context for the test. + context_tokens = [] + for var in context: + try: + if var.get() is context.get(var): + # Not modified by the fixture, so leave it as-is. + continue + except LookupError: + pass + token = var.set(context.get(var)) + context_tokens.append((var, token)) + def finalizer() -> None: """Yield again, to finalize.""" @@ -341,14 +356,39 @@ async def async_finalizer() -> None: task = _create_task_in_context(event_loop, async_finalizer(), context) event_loop.run_until_complete(task) - setup_task = _create_task_in_context(event_loop, setup(), context) - result = event_loop.run_until_complete(setup_task) + # Since the fixture is now complete, restore any context variables + # it had set back to their original values. + while context_tokens: + (var, token) = context_tokens.pop() + var.reset(token) + request.addfinalizer(finalizer) return result fixturedef.func = _asyncgen_fixture_wrapper # type: ignore[misc] +def _create_task_in_context(loop, coro, context): + """ + Return an asyncio task that runs the coro in the specified context, + if possible. + + This allows fixture setup and teardown to be run as separate asyncio tasks, + while still being able to use context-manager idioms to maintain context + variables and make those variables visible to test functions. + + This is only fully supported on Python 3.11 and newer, as it requires + the API added for https://github.com/python/cpython/issues/91150. + On earlier versions, the returned task will use the default context instead. + """ + if context is not None: + try: + return loop.create_task(coro, context=context) + except TypeError: + pass + return loop.create_task(coro) + + def _wrap_async_fixture(fixturedef: FixtureDef) -> None: fixture = fixturedef.func @@ -365,10 +405,11 @@ async def setup(): res = await func(**_add_kwargs(func, kwargs, event_loop, request)) return res - task = _create_task_in_context( - event_loop, setup(), _event_loop_context.get(None) - ) - return event_loop.run_until_complete(task) + # Since the fixture doesn't have a cleanup phase, if it set any context + # variables we don't have a good way to clear them again. + # Instead, treat this fixture like an asyncio.Task, which has its own + # independent Context that doesn't affect the caller. + return event_loop.run_until_complete(setup()) fixturedef.func = _async_fixture_wrapper # type: ignore[misc] @@ -592,46 +633,6 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( Session: "session", } -# _event_loop_context stores the Context in which asyncio tasks on the fixture -# event loop should be run. After fixture setup, individual async test functions -# are run on copies of this context. -_event_loop_context: contextvars.ContextVar[contextvars.Context] = ( - contextvars.ContextVar("pytest_asyncio_event_loop_context") -) - - -@contextlib.contextmanager -def _set_event_loop_context(): - """Set event_loop_context to a copy of the calling thread's current context.""" - context = contextvars.copy_context() - token = _event_loop_context.set(context) - try: - yield - finally: - _event_loop_context.reset(token) - - -def _create_task_in_context(loop, coro, context): - """ - Return an asyncio task that runs the coro in the specified context, - if possible. - - This allows fixture setup and teardown to be run as separate asyncio tasks, - while still being able to use context-manager idioms to maintain context - variables and make those variables visible to test functions. - - This is only fully supported on Python 3.11 and newer, as it requires - the API added for https://github.com/python/cpython/issues/91150. - On earlier versions, the returned task will use the default context instead. - """ - if context is not None: - try: - return loop.create_task(coro, context=context) - except TypeError: - pass - return loop.create_task(coro) - - # A stack used to push package-scoped loops during collection of a package # and pop those loops during collection of a Module __package_loop_stack: list[FixtureFunctionMarker | FixtureFunction] = [] @@ -679,8 +680,7 @@ def scoped_event_loop( loop = asyncio.new_event_loop() loop.__pytest_asyncio = True # type: ignore[attr-defined] asyncio.set_event_loop(loop) - with _set_event_loop_context(): - yield loop + yield loop loop.close() # @pytest.fixture does not register the fixture anywhere, so pytest doesn't @@ -987,16 +987,9 @@ def wrap_in_sync( @functools.wraps(func) def inner(*args, **kwargs): - # Give each test its own context based on the loop's main context. - context = _event_loop_context.get(None) - if context is not None: - # We are using our own event loop fixture, so make a new copy of the - # fixture context so that the test won't pollute it. - context = context.copy() - coro = func(*args, **kwargs) _loop = _get_event_loop_no_warn() - task = _create_task_in_context(_loop, coro, context) + task = asyncio.ensure_future(coro, loop=_loop) try: _loop.run_until_complete(task) except BaseException: @@ -1105,8 +1098,7 @@ def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: # The magic value must be set as part of the function definition, because pytest # seems to have multiple instances of the same FixtureDef or fixture function loop.__original_fixture_loop = True # type: ignore[attr-defined] - with _set_event_loop_context(): - yield loop + yield loop loop.close() @@ -1119,8 +1111,7 @@ def _session_event_loop( loop = asyncio.new_event_loop() loop.__pytest_asyncio = True # type: ignore[attr-defined] asyncio.set_event_loop(loop) - with _set_event_loop_context(): - yield loop + yield loop loop.close() diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py index 25bb8106..3f58be54 100644 --- a/tests/async_fixtures/test_async_fixtures_contextvars.py +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -6,31 +6,54 @@ from __future__ import annotations import sys -from contextlib import asynccontextmanager +from contextlib import contextmanager from contextvars import ContextVar import pytest +_context_var = ContextVar("context_var") -@asynccontextmanager -async def context_var_manager(): - context_var = ContextVar("context_var") - token = context_var.set("value") + +@contextmanager +def context_var_manager(value): + token = _context_var.set(value) try: - yield context_var + yield finally: - context_var.reset(token) + _context_var.reset(token) + + +@pytest.fixture(scope="function") +async def no_var_fixture(): + with pytest.raises(LookupError): + _context_var.get() + yield + with pytest.raises(LookupError): + _context_var.get() + + +@pytest.fixture(scope="function") +async def var_fixture(no_var_fixture): + with context_var_manager("value"): + yield + + +@pytest.fixture(scope="function") +async def var_nop_fixture(var_fixture): + with context_var_manager(_context_var.get()): + yield @pytest.fixture(scope="function") -async def context_var(): - async with context_var_manager() as v: - yield v +def inner_var_fixture(var_nop_fixture): + assert _context_var.get() == "value" + with context_var_manager("value2"): + yield @pytest.mark.asyncio @pytest.mark.xfail( sys.version_info < (3, 11), reason="requires asyncio Task context support" ) -async def test(context_var): - assert context_var.get() == "value" +async def test(inner_var_fixture): + assert _context_var.get() == "value2" From e6a8374060bacfd1362c30c2f89c36b12d51f631 Mon Sep 17 00:00:00 2001 From: "Bryan C. Mills" Date: Fri, 6 Dec 2024 23:38:57 -0500 Subject: [PATCH 3/6] Improve contextvars test coverage --- pytest_asyncio/plugin.py | 10 ++++----- .../test_async_fixtures_contextvars.py | 21 ++++++++++++------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index ac756a2a..062437b7 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -381,12 +381,10 @@ def _create_task_in_context(loop, coro, context): the API added for https://github.com/python/cpython/issues/91150. On earlier versions, the returned task will use the default context instead. """ - if context is not None: - try: - return loop.create_task(coro, context=context) - except TypeError: - pass - return loop.create_task(coro) + try: + return loop.create_task(coro, context=context) + except TypeError: + return loop.create_task(coro) def _wrap_async_fixture(fixturedef: FixtureDef) -> None: diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py index 3f58be54..bddab379 100644 --- a/tests/async_fixtures/test_async_fixtures_contextvars.py +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -33,27 +33,34 @@ async def no_var_fixture(): @pytest.fixture(scope="function") -async def var_fixture(no_var_fixture): - with context_var_manager("value"): +async def var_fixture_1(no_var_fixture): + with context_var_manager("value1"): yield @pytest.fixture(scope="function") -async def var_nop_fixture(var_fixture): +async def var_nop_fixture(var_fixture_1): with context_var_manager(_context_var.get()): yield @pytest.fixture(scope="function") -def inner_var_fixture(var_nop_fixture): - assert _context_var.get() == "value" +def var_fixture_2(var_nop_fixture): + assert _context_var.get() == "value1" with context_var_manager("value2"): yield +@pytest.fixture(scope="function") +async def var_fixture_3(var_fixture_2): + assert _context_var.get() == "value2" + with context_var_manager("value3"): + yield + + @pytest.mark.asyncio @pytest.mark.xfail( sys.version_info < (3, 11), reason="requires asyncio Task context support" ) -async def test(inner_var_fixture): - assert _context_var.get() == "value2" +async def test(var_fixture_3): + assert _context_var.get() == "value3" From 344e62fee745e72f82b9e7d06a19478104b963fe Mon Sep 17 00:00:00 2001 From: "Bryan C. Mills" Date: Mon, 9 Dec 2024 09:37:46 -0500 Subject: [PATCH 4/6] Copy context variables from non-generator fixtures --- pytest_asyncio/plugin.py | 113 +++++++++++------- .../test_async_fixtures_contextvars.py | 11 +- 2 files changed, 80 insertions(+), 44 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 062437b7..e1ce5c9d 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -327,18 +327,7 @@ async def setup(): setup_task = _create_task_in_context(event_loop, setup(), context) result = event_loop.run_until_complete(setup_task) - # Copy the context vars set by the setup task back into the ambient - # context for the test. - context_tokens = [] - for var in context: - try: - if var.get() is context.get(var): - # Not modified by the fixture, so leave it as-is. - continue - except LookupError: - pass - token = var.set(context.get(var)) - context_tokens.append((var, token)) + reset_contextvars = _apply_contextvar_changes(context) def finalizer() -> None: """Yield again, to finalize.""" @@ -355,12 +344,8 @@ async def async_finalizer() -> None: task = _create_task_in_context(event_loop, async_finalizer(), context) event_loop.run_until_complete(task) - - # Since the fixture is now complete, restore any context variables - # it had set back to their original values. - while context_tokens: - (var, token) = context_tokens.pop() - var.reset(token) + if reset_contextvars is not None: + reset_contextvars() request.addfinalizer(finalizer) return result @@ -368,25 +353,6 @@ async def async_finalizer() -> None: fixturedef.func = _asyncgen_fixture_wrapper # type: ignore[misc] -def _create_task_in_context(loop, coro, context): - """ - Return an asyncio task that runs the coro in the specified context, - if possible. - - This allows fixture setup and teardown to be run as separate asyncio tasks, - while still being able to use context-manager idioms to maintain context - variables and make those variables visible to test functions. - - This is only fully supported on Python 3.11 and newer, as it requires - the API added for https://github.com/python/cpython/issues/91150. - On earlier versions, the returned task will use the default context instead. - """ - try: - return loop.create_task(coro, context=context) - except TypeError: - return loop.create_task(coro) - - def _wrap_async_fixture(fixturedef: FixtureDef) -> None: fixture = fixturedef.func @@ -403,11 +369,23 @@ async def setup(): res = await func(**_add_kwargs(func, kwargs, event_loop, request)) return res - # Since the fixture doesn't have a cleanup phase, if it set any context - # variables we don't have a good way to clear them again. - # Instead, treat this fixture like an asyncio.Task, which has its own - # independent Context that doesn't affect the caller. - return event_loop.run_until_complete(setup()) + context = contextvars.copy_context() + setup_task = _create_task_in_context(event_loop, setup(), context) + result = event_loop.run_until_complete(setup_task) + + # Copy the context vars modified by the setup task into the current + # context, and (if needed) add a finalizer to reset them. + # + # Note that this is slightly different from the behavior of a non-async + # fixture, which would rely on the fixture author to add a finalizer + # to reset the variables. In this case, the author of the fixture can't + # write such a finalizer because they have no way to capture the Context + # in which the setup function was run, so we need to do it for them. + reset_contextvars = _apply_contextvar_changes(context) + if reset_contextvars is not None: + request.addfinalizer(reset_contextvars) + + return result fixturedef.func = _async_fixture_wrapper # type: ignore[misc] @@ -432,6 +410,57 @@ def _get_event_loop_fixture_id_for_async_fixture( return event_loop_fixture_id +def _create_task_in_context(loop, coro, context): + """ + Return an asyncio task that runs the coro in the specified context, + if possible. + + This allows fixture setup and teardown to be run as separate asyncio tasks, + while still being able to use context-manager idioms to maintain context + variables and make those variables visible to test functions. + + This is only fully supported on Python 3.11 and newer, as it requires + the API added for https://github.com/python/cpython/issues/91150. + On earlier versions, the returned task will use the default context instead. + """ + try: + return loop.create_task(coro, context=context) + except TypeError: + return loop.create_task(coro) + + +def _apply_contextvar_changes( + context: contextvars.Context, +) -> Callable[[], None] | None: + """ + Copy contextvar changes from the given context to the current context. + + If any contextvars were modified by the fixture, return a finalizer that + will restore them. + """ + context_tokens = [] + for var in context: + try: + if var.get() is context.get(var): + # This variable is not modified, so leave it as-is. + continue + except LookupError: + # This variable isn't yet set in the current context at all. + pass + token = var.set(context.get(var)) + context_tokens.append((var, token)) + + if not context_tokens: + return None + + def restore_contextvars(): + while context_tokens: + (var, token) = context_tokens.pop() + var.reset(token) + + return restore_contextvars + + class PytestAsyncioFunction(Function): """Base class for all test functions managed by pytest-asyncio.""" diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py index bddab379..8d67db49 100644 --- a/tests/async_fixtures/test_async_fixtures_contextvars.py +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -58,9 +58,16 @@ async def var_fixture_3(var_fixture_2): yield +@pytest.fixture(scope="function") +async def var_fixture_4(var_fixture_3, request): + assert _context_var.get() == "value3" + _context_var.set("value4") + # Rely on fixture teardown to reset the context var. + + @pytest.mark.asyncio @pytest.mark.xfail( sys.version_info < (3, 11), reason="requires asyncio Task context support" ) -async def test(var_fixture_3): - assert _context_var.get() == "value3" +async def test(var_fixture_4): + assert _context_var.get() == "value4" From 0f76adf70bc274741bdfce3a486826d079c387ef Mon Sep 17 00:00:00 2001 From: "Bryan C. Mills" Date: Wed, 11 Dec 2024 09:51:09 -0500 Subject: [PATCH 5/6] Refactor tests to use Pytester Also add type annotations for _create_task_in_context. --- pytest_asyncio/plugin.py | 7 +- .../test_async_fixtures_contextvars.py | 264 +++++++++++++++--- 2 files changed, 225 insertions(+), 46 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index e1ce5c9d..12ead10f 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -14,6 +14,7 @@ from collections.abc import ( AsyncIterator, Awaitable, + Coroutine as AbstractCoroutine, Generator, Iterable, Iterator, @@ -410,7 +411,11 @@ def _get_event_loop_fixture_id_for_async_fixture( return event_loop_fixture_id -def _create_task_in_context(loop, coro, context): +def _create_task_in_context( + loop: asyncio.AbstractEventLoop, + coro: AbstractCoroutine[Any, Any, _T], + context: contextvars.Context, +) -> asyncio.Task[_T]: """ Return an asyncio task that runs the coro in the specified context, if possible. diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py index 8d67db49..ff79e17e 100644 --- a/tests/async_fixtures/test_async_fixtures_contextvars.py +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -6,68 +6,242 @@ from __future__ import annotations import sys -from contextlib import contextmanager -from contextvars import ContextVar +from textwrap import dedent import pytest - -_context_var = ContextVar("context_var") +from pytest import Pytester + +_prelude = dedent( + """ + import pytest + import pytest_asyncio + from contextlib import contextmanager + from contextvars import ContextVar + + _context_var = ContextVar("context_var") + + @contextmanager + def context_var_manager(value): + token = _context_var.set(value) + try: + yield + finally: + _context_var.reset(token) +""" +) -@contextmanager -def context_var_manager(value): - token = _context_var.set(value) - try: - yield - finally: - _context_var.reset(token) +def test_var_from_sync_generator_propagates_to_async(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest.fixture + def var_fixture(): + with context_var_manager("value"): + yield + @pytest_asyncio.fixture + async def check_var_fixture(var_fixture): + assert _context_var.get() == "value" -@pytest.fixture(scope="function") -async def no_var_fixture(): - with pytest.raises(LookupError): - _context_var.get() - yield - with pytest.raises(LookupError): - _context_var.get() + @pytest.mark.asyncio + async def test(check_var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) -@pytest.fixture(scope="function") -async def var_fixture_1(no_var_fixture): - with context_var_manager("value1"): - yield +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_from_async_generator_propagates_to_sync(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def var_fixture(): + with context_var_manager("value"): + yield + + @pytest.fixture + def check_var_fixture(var_fixture): + assert _context_var.get() == "value" + + @pytest.mark.asyncio + async def test(check_var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) -@pytest.fixture(scope="function") -async def var_nop_fixture(var_fixture_1): - with context_var_manager(_context_var.get()): - yield +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_from_async_fixture_propagates_to_sync(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def var_fixture(): + _context_var.set("value") + # Rely on async fixture teardown to reset the context var. + + @pytest.fixture + def check_var_fixture(var_fixture): + assert _context_var.get() == "value" + + def test(check_var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) -@pytest.fixture(scope="function") -def var_fixture_2(var_nop_fixture): - assert _context_var.get() == "value1" - with context_var_manager("value2"): - yield +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_from_generator_reset_before_previous_fixture_cleanup(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def no_var_fixture(): + with pytest.raises(LookupError): + _context_var.get() + yield + with pytest.raises(LookupError): + _context_var.get() + + @pytest_asyncio.fixture + async def var_fixture(no_var_fixture): + with context_var_manager("value"): + yield + + @pytest.mark.asyncio + async def test(var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) -@pytest.fixture(scope="function") -async def var_fixture_3(var_fixture_2): - assert _context_var.get() == "value2" - with context_var_manager("value3"): - yield +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_from_fixture_reset_before_previous_fixture_cleanup(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def no_var_fixture(): + with pytest.raises(LookupError): + _context_var.get() + yield + with pytest.raises(LookupError): + _context_var.get() + + @pytest_asyncio.fixture + async def var_fixture(no_var_fixture): + _context_var.set("value") + # Rely on async fixture teardown to reset the context var. + + @pytest.mark.asyncio + async def test(var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) -@pytest.fixture(scope="function") -async def var_fixture_4(var_fixture_3, request): - assert _context_var.get() == "value3" - _context_var.set("value4") - # Rely on fixture teardown to reset the context var. +@pytest.mark.xfail( + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, +) +def test_var_previous_value_restored_after_fixture(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def var_fixture_1(): + with context_var_manager("value1"): + yield + assert _context_var.get() == "value1" + + @pytest_asyncio.fixture + async def var_fixture_2(var_fixture_1): + with context_var_manager("value2"): + yield + assert _context_var.get() == "value2" + + @pytest.mark.asyncio + async def test(var_fixture_2): + assert _context_var.get() == "value2" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) -@pytest.mark.asyncio @pytest.mark.xfail( - sys.version_info < (3, 11), reason="requires asyncio Task context support" + sys.version_info < (3, 11), + reason="requires asyncio Task context support", + strict=True, ) -async def test(var_fixture_4): - assert _context_var.get() == "value4" +def test_var_set_to_existing_value_ok(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + _prelude + + dedent( + """ + @pytest_asyncio.fixture + async def var_fixture(): + with context_var_manager("value"): + yield + + @pytest_asyncio.fixture + async def same_var_fixture(var_fixture): + with context_var_manager(_context_var.get()): + yield + + @pytest.mark.asyncio + async def test(same_var_fixture): + assert _context_var.get() == "value" + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) From 7e2da55c9c5f3e05d85bc9b1325afbaf533d6ac0 Mon Sep 17 00:00:00 2001 From: "Bryan C. Mills" Date: Wed, 11 Dec 2024 10:04:48 -0500 Subject: [PATCH 6/6] Add release note for #1008 --- docs/reference/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index d29fa184..6f300ba7 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -7,6 +7,8 @@ Changelog - Deprecated: Added warning when asyncio test requests async ``@pytest.fixture`` in strict mode. This will become an error in a future version of flake8-asyncio. `#979 `_ - Updates the error message about `pytest.mark.asyncio`'s `scope` keyword argument to say `loop_scope` instead. `#1004 `_ - Verbose log displays correct parameter name: asyncio_default_fixture_loop_scope `#990 `_ +- Propagates `contextvars` set in async fixtures to other fixtures and tests on Python 3.11 and above. `#1008 `_ + 0.24.0 (2024-08-22) ===================