-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathtextual_towers_weather.py
228 lines (190 loc) · 7.38 KB
/
textual_towers_weather.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
"""A longer-form example of using textual-plotext."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from itertools import cycle
from json import loads
from typing import Any
from urllib.error import URLError
from urllib.request import Request, urlopen
from textual import on, work
from textual.app import App, ComposeResult
from textual.containers import Grid
from textual.message import Message
from textual.reactive import var
from textual.widgets import Footer, Header
from typing_extensions import Final
from textual_plotext import PlotextPlot
TEXTUAL_ICBM: Final[tuple[float, float]] = (55.9533, -3.1883)
"""The ICBM address of the approximate location of Textualize HQ."""
class Weather(PlotextPlot):
"""A widget for plotting weather data."""
marker: var[str] = var("sd")
"""The type of marker to use for the plot."""
def __init__(
self,
title: str,
*,
name: str | None = None,
id: str | None = None, # pylint:disable=redefined-builtin
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialise the weather widget.
Args:
name: The name of the weather widget.
id: The ID of the weather widget in the DOM.
classes: The CSS classes of the weather widget.
disabled: Whether the weather widget is disabled or not.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._title = title
self._unit = "Loading..."
self._data: list[float] = []
self._time: list[str] = []
self.watch(self.app, "theme", lambda: self.call_after_refresh(self.replot))
def on_mount(self) -> None:
"""Plot the data using Plotext."""
self.plt.date_form("Y-m-d H:M")
self.plt.title(self._title)
self.plt.xlabel("Time")
def replot(self) -> None:
"""Redraw the plot."""
self.plt.clear_data()
self.plt.ylabel(self._unit)
self.plt.plot(self._time, self._data, marker=self.marker)
self.refresh()
def update(self, data: dict[str, Any], values: str) -> None:
"""Update the data for the weather plot.
Args:
data: The data from the weather API.
values: The name of the values to plot.
"""
self._data = data["hourly"][values]
self._time = [moment.replace("T", " ") for moment in data["hourly"]["time"]]
self._unit = data["hourly_units"][values]
self.replot()
def _watch_marker(self) -> None:
"""React to the marker being changed."""
self.replot()
class TextualTowersWeatherApp(App[None]):
"""An application for showing recent Textualize weather."""
CSS = """
Grid {
grid-size: 2;
}
Weather {
padding: 1 2;
}
"""
TITLE = "Weather at Textual Towers Around a Year Ago"
BINDINGS = [
("d", "app.toggle_dark", "Toggle light/dark mode"),
("m", "marker", "Cycle example markers"),
("q", "app.quit", "Quit the example"),
("[", "previous_theme", "Previous theme"),
("]", "next_theme", "Next theme"),
]
MARKERS = {
"dot": "Dot",
"hd": "High Definition",
"fhd": "Higher Definition",
"braille": "Braille",
"sd": "Standard Definition",
}
marker: var[str] = var("sd")
"""The marker used for each of the plots."""
def __init__(self) -> None:
"""Initialise the application."""
super().__init__()
self._markers = cycle(self.MARKERS.keys())
self.theme_names = [
theme for theme in self.available_themes if theme != "textual-ansi"
]
def compose(self) -> ComposeResult:
"""Compose the display of the example app."""
yield Header()
with Grid():
yield Weather("Temperature", id="temperature")
yield Weather("Wind Speed (10m)", id="windspeed")
yield Weather("Precipitation", id="precipitation")
yield Weather("Surface Pressure", id="pressure")
yield Footer()
def on_mount(self) -> None:
"""Start the process of gathering the weather data."""
self.gather_weather()
@dataclass
class WeatherData(Message):
"""Message posted once the weather data has been gathered."""
history: dict[str, Any]
"""The history data gathered from the weather API."""
@work(thread=True, exclusive=True)
def gather_weather(self) -> None:
"""Worker thread that gathers historical weather data."""
end_date = (
datetime.now() - timedelta(days=365) + timedelta(weeks=1)
) # Yes, yes, I know. It's just an example.
start_date = end_date - timedelta(weeks=2) # Two! Weeks!
try:
with urlopen(
Request(
"https://archive-api.open-meteo.com/v1/archive?"
f"latitude={TEXTUAL_ICBM[0]}&longitude={TEXTUAL_ICBM[1]}"
f"&start_date={start_date.strftime('%Y-%m-%d')}"
f"&end_date={end_date.strftime('%Y-%m-%d')}"
"&hourly=temperature_2m,precipitation,surface_pressure,windspeed_10m"
)
) as result:
self.post_message(
self.WeatherData(loads(result.read().decode("utf-8")))
)
except URLError as error:
self.notify(
str(error),
title="Error loading weather data",
severity="error",
timeout=6,
)
@on(WeatherData)
def populate_plots(self, event: WeatherData) -> None:
"""Populate the weather plots with data received from the API.
Args:
event: The weather data reception event.
"""
with self.batch_update():
self.query_one("#temperature", Weather).update(
event.history, "temperature_2m"
)
self.query_one("#windspeed", Weather).update(event.history, "windspeed_10m")
self.query_one("#precipitation", Weather).update(
event.history, "precipitation"
)
self.query_one("#pressure", Weather).update(
event.history, "surface_pressure"
)
def watch_marker(self) -> None:
"""React to the marker type being changed."""
self.sub_title = self.MARKERS[self.marker]
for plot in self.query(Weather).results(Weather):
plot.marker = self.marker
def action_marker(self) -> None:
"""Cycle to the next marker type."""
self.marker = next(self._markers)
def action_next_theme(self) -> None:
themes = self.theme_names
index = themes.index(self.current_theme.name)
self.theme = themes[(index + 1) % len(themes)]
self.notify_new_theme(self.current_theme.name)
def action_previous_theme(self) -> None:
themes = self.theme_names
index = themes.index(self.current_theme.name)
self.theme = themes[(index - 1) % len(themes)]
self.notify_new_theme(self.current_theme.name)
def notify_new_theme(self, theme_name: str) -> None:
self.clear_notifications()
self.notify(
title="Theme updated",
message=f"Theme is {theme_name}.",
)
if __name__ == "__main__":
TextualTowersWeatherApp().run()