pytest-dev/pytest-asyncio

Missing warning about unconfigured `asyncio_default_fixture_loop_scope`

Opened this issue · 2 comments

Hi. First of all, thanks for the library — it is really a timesaver! ;-)

Now, to the point: I maintain a fancy library looptime that compacts the loop time into zero true (aka wall-clock) time, while simulating the time flow within the loop (be that seconds or years of loop time in 0.1s of true time). Previously (pytest-asyncio<1.0.0), every test had its own event loop and lived in its own universe from the event loop creation (say, time 0). Now (>=1.0.0), the event loop can be shared across many tests at different scopes, also separated by modules/classes/packages. I am trying to adapt my library to this behaviour.

I have found a somewhat confusing case which is easy to hit — see a repro here.

The key point is that a regular fixture fixt is function-scoped by default, and is intended to be so. The rest of the code is basically an example from pytest-asyncio's docs. It fails in test B because the usage of the fixt fixture overrides the current event loop to be function-scoped, despite the code declares the intention to use the session-scoped event loop.

No warnings are issued, no errors raised, it just silently falls to another event loop — it would be difficult to catch such issues in a big codebase if at least one of many fixtures is defined in the wrong scope.

When fixt is removed from test B, the sample works. When fixt is made session-scoped explicitly, it also works.

Can you please clarify what is the intended outcome in this code below? Should it work?

I also wonder why was it made so that every collector has its own event loop? The most straightforward way at the first glance seems to be to always provide the event_loop fixture as before, always function-scoped, which internally either creates a new event loop or escalates to the higher-level _class_event_loop, _module_event_loop, … _session_event_loop — if the test is marked with some higher scope. But maybe I miss some edge-cases or scenarios that prevent such usage?

I would really appreciate it, so that I could adjust my code properly long-term and without hacks.

import asyncio

import pytest
import pytest_asyncio

loop: asyncio.AbstractEventLoop


@pytest_asyncio.fixture  # implies: scope=function    # ❌FAILS
# @pytest_asyncio.fixture(scope='session')              # ✅WORKS
async def fixt() -> None:
    yield


@pytest.mark.asyncio(loop_scope="session")  # uses `_session_event_loop`
async def test_a():
    global loop
    loop = asyncio.get_running_loop()
    print(f"TST {id(asyncio.get_running_loop())=}")


@pytest.mark.asyncio(loop_scope="session")  # uses `_session_event_loop` or `_function_event_loop`
async def test_b(fixt):
    print(f"TST {id(asyncio.get_running_loop())=}")
    assert asyncio.get_running_loop() is loop

Can you please clarify what is the intended outcome in this code below? Should it work?

The pytest_asyncio.fixture decorator has two different kinds of scopes: pytest's caching scope which defaults to scope=function and loop_scope which determines the event loop to be used.

When the value of loop_scope is omitted, it defaults to the asyncio_default_fixture_loop_scope configuration setting. When this value is unset, the loop_scope defaults to the fixture's caching scope for backwards compatibility reasons. (see #924)

I assume that the asyncio_default_fixture_loop_scope config value is unset in your example. A warning should be emitted in this case, so this is actually a bug.

I also wonder why was it made so that every collector has its own event loop? The most straightforward way at the first glance seems to be to always provide the event_loop fixture as before, always function-scoped, which internally either creates a new event loop or escalates to the higher-level _class_event_loop, _module_event_loop, … _session_event_loop — if the test is marked with some higher scope. But maybe I miss some edge-cases or scenarios that prevent such usage?

The driver for removing the event_loop fixture is that users started to override the fixture and put a lot of logic into it. This made it extremely hard for pytest-asyncio to change its code. Please refer to #670 (comment) for a longer write up on this issue.

In practice users could have more than one event_loop fixture in the same test suite, for example the default fixture that creates a new asyncio loop for every function under test, and a second fixture with larger scope where they start a web server or database connection and run tests against it. Both seem like valid use cases for software tests, so we needed a way to have loops with multiple scopes.

The event_loop fixture was deprecated in v0.23 and the scope of the event loop was initially tied to the scope of the fixture. However, it turned out in #706 that there need to be separate controls for the evaluation of the fixture itself (caching scope) and the event loop used to run the fixture (loop_scope).

I'm happy to provide more insight if needed, but that's the gist of it.

Yes, it is now clear. And all pieces have "clicked" into a bigger picture now. Thanks!

I'm not closing this since you said that the absence of the warning/error might be a bug. I did check running as pytest -ra -W default _repro.py — indeed no warnings/errors, just silence. So, it is up to you to decide what to do with this ticket, and is it worth fixing ("it" — the hidden change of the event loop scope contrary to the declaration of intentions in the markers). The asyncio_default_fixture_loop_scope was not set, there were no configs at all, just the default setup.