Skip to content

Commit d3c835d

Browse files
committed
Implement event loop factory hook
1 parent dbacf7b commit d3c835d

File tree

6 files changed

+535
-3
lines changed

6 files changed

+535
-3
lines changed

changelog.d/1164.added.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added the ``pytest_asyncio_loop_factories`` hook to parametrize asyncio tests with custom event loop factories.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
==================================================
2+
How to use custom event loop factories for tests
3+
==================================================
4+
5+
pytest-asyncio can run asynchronous tests with custom event loop factories by defining a ``pytest_asyncio_loop_factories`` hook in ``conftest.py``. The hook returns the factories to use for the current test item:
6+
7+
.. code-block:: python
8+
9+
import asyncio
10+
11+
import pytest
12+
13+
14+
class CustomEventLoop(asyncio.SelectorEventLoop):
15+
pass
16+
17+
18+
def pytest_asyncio_loop_factories(config, item):
19+
return [CustomEventLoop]
20+
21+
When multiple factories are returned, each asynchronous test is run once per factory. Synchronous tests are not parametrized. The configured loop scope still determines how long each event loop instance is kept alive.
22+
23+
Factories should be callables without required parameters and should return an ``asyncio.AbstractEventLoop`` instance. The hook must return a non-empty sequence for every asyncio test.
24+
25+
To select different factories for specific tests, you can inspect ``item``:
26+
27+
.. code-block:: python
28+
29+
import asyncio
30+
31+
import uvloop
32+
33+
34+
def pytest_asyncio_loop_factories(config, item):
35+
if item.get_closest_marker("uvloop"):
36+
return [uvloop.new_event_loop]
37+
else:
38+
return [asyncio.new_event_loop]
39+
40+
Factory selection can vary per test item, regardless of loop scope. In other words, with ``module``/``package``/``session`` loop scopes you can still choose different factories for different tests by inspecting ``item``.
41+
42+
.. note::
43+
44+
When the hook is defined, async tests are parametrized, so factory names are appended to test IDs. For example, a test ``test_example`` with factory ``CustomEventLoop`` will appear as ``test_example[CustomEventLoop]`` in the test output.

docs/how-to-guides/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ How-To Guides
1010
change_fixture_loop
1111
change_default_fixture_loop
1212
change_default_test_loop
13+
custom_loop_factory
1314
run_class_tests_in_same_loop
1415
run_module_tests_in_same_loop
1516
run_package_tests_in_same_loop

docs/how-to-guides/uvloop.rst

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,29 @@
22
How to test with uvloop
33
=======================
44

5-
Redefining the *event_loop_policy* fixture will parametrize all async tests. The following example causes all async tests to run multiple times, once for each event loop in the fixture parameters:
6-
Replace the default event loop policy in your *conftest.py:*
5+
Define a ``pytest_asyncio_loop_factories`` hook in your *conftest.py* that returns ``uvloop.new_event_loop`` as a loop factory:
6+
7+
.. code-block:: python
8+
9+
import uvloop
10+
11+
12+
def pytest_asyncio_loop_factories(config, item):
13+
return [uvloop.new_event_loop]
14+
15+
.. seealso::
16+
17+
:doc:`custom_loop_factory`
18+
More details on the ``pytest_asyncio_loop_factories`` hook, including per-test factory selection and multiple factory parametrization.
19+
20+
Using the event_loop_policy fixture
21+
------------------------------------
22+
23+
.. note::
24+
25+
``asyncio.AbstractEventLoopPolicy`` is deprecated as of Python 3.14 (removal planned for 3.16), and ``uvloop.EventLoopPolicy`` will be removed alongside it. Prefer the hook approach above.
26+
27+
For older versions of Python and uvloop, you can override the *event_loop_policy* fixture in your *conftest.py:*
728

829
.. code-block:: python
930

pytest_asyncio/plugin.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
Any,
2828
Literal,
2929
ParamSpec,
30+
TypeAlias,
3031
TypeVar,
3132
overload,
3233
)
@@ -63,6 +64,7 @@
6364
_R = TypeVar("_R", bound=Awaitable[Any] | AsyncIterator[Any])
6465
_P = ParamSpec("_P")
6566
FixtureFunction = Callable[_P, _R]
67+
LoopFactory: TypeAlias = Callable[[], AbstractEventLoop]
6668

6769

6870
class PytestAsyncioError(Exception):
@@ -74,6 +76,19 @@ class Mode(str, enum.Enum):
7476
STRICT = "strict"
7577

7678

79+
hookspec = pluggy.HookspecMarker("pytest")
80+
81+
82+
class PytestAsyncioSpecs:
83+
@hookspec
84+
def pytest_asyncio_loop_factories(
85+
self,
86+
config: Config,
87+
item: Item,
88+
) -> Iterable[LoopFactory]:
89+
raise NotImplementedError # pragma: no cover
90+
91+
7792
ASYNCIO_MODE_HELP = """\
7893
'auto' - for automatically handling all async functions by the plugin
7994
'strict' - for autoprocessing disabling (useful if different async frameworks \
@@ -83,6 +98,7 @@ class Mode(str, enum.Enum):
8398

8499

85100
def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None:
101+
pluginmanager.add_hookspecs(PytestAsyncioSpecs)
86102
group = parser.getgroup("asyncio")
87103
group.addoption(
88104
"--asyncio-mode",
@@ -219,6 +235,45 @@ def _get_asyncio_debug(config: Config) -> bool:
219235
return val == "true"
220236

221237

238+
def _collect_hook_loop_factories(
239+
config: Config,
240+
item: Item,
241+
) -> tuple[LoopFactory, ...] | None:
242+
hook_caller = config.hook.pytest_asyncio_loop_factories
243+
hook_impls = hook_caller.get_hookimpls()
244+
if not hook_impls:
245+
return None
246+
if len(hook_impls) > 1:
247+
msg = (
248+
"Multiple pytest_asyncio_loop_factories implementations found; please"
249+
" provide a single hook implementation."
250+
)
251+
raise pytest.UsageError(msg)
252+
253+
results: list[Iterable[LoopFactory] | None] = hook_caller(config=config, item=item)
254+
msg = "pytest_asyncio_loop_factories must return a non-empty sequence of callables."
255+
if not results:
256+
raise pytest.UsageError(msg)
257+
result = results[0]
258+
if result is None or not isinstance(result, Sequence):
259+
raise pytest.UsageError(msg)
260+
# Copy into an immutable snapshot so later mutations of the hook's
261+
# original container do not affect stash state or parametrization.
262+
factories = tuple(result)
263+
if not factories or any(not callable(factory) for factory in factories):
264+
raise pytest.UsageError(msg)
265+
return factories
266+
267+
268+
def _get_item_loop_scope(item: Item, config: Config) -> _ScopeName:
269+
marker = item.get_closest_marker("asyncio")
270+
default_loop_scope = _get_default_test_loop_scope(config)
271+
if marker is None:
272+
return default_loop_scope
273+
else:
274+
return _get_marked_loop_scope(marker, default_loop_scope)
275+
276+
222277
_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET = """\
223278
The configuration option "asyncio_default_fixture_loop_scope" is unset.
224279
The event loop scope for asynchronous fixtures will default to the "fixture" caching \
@@ -611,6 +666,27 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
611666
hook_result.force_result(updated_node_collection)
612667

613668

669+
@pytest.hookimpl(tryfirst=True)
670+
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
671+
if _get_asyncio_mode(
672+
metafunc.config
673+
) == Mode.STRICT and not metafunc.definition.get_closest_marker("asyncio"):
674+
return
675+
if PytestAsyncioFunction.item_subclass_for(metafunc.definition) is None:
676+
return
677+
hook_factories = _collect_hook_loop_factories(metafunc.config, metafunc.definition)
678+
if hook_factories is None:
679+
return
680+
metafunc.fixturenames.append("asyncio_loop_factory")
681+
loop_scope = _get_item_loop_scope(metafunc.definition, metafunc.config)
682+
metafunc.parametrize(
683+
"asyncio_loop_factory",
684+
hook_factories,
685+
indirect=True,
686+
scope=loop_scope,
687+
)
688+
689+
614690
@contextlib.contextmanager
615691
def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]:
616692
old_loop_policy = _get_event_loop_policy()
@@ -798,12 +874,16 @@ def _create_scoped_runner_fixture(scope: _ScopeName) -> Callable:
798874
)
799875
def _scoped_runner(
800876
event_loop_policy,
877+
asyncio_loop_factory,
801878
request: FixtureRequest,
802879
) -> Iterator[Runner]:
803880
new_loop_policy = event_loop_policy
804881
debug_mode = _get_asyncio_debug(request.config)
805882
with _temporary_event_loop_policy(new_loop_policy):
806-
runner = Runner(debug=debug_mode).__enter__()
883+
runner = Runner(
884+
debug=debug_mode,
885+
loop_factory=asyncio_loop_factory,
886+
).__enter__()
807887
try:
808888
yield runner
809889
except Exception as e:
@@ -830,6 +910,11 @@ def _scoped_runner(
830910
)
831911

832912

913+
@pytest.fixture(scope="session")
914+
def asyncio_loop_factory(request: FixtureRequest) -> LoopFactory | None:
915+
return getattr(request, "param", None)
916+
917+
833918
@pytest.fixture(scope="session", autouse=True)
834919
def event_loop_policy() -> AbstractEventLoopPolicy:
835920
"""Return an instance of the policy used to create asyncio event loops."""

0 commit comments

Comments
 (0)