From f06c1aeae9d040dc45a4f6aa44527d736bded3ea Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 30 Mar 2023 17:58:10 +0100 Subject: [PATCH 01/46] worker class --- src/textual/worker.py | 110 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/textual/worker.py diff --git a/src/textual/worker.py b/src/textual/worker.py new file mode 100644 index 0000000000..617bb22d62 --- /dev/null +++ b/src/textual/worker.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import asyncio +import enum +from abc import ABC, abstractmethod +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any, Callable, Coroutine + +if TYPE_CHECKING: + from .app import App + + +active_worker: ContextVar[Worker] = ContextVar("active_worker") + + +class WorkerState(enum.Enum): + """A description of the worker's current state.""" + + READY = 1 + RUNNING = 2 + CANCELLED = 3 + ERROR = 4 + SUCCESS = 5 + + +class Worker(ABC): + def __init__( + self, name: str = "", group: str = "default", auto_cancel: bool = False + ) -> None: + self.name = name + self.group = group + self.auto_cancel = auto_cancel + self._state = WorkerState.READY + self._error: Exception | None = None + self._step: int = 0 + self._total_steps: int = 0 + self._message: str | None = None + + @property + def state(self) -> WorkerState: + return self._state + + @property + def message(self) -> str | None: + return self._message + + @message.setter + def message(self, message: str) -> None: + self._message = message + + @abstractmethod + async def _start(self, app: App, done_callback: Callable[[Worker], None]) -> None: + ... + + @abstractmethod + def cancel(self) -> None: + ... + + @abstractmethod + async def _wait(self) -> None: + ... + + +class AsyncWorker(Worker): + def __init__( + self, + work_function: Callable[[], Coroutine] | None = None, + *, + name: str = "", + group: str = "default", + ) -> None: + self._work_function = work_function + self._task: asyncio.Task | None = None + super().__init__(name=name, group=group) + + async def run(self) -> None: + """Run the work. + + Implement this method in a subclass, or pass a callable to the constructor. + + """ + if self._work_function is not None: + await self._work_function() + + async def _run(self, app: App) -> None: + app._set_active() + active_worker.set(self) + + self._state = WorkerState.RUNNING + try: + await self.run() + except Exception as error: + self._state = WorkerState.ERROR + self._error = error + except asyncio.CancelledError: + self._state = WorkerState.CANCELLED + else: + self._state = WorkerState.SUCCESS + + async def _start(self, app: App, done_callback: Callable[[Worker], None]) -> None: + self._task = asyncio.create_task(self._run(app)) + + def task_done_callback(_task: asyncio.Task): + done_callback(self) + + self._task.add_done_callback(task_done_callback) + + async def _wait(self) -> None: + if self._task is not None: + await self._task From 6129de31a06f7698db3374d2f3ca89b8e69cfee9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 31 Mar 2023 23:02:22 +0100 Subject: [PATCH 02/46] worker API tests --- examples/dictionary.py | 14 +- src/textual/__init__.py | 9 +- src/textual/_log.py | 1 + src/textual/_work_decorator.py | 57 +++++++ src/textual/_worker_manager.py | 118 +++++++++++++ src/textual/app.py | 2 + src/textual/dom.py | 30 ++++ src/textual/message_pump.py | 14 +- src/textual/widgets/_markdown.py | 12 +- src/textual/worker.py | 281 ++++++++++++++++++++++++++----- tests/test_screens.py | 4 + tests/test_workers.py | 60 +++++++ 12 files changed, 543 insertions(+), 59 deletions(-) create mode 100644 src/textual/_work_decorator.py create mode 100644 src/textual/_worker_manager.py create mode 100644 tests/test_workers.py diff --git a/examples/dictionary.py b/examples/dictionary.py index 6fe2ceb5a8..e01b85aeb4 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -1,13 +1,12 @@ from __future__ import annotations -import asyncio - try: import httpx except ImportError: raise ImportError("Please install httpx with 'pip install httpx' ") +from textual import work from textual.app import App, ComposeResult from textual.containers import Content from textual.widgets import Input, Markdown @@ -31,26 +30,27 @@ def on_mount(self) -> None: async def on_input_changed(self, message: Input.Changed) -> None: """A coroutine to handle a text changed message.""" if message.value: - # Look up the word in the background - asyncio.create_task(self.lookup_word(message.value)) + self.lookup_word(message.value) else: # Clear the results - await self.query_one("#results", Markdown).update("") + self.query_one("#results", Markdown).update("") + @work async def lookup_word(self, word: str) -> None: """Looks up a word.""" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" + async with httpx.AsyncClient() as client: response = await client.get(url) try: results = response.json() except Exception: - self.query_one("#results", Static).update(response.text) + self.query_one("#results", Markdown).update(response.text) return if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) - await self.query_one("#results", Markdown).update(markdown) + self.query_one("#results", Markdown).update(markdown) def make_word_markdown(self, results: object) -> str: """Convert the results in to markdown.""" diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 935f4da97b..e23bdf6ee4 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +from functools import partial, wraps from typing import TYPE_CHECKING, Callable import rich.repr @@ -9,11 +10,12 @@ from . import constants from ._context import active_app from ._log import LogGroup, LogVerbosity +from ._work_decorator import work as work if TYPE_CHECKING: from typing_extensions import TypeAlias -__all__ = ["log", "panic", "__version__"] # type: ignore +__all__ = ["log", "panic", "__version__", "work"] # type: ignore LogCallable: TypeAlias = "Callable" @@ -147,6 +149,11 @@ def logging(self) -> Logger: """Logs from stdlib logging module.""" return Logger(self._log, LogGroup.LOGGING) + @property + def worker(self) -> Logger: + """Logs worker information.""" + return Logger(self._log, LogGroup.WORKER) + log = Logger(None) diff --git a/src/textual/_log.py b/src/textual/_log.py index 26125052da..d42811089b 100644 --- a/src/textual/_log.py +++ b/src/textual/_log.py @@ -13,6 +13,7 @@ class LogGroup(Enum): PRINT = 6 SYSTEM = 7 LOGGING = 8 + WORKER = 9 class LogVerbosity(Enum): diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py new file mode 100644 index 0000000000..3552f12db0 --- /dev/null +++ b/src/textual/_work_decorator.py @@ -0,0 +1,57 @@ +from functools import partial, wraps +from typing import TYPE_CHECKING, Callable, TypeVar, cast, overload + +from typing_extensions import ParamSpec, TypeAlias + +if TYPE_CHECKING: + from .dom import DOMNode + + +T = TypeVar("T") +P = ParamSpec("P") + +DecoratedMethod = TypeVar("DecoratedMethod") + +X = TypeVar("X") + +Decorator: TypeAlias = Callable[[Callable[P, T]], Callable[P, None]] + + +@overload +def work(method: Callable[P, T]) -> Callable[P, None]: + ... + + +@overload +def work(*, exclusive: bool = False) -> Decorator: + ... + + +def work( + method: Callable[P, T] | None = None, exclusive: bool = False +) -> Callable[P, None] | Decorator: + def do_work(method: Callable[P, T]) -> Callable[P, None]: + @wraps(method) + def decorated(*args: P.args, **kwargs: P.kwargs) -> None: + from .dom import DOMNode + + assert isinstance(args[0], DOMNode) + self: DOMNode = cast(DOMNode, args[0]) + positional_arguments = ", ".join(repr(arg) for arg in args[1:]) + keyword_arguments = ", ".join( + f"{name}={value!r}" for name, value in kwargs.items() + ) + worker_description = f"{method.__name__}({', '.join(token for token in [positional_arguments, keyword_arguments] if token)})" + self.run_worker( + partial(method, *args, **kwargs), + name=method.__name__, + description=worker_description, + exclusive=exclusive, + ) + + return decorated + + if method is None: + return do_work + else: + return do_work(method) diff --git a/src/textual/_worker_manager.py b/src/textual/_worker_manager.py new file mode 100644 index 0000000000..e1c0fdbe08 --- /dev/null +++ b/src/textual/_worker_manager.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from collections import Counter +from typing import TYPE_CHECKING, Any + +import rich.repr + +from .worker import Worker, WorkerState, WorkType + +if TYPE_CHECKING: + from .app import App + + +@rich.repr.auto(angular=True) +class WorkerManager: + """An object to manager a number of workers. + + You will not have to construct this class manually, as widgets, screens, and apps + have a worker manager accessibly via a `workers` attribute. + + """ + + def __init__(self, app: App) -> None: + """Initialize a worker manager. + + Args: + app: An App instance. + """ + self._app = app + """A reference to the app.""" + self._workers: set[Worker] = set() + """The workers being managed.""" + + def __rich_repr__(self) -> rich.repr.Result: + counter: Counter[WorkerState] = Counter() + counter.update(worker.state for worker in self._workers) + for state, count in sorted(counter.items()): + yield state.name, count + + def add_worker( + self, worker: Worker, start: bool = True, exclusive: bool = True + ) -> None: + """Add a new worker. + + Args: + worker: A Worker instance. + start: Start the worker if True, otherwise the worker must be started manually. + exclusive: Cancel all workers in the same group as `worker`. + """ + if exclusive and worker.group: + self.cancel_group(worker.group) + self._workers.add(worker) + if start: + worker._start(self._app, self._remove_worker) + + def _run( + self, + work: WorkType, + *, + name: str | None = "", + group: str = "default", + description: str = "", + start: bool = True, + exclusive: bool = False, + ) -> Worker: + """Create a worker from a function, coroutine, or awaitable. + + Args: + work: A callable, a coroutine, or other awaitable. + name: A name to identify the worker. + group: The worker group. + description: A description of the worker. + start: Automatically start the worker. + exclusive: Cancel all workers in the same group. + + Returns: + A Worker instance. + """ + worker: Worker[Any] = Worker( + work, + name=name or getattr(work, "__name__", "") or "", + group=group, + description=description or repr(work), + ) + self.add_worker(worker, start=start, exclusive=exclusive) + return worker + + def _remove_worker(self, worker: Worker) -> None: + """Remove a worker from the manager. + + Args: + worker: A Worker instance. + """ + self._workers.discard(worker) + + def start_all(self) -> None: + """Start all the workers.""" + for worker in self._workers: + worker._start(self._app, self._remove_worker) + + def cancel_all(self) -> None: + """Cancel all workers.""" + for worker in self._workers: + worker.cancel() + + def cancel_group(self, group: str) -> list[Worker]: + """Cancel a single group. + + Args: + group: A group name. + + Return: + A list of workers that were cancelled. + """ + workers = [worker for worker in self._workers if worker.group == group] + for worker in workers: + worker.cancel() + return workers diff --git a/src/textual/app.py b/src/textual/app.py index 17c75ffb57..3e1c689707 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -937,6 +937,8 @@ def on_app_ready() -> None: app_ready_event.set() async def run_app(app) -> None: + app._loop = asyncio.get_running_loop() + app._thread_id = threading.get_ident() await app._process_messages( ready_callback=on_app_ready, headless=headless, diff --git a/src/textual/dom.py b/src/textual/dom.py index 7eca75d885..e1e33e1e2a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -24,6 +24,7 @@ from ._context import NoActiveAppError from ._node_list import NodeList from ._types import WatchCallbackType +from ._worker_manager import WorkerManager from .binding import Binding, Bindings, BindingType from .color import BLACK, WHITE, Color from .css._error_tools import friendly_list @@ -42,6 +43,7 @@ from .css.query import DOMQuery, QueryType from .screen import Screen from .widget import Widget + from .worker import Worker, WorkType, ResultType from typing_extensions import Self, TypeAlias from typing_extensions import Literal @@ -172,6 +174,7 @@ def __init__( ) self._has_hover_style: bool = False self._has_focus_within: bool = False + self._worker_manager: WorkerManager | None = None super().__init__() @@ -208,6 +211,33 @@ def auto_refresh(self, interval: float | None) -> None: ) self._auto_refresh = interval + @property + def workers(self) -> WorkerManager: + """A worker manager.""" + if self._worker_manager is None: + self._worker_manager = WorkerManager(self.app) + return self._worker_manager + + def run_worker( + self, + work: WorkType[ResultType], + *, + name: str | None = "", + group: str = "default", + description: str = "", + start: bool = True, + exclusive: bool = True, + ) -> Worker[ResultType]: + worker: Worker[ResultType] = self.workers._run( + work, + name=name, + group=group, + description=description, + start=start, + exclusive=exclusive, + ) + return worker + def _automatic_refresh(self) -> None: """Perform an automatic refresh (set with auto_refresh property).""" self.refresh() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7e5acf84c8..2d0d9fbdcb 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -9,6 +9,7 @@ import asyncio import inspect +import threading from asyncio import CancelledError, Queue, QueueEmpty, Task from contextlib import contextmanager from functools import partial @@ -69,6 +70,8 @@ def __new__( class MessagePump(metaclass=MessagePumpMeta): + """Base class which supplies a message pump.""" + def __init__(self, parent: MessagePump | None = None) -> None: self._message_queue: Queue[Message | None] = Queue() self._active_message: Message | None = None @@ -84,6 +87,7 @@ def __init__(self, parent: MessagePump | None = None) -> None: self._max_idle: float | None = None self._mounted_event = asyncio.Event() self._next_callbacks: list[CallbackType] = [] + self._thread_id = threading.get_ident() @property def _prevent_message_types_stack(self) -> list[set[type[Message]]]: @@ -631,7 +635,15 @@ def post_message(self, message: Message) -> bool: # Add a copy of the prevented message types to the message # This is so that prevented messages are honoured by the event's handler message._prevent.update(self._get_prevented_messages()) - self._message_queue.put_nowait(message) + if self._thread_id != threading.get_ident(): + # If we're not calling from the same thread, make it threadsafe + loop = self.app._loop + assert loop is not None + asyncio.run_coroutine_threadsafe( + self._message_queue.put(message), loop=loop + ) + else: + self._message_queue.put_nowait(message) return True async def on_callback(self, event: events.Callback) -> None: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 00ecffbba1..a849a7c712 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -589,7 +589,7 @@ def __init__(self, href: str) -> None: async def on_mount(self) -> None: if self._markdown is not None: - await self.update(self._markdown) + self.update(self._markdown) async def load(self, path: Path) -> bool: """Load a new Markdown document. @@ -605,10 +605,10 @@ async def load(self, path: Path) -> bool: except Exception: return False - await self.update(markdown) + self.update(markdown) return True - async def update(self, markdown: str) -> None: + def update(self, markdown: str) -> None: """Update the document with new Markdown. Args: @@ -750,8 +750,8 @@ async def update(self, markdown: str) -> None: self.post_message(Markdown.TableOfContentsUpdated(table_of_contents)) with self.app.batch_update(): - await self.query("MarkdownBlock").remove() - await self.mount_all(output) + self.query("MarkdownBlock").remove() + self.mount_all(output) class MarkdownTableOfContents(Widget, can_focus_children=True): @@ -874,7 +874,7 @@ def table_of_contents(self) -> MarkdownTableOfContents: async def on_mount(self) -> None: if self._markdown is not None: - await self.document.update(self._markdown) + self.document.update(self._markdown) async def go(self, location: str | PurePath) -> bool: """Navigate to a new document path.""" diff --git a/src/textual/worker.py b/src/textual/worker.py index 617bb22d62..8166e0f953 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -2,9 +2,21 @@ import asyncio import enum -from abc import ABC, abstractmethod +import inspect from contextvars import ContextVar -from typing import TYPE_CHECKING, Any, Callable, Coroutine +from time import monotonic +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + Coroutine, + Generic, + TypeVar, + Union, +) + +import rich.repr +from typing_extensions import TypeAlias if TYPE_CHECKING: from .app import App @@ -13,98 +25,279 @@ active_worker: ContextVar[Worker] = ContextVar("active_worker") +class NoActiveWorker(Exception): + """There is no active worker.""" + + +class WorkerError(Exception): + """A worker related error.""" + + +class WorkerFailed(WorkerError): + """The worker raised an exception and did not complete.""" + + def __init__(self, error: BaseException) -> None: + self.error = error + super().__init__(f"Worker raise exception: {error!r}") + + +class WorkerCancelled(WorkerError): + """The worker was cancelled and did not complete.""" + + +def get_worker() -> Worker: + """Get the currently active worker. + + Raises: + NoActiveWorker: If there is no active worker. + + Returns: + A Worker instance. + """ + try: + return active_worker.get() + except LookupError: + raise NoActiveWorker("There is no active worker in this task or thread.") + + class WorkerState(enum.Enum): """A description of the worker's current state.""" - READY = 1 + PENDING = 1 + """Worker is initialized, but not running.""" RUNNING = 2 + """Worker is running.""" CANCELLED = 3 + """Worker is not running, and was cancelled.""" ERROR = 4 + """Worker is not running, and exited with an error.""" SUCCESS = 5 + """Worker is not running, and completed successfully.""" + + +ResultType = TypeVar("ResultType") + + +WorkType: TypeAlias = Union[ + Callable[[], Coroutine[None, None, ResultType]], + Callable[[], ResultType], + Awaitable[ResultType], +] + +class _ReprText: + """Shim to insert a word in to the Worker's repr.""" + + def __init__(self, text: str) -> None: + self.text = text + + def __repr__(self) -> str: + return self.text + + +@rich.repr.auto(angular=True) +class Worker(Generic[ResultType]): + """A class to manage concurrent work (either a task or a thread).""" -class Worker(ABC): def __init__( - self, name: str = "", group: str = "default", auto_cancel: bool = False + self, + work: WorkType | None = None, + *, + name: str = "", + group: str = "default", + description: str = "", ) -> None: + """Initialize a worker. + + Args: + work: A callable, coroutine, or other awaitable. + name: Name of the worker (short string to help identify when debugging). + group: The worker group. + description: Description of the worker (longer string with more details). + """ + self._work = work self.name = name self.group = group - self.auto_cancel = auto_cancel - self._state = WorkerState.READY - self._error: Exception | None = None - self._step: int = 0 - self._total_steps: int = 0 - self._message: str | None = None + self.description = description + self._state = WorkerState.PENDING + self._error: BaseException | None = None + self._completed_steps: int = 0 + self._total_steps: int | None = None + self._cancelled: bool = False + self._created_time = monotonic() + self._result: ResultType | None = None + self._task: asyncio.Task | None = None + + def __rich_repr__(self) -> rich.repr.Result: + yield _ReprText(self.state.name) + yield "name", self.name, "" + yield "group", self.group, "default" + yield "description", self.description, "" + yield "progress", round(self.progress, 1), 0.0 @property def state(self) -> WorkerState: + """The current state of the worker.""" return self._state @property - def message(self) -> str | None: - return self._message + def is_cancelled(self) -> bool: + """Has the work been cancelled? - @message.setter - def message(self, message: str) -> None: - self._message = message + Note that cancelled work may still be running. - @abstractmethod - async def _start(self, app: App, done_callback: Callable[[Worker], None]) -> None: - ... + """ + return self._cancelled - @abstractmethod - def cancel(self) -> None: - ... + @property + def is_running(self) -> bool: + """Is the task running?""" + return self.state == WorkerState.RUNNING - @abstractmethod - async def _wait(self) -> None: - ... + @property + def is_finished(self) -> bool: + """Has the task finished (cancelled, error, or success).""" + return self.state in ( + WorkerState.CANCELLED, + WorkerState.ERROR, + WorkerState.SUCCESS, + ) + @property + def completed_steps(self) -> int: + """The number of completed steps.""" + return self._completed_steps -class AsyncWorker(Worker): - def __init__( - self, - work_function: Callable[[], Coroutine] | None = None, - *, - name: str = "", - group: str = "default", + @property + def total_steps(self) -> int | None: + """The number of total steps, or None if indeterminate.""" + return self._total_steps + + @property + def progress(self) -> float: + """Progress as a percentage. + + If the total steps is None, then this will return 0. The percentage will be clamped between 0 and 100. + + """ + if not self._total_steps: + return 0.0 + return max(0, min(100, (self._completed_steps / self._total_steps) * 100.0)) + + @property + def result(self) -> ResultType | None: + """The result of the worker, or `None` if there is no result.""" + return self._result + + def update( + self, completed_steps: int | None = None, total_steps: int | None = -1 ) -> None: - self._work_function = work_function - self._task: asyncio.Task | None = None - super().__init__(name=name, group=group) + """Update the number of completed steps. - async def run(self) -> None: + Args: + completed_steps: The number of completed seps, or `None` to not change. + total_steps: The total number of steps, `None` for indeterminate, or -1 to leave unchanged. + """ + if completed_steps is not None: + self._completed_steps += completed_steps + if total_steps != -1: + self._total_steps = None if total_steps is None else min(0, total_steps) + + def advance(self, steps: int = 1) -> None: + """Advance the number of completed steps.""" + self._completed_steps += steps + + async def run(self) -> ResultType: """Run the work. Implement this method in a subclass, or pass a callable to the constructor. """ - if self._work_function is not None: - await self._work_function() + + if inspect.iscoroutinefunction(self._work): + # Coroutine, we can await it. + result: ResultType = await self._work() + elif inspect.isawaitable(self._work): + result = await self._work + else: + assert callable(self._work) + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, self._work) + return result async def _run(self, app: App) -> None: + """Run the worker. + + Args: + app: App instance. + """ app._set_active() active_worker.set(self) self._state = WorkerState.RUNNING + app.log.worker(self, "RUNNING") try: - await self.run() + self._result = await self.run() except Exception as error: self._state = WorkerState.ERROR self._error = error - except asyncio.CancelledError: + app.log.worker(self, "ERROR", repr(error)) + app.fatal_error() + except asyncio.CancelledError as error: self._state = WorkerState.CANCELLED + self._error = error + app.log.worker(self, "CANCELLED") else: self._state = WorkerState.SUCCESS + app.log.worker(self, "SUCCESS") - async def _start(self, app: App, done_callback: Callable[[Worker], None]) -> None: + def _start( + self, app: App, done_callback: Callable[[Worker], None] | None = None + ) -> None: + """Start the worker. + + Args: + app: An app instance. + done_callback: A callback to call when the task is done. + """ + if self._task is not None: + return + self._state = WorkerState.RUNNING self._task = asyncio.create_task(self._run(app)) - def task_done_callback(_task: asyncio.Task): - done_callback(self) + def task_done_callback(_task: asyncio.Task) -> None: + """Run thhe callback. + + Called by `Task.add_done_callback` + + Args: + _task: The worker's task. + """ + if done_callback is not None: + done_callback(self) self._task.add_done_callback(task_done_callback) - async def _wait(self) -> None: + def cancel(self) -> None: + """Cancel the task.""" + self._cancelled = True + if self._task is not None: + self._task.cancel() + + async def wait(self) -> ResultType: + """Wait for the work to complete.""" + if self.state == WorkerState.PENDING: + raise WorkerError("Worker must be started before calling this method.") + if self._task is not None: await self._task + + if self.state == WorkerState.ERROR: + assert self._error is not None + raise WorkerError(self._error) + elif self.state == WorkerState.CANCELLED: + raise WorkerCancelled("Worker was cancelled, and did not complete.") + + assert self._result is not None + + return self._result diff --git a/tests/test_screens.py b/tests/test_screens.py index 3edc5dce5a..6825c101c5 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -1,4 +1,6 @@ +import asyncio import sys +import threading import pytest @@ -60,6 +62,8 @@ class ScreensApp(App): async def test_screens(): app = App() + app._loop = asyncio.get_running_loop() + app._thread_id = threading.get_ident() # There should be nothing in the children since the app hasn't run yet assert not app._nodes assert not app.children diff --git a/tests/test_workers.py b/tests/test_workers.py new file mode 100644 index 0000000000..8ec11f8a79 --- /dev/null +++ b/tests/test_workers.py @@ -0,0 +1,60 @@ +from textual.app import App +from textual.worker import Worker, WorkerState + + +def test_initialize(): + def foo() -> str: + return "foo" + + worker = Worker(foo, name="foo", group="foo-group", description="Foo test") + repr(worker) + + assert worker.state == WorkerState.PENDING + assert not worker.is_cancelled + assert not worker.is_running + assert not worker.is_finished + assert worker.completed_steps == 0 + assert worker.total_steps is None + assert worker.progress == 0.0 + assert worker.result is None + + +async def test_run() -> None: + def foo() -> str: + """Regular function.""" + return "foo" + + async def bar() -> str: + """Coroutine.""" + return "bar" + + async def baz() -> str: + """Coroutine.""" + return "baz" + + class RunApp(App): + pass + + app = RunApp() + async with app.run_test(): + # Call regular function + foo_worker: Worker[str] = Worker( + foo, name="foo", group="foo-group", description="Foo test" + ) + # Call coroutine function + bar_worker: Worker[str] = Worker( + bar, name="bar", group="bar-group", description="Bar test" + ) + # Call coroutine + baz_worker: Worker[str] = Worker( + baz(), name="baz", group="baz-group", description="Baz test" + ) + foo_worker._start(app) + bar_worker._start(app) + baz_worker._start(app) + assert await foo_worker.wait() == "foo" + assert await bar_worker.wait() == "bar" + assert await baz_worker.wait() == "baz" + assert foo_worker.result == "bar" + assert bar_worker.result == "bar" + assert baz_worker.result == "baz" From 01e1b656dff754ca915c178ed34788270959e9c6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 31 Mar 2023 23:10:34 +0100 Subject: [PATCH 03/46] tidy --- src/textual/_work_decorator.py | 8 ++------ tests/test_workers.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 3552f12db0..42439cfe64 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -1,5 +1,5 @@ from functools import partial, wraps -from typing import TYPE_CHECKING, Callable, TypeVar, cast, overload +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast, overload from typing_extensions import ParamSpec, TypeAlias @@ -10,10 +10,6 @@ T = TypeVar("T") P = ParamSpec("P") -DecoratedMethod = TypeVar("DecoratedMethod") - -X = TypeVar("X") - Decorator: TypeAlias = Callable[[Callable[P, T]], Callable[P, None]] @@ -28,7 +24,7 @@ def work(*, exclusive: bool = False) -> Decorator: def work( - method: Callable[P, T] | None = None, exclusive: bool = False + method: Callable[P, Any] | None = None, exclusive: bool = False ) -> Callable[P, None] | Decorator: def do_work(method: Callable[P, T]) -> Callable[P, None]: @wraps(method) diff --git a/tests/test_workers.py b/tests/test_workers.py index 8ec11f8a79..80abfc1d33 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -3,6 +3,8 @@ def test_initialize(): + """Test initial values.""" + def foo() -> str: return "foo" @@ -19,7 +21,9 @@ def foo() -> str: assert worker.result is None -async def test_run() -> None: +async def test_run_success() -> None: + """Test successful runs.""" + def foo() -> str: """Regular function.""" return "foo" @@ -49,12 +53,15 @@ class RunApp(App): baz_worker: Worker[str] = Worker( baz(), name="baz", group="baz-group", description="Baz test" ) + assert foo_worker.result is None + assert bar_worker.result is None + assert baz_worker.result is None foo_worker._start(app) bar_worker._start(app) baz_worker._start(app) assert await foo_worker.wait() == "foo" assert await bar_worker.wait() == "bar" assert await baz_worker.wait() == "baz" - assert foo_worker.result == "bar" + assert foo_worker.result == "foo" assert bar_worker.result == "bar" assert baz_worker.result == "baz" From 3da808033d62c9055d34282313e76e2b63014152 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 1 Apr 2023 09:28:54 +0100 Subject: [PATCH 04/46] Decorator and more tests --- examples/dictionary.py | 10 ++-- src/textual/_animator.py | 6 ++- src/textual/_work_decorator.py | 85 ++++++++++++++++++++++++-------- src/textual/message_pump.py | 2 +- src/textual/worker.py | 19 ++++---- tests/test_workers.py | 88 +++++++++++++++++++++++++++++++++- 6 files changed, 172 insertions(+), 38 deletions(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index e01b85aeb4..d338750557 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -30,13 +30,14 @@ def on_mount(self) -> None: async def on_input_changed(self, message: Input.Changed) -> None: """A coroutine to handle a text changed message.""" if message.value: - self.lookup_word(message.value) + worker = self.lookup_word(message.value) + result = worker.result else: # Clear the results self.query_one("#results", Markdown).update("") - @work - async def lookup_word(self, word: str) -> None: + @work() + async def lookup_word(self, word: str) -> str: """Looks up a word.""" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" @@ -46,11 +47,12 @@ async def lookup_word(self, word: str) -> None: results = response.json() except Exception: self.query_one("#results", Markdown).update(response.text) - return + return "foo" if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) self.query_one("#results", Markdown).update(markdown) + return "foo" def make_word_markdown(self, results: object) -> str: """Convert the results in to markdown.""" diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 060da0c984..d7f9ad67b7 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -27,12 +27,14 @@ class AnimationError(Exception): """An issue prevented animation from starting.""" -T = TypeVar("T") +ReturnType = TypeVar("T") @runtime_checkable class Animatable(Protocol): - def blend(self: T, destination: T, factor: float) -> T: # pragma: no cover + def blend( + self: ReturnType, destination: ReturnType, factor: float + ) -> ReturnType: # pragma: no cover ... diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 42439cfe64..2175e9efff 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -1,53 +1,96 @@ +from __future__ import annotations + from functools import partial, wraps -from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast, overload +from typing import TYPE_CHECKING, Callable, Coroutine, TypeVar, cast, overload from typing_extensions import ParamSpec, TypeAlias if TYPE_CHECKING: - from .dom import DOMNode + from .worker import Worker -T = TypeVar("T") -P = ParamSpec("P") +FactoryParamSpec = ParamSpec("FactoryParamSpec") +DecoratorParamSpec = ParamSpec("DecoratorParamSpec") +ReturnType = TypeVar("ReturnType") -Decorator: TypeAlias = Callable[[Callable[P, T]], Callable[P, None]] +Decorator: TypeAlias = Callable[ + [ + Callable[DecoratorParamSpec, ReturnType] + | Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]] + ], + Callable[DecoratorParamSpec, "Worker[ReturnType]"], +] @overload -def work(method: Callable[P, T]) -> Callable[P, None]: +def work( + method: Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] +) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... @overload -def work(*, exclusive: bool = False) -> Decorator: +def work( + method: Callable[FactoryParamSpec, ReturnType] +) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: + ... + + +@overload +def work(*, exclusive: bool = False) -> Decorator[..., ReturnType]: ... def work( - method: Callable[P, Any] | None = None, exclusive: bool = False -) -> Callable[P, None] | Decorator: - def do_work(method: Callable[P, T]) -> Callable[P, None]: + method: Callable[FactoryParamSpec, ReturnType] + | Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] + | None = None, + *, + name: str = "", + group: str = "default", + exclusive: bool = False, +) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator: + """Worker decorator factory.""" + + def decorator( + method: Callable[DecoratorParamSpec, ReturnType] + | Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]] + ) -> Callable[DecoratorParamSpec, Worker[ReturnType]]: + """The decorator.""" + @wraps(method) - def decorated(*args: P.args, **kwargs: P.kwargs) -> None: + def decorated( + *args: DecoratorParamSpec.args, **kwargs: DecoratorParamSpec.kwargs + ) -> Worker[ReturnType]: + """The replaced callable.""" from .dom import DOMNode - assert isinstance(args[0], DOMNode) - self: DOMNode = cast(DOMNode, args[0]) + self = args[0] + assert isinstance(self, DOMNode) + positional_arguments = ", ".join(repr(arg) for arg in args[1:]) keyword_arguments = ", ".join( f"{name}={value!r}" for name, value in kwargs.items() ) - worker_description = f"{method.__name__}({', '.join(token for token in [positional_arguments, keyword_arguments] if token)})" - self.run_worker( - partial(method, *args, **kwargs), - name=method.__name__, - description=worker_description, - exclusive=exclusive, + tokens = [positional_arguments, keyword_arguments] + worker_description = ( + f"{method.__name__}({', '.join(token for token in tokens if token)})" + ) + worker = cast( + Worker[ReturnType], + self.run_worker( + partial(method, *args, **kwargs), + name=name or method.__name__, + group=group, + description=worker_description, + exclusive=exclusive, + ), ) + return worker return decorated if method is None: - return do_work + return decorator else: - return do_work(method) + return decorator(method) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 2d0d9fbdcb..25e996ccf3 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -638,7 +638,7 @@ def post_message(self, message: Message) -> bool: if self._thread_id != threading.get_ident(): # If we're not calling from the same thread, make it threadsafe loop = self.app._loop - assert loop is not None + q asyncio.run_coroutine_threadsafe( self._message_queue.put(message), loop=loop ) diff --git a/src/textual/worker.py b/src/textual/worker.py index 8166e0f953..6d7888f39b 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -45,7 +45,7 @@ class WorkerCancelled(WorkerError): """The worker was cancelled and did not complete.""" -def get_worker() -> Worker: +def get_current_worker() -> Worker: """Get the currently active worker. Raises: @@ -266,12 +266,12 @@ def _start( self._task = asyncio.create_task(self._run(app)) def task_done_callback(_task: asyncio.Task) -> None: - """Run thhe callback. + """Run the callback. - Called by `Task.add_done_callback` + Called by `Task.add_done_callback`. Args: - _task: The worker's task. + The worker's task. """ if done_callback is not None: done_callback(self) @@ -288,16 +288,17 @@ async def wait(self) -> ResultType: """Wait for the work to complete.""" if self.state == WorkerState.PENDING: raise WorkerError("Worker must be started before calling this method.") - if self._task is not None: - await self._task - + try: + await self._task + except asyncio.CancelledError as error: + self._state = WorkerState.CANCELLED + self._error = error if self.state == WorkerState.ERROR: assert self._error is not None - raise WorkerError(self._error) + raise WorkerFailed(self._error) elif self.state == WorkerState.CANCELLED: raise WorkerCancelled("Worker was cancelled, and did not complete.") assert self._result is not None - return self._result diff --git a/tests/test_workers.py b/tests/test_workers.py index 80abfc1d33..76a10a9f15 100644 --- a/tests/test_workers.py +++ b/tests/test_workers.py @@ -1,5 +1,15 @@ +import asyncio + +import pytest + from textual.app import App -from textual.worker import Worker, WorkerState +from textual.worker import ( + Worker, + WorkerCancelled, + WorkerFailed, + WorkerState, + get_current_worker, +) def test_initialize(): @@ -65,3 +75,79 @@ class RunApp(App): assert foo_worker.result == "foo" assert bar_worker.result == "bar" assert baz_worker.result == "baz" + + +async def test_run_error() -> None: + async def run_error() -> str: + await asyncio.sleep(0.1) + 1 / 0 + return "Never" + + class ErrorApp(App): + pass + + app = ErrorApp() + async with app.run_test(): + worker: Worker[str] = Worker(run_error) + worker._start(app) + with pytest.raises(WorkerFailed): + await worker.wait() + + +async def test_run_cancel() -> None: + """Test run may be cancelled.""" + + async def run_error() -> str: + await asyncio.sleep(0.1) + return "Never" + + class ErrorApp(App): + pass + + app = ErrorApp() + async with app.run_test(): + worker: Worker[str] = Worker(run_error) + worker._start(app) + await asyncio.sleep(0) + worker.cancel() + assert worker.is_cancelled + with pytest.raises(WorkerCancelled): + await worker.wait() + + +async def test_run_cancel_immediately() -> None: + """Edge case for cancelling immediately.""" + + async def run_error() -> str: + await asyncio.sleep(0.1) + return "Never" + + class ErrorApp(App): + pass + + app = ErrorApp() + async with app.run_test(): + worker: Worker[str] = Worker(run_error) + worker._start(app) + worker.cancel() + assert worker.is_cancelled + with pytest.raises(WorkerCancelled): + await worker.wait() + + +async def test_get_worker() -> None: + """Check get current worker.""" + + async def run_worker() -> Worker: + worker = get_current_worker() + return worker + + class WorkerApp(App): + pass + + app = WorkerApp() + async with app.run_test(): + worker: Worker[Worker] = Worker(run_worker) + worker._start(app) + + assert await worker.wait() is worker From 0cb869ca6b7face598e41545a360b91acab216e9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 1 Apr 2023 09:33:07 +0100 Subject: [PATCH 05/46] type fix --- src/textual/_work_decorator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 2175e9efff..0dbe9e69e5 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import partial, wraps -from typing import TYPE_CHECKING, Callable, Coroutine, TypeVar, cast, overload +from typing import TYPE_CHECKING, Callable, Coroutine, TypeVar, Union, cast, overload from typing_extensions import ParamSpec, TypeAlias @@ -15,8 +15,10 @@ Decorator: TypeAlias = Callable[ [ - Callable[DecoratorParamSpec, ReturnType] - | Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]] + Union[ + Callable[DecoratorParamSpec, ReturnType], + Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]], + ] ], Callable[DecoratorParamSpec, "Worker[ReturnType]"], ] From bab3d1dd9e4c75ad8cd4d1859971801240771d4d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 1 Apr 2023 09:47:58 +0100 Subject: [PATCH 06/46] error order --- src/textual/_work_decorator.py | 6 ++++-- src/textual/worker.py | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 0dbe9e69e5..a4bebe9612 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -55,8 +55,10 @@ def work( """Worker decorator factory.""" def decorator( - method: Callable[DecoratorParamSpec, ReturnType] - | Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]] + method: ( + Callable[DecoratorParamSpec, ReturnType] + | Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]] + ) ) -> Callable[DecoratorParamSpec, Worker[ReturnType]]: """The decorator.""" diff --git a/src/textual/worker.py b/src/textual/worker.py index 6d7888f39b..f690c641a0 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -238,15 +238,15 @@ async def _run(self, app: App) -> None: app.log.worker(self, "RUNNING") try: self._result = await self.run() + except asyncio.CancelledError as error: + self._state = WorkerState.CANCELLED + self._error = error + app.log.worker(self, "CANCELLED") except Exception as error: self._state = WorkerState.ERROR self._error = error app.log.worker(self, "ERROR", repr(error)) app.fatal_error() - except asyncio.CancelledError as error: - self._state = WorkerState.CANCELLED - self._error = error - app.log.worker(self, "CANCELLED") else: self._state = WorkerState.SUCCESS app.log.worker(self, "SUCCESS") From 21d5410849dd5f7177217ee4f092c4e77933af5f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 1 Apr 2023 12:23:50 +0100 Subject: [PATCH 07/46] more tests --- src/textual/_work_decorator.py | 2 +- src/textual/_worker_manager.py | 31 ++++++++- src/textual/dom.py | 2 +- src/textual/message_pump.py | 1 - src/textual/worker.py | 55 ++++++++++++--- tests/{test_workers.py => test_worker.py} | 22 +++--- tests/test_worker_manager.py | 83 +++++++++++++++++++++++ 7 files changed, 171 insertions(+), 25 deletions(-) rename tests/{test_workers.py => test_worker.py} (83%) create mode 100644 tests/test_worker_manager.py diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index a4bebe9612..9e3659a4f8 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -81,7 +81,7 @@ def decorated( f"{method.__name__}({', '.join(token for token in tokens if token)})" ) worker = cast( - Worker[ReturnType], + "Worker[ReturnType]", self.run_worker( partial(method, *args, **kwargs), name=name or method.__name__, diff --git a/src/textual/_worker_manager.py b/src/textual/_worker_manager.py index e1c0fdbe08..47ef714754 100644 --- a/src/textual/_worker_manager.py +++ b/src/textual/_worker_manager.py @@ -1,7 +1,9 @@ from __future__ import annotations +import asyncio from collections import Counter -from typing import TYPE_CHECKING, Any +from operator import attrgetter +from typing import TYPE_CHECKING, Any, Iterator import rich.repr @@ -9,6 +11,7 @@ if TYPE_CHECKING: from .app import App + from .dom import DOMNode @rich.repr.auto(angular=True) @@ -20,7 +23,7 @@ class WorkerManager: """ - def __init__(self, app: App) -> None: + def __init__(self, app: App, node: DOMNode) -> None: """Initialize a worker manager. Args: @@ -28,6 +31,8 @@ def __init__(self, app: App) -> None: """ self._app = app """A reference to the app.""" + self._node = node + """A reference to the node.""" self._workers: set[Worker] = set() """The workers being managed.""" @@ -37,6 +42,23 @@ def __rich_repr__(self) -> rich.repr.Result: for state, count in sorted(counter.items()): yield state.name, count + def __iter__(self) -> Iterator[Worker[Any]]: + return iter(sorted(self._workers, key=attrgetter("_created_time"))) + + def __reversed__(self) -> Iterator[Worker[Any]]: + return iter( + sorted(self._workers, key=attrgetter("_created_time"), reverse=True) + ) + + def __bool__(self) -> bool: + return bool(self._workers) + + def __len__(self) -> int: + return len(self._workers) + + def __contains__(self, worker: object) -> bool: + return worker in self._workers + def add_worker( self, worker: Worker, start: bool = True, exclusive: bool = True ) -> None: @@ -77,6 +99,7 @@ def _run( A Worker instance. """ worker: Worker[Any] = Worker( + self._node, work, name=name or getattr(work, "__name__", "") or "", group=group, @@ -116,3 +139,7 @@ def cancel_group(self, group: str) -> list[Worker]: for worker in workers: worker.cancel() return workers + + async def wait_for_complete(self) -> None: + """Cancel all tasks and wait their completion.""" + await asyncio.gather(*[worker.wait() for worker in self]) diff --git a/src/textual/dom.py b/src/textual/dom.py index e1e33e1e2a..9246073592 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -215,7 +215,7 @@ def auto_refresh(self, interval: float | None) -> None: def workers(self) -> WorkerManager: """A worker manager.""" if self._worker_manager is None: - self._worker_manager = WorkerManager(self.app) + self._worker_manager = WorkerManager(self.app, self) return self._worker_manager def run_worker( diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 25e996ccf3..6ca0bd16b5 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -638,7 +638,6 @@ def post_message(self, message: Message) -> bool: if self._thread_id != threading.get_ident(): # If we're not calling from the same thread, make it threadsafe loop = self.app._loop - q asyncio.run_coroutine_threadsafe( self._message_queue.put(message), loop=loop ) diff --git a/src/textual/worker.py b/src/textual/worker.py index f690c641a0..fb13cf93e0 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -18,8 +18,11 @@ import rich.repr from typing_extensions import TypeAlias +from .message import Message + if TYPE_CHECKING: from .app import App + from .dom import DOMNode active_worker: ContextVar[Worker] = ContextVar("active_worker") @@ -99,8 +102,24 @@ def __repr__(self) -> str: class Worker(Generic[ResultType]): """A class to manage concurrent work (either a task or a thread).""" + @rich.repr.auto + class StateChanged(Message, bubble=False): + """The worker state changed.""" + + namespace = "worker" + + def __init__(self, worker: Worker, state: WorkerState) -> None: + self.worker = worker + self.state = state + super().__init__() + + def __rich_repr__(self) -> rich.repr.Result: + yield self.worker + yield self.state + def __init__( self, + node: DOMNode, work: WorkType | None = None, *, name: str = "", @@ -115,11 +134,13 @@ def __init__( group: The worker group. description: Description of the worker (longer string with more details). """ + self._node = node self._work = work self.name = name self.group = group self.description = description self._state = WorkerState.PENDING + self.state = self._state self._error: BaseException | None = None self._completed_steps: int = 0 self._total_steps: int | None = None @@ -127,6 +148,7 @@ def __init__( self._created_time = monotonic() self._result: ResultType | None = None self._task: asyncio.Task | None = None + self._node.post_message(self.StateChanged(self, self._state)) def __rich_repr__(self) -> rich.repr.Result: yield _ReprText(self.state.name) @@ -135,11 +157,24 @@ def __rich_repr__(self) -> rich.repr.Result: yield "description", self.description, "" yield "progress", round(self.progress, 1), 0.0 + @property + def node(self) -> DOMNode: + """The node where this worker was run from.""" + return self._node + @property def state(self) -> WorkerState: """The current state of the worker.""" return self._state + @state.setter + def state(self, state: WorkerState) -> None: + """Set the state, and send a message.""" + changed = state != self._state + self._state = state + if changed: + self._node.post_message(self.StateChanged(self, state)) + @property def is_cancelled(self) -> bool: """Has the work been cancelled? @@ -234,22 +269,22 @@ async def _run(self, app: App) -> None: app._set_active() active_worker.set(self) - self._state = WorkerState.RUNNING - app.log.worker(self, "RUNNING") + self.state = WorkerState.RUNNING + app.log.worker(self) try: self._result = await self.run() except asyncio.CancelledError as error: - self._state = WorkerState.CANCELLED + self.state = WorkerState.CANCELLED self._error = error - app.log.worker(self, "CANCELLED") + app.log.worker(self) except Exception as error: - self._state = WorkerState.ERROR + self.state = WorkerState.ERROR self._error = error - app.log.worker(self, "ERROR", repr(error)) + app.log.worker(self, "failed", repr(error)) app.fatal_error() else: - self._state = WorkerState.SUCCESS - app.log.worker(self, "SUCCESS") + self.state = WorkerState.SUCCESS + app.log.worker(self) def _start( self, app: App, done_callback: Callable[[Worker], None] | None = None @@ -262,7 +297,7 @@ def _start( """ if self._task is not None: return - self._state = WorkerState.RUNNING + self.state = WorkerState.RUNNING self._task = asyncio.create_task(self._run(app)) def task_done_callback(_task: asyncio.Task) -> None: @@ -292,7 +327,7 @@ async def wait(self) -> ResultType: try: await self._task except asyncio.CancelledError as error: - self._state = WorkerState.CANCELLED + self.state = WorkerState.CANCELLED self._error = error if self.state == WorkerState.ERROR: assert self._error is not None diff --git a/tests/test_workers.py b/tests/test_worker.py similarity index 83% rename from tests/test_workers.py rename to tests/test_worker.py index 76a10a9f15..3ef802f5f2 100644 --- a/tests/test_workers.py +++ b/tests/test_worker.py @@ -12,14 +12,16 @@ ) -def test_initialize(): +async def test_initialize(): """Test initial values.""" def foo() -> str: return "foo" - worker = Worker(foo, name="foo", group="foo-group", description="Foo test") - repr(worker) + app = App() + async with app.run_test(): + worker = Worker(app, foo, name="foo", group="foo-group", description="Foo test") + repr(worker) assert worker.state == WorkerState.PENDING assert not worker.is_cancelled @@ -53,15 +55,15 @@ class RunApp(App): async with app.run_test(): # Call regular function foo_worker: Worker[str] = Worker( - foo, name="foo", group="foo-group", description="Foo test" + app, foo, name="foo", group="foo-group", description="Foo test" ) # Call coroutine function bar_worker: Worker[str] = Worker( - bar, name="bar", group="bar-group", description="Bar test" + app, bar, name="bar", group="bar-group", description="Bar test" ) # Call coroutine baz_worker: Worker[str] = Worker( - baz(), name="baz", group="baz-group", description="Baz test" + app, baz(), name="baz", group="baz-group", description="Baz test" ) assert foo_worker.result is None assert bar_worker.result is None @@ -88,7 +90,7 @@ class ErrorApp(App): app = ErrorApp() async with app.run_test(): - worker: Worker[str] = Worker(run_error) + worker: Worker[str] = Worker(app, run_error) worker._start(app) with pytest.raises(WorkerFailed): await worker.wait() @@ -106,7 +108,7 @@ class ErrorApp(App): app = ErrorApp() async with app.run_test(): - worker: Worker[str] = Worker(run_error) + worker: Worker[str] = Worker(app, run_error) worker._start(app) await asyncio.sleep(0) worker.cancel() @@ -127,7 +129,7 @@ class ErrorApp(App): app = ErrorApp() async with app.run_test(): - worker: Worker[str] = Worker(run_error) + worker: Worker[str] = Worker(app, run_error) worker._start(app) worker.cancel() assert worker.is_cancelled @@ -147,7 +149,7 @@ class WorkerApp(App): app = WorkerApp() async with app.run_test(): - worker: Worker[Worker] = Worker(run_worker) + worker: Worker[Worker] = Worker(app, run_worker) worker._start(app) assert await worker.wait() is worker diff --git a/tests/test_worker_manager.py b/tests/test_worker_manager.py new file mode 100644 index 0000000000..2eebdb5d87 --- /dev/null +++ b/tests/test_worker_manager.py @@ -0,0 +1,83 @@ +import asyncio +import time + +from textual.app import App, ComposeResult +from textual.reactive import var +from textual.widget import Widget +from textual.worker import Worker, WorkerState + + +async def test_run_worker_async(): + """Check self.run_worker""" + worker_events: list[Worker.StateChanged] = [] + + work_result: str = "" + + class WorkerWidget(Widget): + async def work(self) -> str: + nonlocal work_result + await asyncio.sleep(0.02) + work_result = "foo" + return "foo" + + def on_mount(self): + self.run_worker(self.work) + + def on_worker_state_changed(self, event) -> None: + worker_events.append(event) + + class WorkerApp(App): + def compose(self) -> ComposeResult: + yield WorkerWidget() + + app = WorkerApp() + async with app.run_test(): + worker_widget = app.query_one(WorkerWidget) + await worker_widget.workers.wait_for_complete() + + assert work_result == "foo" + assert isinstance(worker_events[0].worker.node, WorkerWidget) + states = [event.state for event in worker_events] + assert states == [ + WorkerState.PENDING, + WorkerState.RUNNING, + WorkerState.SUCCESS, + ] + + +async def test_run_worker_thread(): + """Check self.run_worker""" + worker_events: list[Worker.StateChanged] = [] + + work_result: str = "" + + class WorkerWidget(Widget): + def work(self) -> str: + nonlocal work_result + time.sleep(0.02) + work_result = "foo" + return "foo" + + def on_mount(self): + self.run_worker(self.work) + + def on_worker_state_changed(self, event) -> None: + worker_events.append(event) + + class WorkerApp(App): + def compose(self) -> ComposeResult: + yield WorkerWidget() + + app = WorkerApp() + async with app.run_test(): + worker_widget = app.query_one(WorkerWidget) + await worker_widget.workers.wait_for_complete() + + assert work_result == "foo" + assert isinstance(worker_events[0].worker.node, WorkerWidget) + states = [event.state for event in worker_events] + assert states == [ + WorkerState.PENDING, + WorkerState.RUNNING, + WorkerState.SUCCESS, + ] From 0aa2d19d611638d5ffcda47a81414607d8095f03 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 1 Apr 2023 12:46:48 +0100 Subject: [PATCH 08/46] remove active message --- src/textual/message_pump.py | 60 +++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 6ca0bd16b5..9439087261 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -74,7 +74,6 @@ class MessagePump(metaclass=MessagePumpMeta): def __init__(self, parent: MessagePump | None = None) -> None: self._message_queue: Queue[Message | None] = Queue() - self._active_message: Message | None = None self._parent = parent self._running: bool = False self._closing: bool = False @@ -478,40 +477,35 @@ async def _process_messages_loop(self) -> None: except MessagePumpClosed: break - self._active_message = message - try: - try: - await self._dispatch_message(message) - except CancelledError: - raise - except Exception as error: - self._mounted_event.set() - self.app._handle_exception(error) - break - finally: - self._message_queue.task_done() - - current_time = time() - - # Insert idle events - if self._message_queue.empty() or ( - self._max_idle is not None - and current_time - self._last_idle > self._max_idle - ): - self._last_idle = current_time - if not self._closed: - event = events.Idle() - for _cls, method in self._get_dispatch_methods( - "on_idle", event - ): - try: - await invoke(method, event) - except Exception as error: - self.app._handle_exception(error) - break + await self._dispatch_message(message) + except CancelledError: + raise + except Exception as error: + self._mounted_event.set() + self.app._handle_exception(error) + break finally: - self._active_message = None + self._message_queue.task_done() + + current_time = time() + + # Insert idle events + if self._message_queue.empty() or ( + self._max_idle is not None + and current_time - self._last_idle > self._max_idle + ): + self._last_idle = current_time + if not self._closed: + event = events.Idle() + for _cls, method in self._get_dispatch_methods( + "on_idle", event + ): + try: + await invoke(method, event) + except Exception as error: + self.app._handle_exception(error) + break async def _flush_next_callbacks(self) -> None: """Invoke pending callbacks in next callbacks queue.""" From be895225c93aab0c60b0e41e8a5754f06328ac5e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 1 Apr 2023 17:46:24 +0100 Subject: [PATCH 09/46] move worker manager to app --- src/textual/_worker_manager.py | 16 +++++++++++----- src/textual/app.py | 7 +++++++ src/textual/dom.py | 7 +++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/textual/_worker_manager.py b/src/textual/_worker_manager.py index 47ef714754..33c839dd55 100644 --- a/src/textual/_worker_manager.py +++ b/src/textual/_worker_manager.py @@ -23,7 +23,7 @@ class WorkerManager: """ - def __init__(self, app: App, node: DOMNode) -> None: + def __init__(self, app: App) -> None: """Initialize a worker manager. Args: @@ -31,8 +31,6 @@ def __init__(self, app: App, node: DOMNode) -> None: """ self._app = app """A reference to the app.""" - self._node = node - """A reference to the node.""" self._workers: set[Worker] = set() """The workers being managed.""" @@ -75,9 +73,10 @@ def add_worker( if start: worker._start(self._app, self._remove_worker) - def _run( + def _new_worker( self, work: WorkType, + node: DOMNode, *, name: str | None = "", group: str = "default", @@ -99,7 +98,7 @@ def _run( A Worker instance. """ worker: Worker[Any] = Worker( - self._node, + node, work, name=name or getattr(work, "__name__", "") or "", group=group, @@ -140,6 +139,13 @@ def cancel_group(self, group: str) -> list[Worker]: worker.cancel() return workers + def cancel_node(self, node: DOMNode) -> list[Worker]: + """Cancel all workers associated with a given node.""" + workers = [worker for worker in self._workers if worker.node == node] + for worker in workers: + worker.cancel() + return workers + async def wait_for_complete(self) -> None: """Cancel all tasks and wait their completion.""" await asyncio.gather(*[worker.wait() for worker in self]) diff --git a/src/textual/app.py b/src/textual/app.py index 3e1c689707..e82d564570 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -58,6 +58,7 @@ from ._event_broker import NoHandler, extract_handler_actions from ._path import _make_path_object_relative from ._wait import wait_for_idle +from ._worker_manager import WorkerManager from .actions import ActionParseResult, SkipAction from .await_remove import AwaitRemove from .binding import Binding, Bindings @@ -320,6 +321,7 @@ def __init__( legacy_windows=False, _environ=environ, ) + self._workers = WorkerManager(self) self.error_console = Console(markup=False, stderr=True) self.driver_class = driver_class or self.get_driver_class() self._screen_stack: list[Screen] = [] @@ -431,6 +433,11 @@ def __init__( self.set_class(self.dark, "-dark-mode") self.set_class(not self.dark, "-light-mode") + @property + def workers(self) -> WorkerManager: + """A worker manager.""" + return self._workers + @property def return_value(self) -> ReturnType | None: """ReturnType | None: The return type of the app.""" diff --git a/src/textual/dom.py b/src/textual/dom.py index 9246073592..1fa76cb997 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -214,9 +214,7 @@ def auto_refresh(self, interval: float | None) -> None: @property def workers(self) -> WorkerManager: """A worker manager.""" - if self._worker_manager is None: - self._worker_manager = WorkerManager(self.app, self) - return self._worker_manager + return self.app.workers def run_worker( self, @@ -228,8 +226,9 @@ def run_worker( start: bool = True, exclusive: bool = True, ) -> Worker[ResultType]: - worker: Worker[ResultType] = self.workers._run( + worker: Worker[ResultType] = self.workers._new_worker( work, + self, name=name, group=group, description=description, From 48867d3602281c96e4fe40f2b5c643b2aba17ac3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 15:27:53 +0100 Subject: [PATCH 10/46] cancel nodes --- src/textual/app.py | 1 + src/textual/dom.py | 1 + src/textual/widget.py | 3 +++ 3 files changed, 5 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index e82d564570..438a9beb7b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1633,6 +1633,7 @@ async def invoke_ready_callback() -> None: except asyncio.CancelledError: pass finally: + self.workers.cancel_all() self._running = False try: await self.animator.stop() diff --git a/src/textual/dom.py b/src/textual/dom.py index 1fa76cb997..538c46cd33 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -923,6 +923,7 @@ def set_styles(self, css: str | None = None, **update_styles) -> Self: styles = self.styles for key, value in update_styles.items(): setattr(styles, key, value) + return self def has_class(self, *class_names: str) -> bool: """Check if the Node has all the given class names. diff --git a/src/textual/widget.py b/src/textual/widget.py index 09eda4f541..d387523cb5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2904,6 +2904,9 @@ def _on_hide(self, event: events.Hide) -> None: def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None: self.scroll_to_region(message.region, animate=True) + def _on_unmount(self) -> None: + self.workers.cancel_node(self) + def action_scroll_home(self) -> None: if not self._allow_scroll: raise SkipAction() From 6b2cf1fcaf45f6a4b1070f0137884ff0e58ffaa7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 15:43:33 +0100 Subject: [PATCH 11/46] typing fix --- src/textual/_animator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index d7f9ad67b7..fa8c1fb640 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -27,7 +27,7 @@ class AnimationError(Exception): """An issue prevented animation from starting.""" -ReturnType = TypeVar("T") +ReturnType = TypeVar("ReturnType") @runtime_checkable From a716f6e08e8d6a13955d59ffe1d255e39d615d3a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 15:48:09 +0100 Subject: [PATCH 12/46] revert change --- src/textual/_animator.py | 6 ++---- src/textual/app.py | 14 +++++++------- src/textual/pilot.py | 10 +++++----- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index fa8c1fb640..060da0c984 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -27,14 +27,12 @@ class AnimationError(Exception): """An issue prevented animation from starting.""" -ReturnType = TypeVar("ReturnType") +T = TypeVar("T") @runtime_checkable class Animatable(Protocol): - def blend( - self: ReturnType, destination: ReturnType, factor: float - ) -> ReturnType: # pragma: no cover + def blend(self: T, destination: T, factor: float) -> T: # pragma: no cover ... diff --git a/src/textual/app.py b/src/textual/app.py index d9a51be817..899f136ec6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -148,7 +148,7 @@ class CssPathError(Exception): """Raised when supplied CSS path(s) are invalid.""" -ReturnType = TypeVar("ReturnType") +T = TypeVar("ReturnType") class _NullFile: @@ -233,7 +233,7 @@ def stop(self) -> None: @rich.repr.auto -class App(Generic[ReturnType], DOMNode): +class App(Generic[T], DOMNode): """The base class for Textual Applications. Args: driver_class: Driver class or ``None`` to auto-detect. Defaults to None. @@ -414,7 +414,7 @@ def __init__( self._loop: asyncio.AbstractEventLoop | None = None self._thread_id: int = 0 - self._return_value: ReturnType | None = None + self._return_value: T | None = None self._exit = False self.css_monitor = ( @@ -435,7 +435,7 @@ def workers(self) -> WorkerManager: return self._workers @property - def return_value(self) -> ReturnType | None: + def return_value(self) -> T | None: """The return type of the app.""" return self._return_value @@ -519,7 +519,7 @@ def screen_stack(self) -> list[Screen]: return self._screen_stack.copy() def exit( - self, result: ReturnType | None = None, message: RenderableType | None = None + self, result: T | None = None, message: RenderableType | None = None ) -> None: """Exit the app, and return the supplied result. @@ -972,7 +972,7 @@ async def run_async( headless: bool = False, size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, - ) -> ReturnType | None: + ) -> T | None: """Run the app asynchronously. Args: @@ -1031,7 +1031,7 @@ def run( headless: bool = False, size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, - ) -> ReturnType | None: + ) -> T | None: """Run the app. Args: diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 6e2ea02391..3370b9c971 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -6,7 +6,7 @@ import rich.repr from ._wait import wait_for_idle -from .app import App, ReturnType +from .app import App, T from .events import Click, MouseDown, MouseMove, MouseUp from .geometry import Offset from .widget import Widget @@ -38,17 +38,17 @@ def _get_mouse_message_arguments( @rich.repr.auto(angular=True) -class Pilot(Generic[ReturnType]): +class Pilot(Generic[T]): """Pilot object to drive an app.""" - def __init__(self, app: App[ReturnType]) -> None: + def __init__(self, app: App[T]) -> None: self._app = app def __rich_repr__(self) -> rich.repr.Result: yield "app", self._app @property - def app(self) -> App[ReturnType]: + def app(self) -> App[T]: """App: A reference to the application.""" return self._app @@ -149,7 +149,7 @@ async def wait_for_scheduled_animations(self) -> None: await self._app.animator.wait_until_complete() await wait_for_idle() - async def exit(self, result: ReturnType) -> None: + async def exit(self, result: T) -> None: """Exit the app with the given result. Args: From d5fc068e06ff8e13d60625fed12cbcd9f7397f68 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 15:55:48 +0100 Subject: [PATCH 13/46] typing fixes and cleanup --- src/textual/_animator.py | 6 ++++-- src/textual/app.py | 15 +++++++-------- src/textual/dom.py | 1 - src/textual/message_pump.py | 3 ++- src/textual/pilot.py | 10 +++++----- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 060da0c984..d7f9ad67b7 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -27,12 +27,14 @@ class AnimationError(Exception): """An issue prevented animation from starting.""" -T = TypeVar("T") +ReturnType = TypeVar("T") @runtime_checkable class Animatable(Protocol): - def blend(self: T, destination: T, factor: float) -> T: # pragma: no cover + def blend( + self: ReturnType, destination: ReturnType, factor: float + ) -> ReturnType: # pragma: no cover ... diff --git a/src/textual/app.py b/src/textual/app.py index 899f136ec6..93adc6803a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -148,7 +148,7 @@ class CssPathError(Exception): """Raised when supplied CSS path(s) are invalid.""" -T = TypeVar("ReturnType") +ReturnType = TypeVar("ReturnType") class _NullFile: @@ -233,7 +233,7 @@ def stop(self) -> None: @rich.repr.auto -class App(Generic[T], DOMNode): +class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications. Args: driver_class: Driver class or ``None`` to auto-detect. Defaults to None. @@ -413,8 +413,7 @@ def __init__( self.devtools = DevtoolsClient() self._loop: asyncio.AbstractEventLoop | None = None - self._thread_id: int = 0 - self._return_value: T | None = None + self._return_value: ReturnType | None = None self._exit = False self.css_monitor = ( @@ -435,7 +434,7 @@ def workers(self) -> WorkerManager: return self._workers @property - def return_value(self) -> T | None: + def return_value(self) -> ReturnType | None: """The return type of the app.""" return self._return_value @@ -519,7 +518,7 @@ def screen_stack(self) -> list[Screen]: return self._screen_stack.copy() def exit( - self, result: T | None = None, message: RenderableType | None = None + self, result: ReturnType | None = None, message: RenderableType | None = None ) -> None: """Exit the app, and return the supplied result. @@ -972,7 +971,7 @@ async def run_async( headless: bool = False, size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, - ) -> T | None: + ) -> ReturnType | None: """Run the app asynchronously. Args: @@ -1031,7 +1030,7 @@ def run( headless: bool = False, size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, - ) -> T | None: + ) -> ReturnType | None: """Run the app. Args: diff --git a/src/textual/dom.py b/src/textual/dom.py index 9fdae5e5cd..f39d9b3261 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -174,7 +174,6 @@ def __init__( ) self._has_hover_style: bool = False self._has_focus_within: bool = False - self._worker_manager: WorkerManager | None = None super().__init__() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 9439087261..38df2c1c15 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -86,7 +86,7 @@ def __init__(self, parent: MessagePump | None = None) -> None: self._max_idle: float | None = None self._mounted_event = asyncio.Event() self._next_callbacks: list[CallbackType] = [] - self._thread_id = threading.get_ident() + self._thread_id: int = 0 @property def _prevent_message_types_stack(self) -> list[set[type[Message]]]: @@ -454,6 +454,7 @@ def _post_mount(self): async def _process_messages_loop(self) -> None: """Process messages until the queue is closed.""" _rich_traceback_guard = True + self._thread_id = threading.get_ident() while not self._closed: try: message = await self._get_message() diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 3370b9c971..6e2ea02391 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -6,7 +6,7 @@ import rich.repr from ._wait import wait_for_idle -from .app import App, T +from .app import App, ReturnType from .events import Click, MouseDown, MouseMove, MouseUp from .geometry import Offset from .widget import Widget @@ -38,17 +38,17 @@ def _get_mouse_message_arguments( @rich.repr.auto(angular=True) -class Pilot(Generic[T]): +class Pilot(Generic[ReturnType]): """Pilot object to drive an app.""" - def __init__(self, app: App[T]) -> None: + def __init__(self, app: App[ReturnType]) -> None: self._app = app def __rich_repr__(self) -> rich.repr.Result: yield "app", self._app @property - def app(self) -> App[T]: + def app(self) -> App[ReturnType]: """App: A reference to the application.""" return self._app @@ -149,7 +149,7 @@ async def wait_for_scheduled_animations(self) -> None: await self._app.animator.wait_until_complete() await wait_for_idle() - async def exit(self, result: T) -> None: + async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. Args: From a3e941a1edb4f2b896b30076be2cbfd6cedf45c2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 15:57:14 +0100 Subject: [PATCH 14/46] revert typing --- src/textual/_animator.py | 6 ++---- src/textual/_work_decorator.py | 32 ++++++++++++++++---------------- src/textual/app.py | 14 +++++++------- src/textual/pilot.py | 10 +++++----- 4 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index d7f9ad67b7..060da0c984 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -27,14 +27,12 @@ class AnimationError(Exception): """An issue prevented animation from starting.""" -ReturnType = TypeVar("T") +T = TypeVar("T") @runtime_checkable class Animatable(Protocol): - def blend( - self: ReturnType, destination: ReturnType, factor: float - ) -> ReturnType: # pragma: no cover + def blend(self: T, destination: T, factor: float) -> T: # pragma: no cover ... diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 9e3659a4f8..ecce3b6337 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -11,61 +11,61 @@ FactoryParamSpec = ParamSpec("FactoryParamSpec") DecoratorParamSpec = ParamSpec("DecoratorParamSpec") -ReturnType = TypeVar("ReturnType") +T = TypeVar("ReturnType") Decorator: TypeAlias = Callable[ [ Union[ - Callable[DecoratorParamSpec, ReturnType], - Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]], + Callable[DecoratorParamSpec, T], + Callable[DecoratorParamSpec, Coroutine[None, None, T]], ] ], - Callable[DecoratorParamSpec, "Worker[ReturnType]"], + Callable[DecoratorParamSpec, "Worker[T]"], ] @overload def work( - method: Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] -) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: + method: Callable[FactoryParamSpec, Coroutine[None, None, T]] +) -> Callable[FactoryParamSpec, "Worker[T]"]: ... @overload def work( - method: Callable[FactoryParamSpec, ReturnType] -) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: + method: Callable[FactoryParamSpec, T] +) -> Callable[FactoryParamSpec, "Worker[T]"]: ... @overload -def work(*, exclusive: bool = False) -> Decorator[..., ReturnType]: +def work(*, exclusive: bool = False) -> Decorator[..., T]: ... def work( - method: Callable[FactoryParamSpec, ReturnType] - | Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] + method: Callable[FactoryParamSpec, T] + | Callable[FactoryParamSpec, Coroutine[None, None, T]] | None = None, *, name: str = "", group: str = "default", exclusive: bool = False, -) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator: +) -> Callable[FactoryParamSpec, Worker[T]] | Decorator: """Worker decorator factory.""" def decorator( method: ( - Callable[DecoratorParamSpec, ReturnType] - | Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]] + Callable[DecoratorParamSpec, T] + | Callable[DecoratorParamSpec, Coroutine[None, None, T]] ) - ) -> Callable[DecoratorParamSpec, Worker[ReturnType]]: + ) -> Callable[DecoratorParamSpec, Worker[T]]: """The decorator.""" @wraps(method) def decorated( *args: DecoratorParamSpec.args, **kwargs: DecoratorParamSpec.kwargs - ) -> Worker[ReturnType]: + ) -> Worker[T]: """The replaced callable.""" from .dom import DOMNode diff --git a/src/textual/app.py b/src/textual/app.py index 93adc6803a..504ec21056 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -148,7 +148,7 @@ class CssPathError(Exception): """Raised when supplied CSS path(s) are invalid.""" -ReturnType = TypeVar("ReturnType") +T = TypeVar("ReturnType") class _NullFile: @@ -233,7 +233,7 @@ def stop(self) -> None: @rich.repr.auto -class App(Generic[ReturnType], DOMNode): +class App(Generic[T], DOMNode): """The base class for Textual Applications. Args: driver_class: Driver class or ``None`` to auto-detect. Defaults to None. @@ -413,7 +413,7 @@ def __init__( self.devtools = DevtoolsClient() self._loop: asyncio.AbstractEventLoop | None = None - self._return_value: ReturnType | None = None + self._return_value: T | None = None self._exit = False self.css_monitor = ( @@ -434,7 +434,7 @@ def workers(self) -> WorkerManager: return self._workers @property - def return_value(self) -> ReturnType | None: + def return_value(self) -> T | None: """The return type of the app.""" return self._return_value @@ -518,7 +518,7 @@ def screen_stack(self) -> list[Screen]: return self._screen_stack.copy() def exit( - self, result: ReturnType | None = None, message: RenderableType | None = None + self, result: T | None = None, message: RenderableType | None = None ) -> None: """Exit the app, and return the supplied result. @@ -971,7 +971,7 @@ async def run_async( headless: bool = False, size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, - ) -> ReturnType | None: + ) -> T | None: """Run the app asynchronously. Args: @@ -1030,7 +1030,7 @@ def run( headless: bool = False, size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, - ) -> ReturnType | None: + ) -> T | None: """Run the app. Args: diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 6e2ea02391..3370b9c971 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -6,7 +6,7 @@ import rich.repr from ._wait import wait_for_idle -from .app import App, ReturnType +from .app import App, T from .events import Click, MouseDown, MouseMove, MouseUp from .geometry import Offset from .widget import Widget @@ -38,17 +38,17 @@ def _get_mouse_message_arguments( @rich.repr.auto(angular=True) -class Pilot(Generic[ReturnType]): +class Pilot(Generic[T]): """Pilot object to drive an app.""" - def __init__(self, app: App[ReturnType]) -> None: + def __init__(self, app: App[T]) -> None: self._app = app def __rich_repr__(self) -> rich.repr.Result: yield "app", self._app @property - def app(self) -> App[ReturnType]: + def app(self) -> App[T]: """App: A reference to the application.""" return self._app @@ -149,7 +149,7 @@ async def wait_for_scheduled_animations(self) -> None: await self._app.animator.wait_until_complete() await wait_for_idle() - async def exit(self, result: ReturnType) -> None: + async def exit(self, result: T) -> None: """Exit the app with the given result. Args: From e6e824c520b83c381c1807649b5f2ea3bbb0ce49 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 16:31:36 +0100 Subject: [PATCH 15/46] test fix --- src/textual/_animator.py | 6 ++++-- src/textual/_work_decorator.py | 32 ++++++++++++++++---------------- src/textual/app.py | 14 +++++++------- src/textual/dom.py | 1 + src/textual/message_pump.py | 8 +++----- src/textual/pilot.py | 10 +++++----- 6 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 060da0c984..d7f9ad67b7 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -27,12 +27,14 @@ class AnimationError(Exception): """An issue prevented animation from starting.""" -T = TypeVar("T") +ReturnType = TypeVar("T") @runtime_checkable class Animatable(Protocol): - def blend(self: T, destination: T, factor: float) -> T: # pragma: no cover + def blend( + self: ReturnType, destination: ReturnType, factor: float + ) -> ReturnType: # pragma: no cover ... diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index ecce3b6337..9e3659a4f8 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -11,61 +11,61 @@ FactoryParamSpec = ParamSpec("FactoryParamSpec") DecoratorParamSpec = ParamSpec("DecoratorParamSpec") -T = TypeVar("ReturnType") +ReturnType = TypeVar("ReturnType") Decorator: TypeAlias = Callable[ [ Union[ - Callable[DecoratorParamSpec, T], - Callable[DecoratorParamSpec, Coroutine[None, None, T]], + Callable[DecoratorParamSpec, ReturnType], + Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]], ] ], - Callable[DecoratorParamSpec, "Worker[T]"], + Callable[DecoratorParamSpec, "Worker[ReturnType]"], ] @overload def work( - method: Callable[FactoryParamSpec, Coroutine[None, None, T]] -) -> Callable[FactoryParamSpec, "Worker[T]"]: + method: Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] +) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... @overload def work( - method: Callable[FactoryParamSpec, T] -) -> Callable[FactoryParamSpec, "Worker[T]"]: + method: Callable[FactoryParamSpec, ReturnType] +) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... @overload -def work(*, exclusive: bool = False) -> Decorator[..., T]: +def work(*, exclusive: bool = False) -> Decorator[..., ReturnType]: ... def work( - method: Callable[FactoryParamSpec, T] - | Callable[FactoryParamSpec, Coroutine[None, None, T]] + method: Callable[FactoryParamSpec, ReturnType] + | Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] | None = None, *, name: str = "", group: str = "default", exclusive: bool = False, -) -> Callable[FactoryParamSpec, Worker[T]] | Decorator: +) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator: """Worker decorator factory.""" def decorator( method: ( - Callable[DecoratorParamSpec, T] - | Callable[DecoratorParamSpec, Coroutine[None, None, T]] + Callable[DecoratorParamSpec, ReturnType] + | Callable[DecoratorParamSpec, Coroutine[None, None, ReturnType]] ) - ) -> Callable[DecoratorParamSpec, Worker[T]]: + ) -> Callable[DecoratorParamSpec, Worker[ReturnType]]: """The decorator.""" @wraps(method) def decorated( *args: DecoratorParamSpec.args, **kwargs: DecoratorParamSpec.kwargs - ) -> Worker[T]: + ) -> Worker[ReturnType]: """The replaced callable.""" from .dom import DOMNode diff --git a/src/textual/app.py b/src/textual/app.py index 504ec21056..93adc6803a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -148,7 +148,7 @@ class CssPathError(Exception): """Raised when supplied CSS path(s) are invalid.""" -T = TypeVar("ReturnType") +ReturnType = TypeVar("ReturnType") class _NullFile: @@ -233,7 +233,7 @@ def stop(self) -> None: @rich.repr.auto -class App(Generic[T], DOMNode): +class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications. Args: driver_class: Driver class or ``None`` to auto-detect. Defaults to None. @@ -413,7 +413,7 @@ def __init__( self.devtools = DevtoolsClient() self._loop: asyncio.AbstractEventLoop | None = None - self._return_value: T | None = None + self._return_value: ReturnType | None = None self._exit = False self.css_monitor = ( @@ -434,7 +434,7 @@ def workers(self) -> WorkerManager: return self._workers @property - def return_value(self) -> T | None: + def return_value(self) -> ReturnType | None: """The return type of the app.""" return self._return_value @@ -518,7 +518,7 @@ def screen_stack(self) -> list[Screen]: return self._screen_stack.copy() def exit( - self, result: T | None = None, message: RenderableType | None = None + self, result: ReturnType | None = None, message: RenderableType | None = None ) -> None: """Exit the app, and return the supplied result. @@ -971,7 +971,7 @@ async def run_async( headless: bool = False, size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, - ) -> T | None: + ) -> ReturnType | None: """Run the app asynchronously. Args: @@ -1030,7 +1030,7 @@ def run( headless: bool = False, size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, - ) -> T | None: + ) -> ReturnType | None: """Run the app. Args: diff --git a/src/textual/dom.py b/src/textual/dom.py index f39d9b3261..94264e0b81 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -236,6 +236,7 @@ def run_worker( ) return worker + @property def is_modal(self) -> bool: """Is the node a modal?""" return False diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 38df2c1c15..cbdeabef5e 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -86,7 +86,7 @@ def __init__(self, parent: MessagePump | None = None) -> None: self._max_idle: float | None = None self._mounted_event = asyncio.Event() self._next_callbacks: list[CallbackType] = [] - self._thread_id: int = 0 + self._thread_id: int = threading.get_ident() @property def _prevent_message_types_stack(self) -> list[set[type[Message]]]: @@ -630,12 +630,10 @@ def post_message(self, message: Message) -> bool: # Add a copy of the prevented message types to the message # This is so that prevented messages are honoured by the event's handler message._prevent.update(self._get_prevented_messages()) - if self._thread_id != threading.get_ident(): + if self._thread_id != threading.get_ident() and self.app._loop is not None: # If we're not calling from the same thread, make it threadsafe loop = self.app._loop - asyncio.run_coroutine_threadsafe( - self._message_queue.put(message), loop=loop - ) + loop.call_soon_threadsafe(self._message_queue.put_nowait, message) else: self._message_queue.put_nowait(message) return True diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 3370b9c971..6e2ea02391 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -6,7 +6,7 @@ import rich.repr from ._wait import wait_for_idle -from .app import App, T +from .app import App, ReturnType from .events import Click, MouseDown, MouseMove, MouseUp from .geometry import Offset from .widget import Widget @@ -38,17 +38,17 @@ def _get_mouse_message_arguments( @rich.repr.auto(angular=True) -class Pilot(Generic[T]): +class Pilot(Generic[ReturnType]): """Pilot object to drive an app.""" - def __init__(self, app: App[T]) -> None: + def __init__(self, app: App[ReturnType]) -> None: self._app = app def __rich_repr__(self) -> rich.repr.Result: yield "app", self._app @property - def app(self) -> App[T]: + def app(self) -> App[ReturnType]: """App: A reference to the application.""" return self._app @@ -149,7 +149,7 @@ async def wait_for_scheduled_animations(self) -> None: await self._app.animator.wait_until_complete() await wait_for_idle() - async def exit(self, result: T) -> None: + async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. Args: From efc0e6ca5185d51d20aef49f3cf2e3dd186b42fb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 18:03:51 +0100 Subject: [PATCH 16/46] cancel group --- src/textual/_worker_manager.py | 24 +++++++++++++++++------- tests/test_worker_manager.py | 29 +++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/textual/_worker_manager.py b/src/textual/_worker_manager.py index 33c839dd55..72c9e66e82 100644 --- a/src/textual/_worker_manager.py +++ b/src/textual/_worker_manager.py @@ -3,7 +3,7 @@ import asyncio from collections import Counter from operator import attrgetter -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any, Iterable, Iterator import rich.repr @@ -68,7 +68,7 @@ def add_worker( exclusive: Cancel all workers in the same group as `worker`. """ if exclusive and worker.group: - self.cancel_group(worker.group) + self.cancel_group(worker.node, worker.group) self._workers.add(worker) if start: worker._start(self._app, self._remove_worker) @@ -125,16 +125,21 @@ def cancel_all(self) -> None: for worker in self._workers: worker.cancel() - def cancel_group(self, group: str) -> list[Worker]: + def cancel_group(self, node: DOMNode, group: str) -> list[Worker]: """Cancel a single group. Args: + node: Worker DOM node. group: A group name. Return: A list of workers that were cancelled. """ - workers = [worker for worker in self._workers if worker.group == group] + workers = [ + worker + for worker in self._workers + if (worker.group == group and worker.node == node) + ] for worker in workers: worker.cancel() return workers @@ -146,6 +151,11 @@ def cancel_node(self, node: DOMNode) -> list[Worker]: worker.cancel() return workers - async def wait_for_complete(self) -> None: - """Cancel all tasks and wait their completion.""" - await asyncio.gather(*[worker.wait() for worker in self]) + async def wait_for_complete(self, workers: Iterable[Worker] | None = None) -> None: + """Wait for workers to complete. + + Args: + workers: An iterable of workers or None to wait for all workers in the manager. + """ + + await asyncio.gather(*[worker.wait() for worker in (workers or self)]) diff --git a/tests/test_worker_manager.py b/tests/test_worker_manager.py index 2eebdb5d87..0b582b42a3 100644 --- a/tests/test_worker_manager.py +++ b/tests/test_worker_manager.py @@ -2,17 +2,27 @@ import time from textual.app import App, ComposeResult -from textual.reactive import var from textual.widget import Widget from textual.worker import Worker, WorkerState -async def test_run_worker_async(): +def test_worker_manager_init(): + app = App() + assert isinstance(repr(app.workers), str) + assert not bool(app.workers) + assert len(app.workers) == 0 + assert list(app.workers) == [] + assert list(reversed(app.workers)) == [] + + +async def test_run_worker_async() -> None: """Check self.run_worker""" worker_events: list[Worker.StateChanged] = [] work_result: str = "" + new_worker: Worker + class WorkerWidget(Widget): async def work(self) -> str: nonlocal work_result @@ -21,7 +31,8 @@ async def work(self) -> str: return "foo" def on_mount(self): - self.run_worker(self.work) + nonlocal new_worker + new_worker = self.run_worker(self.work, start=False) def on_worker_state_changed(self, event) -> None: worker_events.append(event) @@ -32,8 +43,11 @@ def compose(self) -> ComposeResult: app = WorkerApp() async with app.run_test(): - worker_widget = app.query_one(WorkerWidget) - await worker_widget.workers.wait_for_complete() + assert new_worker in app.workers + assert len(app.workers) == 1 + app.workers.start_all() + await app.workers.wait_for_complete() + assert len(app.workers) == 0 assert work_result == "foo" assert isinstance(worker_events[0].worker.node, WorkerWidget) @@ -45,7 +59,7 @@ def compose(self) -> ComposeResult: ] -async def test_run_worker_thread(): +async def test_run_worker_thread() -> None: """Check self.run_worker""" worker_events: list[Worker.StateChanged] = [] @@ -70,8 +84,7 @@ def compose(self) -> ComposeResult: app = WorkerApp() async with app.run_test(): - worker_widget = app.query_one(WorkerWidget) - await worker_widget.workers.wait_for_complete() + await app.workers.wait_for_complete() assert work_result == "foo" assert isinstance(worker_events[0].worker.node, WorkerWidget) From 56969b1abcc07244ad451d5964785eace6104168 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 18:29:49 +0100 Subject: [PATCH 17/46] Added test for worker --- src/textual/worker.py | 4 ++-- tests/test_work_decorator.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/test_work_decorator.py diff --git a/src/textual/worker.py b/src/textual/worker.py index fb13cf93e0..912e44749a 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -13,6 +13,7 @@ Generic, TypeVar, Union, + cast, ) import rich.repr @@ -335,5 +336,4 @@ async def wait(self) -> ResultType: elif self.state == WorkerState.CANCELLED: raise WorkerCancelled("Worker was cancelled, and did not complete.") - assert self._result is not None - return self._result + return cast("ResultType", self._result) diff --git a/tests/test_work_decorator.py b/tests/test_work_decorator.py new file mode 100644 index 0000000000..2eb9d7ba78 --- /dev/null +++ b/tests/test_work_decorator.py @@ -0,0 +1,31 @@ +import asyncio + +from textual import work +from textual.app import App +from textual.worker import Worker, WorkerState + + +async def test_work() -> None: + states: list[WorkerState] = [] + worker: Worker[str] | None = None + + class WorkApp(App): + @work + async def foo(self) -> str: + await asyncio.sleep(0.1) + return "foo" + + def on_mount(self) -> None: + nonlocal worker + worker = self.foo() + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + states.append(event.state) + + app = WorkApp() + async with app.run_test() as pilot: + assert isinstance(worker, Worker) + await app.workers.wait_for_complete() + assert await worker.wait() == "foo" + await pilot.pause() + assert states == [WorkerState.PENDING, WorkerState.RUNNING, WorkerState.SUCCESS] From 8408c2a4b7181485cb702b219666c237152f82b7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 2 Apr 2023 18:39:03 +0100 Subject: [PATCH 18/46] comment --- tests/test_work_decorator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_work_decorator.py b/tests/test_work_decorator.py index 2eb9d7ba78..e743d24ace 100644 --- a/tests/test_work_decorator.py +++ b/tests/test_work_decorator.py @@ -6,6 +6,7 @@ async def test_work() -> None: + """Test basic usage of the @work decorator.""" states: list[WorkerState] = [] worker: Worker[str] | None = None From bca93f430c41f9dbc12e7cebac582546cdc5fe75 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 16:27:06 +0100 Subject: [PATCH 19/46] workers docs --- docs/api/worker.md | 1 + docs/api/worker_manager.md | 1 + docs/examples/guide/workers/weather.css | 16 ++ docs/examples/guide/workers/weather01.py | 40 ++++ docs/examples/guide/workers/weather02.py | 40 ++++ docs/examples/guide/workers/weather03.py | 42 ++++ docs/examples/guide/workers/weather04.py | 47 ++++ docs/examples/guide/workers/weather05.py | 52 +++++ docs/guide/workers.md | 155 +++++++++++++ docs/images/weather.svg | 242 ++++++++++++++++++++ docs/images/workers/lifetime.excalidraw.svg | 16 ++ mkdocs-nav.yml | 3 + src/textual/app.py | 3 +- src/textual/dom.py | 17 +- src/textual/worker.py | 14 +- 15 files changed, 685 insertions(+), 4 deletions(-) create mode 100644 docs/api/worker.md create mode 100644 docs/api/worker_manager.md create mode 100644 docs/examples/guide/workers/weather.css create mode 100644 docs/examples/guide/workers/weather01.py create mode 100644 docs/examples/guide/workers/weather02.py create mode 100644 docs/examples/guide/workers/weather03.py create mode 100644 docs/examples/guide/workers/weather04.py create mode 100644 docs/examples/guide/workers/weather05.py create mode 100644 docs/guide/workers.md create mode 100644 docs/images/weather.svg create mode 100644 docs/images/workers/lifetime.excalidraw.svg diff --git a/docs/api/worker.md b/docs/api/worker.md new file mode 100644 index 0000000000..0889e0f5a1 --- /dev/null +++ b/docs/api/worker.md @@ -0,0 +1 @@ +::: textual.worker diff --git a/docs/api/worker_manager.md b/docs/api/worker_manager.md new file mode 100644 index 0000000000..80e847b6c6 --- /dev/null +++ b/docs/api/worker_manager.md @@ -0,0 +1 @@ +::: textual._worker_manager diff --git a/docs/examples/guide/workers/weather.css b/docs/examples/guide/workers/weather.css new file mode 100644 index 0000000000..fab898500a --- /dev/null +++ b/docs/examples/guide/workers/weather.css @@ -0,0 +1,16 @@ +Input { + dock: top; + width: 100%; +} + +#weather-container { + width: 100%; + height: 1fr; + align: center middle; + overflow: auto; +} + +#weather { + width: auto; + height: auto; +} diff --git a/docs/examples/guide/workers/weather01.py b/docs/examples/guide/workers/weather01.py new file mode 100644 index 0000000000..f8f9338d06 --- /dev/null +++ b/docs/examples/guide/workers/weather01.py @@ -0,0 +1,40 @@ +import httpx +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Input, Static + + +class WeatherApp(App): + """App to display the current weather.""" + + CSS_PATH = "weather.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter a City") + with VerticalScroll(id="weather-container"): + yield Static(id="weather") + + async def on_input_changed(self, message: Input.Changed) -> None: + """Called when the input changes""" + await self.update_weather(message.value) + + async def update_weather(self, city: str) -> None: + """Update the weather for the given city.""" + weather_widget = self.query_one("#weather", Static) + if city: + # Query the network API + url = f"https://wttr.in/{city}" + async with httpx.AsyncClient() as client: + response = await client.get(url) + weather = Text.from_ansi(response.text) + weather_widget.update(weather) + else: + # No city, so just blank out the weather + weather_widget.update("") + + +if __name__ == "__main__": + app = WeatherApp() + app.run() diff --git a/docs/examples/guide/workers/weather02.py b/docs/examples/guide/workers/weather02.py new file mode 100644 index 0000000000..25b2b24049 --- /dev/null +++ b/docs/examples/guide/workers/weather02.py @@ -0,0 +1,40 @@ +import httpx +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Input, Static + + +class WeatherApp(App): + """App to display the current weather.""" + + CSS_PATH = "weather.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter a City") + with VerticalScroll(id="weather-container"): + yield Static(id="weather") + + async def on_input_changed(self, message: Input.Changed) -> None: + """Called when the input changes""" + self.run_worker(self.update_weather(message.value), exclusive=True) + + async def update_weather(self, city: str) -> None: + """Update the weather for the given city.""" + weather_widget = self.query_one("#weather", Static) + if city: + # Query the network API + url = f"https://wttr.in/{city}" + async with httpx.AsyncClient() as client: + response = await client.get(url) + weather = Text.from_ansi(response.text) + weather_widget.update(weather) + else: + # No city, so just blank out the weather + weather_widget.update("") + + +if __name__ == "__main__": + app = WeatherApp() + app.run() diff --git a/docs/examples/guide/workers/weather03.py b/docs/examples/guide/workers/weather03.py new file mode 100644 index 0000000000..6fc10082f5 --- /dev/null +++ b/docs/examples/guide/workers/weather03.py @@ -0,0 +1,42 @@ +import httpx +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 + + +class WeatherApp(App): + """App to display the current weather.""" + + CSS_PATH = "weather.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter a City") + with VerticalScroll(id="weather-container"): + yield Static(id="weather") + + async def on_input_changed(self, message: Input.Changed) -> None: + """Called when the input changes""" + self.update_weather(message.value) + + @work(exclusive=True) + async def update_weather(self, city: str) -> None: + """Update the weather for the given city.""" + weather_widget = self.query_one("#weather", Static) + if city: + # Query the network API + url = f"https://wttr.in/{city}" + async with httpx.AsyncClient() as client: + response = await client.get(url) + weather = Text.from_ansi(response.text) + weather_widget.update(weather) + else: + # No city, so just blank out the weather + weather_widget.update("") + + +if __name__ == "__main__": + app = WeatherApp() + app.run() diff --git a/docs/examples/guide/workers/weather04.py b/docs/examples/guide/workers/weather04.py new file mode 100644 index 0000000000..13820927ab --- /dev/null +++ b/docs/examples/guide/workers/weather04.py @@ -0,0 +1,47 @@ +import httpx +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 +from textual.worker import Worker + + +class WeatherApp(App): + """App to display the current weather.""" + + CSS_PATH = "weather.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter a City") + with VerticalScroll(id="weather-container"): + yield Static(id="weather") + + async def on_input_changed(self, message: Input.Changed) -> None: + """Called when the input changes""" + self.update_weather(message.value) + + @work(exclusive=True) + async def update_weather(self, city: str) -> None: + """Update the weather for the given city.""" + weather_widget = self.query_one("#weather", Static) + if city: + # Query the network API + url = f"https://wttr.in/{city}" + async with httpx.AsyncClient() as client: + response = await client.get(url) + weather = Text.from_ansi(response.text) + weather_widget.update(weather) + else: + # No city, so just blank out the weather + weather_widget.update("") + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + """Called when the worker state changes.""" + self.log(event) + + +if __name__ == "__main__": + app = WeatherApp() + app.run() diff --git a/docs/examples/guide/workers/weather05.py b/docs/examples/guide/workers/weather05.py new file mode 100644 index 0000000000..323b814082 --- /dev/null +++ b/docs/examples/guide/workers/weather05.py @@ -0,0 +1,52 @@ +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 +from textual.worker import Worker, get_current_worker + + +class WeatherApp(App): + """App to display the current weather.""" + + CSS_PATH = "weather.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter a City") + with VerticalScroll(id="weather-container"): + yield Static(id="weather") + + async def on_input_changed(self, message: Input.Changed) -> None: + """Called when the input changes""" + self.update_weather(message.value) + + @work(exclusive=True) + def update_weather(self, city: str) -> None: + """Update the weather for the given city.""" + weather_widget = self.query_one("#weather", Static) + worker = get_current_worker() + if city: + # Query the network API + url = f"https://wttr.in/{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.call_from_thread(weather_widget.update, weather) + else: + # No city, so just blank out the weather + if not worker.is_cancelled: + self.call_from_thread(weather_widget.update, "") + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + """Called when the worker state changes.""" + self.log(event) + + +if __name__ == "__main__": + app = WeatherApp() + app.run() diff --git a/docs/guide/workers.md b/docs/guide/workers.md new file mode 100644 index 0000000000..b9c9c72d89 --- /dev/null +++ b/docs/guide/workers.md @@ -0,0 +1,155 @@ +# Workers + +In this chapter we will explore the topic of *concurrency* and how to use Textual's Worker API to make it easier. + +!!! tip "The worker API was added in version 0.18.0" + +## Concurrency + +There are many interesting uses for Textual which required reading data from an internet service. +When an app requests data from the network it is important that it doesn't prevent the user interface from updating. +In other words, the requests should be concurrent (happen at the same time) with the UI updates. + +Managing this concurrency is a tricky topic, in any language or framework. +Even for experienced developers, there are many gotchas which could make your app lock up or behave oddly. +Textual's Worker API makes concurrency less error prone and easier to reason about. + +## Workers + +Before we go in to detail, lets see an example that demonstrates a common pitfall for apps that make network requests. + +The following app uses [httpx](https://www.python-httpx.org/) query the weather with [wttr.in](https://wttr.in/) for any city name you enter in to an input: + +=== "weather01.py" + + ```python title="weather01.py" + --8<-- "docs/examples/guide/workers/weather01.py" + ``` + +=== "weather.css" + + ```sass title="weather.css" + --8<-- "docs/examples/guide/workers/weather.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/workers/weather01.py"} + ``` + +If you were to run this app, you should see weather information update as you type. +But you may find that the input is not as responsive as usual, with a noticeable delay between pressing a key and seeing it echoed in screen. +This is because we are making a request to the weather API within a message handler, and the input will not be able to process the next key until the request has completed (which may be anything from a few hundred milliseconds to several seconds later). + +To resolve this we can use the [run_worker][textual.dom.DOMNode.run_worker] function which runs the `update_weather` coroutine (async function) in the background. Here's the code: + +```python title="weather02.py" hl_lines="22" +--8<-- "docs/examples/guide/workers/weather02.py" +``` + +This one line change will make typing as responsive as you would expect from any app. + +The `run_worker` method schedules a new *worker* to run `update_weather`, and returns a [Worker](textual.worker.Worker) object. +This Worker object has a few useful methods on it, but you can often ignore it as we did in `weather02.py`. + +The call to `run_worker` also sets `exclusive=True` which solves an additional problem with concurrent network requests: when pulling data from the network there is no guarantee that you will receive the responses in the same order as the requests. +For example if you start typing `Paris`, you may get the response for `Pari` *after* the response for `Paris`, which could show the wrong weather information. +The `exclusive` flag tells textual to cancel all previous workers before starting the new one. + +### Work decorator + +An alternative to calling `run_worker` manually is the [work][textual.work] decorator, which automatically generates a worker from the decorator method. + +Let's use this decorator in our weather app: + +```python title="weather03.py" hl_lines="3 23 25" +--8<-- "docs/examples/guide/workers/weather03.py" +``` + +The addition of `@work(exclusive=True)` converts the `update_weather` coroutine in to a regular function which creates and starts a worker. +Note that even though `update_weather` is an `async def` function, the decorator means that we don't need to use the `await` keyword when calling it. + +!!! tip + + The decorator takes the same arguments as `run_worker`. + +### Worker return values + +When you run a worker, the return value of the function won't be available until the work has completed. +You can check the return value of a worker with the `worker.result` attribute which will initially be `None`, but will be replaced with the return value of the function when it completes. + +If you need the return value you can call [worker.wait][textual.worker.Worker.wait] which is a coroutine that will wait for the work to complete. +But note that if you do this in a message handler it will also prevent the widget from updating until the worker returns. +Often a better approach is to handle [worker events](#worker-events). + +### Cancelling workers + +You can cancel a worker at any time before it is finished by calling [Worker.cancel][textual.worker.Worker.cancel]. +This will raise an [asyncio.CancelledError] within the coroutine, and should cause it to exit prematurely. + +### Worker lifetime + +Workers are managed by a single [WorkerManager][textual._worker_manager.WorkerManager] instance, which you can access via `app.workers`. +This is a container like object which you can iterator over to sett all your currently active tasks. + +Workers are tied to the DOM node (widget, screen, or app) where they are created. +This means that if you remove the widget or pop the screen when they are created, then the tasks will be cleaned up automatically. +Similarly if you exit the app, any running tasks will be cancelled. + +Worker objects have a `state` attribute which will contain a [WorkerState][textual.worker.WorkerState] enumeration, which indicates what the worker is doing at any given time. +The `state` attribute will contain one of the following values: + + +| Value | Description | +| --------- | ----------------------------------------------------------------------------------- | +| PENDING | The worker was created, but not yet started. | +| RUNNING | The worker is currently running. | +| CANCELLED | The worker was cancelled and is not longer running. | +| ERROR | The worker raised an exception, and `worker.error` will contain the exception. | +| SUCCESS | The worker completed successful, and `worker.result` will contain the return value. | + +
+--8<-- "docs/images/workers/lifetime.excalidraw.svg" +
+ +### Worker events + +When a worker changes state, it sends a [Worker.StateChanged][textual.worker.Worker.StateChanged] event to the widget where the worker was created. +You can handle this message by defining a `on_worker_state_changed` event handler. +For instance, here is how we might log the state of the worker that updates the weather: + +```python title="weather04.py" hl_lines="4 41 43" +--8<-- "docs/examples/guide/workers/weather04.py" +``` + +If you run the above code with `textual` you should see the worker lifetime events logged in the Textual [console](./devtools.md#console). + +``` +textual run weather04.py --dev +``` + +### Thread workers + +In previous examples we used `run_worker` or the `work` decorator in conjunction with coroutines. +This works well if you are using an async API like `httpx`, but if your API doesn't support async you may need to use *threads*. + +!!! info "What are threads?" + + Threads are a form of concurrency supplied by your Operating System. Threads allow your code to run more than a single function simultaneously. + +You can create threads by applying `run_worker` or the `work` decorator to a plain (non async) method or function. +The API for thread workers is identical to async workers, but there are a few differences you need to be aware of when writing threaded code. + +The first difference 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][textual.app.call_from_thread] method which schedules a call in the main thread. + +The second difference is that you can't cancel threads in the same way as coroutines, but you *can* manually check if the worker was cancelled. + +Let's demonstrate thread workers by replacing `httpx` with `urllib.request` (in the standard library). The `urllib` module is not async aware, so we will need to use threads: + +```python title="weather05.py" hl_lines="1 4 27 30 34-39 42-43" +--8<-- "docs/examples/guide/workers/weather05.py" +``` + +The `update_weather` function doesn't have the `async` keyword, so the `@work` decorator will create a thread worker. +Note the user of [get_current_worker][textual.worker.get_current_worker] which the function uses to check if it has been cancelled or not. diff --git a/docs/images/weather.svg b/docs/images/weather.svg new file mode 100644 index 0000000000..cc90a12690 --- /dev/null +++ b/docs/images/weather.svg @@ -0,0 +1,242 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WeatherApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Edinburgh +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +Weather report: Edinburgh + +   \  /       Partly cloudy + _ /"".-.    8 °C            +   \_(   ).  6 km/h        +   /(___(__)  10 km           +                0.0 mm          +                                                       ┌─────────────┐                                                        +┌──────────────────────────────┬───────────────────────┤  Mon 03 Apr ├───────────────────────┬──────────────────────────────┐ +│            Morning           │             Noon      └──────┬──────┘     Evening           │             Night            │ +├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ +│     \   /     Sunny          │     \   /     Sunny          │     \   /     Sunny          │     \   /     Clear          │ +│      .-.     7 °C           │      .-.     +11(12) °C     │      .-.     +10(9) °C      │      .-.     +5(3) °C       │ +│   ― (   ) ―  3-4 km/h     │   ― (   ) ―  3-4 km/h     │   ― (   ) ―  10-14 km/h   │   ― (   ) ―  8-16 km/h    │ +│      `-’      10 km          │      `-’      10 km          │      `-’      10 km          │      `-’      10 km          │ +│     /   \     0.0 mm | 0%    │     /   \     0.0 mm | 0%    │     /   \     0.0 mm | 0%    │     /   \     0.0 mm | 0%    │ +└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ +                                                       ┌─────────────┐                                                        +┌──────────────────────────────┬───────────────────────┤  Tue 04 Apr ├───────────────────────┬──────────────────────────────┐ +│            Morning           │             Noon      └──────┬──────┘     Evening           │             Night            │ +├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ +│     \   /     Sunny          │               Overcast       │    \  /       Partly cloudy  │    \  /       Partly cloudy  │ +│      .-.     +8(5) °C       │      .--.    +11(9) °C      │  _ /"".-.    +9(7) °C       │  _ /"".-.    +5(3) °C       │ +│   ― (   ) ―  16-19 km/h   │   .-(    ).  19-22 km/h   │    \_(   ).  14-19 km/h   │    \_(   ).  9-17 km/h    │ +│      `-’      10 km          │  (___.__)__)  10 km          │    /(___(__)  10 km          │    /(___(__)  10 km          │ +│     /   \     0.0 mm | 0%    │               0.0 mm | 0%    │               0.0 mm | 0%    │               0.0 mm | 0%    │ +└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ +                                                       ┌─────────────┐                                                        +┌──────────────────────────────┬───────────────────────┤  Wed 05 Apr ├───────────────────────┬──────────────────────────────┐ +│            Morning           │             Noon      └──────┬──────┘     Evening           │             Night            │ +├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ +│      .-.      Light drizzle  │      .-.      Light rain     │      .-.      Light rain     │               Mist           │ +│     (   ).   +8(6) °C       │     (   ).   +9(6) °C       │     (   ).   +9(7) °C       │  _ - _ - _ - +8(6) °C       │ +│    (___(__)  12-16 km/h   │    (___(__)  14-20 km/h   │    (___(__)  12-17 km/h   │   _ - _ - _  9-15 km/h    │ +│     ‘ ‘ ‘ ‘   2 km           │     ‘ ‘ ‘ ‘   9 km           │     ‘ ‘ ‘ ‘   9 km           │  _ - _ - _ -  2 km           │ +│    ‘ ‘ ‘ ‘    0.5 mm | 66%   │    ‘ ‘ ‘ ‘    1.0 mm | 85%   │    ‘ ‘ ‘ ‘    0.9 mm | 70%   │               0.0 mm | 0%    │ +└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ +Location: Edinburgh, City of Edinburgh, Scotland, UK [55.9495628,-3.1914971] +▇▇ + + + diff --git a/docs/images/workers/lifetime.excalidraw.svg b/docs/images/workers/lifetime.excalidraw.svg new file mode 100644 index 0000000000..7beece3f6c --- /dev/null +++ b/docs/images/workers/lifetime.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1baVPb2Fx1MDAxMv2eX8FjvrxXXHUwMDE1lLsvqZp6XHUwMDA1xmE3XHUwMDA2Q1x1MDAxODI1lVx1MDAxMpZsXHUwMDE0y5YjyWxT+e/TksHavG9xxpNiQLqSWvf26XO6b/vvd1tb2+Fz197+uLVtP9VN17F883H7fXT8wfZcdTAwMDPH68ApXHUwMDEy/1x1MDAxZHg9v1x1MDAxZY+8XHUwMDBmw27w8cOHtum37LDrmnXbeHCCnulcdTAwMDZhz3I8o+61Pzih3Vx1MDAwZf5cdTAwMWb9rJht+/eu17ZC30hcdTAwMWWyY1tO6Pn9Z9mu3bY7YVx1MDAwMHf/XHUwMDEz/t7a+jv+mbLOt+uh2Wm6dnxBfCoxkFKcP1rxOrGxXHUwMDE4XHTGtJBiMMBcdPbhcaFtwdlcdTAwMDaYbCdnokPb5lX3XHUwMDBl77eqe7vyed9Sl8K2SkHy1IbjurXw2e3PhFm/7/kpm4LQ91r2jWOF99HDc8dcdTAwMDfXWWZwXHUwMDBmXHUwMDA2XGZO+16ved+xgyBzkdc16074XGbHXHUwMDA0XHUwMDFhXHUwMDFj7E/Cx63kyFx1MDAxM/zFMTWYRFgxSlx1MDAwNJZYXHUwMDBmzkaXY61cZqrhJJeIYsJzZpU8XHUwMDE3XHUwMDE2XHUwMDAyzPpccsWfxK47s95qgnFcdTAwMWQrXHUwMDE5g7lp3jWkTEY9vr2uVIZcdTAwMTZcbiONKVJcXFM1XHUwMDE4cm87zftcdTAwMTDGSGwoxaTgXCJthlx1MDAxZK9cdTAwMDbWjHCuOE5cdTAwMTYrenj3yIpcdTAwMWTjr/R0dazX6XrzlMRX6OuRXHUwMDFmyWtE48t5XHUwMDFmS/tZxtdC+ylcdTAwMWO8XcozyClipuvtn13coMuu9Fx1MDAxYXeB/7g9XHUwMDE49+P98Nv2L25+Obx6OKlwhtzqy65cdTAwMTXUnVaosk95e77p+176vq+/Je/f61pm34GxUIhcdTAwMTPFNIF/g/Ou02nByU7PdZNjXr2V+Py7lMFcdTAwMDWsZd4/XHUwMDFkXHUwMDA3XGJcdTAwMTlcdDPBMSNcZis0Nc6Gz+ZycVx1MDAxNnhcdTAwMTBsllx0M85cZsZcdTAwMTmhSmksUMpRn/vzYyjKXGJcdTAwMDFcdTAwMTfO+vfsMFx1MDAwYn2zXHUwMDEzdE1cdTAwMWY8tlxiNC2LwFwiNFx1MDAwZidNNUWc6lnQlHGZXHUwMDAybJbpgclCe52w5rxEk01Q5ugns+24z5m1il1cdTAwMTOmp1qu7Fx1MDAxZlVcdTAwMGXSU1x1MDAxONjw0NhcdTAwMTdlZviu6zQj792uw2vYfsaxQ1x1MDAwN+hoMKDtWFaaYOpgg1x09/SPpiFcdTAwMDbPd5pOx3SvciaOhdpYWiNcXIzCXHUwMDFiUVx1MDAxYSms9fS0VmXfmp+DoNO6quye41x1MDAxMn8+752GP1x1MDAxN256MqspXHUwMDAzkFx1MDAwND5cZmjTOlx1MDAxNX6iy1x1MDAxOdJcdTAwMDZcdTAwMDCRXHUwMDExKSmjcjFWXHUwMDAz0N7ZYiWshinVRMiZcLhGVvtavSlz+pVcdTAwMWPfXHUwMDFmlppe9aJ5Rbpna2C1sfdccoNcdTAwMTMt9oRoVc6PrPDgK3O/kXBcdPe9OpI8bF+df1x1MDAwMlx1MDAxNdS4vK2f3pa+mctiYZBZRCfesVwiXHUwMDE2XHUwMDA2qlx1MDAxZC12KZOEI8amXHUwMDBlXHUwMDBiw1d/w8OCQFx1MDAwNmaIa8ZcdTAwMTRhJCd2XHUwMDE5pVx1MDAwNldcdTAwMTBcdTAwMWRcdTAwMTFnQi1cdTAwMTZcdTAwMTbGszAvXHUwMDA2glwiXHUwMDBiS1DjTKe5cYUsjDlcdTAwMDRcdTAwMWE5g1x1MDAwNy7GwpfXlcq6WXhcdTAwMDKP5Vn4zcT5WVx1MDAxONKq0XjTQlCm0PR4O74tNTqlSvX+9vbiWphUXFxWm88/XHUwMDE3b1x1MDAxOE1cdTAwMDJcdTAwMWPRXHUwMDEyKFCj4TxcZqxngFx1MDAxOGZcdTAwMDIyXHUwMDAwLlx1MDAxNSaLIO63hnmHXHUwMDEwXz5ccit4MiTIm8rC4vOT2XBcdTAwMGY+v3i7n76Rvd7NXHUwMDE5Kl+ugS0ns1x1MDAxYZFEiiSsrYjVXHUwMDE4XHUwMDFhXcJRQlwiJNT0IFx1MDAxYj6ZXHUwMDFiXHUwMDBlMkok5JZcdTAwMTRccmc1WFx1MDAwYkMgyLOi5JMsXGKysbSGU/F/XGavgdjR8Fx1MDAwZtO1XHUwMDEw28xOuFx1MDAxOLGVdiul8ulpeX+t1DaBXHUwMDFi8tSWXHUwMDE4uVx1MDAwMLkpNVxudkJTTjmZQUv2gm717LrVoGe7J59q5UNZpjVn02HHXHQ1MFx1MDAwMXbgWmFcdTAwMDZcdTAwMDDMcVx1MDAxYlx1MDAwN1BcbklcdTAwMDSSgMnUbM3HbZxwspIkk1x1MDAxMkIxl6nX3Sh623NOXHUwMDBlXHUwMDBmXHUwMDFh4rZ5WKFe46h9IdrN79PS20tDMnH89HX3JXzEN0ffm4H1WS+V3pL5XFxcdTAwMTW9pWJGXHUwMDBlZ1x1MDAxY1x1MDAxMlx1MDAwNFx0jkemxtnwydx0nElpYOBcdTAwMTaRT8timEltXGKmMVJcbsh+QZiNZTeJi8BcdTAwMWFGbpqAf6hU8Fs1uc3igouRW/ny8vxyrcQ2gVx1MDAxOPLE1jdwflJcdTAwMTMp7ZRcdTAwMDebXHUwMDA0mYX59GVTom67pXCPnrfDo1x1MDAwZVx1MDAxNzVxvHfzk+sjk7EmOTJcYpaEaiq1XHUwMDAyuZjjNGkgKpmM9tr0ooVThuqIr2YzUEdX81xyTdjI2dND9/HL+Wm5VSv/QT/f6MpcctuIMuR6XHUwMDE4jafkeD5hXHUwMDAzOUWQXHUwMDEyM1Da8NncdJhpakiEiaZcdTAwMWF0Y3pfvZ+xYYNcdTAwMTCtuKJaLbo/Mb5cdTAwMTBJitBcdTAwMWHCaVx1MDAwMHeFRXqL9t/DabXrUqlcXKutldUmUEOe1d5MXHUwMDFji7Y+2ofAjbKRiVx1MDAxYWgqiPJihlwi5Phdm5+CNjVcdGxcdTAwMDJcdTAwMDHYJERcdTAwMTVcclTCJcFcdTAwMTmwXHUwMDExXHUwMDEwkFxmsjRcdTAwMTJcdTAwMTF8pniy5DzNXHUwMDAw71x1MDAxNlx1MDAxYSspKfymhiBcdTAwMGY0Llx1MDAxMFx1MDAxZuTOUdlcdTAwMWYzJFRcdTAwMDGJSlx1MDAxM0FcdTAwMTVNydx5qI2sitrcmvzesFx1MDAwM3larTXr3/ih/1D94s9EQVx1MDAxY1x1MDAxMc6TXHUwMDE1mlx1MDAwMf1BaPrhntOxnE4z+9avPWLT7MXH8aLei6ZgXHUwMDA3XHUwMDE5XGJcdTAwMGJcdTAwMDE6iIBcdTAwMDYhlGjMSGpcXNPsRlx1MDAxMdTAWiBI/1x1MDAxMSM6yk5cblNrd6zJVo3fm8hZJVx1MDAxMdMgwzCNXHUwMDFljYQuXHUwMDE4XHUwMDA1+olcdTAwMTNcIlx1MDAxMUdRUb1okmtcdTAwMDZhyWu3nVx1MDAxMOa+6jmdMD/H8WTuRiHl3jat/Fl4pfS5fOzpRnfMetGfKV9BacdBg9//ej909M5I4ESfXCJkkvu9S/9/5o6lkUVlLlx1MDAxMKFUseklynBUbHbQhFx1MDAxOTVcdTAwMTRAXHUwMDEyMlx1MDAwMVxyiUCupkyJirqZKEBCRjuUi27cjFxmmmw6gVwiMZKSrCPl1pIziFFrkyc3nt+yfSPG43//t1aVMoHr8yolZ+l8YkXokdUujDGVXHUwMDE0XHUwMDBiOr1aXHUwMDE5v7u1oblcdTAwMDFXwoBRoERAlWTy27hHQYFcXOFa4qgtaMFcdTAwMWWFMcAjXHUwMDE4XHUwMDE5Qlx1MDAwMNNQiiNKXHUwMDFlUvvCglx1MDAxOVx1MDAxNDhcYiOab1p8RSVcdTAwMDRtXHUwMDA0b4Bn2edZo1g5dppcdTAwMTdeT+yVXHUwMDBmXHUwMDFireebxs6Lc329M4tY0ZDGgWJZoViZRVx1MDAxNnCmKSxcdTAwMTVRXGbSRqpkatSbKsBRZlx1MDAxZHe8zadSxm8zZc2BNJ4rXGLL8EBccnpcdTAwMDVcdTAwMTfMIVx1MDAwNlx1MDAxOFx0aH5rwvvFVVxu4JJGjVx1MDAwMpq+9sqnr1x1MDAxN9wgSkuQXHJxkXrwsqPvN1x1MDAxMoDRp1x1MDAwML0liVx1MDAxZTay+qkoaCtQk9OLnuHo2vDYS1x0j1ZcdTAwMTGEJEhNxXVCNdH1IHhcZlx1MDAwMjqTg8ykmbrkslVPonDGbjUwJVx0nylcdTAwMTecX/bMXHUwMDFk6uaSPY99MVE3wf3cNeueXHSqIa978qbOJ3wwXHUwMDFh3bKNscJSplx1MDAxN3pcdTAwMTL4xleJN7M3U1BccujCXHUwMDFjXHUwMDBiXHUwMDEwepk+lVj3aFxmIVx1MDAxNLI/oIx+XGJdXHUwMDE1+LQ2pOJIU5hzyPolK2JcdTAwMTFcdTAwMGLIOjEkR1pcdTAwMTa+sPGKTcZgwXB6b3ajtM/B2X7otnqnO/vd25dcdTAwMTOLXHUwMDFjh1x1MDAwZjuHs31xiEuG5+qrWbb2QVx1MDAwNpNYiug/waJIVZBcdTAwMWE4KuWQcVpjKvkzvmybsVxiqFx1MDAwNsIzloqCXHUwMDBmKEaK8odcdTAwMTmKUlx1MDAwMchcdTAwMTbArL94jVx1MDAwNktuRFx1MDAxZOxcdTAwMTJjjePXyYhcdTAwMWZtYJh6yNxQf9dw4u1GQjA+W1x1MDAwMN+S1Fx1MDAwZtYjXHUwMDBi5aCpXHUwMDE1IUhMv/s7XHUwMDFjYJtcdTAwMWSBpZKGJFxmVFx1MDAwNUXFTlx1MDAwYs6UoVx0QqywZ7XcXHUwMDAwzKdqjlx1MDAwN1x1MDAxMtDgT+jfWPPZ9zr2f9YqeSbohbzk6Vx1MDAxYjif0IFcdTAwMWN1pM5cdTAwMTGUXHUwMDAzvUsxfYVnfIPXZsJMXHUwMDAwXHUwMDFkSYRcdTAwMTSkjIApLrMoQ1EmnzRZsFx1MDAxNaFcZlx1MDAwM0VhXCKRgOxcdTAwMTLkXHUwMDE2xNkhModDjs4kXHUwMDAyXHUwMDA2y+9D90FcYveGRIgv2Fx1MDAxOb8ykVN9aFx1MDAxZZw0d9xd79A+aTzwi6dTNeNuXHUwMDE0SNFU0J9f5OSpebLYXHUwMDE43/m0ld1cdTAwMTFcdTAwMDK5jIGgYI1cdTAwMThcdTAwMDdcdM1ShDmotnBcdTAwMTF9qZfFXHJqw/apfiXBsTPafaNcdTAwMGZXXHUwMDA2hTSeydfe2ynqLVx1MDAwMEmOpdZcdTAwMTLBT/BqKtM3LCBhSZKDjI6FXHUwMDFhglx1MDAwMFx1MDAwNsUxfdI33N03O1x1MDAxNoJ8M7iO4iCNe12y/WacSYPCrFx1MDAxM0JcdTAwMDVd9KtcdTAwMGJjgqFcdTAwMWWS41x1MDAxNSVcdTAwMDeRXHUwMDFhXHQmyDrqLZH6lWqWb4QuJjnKT3W7XHUwMDFiRk65Ttkxgb1cdTAwMGLdnVx1MDAwMyP7YHv3XG7nbbPbrYUwf4NwXG4r41ivk5DM2faDYz/uXHLzjPhcdTAwMTPdNVx1MDAwNnBcdTAwMDRcdTAwMTU75qpcdTAwMWbvfvxcdTAwMDPAVCwrIn0= + + + + PENDINGRUNNINGCANCELLEDERRORSUCCESSWorker.start()worker.cancel()Done!Exception diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 60b69e2287..4b17cbe5fc 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -20,6 +20,7 @@ nav: - "guide/widgets.md" - "guide/animation.md" - "guide/screens.md" + - "guide/workers.md" - "widget_gallery.md" - Reference: - "reference/index.md" @@ -196,6 +197,8 @@ nav: - "api/walk.md" - "api/welcome.md" - "api/widget.md" + - "api/worker.md" + - "api/worker_manager.md" - "roadmap.md" - "Blog": - blog/index.md diff --git a/src/textual/app.py b/src/textual/app.py index 93adc6803a..aeba923dff 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -94,7 +94,8 @@ WINDOWS = PLATFORM == "Windows" # asyncio will warn against resources not being cleared -warnings.simplefilter("always", ResourceWarning) +if constants.DEBUG: + warnings.simplefilter("always", ResourceWarning) # `asyncio.get_event_loop()` is deprecated since Python 3.10: _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0) diff --git a/src/textual/dom.py b/src/textual/dom.py index 94264e0b81..4fa7c67a31 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -218,13 +218,28 @@ def workers(self) -> WorkerManager: def run_worker( self, work: WorkType[ResultType], - *, name: str | None = "", group: str = "default", description: str = "", start: bool = True, exclusive: bool = True, ) -> Worker[ResultType]: + """Run work in a worker. + + A worker runs code in the *background* as an async task or as a thread, so as + to avoid stalling the user interface. + + Args: + work: A function, async function, or an awaitable object. + name: A short string to identify the worker (in logs and debugging). + group: A short string to identify a group of workers. + description: A longer string to store longer information on the worker. + start: Start the worker immediately. + exclusive: Cancel all workers in the same group. + + Returns: + New Worker instance. + """ worker: Worker[ResultType] = self.workers._new_worker( work, self, diff --git a/src/textual/worker.py b/src/textual/worker.py index 912e44749a..4d1039250a 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -258,7 +258,13 @@ async def run(self) -> ResultType: else: assert callable(self._work) loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, self._work) + + def run_work() -> None: + """Set the active worker, and run the work.""" + active_worker.set(self) + return self._work() + + result = await loop.run_in_executor(None, run_work) return result async def _run(self, app: App) -> None: @@ -321,7 +327,11 @@ def cancel(self) -> None: self._task.cancel() async def wait(self) -> ResultType: - """Wait for the work to complete.""" + """Wait for the work to complete. + + Returns: + The return value of the work. + """ if self.state == WorkerState.PENDING: raise WorkerError("Worker must be started before calling this method.") if self._task is not None: From 3461d59cc94f42e07023ecc18d11fd639959030d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 17:23:23 +0100 Subject: [PATCH 20/46] Added exit_on_error --- docs/guide/workers.md | 35 ++++++++++++++++++++-------------- src/textual/_work_decorator.py | 2 ++ src/textual/_worker_manager.py | 3 +++ src/textual/dom.py | 4 ++++ src/textual/worker.py | 14 +++++++++++++- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/docs/guide/workers.md b/docs/guide/workers.md index b9c9c72d89..14d98157ae 100644 --- a/docs/guide/workers.md +++ b/docs/guide/workers.md @@ -2,17 +2,17 @@ In this chapter we will explore the topic of *concurrency* and how to use Textual's Worker API to make it easier. -!!! tip "The worker API was added in version 0.18.0" +!!! tip "The Worker API was added in version 0.18.0" ## Concurrency There are many interesting uses for Textual which required reading data from an internet service. When an app requests data from the network it is important that it doesn't prevent the user interface from updating. -In other words, the requests should be concurrent (happen at the same time) with the UI updates. +In other words, the requests should be concurrent (happen at the same time) as the UI updates. Managing this concurrency is a tricky topic, in any language or framework. -Even for experienced developers, there are many gotchas which could make your app lock up or behave oddly. -Textual's Worker API makes concurrency less error prone and easier to reason about. +Even for experienced developers, there are gotchas which could make your app lock up or behave oddly. +Textual's Worker API makes concurrency far less error prone and easier to reason about. ## Workers @@ -43,7 +43,7 @@ This is because we are making a request to the weather API within a message hand To resolve this we can use the [run_worker][textual.dom.DOMNode.run_worker] function which runs the `update_weather` coroutine (async function) in the background. Here's the code: -```python title="weather02.py" hl_lines="22" +```python title="weather02.py" hl_lines="21" --8<-- "docs/examples/guide/workers/weather02.py" ``` @@ -53,7 +53,7 @@ The `run_worker` method schedules a new *worker* to run `update_weather`, and re This Worker object has a few useful methods on it, but you can often ignore it as we did in `weather02.py`. The call to `run_worker` also sets `exclusive=True` which solves an additional problem with concurrent network requests: when pulling data from the network there is no guarantee that you will receive the responses in the same order as the requests. -For example if you start typing `Paris`, you may get the response for `Pari` *after* the response for `Paris`, which could show the wrong weather information. +For instance, if you start typing `Paris`, you may get the response for `Pari` *after* the response for `Paris`, which could show the wrong weather information. The `exclusive` flag tells textual to cancel all previous workers before starting the new one. ### Work decorator @@ -62,7 +62,7 @@ An alternative to calling `run_worker` manually is the [work][textual.work] deco Let's use this decorator in our weather app: -```python title="weather03.py" hl_lines="3 23 25" +```python title="weather03.py" hl_lines="4 22 24" --8<-- "docs/examples/guide/workers/weather03.py" ``` @@ -80,23 +80,28 @@ You can check the return value of a worker with the `worker.result` attribute wh If you need the return value you can call [worker.wait][textual.worker.Worker.wait] which is a coroutine that will wait for the work to complete. But note that if you do this in a message handler it will also prevent the widget from updating until the worker returns. -Often a better approach is to handle [worker events](#worker-events). +Often a better approach is to handle [worker events](#worker-events) which will notify your app when a worker completes, and the return value is available without waiting. ### Cancelling workers You can cancel a worker at any time before it is finished by calling [Worker.cancel][textual.worker.Worker.cancel]. -This will raise an [asyncio.CancelledError] within the coroutine, and should cause it to exit prematurely. +This will raise an [CancelledError][asyncio.CancelledError] within the coroutine, and should cause it to exit prematurely. + +### Worker errors + +The default behavior when a worker encounters an exception is to exit the app and display the traceback in the terminal. +You can also create workers which will *not* immediately exit on exception, by setting `exit_on_error=True` on the call to `run_worker` or the `@work` decorator. ### Worker lifetime Workers are managed by a single [WorkerManager][textual._worker_manager.WorkerManager] instance, which you can access via `app.workers`. -This is a container like object which you can iterator over to sett all your currently active tasks. +This is a container-like object which you iterator over to see your active workers. Workers are tied to the DOM node (widget, screen, or app) where they are created. This means that if you remove the widget or pop the screen when they are created, then the tasks will be cleaned up automatically. Similarly if you exit the app, any running tasks will be cancelled. -Worker objects have a `state` attribute which will contain a [WorkerState][textual.worker.WorkerState] enumeration, which indicates what the worker is doing at any given time. +Worker objects have a `state` attribute which will contain a [WorkerState][textual.worker.WorkerState] enumeration that indicates what the worker is doing at any given time. The `state` attribute will contain one of the following values: @@ -108,6 +113,8 @@ The `state` attribute will contain one of the following values: | ERROR | The worker raised an exception, and `worker.error` will contain the exception. | | SUCCESS | The worker completed successful, and `worker.result` will contain the return value. | + +
--8<-- "docs/images/workers/lifetime.excalidraw.svg"
@@ -118,7 +125,7 @@ When a worker changes state, it sends a [Worker.StateChanged][textual.worker.Wor You can handle this message by defining a `on_worker_state_changed` event handler. For instance, here is how we might log the state of the worker that updates the weather: -```python title="weather04.py" hl_lines="4 41 43" +```python title="weather04.py" hl_lines="4 40-42" --8<-- "docs/examples/guide/workers/weather04.py" ``` @@ -147,9 +154,9 @@ The second difference is that you can't cancel threads in the same way as corout Let's demonstrate thread workers by replacing `httpx` with `urllib.request` (in the standard library). The `urllib` module is not async aware, so we will need to use threads: -```python title="weather05.py" hl_lines="1 4 27 30 34-39 42-43" +```python title="weather05.py" hl_lines="1 26-43" --8<-- "docs/examples/guide/workers/weather05.py" ``` The `update_weather` function doesn't have the `async` keyword, so the `@work` decorator will create a thread worker. -Note the user of [get_current_worker][textual.worker.get_current_worker] which the function uses to check if it has been cancelled or not. +Note the use of [get_current_worker][textual.worker.get_current_worker] which the function uses to check if it has been cancelled or not. diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 9e3659a4f8..34dd69b938 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -51,6 +51,7 @@ def work( name: str = "", group: str = "default", exclusive: bool = False, + exit_on_error: bool = True, ) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator: """Worker decorator factory.""" @@ -88,6 +89,7 @@ def decorated( group=group, description=worker_description, exclusive=exclusive, + exit_on_error=exit_on_error, ), ) return worker diff --git a/src/textual/_worker_manager.py b/src/textual/_worker_manager.py index 72c9e66e82..0be6fcc359 100644 --- a/src/textual/_worker_manager.py +++ b/src/textual/_worker_manager.py @@ -81,6 +81,7 @@ def _new_worker( name: str | None = "", group: str = "default", description: str = "", + exit_on_error: bool = True, start: bool = True, exclusive: bool = False, ) -> Worker: @@ -91,6 +92,7 @@ def _new_worker( name: A name to identify the worker. group: The worker group. description: A description of the worker. + exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. start: Automatically start the worker. exclusive: Cancel all workers in the same group. @@ -103,6 +105,7 @@ def _new_worker( name=name or getattr(work, "__name__", "") or "", group=group, description=description or repr(work), + exit_on_error=exit_on_error, ) self.add_worker(worker, start=start, exclusive=exclusive) return worker diff --git a/src/textual/dom.py b/src/textual/dom.py index 4fa7c67a31..a6183df6e1 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -221,6 +221,7 @@ def run_worker( name: str | None = "", group: str = "default", description: str = "", + exit_on_error: bool = True, start: bool = True, exclusive: bool = True, ) -> Worker[ResultType]: @@ -234,9 +235,11 @@ def run_worker( name: A short string to identify the worker (in logs and debugging). group: A short string to identify a group of workers. description: A longer string to store longer information on the worker. + exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. start: Start the worker immediately. exclusive: Cancel all workers in the same group. + Returns: New Worker instance. """ @@ -246,6 +249,7 @@ def run_worker( name=name, group=group, description=description, + exit_on_error=exit_on_error, start=start, exclusive=exclusive, ) diff --git a/src/textual/worker.py b/src/textual/worker.py index 4d1039250a..3fd9dcbe38 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -17,6 +17,7 @@ ) import rich.repr +from rich.traceback import Traceback from typing_extensions import TypeAlias from .message import Message @@ -110,6 +111,12 @@ class StateChanged(Message, bubble=False): namespace = "worker" def __init__(self, worker: Worker, state: WorkerState) -> None: + """Initialize the StateChanged message. + + Args: + worker: The worker object. + state: New state. + """ self.worker = worker self.state = state super().__init__() @@ -126,6 +133,7 @@ def __init__( name: str = "", group: str = "default", description: str = "", + exit_on_error: bool = True, ) -> None: """Initialize a worker. @@ -134,12 +142,14 @@ def __init__( name: Name of the worker (short string to help identify when debugging). group: The worker group. description: Description of the worker (longer string with more details). + exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. """ self._node = node self._work = work self.name = name self.group = group self.description = description + self.exit_on_error = exit_on_error self._state = WorkerState.PENDING self.state = self._state self._error: BaseException | None = None @@ -288,7 +298,9 @@ async def _run(self, app: App) -> None: self.state = WorkerState.ERROR self._error = error app.log.worker(self, "failed", repr(error)) - app.fatal_error() + app.log.worker(Traceback()) + if self.exit_on_error: + app.fatal_error() else: self.state = WorkerState.SUCCESS app.log.worker(self) From e7fcc2edeaa3e09758fee0a1c58de72ecf1caf1c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 17:28:03 +0100 Subject: [PATCH 21/46] changelog --- CHANGELOG.md | 10 ++++++++++ examples/dictionary.py | 5 ++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ac354e51..3adc041850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.18.0] - Unreleased + +### Added + +- Added Worker APU + +### Changed + +- Markdown.update is no longer a coroutine + ## [0.17.3] - 2023-04-02 ### [Fixed] diff --git a/examples/dictionary.py b/examples/dictionary.py index d338750557..c910d40eef 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -30,13 +30,12 @@ def on_mount(self) -> None: async def on_input_changed(self, message: Input.Changed) -> None: """A coroutine to handle a text changed message.""" if message.value: - worker = self.lookup_word(message.value) - result = worker.result + self.lookup_word(message.value) else: # Clear the results self.query_one("#results", Markdown).update("") - @work() + @work(exclusive=True) async def lookup_word(self, word: str) -> str: """Looks up a word.""" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" From 90058dc6bfa1714660c32decaf96557f5f58de64 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 17:39:46 +0100 Subject: [PATCH 22/46] svg --- docs/images/workers/lifetime.excalidraw.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/images/workers/lifetime.excalidraw.svg b/docs/images/workers/lifetime.excalidraw.svg index 7beece3f6c..108e4975db 100644 --- a/docs/images/workers/lifetime.excalidraw.svg +++ b/docs/images/workers/lifetime.excalidraw.svg @@ -1,6 +1,6 @@ - eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1baVPb2Fx1MDAxMv2eX8FjvrxXXHUwMDE1lLsvqZp6XHUwMDA1xmE3XHUwMDA2Q1x1MDAxODI1lVx1MDAxMpZsXHUwMDE0y5YjyWxT+e/TksHavG9xxpNiQLqSWvf26XO6b/vvd1tb2+Fz197+uLVtP9VN17F883H7fXT8wfZcdTAwMDPH68ApXHUwMDEy/1x1MDAxZHg9v1x1MDAxZY+8XHUwMDBmw27w8cOHtum37LDrmnXbeHCCnulcdTAwMDZhz3I8o+61Pzih3Vx1MDAwZf5cdTAwMWb9rJht+/eu17ZC30hcdTAwMWWyY1tO6Pn9Z9mu3bY7YVx1MDAwMHf/XHUwMDEz/t7a+jv+mbLOt+uh2Wm6dnxBfCoxkFKcP1rxOrGxXHUwMDE4XHTGtJBiMMBcdPbhcaFtwdlcdTAwMDaYbCdnokPb5lX3XHUwMDBl77eqe7vyed9Sl8K2SkHy1IbjurXw2e3PhFm/7/kpm4LQ91r2jWOF99HDc8dcdTAwMDfXWWZwXHUwMDBmXHUwMDA2XGZO+16ved+xgyBzkdc16074XGbHXHUwMDA0XHUwMDFhXHUwMDFj7E/Cx63kyFx1MDAxM/zFMTWYRFgxSlx1MDAwNJZYXHUwMDBmzkaXY61cZqrhJJeIYsJzZpU8XHUwMDE3XHUwMDE2XHUwMDAyzPpccsWfxK47s95qgnFcdTAwMWQrXHUwMDE5g7lp3jWkTEY9vr2uVIZcdTAwMTZcbiONKVJcXFM1XHUwMDE4cm87zftcdTAwMTDGSGwoxaTgXCJthlx1MDAxZK9cdTAwMDbWjHCuOE5cdTAwMTYrenj3yIpcdTAwMWTjr/R0dazX6XrzlMRX6OuRXHUwMDFmyWtE48t5XHUwMDFmS/tZxtdC+ylcdTAwMWO8XcozyClipuvtn13coMuu9Fx1MDAxYXeB/7g9XHUwMDE49+P98Nv2L25+Obx6OKlwhtzqy65cdTAwMTXUnVaosk95e77p+176vq+/Je/f61pm34GxUIhcdTAwMTPFNIF/g/Ou02nByU7PdZNjXr2V+Py7lMFcdTAwMDWsZd4/XHUwMDFkXHUwMDA3XGJcdTAwMTlcdDPBMSNcZis0Nc6Gz+ZycVx1MDAxNnhcdTAwMTBsllx0M85cZsZcdTAwMTmhSmksUMpRn/vzYyjKXGJcdTAwMDFcdTAwMTfO+vfsMFx1MDAwYn2zXHUwMDEzdE1cdTAwMWY8tlxiNC2LwFwiNFx1MDAwZidNNUWc6lnQlHGZXHUwMDAybJbpgclCe52w5rxEk01Q5ugns+24z5m1il1cdTAwMTOmp1qu7Fx1MDAxZlVcdTAwMGXSU1x1MDAxONjw0NhcdTAwMTdlZviu6zQj792uw2vYfsaxQ1x1MDAwN+hoMKDtWFaaYOpgg1x09/SPpiFcdTAwMDbPd5pOx3SvciaOhdpYWiNcXIzCXHUwMDFiUVx1MDAxYSms9fS0VmXfmp+DoNO6quye41x1MDAxMn8+752GP1x1MDAxN256MqspXHUwMDAzkFx1MDAwND5cZmjTOlx1MDAxNX6iy1x1MDAxOdJcdTAwMDZcdTAwMDCRXHUwMDExKSmjcjFWXHUwMDAz0N7ZYiWshinVRMiZcLhGVvtavSlz+pVcdTAwMWPfXHUwMDFmlppe9aJ5Rbpna2C1sfdccoNcdTAwMTMt9oRoVc6PrPDgK3O/kXBcdPe9OpI8bF+df1x1MDAwMlx1MDAxNdS4vK2f3pa+mctiYZBZRCfesVwiXHUwMDE2XHUwMDA2qlx1MDAxZC12KZOEI8amXHUwMDBlXHUwMDBiw1d/w8OCQFx1MDAwNmaIa8ZcdTAwMTRhJCd2XHUwMDE5pVx1MDAwNldcdTAwMTBcdTAwMWRcdTAwMTFnQi1cdTAwMTZcdTAwMTbGszAvXHUwMDA2glwiXHUwMDBiS1DjTKe5cYUsjDlcdTAwMDRcdTAwMWE5g1x1MDAwNy7GwpfXlcq6WXhcdTAwMDKP5Vn4zcT5WVx1MDAxONKq0XjTQlCm0PR4O74tNTqlSvX+9vbiWphUXFxWm88/XHUwMDE3b1x1MDAxOE1cdTAwMDJcdTAwMWPRXHUwMDEyKFCj4TxcZqxngFx1MDAxOGZcdTAwMDIyXHUwMDAwLlx1MDAxNSaLIO63hnmHXHUwMDEwXz5ccit4MiTIm8rC4vOT2XBcdTAwMGY+v3i7n76Rvd7NXHUwMDE5Kl+ugS0ns1x1MDAxYZFEiiSsrYjVXHUwMDE4XHUwMDFhXcJRQlwiJNT0IFx1MDAxYj6ZXHUwMDFiXHUwMDBlMkok5JZcdTAwMTRccmc1WFx1MDAwYkMgyLOi5JMsXGKysbSGU/F/XGavgdjR8Fx1MDAwZtO1XHUwMDEw28xOuFx1MDAxOLGVdiul8ulpeX+t1DaBXHUwMDFi8tSWXHUwMDE4uVx1MDAwMLkpNVxudkJTTjmZQUv2gm717LrVoGe7J59q5UNZpjVn02HHXHQ1MFx1MDAwMXbgWmFcdTAwMDZcdTAwMDDMcVx1MDAxYlx1MDAwN1BcbklcdTAwMDSSgMnUbM3HbZxwspIkk1x1MDAxMkIxl6nX3Sh623NOXHUwMDBlXHUwMDBmXHUwMDFh4rZ5WKFe46h9IdrN79PS20tDMnH89HX3JXzEN0ffm4H1WS+V3pL5XFxcdTAwMTW9pWJGXHUwMDBlZ1x1MDAxY1x1MDAxMlx1MDAwNFx0jkemxtnwydx0nElpYOBcdTAwMTaRT8timEltXGKmMVJcbsh+QZiNZTeJi8BcdTAwMWFGbpqAf6hU8Fs1uc3igouRW/ny8vxyrcQ2gVx1MDAxOPLE1jdwflJcdTAwMTMp7ZRcdTAwMDebXHUwMDA0mYX59GVTom67pXCPnrfDo1x1MDAwZVx1MDAxNzVxvHfzk+sjk7EmOTJcYpaEaiq1XHUwMDAyuZjjNGkgKpmM9tr0ooVThuqIr2YzUEdX81xyTdjI2dND9/HL+Wm5VSv/QT/f6MpcctuIMuR6XHUwMDE4jafkeD5hXHUwMDAzOUWQXHUwMDEyM1Da8NncdJhpakiEiaZcdTAwMWF0Y3pfvZ+xYYNcdTAwMTCtuKJaLbo/Mb5cdTAwMTBJitBcdTAwMWHCaVx1MDAwMHeFRXqL9t/DabXrUqlcXKutldUmUEOe1d5MXHUwMDFji7Y+2ofAjbKRiVx1MDAxYWgqiPJihlwi5Phdm5+CNjVcdGxcdTAwMDJcdTAwMDHYJERcdTAwMTVcclTCJcFcdTAwMTmwXHUwMDExXHUwMDEwkFxmsjRcdTAwMTJcdTAwMTF8pniy5DzNXHUwMDAw71x1MDAxNlx1MDAxYSspKfymhiBcdTAwMGY0Llx1MDAxMFx1MDAxZuTOUdlcdTAwMWYzJFRcdTAwMDGJSlx1MDAxM0FcdTAwMTVNydx5qI2sitrcmvzesFx1MDAwM3larTXr3/ih/1D94s9EQVx1MDAxY1x1MDAxMc6TXHUwMDE1mlx1MDAwMf1BaPrhntOxnE4z+9avPWLT7MXH8aLei6ZgXHUwMDA3XHUwMDE5XGJcdTAwMGJcdTAwMDE6iIBcdTAwMDYhlGjMSGpcXNPsRlx1MDAxMdTAWiBI/1x1MDAxMSM6yk5cblNrd6zJVo3fm8hZJVx1MDAxMdMgwzCNXHUwMDFljYQuXHUwMDE4XHUwMDA1+olcdTAwMTNcIlx1MDAxMUdRUb1okmtcdTAwMDZhyWu3nVx1MDAxMOa+6jmdMD/H8WTuRiHl3jat/Fl4pfS5fOzpRnfMetGfKV9BacdBg9//ej909M5I4ESfXCJkkvu9S/9/5o6lkUVlLlx1MDAxMKFUseklynBUbHbQhFx1MDAxOTVcdTAwMTRAXHUwMDEyMlx1MDAwMVxyiUCupkyJirqZKEBCRjuUi27cjFxmmmw6gVwiMZKSrCPl1pIziFFrkyc3nt+yfSPG43//t1aVMoHr8yolZ+l8YkXokdUujDGVXHUwMDE0XHUwMDBiOr1aXHUwMDE5v7u1oblcdTAwMDFXwoBRoERAlWTy27hHQYFcXOFa4qgtaMFcdTAwMWWFMcAjXHUwMDE4XHUwMDE5Qlx1MDAwMNNQiiNKXHUwMDFlUvvCglx1MDAxOVx1MDAxNDhcYiOab1p8RSVcdTAwMDRtXHUwMDA0b4Bn2edZo1g5dppcdTAwMTdeT+yVXHUwMDBmXHUwMDFireebxs6Lc329M4tY0ZDGgWJZoViZRVx1MDAxNnCmKSxcdTAwMTVRXGbSRqpkatSbKsBRZlx1MDAxZHe8zadSxm8zZc2BNJ4rXGLL8EBccnpcdTAwMDVcdTAwMTfMIVx1MDAwNlx1MDAxOFx0aH5rwvvFVVxu4JJGjVx1MDAwMpq+9sqnr1x1MDAxN9wgSkuQXHJxkXrwsqPvN1x1MDAxMoDRp1x1MDAwML0liVx1MDAxZTay+qkoaCtQk9OLnuHo2vDYS1x0j1ZcdTAwMTGEJEhNxXVCNdH1IHhcZlx1MDAwMjqTg8ykmbrkslVPonDGbjUwJVx0nylcdTAwMTecX/bMXHUwMDFk6uaSPY99MVE3wf3cNeueXHSqIa978qbOJ3wwXHUwMDFh3bKNscJSplx1MDAxN3pcdTAwMTL4xleJN7M3U1BccujCXHUwMDFjXHUwMDBiXHUwMDEwepk+lVj3aFxmIVx1MDAxNLI/oIx+XGJdXHUwMDE1+LQ2pOJIU5hzyPolK2JcdTAwMTFcdTAwMGLIOjEkR1pcdTAwMTa+sPGKTcZgwXB6b3ajtM/B2X7otnqnO/vd25dcdTAwMTOLXHUwMDFjh1x1MDAwZjuHs31xiEuG5+qrWbb2QVx1MDAwNpNYiug/waJIVZBcdTAwMWE4KuWQcVpjKvkzvmybsVxiqFx1MDAwNsIzloqCXHUwMDBmKEaK8odcdTAwMTmKUlx1MDAwMchcdTAwMTbArL94jVx1MDAwNktuRFx1MDAxZOxcdTAwMTJjjePXyYhcdTAwMWZtYJh6yNxQf9dw4u1GQjA+W1x1MDAwMN+S1Fx1MDAwZtYjXHUwMDBi5aCpXHUwMDE1IUhMv/s7XHUwMDFjYJtcdTAwMWSBpZKGJFxmVFx1MDAwNUXFTlx1MDAwYs6UoVx0QqywZ7XcXHUwMDAwzKdqjlx1MDAwN1x1MDAxMtDgT+jfWPPZ9zr2f9YqeSbohbzk6Vx1MDAxYjif0IFcdTAwMWN1pM5cdTAwMTGUXHUwMDAzvUsxfYVnfIPXZsJMXHUwMDAwXHUwMDFkSYRcdTAwMTSkjIApLrMoQ1EmnzRZsFx1MDAxNaFcZlx1MDAwM0VhXCKRgOxcdTAwMTLkXHUwMDE2xNkhModDjs4kXHUwMDAyXHUwMDA2y+9D90FcYveGRIgv2Fx1MDAxOb8ykVN9aFx1MDAxZZw0d9xd79A+aTzwi6dTNeNuXHUwMDE0SNFU0J9f5OSpebLYXHUwMDE43/m0ld1cdTAwMTFcdTAwMDK5jIGgYI1cdTAwMThcdTAwMDdcdM1ShDmotnBcdTAwMTF9qZfFXHJqw/apfiXBsTPafaNcdTAwMGZXXHUwMDA2hTSeydfe2ynqLVx1MDAwMEmOpdZcdTAwMTLBT/BqKtM3LCBhSZKDjI6FXHUwMDFhglx1MDAwMFx1MDAwNsUxfdI33N03O1x1MDAxNoJ8M7iO4iCNe12y/WacSYPCrFx1MDAxM0JcdTAwMDVd9KtcdTAwMGJjgqFcdTAwMWWS41x1MDAxNSVcdTAwMDeRXHUwMDFhXHQmyDrqLZH6lWqWb4QuJjnKT3W7XHUwMDFiRk65Ttkxgb1cdTAwMGLdnVx1MDAwMyP7YHv3XG7nbbPbrYUwf4NwXG4r41ivk5DM2faDYz/uXHLzjPhcdTAwMTPdNVx1MDAwNnBcdTAwMDRcdTAwMTU75qpcdTAwMWbvfvxcdTAwMDPAVCwrIn0= + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVbaVPbSlx1MDAxNv2eX8EwX2aqQqf3JVVTU2BcdTAwMWN2YzDLkFevUsKWjWLZciSZ7VX++7uSwdq8gnFMxkmxqFvS7dv33HNud/PXh7W19fChZ69/Xlu37+uW6zR86279Y3T91vZcdTAwMDPH60JcdTAwMTONf1x1MDAwZry+X4973oRhL/j86VPH8tt22HOtuo1unaBvuUHYbzhcdTAwMWWqe51PTmh3gv9GXytWx/5Pz+s0Qlx1MDAxZiUv2bBcdTAwMWJO6PmDd9mu3bG7YVx1MDAwME//XHUwMDAzfl9b+yv+mrLOt+uh1W25dnxD3JRcdTAwMTjIhM5frXjd2FiKXHUwMDE10YRiTIY9nGBcdTAwMWLeXHUwMDE32lxyaG6CzXbSXHUwMDEyXVq3znrXZLtd3dpUXHUwMDBm21xyfSrtRilIXtt0XFy3XHUwMDE2PrhcdTAwMDNXWPWbvp8yKlxifa9tXzqN8Fx1MDAwNtpJ7vrwvoZcdTAwMTXcgFx1MDAwMcNm3+u3brp2XHUwMDEwZG7yelbdXHRcdTAwMWbgmsTDi1x1MDAwMy98Xkuu3MNvgjDEXHUwMDE1JpozKolcImbYXHUwMDFh3U6MRsxAo1CYXHUwMDExKnJmlTxcdTAwMTdmXHUwMDAyzPonjj+JXddWvd1cdTAwMDLjuo2kXHUwMDBmXHUwMDExlnXdVCrpdfc8XFylkZGaYENcdTAwMTjWwrBkWm5sp3VcdTAwMTNCXHUwMDFmRZDWXFxJIdNm2PFsXHUwMDEww6lcdTAwMTBaXHUwMDEwOWyJXt7ba8SR8WfaXd3Gk7ueQyVcdFx1MDAxNvZ05WcyjKh/OVx1MDAxZmTpQMtcdTAwMDRbaN+Hw9GlXCKDXHUwMDFlYm653vbRySU+7SmveVx1MDAxZPh368N+Pz+Ofuzg5tbX3bPbg4rg2K0+bjaCutNcdTAwMGV19i3P77d830s/9+mnZPz9XsNcdTAwMWFcdTAwMDQwkVx1MDAxYVx1MDAwYmaEMJypYbvrdNvQ2O27bnLNq7eTmP+QMrhcdTAwMDC2zPjTiYCbcTgjlElCXHKZXHUwMDFkZqOduViYXHUwMDA1XHUwMDFlJJtFokxwxFx1MDAwNadMa0MkTsVpdDulXHUwMDE0acYphVxizob3/ChcdTAwMGJ9q1x1MDAxYvQsXHUwMDFmXHUwMDAytogzo4q4oiyPJsNcZotiY1x1MDAxZTBlXCKmgJpFXHUwMDA2YDLRXjesOY+DZJ25+sXqOO5DZq7iyFx1MDAwNPdUy5XtvcpO2oWBXHIvjUNRZbpvuk4rXG7e9TpcZsP2M3FcdTAwMWQ6QEfDXHUwMDBlXHUwMDFkp9FIXHUwMDEzTFx1MDAxZGyw4Jn+3iy84PlOy+la7lnOxIlIm0hrVLPxcMOGXG6qUi6fhrcq/966XGKCbvussnlMSuLhuH9cdTAwMTj+WryZ6aymXHUwMDExQFx04lx1MDAwYuBmXGalXHUwMDE5vHFsXHUwMDEwIJFTpVx1MDAxOFx1MDAwNN/rWFxyUHttyzdhNcKYoVLNXHUwMDA1xCWy2rfqZVmwb3T/ZrfU8qonrTPaO1pcdTAwMDKrTXxuXHUwMDE4XHUwMDFjXHUwMDE4uSVlu3K811xid75x9ztcclx1MDAxN/Dcsz0lws7Z8Vx1MDAxN1BBzdOr+uFV6bv1vliYXHUwMDE4OjYtQHxqRjgzM6eF0bO/4mlBYkQ4XHUwMDA2b3NNOc2JXc5cdTAwMThcdTAwMTJcdTAwMWFcdTAwMDNgXHUwMDA1l/p1aWEyXHKLYlwiKNKwXHUwMDAyNc5cck9N2u9Dw6fnlcqyaXhcbo/lafjZxJfTME/JrVx1MDAxY95cdTAwMDRcdTAwMTPGXGLK2Mxw278qNbulSvXm6urkXFxaTJ5WW1x1MDAwZr9cdTAwMTZuXHUwMDA0T8NcdTAwMWI1XG5cdTAwMTjQ4NE0XGakh0BcZnNJoHpTUGy/ioeb1jXGYvEsrOHNUFx1MDAxZq8qXHTLi3ur6e5cXDx6m1++063+5Vx1MDAxMS6fLoEsV4XUOFx1MDAxZLuEXHUwMDAzYWUwwXNw2mhnrjjIXHUwMDE4VVBbMjya1GAukMRU86j4pK9cdTAwMDTZRFYjqfQ/gdaI0Vx1MDAwNv5cdTAwMTP2O/JaabNSKlx1MDAxZlx1MDAxZZa3l8psU7ghz2yJkS/nNkHEONhRoYWSxMzObf2gVz06bzfZ0ebBl1p5V5VZzVl12Fx1MDAwMXsjQoFcdTAwMWSE0YRcdTAwMDNcdTAwMDBz3CZcdTAwMDCUUlGJXHUwMDE1YFLr18BcdTAwMGW4TURF+1vUmIxSRoRKXHJ3pehtyznY3WnKq9ZuhXnNvc6J7LR+zEpvj03F5f79t83H8I5cXO79aFx1MDAwNY1cdTAwMGLzzuiN87FcdTAwMWFSSC4wobOv5Ix25qrjTClEgFtkviqLYaZcZpLcQPmqtVSvhNlEdlOkXGKsUeRmKMSHTs3a70Nu5dPT49OlXHUwMDEy21x1MDAxNGLIXHUwMDEz28DAl5Oa5HJcdTAwMWPYiIZMbrjWc+xT6KteKdxix51wrytkTe5vXf7iXHUwMDA1kuloU1x1MDAwMiNKXHUwMDE0ZYYpo3VKXFxcdTAwMGZYTSHMXHUwMDE0V9Fmm3ntyinHdSzeZjfQRHeLXHUwMDE1Ldno0f1t7+7r8WG5XSv/j11cXJrKJf8/WodcdTAwMTSpdbpcdTAwMDLMXGKIJVx1MDAwZVbMXHUwMDBls5HeXFx1mFx1MDAxOYZcdTAwMTRwt2FcdTAwMDaUY3pjfVCzXHUwMDExRKmJlmSNfu1cdTAwMDbF5JVIWoTWXGJWXHUwMDAzuGtcIomeRyS+XHUwMDE3Vqudl0rlWm2pvDaFXHUwMDFh8rz2bOJEtFxy0D5cdTAwMDJuTI9dIYlyK+irOUq1yds2v1x1MDAwNG16XHUwMDFh2CRmQFx1MDAxNlx1MDAxMMRcbpiBM5JcdTAwMDVcdTAwMWKUcIjA1Vx1MDAxMXSy2EpcckF0S0O0UlxmftIjkFx1MDAwNypcdTAwMTeIj0FCgFxmSDiWuoBEbahkOj2lL6E2+lbU5tbUj6ZcdTAwMWSow2qtVf8udv3b6ld/PlxugrQok3icXHUwMDAz/UFo+eGW02043VZ21E+nxGbZjY/zRb1cdTAwMWa5YFx1MDAwMyNcZlLEKFx1MDAxZS1RR3pcIknEkd+tXpRAkZagilx1MDAxNOFxoi741e42pps0eWdcImdcdTAwMTKEXHUwMDEwN8JoZVx1MDAwNFCE5Fx1MDAwNZM0opoorJRkUUlEdMEo11xuwpLX6TghuL7qOd0w7+LYl5tRRrmxrUa+XHUwMDE1XHUwMDA2lW7Lp55e9MRsXHUwMDEw/ZFcblx1MDAxNZyOXHUwMDFiPPz5z48je2+MxU30KVwiJnneh/T3eTWKXHUwMDFjL1EkOJ5cdTAwMDPIZ19WXHUwMDFljYrVTprgUmSUVlB6Q+LkJLt1w6hBhEJcdTAwMWNySplk9M2Wt/hsXHUwMDAyRVx1MDAxMYh3upyiW2GskvG+tTy59Py27aNcdTAwMTiQ//r3UlXKXHUwMDE0rs+rlJylL1x1MDAxMytqwnaOwVQxSMazXHUwMDAzb/L+1orWXHUwMDA2QktcdTAwMDS9KDGKZlx1MDAwNUl8SEFcdTAwMWLEhVEkOlx1MDAxN/TKQ1xuXHUwMDEzgEdcdEZSYoVcdTAwMTnoJUzFiNUvXCI5YoRcdTAwMDEzsvyxxSdUQtbGMFx1MDAwMjLPTs9cdTAwMTLFyr7TOvH6cqu822w/XFw2N1x1MDAxZZ3z8423rZfnXHUwMDE1K/MoXHUwMDAzwVxyg6mimkPZyLRK9XpcdTAwMTZcdTAwMDZES0HZy4XK5I2mrDk0OlJcdTAwMDRpXHUwMDE5XmhcdTAwMDCzpGBcdTAwMGVFYKRkw1N471xcplx1MDAwMC5ZdFTAsKfD8un7pVx1MDAwMFFmXHUwMDE0Y4Nl6uFgxz9vLFx1MDAwMKNPXHUwMDAxelx1MDAwYlI9avwxbcJcdTAwMTVhkHJm321cdTAwMThccq9cdTAwMTVPvoyKaFx1MDAxYUFKgtjUXHUwMDAw70zyXHUwMDE1nCFQPVxcgNBkmYXJRcueROJM3G3gWlExVzH4XpZl7lx1MDAwNmqibkH8uUtcdTAwMTY+U2RDXvjkTX2Z8oFcdTAwMDJzbM0ho9NcdTAwMWRcdTAwMDC/2cE3eZl4NU9nSmZcdTAwMDBdRFx1MDAxMKi2WeaoSix8XGaBXHUwMDFjXG7131Nd+2bSh1x1MDAxOIOUXHUwMDE22DBcdTAwMDI1XHUwMDA1o4pcdTAwMTexSCTUnURjXG4l0lx1MDAxOPHDuVx1MDAwMv7jYkXFz87Rdui2+4dcdTAwMWLbvavHg1x1MDAwNt1cdTAwMGZvN3bfqfjBXGLIScnon+QwJSbVZ6A1SLSWQyeJjZn0z+R124xFQDWQnomKTlVcdTAwMGLNaVH/cKRcdTAwMTmTRFx1MDAwM5njhMLfp/whSiCjwfWEXHUwMDE4XHUwMDEyXHUwMDBmJ6N+XGZcIuB6XCJcdTAwMDfbedPVz3hcYsatXHUwMDA18C1I/tDxxadcdTAwMDAsQ1Jis2//jlx1MDAwNthqZ2ClXHUwMDE1UpSDqmC4eNhCcI1cZsWYXHUwMDE3Nq1cdTAwMTabgMVMx+OBXHUwMDA0XGbEXHUwMDEz/lx1MDAxZE9abHtd+1x1MDAxZkuVPFP0Ql7yXGZcZnyZ0GF6rM5cdTAwMTGR8Dacz46yyUe8Vlx1MDAxM2VcdTAwMTLYSGGsoWRcdTAwMDRIXHSVXHUwMDA1XHUwMDE5jir55JBcdTAwMDV/I5BcdTAwMTFgKEJcdTAwMTWWUF1CYoM0O0LlXGKo0bnCQGD5fehcdTAwMDFcdTAwMDbh2VBcdTAwMDeJV56NfzONU71t7Vx1MDAxY7Q23E1v1z5o3oqT+0M9527UojROnpmna43JZ5/WsptCoJaJplx1MDAxNGzlXHUwMDAyXHUwMDE0NC/uXG5RJGT0Z708PqKm3/1yy9jwjT5CI1x1MDAwNlU8V0+nb2dYb1x1MDAwMUhcbqKMUVx1MDAxOL5CVDOVfmBcdTAwMDFcdFx1MDAwYlJcdTAwMWNi/DZcdTAwMTOL8iCAa/ZcXDg63Fc7XHUwMDE3gnpD0Z9CcfB45NnseTPBXHUwMDE1YuD1p02mV/+F0LhkaEaUeEXFQZXBkkv6Oy63lO/rdi+MgnKZqmNcbntcdTAwMTfOd1x1MDAwZY1cdTAwMWOA7cNcdTAwMTOc161er1x1MDAxNoL/hulcdTAwMTRmxmk8OSHx2fqtY99tjYqM+Fx1MDAxMz01XHUwMDA2cFx1MDAwNFx1MDAxNTvmqp9cdTAwMWZ+/lxycIktXGYifQ== - PENDINGRUNNINGCANCELLEDERRORSUCCESSWorker.start()worker.cancel()Done!Exception + PENDINGRUNNINGCANCELLEDERRORSUCCESSWorker.start()worker.cancel()Done!Exception From 92ed4c0ee3b9683a8c80c5ef6964d7ac8a089017 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 17:46:22 +0100 Subject: [PATCH 23/46] refactor test --- tests/test_work_decorator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_work_decorator.py b/tests/test_work_decorator.py index e743d24ace..90e2daeb06 100644 --- a/tests/test_work_decorator.py +++ b/tests/test_work_decorator.py @@ -27,6 +27,7 @@ def on_worker_state_changed(self, event: Worker.StateChanged) -> None: async with app.run_test() as pilot: assert isinstance(worker, Worker) await app.workers.wait_for_complete() - assert await worker.wait() == "foo" + result = await worker.wait() + assert result == "foo" await pilot.pause() assert states == [WorkerState.PENDING, WorkerState.RUNNING, WorkerState.SUCCESS] From 074b36e87ded57a4a579b2de58e52624b2b4fe19 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 17:48:41 +0100 Subject: [PATCH 24/46] remove debug tweaks --- examples/dictionary.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index c910d40eef..daaaab8145 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -36,7 +36,7 @@ async def on_input_changed(self, message: Input.Changed) -> None: self.query_one("#results", Markdown).update("") @work(exclusive=True) - async def lookup_word(self, word: str) -> str: + async def lookup_word(self, word: str) -> None: """Looks up a word.""" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" @@ -46,12 +46,10 @@ async def lookup_word(self, word: str) -> str: results = response.json() except Exception: self.query_one("#results", Markdown).update(response.text) - return "foo" if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) self.query_one("#results", Markdown).update(markdown) - return "foo" def make_word_markdown(self, results: object) -> str: """Convert the results in to markdown.""" From 0dacf4ae445ab4361679affd21338f509c961a6f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 17:52:41 +0100 Subject: [PATCH 25/46] docstrings --- src/textual/worker.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/textual/worker.py b/src/textual/worker.py index 3fd9dcbe38..3c0ba04999 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -28,6 +28,7 @@ active_worker: ContextVar[Worker] = ContextVar("active_worker") +"""Currently active worker context var.""" class NoActiveWorker(Exception): @@ -135,9 +136,10 @@ def __init__( description: str = "", exit_on_error: bool = True, ) -> None: - """Initialize a worker. + """Initialize a Worker. Args: + node: THe widget, screen, or App that initiated the work. work: A callable, coroutine, or other awaitable. name: Name of the worker (short string to help identify when debugging). group: The worker group. @@ -202,7 +204,7 @@ def is_running(self) -> bool: @property def is_finished(self) -> bool: - """Has the task finished (cancelled, error, or success).""" + """Has the task finished (cancelled, error, or success)?""" return self.state in ( WorkerState.CANCELLED, WorkerState.ERROR, @@ -211,7 +213,7 @@ def is_finished(self) -> bool: @property def completed_steps(self) -> int: - """The number of completed steps.""" + """The number of completed steps.""" return self._completed_steps @property @@ -250,7 +252,12 @@ def update( self._total_steps = None if total_steps is None else min(0, total_steps) def advance(self, steps: int = 1) -> None: - """Advance the number of completed steps.""" + """Advance the number of completed steps. + + Args: + steps: Number of steps to advance. + + """ self._completed_steps += steps async def run(self) -> ResultType: @@ -269,7 +276,7 @@ async def run(self) -> ResultType: assert callable(self._work) loop = asyncio.get_running_loop() - def run_work() -> None: + def run_work() -> ResultType: """Set the active worker, and run the work.""" active_worker.set(self) return self._work() From 193440a69eeafbfc13085587bb630cbbd7e09816 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 17:56:01 +0100 Subject: [PATCH 26/46] worker test --- tests/test_work_decorator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_work_decorator.py b/tests/test_work_decorator.py index 90e2daeb06..b58657079b 100644 --- a/tests/test_work_decorator.py +++ b/tests/test_work_decorator.py @@ -8,26 +8,26 @@ async def test_work() -> None: """Test basic usage of the @work decorator.""" states: list[WorkerState] = [] - worker: Worker[str] | None = None class WorkApp(App): + worker: Worker + @work async def foo(self) -> str: await asyncio.sleep(0.1) return "foo" def on_mount(self) -> None: - nonlocal worker - worker = self.foo() + self.worker = self.foo() def on_worker_state_changed(self, event: Worker.StateChanged) -> None: states.append(event.state) app = WorkApp() + async with app.run_test() as pilot: - assert isinstance(worker, Worker) await app.workers.wait_for_complete() - result = await worker.wait() + result = await app.worker.wait() assert result == "foo" await pilot.pause() assert states == [WorkerState.PENDING, WorkerState.RUNNING, WorkerState.SUCCESS] From 64a903d2e54dc071868256bed15b7ed3eb1e303b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 18:05:03 +0100 Subject: [PATCH 27/46] fix typing in run --- src/textual/worker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/worker.py b/src/textual/worker.py index 3c0ba04999..f17f6a345d 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -276,12 +276,12 @@ async def run(self) -> ResultType: assert callable(self._work) loop = asyncio.get_running_loop() - def run_work() -> ResultType: + def run_work(work: Callable[[], ResultType]) -> ResultType: """Set the active worker, and run the work.""" active_worker.set(self) - return self._work() + return work() - result = await loop.run_in_executor(None, run_work) + result = await loop.run_in_executor(None, run_work, self._work) return result async def _run(self, app: App) -> None: From 879fe90d05acaeb82c7d30e42b44009f3c61805e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 20:22:13 +0100 Subject: [PATCH 28/46] fix 3.7 tests --- src/textual/worker.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/textual/worker.py b/src/textual/worker.py index f17f6a345d..b4de075deb 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -267,7 +267,11 @@ async def run(self) -> ResultType: """ - if inspect.iscoroutinefunction(self._work): + if ( + inspect.iscoroutinefunction(self._work) + or hasattr(self._work, "func") + and inspect.iscoroutinefunction(self._work.func) + ): # Coroutine, we can await it. result: ResultType = await self._work() elif inspect.isawaitable(self._work): @@ -348,6 +352,10 @@ def cancel(self) -> None: async def wait(self) -> ResultType: """Wait for the work to complete. + Raises: + WorkerFailed: If the Worker raised an exception + WorkerCancelled: If the Worker was cancelled before it completed. + Returns: The return value of the work. """ @@ -364,5 +372,4 @@ async def wait(self) -> ResultType: raise WorkerFailed(self._error) elif self.state == WorkerState.CANCELLED: raise WorkerCancelled("Worker was cancelled, and did not complete.") - return cast("ResultType", self._result) From 4660fbba5449e915c397445c09568fd0bd260525 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 3 Apr 2023 22:00:39 +0100 Subject: [PATCH 29/46] blog post --- docs/blog/posts/release0-18-0.md | 41 ++++++++++++++++++++++++++++++++ docs/guide/workers.md | 16 +++++++++---- src/textual/dom.py | 4 +--- src/textual/worker.py | 9 +++++++ 4 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 docs/blog/posts/release0-18-0.md diff --git a/docs/blog/posts/release0-18-0.md b/docs/blog/posts/release0-18-0.md new file mode 100644 index 0000000000..597f567d90 --- /dev/null +++ b/docs/blog/posts/release0-18-0.md @@ -0,0 +1,41 @@ +--- +draft: false +date: 2023-04-04 +categories: + - Release +title: "Textual 0.18.0 adds API for managing concurrent workers" +authors: + - willmcgugan +--- + +# Textual 0.18.0 adds API for managing concurrent workers + +Less than a week since the last release, and we have a new API to show you. + + + +This release adds a new [Worker API](../../guide/workers.md) designed to manage concurrency, both asyncio tasks and threads. + +An API to manage concurrency may seem like a strange addition to a library for building user interfaces, but on reflection it makes a lot of sense. +People are building Textual apps to interface with REST APIs, websockets, and processes; and they are running in to predictable issues. +These aren't specifically Textual problems, but rather general problems related to async tasks and threads. +It's not enough for us to point users at the docs, we needed a better answer. + +The new `run_worker` method provides an easy way of launching "Workers" (a wrapper over async tasks and threads) which also manages their lifetime. + +One of the challenges I've found with tasks and threads is ensuring that they are shut down in an orderly manner. Interestingly enough, Textual already implemented an orderly shutdown procedure to close the tasks that power widgets: children are shut down before parents, all the way up to the App (the root node). +The new API piggy backs on to that existing mechanism to ensure that worker tasks are also shut down in the same order. + +!!! tip + + You won't need to worry about this [gnarly issue](https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/) with the new Worker API. + + +I'm particularly pleased with the new `@work` decorator which can turn an coroutine OR a regular function in to a Textual Worker object, by scheduling it as either an asyncio task or a thread as appropriate. +I honestly think it will solve 90% of the concurrency issues we see with Textual apps. + +See the [Worker API](../../guide/workers.md) for the details. + +## Join us + +If you want to talk about this update or anything else Textual related, join us on our [Discord server](https://discord.gg/Enf6Z3qhVr). diff --git a/docs/guide/workers.md b/docs/guide/workers.md index 14d98157ae..40f0a037c9 100644 --- a/docs/guide/workers.md +++ b/docs/guide/workers.md @@ -39,9 +39,9 @@ The following app uses [httpx](https://www.python-httpx.org/) query the weather If you were to run this app, you should see weather information update as you type. But you may find that the input is not as responsive as usual, with a noticeable delay between pressing a key and seeing it echoed in screen. -This is because we are making a request to the weather API within a message handler, and the input will not be able to process the next key until the request has completed (which may be anything from a few hundred milliseconds to several seconds later). +This is because we are making a request to the weather API within a message handler, and the app will not be able to process the next key until the request has completed (which may be anything from a few hundred milliseconds to several seconds later). -To resolve this we can use the [run_worker][textual.dom.DOMNode.run_worker] function which runs the `update_weather` coroutine (async function) in the background. Here's the code: +To resolve this we can use the [run_worker][textual.dom.DOMNode.run_worker] method which runs the `update_weather` coroutine (`async def` function) in the background. Here's the code: ```python title="weather02.py" hl_lines="21" --8<-- "docs/examples/guide/workers/weather02.py" @@ -52,7 +52,7 @@ This one line change will make typing as responsive as you would expect from any The `run_worker` method schedules a new *worker* to run `update_weather`, and returns a [Worker](textual.worker.Worker) object. This Worker object has a few useful methods on it, but you can often ignore it as we did in `weather02.py`. -The call to `run_worker` also sets `exclusive=True` which solves an additional problem with concurrent network requests: when pulling data from the network there is no guarantee that you will receive the responses in the same order as the requests. +The call to `run_worker` sets `exclusive=True` which solves an additional problem with concurrent network requests: when pulling data from the network, there is no guarantee that you will receive the responses in the same order as the requests. For instance, if you start typing `Paris`, you may get the response for `Pari` *after* the response for `Paris`, which could show the wrong weather information. The `exclusive` flag tells textual to cancel all previous workers before starting the new one. @@ -66,7 +66,7 @@ Let's use this decorator in our weather app: --8<-- "docs/examples/guide/workers/weather03.py" ``` -The addition of `@work(exclusive=True)` converts the `update_weather` coroutine in to a regular function which creates and starts a worker. +The addition of `@work(exclusive=True)` converts the `update_weather` coroutine in to a regular function which when called will create and start a worker. Note that even though `update_weather` is an `async def` function, the decorator means that we don't need to use the `await` keyword when calling it. !!! tip @@ -148,7 +148,7 @@ You can create threads by applying `run_worker` or the `work` decorator to a pla The API for thread workers is identical to async workers, but there are a few differences you need to be aware of when writing threaded code. The first difference 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][textual.app.call_from_thread] method which schedules a call in the main thread. +You can work around this with the [App.call_from_thread][textual.app.App.call_from_thread] method which schedules a call in the main thread. The second difference is that you can't cancel threads in the same way as coroutines, but you *can* manually check if the worker was cancelled. @@ -160,3 +160,9 @@ Let's demonstrate thread workers by replacing `httpx` with `urllib.request` (in The `update_weather` function doesn't have the `async` keyword, so the `@work` decorator will create a thread worker. Note the use of [get_current_worker][textual.worker.get_current_worker] which the function uses to check if it has been cancelled or not. + +#### Posting messages + +Most Textual functions are not thread-safe which means you will need to use `call_from_thread` to run them from a thread worker. +An exception would be [post_message][textual.widget.Widget.post_message] which *is* thread-safe. +If your worker needs to make multiple updates to the UI, it is a good idea to send [custom messages](./events.md) and let the message handler update the state of the UI. diff --git a/src/textual/dom.py b/src/textual/dom.py index a6183df6e1..e50c9e633a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -227,8 +227,7 @@ def run_worker( ) -> Worker[ResultType]: """Run work in a worker. - A worker runs code in the *background* as an async task or as a thread, so as - to avoid stalling the user interface. + A worker runs a function, coroutine, or awaitable, in the *background* as an async task or as a thread. Args: work: A function, async function, or an awaitable object. @@ -239,7 +238,6 @@ def run_worker( start: Start the worker immediately. exclusive: Cancel all workers in the same group. - Returns: New Worker instance. """ diff --git a/src/textual/worker.py b/src/textual/worker.py index b4de075deb..aabdd58f9d 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -47,6 +47,10 @@ def __init__(self, error: BaseException) -> None: super().__init__(f"Worker raise exception: {error!r}") +class DeadlockError(WorkerError): + """The operation would result in a deadlock.""" + + class WorkerCancelled(WorkerError): """The worker was cancelled and did not complete.""" @@ -359,6 +363,11 @@ async def wait(self) -> ResultType: Returns: The return value of the work. """ + if active_worker.get() is self: + raise DeadlockError( + "Can't call worker.wait from within the worker function!" + ) + if self.state == WorkerState.PENDING: raise WorkerError("Worker must be started before calling this method.") if self._task is not None: From cf10ec81b005be398b888cfef352b19943cc7db7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 09:43:10 +0100 Subject: [PATCH 30/46] fix deadlock test --- src/textual/worker.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/textual/worker.py b/src/textual/worker.py index aabdd58f9d..088c793ce4 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -363,10 +363,14 @@ async def wait(self) -> ResultType: Returns: The return value of the work. """ - if active_worker.get() is self: - raise DeadlockError( - "Can't call worker.wait from within the worker function!" - ) + try: + if active_worker.get() is self: + raise DeadlockError( + "Can't call worker.wait from within the worker function!" + ) + except LookupError: + # Not in a worker + pass if self.state == WorkerState.PENDING: raise WorkerError("Worker must be started before calling this method.") From 79d78b2492fa6a029c0c10783fb7c8be6cbe32a1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 09:47:45 +0100 Subject: [PATCH 31/46] words --- docs/blog/posts/release0-18-0.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/blog/posts/release0-18-0.md b/docs/blog/posts/release0-18-0.md index 597f567d90..5f2e2a2aa2 100644 --- a/docs/blog/posts/release0-18-0.md +++ b/docs/blog/posts/release0-18-0.md @@ -19,7 +19,7 @@ This release adds a new [Worker API](../../guide/workers.md) designed to manage An API to manage concurrency may seem like a strange addition to a library for building user interfaces, but on reflection it makes a lot of sense. People are building Textual apps to interface with REST APIs, websockets, and processes; and they are running in to predictable issues. These aren't specifically Textual problems, but rather general problems related to async tasks and threads. -It's not enough for us to point users at the docs, we needed a better answer. +It's not enough for us to point users at the asyncio docs, we needed a better answer. The new `run_worker` method provides an easy way of launching "Workers" (a wrapper over async tasks and threads) which also manages their lifetime. @@ -31,7 +31,7 @@ The new API piggy backs on to that existing mechanism to ensure that worker task You won't need to worry about this [gnarly issue](https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/) with the new Worker API. -I'm particularly pleased with the new `@work` decorator which can turn an coroutine OR a regular function in to a Textual Worker object, by scheduling it as either an asyncio task or a thread as appropriate. +I'm particularly pleased with the new `@work` decorator which can turn a coroutine OR a regular function in to a Textual Worker object, by scheduling it as either an asyncio task or a thread. I honestly think it will solve 90% of the concurrency issues we see with Textual apps. See the [Worker API](../../guide/workers.md) for the details. From 955b393e6dda8e513ba01974c88d3e1edc0dad7a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 10:10:02 +0100 Subject: [PATCH 32/46] words --- docs/guide/workers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/workers.md b/docs/guide/workers.md index 40f0a037c9..b3b77c0276 100644 --- a/docs/guide/workers.md +++ b/docs/guide/workers.md @@ -18,7 +18,7 @@ Textual's Worker API makes concurrency far less error prone and easier to reason Before we go in to detail, lets see an example that demonstrates a common pitfall for apps that make network requests. -The following app uses [httpx](https://www.python-httpx.org/) query the weather with [wttr.in](https://wttr.in/) for any city name you enter in to an input: +The following app uses [httpx](https://www.python-httpx.org/) to get the current weather for any given city, by making a request to [wttr.in](https://wttr.in/). === "weather01.py" From 8b7751a79e5a98a20d6280ef00e7e0942badc761 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 10:24:34 +0100 Subject: [PATCH 33/46] words --- docs/guide/workers.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/guide/workers.md b/docs/guide/workers.md index b3b77c0276..83a890422f 100644 --- a/docs/guide/workers.md +++ b/docs/guide/workers.md @@ -39,7 +39,7 @@ The following app uses [httpx](https://www.python-httpx.org/) to get the current If you were to run this app, you should see weather information update as you type. But you may find that the input is not as responsive as usual, with a noticeable delay between pressing a key and seeing it echoed in screen. -This is because we are making a request to the weather API within a message handler, and the app will not be able to process the next key until the request has completed (which may be anything from a few hundred milliseconds to several seconds later). +This is because we are making a request to the weather API within a message handler, and the app will not be able to process other messages until the request has completed (which may be anything from a few hundred milliseconds to several seconds later). To resolve this we can use the [run_worker][textual.dom.DOMNode.run_worker] method which runs the `update_weather` coroutine (`async def` function) in the background. Here's the code: @@ -49,8 +49,11 @@ To resolve this we can use the [run_worker][textual.dom.DOMNode.run_worker] meth This one line change will make typing as responsive as you would expect from any app. -The `run_worker` method schedules a new *worker* to run `update_weather`, and returns a [Worker](textual.worker.Worker) object. -This Worker object has a few useful methods on it, but you can often ignore it as we did in `weather02.py`. +The `run_worker` method schedules a new *worker* to run `update_weather`, and returns a [Worker](textual.worker.Worker) object. This happens almost immediately, so it won't prevent other messages from being processed. The `update_weather` function is now running concurrently, and will finish a second or two later, and won't delay the rest of your app. + +!!! tip + + This Worker object has a few useful methods on it, but you can often ignore it as we did in `weather02.py`. The call to `run_worker` sets `exclusive=True` which solves an additional problem with concurrent network requests: when pulling data from the network, there is no guarantee that you will receive the responses in the same order as the requests. For instance, if you start typing `Paris`, you may get the response for `Pari` *after* the response for `Paris`, which could show the wrong weather information. From c2d1450bb2e530e362decff59406ad8429356dfa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 10:32:22 +0100 Subject: [PATCH 34/46] workers docs --- docs/guide/workers.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/guide/workers.md b/docs/guide/workers.md index 83a890422f..0e292e1f67 100644 --- a/docs/guide/workers.md +++ b/docs/guide/workers.md @@ -49,19 +49,19 @@ To resolve this we can use the [run_worker][textual.dom.DOMNode.run_worker] meth This one line change will make typing as responsive as you would expect from any app. -The `run_worker` method schedules a new *worker* to run `update_weather`, and returns a [Worker](textual.worker.Worker) object. This happens almost immediately, so it won't prevent other messages from being processed. The `update_weather` function is now running concurrently, and will finish a second or two later, and won't delay the rest of your app. +The `run_worker` method schedules a new *worker* to run `update_weather`, and returns a [Worker](textual.worker.Worker) object. This happens almost immediately, so it won't prevent other messages from being processed. The `update_weather` function is now running concurrently, and will finish a second or two later. !!! tip - This Worker object has a few useful methods on it, but you can often ignore it as we did in `weather02.py`. + The [Worker][textual.worker.Worker] object has a few useful methods on it, but you can often ignore it as we did in `weather02.py`. -The call to `run_worker` sets `exclusive=True` which solves an additional problem with concurrent network requests: when pulling data from the network, there is no guarantee that you will receive the responses in the same order as the requests. -For instance, if you start typing `Paris`, you may get the response for `Pari` *after* the response for `Paris`, which could show the wrong weather information. +The call to `run_worker` also sets `exclusive=True` which solves an additional problem with concurrent network requests: when pulling data from the network, there is no guarantee that you will receive the responses in the same order as the requests. +For instance, if you start typing "Paris", you may get the response for "Pari" *after* the response for "Paris", which could show the wrong weather information. The `exclusive` flag tells textual to cancel all previous workers before starting the new one. ### Work decorator -An alternative to calling `run_worker` manually is the [work][textual.work] decorator, which automatically generates a worker from the decorator method. +An alternative to calling `run_worker` manually is the [work][textual.work] decorator, which automatically generates a worker from the decorated method. Let's use this decorator in our weather app: @@ -116,7 +116,7 @@ The `state` attribute will contain one of the following values: | ERROR | The worker raised an exception, and `worker.error` will contain the exception. | | SUCCESS | The worker completed successful, and `worker.result` will contain the return value. | - +Wokers start with a `PENDING` state, then go to `RUNNING`. From there, they will go to `CANCELLED`, `ERROR` or `SUCCESS`.
--8<-- "docs/images/workers/lifetime.excalidraw.svg" From 8d9fabdaf339c202b417dcb83a0ba509f95f66f7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 10:33:36 +0100 Subject: [PATCH 35/46] blog post --- docs/blog/posts/release0-18-0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/release0-18-0.md b/docs/blog/posts/release0-18-0.md index 5f2e2a2aa2..fd94e8b57b 100644 --- a/docs/blog/posts/release0-18-0.md +++ b/docs/blog/posts/release0-18-0.md @@ -32,7 +32,7 @@ The new API piggy backs on to that existing mechanism to ensure that worker task I'm particularly pleased with the new `@work` decorator which can turn a coroutine OR a regular function in to a Textual Worker object, by scheduling it as either an asyncio task or a thread. -I honestly think it will solve 90% of the concurrency issues we see with Textual apps. +I suspect this will solve 90% of the concurrency issues we see with Textual apps. See the [Worker API](../../guide/workers.md) for the details. From da3edfa3f398637ab84a1101c39a0a260b978419 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 11:32:26 +0100 Subject: [PATCH 36/46] Apply suggestions from code review Co-authored-by: Dave Pearson --- docs/blog/posts/release0-18-0.md | 2 +- docs/guide/workers.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/blog/posts/release0-18-0.md b/docs/blog/posts/release0-18-0.md index fd94e8b57b..e5bcdcd9c5 100644 --- a/docs/blog/posts/release0-18-0.md +++ b/docs/blog/posts/release0-18-0.md @@ -24,7 +24,7 @@ It's not enough for us to point users at the asyncio docs, we needed a better an The new `run_worker` method provides an easy way of launching "Workers" (a wrapper over async tasks and threads) which also manages their lifetime. One of the challenges I've found with tasks and threads is ensuring that they are shut down in an orderly manner. Interestingly enough, Textual already implemented an orderly shutdown procedure to close the tasks that power widgets: children are shut down before parents, all the way up to the App (the root node). -The new API piggy backs on to that existing mechanism to ensure that worker tasks are also shut down in the same order. +The new API piggybacks on to that existing mechanism to ensure that worker tasks are also shut down in the same order. !!! tip diff --git a/docs/guide/workers.md b/docs/guide/workers.md index 0e292e1f67..bc5431acd2 100644 --- a/docs/guide/workers.md +++ b/docs/guide/workers.md @@ -6,7 +6,7 @@ In this chapter we will explore the topic of *concurrency* and how to use Textua ## Concurrency -There are many interesting uses for Textual which required reading data from an internet service. +There are many interesting uses for Textual which require reading data from an internet service. When an app requests data from the network it is important that it doesn't prevent the user interface from updating. In other words, the requests should be concurrent (happen at the same time) as the UI updates. @@ -16,7 +16,7 @@ Textual's Worker API makes concurrency far less error prone and easier to reason ## Workers -Before we go in to detail, lets see an example that demonstrates a common pitfall for apps that make network requests. +Before we go into detail, let's see an example that demonstrates a common pitfall for apps that make network requests. The following app uses [httpx](https://www.python-httpx.org/) to get the current weather for any given city, by making a request to [wttr.in](https://wttr.in/). @@ -98,10 +98,10 @@ You can also create workers which will *not* immediately exit on exception, by s ### Worker lifetime Workers are managed by a single [WorkerManager][textual._worker_manager.WorkerManager] instance, which you can access via `app.workers`. -This is a container-like object which you iterator over to see your active workers. +This is a container-like object which you iterate over to see your active workers. Workers are tied to the DOM node (widget, screen, or app) where they are created. -This means that if you remove the widget or pop the screen when they are created, then the tasks will be cleaned up automatically. +This means that if you remove the widget or pop the screen where they are created, then the tasks will be cleaned up automatically. Similarly if you exit the app, any running tasks will be cancelled. Worker objects have a `state` attribute which will contain a [WorkerState][textual.worker.WorkerState] enumeration that indicates what the worker is doing at any given time. @@ -125,7 +125,7 @@ Wokers start with a `PENDING` state, then go to `RUNNING`. From there, they will ### Worker events When a worker changes state, it sends a [Worker.StateChanged][textual.worker.Worker.StateChanged] event to the widget where the worker was created. -You can handle this message by defining a `on_worker_state_changed` event handler. +You can handle this message by defining an `on_worker_state_changed` event handler. For instance, here is how we might log the state of the worker that updates the weather: ```python title="weather04.py" hl_lines="4 40-42" From fdc38c4aac1d135023d826b1449afb9521b38bde Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 11:01:37 +0100 Subject: [PATCH 37/46] docstring --- src/textual/_work_decorator.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 34dd69b938..47ca4409b9 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -50,10 +50,19 @@ def work( *, name: str = "", group: str = "default", - exclusive: bool = False, exit_on_error: bool = True, + exclusive: bool = False, ) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator: - """Worker decorator factory.""" + """Worker decorator factory. + + Args: + method: A function or coroutine. + name: A short string to identify the worker (in logs and debugging). + group: A short string to identify a group of workers. + exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. + exclusive: Cancel all workers in the same group. + + """ def decorator( method: ( From 9d2144376dda04fa2b25aad56af075e06a68f845 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 11:32:43 +0100 Subject: [PATCH 38/46] fix and docstring --- src/textual/_animator.py | 2 +- src/textual/_worker_manager.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index d7f9ad67b7..fa8c1fb640 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -27,7 +27,7 @@ class AnimationError(Exception): """An issue prevented animation from starting.""" -ReturnType = TypeVar("T") +ReturnType = TypeVar("ReturnType") @runtime_checkable diff --git a/src/textual/_worker_manager.py b/src/textual/_worker_manager.py index 0be6fcc359..b6ae268fac 100644 --- a/src/textual/_worker_manager.py +++ b/src/textual/_worker_manager.py @@ -148,7 +148,15 @@ def cancel_group(self, node: DOMNode, group: str) -> list[Worker]: return workers def cancel_node(self, node: DOMNode) -> list[Worker]: - """Cancel all workers associated with a given node.""" + """Cancel all workers associated with a given node + + Args: + node: A DOM node (widget, screen, or App). + + Returns: + List of cancelled workers. + + .""" workers = [worker for worker in self._workers if worker.node == node] for worker in workers: worker.cancel() From e4bf6a765920130979b5747c523a4e7ceda52cbd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 11:57:16 +0100 Subject: [PATCH 39/46] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- CHANGELOG.md | 3 --- docs/blog/posts/release0-18-0.md | 4 ++-- docs/guide/workers.md | 8 ++++---- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 297de7deb2..01edc39617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `RadioSet` is now far less likely to report `pressed_button` as `None` https://github.com/Textualize/textual/issues/2203 -## Unreleased - - ## [0.17.3] - 2023-04-02 ### [Fixed] diff --git a/docs/blog/posts/release0-18-0.md b/docs/blog/posts/release0-18-0.md index e5bcdcd9c5..ca538e6f72 100644 --- a/docs/blog/posts/release0-18-0.md +++ b/docs/blog/posts/release0-18-0.md @@ -17,7 +17,7 @@ Less than a week since the last release, and we have a new API to show you. This release adds a new [Worker API](../../guide/workers.md) designed to manage concurrency, both asyncio tasks and threads. An API to manage concurrency may seem like a strange addition to a library for building user interfaces, but on reflection it makes a lot of sense. -People are building Textual apps to interface with REST APIs, websockets, and processes; and they are running in to predictable issues. +People are building Textual apps to interface with REST APIs, websockets, and processes; and they are running into predictable issues. These aren't specifically Textual problems, but rather general problems related to async tasks and threads. It's not enough for us to point users at the asyncio docs, we needed a better answer. @@ -31,7 +31,7 @@ The new API piggybacks on to that existing mechanism to ensure that worker tasks You won't need to worry about this [gnarly issue](https://textual.textualize.io/blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/) with the new Worker API. -I'm particularly pleased with the new `@work` decorator which can turn a coroutine OR a regular function in to a Textual Worker object, by scheduling it as either an asyncio task or a thread. +I'm particularly pleased with the new `@work` decorator which can turn a coroutine OR a regular function into a Textual Worker object, by scheduling it as either an asyncio task or a thread. I suspect this will solve 90% of the concurrency issues we see with Textual apps. See the [Worker API](../../guide/workers.md) for the details. diff --git a/docs/guide/workers.md b/docs/guide/workers.md index bc5431acd2..c04d2ce787 100644 --- a/docs/guide/workers.md +++ b/docs/guide/workers.md @@ -57,7 +57,7 @@ The `run_worker` method schedules a new *worker* to run `update_weather`, and re The call to `run_worker` also sets `exclusive=True` which solves an additional problem with concurrent network requests: when pulling data from the network, there is no guarantee that you will receive the responses in the same order as the requests. For instance, if you start typing "Paris", you may get the response for "Pari" *after* the response for "Paris", which could show the wrong weather information. -The `exclusive` flag tells textual to cancel all previous workers before starting the new one. +The `exclusive` flag tells Textual to cancel all previous workers before starting the new one. ### Work decorator @@ -69,7 +69,7 @@ Let's use this decorator in our weather app: --8<-- "docs/examples/guide/workers/weather03.py" ``` -The addition of `@work(exclusive=True)` converts the `update_weather` coroutine in to a regular function which when called will create and start a worker. +The addition of `@work(exclusive=True)` converts the `update_weather` coroutine into a regular function which when called will create and start a worker. Note that even though `update_weather` is an `async def` function, the decorator means that we don't need to use the `await` keyword when calling it. !!! tip @@ -88,12 +88,12 @@ Often a better approach is to handle [worker events](#worker-events) which will ### Cancelling workers You can cancel a worker at any time before it is finished by calling [Worker.cancel][textual.worker.Worker.cancel]. -This will raise an [CancelledError][asyncio.CancelledError] within the coroutine, and should cause it to exit prematurely. +This will raise a [CancelledError][asyncio.CancelledError] within the coroutine, and should cause it to exit prematurely. ### Worker errors The default behavior when a worker encounters an exception is to exit the app and display the traceback in the terminal. -You can also create workers which will *not* immediately exit on exception, by setting `exit_on_error=True` on the call to `run_worker` or the `@work` decorator. +You can also create workers which will *not* immediately exit on exception, by setting `exit_on_error=False` on the call to `run_worker` or the `@work` decorator. ### Worker lifetime From 20ca9238fe1f6d26d2c21a34935ab85fdf572a3c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 12:00:59 +0100 Subject: [PATCH 40/46] Update src/textual/widgets/_markdown.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/widgets/_markdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index a849a7c712..93d84e6258 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -587,7 +587,7 @@ def __init__(self, href: str) -> None: self.href: str = href """The link that was selected.""" - async def on_mount(self) -> None: + def on_mount(self) -> None: if self._markdown is not None: self.update(self._markdown) From b6b077d971f67dd06e18fe73d2c379d9b3e4e882 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 12:04:41 +0100 Subject: [PATCH 41/46] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/worker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/worker.py b/src/textual/worker.py index 088c793ce4..ff4bda2596 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -44,7 +44,7 @@ class WorkerFailed(WorkerError): def __init__(self, error: BaseException) -> None: self.error = error - super().__init__(f"Worker raise exception: {error!r}") + super().__init__(f"Worker raised exception: {error!r}") class DeadlockError(WorkerError): @@ -67,7 +67,7 @@ def get_current_worker() -> Worker: try: return active_worker.get() except LookupError: - raise NoActiveWorker("There is no active worker in this task or thread.") + raise NoActiveWorker("There is no active worker in this task or thread.") from None class WorkerState(enum.Enum): @@ -96,7 +96,7 @@ class WorkerState(enum.Enum): class _ReprText: - """Shim to insert a word in to the Worker's repr.""" + """Shim to insert a word into the Worker's repr.""" def __init__(self, text: str) -> None: self.text = text @@ -143,7 +143,7 @@ def __init__( """Initialize a Worker. Args: - node: THe widget, screen, or App that initiated the work. + node: The widget, screen, or App that initiated the work. work: A callable, coroutine, or other awaitable. name: Name of the worker (short string to help identify when debugging). group: The worker group. From 92b778dcb91508cedf88901998c4ed2b819169ff Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 12:05:30 +0100 Subject: [PATCH 42/46] Update src/textual/worker.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/worker.py b/src/textual/worker.py index ff4bda2596..5019c0ea4d 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -357,7 +357,7 @@ async def wait(self) -> ResultType: """Wait for the work to complete. Raises: - WorkerFailed: If the Worker raised an exception + WorkerFailed: If the Worker raised an exception. WorkerCancelled: If the Worker was cancelled before it completed. Returns: From 965103e4078722c2166991c3780c54528acde888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 4 Apr 2023 12:50:52 +0100 Subject: [PATCH 43/46] Fix black --- src/textual/worker.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/worker.py b/src/textual/worker.py index 5019c0ea4d..50383236fb 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -67,7 +67,9 @@ def get_current_worker() -> Worker: try: return active_worker.get() except LookupError: - raise NoActiveWorker("There is no active worker in this task or thread.") from None + raise NoActiveWorker( + "There is no active worker in this task or thread." + ) from None class WorkerState(enum.Enum): From e6abffda4dbbd284f8222b9813fe8ec9b4d297c7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 11:33:38 +0100 Subject: [PATCH 44/46] docstring --- src/textual/worker.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/worker.py b/src/textual/worker.py index 50383236fb..210e14656d 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -271,6 +271,9 @@ async def run(self) -> ResultType: Implement this method in a subclass, or pass a callable to the constructor. + Returns: + Return value of work. + """ if ( From 544bcf2b91ccd72336b3a4da9f94fcf5905d23c8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 12:59:57 +0100 Subject: [PATCH 45/46] merge --- src/textual/dom.py | 2 +- src/textual/worker.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index df51e44011..0ab188ef1a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -230,7 +230,7 @@ def run_worker( A worker runs a function, coroutine, or awaitable, in the *background* as an async task or as a thread. Args: - work: A function, async function, or an awaitable object. + work: A function, async function, or an awaitable object to run in a worker. name: A short string to identify the worker (in logs and debugging). group: A short string to identify a group of workers. description: A longer string to store longer information on the worker. diff --git a/src/textual/worker.py b/src/textual/worker.py index 210e14656d..f64ba8ec98 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -146,7 +146,7 @@ def __init__( Args: node: The widget, screen, or App that initiated the work. - work: A callable, coroutine, or other awaitable. + work: A callable, coroutine, or other awaitable object to run in the worker. name: Name of the worker (short string to help identify when debugging). group: The worker group. description: Description of the worker (longer string with more details). From 157d9b3db8816f70a6288b1eb6b168f0723b72ae Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 4 Apr 2023 12:59:24 +0100 Subject: [PATCH 46/46] changelog --- CHANGELOG.md | 7 ++++--- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01edc39617..4d183128c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.18.0] - Unreleased +## [0.18.0] - 2023-04-04 ### Added -- Added Worker API +- Added Worker API https://github.com/Textualize/textual/pull/2182 ### Changed -- Markdown.update is no longer a coroutine +- Markdown.update is no longer a coroutine https://github.com/Textualize/textual/pull/2182 ### [Fixed] @@ -713,6 +713,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.18.0]: https://github.com/Textualize/textual/compare/v0.17.4...v0.18.0 [0.17.3]: https://github.com/Textualize/textual/compare/v0.17.2...v0.17.3 [0.17.2]: https://github.com/Textualize/textual/compare/v0.17.1...v0.17.2 [0.17.1]: https://github.com/Textualize/textual/compare/v0.17.0...v0.17.1 diff --git a/pyproject.toml b/pyproject.toml index 6c4cd98694..361f71a3bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.17.3" +version = "0.18.0" homepage = "https://github.com/Textualize/textual" description = "Modern Text User Interface framework" authors = ["Will McGugan "]