Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Worker API #2182

Merged
merged 49 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
f06c1ae
worker class
willmcgugan Mar 30, 2023
6129de3
worker API tests
willmcgugan Mar 31, 2023
01e1b65
tidy
willmcgugan Mar 31, 2023
3da8080
Decorator and more tests
willmcgugan Apr 1, 2023
0cb869c
type fix
willmcgugan Apr 1, 2023
bab3d1d
error order
willmcgugan Apr 1, 2023
21d5410
more tests
willmcgugan Apr 1, 2023
0aa2d19
remove active message
willmcgugan Apr 1, 2023
be89522
move worker manager to app
willmcgugan Apr 1, 2023
48867d3
cancel nodes
willmcgugan Apr 2, 2023
71e08f8
merge
willmcgugan Apr 2, 2023
6b2cf1f
typing fix
willmcgugan Apr 2, 2023
7157b05
Merge branch 'main' into worker-api
willmcgugan Apr 2, 2023
a716f6e
revert change
willmcgugan Apr 2, 2023
d5fc068
typing fixes and cleanup
willmcgugan Apr 2, 2023
a3e941a
revert typing
willmcgugan Apr 2, 2023
e6e824c
test fix
willmcgugan Apr 2, 2023
efc0e6c
cancel group
willmcgugan Apr 2, 2023
56969b1
Added test for worker
willmcgugan Apr 2, 2023
8408c2a
comment
willmcgugan Apr 2, 2023
bca93f4
workers docs
willmcgugan Apr 3, 2023
3461d59
Added exit_on_error
willmcgugan Apr 3, 2023
e7fcc2e
changelog
willmcgugan Apr 3, 2023
90058dc
svg
willmcgugan Apr 3, 2023
92ed4c0
refactor test
willmcgugan Apr 3, 2023
074b36e
remove debug tweaks
willmcgugan Apr 3, 2023
0dacf4a
docstrings
willmcgugan Apr 3, 2023
193440a
worker test
willmcgugan Apr 3, 2023
64a903d
fix typing in run
willmcgugan Apr 3, 2023
879fe90
fix 3.7 tests
willmcgugan Apr 3, 2023
4660fbb
blog post
willmcgugan Apr 3, 2023
cf10ec8
fix deadlock test
willmcgugan Apr 4, 2023
79d78b2
words
willmcgugan Apr 4, 2023
472bad6
Merge branch 'main' into worker-api
willmcgugan Apr 4, 2023
955b393
words
willmcgugan Apr 4, 2023
8b7751a
words
willmcgugan Apr 4, 2023
c2d1450
workers docs
willmcgugan Apr 4, 2023
8d9fabd
blog post
willmcgugan Apr 4, 2023
da3edfa
Apply suggestions from code review
willmcgugan Apr 4, 2023
fdc38c4
docstring
willmcgugan Apr 4, 2023
9d21443
fix and docstring
willmcgugan Apr 4, 2023
e4bf6a7
Apply suggestions from code review
willmcgugan Apr 4, 2023
20ca923
Update src/textual/widgets/_markdown.py
willmcgugan Apr 4, 2023
b6b077d
Apply suggestions from code review
willmcgugan Apr 4, 2023
92b778d
Update src/textual/worker.py
willmcgugan Apr 4, 2023
965103e
Fix black
rodrigogiraoserrao Apr 4, 2023
e6abffd
docstring
willmcgugan Apr 4, 2023
544bcf2
merge
willmcgugan Apr 4, 2023
157d9b3
changelog
willmcgugan Apr 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,23 @@ 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/).

## Unreleased
## [0.18.0] - Unreleased

### Added

- Added Worker API

### Changed

- Markdown.update is no longer a coroutine

### [Fixed]

- `RadioSet` is now far less likely to report `pressed_button` as `None` https://github.com/Textualize/textual/issues/2203

## Unreleased


willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
## [0.17.3] - 2023-04-02

### [Fixed]
Expand Down
1 change: 1 addition & 0 deletions docs/api/worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: textual.worker
1 change: 1 addition & 0 deletions docs/api/worker_manager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: textual._worker_manager
41 changes: 41 additions & 0 deletions docs/blog/posts/release0-18-0.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- more -->

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.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
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.

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.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved

!!! 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.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved


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.
willmcgugan marked this conversation as resolved.
Show resolved Hide resolved
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.

## 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).
16 changes: 16 additions & 0 deletions docs/examples/guide/workers/weather.css
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 40 additions & 0 deletions docs/examples/guide/workers/weather01.py
Original file line number Diff line number Diff line change
@@ -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()
40 changes: 40 additions & 0 deletions docs/examples/guide/workers/weather02.py
Original file line number Diff line number Diff line change
@@ -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()
42 changes: 42 additions & 0 deletions docs/examples/guide/workers/weather03.py
Original file line number Diff line number Diff line change
@@ -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()
47 changes: 47 additions & 0 deletions docs/examples/guide/workers/weather04.py
Original file line number Diff line number Diff line change
@@ -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()
52 changes: 52 additions & 0 deletions docs/examples/guide/workers/weather05.py
Original file line number Diff line number Diff line change
@@ -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()
Loading