From 7962663c0f6e7e34aca4f730b8d848ec6f6f7eaf Mon Sep 17 00:00:00 2001 From: Vizonex Date: Wed, 9 Jul 2025 10:34:34 -0500 Subject: [PATCH 01/12] Add 2 new functions for using eventloops in non-deprecated ways --- pytest_asyncio/plugin.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 9bfcfc64..79d08e4e 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -546,6 +546,21 @@ def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[No _set_event_loop(old_loop) +@contextlib.contextmanager +def _temporary_event_loop(loop:AbstractEventLoop): + try: + old_event_loop = asyncio.get_event_loop() + except RuntimeError: + old_event_loop = None + + asyncio.set_event_loop(old_event_loop) + try: + yield + finally: + asyncio.set_event_loop(old_event_loop) + + + def _get_event_loop_policy() -> AbstractEventLoopPolicy: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) @@ -772,6 +787,9 @@ def _scoped_runner( RuntimeWarning, ) + + + return _scoped_runner @@ -780,6 +798,11 @@ def _scoped_runner( scope.value ) +@pytest.fixture(scope="session", autouse=True) +def new_event_loop() -> AbstractEventLoop: + """Creates a new eventloop for different tests being ran""" + return asyncio.new_event_loop() + @pytest.fixture(scope="session", autouse=True) def event_loop_policy() -> AbstractEventLoopPolicy: From 9df1f6ac7b8fefe70b996e7f0387fa597e45c8d6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:45:44 +0000 Subject: [PATCH 02/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pytest_asyncio/plugin.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 79d08e4e..5b3d35dd 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -547,20 +547,19 @@ def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[No @contextlib.contextmanager -def _temporary_event_loop(loop:AbstractEventLoop): +def _temporary_event_loop(loop: AbstractEventLoop): try: old_event_loop = asyncio.get_event_loop() except RuntimeError: old_event_loop = None - + asyncio.set_event_loop(old_event_loop) try: - yield + yield finally: asyncio.set_event_loop(old_event_loop) - def _get_event_loop_policy() -> AbstractEventLoopPolicy: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) @@ -787,9 +786,6 @@ def _scoped_runner( RuntimeWarning, ) - - - return _scoped_runner @@ -798,6 +794,7 @@ def _scoped_runner( scope.value ) + @pytest.fixture(scope="session", autouse=True) def new_event_loop() -> AbstractEventLoop: """Creates a new eventloop for different tests being ran""" From d4effb261c2e03829b813173924b7711c3905bb5 Mon Sep 17 00:00:00 2001 From: Vizonex Date: Wed, 9 Jul 2025 10:49:44 -0500 Subject: [PATCH 03/12] Add to timeline --- changelog.d/1164.added.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1164.added.rst diff --git a/changelog.d/1164.added.rst b/changelog.d/1164.added.rst new file mode 100644 index 00000000..321bacd4 --- /dev/null +++ b/changelog.d/1164.added.rst @@ -0,0 +1 @@ +Added ``new_event_loop`` fixture to provide an alternative approch to event loop policies being deprecated \ No newline at end of file From c26f806b98984297ff4d83feb8c899777aec7330 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:53:25 +0000 Subject: [PATCH 04/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- changelog.d/1164.added.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1164.added.rst b/changelog.d/1164.added.rst index 321bacd4..e4cf9e53 100644 --- a/changelog.d/1164.added.rst +++ b/changelog.d/1164.added.rst @@ -1 +1 @@ -Added ``new_event_loop`` fixture to provide an alternative approch to event loop policies being deprecated \ No newline at end of file +Added ``new_event_loop`` fixture to provide an alternative approch to event loop policies being deprecated From ee7bdce07f7c2d342260484cf473050196352ae1 Mon Sep 17 00:00:00 2001 From: Vizonex Date: Thu, 10 Jul 2025 16:48:51 -0500 Subject: [PATCH 05/12] Incomplete need to figure out how to get loop_factory / multiple into asyncio.Runner --- pytest_asyncio/plugin.py | 76 ++++++++++++++++--------- tests/markers/test_invalid_arguments.py | 12 ++-- tests/test_asyncio_mark.py | 32 +++++++++++ 3 files changed, 87 insertions(+), 33 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 5b3d35dd..76227ddc 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -372,6 +372,8 @@ def restore_contextvars(): class PytestAsyncioFunction(Function): """Base class for all test functions managed by pytest-asyncio.""" + loop_factory: Callable[[], AbstractEventLoop] | None + @classmethod def item_subclass_for(cls, item: Function, /) -> type[PytestAsyncioFunction] | None: """ @@ -386,12 +388,18 @@ def item_subclass_for(cls, item: Function, /) -> type[PytestAsyncioFunction] | N return None @classmethod - def _from_function(cls, function: Function, /) -> Function: + def _from_function( + cls, + function: Function, + loop_factory: Callable[[], AbstractEventLoop] | None = None, + /, + ) -> Function: """ Instantiates this specific PytestAsyncioFunction type from the specified Function item. """ assert function.get_closest_marker("asyncio") + subclass_instance = cls.from_parent( function.parent, name=function.name, @@ -401,6 +409,7 @@ def _from_function(cls, function: Function, /) -> Function: keywords=function.keywords, originalname=function.originalname, ) + subclass_instance.loop_factory = loop_factory subclass_instance.own_markers = function.own_markers assert subclass_instance.own_markers == function.own_markers return subclass_instance @@ -525,9 +534,27 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( node.config ) == Mode.AUTO and not node.get_closest_marker("asyncio"): node.add_marker("asyncio") - if node.get_closest_marker("asyncio"): - updated_item = specialized_item_class._from_function(node) - updated_node_collection.append(updated_item) + if asyncio_marker := node.get_closest_marker("asyncio"): + if loop_factory := asyncio_marker.kwargs.get("loop_factory", None): + # multiply if loop_factory is an iterable object of factories + if hasattr(loop_factory, "__iter__"): + updated_item = [ + specialized_item_class._from_function(node, lf) + for lf in loop_factory + ] + else: + updated_item = specialized_item_class._from_function( + node, loop_factory + ) + else: + updated_item = specialized_item_class._from_function(node) + + # we could have multiple factroies to test if so, + # multiply the number of functions for us... + if isinstance(updated_item, list): + updated_node_collection.extend(updated_item) + else: + updated_node_collection.append(updated_item) hook_result.force_result(updated_node_collection) @@ -546,20 +573,6 @@ def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[No _set_event_loop(old_loop) -@contextlib.contextmanager -def _temporary_event_loop(loop: AbstractEventLoop): - try: - old_event_loop = asyncio.get_event_loop() - except RuntimeError: - old_event_loop = None - - asyncio.set_event_loop(old_event_loop) - try: - yield - finally: - asyncio.set_event_loop(old_event_loop) - - def _get_event_loop_policy() -> AbstractEventLoopPolicy: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) @@ -669,12 +682,15 @@ def pytest_runtest_setup(item: pytest.Item) -> None: marker = item.get_closest_marker("asyncio") if marker is None: return + getattr(marker, "loop_factory", None) default_loop_scope = _get_default_test_loop_scope(item.config) loop_scope = _get_marked_loop_scope(marker, default_loop_scope) runner_fixture_id = f"_{loop_scope}_scoped_runner" - fixturenames = item.fixturenames # type: ignore[attr-defined] + fixturenames: list[str] = item.fixturenames # type: ignore[attr-defined] + if runner_fixture_id not in fixturenames: fixturenames.append(runner_fixture_id) + obj = getattr(item, "obj", None) if not getattr(obj, "hypothesis", False) and getattr( obj, "is_hypothesis_test", False @@ -701,8 +717,15 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: or default_loop_scope or fixturedef.scope ) + # XXX: Currently Confused as to where to debug and harvest and get the runner to use the loop_factory argument. + loop_factory = getattr(fixturedef.func, "loop_factory", None) + + print(f"LOOP FACTORY: {loop_factory} {fixturedef.func}") + sys.stdout.flush() + runner_fixture_id = f"_{loop_scope}_scoped_runner" - runner = request.getfixturevalue(runner_fixture_id) + runner: Runner = request.getfixturevalue(runner_fixture_id) + synchronizer = _fixture_synchronizer(fixturedef, runner, request) _make_asyncio_fixture_function(synchronizer, loop_scope) with MonkeyPatch.context() as c: @@ -727,9 +750,12 @@ def _get_marked_loop_scope( ) -> _ScopeName: assert asyncio_marker.name == "asyncio" if asyncio_marker.args or ( - asyncio_marker.kwargs and set(asyncio_marker.kwargs) - {"loop_scope", "scope"} + asyncio_marker.kwargs + and set(asyncio_marker.kwargs) - {"loop_scope", "scope", "loop_factory"} ): - raise ValueError("mark.asyncio accepts only a keyword argument 'loop_scope'.") + raise ValueError( + "mark.asyncio accepts only keyword arguments 'loop_scope', 'loop_factory'." + ) if "scope" in asyncio_marker.kwargs: if "loop_scope" in asyncio_marker.kwargs: raise pytest.UsageError(_DUPLICATE_LOOP_SCOPE_DEFINITION_ERROR) @@ -795,12 +821,6 @@ def _scoped_runner( ) -@pytest.fixture(scope="session", autouse=True) -def new_event_loop() -> AbstractEventLoop: - """Creates a new eventloop for different tests being ran""" - return asyncio.new_event_loop() - - @pytest.fixture(scope="session", autouse=True) def event_loop_policy() -> AbstractEventLoopPolicy: """Return an instance of the policy used to create asyncio event loops.""" diff --git a/tests/markers/test_invalid_arguments.py b/tests/markers/test_invalid_arguments.py index 2d5c3552..a7e499a3 100644 --- a/tests/markers/test_invalid_arguments.py +++ b/tests/markers/test_invalid_arguments.py @@ -40,9 +40,7 @@ async def test_anything(): ) result = pytester.runpytest_subprocess() result.assert_outcomes(errors=1) - result.stdout.fnmatch_lines( - ["*ValueError: mark.asyncio accepts only a keyword argument*"] - ) + result.stdout.fnmatch_lines([""]) def test_error_when_wrong_keyword_argument_is_passed( @@ -62,7 +60,9 @@ async def test_anything(): result = pytester.runpytest_subprocess() result.assert_outcomes(errors=1) result.stdout.fnmatch_lines( - ["*ValueError: mark.asyncio accepts only a keyword argument 'loop_scope'*"] + [ + "*ValueError: mark.asyncio accepts only keyword arguments 'loop_scope', 'loop_factory'*" + ] ) @@ -83,5 +83,7 @@ async def test_anything(): result = pytester.runpytest_subprocess() result.assert_outcomes(errors=1) result.stdout.fnmatch_lines( - ["*ValueError: mark.asyncio accepts only a keyword argument*"] + [ + "*ValueError: mark.asyncio accepts only keyword arguments 'loop_scope', 'loop_factory'*" + ] ) diff --git a/tests/test_asyncio_mark.py b/tests/test_asyncio_mark.py index 81731adb..094093c3 100644 --- a/tests/test_asyncio_mark.py +++ b/tests/test_asyncio_mark.py @@ -223,3 +223,35 @@ async def test_a(session_loop_fixture): result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) + + +def test_asyncio_marker_event_loop_factories(pytester: Pytester): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_fixture_loop_scope = function + asyncio_default_test_loop_scope = module + """ + ) + ) + + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest_asyncio + import pytest + + class CustomEventLoop(asyncio.SelectorEventLoop): + pass + + @pytest.mark.asyncio(loop_factory=CustomEventLoop) + async def test_has_different_event_loop(): + assert type(asyncio.get_running_loop()).__name__ == "CustomEventLoop" + """ + ) + ) + + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) From edfbfeffaafd109e8fe808585a499d1840be0f44 Mon Sep 17 00:00:00 2001 From: Vizonex Date: Fri, 25 Jul 2025 19:53:05 -0500 Subject: [PATCH 06/12] figured out loop_factory :) --- pytest_asyncio/plugin.py | 103 ++++++++++++++++----------------------- 1 file changed, 43 insertions(+), 60 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 76227ddc..1349b251 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -49,6 +49,7 @@ PytestPluginManager, ) +from typing import Callable if sys.version_info >= (3, 10): from typing import ParamSpec else: @@ -116,6 +117,7 @@ def fixture( *, scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., loop_scope: _ScopeName | None = ..., + loop_factory: _ScopeName | Callable[[], AbstractEventLoop] = ..., params: Iterable[object] | None = ..., autouse: bool = ..., ids: ( @@ -133,6 +135,7 @@ def fixture( *, scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., loop_scope: _ScopeName | None = ..., + loop_factory: _ScopeName | Callable[[], AbstractEventLoop] = ..., params: Iterable[object] | None = ..., autouse: bool = ..., ids: ( @@ -147,20 +150,21 @@ def fixture( def fixture( fixture_function: FixtureFunction[_P, _R] | None = None, loop_scope: _ScopeName | None = None, + loop_factory: _ScopeName | Callable[[], AbstractEventLoop] = ..., **kwargs: Any, ) -> ( FixtureFunction[_P, _R] | Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]] ): if fixture_function is not None: - _make_asyncio_fixture_function(fixture_function, loop_scope) + _make_asyncio_fixture_function(fixture_function, loop_scope, loop_factory) return pytest.fixture(fixture_function, **kwargs) else: @functools.wraps(fixture) def inner(fixture_function: FixtureFunction[_P, _R]) -> FixtureFunction[_P, _R]: - return fixture(fixture_function, loop_scope=loop_scope, **kwargs) + return fixture(fixture_function, loop_factory=loop_factory, loop_scope=loop_scope, **kwargs) return inner @@ -170,12 +174,13 @@ def _is_asyncio_fixture_function(obj: Any) -> bool: return getattr(obj, "_force_asyncio_fixture", False) -def _make_asyncio_fixture_function(obj: Any, loop_scope: _ScopeName | None) -> None: +def _make_asyncio_fixture_function(obj: Any, loop_scope: _ScopeName | None, loop_factory: _ScopeName | None) -> None: if hasattr(obj, "__func__"): # instance method, check the function object obj = obj.__func__ obj._force_asyncio_fixture = True obj._loop_scope = loop_scope + obj._loop_factory = loop_factory def _is_coroutine_or_asyncgen(obj: Any) -> bool: @@ -234,14 +239,14 @@ def pytest_report_header(config: Config) -> list[str]: def _fixture_synchronizer( - fixturedef: FixtureDef, runner: Runner, request: FixtureRequest + fixturedef: FixtureDef, runner: Runner, request: FixtureRequest, loop_factory: Callable[[], AbstractEventLoop] ) -> Callable: """Returns a synchronous function evaluating the specified fixture.""" fixture_function = resolve_fixture_function(fixturedef, request) if inspect.isasyncgenfunction(fixturedef.func): - return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type] + return _wrap_asyncgen_fixture(fixture_function, runner, request, loop_factory) # type: ignore[arg-type] elif inspect.iscoroutinefunction(fixturedef.func): - return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type] + return _wrap_async_fixture(fixture_function, runner, request, loop_factory) # type: ignore[arg-type] else: return fixturedef.func @@ -256,6 +261,7 @@ def _wrap_asyncgen_fixture( ], runner: Runner, request: FixtureRequest, + loop_factory:Callable[[], AbstractEventLoop] ) -> Callable[AsyncGenFixtureParams, AsyncGenFixtureYieldType]: @functools.wraps(fixture_function) def _asyncgen_fixture_wrapper( @@ -285,6 +291,9 @@ async def async_finalizer() -> None: msg = "Async generator fixture didn't stop." msg += "Yield only once." raise ValueError(msg) + if loop_factory: + _loop = loop_factory() + asyncio.set_event_loop(_loop) runner.run(async_finalizer(), context=context) if reset_contextvars is not None: @@ -306,6 +315,7 @@ def _wrap_async_fixture( ], runner: Runner, request: FixtureRequest, + loop_factory: Callable[[], AbstractEventLoop] | None = None ) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: @functools.wraps(fixture_function) # type: ignore[arg-type] @@ -318,8 +328,12 @@ async def setup(): return res context = contextvars.copy_context() - result = runner.run(setup(), context=context) + # ensure loop_factory gets ran before we start running... + if loop_factory: + asyncio.set_event_loop(loop_factory()) + + result = runner.run(setup(), context=context) # Copy the context vars modified by the setup task into the current # context, and (if needed) add a finalizer to reset them. # @@ -372,8 +386,6 @@ def restore_contextvars(): class PytestAsyncioFunction(Function): """Base class for all test functions managed by pytest-asyncio.""" - loop_factory: Callable[[], AbstractEventLoop] | None - @classmethod def item_subclass_for(cls, item: Function, /) -> type[PytestAsyncioFunction] | None: """ @@ -388,18 +400,12 @@ def item_subclass_for(cls, item: Function, /) -> type[PytestAsyncioFunction] | N return None @classmethod - def _from_function( - cls, - function: Function, - loop_factory: Callable[[], AbstractEventLoop] | None = None, - /, - ) -> Function: + def _from_function(cls, function: Function, /) -> Function: """ Instantiates this specific PytestAsyncioFunction type from the specified Function item. """ assert function.get_closest_marker("asyncio") - subclass_instance = cls.from_parent( function.parent, name=function.name, @@ -409,7 +415,6 @@ def _from_function( keywords=function.keywords, originalname=function.originalname, ) - subclass_instance.loop_factory = loop_factory subclass_instance.own_markers = function.own_markers assert subclass_instance.own_markers == function.own_markers return subclass_instance @@ -429,7 +434,8 @@ def _can_substitute(item: Function) -> bool: return inspect.iscoroutinefunction(func) def runtest(self) -> None: - synchronized_obj = wrap_in_sync(self.obj) + # print(self.obj.pytestmark[0].__dict__) + synchronized_obj = wrap_in_sync(self.obj, self.obj.pytestmark[0].kwargs.get('loop_factory', None)) with MonkeyPatch.context() as c: c.setattr(self, "obj", synchronized_obj) super().runtest() @@ -534,27 +540,9 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( node.config ) == Mode.AUTO and not node.get_closest_marker("asyncio"): node.add_marker("asyncio") - if asyncio_marker := node.get_closest_marker("asyncio"): - if loop_factory := asyncio_marker.kwargs.get("loop_factory", None): - # multiply if loop_factory is an iterable object of factories - if hasattr(loop_factory, "__iter__"): - updated_item = [ - specialized_item_class._from_function(node, lf) - for lf in loop_factory - ] - else: - updated_item = specialized_item_class._from_function( - node, loop_factory - ) - else: - updated_item = specialized_item_class._from_function(node) - - # we could have multiple factroies to test if so, - # multiply the number of functions for us... - if isinstance(updated_item, list): - updated_node_collection.extend(updated_item) - else: - updated_node_collection.append(updated_item) + if node.get_closest_marker("asyncio"): + updated_item = specialized_item_class._from_function(node) + updated_node_collection.append(updated_item) hook_result.force_result(updated_node_collection) @@ -654,20 +642,25 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: def wrap_in_sync( func: Callable[..., Awaitable[Any]], + loop_factory:Callable[[], AbstractEventLoop] | None = None ): """ Return a sync wrapper around an async function executing it in the current event loop. """ - @functools.wraps(func) def inner(*args, **kwargs): - coro = func(*args, **kwargs) - _loop = _get_event_loop_no_warn() - task = asyncio.ensure_future(coro, loop=_loop) + _last_loop = asyncio.get_event_loop() + if loop_factory: + _loop = loop_factory() + asyncio.set_event_loop(_loop) + else: + _loop = asyncio.get_event_loop() + task = asyncio.ensure_future(func(*args, **kwargs), loop=_loop) try: _loop.run_until_complete(task) except BaseException: + # run_until_complete doesn't get the result from exceptions # that are not subclasses of `Exception`. Consume all # exceptions to prevent asyncio's warning from logging. @@ -675,6 +668,7 @@ def inner(*args, **kwargs): task.exception() raise + asyncio.set_event_loop(_last_loop) return inner @@ -682,15 +676,12 @@ def pytest_runtest_setup(item: pytest.Item) -> None: marker = item.get_closest_marker("asyncio") if marker is None: return - getattr(marker, "loop_factory", None) default_loop_scope = _get_default_test_loop_scope(item.config) loop_scope = _get_marked_loop_scope(marker, default_loop_scope) runner_fixture_id = f"_{loop_scope}_scoped_runner" - fixturenames: list[str] = item.fixturenames # type: ignore[attr-defined] - + fixturenames = item.fixturenames # type: ignore[attr-defined] if runner_fixture_id not in fixturenames: fixturenames.append(runner_fixture_id) - obj = getattr(item, "obj", None) if not getattr(obj, "hypothesis", False) and getattr( obj, "is_hypothesis_test", False @@ -717,17 +708,12 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: or default_loop_scope or fixturedef.scope ) - # XXX: Currently Confused as to where to debug and harvest and get the runner to use the loop_factory argument. loop_factory = getattr(fixturedef.func, "loop_factory", None) - print(f"LOOP FACTORY: {loop_factory} {fixturedef.func}") - sys.stdout.flush() - runner_fixture_id = f"_{loop_scope}_scoped_runner" - runner: Runner = request.getfixturevalue(runner_fixture_id) - - synchronizer = _fixture_synchronizer(fixturedef, runner, request) - _make_asyncio_fixture_function(synchronizer, loop_scope) + runner = request.getfixturevalue(runner_fixture_id) + synchronizer = _fixture_synchronizer(fixturedef, runner, request, loop_factory) + _make_asyncio_fixture_function(synchronizer, loop_scope, loop_factory) with MonkeyPatch.context() as c: c.setattr(fixturedef, "func", synchronizer) hook_result = yield @@ -750,12 +736,9 @@ def _get_marked_loop_scope( ) -> _ScopeName: assert asyncio_marker.name == "asyncio" if asyncio_marker.args or ( - asyncio_marker.kwargs - and set(asyncio_marker.kwargs) - {"loop_scope", "scope", "loop_factory"} + asyncio_marker.kwargs and set(asyncio_marker.kwargs) - {"loop_scope", "scope", "loop_factory"} ): - raise ValueError( - "mark.asyncio accepts only keyword arguments 'loop_scope', 'loop_factory'." - ) + raise ValueError("mark.asyncio accepts only a keyword arguments 'loop_scope' or 'loop_factory'") if "scope" in asyncio_marker.kwargs: if "loop_scope" in asyncio_marker.kwargs: raise pytest.UsageError(_DUPLICATE_LOOP_SCOPE_DEFINITION_ERROR) From b68073ab3b7d7b77968d77cc5f54a39be2c94f6e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 26 Jul 2025 00:53:27 +0000 Subject: [PATCH 07/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pytest_asyncio/plugin.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 1349b251..81f7cfb4 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -49,7 +49,6 @@ PytestPluginManager, ) -from typing import Callable if sys.version_info >= (3, 10): from typing import ParamSpec else: @@ -164,7 +163,12 @@ def fixture( @functools.wraps(fixture) def inner(fixture_function: FixtureFunction[_P, _R]) -> FixtureFunction[_P, _R]: - return fixture(fixture_function, loop_factory=loop_factory, loop_scope=loop_scope, **kwargs) + return fixture( + fixture_function, + loop_factory=loop_factory, + loop_scope=loop_scope, + **kwargs, + ) return inner @@ -174,7 +178,9 @@ def _is_asyncio_fixture_function(obj: Any) -> bool: return getattr(obj, "_force_asyncio_fixture", False) -def _make_asyncio_fixture_function(obj: Any, loop_scope: _ScopeName | None, loop_factory: _ScopeName | None) -> None: +def _make_asyncio_fixture_function( + obj: Any, loop_scope: _ScopeName | None, loop_factory: _ScopeName | None +) -> None: if hasattr(obj, "__func__"): # instance method, check the function object obj = obj.__func__ @@ -239,7 +245,10 @@ def pytest_report_header(config: Config) -> list[str]: def _fixture_synchronizer( - fixturedef: FixtureDef, runner: Runner, request: FixtureRequest, loop_factory: Callable[[], AbstractEventLoop] + fixturedef: FixtureDef, + runner: Runner, + request: FixtureRequest, + loop_factory: Callable[[], AbstractEventLoop], ) -> Callable: """Returns a synchronous function evaluating the specified fixture.""" fixture_function = resolve_fixture_function(fixturedef, request) @@ -261,7 +270,7 @@ def _wrap_asyncgen_fixture( ], runner: Runner, request: FixtureRequest, - loop_factory:Callable[[], AbstractEventLoop] + loop_factory: Callable[[], AbstractEventLoop], ) -> Callable[AsyncGenFixtureParams, AsyncGenFixtureYieldType]: @functools.wraps(fixture_function) def _asyncgen_fixture_wrapper( @@ -291,6 +300,7 @@ async def async_finalizer() -> None: msg = "Async generator fixture didn't stop." msg += "Yield only once." raise ValueError(msg) + if loop_factory: _loop = loop_factory() asyncio.set_event_loop(_loop) @@ -315,7 +325,7 @@ def _wrap_async_fixture( ], runner: Runner, request: FixtureRequest, - loop_factory: Callable[[], AbstractEventLoop] | None = None + loop_factory: Callable[[], AbstractEventLoop] | None = None, ) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: @functools.wraps(fixture_function) # type: ignore[arg-type] @@ -435,7 +445,9 @@ def _can_substitute(item: Function) -> bool: def runtest(self) -> None: # print(self.obj.pytestmark[0].__dict__) - synchronized_obj = wrap_in_sync(self.obj, self.obj.pytestmark[0].kwargs.get('loop_factory', None)) + synchronized_obj = wrap_in_sync( + self.obj, self.obj.pytestmark[0].kwargs.get("loop_factory", None) + ) with MonkeyPatch.context() as c: c.setattr(self, "obj", synchronized_obj) super().runtest() @@ -642,12 +654,13 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: def wrap_in_sync( func: Callable[..., Awaitable[Any]], - loop_factory:Callable[[], AbstractEventLoop] | None = None + loop_factory: Callable[[], AbstractEventLoop] | None = None, ): """ Return a sync wrapper around an async function executing it in the current event loop. """ + @functools.wraps(func) def inner(*args, **kwargs): _last_loop = asyncio.get_event_loop() @@ -660,7 +673,7 @@ def inner(*args, **kwargs): try: _loop.run_until_complete(task) except BaseException: - + # run_until_complete doesn't get the result from exceptions # that are not subclasses of `Exception`. Consume all # exceptions to prevent asyncio's warning from logging. @@ -669,6 +682,7 @@ def inner(*args, **kwargs): raise asyncio.set_event_loop(_last_loop) + return inner @@ -736,9 +750,12 @@ def _get_marked_loop_scope( ) -> _ScopeName: assert asyncio_marker.name == "asyncio" if asyncio_marker.args or ( - asyncio_marker.kwargs and set(asyncio_marker.kwargs) - {"loop_scope", "scope", "loop_factory"} + asyncio_marker.kwargs + and set(asyncio_marker.kwargs) - {"loop_scope", "scope", "loop_factory"} ): - raise ValueError("mark.asyncio accepts only a keyword arguments 'loop_scope' or 'loop_factory'") + raise ValueError( + "mark.asyncio accepts only a keyword arguments 'loop_scope' or 'loop_factory'" + ) if "scope" in asyncio_marker.kwargs: if "loop_scope" in asyncio_marker.kwargs: raise pytest.UsageError(_DUPLICATE_LOOP_SCOPE_DEFINITION_ERROR) From cd1144c953588e1bb098ec2004433c97e055bd6f Mon Sep 17 00:00:00 2001 From: Vizonex Date: Mon, 28 Jul 2025 12:03:02 -0500 Subject: [PATCH 08/12] inject at the runner instead however there was a side-effect so I made a comment explaining it. --- pytest_asyncio/plugin.py | 85 +++++++++++++++++++++----------------- tests/test_asyncio_mark.py | 10 +++++ 2 files changed, 58 insertions(+), 37 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 81f7cfb4..6878739a 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -116,7 +116,7 @@ def fixture( *, scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., loop_scope: _ScopeName | None = ..., - loop_factory: _ScopeName | Callable[[], AbstractEventLoop] = ..., + loop_factory: Callable[[], AbstractEventLoop] | None = ..., params: Iterable[object] | None = ..., autouse: bool = ..., ids: ( @@ -134,7 +134,7 @@ def fixture( *, scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., loop_scope: _ScopeName | None = ..., - loop_factory: _ScopeName | Callable[[], AbstractEventLoop] = ..., + loop_factory: Callable[[], AbstractEventLoop] | None = ..., params: Iterable[object] | None = ..., autouse: bool = ..., ids: ( @@ -149,7 +149,7 @@ def fixture( def fixture( fixture_function: FixtureFunction[_P, _R] | None = None, loop_scope: _ScopeName | None = None, - loop_factory: _ScopeName | Callable[[], AbstractEventLoop] = ..., + loop_factory: Callable[[], AbstractEventLoop] | None = None, **kwargs: Any, ) -> ( FixtureFunction[_P, _R] @@ -179,7 +179,9 @@ def _is_asyncio_fixture_function(obj: Any) -> bool: def _make_asyncio_fixture_function( - obj: Any, loop_scope: _ScopeName | None, loop_factory: _ScopeName | None + obj: Any, + loop_scope: _ScopeName | None, + loop_factory: Callable[[], AbstractEventLoop] | None, ) -> None: if hasattr(obj, "__func__"): # instance method, check the function object @@ -248,14 +250,13 @@ def _fixture_synchronizer( fixturedef: FixtureDef, runner: Runner, request: FixtureRequest, - loop_factory: Callable[[], AbstractEventLoop], ) -> Callable: """Returns a synchronous function evaluating the specified fixture.""" fixture_function = resolve_fixture_function(fixturedef, request) if inspect.isasyncgenfunction(fixturedef.func): - return _wrap_asyncgen_fixture(fixture_function, runner, request, loop_factory) # type: ignore[arg-type] + return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type] elif inspect.iscoroutinefunction(fixturedef.func): - return _wrap_async_fixture(fixture_function, runner, request, loop_factory) # type: ignore[arg-type] + return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type] else: return fixturedef.func @@ -270,7 +271,6 @@ def _wrap_asyncgen_fixture( ], runner: Runner, request: FixtureRequest, - loop_factory: Callable[[], AbstractEventLoop], ) -> Callable[AsyncGenFixtureParams, AsyncGenFixtureYieldType]: @functools.wraps(fixture_function) def _asyncgen_fixture_wrapper( @@ -301,10 +301,6 @@ async def async_finalizer() -> None: msg += "Yield only once." raise ValueError(msg) - if loop_factory: - _loop = loop_factory() - asyncio.set_event_loop(_loop) - runner.run(async_finalizer(), context=context) if reset_contextvars is not None: reset_contextvars() @@ -325,9 +321,7 @@ def _wrap_async_fixture( ], runner: Runner, request: FixtureRequest, - loop_factory: Callable[[], AbstractEventLoop] | None = None, ) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: - @functools.wraps(fixture_function) # type: ignore[arg-type] def _async_fixture_wrapper( *args: AsyncFixtureParams.args, @@ -339,10 +333,6 @@ async def setup(): context = contextvars.copy_context() - # ensure loop_factory gets ran before we start running... - if loop_factory: - asyncio.set_event_loop(loop_factory()) - result = runner.run(setup(), context=context) # Copy the context vars modified by the setup task into the current # context, and (if needed) add a finalizer to reset them. @@ -445,9 +435,7 @@ def _can_substitute(item: Function) -> bool: def runtest(self) -> None: # print(self.obj.pytestmark[0].__dict__) - synchronized_obj = wrap_in_sync( - self.obj, self.obj.pytestmark[0].kwargs.get("loop_factory", None) - ) + synchronized_obj = wrap_in_sync(self.obj) with MonkeyPatch.context() as c: c.setattr(self, "obj", synchronized_obj) super().runtest() @@ -559,16 +547,32 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( @contextlib.contextmanager -def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]: +def _temporary_event_loop_policy( + policy: AbstractEventLoopPolicy, + loop_facotry: Callable[..., AbstractEventLoop] | None, +) -> Iterator[None]: + old_loop_policy = _get_event_loop_policy() try: old_loop = _get_event_loop_no_warn() except RuntimeError: old_loop = None + # XXX: For some reason this function can override runner's + # _loop_factory (At least observed on backported versions of Runner) + # so we need to re-override if existing... + if loop_facotry: + _loop = loop_facotry() + _set_event_loop(_loop) + else: + _loop = None + _set_event_loop_policy(policy) try: yield finally: + if _loop: + # Do not let BaseEventLoop.__del__ complain! + _loop.close() _set_event_loop_policy(old_loop_policy) _set_event_loop(old_loop) @@ -654,7 +658,6 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: def wrap_in_sync( func: Callable[..., Awaitable[Any]], - loop_factory: Callable[[], AbstractEventLoop] | None = None, ): """ Return a sync wrapper around an async function executing it in the @@ -663,17 +666,11 @@ def wrap_in_sync( @functools.wraps(func) def inner(*args, **kwargs): - _last_loop = asyncio.get_event_loop() - if loop_factory: - _loop = loop_factory() - asyncio.set_event_loop(_loop) - else: - _loop = asyncio.get_event_loop() + _loop = asyncio.get_event_loop() task = asyncio.ensure_future(func(*args, **kwargs), loop=_loop) try: _loop.run_until_complete(task) except BaseException: - # run_until_complete doesn't get the result from exceptions # that are not subclasses of `Exception`. Consume all # exceptions to prevent asyncio's warning from logging. @@ -681,8 +678,6 @@ def inner(*args, **kwargs): task.exception() raise - asyncio.set_event_loop(_last_loop) - return inner @@ -726,7 +721,7 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: runner_fixture_id = f"_{loop_scope}_scoped_runner" runner = request.getfixturevalue(runner_fixture_id) - synchronizer = _fixture_synchronizer(fixturedef, runner, request, loop_factory) + synchronizer = _fixture_synchronizer(fixturedef, runner, request) _make_asyncio_fixture_function(synchronizer, loop_scope, loop_factory) with MonkeyPatch.context() as c: c.setattr(fixturedef, "func", synchronizer) @@ -754,7 +749,8 @@ def _get_marked_loop_scope( and set(asyncio_marker.kwargs) - {"loop_scope", "scope", "loop_factory"} ): raise ValueError( - "mark.asyncio accepts only a keyword arguments 'loop_scope' or 'loop_factory'" + "mark.asyncio accepts only a keyword arguments 'loop_scope'" + " or 'loop_factory'" ) if "scope" in asyncio_marker.kwargs: if "loop_scope" in asyncio_marker.kwargs: @@ -784,17 +780,32 @@ def _get_default_test_loop_scope(config: Config) -> _ScopeName: """ +def _get_loop_facotry( + request: FixtureRequest, +) -> Callable[[], AbstractEventLoop] | None: + if asyncio_mark := request._pyfuncitem.get_closest_marker("asyncio"): + factory = asyncio_mark.kwargs.get("loop_factory", None) + print(f"FACTORY {factory}") + return factory + else: + return request.obj.__dict__.get("_loop_factory", None) # type: ignore[attr-defined] + + def _create_scoped_runner_fixture(scope: _ScopeName) -> Callable: @pytest.fixture( scope=scope, name=f"_{scope}_scoped_runner", ) def _scoped_runner( - event_loop_policy, + event_loop_policy: AbstractEventLoopPolicy, request: FixtureRequest ) -> Iterator[Runner]: new_loop_policy = event_loop_policy - with _temporary_event_loop_policy(new_loop_policy): - runner = Runner().__enter__() + + # We need to get the factory now because + # _temporary_event_loop_policy can override the Runner + factory = _get_loop_facotry(request) + with _temporary_event_loop_policy(new_loop_policy, factory): + runner = Runner(loop_factory=factory).__enter__() try: yield runner except Exception as e: diff --git a/tests/test_asyncio_mark.py b/tests/test_asyncio_mark.py index 094093c3..0e839bbc 100644 --- a/tests/test_asyncio_mark.py +++ b/tests/test_asyncio_mark.py @@ -249,6 +249,16 @@ class CustomEventLoop(asyncio.SelectorEventLoop): @pytest.mark.asyncio(loop_factory=CustomEventLoop) async def test_has_different_event_loop(): assert type(asyncio.get_running_loop()).__name__ == "CustomEventLoop" + + @pytest_asyncio.fixture(loop_factory=CustomEventLoop) + async def custom_fixture(): + yield asyncio.get_running_loop() + + async def test_with_fixture(custom_fixture): + # Both of these should be the same... + type(asyncio.get_running_loop()).__name__ == "CustomEventLoop" + type(custom_fixture).__name__ == "CustomEventLoop" + """ ) ) From 98a6f4e183e2a34801e9100c724c49219bb8f422 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:12:20 +0000 Subject: [PATCH 09/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pytest_asyncio/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 41cb520b..3f49d666 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -827,8 +827,8 @@ def _scoped_runner( request: FixtureRequest, ) -> Iterator[Runner]: new_loop_policy = event_loop_policy - - # We need to get the factory now because + + # We need to get the factory now because # _temporary_event_loop_policy can override the Runner factory = _get_loop_facotry(request) debug_mode = _get_asyncio_debug(request.config) From b2a4c893000ac18af4cce8126ece9cc557603b16 Mon Sep 17 00:00:00 2001 From: Vizonex Date: Mon, 28 Jul 2025 12:15:43 -0500 Subject: [PATCH 10/12] reformat errors and try and match the new one about loop_factory existing --- tests/markers/test_invalid_arguments.py | 6 ++++-- tests/modes/test_strict_mode.py | 5 +---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/markers/test_invalid_arguments.py b/tests/markers/test_invalid_arguments.py index a7e499a3..fc2c88f1 100644 --- a/tests/markers/test_invalid_arguments.py +++ b/tests/markers/test_invalid_arguments.py @@ -61,7 +61,8 @@ async def test_anything(): result.assert_outcomes(errors=1) result.stdout.fnmatch_lines( [ - "*ValueError: mark.asyncio accepts only keyword arguments 'loop_scope', 'loop_factory'*" + "*ValueError: mark.asyncio accepts only a keyword arguments " + "'loop_scope' or 'loop_factory'*" ] ) @@ -84,6 +85,7 @@ async def test_anything(): result.assert_outcomes(errors=1) result.stdout.fnmatch_lines( [ - "*ValueError: mark.asyncio accepts only keyword arguments 'loop_scope', 'loop_factory'*" + "*ValueError: mark.asyncio accepts only a keyword arguments " + "'loop_scope' or 'loop_factory'*" ] ) diff --git a/tests/modes/test_strict_mode.py b/tests/modes/test_strict_mode.py index 44f54b7d..0655fbdb 100644 --- a/tests/modes/test_strict_mode.py +++ b/tests/modes/test_strict_mode.py @@ -163,10 +163,7 @@ async def test_anything(any_fixture): result.stdout.fnmatch_lines( [ "*warnings summary*", - ( - "test_strict_mode_marked_test_unmarked_fixture_warning.py::" - "test_anything" - ), + ("test_strict_mode_marked_test_unmarked_fixture_warning.py::test_anything"), ( "*/pytest_asyncio/plugin.py:*: PytestDeprecationWarning: " "asyncio test 'test_anything' requested async " From d45d89c5256feeb1a9eb8a9e1a1a2bd3cc7f243a Mon Sep 17 00:00:00 2001 From: Vizonex Date: Mon, 28 Jul 2025 12:17:46 -0500 Subject: [PATCH 11/12] update changelog with more accurate information --- changelog.d/1164.added.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1164.added.rst b/changelog.d/1164.added.rst index e4cf9e53..f6e6612b 100644 --- a/changelog.d/1164.added.rst +++ b/changelog.d/1164.added.rst @@ -1 +1 @@ -Added ``new_event_loop`` fixture to provide an alternative approch to event loop policies being deprecated +Added ``loop_factory`` to pytest_asyncio.fixture and asyncio mark From dfa3a504b7b1072f5410d1d3178601fb2a1f5dab Mon Sep 17 00:00:00 2001 From: Vizonex Date: Mon, 28 Jul 2025 12:20:00 -0500 Subject: [PATCH 12/12] other commits outside of this fork screwed this up, let me fix that... --- pytest_asyncio/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 3f49d666..d0708c87 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -832,7 +832,7 @@ def _scoped_runner( # _temporary_event_loop_policy can override the Runner factory = _get_loop_facotry(request) debug_mode = _get_asyncio_debug(request.config) - with _temporary_event_loop_policy(new_loop_policy): + with _temporary_event_loop_policy(new_loop_policy, factory): runner = Runner(debug=debug_mode, loop_factory=factory).__enter__() try: