Worker API (#2182)

* worker class

* worker API tests

* tidy

* Decorator and more tests

* type fix

* error order

* more tests

* remove active message

* move worker manager to app

* cancel nodes

* typing fix

* revert change

* typing fixes and cleanup

* revert typing

* test fix

* cancel group

* Added test for worker

* comment

* workers docs

* Added exit_on_error

* changelog

* svg

* refactor test

* remove debug tweaks

* docstrings

* worker test

* fix typing in run

* fix 3.7 tests

* blog post

* fix deadlock test

* words

* words

* words

* workers docs

* blog post

* Apply suggestions from code review

Co-authored-by: Dave Pearson <davep@davep.org>

* docstring

* fix and docstring

* Apply suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update src/textual/widgets/_markdown.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update src/textual/worker.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Fix black

* docstring

* merge

* changelog

---------

Co-authored-by: Dave Pearson <davep@davep.org>
Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
Will McGugan
2023-04-04 13:12:51 +01:00
committed by GitHub
parent c1ef3702fd
commit b5689b1f69
31 changed files with 1813 additions and 56 deletions

View File

@@ -1,4 +1,6 @@
import asyncio
import sys
import threading
import pytest
@@ -60,6 +62,8 @@ async def test_installed_screens():
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

View File

@@ -0,0 +1,33 @@
import asyncio
from textual import work
from textual.app import App
from textual.worker import Worker, WorkerState
async def test_work() -> None:
"""Test basic usage of the @work decorator."""
states: list[WorkerState] = []
class WorkApp(App):
worker: Worker
@work
async def foo(self) -> str:
await asyncio.sleep(0.1)
return "foo"
def on_mount(self) -> None:
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:
await app.workers.wait_for_complete()
result = await app.worker.wait()
assert result == "foo"
await pilot.pause()
assert states == [WorkerState.PENDING, WorkerState.RUNNING, WorkerState.SUCCESS]

155
tests/test_worker.py Normal file
View File

@@ -0,0 +1,155 @@
import asyncio
import pytest
from textual.app import App
from textual.worker import (
Worker,
WorkerCancelled,
WorkerFailed,
WorkerState,
get_current_worker,
)
async def test_initialize():
"""Test initial values."""
def foo() -> str:
return "foo"
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
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_success() -> None:
"""Test successful runs."""
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(
app, foo, name="foo", group="foo-group", description="Foo test"
)
# Call coroutine function
bar_worker: Worker[str] = Worker(
app, bar, name="bar", group="bar-group", description="Bar test"
)
# Call coroutine
baz_worker: Worker[str] = Worker(
app, 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 == "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(app, 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(app, 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(app, 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(app, run_worker)
worker._start(app)
assert await worker.wait() is worker

View File

@@ -0,0 +1,96 @@
import asyncio
import time
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.worker import Worker, WorkerState
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
await asyncio.sleep(0.02)
work_result = "foo"
return "foo"
def on_mount(self):
nonlocal new_worker
new_worker = self.run_worker(self.work, start=False)
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():
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)
states = [event.state for event in worker_events]
assert states == [
WorkerState.PENDING,
WorkerState.RUNNING,
WorkerState.SUCCESS,
]
async def test_run_worker_thread() -> None:
"""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():
await app.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,
]