Repository: beeware/toga (Python)

Issue:
Deprecate using a generator as an event handler
Toga currently supports using a generator as an event handler, which can sleep by yielding an int or float which is interpreted as a number of seconds. Although this is used in a few of the example apps, it [isn't documented anywhere](https://www.google.com/search?q="generator"+site%3Ahttps%3A%2F%2Ftoga.readthedocs.io%2Fen%2Fstable) apart from an incidental mention in the release notes, and is redundant with the much more capable asyncio API.

Previous discussion: https://github.com/beeware/toga/issues/2099#issuecomment-1705741397.


Discussion:
It would be good to get this done before the Toga 0.5 release.



Patch:
diff --git a/changes/2721.removal.rst b/changes/2721.removal.rst
new file mode 100644
index 0000000000..7efdb9fa1c
--- /dev/null
+++ b/changes/2721.removal.rst
@@ -0,0 +1,1 @@
+The use of generators as event handlers has been deprecated. Any generator-based event handler can be converted into an asynchronous co-routine by converting the handler to ``async def``, and using ``await asyncio.sleep(t)`` in place of ``yield t`` (for some sleep interval ``t``).
diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py
index 4afdead646..20b829a1d8 100644
--- a/core/src/toga/handlers.py
+++ b/core/src/toga/handlers.py
@@ -39,7 +39,18 @@ async def long_running_task(
     generator: HandlerGeneratorReturnT[object],
     cleanup: HandlerSyncT | None,
 ) -> object | None:
-    """Run a generator as an asynchronous coroutine."""
+    """Run a generator as an asynchronous coroutine.
+
+    **DEPRECATED** - use async co-routines instead of generators.
+    """
+    ######################################################################
+    # 2025-02: Deprecated in 0.5.0
+    ######################################################################
+    warnings.warn(
+        "Use of generators for async handlers has been deprecated; convert "
+        "the handler to an async co-routine that uses `asyncio.sleep()`.",
+        DeprecationWarning,
+    )
     try:
         try:
             while True:
diff --git a/demo/toga_demo/app.py b/demo/toga_demo/app.py
index 8d015d6f07..d536a56e86 100755
--- a/demo/toga_demo/app.py
+++ b/demo/toga_demo/app.py
@@ -1,3 +1,5 @@
+import asyncio
+
 import toga
 from toga.constants import COLUMN
 from toga.style import Pack
@@ -82,10 +84,10 @@ def startup(self):
         # Show the main window
         self.main_window.show()
 
-    def button_handler(self, widget):
+    async def button_handler(self, widget):
         print("button press")
         for i in range(0, 10):
-            yield 1
+            await asyncio.sleep(1)
             print("still running... (iteration %s)" % i)
 
     def action1(self, widget):
diff --git a/examples/handlers/handlers/app.py b/examples/handlers/handlers/app.py
index cb6f620bdf..d7265333dd 100644
--- a/examples/handlers/handlers/app.py
+++ b/examples/handlers/handlers/app.py
@@ -15,7 +15,6 @@ def do_clear(self, widget, **kwargs):
         self.on_running_label.text = "Ready."
         self.background_label.text = "Ready."
         self.function_label.text = "Ready."
-        self.generator_label.text = "Ready."
         self.async_label.text = "Ready."
 
     def do_function(self, widget, **kwargs):
@@ -25,17 +24,6 @@ def do_function(self, widget, **kwargs):
             random.randint(0, 100)
         )
 
-    def do_generator(self, widget, **kwargs):
-        """A generator-based handler."""
-        # The generator yields a number; that number is the number of seconds
-        # to yield to the main event loop before processing is resumed.
-        widget.enabled = False
-        for i in range(1, 10):
-            self.generator_label.text = f"Iteration {i}"
-            yield 1
-        self.generator_label.text = "Ready."
-        widget.enabled = True
-
     async def do_async(self, widget, **kwargs):
         """An async handler."""
         # This handler is integrated with the main event loop; every call to
@@ -81,7 +69,6 @@ def startup(self):
         self.on_running_label = toga.Label("Ready.", style=Pack(margin=10))
         self.background_label = toga.Label("Ready.", style=Pack(margin=10))
         self.function_label = toga.Label("Ready.", style=Pack(margin=10))
-        self.generator_label = toga.Label("Ready.", style=Pack(margin=10))
         self.async_label = toga.Label("Ready.", style=Pack(margin=10))
         self.web_label = toga.Label("Ready.", style=Pack(margin=10))
 
@@ -94,9 +81,6 @@ def startup(self):
         btn_function = toga.Button(
             "Function callback", on_press=self.do_function, style=btn_style
         )
-        btn_generator = toga.Button(
-            "Generator callback", on_press=self.do_generator, style=btn_style
-        )
         btn_async = toga.Button(
             "Async callback", on_press=self.do_async, style=btn_style
         )
@@ -112,8 +96,6 @@ def startup(self):
                 self.background_label,
                 btn_function,
                 self.function_label,
-                btn_generator,
-                self.generator_label,
                 btn_async,
                 self.async_label,
                 btn_web,
diff --git a/examples/textinput/textinput/app.py b/examples/textinput/textinput/app.py
index 76021f6c16..ca5e206ac0 100644
--- a/examples/textinput/textinput/app.py
+++ b/examples/textinput/textinput/app.py
@@ -1,3 +1,4 @@
+import asyncio
 from string import ascii_lowercase, ascii_uppercase, digits
 
 import toga
@@ -10,7 +11,7 @@
 
 class TextInputApp(toga.App):
     # Button callback functions
-    def do_extract_values(self, widget, **kwargs):
+    async def do_extract_values(self, widget, **kwargs):
         # Disable all the text inputs
         for input in self.inputs:
             input.enabled = False
@@ -38,7 +39,7 @@ def do_extract_values(self, widget, **kwargs):
         # Wait a few seconds
         for i in range(2, 0, -1):
             self.label.text = f"Counting down from {i}..."
-            yield 1
+            await asyncio.sleep(1)
         self.label.text = "Enter some values and press extract."
 
         # Re-enable the inputs again.
diff --git a/examples/tutorial2/tutorial/app.py b/examples/tutorial2/tutorial/app.py
index 45f489d94b..04661aa303 100644
--- a/examples/tutorial2/tutorial/app.py
+++ b/examples/tutorial2/tutorial/app.py
@@ -1,12 +1,14 @@
+import asyncio
+
 import toga
 from toga.style.pack import COLUMN, Pack
 
 
-def button_handler(widget):
+async def button_handler(widget):
     print("button handler")
     for i in range(0, 10):
         print("hello", i)
-        yield 1
+        await asyncio.sleep(1)
     print("done", i)
 
 
diff --git a/examples/window/window/app.py b/examples/window/window/app.py
index 454b2c09fe..3d2f08b10c 100644
--- a/examples/window/window/app.py
+++ b/examples/window/window/app.py
@@ -86,11 +86,11 @@ def do_window_state_normal(self, widget, **kwargs):
     def do_window_state_maximize(self, widget, **kwargs):
         self.main_window.state = WindowState.MAXIMIZED
 
-    def do_window_state_minimize(self, widget, **kwargs):
+    async def do_window_state_minimize(self, widget, **kwargs):
         self.main_window.state = WindowState.MINIMIZED
         for i in range(5, 0, -1):
             print(f"Back in {i}...")
-            yield 1
+            await asyncio.sleep(1)
         self.main_window.state = WindowState.NORMAL
 
     def do_window_state_full_screen(self, widget, **kwargs):


Test patch:
diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py
index 090ef4dde6..f06dd42396 100644
--- a/core/tests/test_handlers.py
+++ b/core/tests/test_handlers.py
@@ -179,6 +179,11 @@ def handler(*args, **kwargs):
     )
 
 
+######################################################################
+# 2025-02: Generator handlers deprecated in 0.5.0
+######################################################################
+
+
 def test_generator_handler(event_loop):
     """A generator can be used as a handler."""
     obj = Mock()
@@ -198,10 +203,15 @@ def handler(*args, **kwargs):
     # Raw handler is the original generator
     assert wrapped._raw == handler
 
-    # Invoke the handler, and run until it is complete.
-    assert (
-        event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42
-    )
+    # Invoke the handler, and run until it is complete. Raises a deprecation warning.
+    with pytest.warns(
+        DeprecationWarning,
+        match=r"Use of generators for async handlers has been deprecated;",
+    ):
+        assert (
+            event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4))
+            == 42
+        )
 
     # Handler arguments are as expected.
     assert handler_call == {
@@ -228,11 +238,16 @@ def handler(*args, **kwargs):
     # Raw handler is the original generator
     assert wrapped._raw == handler
 
-    # Invoke the handler; return value is None due to exception
-    assert (
-        event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4))
-        is None
-    )
+    # Invoke the handler; raises a deprecation warning, return value is None due to
+    # exception.
+    with pytest.warns(
+        DeprecationWarning,
+        match=r"Use of generators for async handlers has been deprecated;",
+    ):
+        assert (
+            event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4))
+            is None
+        )
 
     # Handler arguments are as expected.
     assert handler_call == {
@@ -267,10 +282,15 @@ def handler(*args, **kwargs):
     # Raw handler is the original generator
     assert wrapped._raw == handler
 
-    # Invoke the handler
-    assert (
-        event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42
-    )
+    # Invoke the handler; raises a deprecation warning
+    with pytest.warns(
+        DeprecationWarning,
+        match=r"Use of generators for async handlers has been deprecated;",
+    ):
+        assert (
+            event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4))
+            == 42
+        )
 
     # Handler arguments are as expected.
     assert handler_call == {
@@ -304,10 +324,15 @@ def handler(*args, **kwargs):
     # Raw handler is the original generator
     assert wrapped._raw == handler
 
-    # Invoke the handler; error in cleanup is swallowed
-    assert (
-        event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42
-    )
+    # Invoke the handler; raises a deprecation warning, error in cleanup is swallowed
+    with pytest.warns(
+        DeprecationWarning,
+        match=r"Use of generators for async handlers has been deprecated;",
+    ):
+        assert (
+            event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4))
+            == 42
+        )
 
     # Handler arguments are as expected.
     assert handler_call == {
@@ -327,6 +352,9 @@ def handler(*args, **kwargs):
     )
 
 
+######################################################################
+
+
 def test_coroutine_handler(event_loop):
     """A coroutine can be used as a handler."""
     obj = Mock()