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.
Files changed (25) hide show
  1. {isobar_cli-1.3.2/src/isobar_cli.egg-info → isobar_cli-1.3.4}/PKG-INFO +6 -49
  2. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/README.md +5 -48
  3. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/pyproject.toml +1 -1
  4. isobar_cli-1.3.4/src/isobar_cli/__init__.py +1 -0
  5. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/api.py +13 -13
  6. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/logic.py +124 -0
  7. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/main.py +18 -10
  8. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/ui.py +123 -240
  9. {isobar_cli-1.3.2 → isobar_cli-1.3.4/src/isobar_cli.egg-info}/PKG-INFO +6 -49
  10. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_isobar_extra.py +36 -6
  11. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_main.py +16 -0
  12. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_ui.py +1 -1
  13. isobar_cli-1.3.2/src/isobar_cli/__init__.py +0 -1
  14. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/LICENSE +0 -0
  15. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/setup.cfg +0 -0
  16. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/config.py +0 -0
  17. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/location.py +0 -0
  18. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli/models.py +0 -0
  19. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/SOURCES.txt +0 -0
  20. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/dependency_links.txt +0 -0
  21. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/entry_points.txt +0 -0
  22. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/requires.txt +0 -0
  23. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/src/isobar_cli.egg-info/top_level.txt +0 -0
  24. {isobar_cli-1.3.2 → isobar_cli-1.3.4}/tests/test_api.py +0 -0
  25. {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.2
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
  ![License](https://img.shields.io/badge/license-MIT-green)
45
45
  ![Security Scan](https://github.com/KnowOneActual/isobar-cli/actions/workflows/security.yml/badge.svg)
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 overwhelm with data. Isobar strips away everything except what matters when deciding how to prepare for the day.
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
- ┌─ WEATHER OBSERVATORY ─┐
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
- | **Phase 7 Features** | **Intuition & Analysis** |
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
  ![License](https://img.shields.io/badge/license-MIT-green)
10
10
  ![Security Scan](https://github.com/KnowOneActual/isobar-cli/actions/workflows/security.yml/badge.svg)
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 overwhelm with data. Isobar strips away everything except what matters when deciding how to prepare for the day.
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
- ┌─ WEATHER OBSERVATORY ─┐
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
- | **Phase 7 Features** | **Intuition & Analysis** |
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.2"
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
- # Log error for debugging but don't crash
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
- # Catch-all for unexpected errors
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
- results = GeocodingClient.search(city, count=5)
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
- print(f"Error fetching weather: {e}")
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 get_cached_cities, get_city_suggestions, get_weather_data
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
- weather = get_weather_data(full_city, metric=metric)
165
- if weather:
166
- results.append(weather)
167
- else:
168
- console.print(f"[bold red]❌ '{full_city}' not found.[/bold red]")
169
- suggestions = get_city_suggestions(full_city)
170
- if suggestions:
171
- suggest_str = ", ".join(suggestions[:3])
172
- console.print(f"[dim]Did you mean: {suggest_str}?[/dim]")
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
- get_precip_headline,
11
+ get_humidity_category,
13
12
  get_preparation_guidance,
14
13
  get_temp_color,
14
+ get_temperature_comfort,
15
15
  get_temporal_context,
16
- get_uv_guidance,
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
- # Create industrial-style table with heavy borders
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]┌─ WEATHER OBSERVATORY ─┐[/{COLORS['primary']} bold]\n"
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=box.HEAVY,
67
- border_style=COLORS["secondary"],
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 with industrial styling
74
- table.add_column("", justify="center", width=4, style=COLORS["accent"])
75
- table.add_column("METRIC", justify="left", width=20, style=COLORS["muted"])
76
- table.add_column("READING", justify="right", width=15, style=COLORS["primary"])
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
- "[bold]CONDITIONS[/bold]",
86
- f"[{COLORS['primary']} bold]{desc.upper()}[/{COLORS['primary']} bold]",
87
- f"[{COLORS['warning']}]{SEVERITY_ICONS[severity]}[/{COLORS['warning']}]",
83
+ "CONDITIONS",
84
+ f"[{COLORS['primary']}]{desc.upper()}[/{COLORS['primary']}]",
85
+ "",
88
86
  )
89
87
 
90
- # Temperature with industrial gauge visualization
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
- "[bold]TEMPERATURE[/bold]",
91
+ "TEMPERATURE",
97
92
  f"[{temp_color}]{weather.temp}{weather.units.temp}[/{temp_color}]",
98
- temp_gauge,
93
+ f"[{temp_comfort_color}]{temp_comfort}[/{temp_comfort_color}]",
99
94
  )
100
95
 
101
- # Feels like with difference indicator
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"[bold]{feels_label.upper()}[/bold]",
111
+ f"{feels_label.upper()}",
114
112
  f"[{feels_color}]{weather.feels_like}{weather.units.temp}[/{feels_color}]",
115
- f"[{diff_color}]{diff_symbol} {abs(temp_diff):.1f}{weather.units.temp}[/{diff_color}]",
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 speed category
119
- wind_category = get_wind_category(weather.wind_speed, weather.units.wind)
116
+ # Wind with category
120
117
  table.add_row(
121
118
  "💨",
122
- "[bold]WIND SPEED[/bold]",
119
+ "WIND SPEED",
123
120
  f"{weather.wind_speed} {weather.units.wind}",
124
- f"[{COLORS['steel']}]{wind_category}[/{COLORS['steel']}]",
121
+ f"[{wind_color}]{wind_category}[/{wind_color}]",
125
122
  )
126
123
 
127
- # Humidity with moisture indicator
128
- humidity_indicator = "▓" * (weather.humidity // 20) + "░" * (
129
- 5 - weather.humidity // 20
130
- )
124
+ # Humidity with category
131
125
  table.add_row(
132
126
  "💧",
133
- "[bold]HUMIDITY[/bold]",
127
+ "HUMIDITY",
134
128
  f"{weather.humidity}%",
135
- f"[{COLORS['accent']}]{humidity_indicator}[/{COLORS['accent']}]",
129
+ f"[{humidity_color}]{humidity_category}[/{humidity_color}]",
136
130
  )
137
131
 
138
- # Air Quality with health impact
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
- "[bold]AIR QUALITY[/bold]",
137
+ "AIR QUALITY",
147
138
  f"[{color}]{weather.aqi}[/{color}]",
148
- f"[{color}]{label.upper()} {SEVERITY_ICONS[aqi_severity]}[/{color}]",
139
+ f"[{color}]{label.upper()}[/{color}]",
149
140
  )
150
141
 
151
- # Precipitation with visual indicator
152
- precip_indicator = "●" * min(weather.precip_prob // 20, 5)
153
- headline = get_precip_headline(
154
- weather.precip_prob, weather.rainfall, weather.snowfall, weather.units.precip
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
- "[bold]PRECIPITATION[/bold]",
161
+ "PRECIPITATION",
159
162
  f"{weather.precip_prob}% (6h)",
160
- f"[{COLORS['accent']}]{precip_indicator}[/{COLORS['accent']}]",
163
+ f"[{precip_color}]{precip_category}[/{precip_color}]",
161
164
  )
162
165
 
163
- if headline:
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
- "[bold]SUNRISE[/bold]",
169
+ "SUNRISE",
175
170
  f"[{COLORS['warning']}]{weather.sunrise}[/{COLORS['warning']}]",
176
- "[dim]DAWN[/dim]",
171
+ "",
177
172
  )
178
173
 
179
174
  table.add_row(
180
175
  "🌇",
181
- "[bold]SUNSET[/bold]",
176
+ "SUNSET",
182
177
  f"[{COLORS['danger']}]{weather.sunset}[/{COLORS['danger']}]",
183
- "[dim]DUSK[/dim]",
178
+ "",
184
179
  )
185
180
 
186
- # UV Index with protection level
181
+ # UV Index
187
182
  if weather.uv_index is not None:
188
- uv_label, uv_color = get_uv_guidance(weather.uv_index)
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
- "[bold]UV INDEX[/bold]",
186
+ "UV INDEX",
194
187
  f"[{uv_color}]{weather.uv_index:.1f}[/{uv_color}]",
195
- f"[{uv_color}]{uv_label.upper()} {uv_indicator}[/{uv_color}]",
188
+ f"[{uv_color}]{uv_category}[/{uv_color}]",
196
189
  )
197
190
 
198
- # Wind Gust with alert styling
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]⚠️ SEVERE[/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
- "[bold]WIND GUST[/bold]",
207
+ "WIND GUST",
216
208
  f"{weather.wind_gust} {weather.units.wind}",
217
- f"[{COLORS['steel']}]{gust_indicator}[/{COLORS['steel']}]",
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['secondary']}]┌─ TREND ANALYSIS ─┐[/{COLORS['secondary']}]"
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['secondary']}]└──────────────────┘[/{COLORS['secondary']}]"
266
+ f"[{COLORS['muted']}]────────────────────────────────────────[/{COLORS['muted']}]"
349
267
  )
350
268
  print()
351
269
 
352
- # Create industrial guidance panel
270
+ # Create borderless guidance panel
353
271
  console.print(
354
- f"[{COLORS['primary']} bold]┌─ PREPARATION PROTOCOL ─┐[/{COLORS['primary']} bold]"
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] ⚠️ HIGH PRIORITY[/{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] ▲ RECOMMENDED[/{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" [{COLORS['warning']}]▶[/{COLORS['warning']}] {suggestion}"
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] ○ ADVISORY[/{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" [{COLORS['muted']}]▶[/{COLORS['muted']}] {suggestion}")
324
+ console.print(f" [{COLORS['muted']}]▶[/{COLORS['muted']}] {suggestion}")
403
325
 
404
326
  console.print(
405
- f"[{COLORS['primary']} bold]└─────────────────────────┘[/{COLORS['primary']} bold]"
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 industrial forecast table
357
+ # Create borderless forecast table
436
358
  table = Table(
437
- title=f"[{COLORS['primary']} bold]┌─ FORECAST PANEL ─┐[/{COLORS['primary']} bold]\n"
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
- # Industrial column headers
448
- table.add_column("DAY", justify="left", width=12, style=f"{COLORS['accent']} bold")
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=20, style=COLORS["muted"])
370
+ table.add_column("CONDITIONS", justify="left", width=18, style=COLORS["muted"])
451
371
  table.add_column(
452
- "HIGH", justify="right", width=10, style=f"{COLORS['primary']} bold"
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 severity indicator
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 industrial hourly table
420
+ # Create borderless hourly table
516
421
  table = Table(
517
- title=f"[{COLORS['primary']} bold]┌─ HOURLY TRACKER ─┐[/{COLORS['primary']} bold]\n"
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=65,
427
+ width=60,
525
428
  )
526
429
 
527
- # Industrial column headers
528
- table.add_column("TIME", justify="left", width=8, style=f"{COLORS['accent']} bold")
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=10, style=f"{COLORS['primary']} bold"
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=8, style=f"{COLORS['accent']} bold"
437
+ "RAIN%", justify="right", width=6, style=f"{COLORS['accent']} bold"
536
438
  )
537
- table.add_column("CONDITIONS", justify="left", width=20, style=COLORS["concrete"])
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
- # Temperature gauge
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.2
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
  ![License](https://img.shields.io/badge/license-MIT-green)
45
45
  ![Security Scan](https://github.com/KnowOneActual/isobar-cli/actions/workflows/security.yml/badge.svg)
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 overwhelm with data. Isobar strips away everything except what matters when deciding how to prepare for the day.
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
- ┌─ WEATHER OBSERVATORY ─┐
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
- | **Phase 7 Features** | **Intuition & Analysis** |
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
- result = get_weather_data("FailCity")
208
- assert result is None
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 TRACKER" header
384
- assert "HOURLY TRACKER" in result.output.upper()
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 ui.get_precip_headline(80, 0.5, 0, "in") == "Moderate rain likely"
576
- assert ui.get_precip_headline(80, 0.1, 0, "in") == "Light rain likely"
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,8 +1,8 @@
1
+ from isobar_cli.logic import get_precip_headline
1
2
  from isobar_cli.models import WeatherData, WeatherUnits
2
3
  from isobar_cli.ui import (
3
4
  build_weather_table,
4
5
  get_aqi_label,
5
- get_precip_headline,
6
6
  get_temp_color,
7
7
  get_weather_icon,
8
8
  )
@@ -1 +0,0 @@
1
- __version__ = "1.3.2"
File without changes
File without changes
File without changes