Skip to content

Commit b5689b1

Browse files
willmcgugandaveprodrigogiraoserrao
authored
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 <[email protected]> * docstring * fix and docstring * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <[email protected]> * Update src/textual/widgets/_markdown.py Co-authored-by: Rodrigo Girão Serrão <[email protected]> * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <[email protected]> * Update src/textual/worker.py Co-authored-by: Rodrigo Girão Serrão <[email protected]> * Fix black * docstring * merge * changelog --------- Co-authored-by: Dave Pearson <[email protected]> Co-authored-by: Rodrigo Girão Serrão <[email protected]>
1 parent c1ef370 commit b5689b1

31 files changed

+1813
-56
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8-
## Unreleased
8+
## [0.18.0] - 2023-04-04
9+
10+
### Added
11+
12+
- Added Worker API https://github.com/Textualize/textual/pull/2182
13+
14+
### Changed
15+
16+
- Markdown.update is no longer a coroutine https://github.com/Textualize/textual/pull/2182
917

1018
### [Fixed]
1119

@@ -705,6 +713,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
705713
- New handler system for messages that doesn't require inheritance
706714
- Improved traceback handling
707715

716+
[0.18.0]: https://github.com/Textualize/textual/compare/v0.17.4...v0.18.0
708717
[0.17.3]: https://github.com/Textualize/textual/compare/v0.17.2...v0.17.3
709718
[0.17.2]: https://github.com/Textualize/textual/compare/v0.17.1...v0.17.2
710719
[0.17.1]: https://github.com/Textualize/textual/compare/v0.17.0...v0.17.1

docs/api/worker.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: textual.worker

docs/api/worker_manager.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: textual._worker_manager

docs/blog/posts/release0-18-0.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
draft: false
3+
date: 2023-04-04
4+
categories:
5+
- Release
6+
title: "Textual 0.18.0 adds API for managing concurrent workers"
7+
authors:
8+
- willmcgugan
9+
---
10+
11+
# Textual 0.18.0 adds API for managing concurrent workers
12+
13+
Less than a week since the last release, and we have a new API to show you.
14+
15+
<!-- more -->
16+
17+
This release adds a new [Worker API](../../guide/workers.md) designed to manage concurrency, both asyncio tasks and threads.
18+
19+
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.
20+
People are building Textual apps to interface with REST APIs, websockets, and processes; and they are running into predictable issues.
21+
These aren't specifically Textual problems, but rather general problems related to async tasks and threads.
22+
It's not enough for us to point users at the asyncio docs, we needed a better answer.
23+
24+
The new `run_worker` method provides an easy way of launching "Workers" (a wrapper over async tasks and threads) which also manages their lifetime.
25+
26+
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).
27+
The new API piggybacks on to that existing mechanism to ensure that worker tasks are also shut down in the same order.
28+
29+
!!! tip
30+
31+
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.
32+
33+
34+
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.
35+
I suspect this will solve 90% of the concurrency issues we see with Textual apps.
36+
37+
See the [Worker API](../../guide/workers.md) for the details.
38+
39+
## Join us
40+
41+
If you want to talk about this update or anything else Textual related, join us on our [Discord server](https://discord.gg/Enf6Z3qhVr).
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Input {
2+
dock: top;
3+
width: 100%;
4+
}
5+
6+
#weather-container {
7+
width: 100%;
8+
height: 1fr;
9+
align: center middle;
10+
overflow: auto;
11+
}
12+
13+
#weather {
14+
width: auto;
15+
height: auto;
16+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import httpx
2+
from rich.text import Text
3+
4+
from textual.app import App, ComposeResult
5+
from textual.containers import VerticalScroll
6+
from textual.widgets import Input, Static
7+
8+
9+
class WeatherApp(App):
10+
"""App to display the current weather."""
11+
12+
CSS_PATH = "weather.css"
13+
14+
def compose(self) -> ComposeResult:
15+
yield Input(placeholder="Enter a City")
16+
with VerticalScroll(id="weather-container"):
17+
yield Static(id="weather")
18+
19+
async def on_input_changed(self, message: Input.Changed) -> None:
20+
"""Called when the input changes"""
21+
await self.update_weather(message.value)
22+
23+
async def update_weather(self, city: str) -> None:
24+
"""Update the weather for the given city."""
25+
weather_widget = self.query_one("#weather", Static)
26+
if city:
27+
# Query the network API
28+
url = f"https://wttr.in/{city}"
29+
async with httpx.AsyncClient() as client:
30+
response = await client.get(url)
31+
weather = Text.from_ansi(response.text)
32+
weather_widget.update(weather)
33+
else:
34+
# No city, so just blank out the weather
35+
weather_widget.update("")
36+
37+
38+
if __name__ == "__main__":
39+
app = WeatherApp()
40+
app.run()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import httpx
2+
from rich.text import Text
3+
4+
from textual.app import App, ComposeResult
5+
from textual.containers import VerticalScroll
6+
from textual.widgets import Input, Static
7+
8+
9+
class WeatherApp(App):
10+
"""App to display the current weather."""
11+
12+
CSS_PATH = "weather.css"
13+
14+
def compose(self) -> ComposeResult:
15+
yield Input(placeholder="Enter a City")
16+
with VerticalScroll(id="weather-container"):
17+
yield Static(id="weather")
18+
19+
async def on_input_changed(self, message: Input.Changed) -> None:
20+
"""Called when the input changes"""
21+
self.run_worker(self.update_weather(message.value), exclusive=True)
22+
23+
async def update_weather(self, city: str) -> None:
24+
"""Update the weather for the given city."""
25+
weather_widget = self.query_one("#weather", Static)
26+
if city:
27+
# Query the network API
28+
url = f"https://wttr.in/{city}"
29+
async with httpx.AsyncClient() as client:
30+
response = await client.get(url)
31+
weather = Text.from_ansi(response.text)
32+
weather_widget.update(weather)
33+
else:
34+
# No city, so just blank out the weather
35+
weather_widget.update("")
36+
37+
38+
if __name__ == "__main__":
39+
app = WeatherApp()
40+
app.run()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import httpx
2+
from rich.text import Text
3+
4+
from textual import work
5+
from textual.app import App, ComposeResult
6+
from textual.containers import VerticalScroll
7+
from textual.widgets import Input, Static
8+
9+
10+
class WeatherApp(App):
11+
"""App to display the current weather."""
12+
13+
CSS_PATH = "weather.css"
14+
15+
def compose(self) -> ComposeResult:
16+
yield Input(placeholder="Enter a City")
17+
with VerticalScroll(id="weather-container"):
18+
yield Static(id="weather")
19+
20+
async def on_input_changed(self, message: Input.Changed) -> None:
21+
"""Called when the input changes"""
22+
self.update_weather(message.value)
23+
24+
@work(exclusive=True)
25+
async def update_weather(self, city: str) -> None:
26+
"""Update the weather for the given city."""
27+
weather_widget = self.query_one("#weather", Static)
28+
if city:
29+
# Query the network API
30+
url = f"https://wttr.in/{city}"
31+
async with httpx.AsyncClient() as client:
32+
response = await client.get(url)
33+
weather = Text.from_ansi(response.text)
34+
weather_widget.update(weather)
35+
else:
36+
# No city, so just blank out the weather
37+
weather_widget.update("")
38+
39+
40+
if __name__ == "__main__":
41+
app = WeatherApp()
42+
app.run()
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import httpx
2+
from rich.text import Text
3+
4+
from textual import work
5+
from textual.app import App, ComposeResult
6+
from textual.containers import VerticalScroll
7+
from textual.widgets import Input, Static
8+
from textual.worker import Worker
9+
10+
11+
class WeatherApp(App):
12+
"""App to display the current weather."""
13+
14+
CSS_PATH = "weather.css"
15+
16+
def compose(self) -> ComposeResult:
17+
yield Input(placeholder="Enter a City")
18+
with VerticalScroll(id="weather-container"):
19+
yield Static(id="weather")
20+
21+
async def on_input_changed(self, message: Input.Changed) -> None:
22+
"""Called when the input changes"""
23+
self.update_weather(message.value)
24+
25+
@work(exclusive=True)
26+
async def update_weather(self, city: str) -> None:
27+
"""Update the weather for the given city."""
28+
weather_widget = self.query_one("#weather", Static)
29+
if city:
30+
# Query the network API
31+
url = f"https://wttr.in/{city}"
32+
async with httpx.AsyncClient() as client:
33+
response = await client.get(url)
34+
weather = Text.from_ansi(response.text)
35+
weather_widget.update(weather)
36+
else:
37+
# No city, so just blank out the weather
38+
weather_widget.update("")
39+
40+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
41+
"""Called when the worker state changes."""
42+
self.log(event)
43+
44+
45+
if __name__ == "__main__":
46+
app = WeatherApp()
47+
app.run()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from urllib.request import Request, urlopen
2+
3+
from rich.text import Text
4+
5+
from textual import work
6+
from textual.app import App, ComposeResult
7+
from textual.containers import VerticalScroll
8+
from textual.widgets import Input, Static
9+
from textual.worker import Worker, get_current_worker
10+
11+
12+
class WeatherApp(App):
13+
"""App to display the current weather."""
14+
15+
CSS_PATH = "weather.css"
16+
17+
def compose(self) -> ComposeResult:
18+
yield Input(placeholder="Enter a City")
19+
with VerticalScroll(id="weather-container"):
20+
yield Static(id="weather")
21+
22+
async def on_input_changed(self, message: Input.Changed) -> None:
23+
"""Called when the input changes"""
24+
self.update_weather(message.value)
25+
26+
@work(exclusive=True)
27+
def update_weather(self, city: str) -> None:
28+
"""Update the weather for the given city."""
29+
weather_widget = self.query_one("#weather", Static)
30+
worker = get_current_worker()
31+
if city:
32+
# Query the network API
33+
url = f"https://wttr.in/{city}"
34+
request = Request(url)
35+
request.add_header("User-agent", "CURL")
36+
response_text = urlopen(request).read().decode("utf-8")
37+
weather = Text.from_ansi(response_text)
38+
if not worker.is_cancelled:
39+
self.call_from_thread(weather_widget.update, weather)
40+
else:
41+
# No city, so just blank out the weather
42+
if not worker.is_cancelled:
43+
self.call_from_thread(weather_widget.update, "")
44+
45+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
46+
"""Called when the worker state changes."""
47+
self.log(event)
48+
49+
50+
if __name__ == "__main__":
51+
app = WeatherApp()
52+
app.run()

0 commit comments

Comments
 (0)