Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion changelog.d/1164.added.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Added the ``pytest_asyncio_loop_factories`` hook to parametrize asyncio tests with custom event loop factories.

The hook now returns a mapping of factory names to loop factories, and ``pytest.mark.asyncio(loop_factories=[...])`` can be used to select a subset of configured factories per test.
The hook returns a mapping of factory names to loop factories, and ``pytest.mark.asyncio(loop_factories=[...])`` selects a subset of configured factories per test. When a single factory is configured, test names are unchanged on pytest 8.4+.

Synchronous ``@pytest_asyncio.fixture`` functions now see the correct event loop when custom loop factories are configured, even when test code disrupts the current event loop (e.g., via ``asyncio.run()`` or ``asyncio.set_event_loop(None)``).
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ To run a test with only a subset of configured factories, use the ``loop_factori
@pytest.mark.asyncio(loop_factories=["custom"])
async def test_only_with_custom_event_loop():
pass
If a requested factory name is not available from the hook, the test variant for that factory is skipped.
2 changes: 1 addition & 1 deletion docs/reference/markers/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Subpackages do not share the loop with their parent package.

Tests marked with *session* scope share the same event loop, even if the tests exist in different packages.

The ``pytest.mark.asyncio`` marker also accepts a ``loop_factories`` keyword argument to select a subset of configured event loop factories for a test. If ``loop_factories`` contains unknown names, pytest-asyncio raises a ``pytest.UsageError`` during collection.
The ``pytest.mark.asyncio`` marker also accepts a ``loop_factories`` keyword argument to select a subset of configured event loop factories for a test. If ``loop_factories`` contains names not available from the hook, those test variants are skipped.

.. |auto mode| replace:: *auto mode*
.. _auto mode: ../../concepts.html#auto-mode
Expand Down
133 changes: 113 additions & 20 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
AsyncIterator,
Awaitable,
Callable,
Collection,
Generator,
Iterable,
Iterator,
Expand Down Expand Up @@ -328,8 +329,50 @@ def _fixture_synchronizer(
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) # type: ignore[arg-type]
elif inspect.isgeneratorfunction(fixturedef.func):
return _wrap_syncgen_fixture(fixture_function, runner) # type: ignore[arg-type]
else:
return fixturedef.func
return _wrap_sync_fixture(fixture_function, runner) # type: ignore[arg-type]


SyncGenFixtureParams = ParamSpec("SyncGenFixtureParams")
SyncGenFixtureYieldType = TypeVar("SyncGenFixtureYieldType")


def _wrap_syncgen_fixture(
fixture_function: Callable[
SyncGenFixtureParams, Generator[SyncGenFixtureYieldType]
],
runner: Runner,
) -> Callable[SyncGenFixtureParams, Generator[SyncGenFixtureYieldType]]:
@functools.wraps(fixture_function)
def _syncgen_fixture_wrapper(
*args: SyncGenFixtureParams.args,
**kwargs: SyncGenFixtureParams.kwargs,
) -> Generator[SyncGenFixtureYieldType]:
with _temporary_event_loop(runner.get_loop()):
yield from fixture_function(*args, **kwargs)

return _syncgen_fixture_wrapper


SyncFixtureParams = ParamSpec("SyncFixtureParams")
SyncFixtureReturnType = TypeVar("SyncFixtureReturnType")


def _wrap_sync_fixture(
fixture_function: Callable[SyncFixtureParams, SyncFixtureReturnType],
runner: Runner,
) -> Callable[SyncFixtureParams, SyncFixtureReturnType]:
@functools.wraps(fixture_function)
def _sync_fixture_wrapper(
*args: SyncFixtureParams.args,
**kwargs: SyncFixtureParams.kwargs,
) -> SyncFixtureReturnType:
with _temporary_event_loop(runner.get_loop()):
return fixture_function(*args, **kwargs)

return _sync_fixture_wrapper


AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams")
Expand Down Expand Up @@ -500,6 +543,12 @@ def setup(self) -> None:
runner_fixture_id = f"_{self._loop_scope}_scoped_runner"
if runner_fixture_id not in self.fixturenames:
self.fixturenames.append(runner_fixture_id)
# When loop factories are configured, resolve the loop factory
# fixture early so that a factory variant change cascades cache
# invalidation before any async fixture checks its cache.
hook_caller = self.config.hook.pytest_asyncio_loop_factories
if hook_caller.get_hookimpls():
_ = self._request.getfixturevalue(_asyncio_loop_factory.__name__)
return super().setup()

def runtest(self) -> None:
Expand Down Expand Up @@ -692,42 +741,76 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
)
return

factory_params: Collection[object]
factory_ids: Collection[str]
if marker_selected_factory_names is None:
effective_factories = hook_factories
factory_params = hook_factories.values()
factory_ids = hook_factories.keys()
else:
missing_factory_names = tuple(
name for name in marker_selected_factory_names if name not in hook_factories
)
if missing_factory_names:
msg = (
f"Unknown factory name(s) {missing_factory_names}."
f" Available names: {', '.join(hook_factories)}."
# Iterate in marker order to preserve explicit user selection
# order.
factory_ids = marker_selected_factory_names
factory_params = [
(
hook_factories[name]
if name in hook_factories
else pytest.param(
None,
marks=pytest.mark.skip(
reason=(
f"Loop factory {name!r} is not available."
f" Available factories:"
f" {', '.join(hook_factories)}."
),
),
)
)
raise pytest.UsageError(msg)
# Build the mapping in marker order to preserve explicit user
# selection order in parametrization.
effective_factories = {
name: hook_factories[name] for name in marker_selected_factory_names
}
for name in marker_selected_factory_names
]
metafunc.fixturenames.append(_asyncio_loop_factory.__name__)
default_loop_scope = _get_default_test_loop_scope(metafunc.config)
loop_scope = marker_loop_scope or default_loop_scope
# pytest.HIDDEN_PARAM was added in pytest 8.4
hide_id = len(factory_ids) == 1 and hasattr(pytest, "HIDDEN_PARAM")
metafunc.parametrize(
_asyncio_loop_factory.__name__,
effective_factories.values(),
ids=effective_factories.keys(),
factory_params,
ids=(pytest.HIDDEN_PARAM,) if hide_id else factory_ids,
indirect=True,
scope=loop_scope,
)


@contextlib.contextmanager
def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]:
old_loop_policy = _get_event_loop_policy()
def _temporary_event_loop(loop: AbstractEventLoop) -> Iterator[None]:
try:
old_loop = _get_event_loop_no_warn()
except RuntimeError:
old_loop = None
if old_loop is loop:
yield
return
_set_event_loop(loop)
try:
yield
finally:
_set_event_loop(old_loop)


@contextlib.contextmanager
def _temporary_event_loop_policy(
policy: AbstractEventLoopPolicy,
*,
has_custom_factory: bool,
) -> Iterator[None]:
old_loop_policy = _get_event_loop_policy()
if has_custom_factory:
old_loop = None
else:
try:
old_loop = _get_event_loop_no_warn()
except RuntimeError:
old_loop = None
_set_event_loop_policy(policy)
try:
yield
Expand Down Expand Up @@ -846,6 +929,11 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None:
)
runner_fixture_id = f"_{loop_scope}_scoped_runner"
runner = request.getfixturevalue(runner_fixture_id)
# Prevent the runner closing before the fixture's async teardown.
runner_fixturedef = request._get_active_fixturedef(runner_fixture_id)
runner_fixturedef.addfinalizer(
functools.partial(fixturedef.finish, request=request)
)
synchronizer = _fixture_synchronizer(fixturedef, runner, request)
_make_asyncio_fixture_function(synchronizer, loop_scope)
with MonkeyPatch.context() as c:
Expand Down Expand Up @@ -935,11 +1023,16 @@ def _scoped_runner(
) -> Iterator[Runner]:
new_loop_policy = event_loop_policy
debug_mode = _get_asyncio_debug(request.config)
with _temporary_event_loop_policy(new_loop_policy):
with _temporary_event_loop_policy(
new_loop_policy,
has_custom_factory=_asyncio_loop_factory is not None,
):
runner = Runner(
debug=debug_mode,
loop_factory=_asyncio_loop_factory,
).__enter__()
if _asyncio_loop_factory is not None:
_set_event_loop(runner.get_loop())
try:
yield runner
except Exception as e:
Expand Down
Loading
Loading