pytest-dev/pytest-asyncio

0.25.1 regression Python 3.9 asyncio_mode="auto" # RuntimeError: There is no current event loop in thread 'MainThread'

dimaqq opened this issue · 6 comments

Happy new year.

0.25.1 was released recently and somehow it fails on Python 3.9 specifically.

In my case, asyncio_mode = "auto" is used, so maybe similar to #658, however I don't know if that's required for the regression.

Meanwhile, Python 3.8 is OK (0.25.x can't be used, 0.24 is used instead)
And Python 3.10, 3.11, 3.12, 3.13 are OK (with the new version)

Version resolution:

    { name = "pytest-asyncio", version = "0.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
    { name = "pytest-asyncio", version = "0.25.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },

Run log: https://github.com/juju/python-libjuju/actions/runs/12423507633

Screenshot for the above:
Screenshot 2025-01-07 at 14 43 51

I've manually validated that 0.25.0 works fine in my setup.

Log, if helps any:

self = <tests.unit.test_model.TestModelState testMethod=test_apply_delta>

    def test_apply_delta(self):
>       model = Model()

tests/unit/test_model.py:72:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
juju/model/__init__.py:677: in __init__
    self._watch_stopping = asyncio.Event()
/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/asyncio/locks.py:177: in __init__
    self._loop = events.get_event_loop()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <asyncio.unix_events._UnixDefaultEventLoopPolicy object at 0x105a521f0>

    def get_event_loop(self):
        """Get the event loop for the current context.

        Returns an instance of EventLoop or raises an exception.
        """
        if (self._local._loop is None and
                not self._local._set_called and
                threading.current_thread() is threading.main_thread()):
            self.set_event_loop(self.new_event_loop())

        if self._local._loop is None:
>           raise RuntimeError('There is no current event loop in thread %r.'
                               % threading.current_thread().name)
E           RuntimeError: There is no current event loop in thread 'MainThread'.

My config, since the most likely cause is the change that fixed interleaved loop scopes:

[tool.pytest.ini_options]
addopts = "-ra -v"
asyncio_default_fixture_loop_scope = "function"
asyncio_mode = "auto"
filterwarnings = "ignore::DeprecationWarning:websockets"
markers = [
    "serial: mark a test that must run by itself",
    "wait_for_idle: mark a test that waits for the model to be idle",
    "bundle: mark a test that uses a bundle",
]

I don't have custom loop_scope's in the tests, only this config.

I do, however, have a mix of modern pytest tests (module-level test_foo(): ...) and older class-based tests class TestFoo(unittest.TestCase): def test_foo(self): ...)

Easy reproducer:

git clone git@github.com:juju/python-libjuju.git
cd python-libjuju
# currently at a58645e
uvx -p 3.9 tox -e unit

Thanks for the detailed report!
The failing test is a synchronous test. Maybe #1029 introduced a regression here.

Yes the test is synchronous, however it instantiates some code which in turn creates an asyncio.Event().

Also have received a similar traceback for this test definition:

@pytest.mark.asyncio()
async def test_global_retry_config_disable_async():
    s = SDK(retry_config=None)

    with pytest.raises(
        errors.APIError, match="API error occurred: Status 503"
    ) as exc_info:
        await s.retries.retries_get_async(request_id=str(uuid.uuid4()), num_retries=2)

    assert exc_info.value.status_code == 503
  [gw0] linux -- Python 3.9.21 /home/runner/work/xxx/.venv/bin/python
  
  self = <Coroutine test_global_retry_config_disable_async>
  
      def runtest(self) -> None:
          self.obj = wrap_in_sync(
              # https://github.com/pytest-dev/pytest-asyncio/issues/596
              self.obj,  # type: ignore[has-type]
          )
  >       super().runtest()
  
  .venv/lib/python3.9/site-packages/pytest_asyncio/plugin.py:533: 
  _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
  .venv/lib/python3.9/site-packages/pytest_asyncio/plugin.py:1049: in inner
      _loop = _get_event_loop_no_warn()
  .venv/lib/python3.9/site-packages/pytest_asyncio/plugin.py:979: in _get_event_loop_no_warn
      return asyncio.get_event_loop()
  _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
  
  self = <asyncio.unix_events._UnixDefaultEventLoopPolicy object at 0x7fd55aef8ee0>
  
      def get_event_loop(self):
          """Get the event loop for the current context.
      
          Returns an instance of EventLoop or raises an exception.
          """
          if (self._local._loop is None and
                  not self._local._set_called and
                  threading.current_thread() is threading.main_thread()):
              self.set_event_loop(self.new_event_loop())
      
          if self._local._loop is None:
  >           raise RuntimeError('There is no current event loop in thread %r.'
                                 % threading.current_thread().name)
  E           RuntimeError: There is no current event loop in thread 'MainThread'.
  
  /opt/hostedtoolcache/Python/3.9.21/x64/lib/python3.9/asyncio/events.py:642: RuntimeError

I found some time to dig into this. Let me outline my findings.

As @dimaqq already mentioned, the failing test calls asyncio.Event() and only fails on Python 3.9. The implementation of the Event constructor in CPython 3.9 calls asyncio.get_event_loop() which is also seen in the stacktrace. In CPython 3.9 this function behaves differently depending on whether asyncio.set_event_loop() has been called or not: If set_event_loop has never been called before, get_event_loop will return a fresh loop. However, if set_event_loop has already been called on the current event loop policy, get_event_loop will raise the reported RuntimeError (see the docs). Later implementations of asyncio.Event() from CPython 3.10 onwards no longer call asyncio.get_event_loop(), which is why they don't produce the same error.

Indeed, running the failing test "test_apply_delta" on its own causes the test to run successfully, whereas running the full test suite makes the test fail when pytest-asyncio v0.25.1 is installed.

Therefore, I incrementally disabled tests from the test suite and found that pytest -k "test_controller or test_apply_delta" causes test_apply_delta to fail. When I inspected the test fixtures with pytest's --setup-show option, I saw that none of the tests use pytest-asyncio fixtures. The tests in the test_controller module use unittest.IsolatedAsyncioTestCase, which happens to call asyncio.set_event_loop() during the test run. This explains why the subsequent call to asyncio.Event() fails during test_apply_delta.

Most importantly, the tests produce the same error even when pytest-asyncio is disabled with -p no:asyncio:

$ tox -e unit
unit: commands[0]> pytest -x --setup-show -k 'test_controller or test_apply_delta' -p no:asyncio /tmp/python-libjuju/tests/unit
===== test session starts =====
platform linux -- Python 3.9.21, pytest-8.3.4, pluggy-1.5.0 -- /tmp/python-libjuju/.tox/py3/bin/python
cachedir: .tox/py3/.pytest_cache
rootdir: /tmp/python-libjuju
configfile: pyproject.toml
collected 260 items / 250 deselected / 10 selected                                                                                                                                                                    

tests/unit/test_controller.py::TestControllerConnect::test_file_cred_v2 
      SETUP    C _unittest_setUpClass_fixture_TestControllerConnect
        tests/unit/test_controller.py::TestControllerConnect::test_file_cred_v2 (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
tests/unit/test_controller.py::TestControllerConnect::test_file_cred_v3 
        tests/unit/test_controller.py::TestControllerConnect::test_file_cred_v3 (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
tests/unit/test_controller.py::TestControllerConnect::test_no_args 
        tests/unit/test_controller.py::TestControllerConnect::test_no_args (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
tests/unit/test_controller.py::TestControllerConnect::test_with_controller_name 
        tests/unit/test_controller.py::TestControllerConnect::test_with_controller_name (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
tests/unit/test_controller.py::TestControllerConnect::test_with_endpoint_and_bakery_client 
        tests/unit/test_controller.py::TestControllerConnect::test_with_endpoint_and_bakery_client (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
tests/unit/test_controller.py::TestControllerConnect::test_with_endpoint_and_macaroons 
        tests/unit/test_controller.py::TestControllerConnect::test_with_endpoint_and_macaroons (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
tests/unit/test_controller.py::TestControllerConnect::test_with_endpoint_and_no_auth 
        tests/unit/test_controller.py::TestControllerConnect::test_with_endpoint_and_no_auth (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
tests/unit/test_controller.py::TestControllerConnect::test_with_endpoint_and_userpass 
        tests/unit/test_controller.py::TestControllerConnect::test_with_endpoint_and_userpass (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
tests/unit/test_controller.py::TestControllerConnect::test_with_posargs 
        tests/unit/test_controller.py::TestControllerConnect::test_with_posargs (fixtures used: _unittest_setUpClass_fixture_TestControllerConnect, request)PASSED
      TEARDOWN C _unittest_setUpClass_fixture_TestControllerConnect
tests/unit/test_model.py::TestModelState::test_apply_delta 
      SETUP    C _unittest_setUpClass_fixture_TestModelState
        tests/unit/test_model.py::TestModelState::test_apply_delta (fixtures used: _unittest_setUpClass_fixture_TestModelState, request)FAILED
      TEARDOWN C _unittest_setUpClass_fixture_TestModelState

===== FAILURES =====
_____ TestModelState.test_apply_delta _____

self = <tests.unit.test_model.TestModelState testMethod=test_apply_delta>

    def test_apply_delta(self):
>       model = Model()

tests/unit/test_model.py:72: 
_ _ _
juju/model/__init__.py:677: in __init__
    self._watch_stopping = asyncio.Event()
/usr/lib/python3.9/asyncio/locks.py:177: in __init__
    self._loop = events.get_event_loop()
_ _ _

self = <asyncio.unix_events._UnixDefaultEventLoopPolicy object at 0x7f00fa9d7fa0>

    def get_event_loop(self):
        """Get the event loop for the current context.
    
        Returns an instance of EventLoop or raises an exception.
        """
        if (self._local._loop is None and
                not self._local._set_called and
                threading.current_thread() is threading.main_thread()):
            self.set_event_loop(self.new_event_loop())
    
        if self._local._loop is None:
>           raise RuntimeError('There is no current event loop in thread %r.'
                               % threading.current_thread().name)
E           RuntimeError: There is no current event loop in thread 'MainThread'.

/usr/lib/python3.9/asyncio/events.py:642: RuntimeError
===== warnings summary =====
.tox/py3/lib/python3.9/site-packages/_pytest/config/__init__.py:1441
  /tmp/python-libjuju/.tox/py3/lib/python3.9/site-packages/_pytest/config/__init__.py:1441: PytestConfigWarning: Unknown config option: asyncio_default_fixture_loop_scope
  
    self._warn_or_fail_if_strict(f"Unknown config option: {key}\n")

.tox/py3/lib/python3.9/site-packages/_pytest/config/__init__.py:1441
  /tmp/python-libjuju/.tox/py3/lib/python3.9/site-packages/_pytest/config/__init__.py:1441: PytestConfigWarning: Unknown config option: asyncio_mode
  
    self._warn_or_fail_if_strict(f"Unknown config option: {key}\n")

juju/client/connection.py:31
  /tmp/python-libjuju/juju/client/connection.py:31: DeprecationWarning: websockets.WebSocketClientProtocol is deprecated
    _WebSocket: TypeAlias = websockets.WebSocketClientProtocol

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
===== short test summary info =====
FAILED tests/unit/test_model.py::TestModelState::test_apply_delta - RuntimeError: There is no current event loop in thread 'MainThread'.
!!!!! stopping after 1 failures !!!!!
===== 1 failed, 9 passed, 250 deselected, 3 warnings in 0.66s =====
unit: exit 1 (0.90 seconds) /tmp/python-libjuju> pytest -x --setup-show -k 'test_controller or test_apply_delta' -p no:asyncio /tmp/python-libjuju/tests/unit pid=25775
  unit: FAIL code 1 (0.94=setup[0.04]+cmd[0.90] seconds)
  evaluation failed :( (1.02 seconds)
(

Therefore, I conclude that this is not a pytest-asyncio bug. The issue in the test suite was simply overshadowed by bad behavior of pytest-asyncio in earlier versions.

Changing line 70 of tests/unit/test_model.py from class TestModelState(unittest.TestCase): to class TestModelState(unittest.IsolatedAsyncioTestCase): fixes the issue on Python 3.9.

@bflad Your issue is likely a different one. The best way to solve it is to provide a minimal reproducible example in a separate issue.