Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

set_loading in a @work function RunTimeError when using on_mount after release 0.82 #5137

Closed
r-dgreen opened this issue Oct 18, 2024 · 4 comments

Comments

@r-dgreen
Copy link

When setting loading = True in a work thread, and running the thread from an on_mount command a RuntimeError: no running event loop is thrown. This only happens after release 0.82.

Take the following code, and run it with textual version 0.82 or 0.83:

from urllib.parse import quote
from urllib.request import Request, urlopen

from rich.text import Text

from textual import work
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Input, Static, TabbedContent, TabPane, Label
from textual.worker import Worker, get_current_worker


class WeatherApp(App):
    """App to display the current weather."""

    CSS_PATH = "weather.tcss"

    def compose(self) -> ComposeResult:
        yield Input(placeholder="Enter a City")
        with VerticalScroll(id="weather-container"):
            yield Content(id="weather")

    async def on_input_changed(self, message: Input.Changed) -> None:
        """Called when the input changes"""
        self.query_one('#weather').update_weather(message.value)


class Content(Static):
    @work(exclusive=True, thread=True)
    def update_weather(self, city: str) -> None:
        """Update the weather for the given city."""
        self.loading = True
        worker = get_current_worker()
        if city:
            # Query the network API
            url = f"https://wttr.in/{quote(city)}"
            request = Request(url)
            request.add_header("User-agent", "CURL")
            response_text = urlopen(request).read().decode("utf-8")
            weather = Text.from_ansi(response_text)
            if not worker.is_cancelled:
                self.app.call_from_thread(self.update, weather)
        else:
            # No city, so just blank out the weather
            if not worker.is_cancelled:
                self.app.call_from_thread(self.update, "")
        self.loading = False

    def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
        """Called when the worker state changes."""
        self.log(event)
    
    def on_mount(self):
        self.update_weather('Chicago')

if __name__ == "__main__":
    app = WeatherApp()
    app.run()

This results in the following stacktrace:


╭───────────────────────────────────────── Traceback (most recent call last) ──────────────────────────────────────────╮
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\site-packages\textual\worker.py:368 in _run                   │
│                                                                                                                      │
│   365 │   │   │   self.state = WorkerState.RUNNING                                                                   │
│   366 │   │   │   app.log.worker(self)                                                                               │
│   367 │   │   │   try:                                                                                               │
│ ❱ 368 │   │   │   │   self._result = await self.run()                                                                │
│   369 │   │   │   except asyncio.CancelledError as error:                                                            │
│   370 │   │   │   │   self.state = WorkerState.CANCELLED                                                             │
│   371 │   │   │   │   self._error = error                                                                            │
│                                                                                                                      │
│ ╭───────────────────────────────────────────────── locals ─────────────────────────────────────────────────╮         │
│ │           app = WeatherApp(title='WeatherApp', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) │         │
│ │         error = RuntimeError('no running event loop')                                                    │         │
│ │          self = <Worker ERROR name='update_weather' description="update_weather('Chicago')">             │         │
│ │     Traceback = <class 'rich.traceback.Traceback'>                                                       │         │
│ │ worker_failed = WorkerFailed("Worker raised exception: RuntimeError('no running event loop')")           │         │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯         │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\site-packages\textual\worker.py:352 in run                    │
│                                                                                                                      │
│   349 │   │   Returns:                                                                                               │
│   350 │   │   │   Return value of the work.                                                                          │
│   351 │   │   """                                                                                                    │
│ ❱ 352 │   │   return await (                                                                                         │
│   353 │   │   │   self._run_threaded() if self._thread_worker else self._run_async()                                 │
│   354 │   │   )                                                                                                      │
│   355                                                                                                                │
│                                                                                                                      │
│ ╭────────────────────────────────────── locals ───────────────────────────────────────╮                              │
│ │ self = <Worker ERROR name='update_weather' description="update_weather('Chicago')"> │                              │
│ ╰─────────────────────────────────────────────────────────────────────────────────────╯                              │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\site-packages\textual\worker.py:324 in _run_threaded          │
│                                                                                                                      │
│   321 │   │                                                                                                          │
│   322 │   │   loop = asyncio.get_running_loop()                                                                      │
│   323 │   │   assert loop is not None                                                                                │
│ ❱ 324 │   │   return await loop.run_in_executor(None, runner, self._work)                                            │
│   325 │                                                                                                              │
│   326 │   async def _run_async(self) -> ResultType:                                                                  │
│   327 │   │   """Run an async worker.                                                                                │
│                                                                                                                      │
│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮                     │
│ │          loop = <ProactorEventLoop running=True closed=False debug=False>                    │                     │
│ │ run_awaitable = <function Worker._run_threaded.<locals>.run_awaitable at 0x0000022B2DFF42C0> │                     │
│ │  run_callable = <function Worker._run_threaded.<locals>.run_callable at 0x0000022B2DFF4220>  │                     │
│ │ run_coroutine = <function Worker._run_threaded.<locals>.run_coroutine at 0x0000022B2DFF4180> │                     │
│ │        runner = <function Worker._run_threaded.<locals>.run_callable at 0x0000022B2DFF4220>  │                     │
│ │          self = <Worker ERROR name='update_weather' description="update_weather('Chicago')"> │                     │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯                     │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\concurrent\futures\thread.py:58 in run                        │
│                                                                                                                      │
│    55 │   │   │   return                                                                       ╭── locals ───╮       │
│    56 │   │                                                                                    │ self = None │       │
│    57 │   │   try:                                                                             ╰─────────────╯       │
│ ❱  58 │   │   │   result = self.fn(*self.args, **self.kwargs)                                                        │
│    59 │   │   except BaseException as exc:                                                                           │
│    60 │   │   │   self.future.set_exception(exc)                                                                     │
│    61 │   │   │   # Break a reference cycle with the exception 'exc'                                                 │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\site-packages\textual\worker.py:307 in run_callable           │
│                                                                                                                      │
│   304 │   │   def run_callable(work: Callable[[], ResultType]) -> ResultType:                                        │
│   305 │   │   │   """Set the active worker, and call the callable."""                                                │
│   306 │   │   │   active_worker.set(self)                                                                            │
│ ❱ 307 │   │   │   return work()                                                                                      │
│   308 │   │                                                                                                          │
│   309 │   │   if (                                                                                                   │
│   310 │   │   │   inspect.iscoroutinefunction(self._work)                                                            │
│                                                                                                                      │
│ ╭───────────────────────────────────────────────────── locals ─────────────────────────────────────────────────────╮ │
│ │ self = <Worker ERROR name='update_weather' description="update_weather('Chicago')">                              │ │
│ │ work = functools.partial(<function Content.update_weather at 0x0000022B2D70AA20>, Content(id='weather'),         │ │
│ │        'Chicago')                                                                                                │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                                      │
│ C:\Users\r\code\weather_notab.py:32 in update_weather                                                                │
│                                                                                                                      │
│   29 │   @work(exclusive=True, thread=True)                                                                          │
│   30 │   def update_weather(self, city: str) -> None:                                                                │
│   31 │   │   """Update the weather for the given city."""                                                            │
│ ❱ 32 │   │   self.loading = True                                                                                     │
│   33 │   │   worker = get_current_worker()                                                                           │
│   34 │   │   if city:                                                                                                │
│   35 │   │   │   # Query the network API                                                                             │
│                                                                                                                      │
│ ╭─────────── locals ───────────╮                                                                                     │
│ │ city = 'Chicago'             │                                                                                     │
│ │ self = Content(id='weather') │                                                                                     │
│ ╰──────────────────────────────╯                                                                                     │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\site-packages\textual\widget.py:767 in _watch_loading         │
│                                                                                                                      │
│    764 │                                                                                                             │
│    765 │   def _watch_loading(self, loading: bool) -> None:                                                          │
│    766 │   │   """Called when the 'loading' reactive is changed."""                                                  │
│ ❱  767 │   │   self.set_loading(loading)                                                                             │
│    768 │                                                                                                             │
│    769 │   ExpectType = TypeVar("ExpectType", bound="Widget")                                                        │
│    770                                                                                                               │
│                                                                                                                      │
│ ╭──────────── locals ─────────────╮                                                                                  │
│ │ loading = True                  │                                                                                  │
│ │    self = Content(id='weather') │                                                                                  │
│ ╰─────────────────────────────────╯                                                                                  │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\site-packages\textual\widget.py:761 in set_loading            │
│                                                                                                                      │
│    758 │   │   if loading:                                                                                           │
│    759 │   │   │   loading_indicator = self.get_loading_widget()                                                     │
│    760 │   │   │   loading_indicator.add_class(LOADING_INDICATOR_CLASS)                                              │
│ ❱  761 │   │   │   self._cover(loading_indicator)                                                                    │
│    762 │   │   else:                                                                                                 │
│    763 │   │   │   self._uncover()                                                                                   │
│    764                                                                                                               │
│                                                                                                                      │
│ ╭──────────────────────── locals ────────────────────────╮                                                           │
│ │                 loading = True                         │                                                           │
│ │       loading_indicator = LoadingIndicator()           │                                                           │
│ │ LOADING_INDICATOR_CLASS = '-textual-loading-indicator' │                                                           │
│ │                    self = Content(id='weather')        │                                                           │
│ ╰────────────────────────────────────────────────────────╯                                                           │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\site-packages\textual\widget.py:605 in _cover                 │
│                                                                                                                      │
│    602 │   │   self._uncover()                                                                                       │
│    603 │   │   self._cover_widget = widget                                                                           │
│    604 │   │   widget._parent = self                                                                                 │
│ ❱  605 │   │   widget._start_messages()                                                                              │
│    606 │   │   widget._post_register(self.app)                                                                       │
│    607 │   │   self.app.stylesheet.apply(widget)                                                                     │
│    608 │   │   self.refresh(layout=True)                                                                             │
│                                                                                                                      │
│ ╭──────────── locals ────────────╮                                                                                   │
│ │   self = Content(id='weather') │                                                                                   │
│ │ widget = LoadingIndicator()    │                                                                                   │
│ ╰────────────────────────────────╯                                                                                   │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\site-packages\textual\message_pump.py:497 in _start_messages  │
│                                                                                                                      │
│   494 │   def _start_messages(self) -> None:                                                                         │
│   495 │   │   """Start messages task."""                                                                             │
│   496 │   │   if self.app._running:                                                                                  │
│ ❱ 497 │   │   │   self._task = create_task(                                                                          │
│   498 │   │   │   │   self._process_messages(), name=f"message pump {self}"                                          │
│   499 │   │   │   )                                                                                                  │
│   500 │   │   else:                                                                                                  │
│                                                                                                                      │
│ ╭───────── locals ──────────╮                                                                                        │
│ │ self = LoadingIndicator() │                                                                                        │
│ ╰───────────────────────────╯                                                                                        │
│                                                                                                                      │
│ C:\Users\r\AppData\Local\Programs\Python\Python311\Lib\asyncio\tasks.py:371 in create_task                           │
│                                                                                                                      │
│   368 │                                                                                                              │
│   369 │   Return a Task object.                                                                                      │
│   370 │   """                                                                                                        │
│ ❱ 371 │   loop = events.get_running_loop()                                                                           │
│   372 │   if context is None:                                                                                        │
│   373 │   │   # Use legacy API if context is not needed                                                              │
│   374 │   │   task = loop.create_task(coro)                                                                          │
│                                                                                                                      │
│ ╭───────────────────────────────────── locals ─────────────────────────────────────╮                                 │
│ │ context = None                                                                   │                                 │
│ │    coro = <coroutine object MessagePump._process_messages at 0x0000022B2DFEB4C0> │                                 │
│ │    name = 'message pump LoadingIndicator()'                                      │                                 │
│ ╰──────────────────────────────────────────────────────────────────────────────────╯                                 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
RuntimeError: no running event loop

This does not occur in version 0.81 and prior. I have also confirmed this error happens on both Windows Python 3.11, and Debian Python 3.12.

Copy link

We found the following entry in the FAQ which you may find helpful:

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by FAQtory

@TomJGooding
Copy link
Contributor

TomJGooding commented Oct 18, 2024

There was a change to the loading indicator mechanism in v0.82.0, but the issue here is that you're directly setting a reactive inside a thread worker. There is a warning in the Worker docs that most Textual functions are not thread-safe:

The first difference [with thread workers] is that you should avoid calling methods on your UI directly, or setting reactive variables. You can work around this with the App.call_from_thread method which schedules a call in the main thread.

@r-dgreen
Copy link
Author

Ok, tried it with self.app.call_from_thread(self.set_loading, True) and self.app.call_from_thread(self.set_loading, False) instead of self.loading = True and self.loading = False and you're right. That fixes the issue.

Copy link

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants