Repository: pytest-dev/pytest-asyncio

Issue:
Requesting loop_scope="module" (or "session" etc.) returns incorrect event_loop if another test function requests a different loop.
First of all, thanks for the great pytest plugin! 
Also, I like the idea of being able to specify event_loop scopes explicitly and separately to the fixture scopes. 

While playing with this, I kept running into issues that my "session" scoped event_loop seemed to change between tests that were running in different files, but only if certain other tests were run... 

I think this example reproduces the issues in a single file.  I recognize that the docs explicitly advise against doing this, but I hope it makes the problem easier to reason about.

Here's the example.

```python
import pytest_asyncio
import asyncio
import pytest


class FakeAsyncConnection:
    def __init__(self, loop):
        self.loop = loop

    async def do_something(self):
        # Check if the current loop is the same as the one with which the
        #  connection was created
        if asyncio.get_event_loop() is not self.loop:
            raise RuntimeError(
                "This connection is being used with a different event loop!")
        return "Success"


@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def async_connection():
    """Set up a async connection object with module scope."""
    event_loop = asyncio.get_event_loop()
    print(f"Setting up fixture: event_loop_id {id(event_loop)}")
    connection = FakeAsyncConnection(event_loop)
    yield connection


@pytest.mark.asyncio(loop_scope="module")
async def test_use_module_scope_loop_1(async_connection):
    """Use module loop"""
    print(f"Test using loop with id: {id(asyncio.get_event_loop())}")
    result = await async_connection.do_something()
    assert result == "Success"


@pytest.mark.asyncio(loop_scope="module")
async def test_use_module_scope_loop_2(async_connection):
    """Use module loop again"""
    print(f"Test using loop with id: {id(asyncio.get_event_loop())}")
    result = await async_connection.do_something()
    assert result == "Success"


@pytest.mark.asyncio(loop_scope="function")
async def test_use_function_scope_loop_1(async_connection):
    """Use function loop"""
    print(f"Test using loop with id: {id(asyncio.get_event_loop())}")
    with pytest.raises(RuntimeError, match="This connection is being used with a different event loop!"):
        # This should raise an error because the connection is being used with a different loop
        await async_connection.do_something()


@pytest.mark.asyncio(loop_scope="module")
async def test_use_module_scope_loop_3(async_connection):
    """Unexpectedly fail to use module scope again"""
    print(f"Test using loop with id: {id(asyncio.get_event_loop())}")
    result = await async_connection.do_something()
    assert result == "Success"
```

I would expect all tests to pass, however, the final test `test_use_module_scope_loop_3` fails **only** if the `test_use_function_scope_loop_1` is present. If the function scope one is commented out, the final test does pass (as expected).

The fixtures aren't obviously set up incorrectly (running with `--setup-show`):
```
SETUP    S event_loop_policy
    SETUP    M tests/test_a.py::<event_loop> (fixtures used: event_loop_policy)
    SETUP    M async_connection
        tests/test_a.py::test_use_module_scope_loop_1 (fixtures used: async_connection, event_loop_policy, request, tests/test_a.py::<event_loop>).
        tests/test_a.py::test_use_module_scope_loop_2 (fixtures used: async_connection, event_loop_policy, request, tests/test_a.py::<event_loop>).
        SETUP    F event_loop
        tests/test_a.py::test_use_function_scope_loop_1 (fixtures used: async_connection, event_loop, event_loop_policy, request).
        TEARDOWN F event_loop
        tests/test_a.py::test_use_module_scope_loop_3 (fixtures used: async_connection, event_loop_policy, request, tests/test_a.py::<event_loop>)F
    TEARDOWN M async_connection
    TEARDOWN M tests/test_a.py::<event_loop>
TEARDOWN S event_loop_policy
```

But for some reason I don't understand, the module scoped event loop changes for the last test.


The printed loop ids tell the same story... The fixture and first two tests all get the the same loop_id as expected. The function scope test gets a new one as expected. Then the final module scope test also gets a new loop_id (different from both previous loop_ids) unexpectedly. 

Versions:
python: 3.12.7
pytest: 8.3.3
pytest-asyncio: 0.24.0

Also, my pytest settings are:
```
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope="function"
```

Discussion:
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/pytest-dev/pytest-asyncio/pull/1029?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytest-dev) Report
Attention: Patch coverage is `90.32258%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 91.10%. Comparing base [(`581bd29`)](https://app.codecov.io/gh/pytest-dev/pytest-asyncio/commit/581bd29b83fd063dcf6df3f7eb35b99211f7141f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytest-dev) to head [(`7600594`)](https://app.codecov.io/gh/pytest-dev/pytest-asyncio/commit/7600594433bada0539742e7fa0bb4840a5499960?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytest-dev).
> Report is 4 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/pytest-dev/pytest-asyncio/pull/1029?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytest-dev) | Patch % | Lines |
|---|---|---|
| [pytest\_asyncio/plugin.py](https://app.codecov.io/gh/pytest-dev/pytest-asyncio/pull/1029?src=pr&el=tree&filepath=pytest_asyncio%2Fplugin.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytest-dev#diff-cHl0ZXN0X2FzeW5jaW8vcGx1Z2luLnB5) | 90.32% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pytest-dev/pytest-asyncio/pull/1029?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytest-dev) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1029      +/-   ##
==========================================
- Coverage   91.42%   91.10%   -0.33%     
==========================================
  Files           2        2              
  Lines         548      562      +14     
  Branches       72       74       +2     
==========================================
+ Hits          501      512      +11     
- Misses         28       30       +2     
- Partials       19       20       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pytest-dev/pytest-asyncio/pull/1029?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytest-dev).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pytest-dev).


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7924920..c79734c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -53,7 +53,7 @@ repos:
   hooks:
   - id: python-use-type-annotations
 - repo: https://github.com/rhysd/actionlint
-  rev: v1.7.4
+  rev: v1.7.5
   hooks:
   - id: actionlint-docker
     args:
diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst
index 0b302d5..e219b72 100644
--- a/docs/reference/changelog.rst
+++ b/docs/reference/changelog.rst
@@ -2,6 +2,11 @@
 Changelog
 =========
 
+0.25.1 (UNRELEASED)
+===================
+- Fixes an issue that caused a broken event loop when a function-scoped test was executed in between two tests with wider loop scope `#950 <https://github.com/pytest-dev/pytest-asyncio/issues/950>`_
+
+
 0.25.0 (2024-12-13)
 ===================
 - 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 <https://github.com/pytest-dev/pytest-asyncio/pull/979>`_


Test patch:
diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py
index 48e5327..2956f58 100644
--- a/pytest_asyncio/plugin.py
+++ b/pytest_asyncio/plugin.py
@@ -10,7 +10,7 @@ import functools
 import inspect
 import socket
 import warnings
-from asyncio import AbstractEventLoopPolicy
+from asyncio import AbstractEventLoop, AbstractEventLoopPolicy
 from collections.abc import (
     AsyncIterator,
     Awaitable,
@@ -709,8 +709,7 @@ def pytest_collectstart(collector: pytest.Collector) -> None:
     ) -> Iterator[asyncio.AbstractEventLoop]:
         new_loop_policy = event_loop_policy
         with _temporary_event_loop_policy(new_loop_policy):
-            loop = asyncio.new_event_loop()
-            loop.__pytest_asyncio = True  # type: ignore[attr-defined]
+            loop = _make_pytest_asyncio_loop(asyncio.new_event_loop())
             asyncio.set_event_loop(loop)
             yield loop
             loop.close()
@@ -762,6 +761,19 @@ def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[No
     try:
         yield
     finally:
+        # Try detecting user-created event loops that were left unclosed
+        # at the end of a test.
+        try:
+            current_loop: AbstractEventLoop | None = _get_event_loop_no_warn()
+        except RuntimeError:
+            current_loop = None
+        if current_loop is not None and not current_loop.is_closed():
+            warnings.warn(
+                _UNCLOSED_EVENT_LOOP_WARNING % current_loop,
+                DeprecationWarning,
+            )
+            current_loop.close()
+
         asyncio.set_event_loop_policy(old_loop_policy)
         # When a test uses both a scoped event loop and the event_loop fixture,
         # the "_provide_clean_event_loop" finalizer of the event_loop fixture
@@ -849,7 +861,7 @@ def pytest_fixture_setup(
         # Weird behavior was observed when checking for an attribute of FixtureDef.func
         # Instead, we now check for a special attribute of the returned event loop
         fixture_filename = inspect.getsourcefile(fixturedef.func)
-        if not getattr(loop, "__original_fixture_loop", False):
+        if not _is_pytest_asyncio_loop(loop):
             _, fixture_line_number = inspect.getsourcelines(fixturedef.func)
             warnings.warn(
                 _REDEFINED_EVENT_LOOP_FIXTURE_WARNING
@@ -859,8 +871,7 @@ def pytest_fixture_setup(
         policy = asyncio.get_event_loop_policy()
         try:
             old_loop = _get_event_loop_no_warn(policy)
-            is_pytest_asyncio_loop = getattr(old_loop, "__pytest_asyncio", False)
-            if old_loop is not loop and not is_pytest_asyncio_loop:
+            if old_loop is not loop and not _is_pytest_asyncio_loop(old_loop):
                 old_loop.close()
         except RuntimeError:
             # Either the current event loop has been set to None
@@ -873,6 +884,15 @@ def pytest_fixture_setup(
     yield
 
 
+def _make_pytest_asyncio_loop(loop: AbstractEventLoop) -> AbstractEventLoop:
+    loop.__pytest_asyncio = True  # type: ignore[attr-defined]
+    return loop
+
+
+def _is_pytest_asyncio_loop(loop: AbstractEventLoop) -> bool:
+    return getattr(loop, "__pytest_asyncio", False)
+
+
 def _add_finalizers(fixturedef: FixtureDef, *finalizers: Callable[[], object]) -> None:
     """
     Registers the specified fixture finalizers in the fixture.
@@ -906,7 +926,7 @@ def _close_event_loop() -> None:
         loop = policy.get_event_loop()
     except RuntimeError:
         loop = None
-    if loop is not None:
+    if loop is not None and not _is_pytest_asyncio_loop(loop):
         if not loop.is_closed():
             warnings.warn(
                 _UNCLOSED_EVENT_LOOP_WARNING % loop,
@@ -923,7 +943,7 @@ def _restore_event_loop_policy(previous_policy) -> Callable[[], None]:
             loop = _get_event_loop_no_warn(previous_policy)
         except RuntimeError:
             loop = None
-        if loop:
+        if loop and not _is_pytest_asyncio_loop(loop):
             loop.close()
         asyncio.set_event_loop_policy(previous_policy)
 
@@ -938,8 +958,13 @@ def _provide_clean_event_loop() -> None:
     # Note that we cannot set the loop to None, because get_event_loop only creates
     # a new loop, when set_event_loop has not been called.
     policy = asyncio.get_event_loop_policy()
-    new_loop = policy.new_event_loop()
-    policy.set_event_loop(new_loop)
+    try:
+        old_loop = _get_event_loop_no_warn(policy)
+    except RuntimeError:
+        old_loop = None
+    if old_loop is not None and not _is_pytest_asyncio_loop(old_loop):
+        new_loop = policy.new_event_loop()
+        policy.set_event_loop(new_loop)
 
 
 def _get_event_loop_no_warn(
@@ -1122,16 +1147,16 @@ def _retrieve_scope_root(item: Collector | Item, scope: str) -> Collector:
 def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]:
     """Create an instance of the default event loop for each test case."""
     new_loop_policy = request.getfixturevalue(event_loop_policy.__name__)
-    asyncio.set_event_loop_policy(new_loop_policy)
-    loop = asyncio.get_event_loop_policy().new_event_loop()
-    # Add a magic value to the event loop, so pytest-asyncio can determine if the
-    # event_loop fixture was overridden. Other implementations of event_loop don't
-    # set this value.
-    # 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
-    loop.close()
+    with _temporary_event_loop_policy(new_loop_policy):
+        loop = asyncio.get_event_loop_policy().new_event_loop()
+        # Add a magic value to the event loop, so pytest-asyncio can determine if the
+        # event_loop fixture was overridden. Other implementations of event_loop don't
+        # set this value.
+        # 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 = _make_pytest_asyncio_loop(loop)
+        yield loop
+        loop.close()
 
 
 @pytest.fixture(scope="session")
@@ -1140,8 +1165,7 @@ def _session_event_loop(
 ) -> Iterator[asyncio.AbstractEventLoop]:
     new_loop_policy = event_loop_policy
     with _temporary_event_loop_policy(new_loop_policy):
-        loop = asyncio.new_event_loop()
-        loop.__pytest_asyncio = True  # type: ignore[attr-defined]
+        loop = _make_pytest_asyncio_loop(asyncio.new_event_loop())
         asyncio.set_event_loop(loop)
         yield loop
         loop.close()
diff --git a/tests/markers/test_mixed_scope.py b/tests/markers/test_mixed_scope.py
new file mode 100644
index 0000000..40eaaa3
--- /dev/null
+++ b/tests/markers/test_mixed_scope.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from textwrap import dedent
+
+from pytest import Pytester
+
+
+def test_function_scoped_loop_restores_previous_loop_scope(pytester: Pytester):
+    pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
+    pytester.makepyfile(
+        dedent(
+            """\
+            import asyncio
+            import pytest
+
+
+            module_loop: asyncio.AbstractEventLoop
+
+            @pytest.mark.asyncio(loop_scope="module")
+            async def test_remember_loop():
+                global module_loop
+                module_loop = asyncio.get_running_loop()
+
+            @pytest.mark.asyncio(loop_scope="function")
+            async def test_with_function_scoped_loop():
+                pass
+
+            @pytest.mark.asyncio(loop_scope="module")
+            async def test_runs_in_same_loop():
+                global module_loop
+                assert asyncio.get_running_loop() is module_loop
+            """
+        )
+    )
+    result = pytester.runpytest("--asyncio-mode=strict")
+    result.assert_outcomes(passed=3)
diff --git a/tests/test_event_loop_fixture_finalizer.py b/tests/test_event_loop_fixture_finalizer.py
index 17cc85b..1e37864 100644
--- a/tests/test_event_loop_fixture_finalizer.py
+++ b/tests/test_event_loop_fixture_finalizer.py
@@ -14,7 +14,8 @@ def test_event_loop_fixture_finalizer_returns_fresh_loop_after_test(pytester: Py
 
             import pytest
 
-            loop = asyncio.get_event_loop_policy().get_event_loop()
+            loop = asyncio.new_event_loop()
+            asyncio.set_event_loop(loop)
 
             @pytest.mark.asyncio
             async def test_1():