cloudspin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudspin/__init__.py +3 -0
- cloudspin/__main__.py +4 -0
- cloudspin/app.py +497 -0
- cloudspin/canvas.py +321 -0
- cloudspin/cli.py +98 -0
- cloudspin/color.py +78 -0
- cloudspin/config.py +105 -0
- cloudspin/library.py +140 -0
- cloudspin/mood.py +245 -0
- cloudspin/player.py +447 -0
- cloudspin/screens.py +130 -0
- cloudspin/sky.py +829 -0
- cloudspin/songlook.py +116 -0
- cloudspin/spectrum.py +83 -0
- cloudspin/themes.py +100 -0
- cloudspin/vinyl.py +182 -0
- cloudspin/weather.py +310 -0
- cloudspin/widgets.py +750 -0
- cloudspin-0.1.0.dist-info/METADATA +206 -0
- cloudspin-0.1.0.dist-info/RECORD +23 -0
- cloudspin-0.1.0.dist-info/WHEEL +4 -0
- cloudspin-0.1.0.dist-info/entry_points.txt +2 -0
- cloudspin-0.1.0.dist-info/licenses/LICENSE +21 -0
cloudspin/weather.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Weather from Open-Meteo (free, no API key) and how it maps onto the sky."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from datetime import date, datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .sky import Daylight, Weather
|
|
17
|
+
|
|
18
|
+
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
|
19
|
+
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
|
20
|
+
TIMEOUT = 8.0
|
|
21
|
+
|
|
22
|
+
_CURRENT = (
|
|
23
|
+
"temperature_2m,apparent_temperature,relative_humidity_2m,is_day,precipitation,"
|
|
24
|
+
"weather_code,cloud_cover,wind_speed_10m,wind_direction_10m"
|
|
25
|
+
)
|
|
26
|
+
_DAILY = "weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max"
|
|
27
|
+
|
|
28
|
+
# WMO weather code -> (description, icon, sky). Sky wind is filled in later.
|
|
29
|
+
_CODES: dict[int, tuple[str, str, Weather]] = {
|
|
30
|
+
0: ("Clear sky", "clear", Weather(cover=0.0)),
|
|
31
|
+
1: ("Mostly clear", "clear", Weather(cover=0.14)),
|
|
32
|
+
2: ("Partly cloudy", "partly", Weather(cover=0.42)),
|
|
33
|
+
3: ("Overcast", "cloud", Weather(cover=0.96)),
|
|
34
|
+
45: ("Fog", "fog", Weather(cover=0.5, fog=1.0)),
|
|
35
|
+
48: ("Freezing fog", "fog", Weather(cover=0.5, fog=1.0)),
|
|
36
|
+
51: ("Light drizzle", "rain", Weather(cover=0.82, rain=0.16)),
|
|
37
|
+
53: ("Drizzle", "rain", Weather(cover=0.88, rain=0.26)),
|
|
38
|
+
55: ("Heavy drizzle", "rain", Weather(cover=0.92, rain=0.36)),
|
|
39
|
+
56: ("Freezing drizzle", "rain", Weather(cover=0.88, rain=0.22)),
|
|
40
|
+
57: ("Freezing drizzle", "rain", Weather(cover=0.92, rain=0.34)),
|
|
41
|
+
61: ("Light rain", "rain", Weather(cover=0.9, rain=0.42)),
|
|
42
|
+
63: ("Rain", "rain", Weather(cover=0.95, rain=0.68)),
|
|
43
|
+
65: ("Heavy rain", "rain", Weather(cover=1.0, rain=1.0)),
|
|
44
|
+
66: ("Freezing rain", "rain", Weather(cover=0.92, rain=0.5)),
|
|
45
|
+
67: ("Freezing rain", "rain", Weather(cover=1.0, rain=0.9)),
|
|
46
|
+
71: ("Light snow", "snow", Weather(cover=0.85, snow=0.35)),
|
|
47
|
+
73: ("Snow", "snow", Weather(cover=0.92, snow=0.65)),
|
|
48
|
+
75: ("Heavy snow", "snow", Weather(cover=1.0, snow=1.0)),
|
|
49
|
+
77: ("Snow grains", "snow", Weather(cover=0.85, snow=0.3)),
|
|
50
|
+
80: ("Light showers", "rain", Weather(cover=0.7, rain=0.48)),
|
|
51
|
+
81: ("Showers", "rain", Weather(cover=0.8, rain=0.74)),
|
|
52
|
+
82: ("Heavy showers", "rain", Weather(cover=0.95, rain=1.0)),
|
|
53
|
+
85: ("Snow showers", "snow", Weather(cover=0.8, snow=0.5)),
|
|
54
|
+
86: ("Heavy snow showers", "snow", Weather(cover=0.95, snow=0.9)),
|
|
55
|
+
95: ("Thunderstorm", "storm", Weather(cover=1.0, rain=0.85, storm=1.0)),
|
|
56
|
+
96: ("Thunderstorm with hail", "storm", Weather(cover=1.0, rain=1.0, storm=1.0)),
|
|
57
|
+
99: ("Thunderstorm with hail", "storm", Weather(cover=1.0, rain=1.0, storm=1.0)),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class WeatherError(Exception):
|
|
61
|
+
"""A weather lookup failed; the message is written for the person using the app."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Place:
|
|
66
|
+
name: str
|
|
67
|
+
latitude: float
|
|
68
|
+
longitude: float
|
|
69
|
+
country: str = ""
|
|
70
|
+
region: str = ""
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def label(self) -> str:
|
|
74
|
+
return f"{self.name}, {self.country}" if self.country else self.name
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Day:
|
|
79
|
+
day: date
|
|
80
|
+
code: int
|
|
81
|
+
high: float
|
|
82
|
+
low: float
|
|
83
|
+
rain_chance: int | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Report:
|
|
88
|
+
place: Place
|
|
89
|
+
units: str
|
|
90
|
+
temperature: float
|
|
91
|
+
feels_like: float
|
|
92
|
+
humidity: int
|
|
93
|
+
wind_speed: float
|
|
94
|
+
wind_direction: float
|
|
95
|
+
code: int
|
|
96
|
+
cloud_cover: float
|
|
97
|
+
high: float
|
|
98
|
+
low: float
|
|
99
|
+
sunrise: datetime # local wall-clock time at the place
|
|
100
|
+
sunset: datetime
|
|
101
|
+
next_sunrise: datetime
|
|
102
|
+
utc_offset: int # seconds
|
|
103
|
+
days: list[Day] = field(default_factory=list)
|
|
104
|
+
fetched_at: float = 0.0
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def description(self) -> str:
|
|
108
|
+
return describe(self.code)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def wind_unit(self) -> str:
|
|
112
|
+
return "mph" if self.units == "imperial" else "km/h"
|
|
113
|
+
|
|
114
|
+
def local_now(self, now: float | None = None) -> datetime:
|
|
115
|
+
stamp = time.time() if now is None else now
|
|
116
|
+
return datetime.fromtimestamp(stamp, timezone.utc).replace(tzinfo=None) + timedelta(seconds=self.utc_offset)
|
|
117
|
+
|
|
118
|
+
def sky(self) -> Weather:
|
|
119
|
+
wind_kmh = self.wind_speed * 1.609 if self.units == "imperial" else self.wind_speed
|
|
120
|
+
return sky_for(self.code, self.cloud_cover, wind_kmh)
|
|
121
|
+
|
|
122
|
+
def daylight(self, now: float | None = None) -> Daylight:
|
|
123
|
+
stamp = time.time() if now is None else now
|
|
124
|
+
return daylight_at(self.local_now(stamp), self.sunrise, self.sunset, self.next_sunrise, moon_phase(stamp))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def describe(code: int) -> str:
|
|
128
|
+
return _CODES.get(code, ("Unsettled", "cloud", Weather()))[0]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def icon_for(code: int) -> str:
|
|
132
|
+
return _CODES.get(code, ("", "cloud", Weather()))[1]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sky_for(code: int, cloud_cover: float | None = None, wind_kmh: float = 6.0) -> Weather:
|
|
136
|
+
base = _CODES.get(code, ("", "cloud", Weather(cover=0.6)))[2]
|
|
137
|
+
cover = base.cover
|
|
138
|
+
if cloud_cover is not None and code in (0, 1, 2, 3):
|
|
139
|
+
# Trust the measured cover for dry skies, but keep it in the band the code implies.
|
|
140
|
+
cover = min(max(cloud_cover / 100.0, cover - 0.14), cover + 0.2)
|
|
141
|
+
return Weather(
|
|
142
|
+
cover=max(0.0, min(1.0, cover)),
|
|
143
|
+
rain=base.rain,
|
|
144
|
+
snow=base.snow,
|
|
145
|
+
fog=base.fog,
|
|
146
|
+
storm=base.storm,
|
|
147
|
+
wind=max(1.0, wind_kmh),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def compass(degrees: float) -> str:
|
|
152
|
+
names = ("N", "NE", "E", "SE", "S", "SW", "W", "NW")
|
|
153
|
+
return names[int((degrees % 360) / 45.0 + 0.5) % 8]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def moon_phase(stamp: float | None = None) -> float:
|
|
157
|
+
"""Fraction through the lunar cycle: 0 new, 0.5 full."""
|
|
158
|
+
stamp = time.time() if stamp is None else stamp
|
|
159
|
+
known_new_moon = 947182440.0 # 2000-01-06 18:14 UTC
|
|
160
|
+
return ((stamp - known_new_moon) / 86400.0 / 29.530588853) % 1.0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def daylight_at(
|
|
164
|
+
now: datetime, sunrise: datetime, sunset: datetime, next_sunrise: datetime, phase: float = 0.5
|
|
165
|
+
) -> Daylight:
|
|
166
|
+
if sunrise <= now < sunset:
|
|
167
|
+
span = (sunset - sunrise).total_seconds() or 1.0
|
|
168
|
+
p = (now - sunrise).total_seconds() / span
|
|
169
|
+
return Daylight(elevation=math.sin(math.pi * p), sun_x=p, moon_x=1.0 if p < 0.5 else 0.0, moon_phase=phase)
|
|
170
|
+
if now >= sunset:
|
|
171
|
+
start, end = sunset, next_sunrise
|
|
172
|
+
else:
|
|
173
|
+
start, end = sunset - timedelta(days=1), sunrise
|
|
174
|
+
span = (end - start).total_seconds() or 1.0
|
|
175
|
+
p = min(1.0, max(0.0, (now - start).total_seconds() / span))
|
|
176
|
+
return Daylight(elevation=-math.sin(math.pi * p), sun_x=1.0 if p < 0.5 else 0.0, moon_x=p, moon_phase=phase)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def clock_daylight(now: datetime | None = None) -> Daylight:
|
|
180
|
+
"""A sensible sky for the local clock when no place is known yet."""
|
|
181
|
+
now = now or datetime.now()
|
|
182
|
+
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
183
|
+
sunrise = midnight + timedelta(hours=6, minutes=15)
|
|
184
|
+
sunset = midnight + timedelta(hours=18, minutes=30)
|
|
185
|
+
return daylight_at(now, sunrise, sunset, sunrise + timedelta(days=1), moon_phase())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# -- network -----------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _get(url: str, params: dict[str, object]) -> dict:
|
|
192
|
+
query = urllib.parse.urlencode(params)
|
|
193
|
+
request = urllib.request.Request(f"{url}?{query}", headers={"User-Agent": f"cloudspin/{__version__}"})
|
|
194
|
+
try:
|
|
195
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
|
196
|
+
return json.loads(response.read().decode("utf-8"))
|
|
197
|
+
except urllib.error.HTTPError as error:
|
|
198
|
+
raise WeatherError(f"The weather service answered with an error ({error.code}).") from error
|
|
199
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
200
|
+
raise WeatherError("Can't reach the weather service. Check your connection.") from error
|
|
201
|
+
except ValueError as error:
|
|
202
|
+
raise WeatherError("The weather service sent something unreadable.") from error
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def geocode(name: str) -> Place:
|
|
206
|
+
data = _get(GEOCODE_URL, {"name": name.strip(), "count": 1, "language": "en", "format": "json"})
|
|
207
|
+
results = data.get("results") or []
|
|
208
|
+
if not results:
|
|
209
|
+
raise WeatherError(f"No place called “{name.strip()}” was found. Try a nearby city.")
|
|
210
|
+
hit = results[0]
|
|
211
|
+
return Place(
|
|
212
|
+
name=str(hit.get("name", name)),
|
|
213
|
+
latitude=float(hit["latitude"]),
|
|
214
|
+
longitude=float(hit["longitude"]),
|
|
215
|
+
country=str(hit.get("country_code", "") or ""),
|
|
216
|
+
region=str(hit.get("admin1", "") or ""),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def parse(data: dict, place: Place, units: str = "metric", fetched_at: float | None = None) -> Report:
|
|
221
|
+
try:
|
|
222
|
+
current = data["current"]
|
|
223
|
+
daily = data["daily"]
|
|
224
|
+
days = [
|
|
225
|
+
Day(
|
|
226
|
+
day=date.fromisoformat(daily["time"][i]),
|
|
227
|
+
code=int(daily["weather_code"][i] or 0),
|
|
228
|
+
high=float(daily["temperature_2m_max"][i]),
|
|
229
|
+
low=float(daily["temperature_2m_min"][i]),
|
|
230
|
+
rain_chance=(
|
|
231
|
+
int(daily["precipitation_probability_max"][i])
|
|
232
|
+
if daily.get("precipitation_probability_max")
|
|
233
|
+
and daily["precipitation_probability_max"][i] is not None
|
|
234
|
+
else None
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
for i in range(len(daily["time"]))
|
|
238
|
+
]
|
|
239
|
+
sunrise = datetime.fromisoformat(daily["sunrise"][0])
|
|
240
|
+
sunset = datetime.fromisoformat(daily["sunset"][0])
|
|
241
|
+
if len(daily["sunrise"]) > 1:
|
|
242
|
+
next_sunrise = datetime.fromisoformat(daily["sunrise"][1])
|
|
243
|
+
else:
|
|
244
|
+
next_sunrise = sunrise + timedelta(days=1)
|
|
245
|
+
return Report(
|
|
246
|
+
place=place,
|
|
247
|
+
units=units,
|
|
248
|
+
temperature=float(current["temperature_2m"]),
|
|
249
|
+
feels_like=float(current.get("apparent_temperature", current["temperature_2m"])),
|
|
250
|
+
humidity=int(current.get("relative_humidity_2m", 0) or 0),
|
|
251
|
+
wind_speed=float(current.get("wind_speed_10m", 0.0) or 0.0),
|
|
252
|
+
wind_direction=float(current.get("wind_direction_10m", 0.0) or 0.0),
|
|
253
|
+
code=int(current.get("weather_code", 0) or 0),
|
|
254
|
+
cloud_cover=float(current.get("cloud_cover", 0.0) or 0.0),
|
|
255
|
+
high=days[0].high,
|
|
256
|
+
low=days[0].low,
|
|
257
|
+
sunrise=sunrise,
|
|
258
|
+
sunset=sunset,
|
|
259
|
+
next_sunrise=next_sunrise,
|
|
260
|
+
utc_offset=int(data.get("utc_offset_seconds", 0) or 0),
|
|
261
|
+
days=days,
|
|
262
|
+
fetched_at=time.time() if fetched_at is None else fetched_at,
|
|
263
|
+
)
|
|
264
|
+
except (KeyError, IndexError, TypeError, ValueError) as error:
|
|
265
|
+
raise WeatherError("The weather service sent something unreadable.") from error
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# -- a small disk cache so the sky is right the moment the app opens ---------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def save_cache(path: Path, data: dict, place: Place, units: str) -> None:
|
|
272
|
+
try:
|
|
273
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
payload = {"saved_at": time.time(), "units": units, "place": asdict(place), "data": data}
|
|
275
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
276
|
+
except OSError:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load_cache(path: Path, place: Place, units: str, max_age: float = 3 * 3600) -> Report | None:
|
|
281
|
+
try:
|
|
282
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
283
|
+
saved_at = float(payload["saved_at"])
|
|
284
|
+
if time.time() - saved_at > max_age or payload.get("units") != units:
|
|
285
|
+
return None
|
|
286
|
+
cached = Place(**payload["place"])
|
|
287
|
+
if abs(cached.latitude - place.latitude) > 0.01 or abs(cached.longitude - place.longitude) > 0.01:
|
|
288
|
+
return None
|
|
289
|
+
return parse(payload["data"], place, units, fetched_at=saved_at)
|
|
290
|
+
except (OSError, ValueError, KeyError, TypeError, WeatherError):
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def fetch(place: Place, units: str = "metric", cache: Path | None = None) -> Report:
|
|
295
|
+
params: dict[str, object] = {
|
|
296
|
+
"latitude": f"{place.latitude:.4f}",
|
|
297
|
+
"longitude": f"{place.longitude:.4f}",
|
|
298
|
+
"current": _CURRENT,
|
|
299
|
+
"daily": _DAILY,
|
|
300
|
+
"timezone": "auto",
|
|
301
|
+
"forecast_days": 7,
|
|
302
|
+
}
|
|
303
|
+
if units == "imperial":
|
|
304
|
+
params["temperature_unit"] = "fahrenheit"
|
|
305
|
+
params["wind_speed_unit"] = "mph"
|
|
306
|
+
data = _get(FORECAST_URL, params)
|
|
307
|
+
report = parse(data, place, units)
|
|
308
|
+
if cache is not None:
|
|
309
|
+
save_cache(cache, data, place, units)
|
|
310
|
+
return report
|