isobar-cli 1.3.2__tar.gz → 1.3.4__tar.gz
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.
- {isobar_cli-1.3.2/src/isobar_cli.egg-info → isobar_cli-1.3.4}/PKG-INFO +6 -49
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/README.md +5 -48
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/pyproject.toml +1 -1
- isobar_cli-1.3.4/src/isobar_cli/__init__.py +1 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/api.py +13 -13
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/logic.py +124 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/main.py +18 -10
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/ui.py +123 -240
- {isobar_cli-1.3.2 → isobar_cli-1.3.4/src/isobar_cli.egg-info}/PKG-INFO +6 -49
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_isobar_extra.py +36 -6
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_main.py +16 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_ui.py +1 -1
- isobar_cli-1.3.2/src/isobar_cli/__init__.py +0 -1
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/LICENSE +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/setup.cfg +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/config.py +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/location.py +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/models.py +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/SOURCES.txt +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/dependency_links.txt +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/entry_points.txt +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/requires.txt +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/top_level.txt +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_api.py +0 -0
- {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_location.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: isobar-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.4
|
|
4
4
|
Summary: A terminal weather tool with industrial aesthetic, focusing on Real Feel and Windchill.
|
|
5
5
|
Author: Beau Bremer / KnowOneActual
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,13 +44,15 @@ Dynamic: license-file
|
|
|
44
44
|

|
|
45
45
|

|
|
46
46
|
|
|
47
|
+
**I'm currently working on improving the layout. Thanks so much for your patience with any little hiccups that might come up along the way!**
|
|
48
|
+
|
|
47
49
|
A terminal weather tool designed to provide a fast, clean sense of what the weather **feels like** outside; right now and for the week ahead. Built with Python and Rich.
|
|
48
50
|
|
|
49
51
|
## Philosophy
|
|
50
52
|
|
|
51
53
|
Isobar CLI answers a simple question: **"What does it feel like outside right now, and do I need a jacket?"**
|
|
52
54
|
|
|
53
|
-
Most weather apps
|
|
55
|
+
Most weather apps are overwhelmed with data. Isobar strips away everything except what matters when deciding how to prepare for the day.
|
|
54
56
|
|
|
55
57
|
### Design Principles
|
|
56
58
|
- **Essential over comprehensive** — Show Real Feel, not 47 data points.
|
|
@@ -214,52 +216,8 @@ isobar --install-completion bash
|
|
|
214
216
|
### Industrial Aesthetic (v1.3.0+)
|
|
215
217
|
|
|
216
218
|
```
|
|
217
|
-
|
|
218
|
-
CHICAGO
|
|
219
|
-
┌─────┬──────────────────────┬────────────────┬────────────────┐
|
|
220
|
-
│ │ METRIC │ READING │ STATUS │
|
|
221
|
-
├─────┼──────────────────────┼────────────────┼────────────────┤
|
|
222
|
-
│ ☀️ │ CONDITIONS │ MAINLY CLEAR │ ◇ │
|
|
223
|
-
│ 🌡️ │ TEMPERATURE │ 75.2°F │ [███░░░░░░░] │
|
|
224
|
-
│ 🤔 │ REAL FEEL │ 78.5°F │ ▲ 3.3°F │
|
|
225
|
-
│ 💨 │ WIND SPEED │ 12.4 mph │ GENTLE │
|
|
226
|
-
│ 💧 │ HUMIDITY │ 65% │ [▓▓▓░░] │
|
|
227
|
-
│ 😷 │ AIR QUALITY │ 45 │ GOOD ◇ │
|
|
228
|
-
│ ☔ │ PRECIPITATION │ 30% (6h) │ [▓░░] │
|
|
229
|
-
│ │ FORECAST │ LIGHT RAIN LIKELY │ │
|
|
230
|
-
│ 🌅 │ SUNRISE │ 06:29 │ DAWN │
|
|
231
|
-
│ 🌇 │ SUNSET │ 17:37 │ DUSK │
|
|
232
|
-
│ ☀️ │ UV INDEX │ 6.5 │ HIGH ☀️☀️☀️ │
|
|
233
|
-
│ ⚡ │ GUST ALERT │ 25 mph │ ⚠️ SEVERE │
|
|
234
|
-
└─────┴──────────────────────┴────────────────┴────────────────┘
|
|
235
|
-
|
|
236
|
-
┌─ TREND ANALYSIS ─┐
|
|
237
|
-
↑ 5.2°F WARMER THAN YESTERDAY
|
|
238
|
-
└──────────────────┘
|
|
239
|
-
|
|
240
|
-
┌─ PREPARATION PROTOCOL ─┐
|
|
241
|
-
⚠️ HIGH PRIORITY
|
|
242
|
-
▶ Wind gusts up to 25 mph - secure loose items
|
|
243
|
-
▲ RECOMMENDED
|
|
244
|
-
▶ Light jacket recommended
|
|
245
|
-
▶ Sunscreen recommended (UV: High)
|
|
246
|
-
○ ADVISORY
|
|
247
|
-
▶ Sunglasses recommended for glare
|
|
248
|
-
└─────────────────────────┘
|
|
249
|
-
|
|
250
|
-
┌─ FORECAST PANEL ─┐
|
|
251
|
-
CHICAGO
|
|
252
|
-
┌──────┬──┬────────────────────┬───────┬───────┬───────┬────────┐
|
|
253
|
-
│ DAY │ │ CONDITIONS │ HIGH │ LOW │ RAIN% │ STATUS │
|
|
254
|
-
├──────┼──┼────────────────────┼───────┼───────┼───────┼────────┤
|
|
255
|
-
│ TODAY│☀️│ MAINLY CLEAR │ 78.7°F│ 63.9°F│ 30% │ ○ │
|
|
256
|
-
│ TUE │⛅│ PARTLY CLOUDY │ 82.4°F│ 65.4°F│ 20% │ ○ │
|
|
257
|
-
│ WED │🌦️│ LIGHT DRIZZLE │ 76.8°F│ 60.9°F│ 45% │ ● │
|
|
258
|
-
│ THU │☀️│ CLEAR SKY │ 80.3°F│ 63.5°F│ 10% │ ○ │
|
|
259
|
-
└──────┴──┴────────────────────┴───────┴───────┴───────┴────────┘
|
|
260
|
-
```
|
|
219
|
+
Coming soon..
|
|
261
220
|
```
|
|
262
|
-
|
|
263
221
|
## 🛠 Tech Stack
|
|
264
222
|
|
|
265
223
|
| Tool | Purpose |
|
|
@@ -273,7 +231,7 @@ CHICAGO
|
|
|
273
231
|
| [requests-mock](https://requests-mock.readthedocs.io/) | API testing |
|
|
274
232
|
| [Ruff](https://docs.astral.sh/ruff/) | Linting and formatting |
|
|
275
233
|
| [pip-audit](https://github.com/pypa/pip-audit) | Dependency security scanning |
|
|
276
|
-
|
|
|
234
|
+
| New improvemets | **Intuition & Analysis** |
|
|
277
235
|
| `config.py` | Persistent home city configuration |
|
|
278
236
|
| Enhanced `logic.py` | Preparation guidance, UV monitoring, gust alerts |
|
|
279
237
|
| Updated `ui.py` | Contextual display of insights |
|
|
@@ -281,7 +239,6 @@ CHICAGO
|
|
|
281
239
|
| Configurable API Endpoints | Environment variable support for custom APIs |
|
|
282
240
|
| Enhanced Error Handling | Specific exception catching with timeouts |
|
|
283
241
|
| Timezone Support | Optional `pytz` dependency for local time display |
|
|
284
|
-
| **v1.3.0 Features** | **Industrial Aesthetic** |
|
|
285
242
|
| Industrial UI Design | Retro-futuristic weather observatory dashboard |
|
|
286
243
|
| Visual Gauges | Temperature and humidity gauge visualizations |
|
|
287
244
|
| Severity Indicators | Weather condition severity classification |
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|

|
|
10
10
|

|
|
11
11
|
|
|
12
|
+
**I'm currently working on improving the layout. Thanks so much for your patience with any little hiccups that might come up along the way!**
|
|
13
|
+
|
|
12
14
|
A terminal weather tool designed to provide a fast, clean sense of what the weather **feels like** outside; right now and for the week ahead. Built with Python and Rich.
|
|
13
15
|
|
|
14
16
|
## Philosophy
|
|
15
17
|
|
|
16
18
|
Isobar CLI answers a simple question: **"What does it feel like outside right now, and do I need a jacket?"**
|
|
17
19
|
|
|
18
|
-
Most weather apps
|
|
20
|
+
Most weather apps are overwhelmed with data. Isobar strips away everything except what matters when deciding how to prepare for the day.
|
|
19
21
|
|
|
20
22
|
### Design Principles
|
|
21
23
|
- **Essential over comprehensive** — Show Real Feel, not 47 data points.
|
|
@@ -179,52 +181,8 @@ isobar --install-completion bash
|
|
|
179
181
|
### Industrial Aesthetic (v1.3.0+)
|
|
180
182
|
|
|
181
183
|
```
|
|
182
|
-
|
|
183
|
-
CHICAGO
|
|
184
|
-
┌─────┬──────────────────────┬────────────────┬────────────────┐
|
|
185
|
-
│ │ METRIC │ READING │ STATUS │
|
|
186
|
-
├─────┼──────────────────────┼────────────────┼────────────────┤
|
|
187
|
-
│ ☀️ │ CONDITIONS │ MAINLY CLEAR │ ◇ │
|
|
188
|
-
│ 🌡️ │ TEMPERATURE │ 75.2°F │ [███░░░░░░░] │
|
|
189
|
-
│ 🤔 │ REAL FEEL │ 78.5°F │ ▲ 3.3°F │
|
|
190
|
-
│ 💨 │ WIND SPEED │ 12.4 mph │ GENTLE │
|
|
191
|
-
│ 💧 │ HUMIDITY │ 65% │ [▓▓▓░░] │
|
|
192
|
-
│ 😷 │ AIR QUALITY │ 45 │ GOOD ◇ │
|
|
193
|
-
│ ☔ │ PRECIPITATION │ 30% (6h) │ [▓░░] │
|
|
194
|
-
│ │ FORECAST │ LIGHT RAIN LIKELY │ │
|
|
195
|
-
│ 🌅 │ SUNRISE │ 06:29 │ DAWN │
|
|
196
|
-
│ 🌇 │ SUNSET │ 17:37 │ DUSK │
|
|
197
|
-
│ ☀️ │ UV INDEX │ 6.5 │ HIGH ☀️☀️☀️ │
|
|
198
|
-
│ ⚡ │ GUST ALERT │ 25 mph │ ⚠️ SEVERE │
|
|
199
|
-
└─────┴──────────────────────┴────────────────┴────────────────┘
|
|
200
|
-
|
|
201
|
-
┌─ TREND ANALYSIS ─┐
|
|
202
|
-
↑ 5.2°F WARMER THAN YESTERDAY
|
|
203
|
-
└──────────────────┘
|
|
204
|
-
|
|
205
|
-
┌─ PREPARATION PROTOCOL ─┐
|
|
206
|
-
⚠️ HIGH PRIORITY
|
|
207
|
-
▶ Wind gusts up to 25 mph - secure loose items
|
|
208
|
-
▲ RECOMMENDED
|
|
209
|
-
▶ Light jacket recommended
|
|
210
|
-
▶ Sunscreen recommended (UV: High)
|
|
211
|
-
○ ADVISORY
|
|
212
|
-
▶ Sunglasses recommended for glare
|
|
213
|
-
└─────────────────────────┘
|
|
214
|
-
|
|
215
|
-
┌─ FORECAST PANEL ─┐
|
|
216
|
-
CHICAGO
|
|
217
|
-
┌──────┬──┬────────────────────┬───────┬───────┬───────┬────────┐
|
|
218
|
-
│ DAY │ │ CONDITIONS │ HIGH │ LOW │ RAIN% │ STATUS │
|
|
219
|
-
├──────┼──┼────────────────────┼───────┼───────┼───────┼────────┤
|
|
220
|
-
│ TODAY│☀️│ MAINLY CLEAR │ 78.7°F│ 63.9°F│ 30% │ ○ │
|
|
221
|
-
│ TUE │⛅│ PARTLY CLOUDY │ 82.4°F│ 65.4°F│ 20% │ ○ │
|
|
222
|
-
│ WED │🌦️│ LIGHT DRIZZLE │ 76.8°F│ 60.9°F│ 45% │ ● │
|
|
223
|
-
│ THU │☀️│ CLEAR SKY │ 80.3°F│ 63.5°F│ 10% │ ○ │
|
|
224
|
-
└──────┴──┴────────────────────┴───────┴───────┴───────┴────────┘
|
|
225
|
-
```
|
|
184
|
+
Coming soon..
|
|
226
185
|
```
|
|
227
|
-
|
|
228
186
|
## 🛠 Tech Stack
|
|
229
187
|
|
|
230
188
|
| Tool | Purpose |
|
|
@@ -238,7 +196,7 @@ CHICAGO
|
|
|
238
196
|
| [requests-mock](https://requests-mock.readthedocs.io/) | API testing |
|
|
239
197
|
| [Ruff](https://docs.astral.sh/ruff/) | Linting and formatting |
|
|
240
198
|
| [pip-audit](https://github.com/pypa/pip-audit) | Dependency security scanning |
|
|
241
|
-
|
|
|
199
|
+
| New improvemets | **Intuition & Analysis** |
|
|
242
200
|
| `config.py` | Persistent home city configuration |
|
|
243
201
|
| Enhanced `logic.py` | Preparation guidance, UV monitoring, gust alerts |
|
|
244
202
|
| Updated `ui.py` | Contextual display of insights |
|
|
@@ -246,7 +204,6 @@ CHICAGO
|
|
|
246
204
|
| Configurable API Endpoints | Environment variable support for custom APIs |
|
|
247
205
|
| Enhanced Error Handling | Specific exception catching with timeouts |
|
|
248
206
|
| Timezone Support | Optional `pytz` dependency for local time display |
|
|
249
|
-
| **v1.3.0 Features** | **Industrial Aesthetic** |
|
|
250
207
|
| Industrial UI Design | Retro-futuristic weather observatory dashboard |
|
|
251
208
|
| Visual Gauges | Temperature and humidity gauge visualizations |
|
|
252
209
|
| Severity Indicators | Weather condition severity classification |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "isobar-cli"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.4"
|
|
8
8
|
description = "A terminal weather tool with industrial aesthetic, focusing on Real Feel and Windchill."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name="Beau Bremer / KnowOneActual" },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.4"
|
|
@@ -15,6 +15,12 @@ CACHE_DIR = Path.home() / ".cache" / "isobar"
|
|
|
15
15
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
class WeatherAPIError(Exception):
|
|
19
|
+
"""Raised when the weather or geocoding API request fails."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
class GeocodingClient:
|
|
19
25
|
@classmethod
|
|
20
26
|
def get_base_url(cls) -> str:
|
|
@@ -31,17 +37,9 @@ class GeocodingClient:
|
|
|
31
37
|
response.raise_for_status()
|
|
32
38
|
return response.json().get("results", [])
|
|
33
39
|
except requests.exceptions.RequestException as e:
|
|
34
|
-
|
|
35
|
-
import sys
|
|
36
|
-
|
|
37
|
-
print(f"Geocoding error for '{city}': {e}", file=sys.stderr)
|
|
38
|
-
return []
|
|
40
|
+
raise WeatherAPIError(f"Geocoding error for '{city}': {e}") from e
|
|
39
41
|
except Exception as e:
|
|
40
|
-
|
|
41
|
-
import sys
|
|
42
|
-
|
|
43
|
-
print(f"Unexpected geocoding error for '{city}': {e}", file=sys.stderr)
|
|
44
|
-
return []
|
|
42
|
+
raise WeatherAPIError(f"Unexpected geocoding error for '{city}': {e}") from e
|
|
45
43
|
|
|
46
44
|
|
|
47
45
|
class WeatherClient:
|
|
@@ -128,7 +126,10 @@ def get_cached_cities() -> list[str]:
|
|
|
128
126
|
|
|
129
127
|
def get_city_suggestions(city: str) -> list[str]:
|
|
130
128
|
"""Fetches a list of likely city name matches for a given input string."""
|
|
131
|
-
|
|
129
|
+
try:
|
|
130
|
+
results = GeocodingClient.search(city, count=5)
|
|
131
|
+
except WeatherAPIError:
|
|
132
|
+
return []
|
|
132
133
|
suggestions = []
|
|
133
134
|
for loc in results:
|
|
134
135
|
region = loc.get("admin1", loc.get("country", ""))
|
|
@@ -175,8 +176,7 @@ def get_weather_data(city: str, metric: bool = False) -> Optional[WeatherData]:
|
|
|
175
176
|
api_data = weather_client.fetch()
|
|
176
177
|
aqi_value = AirQualityClient.get_aqi(lat, lon)
|
|
177
178
|
except requests.RequestException as e:
|
|
178
|
-
|
|
179
|
-
return None
|
|
179
|
+
raise WeatherAPIError(f"Error fetching weather: {e}") from e
|
|
180
180
|
|
|
181
181
|
current = api_data["current"]
|
|
182
182
|
hourly = api_data["hourly"]
|
|
@@ -308,3 +308,127 @@ def get_temporal_context(
|
|
|
308
308
|
return f"{direction} {abs_diff:.1f}°C {'warmer' if diff > 0 else 'cooler'} than yesterday"
|
|
309
309
|
else:
|
|
310
310
|
return f"{direction} {abs_diff:.1f}°F {'warmer' if diff > 0 else 'cooler'} than yesterday"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def get_temperature_comfort(temp: float, unit: str = "°F") -> tuple[str, str]:
|
|
314
|
+
"""Get temperature comfort category and color.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
temp: Temperature value
|
|
318
|
+
unit: Temperature unit (°F or °C)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Tuple of (category, color) where category is a comfort level
|
|
322
|
+
"""
|
|
323
|
+
if unit == "°F":
|
|
324
|
+
if temp < 32:
|
|
325
|
+
return "FREEZING", "bright_cyan"
|
|
326
|
+
elif temp < 50:
|
|
327
|
+
return "COLD", "cyan"
|
|
328
|
+
elif temp < 75:
|
|
329
|
+
return "COMFORTABLE", "bright_green"
|
|
330
|
+
elif temp < 90:
|
|
331
|
+
return "WARM", "bright_yellow"
|
|
332
|
+
else:
|
|
333
|
+
return "HOT", "bright_red"
|
|
334
|
+
else: # °C
|
|
335
|
+
if temp < 0:
|
|
336
|
+
return "FREEZING", "bright_cyan"
|
|
337
|
+
elif temp < 10:
|
|
338
|
+
return "COLD", "cyan"
|
|
339
|
+
elif temp < 24:
|
|
340
|
+
return "COMFORTABLE", "bright_green"
|
|
341
|
+
elif temp < 32:
|
|
342
|
+
return "WARM", "bright_yellow"
|
|
343
|
+
else:
|
|
344
|
+
return "HOT", "bright_red"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def get_humidity_category(humidity: int) -> tuple[str, str]:
|
|
348
|
+
"""Get humidity category and color.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
humidity: Humidity percentage (0-100)
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Tuple of (category, color)
|
|
355
|
+
"""
|
|
356
|
+
if humidity < 30:
|
|
357
|
+
return "DRY", "bright_yellow"
|
|
358
|
+
elif humidity < 60:
|
|
359
|
+
return "IDEAL", "bright_green"
|
|
360
|
+
elif humidity < 80:
|
|
361
|
+
return "HUMID", "bright_yellow"
|
|
362
|
+
else:
|
|
363
|
+
return "MUGGY", "bright_red"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_wind_category_label(speed: float, unit: str) -> tuple[str, str]:
|
|
367
|
+
"""Get wind category label and color.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
speed: Wind speed
|
|
371
|
+
unit: Wind unit (mph or km/h)
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Tuple of (category, color)
|
|
375
|
+
"""
|
|
376
|
+
if unit == "mph":
|
|
377
|
+
if speed < 1:
|
|
378
|
+
return "CALM", "bright_cyan"
|
|
379
|
+
elif speed < 7:
|
|
380
|
+
return "LIGHT", "cyan"
|
|
381
|
+
elif speed < 12:
|
|
382
|
+
return "GENTLE", "bright_green"
|
|
383
|
+
elif speed < 18:
|
|
384
|
+
return "MODERATE", "bright_yellow"
|
|
385
|
+
elif speed < 24:
|
|
386
|
+
return "FRESH", "yellow"
|
|
387
|
+
elif speed < 31:
|
|
388
|
+
return "STRONG", "bright_red"
|
|
389
|
+
elif speed < 38:
|
|
390
|
+
return "GALE", "red"
|
|
391
|
+
elif speed < 46:
|
|
392
|
+
return "SEVERE", "bright_red"
|
|
393
|
+
else:
|
|
394
|
+
return "STORM", "bright_red"
|
|
395
|
+
else: # km/h
|
|
396
|
+
if speed < 2:
|
|
397
|
+
return "CALM", "bright_cyan"
|
|
398
|
+
elif speed < 12:
|
|
399
|
+
return "LIGHT", "cyan"
|
|
400
|
+
elif speed < 20:
|
|
401
|
+
return "GENTLE", "bright_green"
|
|
402
|
+
elif speed < 29:
|
|
403
|
+
return "MODERATE", "bright_yellow"
|
|
404
|
+
elif speed < 39:
|
|
405
|
+
return "FRESH", "yellow"
|
|
406
|
+
elif speed < 50:
|
|
407
|
+
return "STRONG", "bright_red"
|
|
408
|
+
elif speed < 62:
|
|
409
|
+
return "GALE", "red"
|
|
410
|
+
elif speed < 75:
|
|
411
|
+
return "SEVERE", "bright_red"
|
|
412
|
+
else:
|
|
413
|
+
return "STORM", "bright_red"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def get_uv_category(uv_index: float) -> tuple[str, str]:
|
|
417
|
+
"""Get UV index category and color.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
uv_index: UV index value
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Tuple of (category, color)
|
|
424
|
+
"""
|
|
425
|
+
if uv_index <= 2:
|
|
426
|
+
return "LOW", "bright_green"
|
|
427
|
+
elif uv_index <= 5:
|
|
428
|
+
return "MODERATE", "bright_yellow"
|
|
429
|
+
elif uv_index <= 7:
|
|
430
|
+
return "HIGH", "bright_red"
|
|
431
|
+
elif uv_index <= 10:
|
|
432
|
+
return "VERY HIGH", "red"
|
|
433
|
+
else:
|
|
434
|
+
return "EXTREME", "bright_red"
|
|
@@ -3,7 +3,12 @@ from typing import Annotated, Optional
|
|
|
3
3
|
import typer
|
|
4
4
|
from rich.console import Console
|
|
5
5
|
|
|
6
|
-
from isobar_cli.api import
|
|
6
|
+
from isobar_cli.api import (
|
|
7
|
+
WeatherAPIError,
|
|
8
|
+
get_cached_cities,
|
|
9
|
+
get_city_suggestions,
|
|
10
|
+
get_weather_data,
|
|
11
|
+
)
|
|
7
12
|
from isobar_cli.config import clear_home_city, get_home_city, set_home_city
|
|
8
13
|
from isobar_cli.location import get_auto_location
|
|
9
14
|
from isobar_cli.ui import (
|
|
@@ -161,15 +166,18 @@ def _run_weather_logic(
|
|
|
161
166
|
results = []
|
|
162
167
|
for city_name in cities_to_fetch:
|
|
163
168
|
full_city = city_name.replace("_", " ")
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
try:
|
|
170
|
+
weather = get_weather_data(full_city, metric=metric)
|
|
171
|
+
if weather:
|
|
172
|
+
results.append(weather)
|
|
173
|
+
else:
|
|
174
|
+
console.print(f"[bold red]❌ '{full_city}' not found.[/bold red]")
|
|
175
|
+
suggestions = get_city_suggestions(full_city)
|
|
176
|
+
if suggestions:
|
|
177
|
+
suggest_str = ", ".join(suggestions[:3])
|
|
178
|
+
console.print(f"[dim]Did you mean: {suggest_str}?[/dim]")
|
|
179
|
+
except WeatherAPIError as e:
|
|
180
|
+
console.print(f"[bold red]❌ {e}[/bold red]")
|
|
173
181
|
|
|
174
182
|
if not results:
|
|
175
183
|
raise typer.Exit(code=1)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
2
|
|
|
3
|
-
from rich import box
|
|
4
3
|
from rich.columns import Columns
|
|
5
4
|
from rich.console import Console
|
|
6
5
|
from rich.table import Table
|
|
@@ -9,11 +8,13 @@ from .logic import (
|
|
|
9
8
|
WMO_CODES,
|
|
10
9
|
get_aqi_label,
|
|
11
10
|
get_feels_like_label,
|
|
12
|
-
|
|
11
|
+
get_humidity_category,
|
|
13
12
|
get_preparation_guidance,
|
|
14
13
|
get_temp_color,
|
|
14
|
+
get_temperature_comfort,
|
|
15
15
|
get_temporal_context,
|
|
16
|
-
|
|
16
|
+
get_uv_category,
|
|
17
|
+
get_wind_category_label,
|
|
17
18
|
get_wind_gust_alert,
|
|
18
19
|
)
|
|
19
20
|
from .models import WeatherData
|
|
@@ -34,15 +35,6 @@ COLORS = {
|
|
|
34
35
|
"concrete": "white",
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
# Weather severity indicators
|
|
38
|
-
SEVERITY_ICONS = {
|
|
39
|
-
"extreme": "⚡",
|
|
40
|
-
"high": "▲",
|
|
41
|
-
"medium": "●",
|
|
42
|
-
"low": "○",
|
|
43
|
-
"normal": "◇",
|
|
44
|
-
}
|
|
45
|
-
|
|
46
38
|
|
|
47
39
|
def get_weather_icon(code: int) -> tuple[str, str]:
|
|
48
40
|
"""Return (emoji, description) for a WMO weather code."""
|
|
@@ -50,7 +42,7 @@ def get_weather_icon(code: int) -> tuple[str, str]:
|
|
|
50
42
|
|
|
51
43
|
|
|
52
44
|
def build_weather_table(weather: WeatherData) -> Table:
|
|
53
|
-
"""Constructs the current conditions weather table with industrial aesthetic."""
|
|
45
|
+
"""Constructs the current conditions weather table with borderless industrial aesthetic."""
|
|
54
46
|
icon, desc = get_weather_icon(weather.weather_code)
|
|
55
47
|
temp_color = get_temp_color(weather.temp, weather.units.temp)
|
|
56
48
|
feels_color = get_temp_color(weather.feels_like, weather.units.temp)
|
|
@@ -58,47 +50,50 @@ def build_weather_table(weather: WeatherData) -> Table:
|
|
|
58
50
|
weather.temp, weather.feels_like, weather.units.temp
|
|
59
51
|
)
|
|
60
52
|
|
|
61
|
-
#
|
|
53
|
+
# Get meaningful labels for weather data
|
|
54
|
+
temp_comfort, temp_comfort_color = get_temperature_comfort(
|
|
55
|
+
weather.temp, weather.units.temp
|
|
56
|
+
)
|
|
57
|
+
humidity_category, humidity_color = get_humidity_category(weather.humidity)
|
|
58
|
+
wind_category, wind_color = get_wind_category_label(
|
|
59
|
+
weather.wind_speed, weather.units.wind
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Create borderless industrial-style table
|
|
62
63
|
table = Table(
|
|
63
|
-
title=f"[{COLORS['primary']} bold]
|
|
64
|
-
f"[{COLORS['steel']}]{weather.city.upper()}[/{COLORS['steel']}]",
|
|
64
|
+
title=f"[{COLORS['primary']} bold]WEATHER OBSERVATORY · {weather.city.upper()}[/{COLORS['primary']} bold]",
|
|
65
65
|
show_header=False,
|
|
66
|
-
box=
|
|
67
|
-
|
|
68
|
-
title_style=f"{COLORS['primary']} bold",
|
|
69
|
-
padding=(0, 2),
|
|
66
|
+
box=None, # No borders
|
|
67
|
+
padding=(0, 1),
|
|
70
68
|
width=60,
|
|
69
|
+
title_style=f"{COLORS['primary']} bold",
|
|
71
70
|
)
|
|
72
71
|
|
|
73
|
-
# Add columns
|
|
74
|
-
table.add_column("", justify="center", width=
|
|
75
|
-
table.add_column(
|
|
76
|
-
|
|
77
|
-
table.add_column("STATUS", justify="right", width=15, style=COLORS["secondary"])
|
|
78
|
-
|
|
79
|
-
# Weather condition row with severity indicator
|
|
80
|
-
severity = (
|
|
81
|
-
"medium" if weather.weather_code in [95, 96, 99, 65, 75, 86] else "normal"
|
|
72
|
+
# Add columns: icon, metric, value, category
|
|
73
|
+
table.add_column("", justify="center", width=3, style=COLORS["accent"])
|
|
74
|
+
table.add_column(
|
|
75
|
+
"METRIC", justify="left", width=18, style=f"{COLORS['muted']} bold"
|
|
82
76
|
)
|
|
77
|
+
table.add_column("VALUE", justify="right", width=12, style=COLORS["primary"])
|
|
78
|
+
table.add_column("CATEGORY", justify="right", width=20, style=COLORS["secondary"])
|
|
79
|
+
|
|
80
|
+
# Weather condition
|
|
83
81
|
table.add_row(
|
|
84
82
|
f"[{COLORS['accent']}]{icon}[/{COLORS['accent']}]",
|
|
85
|
-
"
|
|
86
|
-
f"[{COLORS['primary']}
|
|
87
|
-
|
|
83
|
+
"CONDITIONS",
|
|
84
|
+
f"[{COLORS['primary']}]{desc.upper()}[/{COLORS['primary']}]",
|
|
85
|
+
"",
|
|
88
86
|
)
|
|
89
87
|
|
|
90
|
-
# Temperature with
|
|
91
|
-
temp_gauge = create_gauge(
|
|
92
|
-
weather.temp, weather.units.temp, 100 if weather.units.temp == "°F" else 40
|
|
93
|
-
)
|
|
88
|
+
# Temperature with comfort category
|
|
94
89
|
table.add_row(
|
|
95
90
|
"🌡️",
|
|
96
|
-
"
|
|
91
|
+
"TEMPERATURE",
|
|
97
92
|
f"[{temp_color}]{weather.temp}{weather.units.temp}[/{temp_color}]",
|
|
98
|
-
|
|
93
|
+
f"[{temp_comfort_color}]{temp_comfort}[/{temp_comfort_color}]",
|
|
99
94
|
)
|
|
100
95
|
|
|
101
|
-
# Feels like with difference
|
|
96
|
+
# Feels like with difference
|
|
102
97
|
temp_diff = weather.feels_like - weather.temp
|
|
103
98
|
diff_symbol = "▲" if temp_diff > 0 else "▼" if temp_diff < 0 else "▬"
|
|
104
99
|
diff_color = (
|
|
@@ -108,195 +103,115 @@ def build_weather_table(weather: WeatherData) -> Table:
|
|
|
108
103
|
if temp_diff < -5
|
|
109
104
|
else COLORS["muted"]
|
|
110
105
|
)
|
|
106
|
+
feels_comfort, feels_comfort_color = get_temperature_comfort(
|
|
107
|
+
weather.feels_like, weather.units.temp
|
|
108
|
+
)
|
|
111
109
|
table.add_row(
|
|
112
110
|
"🤔",
|
|
113
|
-
f"
|
|
111
|
+
f"{feels_label.upper()}",
|
|
114
112
|
f"[{feels_color}]{weather.feels_like}{weather.units.temp}[/{feels_color}]",
|
|
115
|
-
f"[{diff_color}]{diff_symbol}
|
|
113
|
+
f"[{feels_comfort_color}]{feels_comfort} [{diff_color}]{diff_symbol}{abs(temp_diff):.1f}{weather.units.temp}[/{diff_color}][/{feels_comfort_color}]",
|
|
116
114
|
)
|
|
117
115
|
|
|
118
|
-
# Wind with
|
|
119
|
-
wind_category = get_wind_category(weather.wind_speed, weather.units.wind)
|
|
116
|
+
# Wind with category
|
|
120
117
|
table.add_row(
|
|
121
118
|
"💨",
|
|
122
|
-
"
|
|
119
|
+
"WIND SPEED",
|
|
123
120
|
f"{weather.wind_speed} {weather.units.wind}",
|
|
124
|
-
f"[{
|
|
121
|
+
f"[{wind_color}]{wind_category}[/{wind_color}]",
|
|
125
122
|
)
|
|
126
123
|
|
|
127
|
-
# Humidity with
|
|
128
|
-
humidity_indicator = "▓" * (weather.humidity // 20) + "░" * (
|
|
129
|
-
5 - weather.humidity // 20
|
|
130
|
-
)
|
|
124
|
+
# Humidity with category
|
|
131
125
|
table.add_row(
|
|
132
126
|
"💧",
|
|
133
|
-
"
|
|
127
|
+
"HUMIDITY",
|
|
134
128
|
f"{weather.humidity}%",
|
|
135
|
-
f"[{
|
|
129
|
+
f"[{humidity_color}]{humidity_category}[/{humidity_color}]",
|
|
136
130
|
)
|
|
137
131
|
|
|
138
|
-
# Air Quality
|
|
132
|
+
# Air Quality
|
|
139
133
|
if weather.aqi is not None:
|
|
140
134
|
label, color = get_aqi_label(weather.aqi)
|
|
141
|
-
aqi_severity = (
|
|
142
|
-
"high" if weather.aqi > 100 else "medium" if weather.aqi > 50 else "low"
|
|
143
|
-
)
|
|
144
135
|
table.add_row(
|
|
145
136
|
"😷",
|
|
146
|
-
"
|
|
137
|
+
"AIR QUALITY",
|
|
147
138
|
f"[{color}]{weather.aqi}[/{color}]",
|
|
148
|
-
f"[{color}]{label.upper()}
|
|
139
|
+
f"[{color}]{label.upper()}[/{color}]",
|
|
149
140
|
)
|
|
150
141
|
|
|
151
|
-
# Precipitation
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
weather.precip_prob
|
|
142
|
+
# Precipitation
|
|
143
|
+
precip_category = (
|
|
144
|
+
"DRY"
|
|
145
|
+
if weather.precip_prob < 20
|
|
146
|
+
else "LIGHT"
|
|
147
|
+
if weather.precip_prob < 50
|
|
148
|
+
else "MODERATE"
|
|
149
|
+
if weather.precip_prob < 80
|
|
150
|
+
else "HEAVY"
|
|
151
|
+
)
|
|
152
|
+
precip_color = (
|
|
153
|
+
COLORS["success"]
|
|
154
|
+
if weather.precip_prob < 20
|
|
155
|
+
else COLORS["warning"]
|
|
156
|
+
if weather.precip_prob < 50
|
|
157
|
+
else COLORS["danger"]
|
|
155
158
|
)
|
|
156
159
|
table.add_row(
|
|
157
160
|
"☔",
|
|
158
|
-
"
|
|
161
|
+
"PRECIPITATION",
|
|
159
162
|
f"{weather.precip_prob}% (6h)",
|
|
160
|
-
f"[{
|
|
163
|
+
f"[{precip_color}]{precip_category}[/{precip_color}]",
|
|
161
164
|
)
|
|
162
165
|
|
|
163
|
-
|
|
164
|
-
table.add_row(
|
|
165
|
-
"",
|
|
166
|
-
"[dim]FORECAST[/dim]",
|
|
167
|
-
f"[{COLORS['warning']} italic]{headline.upper()}[/{COLORS['warning']} italic]",
|
|
168
|
-
"",
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
# Sunrise/Sunset with day progress
|
|
166
|
+
# Sunrise/Sunset
|
|
172
167
|
table.add_row(
|
|
173
168
|
"🌅",
|
|
174
|
-
"
|
|
169
|
+
"SUNRISE",
|
|
175
170
|
f"[{COLORS['warning']}]{weather.sunrise}[/{COLORS['warning']}]",
|
|
176
|
-
"
|
|
171
|
+
"",
|
|
177
172
|
)
|
|
178
173
|
|
|
179
174
|
table.add_row(
|
|
180
175
|
"🌇",
|
|
181
|
-
"
|
|
176
|
+
"SUNSET",
|
|
182
177
|
f"[{COLORS['danger']}]{weather.sunset}[/{COLORS['danger']}]",
|
|
183
|
-
"
|
|
178
|
+
"",
|
|
184
179
|
)
|
|
185
180
|
|
|
186
|
-
# UV Index
|
|
181
|
+
# UV Index
|
|
187
182
|
if weather.uv_index is not None:
|
|
188
|
-
|
|
189
|
-
uv_level = min(int(weather.uv_index / 2), 5)
|
|
190
|
-
uv_indicator = "☀️" * uv_level
|
|
183
|
+
uv_category, uv_color = get_uv_category(weather.uv_index)
|
|
191
184
|
table.add_row(
|
|
192
185
|
"☀️",
|
|
193
|
-
"
|
|
186
|
+
"UV INDEX",
|
|
194
187
|
f"[{uv_color}]{weather.uv_index:.1f}[/{uv_color}]",
|
|
195
|
-
f"[{uv_color}]{
|
|
188
|
+
f"[{uv_color}]{uv_category}[/{uv_color}]",
|
|
196
189
|
)
|
|
197
190
|
|
|
198
|
-
# Wind Gust
|
|
191
|
+
# Wind Gust
|
|
199
192
|
if weather.wind_gust is not None:
|
|
200
193
|
gust_alert = get_wind_gust_alert(weather.wind_speed, weather.wind_gust)
|
|
194
|
+
gust_category, gust_color = get_wind_category_label(
|
|
195
|
+
weather.wind_gust, weather.units.wind
|
|
196
|
+
)
|
|
201
197
|
if gust_alert:
|
|
202
198
|
table.add_row(
|
|
203
199
|
"⚡",
|
|
204
200
|
"[bold yellow]GUST ALERT[/bold yellow]",
|
|
205
201
|
f"[bold yellow]{weather.wind_gust} {weather.units.wind}[/bold yellow]",
|
|
206
|
-
"[bold yellow]⚠️
|
|
202
|
+
f"[bold yellow]{gust_category} ⚠️[/bold yellow]",
|
|
207
203
|
)
|
|
208
204
|
else:
|
|
209
|
-
gust_ratio = (
|
|
210
|
-
weather.wind_gust / weather.wind_speed if weather.wind_speed > 0 else 1
|
|
211
|
-
)
|
|
212
|
-
gust_indicator = "!" * min(int(gust_ratio * 2), 3)
|
|
213
205
|
table.add_row(
|
|
214
206
|
"💨",
|
|
215
|
-
"
|
|
207
|
+
"WIND GUST",
|
|
216
208
|
f"{weather.wind_gust} {weather.units.wind}",
|
|
217
|
-
f"[{
|
|
209
|
+
f"[{gust_color}]{gust_category}[/{gust_color}]",
|
|
218
210
|
)
|
|
219
211
|
|
|
220
212
|
return table
|
|
221
213
|
|
|
222
214
|
|
|
223
|
-
def create_gauge(value: float, unit: str, max_value: float) -> str:
|
|
224
|
-
"""Create a text-based gauge visualization."""
|
|
225
|
-
if unit == "°F":
|
|
226
|
-
ranges = [
|
|
227
|
-
(32, "bold cyan"),
|
|
228
|
-
(60, "bold blue"),
|
|
229
|
-
(80, "bold green"),
|
|
230
|
-
(95, "bold yellow"),
|
|
231
|
-
(max_value, "bold red"),
|
|
232
|
-
]
|
|
233
|
-
else:
|
|
234
|
-
ranges = [
|
|
235
|
-
(0, "bold cyan"),
|
|
236
|
-
(15, "bold blue"),
|
|
237
|
-
(26, "bold green"),
|
|
238
|
-
(35, "bold yellow"),
|
|
239
|
-
(max_value, "bold red"),
|
|
240
|
-
]
|
|
241
|
-
|
|
242
|
-
# Find appropriate color
|
|
243
|
-
gauge_color = "bold white"
|
|
244
|
-
for threshold, color in ranges:
|
|
245
|
-
if value <= threshold:
|
|
246
|
-
gauge_color = color
|
|
247
|
-
break
|
|
248
|
-
|
|
249
|
-
# Create gauge visualization
|
|
250
|
-
gauge_width = 10
|
|
251
|
-
filled = min(int((value / max_value) * gauge_width), gauge_width)
|
|
252
|
-
gauge = "[" + gauge_color + "]" + "█" * filled + "[/" + gauge_color + "]"
|
|
253
|
-
gauge += "[dim]" + "░" * (gauge_width - filled) + "[/dim]"
|
|
254
|
-
|
|
255
|
-
return gauge
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def get_wind_category(speed: float, unit: str) -> str:
|
|
259
|
-
"""Categorize wind speed."""
|
|
260
|
-
if unit == "mph":
|
|
261
|
-
if speed < 1:
|
|
262
|
-
return "CALM"
|
|
263
|
-
elif speed < 7:
|
|
264
|
-
return "LIGHT"
|
|
265
|
-
elif speed < 12:
|
|
266
|
-
return "GENTLE"
|
|
267
|
-
elif speed < 18:
|
|
268
|
-
return "MODERATE"
|
|
269
|
-
elif speed < 24:
|
|
270
|
-
return "FRESH"
|
|
271
|
-
elif speed < 31:
|
|
272
|
-
return "STRONG"
|
|
273
|
-
elif speed < 38:
|
|
274
|
-
return "GALE"
|
|
275
|
-
elif speed < 46:
|
|
276
|
-
return "SEVERE"
|
|
277
|
-
else:
|
|
278
|
-
return "STORM"
|
|
279
|
-
else: # km/h
|
|
280
|
-
if speed < 2:
|
|
281
|
-
return "CALM"
|
|
282
|
-
elif speed < 12:
|
|
283
|
-
return "LIGHT"
|
|
284
|
-
elif speed < 20:
|
|
285
|
-
return "GENTLE"
|
|
286
|
-
elif speed < 29:
|
|
287
|
-
return "MODERATE"
|
|
288
|
-
elif speed < 39:
|
|
289
|
-
return "FRESH"
|
|
290
|
-
elif speed < 50:
|
|
291
|
-
return "STRONG"
|
|
292
|
-
elif speed < 62:
|
|
293
|
-
return "GALE"
|
|
294
|
-
elif speed < 75:
|
|
295
|
-
return "SEVERE"
|
|
296
|
-
else:
|
|
297
|
-
return "STORM"
|
|
298
|
-
|
|
299
|
-
|
|
300
215
|
def display_weather(weather: WeatherData):
|
|
301
216
|
"""Renders the current conditions weather card for a single city."""
|
|
302
217
|
if not weather:
|
|
@@ -339,19 +254,28 @@ def display_preparation_guidance(weather: WeatherData):
|
|
|
339
254
|
|
|
340
255
|
if temporal_context:
|
|
341
256
|
console.print(
|
|
342
|
-
f"[{COLORS['
|
|
257
|
+
f"[{COLORS['muted']}]────────────────────────────────────────[/{COLORS['muted']}]"
|
|
258
|
+
)
|
|
259
|
+
console.print(
|
|
260
|
+
f"[{COLORS['primary']} bold]TREND ANALYSIS[/{COLORS['primary']} bold]"
|
|
343
261
|
)
|
|
344
262
|
console.print(
|
|
345
263
|
f"[{COLORS['muted']}]{temporal_context.upper()}[/{COLORS['muted']}]"
|
|
346
264
|
)
|
|
347
265
|
console.print(
|
|
348
|
-
f"[{COLORS['
|
|
266
|
+
f"[{COLORS['muted']}]────────────────────────────────────────[/{COLORS['muted']}]"
|
|
349
267
|
)
|
|
350
268
|
print()
|
|
351
269
|
|
|
352
|
-
# Create
|
|
270
|
+
# Create borderless guidance panel
|
|
353
271
|
console.print(
|
|
354
|
-
f"[{COLORS['
|
|
272
|
+
f"[{COLORS['muted']}]────────────────────────────────────────[/{COLORS['muted']}]"
|
|
273
|
+
)
|
|
274
|
+
console.print(
|
|
275
|
+
f"[{COLORS['primary']} bold]PREPARATION PROTOCOL[/{COLORS['primary']} bold]"
|
|
276
|
+
)
|
|
277
|
+
console.print(
|
|
278
|
+
f"[{COLORS['muted']}]────────────────────────────────────────[/{COLORS['muted']}]"
|
|
355
279
|
)
|
|
356
280
|
|
|
357
281
|
# Categorize suggestions by priority
|
|
@@ -376,33 +300,31 @@ def display_preparation_guidance(weather: WeatherData):
|
|
|
376
300
|
# Display high priority items with danger styling
|
|
377
301
|
if high_priority:
|
|
378
302
|
console.print(
|
|
379
|
-
f"[{COLORS['danger']} bold]
|
|
303
|
+
f"[{COLORS['danger']} bold]⚠️ HIGH PRIORITY[/{COLORS['danger']} bold]"
|
|
380
304
|
)
|
|
381
305
|
for suggestion in high_priority:
|
|
382
|
-
console.print(
|
|
383
|
-
f" [{COLORS['danger']}]▶[/{COLORS['danger']}] {suggestion}"
|
|
384
|
-
)
|
|
306
|
+
console.print(f" [{COLORS['danger']}]▶[/{COLORS['danger']}] {suggestion}")
|
|
385
307
|
print()
|
|
386
308
|
|
|
387
309
|
# Display medium priority items with warning styling
|
|
388
310
|
if medium_priority:
|
|
389
311
|
console.print(
|
|
390
|
-
f"[{COLORS['warning']} bold]
|
|
312
|
+
f"[{COLORS['warning']} bold]▲ RECOMMENDED[/{COLORS['warning']} bold]"
|
|
391
313
|
)
|
|
392
314
|
for suggestion in medium_priority:
|
|
393
315
|
console.print(
|
|
394
|
-
f"
|
|
316
|
+
f" [{COLORS['warning']}]▶[/{COLORS['warning']}] {suggestion}"
|
|
395
317
|
)
|
|
396
318
|
print()
|
|
397
319
|
|
|
398
320
|
# Display low priority items with muted styling
|
|
399
321
|
if low_priority:
|
|
400
|
-
console.print(f"[{COLORS['muted']} bold]
|
|
322
|
+
console.print(f"[{COLORS['muted']} bold]○ ADVISORY[/{COLORS['muted']} bold]")
|
|
401
323
|
for suggestion in low_priority:
|
|
402
|
-
console.print(f"
|
|
324
|
+
console.print(f" [{COLORS['muted']}]▶[/{COLORS['muted']}] {suggestion}")
|
|
403
325
|
|
|
404
326
|
console.print(
|
|
405
|
-
f"[{COLORS['
|
|
327
|
+
f"[{COLORS['muted']}]────────────────────────────────────────[/{COLORS['muted']}]"
|
|
406
328
|
)
|
|
407
329
|
|
|
408
330
|
|
|
@@ -425,39 +347,34 @@ def display_multi_weather(weather_list: list[WeatherData]):
|
|
|
425
347
|
|
|
426
348
|
|
|
427
349
|
def display_forecast(weather: WeatherData):
|
|
428
|
-
"""Renders a 7-day forecast table with industrial aesthetic."""
|
|
350
|
+
"""Renders a 7-day forecast table with borderless industrial aesthetic."""
|
|
429
351
|
if not weather.forecast:
|
|
430
352
|
console.print(
|
|
431
353
|
f"[{COLORS['warning']}]No forecast data available.[/{COLORS['warning']}]"
|
|
432
354
|
)
|
|
433
355
|
return
|
|
434
356
|
|
|
435
|
-
# Create
|
|
357
|
+
# Create borderless forecast table
|
|
436
358
|
table = Table(
|
|
437
|
-
title=f"[{COLORS['primary']} bold]
|
|
438
|
-
f"[{COLORS['steel']}]{weather.city.upper()}[/{COLORS['steel']}]",
|
|
359
|
+
title=f"[{COLORS['primary']} bold]FORECAST PANEL · {weather.city.upper()}[/{COLORS['primary']} bold]",
|
|
439
360
|
show_header=True,
|
|
361
|
+
box=None, # No borders
|
|
440
362
|
header_style=f"{COLORS['muted']} bold",
|
|
441
|
-
box=box.HEAVY,
|
|
442
|
-
border_style=COLORS["secondary"],
|
|
443
363
|
padding=(0, 1),
|
|
444
364
|
width=70,
|
|
445
365
|
)
|
|
446
366
|
|
|
447
|
-
#
|
|
448
|
-
table.add_column("DAY", justify="left", width=
|
|
367
|
+
# Borderless column headers
|
|
368
|
+
table.add_column("DAY", justify="left", width=10, style=f"{COLORS['accent']} bold")
|
|
449
369
|
table.add_column("", justify="center", width=2, style=COLORS["accent"])
|
|
450
|
-
table.add_column("CONDITIONS", justify="left", width=
|
|
370
|
+
table.add_column("CONDITIONS", justify="left", width=18, style=COLORS["muted"])
|
|
451
371
|
table.add_column(
|
|
452
|
-
"HIGH", justify="right", width=
|
|
453
|
-
)
|
|
454
|
-
table.add_column(
|
|
455
|
-
"LOW", justify="right", width=10, style=f"{COLORS['primary']} bold"
|
|
372
|
+
"HIGH", justify="right", width=8, style=f"{COLORS['primary']} bold"
|
|
456
373
|
)
|
|
374
|
+
table.add_column("LOW", justify="right", width=8, style=f"{COLORS['primary']} bold")
|
|
457
375
|
table.add_column(
|
|
458
376
|
"RAIN%", justify="right", width=8, style=f"{COLORS['accent']} bold"
|
|
459
377
|
)
|
|
460
|
-
table.add_column("STATUS", justify="center", width=8, style=COLORS["secondary"])
|
|
461
378
|
|
|
462
379
|
for i, day in enumerate(weather.forecast):
|
|
463
380
|
day_label = "TODAY" if i == 0 else day.dt.strftime("%a").upper()
|
|
@@ -467,7 +384,7 @@ def display_forecast(weather: WeatherData):
|
|
|
467
384
|
high_color = get_temp_color(day.high, weather.units.temp)
|
|
468
385
|
low_color = get_temp_color(day.low, weather.units.temp)
|
|
469
386
|
|
|
470
|
-
# Precipitation
|
|
387
|
+
# Precipitation color based on probability
|
|
471
388
|
if day.precip_prob >= 70:
|
|
472
389
|
rain_color = COLORS["danger"]
|
|
473
390
|
elif day.precip_prob >= 40:
|
|
@@ -477,17 +394,6 @@ def display_forecast(weather: WeatherData):
|
|
|
477
394
|
else:
|
|
478
395
|
rain_color = COLORS["muted"]
|
|
479
396
|
|
|
480
|
-
# Day severity based on weather conditions
|
|
481
|
-
if day.weather_code in [95, 96, 99, 65, 75, 86]:
|
|
482
|
-
day_severity = "▲"
|
|
483
|
-
severity_color = COLORS["warning"]
|
|
484
|
-
elif day.precip_prob > 60:
|
|
485
|
-
day_severity = "●"
|
|
486
|
-
severity_color = COLORS["accent"]
|
|
487
|
-
else:
|
|
488
|
-
day_severity = "○"
|
|
489
|
-
severity_color = COLORS["success"]
|
|
490
|
-
|
|
491
397
|
table.add_row(
|
|
492
398
|
f"[{COLORS['primary']} bold]{day_label}[/{COLORS['primary']} bold]"
|
|
493
399
|
if i == 0
|
|
@@ -497,7 +403,6 @@ def display_forecast(weather: WeatherData):
|
|
|
497
403
|
f"[{high_color}]{day.high}{weather.units.temp}[/{high_color}]",
|
|
498
404
|
f"[{low_color}]{day.low}{weather.units.temp}[/{low_color}]",
|
|
499
405
|
f"[{rain_color}]{day.precip_prob}%[/{rain_color}]",
|
|
500
|
-
f"[{severity_color}]{day_severity}[/{severity_color}]",
|
|
501
406
|
)
|
|
502
407
|
|
|
503
408
|
console.print(table)
|
|
@@ -505,49 +410,40 @@ def display_forecast(weather: WeatherData):
|
|
|
505
410
|
|
|
506
411
|
|
|
507
412
|
def display_hourly(weather: WeatherData):
|
|
508
|
-
"""Renders an hourly forecast table with industrial aesthetic."""
|
|
413
|
+
"""Renders an hourly forecast table with borderless industrial aesthetic."""
|
|
509
414
|
if not weather.hourly:
|
|
510
415
|
console.print(
|
|
511
416
|
f"[{COLORS['warning']}]No hourly data available.[/{COLORS['warning']}]"
|
|
512
417
|
)
|
|
513
418
|
return
|
|
514
419
|
|
|
515
|
-
# Create
|
|
420
|
+
# Create borderless hourly table
|
|
516
421
|
table = Table(
|
|
517
|
-
title=f"[{COLORS['primary']} bold]
|
|
518
|
-
f"[{COLORS['steel']}]{weather.city.upper()}[/{COLORS['steel']}]",
|
|
422
|
+
title=f"[{COLORS['primary']} bold]HOURLY FORECAST · {weather.city.upper()}[/{COLORS['primary']} bold]",
|
|
519
423
|
show_header=True,
|
|
424
|
+
box=None, # No borders
|
|
520
425
|
header_style=f"{COLORS['muted']} bold",
|
|
521
|
-
box=box.HEAVY,
|
|
522
|
-
border_style=COLORS["secondary"],
|
|
523
426
|
padding=(0, 1),
|
|
524
|
-
width=
|
|
427
|
+
width=60,
|
|
525
428
|
)
|
|
526
429
|
|
|
527
|
-
#
|
|
528
|
-
table.add_column("TIME", justify="left", width=
|
|
430
|
+
# Simplified column headers
|
|
431
|
+
table.add_column("TIME", justify="left", width=6, style=f"{COLORS['accent']} bold")
|
|
529
432
|
table.add_column("", justify="center", width=2, style=COLORS["accent"])
|
|
530
433
|
table.add_column(
|
|
531
|
-
"TEMP", justify="right", width=
|
|
434
|
+
"TEMP", justify="right", width=8, style=f"{COLORS['primary']} bold"
|
|
532
435
|
)
|
|
533
|
-
table.add_column("GAUGE", justify="left", width=12, style=COLORS["muted"])
|
|
534
436
|
table.add_column(
|
|
535
|
-
"RAIN%", justify="right", width=
|
|
437
|
+
"RAIN%", justify="right", width=6, style=f"{COLORS['accent']} bold"
|
|
536
438
|
)
|
|
537
|
-
table.add_column("CONDITIONS", justify="left", width=
|
|
538
|
-
table.add_column("", justify="center", width=2, style=COLORS["secondary"])
|
|
439
|
+
table.add_column("CONDITIONS", justify="left", width=18, style=COLORS["concrete"])
|
|
539
440
|
|
|
540
441
|
for hour in weather.hourly[:12]:
|
|
541
442
|
time_label = hour.dt.strftime("%-I%p").upper()
|
|
542
443
|
icon, desc = get_weather_icon(hour.weather_code)
|
|
543
444
|
temp_color = get_temp_color(hour.temp, weather.units.temp)
|
|
544
445
|
|
|
545
|
-
#
|
|
546
|
-
temp_gauge = create_gauge(
|
|
547
|
-
hour.temp, weather.units.temp, 100 if weather.units.temp == "°F" else 40
|
|
548
|
-
)
|
|
549
|
-
|
|
550
|
-
# Precipitation indicator
|
|
446
|
+
# Precipitation color
|
|
551
447
|
if hour.precip_prob >= 70:
|
|
552
448
|
rain_color = COLORS["danger"]
|
|
553
449
|
elif hour.precip_prob >= 40:
|
|
@@ -557,25 +453,12 @@ def display_hourly(weather: WeatherData):
|
|
|
557
453
|
else:
|
|
558
454
|
rain_color = COLORS["muted"]
|
|
559
455
|
|
|
560
|
-
# Hour severity indicator
|
|
561
|
-
if hour.weather_code in [95, 96, 99]:
|
|
562
|
-
severity_icon = "⚡"
|
|
563
|
-
severity_color = COLORS["warning"]
|
|
564
|
-
elif hour.precip_prob > 60:
|
|
565
|
-
severity_icon = "☔"
|
|
566
|
-
severity_color = COLORS["accent"]
|
|
567
|
-
else:
|
|
568
|
-
severity_icon = "○"
|
|
569
|
-
severity_color = COLORS["success"]
|
|
570
|
-
|
|
571
456
|
table.add_row(
|
|
572
457
|
f"[{COLORS['muted']}]{time_label}[/{COLORS['muted']}]",
|
|
573
458
|
f"[{COLORS['accent']}]{icon}[/{COLORS['accent']}]",
|
|
574
459
|
f"[{temp_color}]{hour.temp}{weather.units.temp}[/{temp_color}]",
|
|
575
|
-
temp_gauge,
|
|
576
460
|
f"[{rain_color}]{hour.precip_prob}%[/{rain_color}]",
|
|
577
461
|
f"[{COLORS['concrete']}]{desc.upper()}[/{COLORS['concrete']}]",
|
|
578
|
-
f"[{severity_color}]{severity_icon}[/{severity_color}]",
|
|
579
462
|
)
|
|
580
463
|
|
|
581
464
|
console.print(table)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: isobar-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.4
|
|
4
4
|
Summary: A terminal weather tool with industrial aesthetic, focusing on Real Feel and Windchill.
|
|
5
5
|
Author: Beau Bremer / KnowOneActual
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,13 +44,15 @@ Dynamic: license-file
|
|
|
44
44
|

|
|
45
45
|

|
|
46
46
|
|
|
47
|
+
**I'm currently working on improving the layout. Thanks so much for your patience with any little hiccups that might come up along the way!**
|
|
48
|
+
|
|
47
49
|
A terminal weather tool designed to provide a fast, clean sense of what the weather **feels like** outside; right now and for the week ahead. Built with Python and Rich.
|
|
48
50
|
|
|
49
51
|
## Philosophy
|
|
50
52
|
|
|
51
53
|
Isobar CLI answers a simple question: **"What does it feel like outside right now, and do I need a jacket?"**
|
|
52
54
|
|
|
53
|
-
Most weather apps
|
|
55
|
+
Most weather apps are overwhelmed with data. Isobar strips away everything except what matters when deciding how to prepare for the day.
|
|
54
56
|
|
|
55
57
|
### Design Principles
|
|
56
58
|
- **Essential over comprehensive** — Show Real Feel, not 47 data points.
|
|
@@ -214,52 +216,8 @@ isobar --install-completion bash
|
|
|
214
216
|
### Industrial Aesthetic (v1.3.0+)
|
|
215
217
|
|
|
216
218
|
```
|
|
217
|
-
|
|
218
|
-
CHICAGO
|
|
219
|
-
┌─────┬──────────────────────┬────────────────┬────────────────┐
|
|
220
|
-
│ │ METRIC │ READING │ STATUS │
|
|
221
|
-
├─────┼──────────────────────┼────────────────┼────────────────┤
|
|
222
|
-
│ ☀️ │ CONDITIONS │ MAINLY CLEAR │ ◇ │
|
|
223
|
-
│ 🌡️ │ TEMPERATURE │ 75.2°F │ [███░░░░░░░] │
|
|
224
|
-
│ 🤔 │ REAL FEEL │ 78.5°F │ ▲ 3.3°F │
|
|
225
|
-
│ 💨 │ WIND SPEED │ 12.4 mph │ GENTLE │
|
|
226
|
-
│ 💧 │ HUMIDITY │ 65% │ [▓▓▓░░] │
|
|
227
|
-
│ 😷 │ AIR QUALITY │ 45 │ GOOD ◇ │
|
|
228
|
-
│ ☔ │ PRECIPITATION │ 30% (6h) │ [▓░░] │
|
|
229
|
-
│ │ FORECAST │ LIGHT RAIN LIKELY │ │
|
|
230
|
-
│ 🌅 │ SUNRISE │ 06:29 │ DAWN │
|
|
231
|
-
│ 🌇 │ SUNSET │ 17:37 │ DUSK │
|
|
232
|
-
│ ☀️ │ UV INDEX │ 6.5 │ HIGH ☀️☀️☀️ │
|
|
233
|
-
│ ⚡ │ GUST ALERT │ 25 mph │ ⚠️ SEVERE │
|
|
234
|
-
└─────┴──────────────────────┴────────────────┴────────────────┘
|
|
235
|
-
|
|
236
|
-
┌─ TREND ANALYSIS ─┐
|
|
237
|
-
↑ 5.2°F WARMER THAN YESTERDAY
|
|
238
|
-
└──────────────────┘
|
|
239
|
-
|
|
240
|
-
┌─ PREPARATION PROTOCOL ─┐
|
|
241
|
-
⚠️ HIGH PRIORITY
|
|
242
|
-
▶ Wind gusts up to 25 mph - secure loose items
|
|
243
|
-
▲ RECOMMENDED
|
|
244
|
-
▶ Light jacket recommended
|
|
245
|
-
▶ Sunscreen recommended (UV: High)
|
|
246
|
-
○ ADVISORY
|
|
247
|
-
▶ Sunglasses recommended for glare
|
|
248
|
-
└─────────────────────────┘
|
|
249
|
-
|
|
250
|
-
┌─ FORECAST PANEL ─┐
|
|
251
|
-
CHICAGO
|
|
252
|
-
┌──────┬──┬────────────────────┬───────┬───────┬───────┬────────┐
|
|
253
|
-
│ DAY │ │ CONDITIONS │ HIGH │ LOW │ RAIN% │ STATUS │
|
|
254
|
-
├──────┼──┼────────────────────┼───────┼───────┼───────┼────────┤
|
|
255
|
-
│ TODAY│☀️│ MAINLY CLEAR │ 78.7°F│ 63.9°F│ 30% │ ○ │
|
|
256
|
-
│ TUE │⛅│ PARTLY CLOUDY │ 82.4°F│ 65.4°F│ 20% │ ○ │
|
|
257
|
-
│ WED │🌦️│ LIGHT DRIZZLE │ 76.8°F│ 60.9°F│ 45% │ ● │
|
|
258
|
-
│ THU │☀️│ CLEAR SKY │ 80.3°F│ 63.5°F│ 10% │ ○ │
|
|
259
|
-
└──────┴──┴────────────────────┴───────┴───────┴───────┴────────┘
|
|
260
|
-
```
|
|
219
|
+
Coming soon..
|
|
261
220
|
```
|
|
262
|
-
|
|
263
221
|
## 🛠 Tech Stack
|
|
264
222
|
|
|
265
223
|
| Tool | Purpose |
|
|
@@ -273,7 +231,7 @@ CHICAGO
|
|
|
273
231
|
| [requests-mock](https://requests-mock.readthedocs.io/) | API testing |
|
|
274
232
|
| [Ruff](https://docs.astral.sh/ruff/) | Linting and formatting |
|
|
275
233
|
| [pip-audit](https://github.com/pypa/pip-audit) | Dependency security scanning |
|
|
276
|
-
|
|
|
234
|
+
| New improvemets | **Intuition & Analysis** |
|
|
277
235
|
| `config.py` | Persistent home city configuration |
|
|
278
236
|
| Enhanced `logic.py` | Preparation guidance, UV monitoring, gust alerts |
|
|
279
237
|
| Updated `ui.py` | Contextual display of insights |
|
|
@@ -281,7 +239,6 @@ CHICAGO
|
|
|
281
239
|
| Configurable API Endpoints | Environment variable support for custom APIs |
|
|
282
240
|
| Enhanced Error Handling | Specific exception catching with timeouts |
|
|
283
241
|
| Timezone Support | Optional `pytz` dependency for local time display |
|
|
284
|
-
| **v1.3.0 Features** | **Industrial Aesthetic** |
|
|
285
242
|
| Industrial UI Design | Retro-futuristic weather observatory dashboard |
|
|
286
243
|
| Visual Gauges | Temperature and humidity gauge visualizations |
|
|
287
244
|
| Severity Indicators | Weather condition severity classification |
|
|
@@ -199,13 +199,43 @@ def test_get_weather_data_hourly_index_error(requests_mock):
|
|
|
199
199
|
|
|
200
200
|
def test_get_weather_data_request_exception(requests_mock):
|
|
201
201
|
import requests
|
|
202
|
+
from isobar_cli.api import WeatherAPIError
|
|
203
|
+
import pytest
|
|
202
204
|
|
|
203
205
|
requests_mock.get(
|
|
204
206
|
"https://geocoding-api.open-meteo.com/v1/search",
|
|
205
207
|
exc=requests.exceptions.RequestException("Connection error"),
|
|
206
208
|
)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
with pytest.raises(WeatherAPIError):
|
|
210
|
+
get_weather_data("FailCity")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_get_weather_data_forecast_request_exception(requests_mock):
|
|
214
|
+
import requests
|
|
215
|
+
from isobar_cli.api import WeatherAPIError
|
|
216
|
+
import pytest
|
|
217
|
+
|
|
218
|
+
geo_data = {
|
|
219
|
+
"results": [
|
|
220
|
+
{
|
|
221
|
+
"name": "Chicago",
|
|
222
|
+
"latitude": 41.85,
|
|
223
|
+
"longitude": -87.65,
|
|
224
|
+
"admin1": "Illinois",
|
|
225
|
+
"country": "United States",
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
requests_mock.get(
|
|
230
|
+
"https://geocoding-api.open-meteo.com/v1/search",
|
|
231
|
+
json=geo_data,
|
|
232
|
+
)
|
|
233
|
+
requests_mock.get(
|
|
234
|
+
"https://api.open-meteo.com/v1/forecast",
|
|
235
|
+
exc=requests.exceptions.RequestException("Weather connection error"),
|
|
236
|
+
)
|
|
237
|
+
with pytest.raises(WeatherAPIError):
|
|
238
|
+
get_weather_data("Chicago")
|
|
209
239
|
|
|
210
240
|
|
|
211
241
|
# --- Main Tests ---
|
|
@@ -380,8 +410,8 @@ def test_main_with_flags(monkeypatch):
|
|
|
380
410
|
env={"TERM": "dumb", "NO_COLOR": "1"},
|
|
381
411
|
)
|
|
382
412
|
assert result.exit_code == 0
|
|
383
|
-
# In industrial aesthetic, hourly display has "HOURLY
|
|
384
|
-
assert "HOURLY
|
|
413
|
+
# In borderless industrial aesthetic, hourly display has "HOURLY FORECAST" header
|
|
414
|
+
assert "HOURLY FORECAST" in result.output.upper()
|
|
385
415
|
assert "CITYH" in result.output.upper()
|
|
386
416
|
|
|
387
417
|
# Forecast
|
|
@@ -572,8 +602,8 @@ def test_display_multi_weather(mock_cache_dir):
|
|
|
572
602
|
|
|
573
603
|
|
|
574
604
|
def test_get_precip_headline_extra():
|
|
575
|
-
assert
|
|
576
|
-
assert
|
|
605
|
+
assert logic.get_precip_headline(80, 0.5, 0, "in") == "Moderate rain likely"
|
|
606
|
+
assert logic.get_precip_headline(80, 0.1, 0, "in") == "Light rain likely"
|
|
577
607
|
|
|
578
608
|
|
|
579
609
|
def test_build_weather_table_extra():
|
|
@@ -109,3 +109,19 @@ def test_main_not_found_with_suggestions(mock_api):
|
|
|
109
109
|
assert result.exit_code == 1
|
|
110
110
|
assert "'Unknown' not found" in result.output
|
|
111
111
|
assert "Did you mean: Suggested City?" in result.output
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_main_weather_api_error(monkeypatch):
|
|
115
|
+
from isobar_cli.api import WeatherAPIError
|
|
116
|
+
|
|
117
|
+
def mock_get_weather_data(city, metric=False):
|
|
118
|
+
raise WeatherAPIError("Mocked API connection timeout")
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr("isobar_cli.main.get_weather_data", mock_get_weather_data)
|
|
121
|
+
|
|
122
|
+
result = runner.invoke(
|
|
123
|
+
app, ["Chicago"], color=False, env={"TERM": "dumb", "NO_COLOR": "1"}
|
|
124
|
+
)
|
|
125
|
+
assert result.exit_code == 1
|
|
126
|
+
assert "Mocked API connection timeout" in result.output
|
|
127
|
+
assert "not found" not in result.output
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.3.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|