mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Call from thread method
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from concurrent.futures import Future
|
||||||
|
from functools import partial
|
||||||
import inspect
|
import inspect
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
@@ -18,6 +20,7 @@ from time import perf_counter
|
|||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
|
Awaitable,
|
||||||
Callable,
|
Callable,
|
||||||
Generic,
|
Generic,
|
||||||
Iterable,
|
Iterable,
|
||||||
@@ -206,6 +209,8 @@ class _WriterThread(threading.Thread):
|
|||||||
|
|
||||||
CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None]
|
CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None]
|
||||||
|
|
||||||
|
CallThreadReturnType = TypeVar("CallThreadReturnType")
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class App(Generic[ReturnType], DOMNode):
|
class App(Generic[ReturnType], DOMNode):
|
||||||
@@ -353,6 +358,8 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
else:
|
else:
|
||||||
self.devtools = DevtoolsClient()
|
self.devtools = DevtoolsClient()
|
||||||
|
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._thread_id: int = 0
|
||||||
self._return_value: ReturnType | None = None
|
self._return_value: ReturnType | None = None
|
||||||
self._exit = False
|
self._exit = False
|
||||||
|
|
||||||
@@ -604,6 +611,51 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
self._handle_exception(error)
|
self._handle_exception(error)
|
||||||
|
|
||||||
|
def call_from_thread(
|
||||||
|
self,
|
||||||
|
callback: Callable[..., CallThreadReturnType | Awaitable[CallThreadReturnType]],
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> CallThreadReturnType:
|
||||||
|
"""Run a callback from another thread.
|
||||||
|
|
||||||
|
Like asyncio apps in general, Textual apps are not thread-safe. If you call methods
|
||||||
|
or set attributes on Textual objects from a thread, you may get unpredictable results.
|
||||||
|
|
||||||
|
This method will ensure that your code is ran within the correct context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback (Callable): A callable to run.
|
||||||
|
*args: Arguments to the callback.
|
||||||
|
**kwargs: Keyword arguments for the callback.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If the app isn't running or if this method is called from the same
|
||||||
|
thread where the app is running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._loop is None:
|
||||||
|
raise RuntimeError("App is not running")
|
||||||
|
|
||||||
|
if self._thread_id == threading.get_ident():
|
||||||
|
raise RuntimeError(
|
||||||
|
"The `call_from_thread` method must run in a different thread from the app"
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_with_args = partial(callback, *args, **kwargs)
|
||||||
|
|
||||||
|
async def run_callback() -> CallThreadReturnType:
|
||||||
|
"""Run the callback, set the result or error on the future."""
|
||||||
|
self._set_active()
|
||||||
|
return await invoke(callback_with_args)
|
||||||
|
|
||||||
|
# Post the message to the main loop
|
||||||
|
future: Future[Any] = asyncio.run_coroutine_threadsafe(
|
||||||
|
run_callback(), loop=self._loop
|
||||||
|
)
|
||||||
|
result = future.result()
|
||||||
|
return result
|
||||||
|
|
||||||
def action_toggle_dark(self) -> None:
|
def action_toggle_dark(self) -> None:
|
||||||
"""Action to toggle dark mode."""
|
"""Action to toggle dark mode."""
|
||||||
self.dark = not self.dark
|
self.dark = not self.dark
|
||||||
@@ -874,11 +926,17 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
async def run_app() -> None:
|
async def run_app() -> None:
|
||||||
"""Run the app."""
|
"""Run the app."""
|
||||||
await self.run_async(
|
self._loop = asyncio.get_running_loop()
|
||||||
headless=headless,
|
self._thread_id = threading.get_ident()
|
||||||
size=size,
|
try:
|
||||||
auto_pilot=auto_pilot,
|
await self.run_async(
|
||||||
)
|
headless=headless,
|
||||||
|
size=size,
|
||||||
|
auto_pilot=auto_pilot,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._loop = None
|
||||||
|
self._thread_id = 0
|
||||||
|
|
||||||
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
|
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
|
||||||
# N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:
|
# N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:
|
||||||
|
|||||||
50
tests/test_concurrency.py
Normal file
50
tests/test_concurrency.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from threading import Thread
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import TextLog
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_from_thread_app_not_running():
|
||||||
|
app = App()
|
||||||
|
|
||||||
|
# Should fail if app is not running
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
app.call_from_thread(print)
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_from_thread():
|
||||||
|
class BackgroundThread(Thread):
|
||||||
|
"""A background thread which will modify app in some way."""
|
||||||
|
|
||||||
|
def __init__(self, app: App) -> None:
|
||||||
|
self.app = app
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
def write_stuff(text: str) -> None:
|
||||||
|
"""Write stuff to a widget."""
|
||||||
|
self.app.query_one(TextLog).write(text)
|
||||||
|
|
||||||
|
self.app.call_from_thread(write_stuff, "Hello")
|
||||||
|
# Exit the app with a code we can assert
|
||||||
|
self.app.call_from_thread(self.app.exit, 123)
|
||||||
|
|
||||||
|
class ThreadTestApp(App):
|
||||||
|
"""Trivial app with a single widget."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield TextLog()
|
||||||
|
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
"""Launch a thread which will modify the app."""
|
||||||
|
try:
|
||||||
|
self.call_from_thread(print)
|
||||||
|
except RuntimeError as error:
|
||||||
|
self._runtime_error = error
|
||||||
|
BackgroundThread(self).start()
|
||||||
|
|
||||||
|
app = ThreadTestApp()
|
||||||
|
result = app.run(headless=True, size=(80, 24))
|
||||||
|
assert isinstance(app._runtime_error, RuntimeError)
|
||||||
|
assert result == 123
|
||||||
Reference in New Issue
Block a user