From cea812dabddb439d7e44d687ea8c3e5eebb3aa3c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:18:53 +0200 Subject: [PATCH] Adapt Logic on Getting the Event Loop in `Application.run_polling/webhook` to Python 3.14 (#4875) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: harshil21 <37377066+harshil21@users.noreply.github.com> --- .gitignore | 5 +- .../4875.29srP6fENz7FrGGoWSkPNa.toml | 5 + src/telegram/ext/_application.py | 13 +- tests/ext/test_application.py | 122 ++++++++++++++---- 4 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 changes/unreleased/4875.29srP6fENz7FrGGoWSkPNa.toml diff --git a/.gitignore b/.gitignore index 2bd243a74..01c2cfda7 100644 --- a/.gitignore +++ b/.gitignore @@ -97,4 +97,7 @@ pyvenv.cfg Scripts/ # environment manager: -.mise.toml \ No newline at end of file +.mise.toml + +# Support for uv.lock will come in a future PR. See #4796 +uv.lock diff --git a/changes/unreleased/4875.29srP6fENz7FrGGoWSkPNa.toml b/changes/unreleased/4875.29srP6fENz7FrGGoWSkPNa.toml new file mode 100644 index 000000000..ed9df36be --- /dev/null +++ b/changes/unreleased/4875.29srP6fENz7FrGGoWSkPNa.toml @@ -0,0 +1,5 @@ +bugfixes = "Adapt logic on getting the event loop in ``Application.run_polling/webhook`` to Python 3.14" +[[pull_requests]] +uid = "4875" +author_uid = "harshil21" +closes_threads = ["4874"] diff --git a/src/telegram/ext/_application.py b/src/telegram/ext/_application.py index 3e5c5401d..62222f8da 100644 --- a/src/telegram/ext/_application.py +++ b/src/telegram/ext/_application.py @@ -1025,10 +1025,15 @@ class Application( bootstrap_retries: int, close_loop: bool = True, ) -> None: - # Calling get_event_loop() should still be okay even in py3.10+ as long as there is a - # running event loop, or we are in the main thread, which are the intended use cases. - # See the docs of get_event_loop() and get_running_loop() for more info - loop = asyncio.get_event_loop() + # Try to get the running event loop first, and if there isn't one, create a new one. + # This handles the Python 3.14+ behavior where get_event_loop() raises RuntimeError + # when there's no current event loop in the main thread. + try: + loop = asyncio.get_event_loop() + except RuntimeError: + # No running event loop, create and set a new one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) if stop_signals is DEFAULT_NONE and platform.system() != "Windows": stop_signals = (signal.SIGINT, signal.SIGTERM, signal.SIGABRT) diff --git a/tests/ext/test_application.py b/tests/ext/test_application.py index 3d6631c0f..a6128ee81 100644 --- a/tests/ext/test_application.py +++ b/tests/ext/test_application.py @@ -1504,7 +1504,7 @@ class TestApplication: thread.start() with caplog.at_level(logging.DEBUG): app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert len(assertions) == 8 for key, value in assertions.items(): @@ -1558,7 +1558,7 @@ class TestApplication: thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == ["init", "post_init", "start_polling"], "Wrong order of events detected!" @pytest.mark.skipif( @@ -1603,7 +1603,7 @@ class TestApplication: thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == [ "updater.shutdown", "shutdown", @@ -1655,7 +1655,7 @@ class TestApplication: thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == [ "updater.stop", "stop", @@ -1704,7 +1704,7 @@ class TestApplication: thread = Thread(target=thread_target) thread.start() app.run_polling(close_loop=False) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) for name, param in updater_signature.parameters.items(): @@ -1720,7 +1720,7 @@ class TestApplication: thread = Thread(target=thread_target) thread.start() app.run_polling(close_loop=False, **expected) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) assert self.received.pop("error_callback", None) @@ -1779,7 +1779,7 @@ class TestApplication: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert len(assertions) == 7 for key, value in assertions.items(): @@ -1844,7 +1844,7 @@ class TestApplication: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == ["init", "post_init", "start_webhook"], "Wrong order of events detected!" @pytest.mark.skipif( @@ -1900,7 +1900,7 @@ class TestApplication: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == [ "updater.shutdown", "shutdown", @@ -1963,7 +1963,7 @@ class TestApplication: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == [ "updater.stop", "stop", @@ -2014,7 +2014,7 @@ class TestApplication: thread = Thread(target=thread_target) thread.start() app.run_webhook(close_loop=False) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) - {"self"} for name, param in updater_signature.parameters.items(): @@ -2027,7 +2027,7 @@ class TestApplication: thread = Thread(target=thread_target) thread.start() app.run_webhook(close_loop=False, **expected) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(expected.keys()) assert self.received == expected @@ -2359,8 +2359,7 @@ class TestApplication: thread.join(timeout=10) assert not thread.is_alive(), "Test took to long to run. Aborting" - @pytest.mark.flaky(3, 1) # loop.call_later will error the test when a flood error is received - def test_signal_handlers(self, app, monkeypatch): + def test_signal_handlers(self, offline_bot, monkeypatch): # this test should make sure that signal handlers are set by default on Linux + Mac, # and not on Windows. @@ -2368,19 +2367,30 @@ class TestApplication: def signal_handler_test(*args, **kwargs): # args[0] is the signal, [1] the callback - received_signals.append(args[0]) + received_signals.append(args[1]) + + app = ApplicationBuilder().bot(offline_bot).application_class(PytestApplication).build() + + # Mock the necessary methods to avoid network calls + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) loop = asyncio.get_event_loop() - monkeypatch.setattr(loop, "add_signal_handler", signal_handler_test) - monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(loop.__class__, "add_signal_handler", signal_handler_test) - def abort_app(): - raise SystemExit + # Mock initialize to exit quickly after testing signal handler setup + original_initialize = app.initialize - loop.call_later(0.6, abort_app) + async def quick_initialize(*args, **kwargs): + await original_initialize(*args, **kwargs) + # Exit quickly by raising an exception after successful initialization + raise TelegramError("Test completed successfully") - app.run_polling(close_loop=False) + monkeypatch.setattr(app, "initialize", quick_initialize) + + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_polling(close_loop=False) if platform.system() == "Windows": assert received_signals == [] @@ -2388,8 +2398,8 @@ class TestApplication: assert received_signals == [signal.SIGINT, signal.SIGTERM, signal.SIGABRT] received_signals.clear() - loop.call_later(0.8, abort_app) - app.run_webhook(port=49152, webhook_url="example.com", close_loop=False) + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_webhook(port=49152, webhook_url="example.com", close_loop=False) if platform.system() == "Windows": assert received_signals == [] @@ -2540,7 +2550,7 @@ class TestApplication: close_loop=False, ) - thread.join() + thread.join(timeout=10) assert len(assertions) == 5 for key, value in assertions.items(): @@ -2714,3 +2724,67 @@ class TestApplication: # check that exactly those handlers were checked that were configured when # add_error_handler was called assert called_handlers == {"remove_handler"} + + @pytest.mark.skipif( + sys.version_info < (3, 14), + reason="Only relevant for Python 3.14+ where get_event_loop() raises RuntimeError", + ) + def test_run_polling_no_event_loop_python314(self, offline_bot, monkeypatch): + """Test that run_polling works when no event loop exists (Python 3.14+ scenario). + + This simulates the Python 3.14+ behavior where get_event_loop() raises RuntimeError + when there's no current event loop in the main thread. The fix should create a new + event loop in this case. + """ + # Track if our test ran and whether any exceptions occurred + exception_captured = None + + def thread_target(): + nonlocal exception_captured + try: + # Intentionally DON'T set an event loop to simulate Python 3.14 scenario + # Note: the existing test_run_polling_webhook_bootstrap_retries DOES set one + + app = ( + ApplicationBuilder() + .bot(offline_bot) + .application_class(PytestApplication) + .build() + ) + + # Mock the necessary methods to avoid network calls + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + + # Mock initialize to exit quickly after testing event loop creation + original_initialize = app.initialize + + async def quick_initialize(*args, **kwargs): + await original_initialize(*args, **kwargs) + # Exit quickly by raising an exception after successful initialization + raise TelegramError("Test completed successfully") + + monkeypatch.setattr(app, "initialize", quick_initialize) + + # This should work - the key is that it creates an event loop and doesn't + # raise RuntimeError about no current event loop (Python 3.14+ issue) + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_polling( + bootstrap_retries=0, + close_loop=True, + stop_signals=None, # Can't use signals in threads + drop_pending_updates=True, + ) + # If we get here, the event loop was created successfully + except Exception as e: + exception_captured = e + + thread = Thread(target=thread_target) + thread.start() + thread.join(timeout=10) + + assert not thread.is_alive(), "Test took too long to run" + + # If there was an unexpected exception, fail the test + if exception_captured: + raise exception_captured