isobar-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
isobar_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.6.2"
isobar_cli/api.py ADDED
@@ -0,0 +1,248 @@
1
+ import json
2
+ import time
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ import requests
7
+ from timezonefinder import TimezoneFinder
8
+
9
+ CACHE_DIR = Path.home() / ".cache" / "isobar"
10
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
11
+
12
+
13
+ def get_cached_cities() -> list[str]:
14
+ """
15
+ Returns a list of unique city names from the local cache history.
16
+ Used for shell completion.
17
+ """
18
+ cities = set()
19
+ if not CACHE_DIR.exists():
20
+ return []
21
+
22
+ for f in CACHE_DIR.glob("*.json"):
23
+ # Strip unit suffixes and .json extension
24
+ name = f.stem
25
+ for suffix in ["_metric", "_imperial"]:
26
+ if name.endswith(suffix):
27
+ name = name[: -len(suffix)]
28
+ # Convert underscores back to spaces for completion
29
+ cities.add(name.replace("_", " ").title())
30
+
31
+ return sorted(cities)
32
+
33
+
34
+ def format_time(iso_string: str) -> str:
35
+ """Convert ISO 8601 datetime to 12-hour format (e.g., '6:42 AM')."""
36
+ if not iso_string:
37
+ return "--"
38
+ try:
39
+ dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
40
+ hour = dt.hour
41
+ minute = dt.minute
42
+ if hour == 0:
43
+ hour_12 = 12
44
+ period = "AM"
45
+ elif hour < 12:
46
+ hour_12 = hour
47
+ period = "AM"
48
+ elif hour == 12:
49
+ hour_12 = 12
50
+ period = "PM"
51
+ else:
52
+ hour_12 = hour - 12
53
+ period = "PM"
54
+ return f"{hour_12}:{minute:02d} {period}"
55
+ except (ValueError, AttributeError):
56
+ return "--"
57
+
58
+
59
+ def get_city_suggestions(city: str) -> list[str]:
60
+ """
61
+ Fetches a list of likely city name matches for a given input string.
62
+ Useful for 'Did you mean?' suggestions.
63
+ """
64
+ url = (
65
+ f"https://geocoding-api.open-meteo.com/v1/search"
66
+ f"?name={city}&count=5&format=json"
67
+ )
68
+ try:
69
+ response = requests.get(url)
70
+ response.raise_for_status()
71
+ data = response.json()
72
+ if "results" not in data:
73
+ return []
74
+
75
+ suggestions = []
76
+ for loc in data["results"]:
77
+ region = loc.get("admin1", loc.get("country", ""))
78
+ name = f"{loc['name']}, {region}".strip(", ")
79
+ if name not in suggestions:
80
+ suggestions.append(name)
81
+ return suggestions
82
+ except Exception:
83
+ return []
84
+
85
+
86
+ def get_weather_data(city: str, metric: bool = False) -> dict:
87
+ """
88
+ Converts the city name to coordinates, then fetches the weather with precip
89
+ forecast including rain, snow, sunrise/sunset, WMO weather code, and
90
+ a 7-day daily forecast.
91
+ Timezone is resolved dynamically from lat/lon using timezonefinder.
92
+ """
93
+ unit_suffix = "_metric" if metric else "_imperial"
94
+ cache_file = CACHE_DIR / f"{city.lower().replace(' ', '_')}{unit_suffix}.json"
95
+
96
+ # Check cache first
97
+ if cache_file.exists():
98
+ try:
99
+ cache_data = json.loads(cache_file.read_text())
100
+ timestamp = cache_data.get("timestamp", 0)
101
+ if time.time() - timestamp < 900: # 15 minutes
102
+ # Return data with cache metadata
103
+ cache_data["last_updated"] = timestamp
104
+ return cache_data
105
+ except (json.JSONDecodeError, KeyError, ValueError):
106
+ pass # Cache invalid, proceed to API
107
+
108
+ geo_url = (
109
+ f"https://geocoding-api.open-meteo.com/v1/search"
110
+ f"?name={city}&count=1&format=json"
111
+ )
112
+
113
+ try:
114
+ geo_response = requests.get(geo_url)
115
+ geo_response.raise_for_status()
116
+ geo_data = geo_response.json()
117
+
118
+ if "results" not in geo_data:
119
+ return {}
120
+
121
+ location = geo_data["results"][0]
122
+ lat = location["latitude"]
123
+ lon = location["longitude"]
124
+ region = location.get("admin1", location.get("country", ""))
125
+ clean_city_name = f"{location['name']}, {region}".strip(", ")
126
+
127
+ # Resolve timezone dynamically from coordinates
128
+ tf = TimezoneFinder()
129
+ timezone = tf.timezone_at(lat=lat, lng=lon) or "UTC"
130
+
131
+ # Build weather URL — 7-day daily forecast + current conditions
132
+ temp_unit = "celsius" if metric else "fahrenheit"
133
+ wind_unit = "kmh" if metric else "mph"
134
+ precip_unit = "mm" if metric else "inch"
135
+
136
+ weather_url = (
137
+ f"https://api.open-meteo.com/v1/forecast?"
138
+ f"latitude={lat}&longitude={lon}&"
139
+ f"current=temperature_2m,apparent_temperature,wind_speed_10m,"
140
+ f"relative_humidity_2m,precipitation,weather_code&"
141
+ f"daily=sunrise,sunset,temperature_2m_max,temperature_2m_min,"
142
+ f"weather_code,precipitation_probability_max&"
143
+ f"hourly=precipitation_probability,rain,snowfall,temperature_2m,weather_code&"
144
+ f"temperature_unit={temp_unit}&"
145
+ f"wind_speed_unit={wind_unit}&precipitation_unit={precip_unit}&"
146
+ f"timezone={timezone}&forecast_days=7"
147
+ )
148
+
149
+ weather_response = requests.get(weather_url)
150
+ weather_response.raise_for_status()
151
+ api_data = weather_response.json()
152
+
153
+ # Build Air Quality URL (separate API)
154
+ aqi_url = (
155
+ f"https://air-quality-api.open-meteo.com/v1/air-quality?"
156
+ f"latitude={lat}&longitude={lon}&current=us_aqi"
157
+ )
158
+ aqi_value = None
159
+ try:
160
+ aqi_response = requests.get(aqi_url)
161
+ aqi_response.raise_for_status()
162
+ aqi_data = aqi_response.json()
163
+ aqi_value = aqi_data.get("current", {}).get("us_aqi")
164
+ except Exception:
165
+ pass # AQI is non-essential, don't fail if API is down
166
+
167
+ current = api_data["current"]
168
+ hourly = api_data["hourly"]
169
+ daily = api_data["daily"]
170
+
171
+ # Find the starting index in hourly data that matches current time (rounded down)
172
+ current_time_iso = current["time"]
173
+ try:
174
+ start_idx = hourly["time"].index(current_time_iso)
175
+ except ValueError:
176
+ start_idx = 0
177
+
178
+ # Build 24h hourly forecast (from current hour onwards)
179
+ hourly_forecast = []
180
+ for i in range(start_idx, min(start_idx + 24, len(hourly["time"]))):
181
+ hourly_forecast.append(
182
+ {
183
+ "time": hourly["time"][i],
184
+ "temp": hourly["temperature_2m"][i],
185
+ "weather_code": hourly["weather_code"][i],
186
+ "precip_prob": hourly["precipitation_probability"][i],
187
+ }
188
+ )
189
+
190
+ # Next 6 hours precip probability (average)
191
+ next_6h_probs = hourly["precipitation_probability"][start_idx : start_idx + 6]
192
+ avg_precip_prob = (
193
+ sum(next_6h_probs) / len(next_6h_probs) if next_6h_probs else 0
194
+ )
195
+
196
+ # Next 6 hours rainfall/snowfall (total inches)
197
+ next_6h_rain = sum(hourly["rain"][start_idx : start_idx + 6])
198
+ next_6h_snow = sum(hourly["snowfall"][start_idx : start_idx + 6])
199
+
200
+ # Build 7-day forecast list (index 0 = today)
201
+ num_days = len(daily["time"])
202
+ forecast = []
203
+ for i in range(num_days):
204
+ forecast.append(
205
+ {
206
+ "date": daily["time"][i], # YYYY-MM-DD
207
+ "high": daily["temperature_2m_max"][i],
208
+ "low": daily["temperature_2m_min"][i],
209
+ "weather_code": daily["weather_code"][i],
210
+ "precip_prob": daily["precipitation_probability_max"][i] or 0,
211
+ }
212
+ )
213
+
214
+ current_time = time.time()
215
+ result = {
216
+ "city": clean_city_name,
217
+ "temp": current["temperature_2m"],
218
+ "feels_like": current["apparent_temperature"],
219
+ "wind_speed": current["wind_speed_10m"],
220
+ "humidity": current["relative_humidity_2m"],
221
+ "precipitation": current["precipitation"],
222
+ "weather_code": current.get("weather_code", 0),
223
+ "precip_prob": round(avg_precip_prob), # % chance next 6h
224
+ "rainfall": next_6h_rain, # Total rain next 6h
225
+ "snowfall": next_6h_snow, # Total snow next 6h
226
+ "sunrise": format_time(daily["sunrise"][0]),
227
+ "sunset": format_time(daily["sunset"][0]),
228
+ "forecast": forecast, # List of 7 daily dicts
229
+ "hourly": hourly_forecast, # Next 24 hours
230
+ "aqi": aqi_value,
231
+ "last_updated": current_time,
232
+ "units": {
233
+ "temp": "°C" if metric else "°F",
234
+ "wind": "km/h" if metric else "mph",
235
+ "precip": "mm" if metric else "in",
236
+ },
237
+ }
238
+
239
+ # Cache successful result
240
+ result_with_ts = result.copy()
241
+ result_with_ts["timestamp"] = current_time
242
+ cache_file.write_text(json.dumps(result_with_ts))
243
+
244
+ return result
245
+
246
+ except requests.exceptions.RequestException as e:
247
+ print(f"Connection error: {e}")
248
+ return {}
isobar_cli/location.py ADDED
@@ -0,0 +1,30 @@
1
+ """Location detection module for automatic city detection via IP."""
2
+
3
+ from typing import Optional
4
+
5
+ import requests
6
+
7
+
8
+ def get_auto_location() -> Optional[str]:
9
+ """Detect user's location based on their IP address."""
10
+ try:
11
+ headers = {
12
+ "User-Agent": (
13
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
14
+ )
15
+ }
16
+ response = requests.get(
17
+ "http://ip-api.com/json/",
18
+ headers=headers,
19
+ timeout=3,
20
+ )
21
+ response.raise_for_status()
22
+ data = response.json()
23
+
24
+ if data.get("status") == "success":
25
+ return data.get("city")
26
+
27
+ return None
28
+
29
+ except Exception:
30
+ return None
isobar_cli/main.py ADDED
@@ -0,0 +1,130 @@
1
+ from typing import Annotated, Optional
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from isobar_cli.api import get_cached_cities, get_city_suggestions, get_weather_data
7
+ from isobar_cli.location import get_auto_location
8
+ from isobar_cli.ui import (
9
+ display_forecast,
10
+ display_hourly,
11
+ display_multi_weather,
12
+ display_weather,
13
+ )
14
+
15
+ app = typer.Typer(
16
+ help=("Terminal weather focused on what it FEELS LIKE outside right now.")
17
+ )
18
+ console = Console()
19
+
20
+
21
+ def city_complete(incomplete: str):
22
+ """Callback for shell completion of city names from cache."""
23
+ cached = get_cached_cities()
24
+ return [c for c in cached if c.lower().startswith(incomplete.lower())]
25
+
26
+
27
+ @app.command()
28
+ def main(
29
+ cities: Annotated[
30
+ Optional[list[str]],
31
+ typer.Argument(
32
+ help="City name(s) (detects automatically if omitted). Use quotes for multi-word cities (e.g. \"New York\").",
33
+ autocompletion=city_complete,
34
+ ),
35
+ ] = None,
36
+ forecast: Annotated[
37
+ bool,
38
+ typer.Option(
39
+ "--forecast",
40
+ "-f",
41
+ help="Show 7-day forecast after current conditions",
42
+ ),
43
+ ] = False,
44
+ hourly: Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--hourly",
48
+ "-H",
49
+ help="Show next 12 hours of weather",
50
+ ),
51
+ ] = False,
52
+ metric: Annotated[
53
+ bool,
54
+ typer.Option(
55
+ "--metric",
56
+ "-m",
57
+ help="Show weather in metric units (Celsius, km/h, mm)",
58
+ ),
59
+ ] = False,
60
+ ):
61
+ """
62
+ Get the weather and what it FEELS LIKE outside right now.
63
+
64
+ Examples:
65
+ isobar # Auto-detect location
66
+ isobar Chicago # Single city
67
+ isobar London Tokyo Paris # Multiple cities
68
+ isobar "New York" # Quotes for multi-word cities
69
+ isobar --forecast # Current + 7-day outlook
70
+ isobar --hourly # Current + next 12 hours
71
+ isobar --metric # Metric units
72
+ """
73
+ # Resolve cities list
74
+ cities_to_fetch = []
75
+ if cities:
76
+ cities_to_fetch.extend(cities)
77
+
78
+ # Auto-location if no city provided either way
79
+ if not cities_to_fetch:
80
+ console.print("[dim]🌍 Detecting location...[/dim]")
81
+ auto_city = get_auto_location()
82
+
83
+ if auto_city is None:
84
+ console.print(
85
+ "[yellow]⚠️ Could not detect location. "
86
+ "Using Chicago as default.[/yellow]"
87
+ )
88
+ cities_to_fetch = ["Chicago"]
89
+ else:
90
+ console.print(f"[dim]📍 Detected: {auto_city}[/dim]")
91
+ cities_to_fetch = [auto_city]
92
+
93
+ # Process each city
94
+ results = []
95
+ for city_name in cities_to_fetch:
96
+ full_city = city_name.replace("_", " ")
97
+ weather = get_weather_data(full_city, metric=metric)
98
+ if weather:
99
+ results.append(weather)
100
+ else:
101
+ console.print(f"[bold red]❌ '{full_city}' not found.[/bold red]")
102
+ suggestions = get_city_suggestions(full_city)
103
+ if suggestions:
104
+ suggest_str = ", ".join(suggestions[:3])
105
+ console.print(f"[dim]Did you mean: {suggest_str}?[/dim]")
106
+
107
+ if not results:
108
+ raise typer.Exit(code=1)
109
+
110
+ # UI Rendering
111
+ # If multiple cities AND no detail flags, show side-by-side
112
+ if len(results) > 1 and not (hourly or forecast):
113
+ display_multi_weather(results)
114
+ else:
115
+ # Show sequentially (best for hourly/forecast details)
116
+ for i, weather in enumerate(results):
117
+ if i > 0:
118
+ console.print("[dim]───────────────────────────────────────[/dim]")
119
+
120
+ display_weather(weather)
121
+
122
+ if hourly:
123
+ display_hourly(weather)
124
+
125
+ if forecast:
126
+ display_forecast(weather)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ app()
isobar_cli/ui.py ADDED
@@ -0,0 +1,366 @@
1
+ import time
2
+ from datetime import datetime
3
+
4
+ from rich.columns import Columns
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ console = Console()
9
+
10
+
11
+ # WMO Weather interpretation codes -> (emoji, description)
12
+ # https://open-meteo.com/en/docs
13
+ WMO_CODES: dict[int, tuple[str, str]] = {
14
+ 0: ("☀️", "Clear sky"),
15
+ 1: ("🌤️", "Mainly clear"),
16
+ 2: ("⛅", "Partly cloudy"),
17
+ 3: ("☁️", "Overcast"),
18
+ 45: ("🌫️", "Foggy"),
19
+ 48: ("🌫️", "Rime fog"),
20
+ 51: ("🌦️", "Light drizzle"),
21
+ 53: ("🌦️", "Moderate drizzle"),
22
+ 55: ("🌧️", "Dense drizzle"),
23
+ 61: ("🌧️", "Slight rain"),
24
+ 63: ("🌧️", "Moderate rain"),
25
+ 65: ("🌧️", "Heavy rain"),
26
+ 71: ("🌨️", "Slight snow"),
27
+ 73: ("🌨️", "Moderate snow"),
28
+ 75: ("❄️", "Heavy snow"),
29
+ 77: ("🌨️", "Snow grains"),
30
+ 80: ("🌦️", "Slight showers"),
31
+ 81: ("🌧️", "Moderate showers"),
32
+ 82: ("🌧️", "Violent showers"),
33
+ 85: ("🌨️", "Slight snow showers"),
34
+ 86: ("❄️", "Heavy snow showers"),
35
+ 95: ("⛈️", "Thunderstorm"),
36
+ 96: ("⛈️", "Thunderstorm w/ hail"),
37
+ 99: ("⛈️", "Thunderstorm w/ heavy hail"),
38
+ }
39
+
40
+
41
+ def get_weather_icon(code: int) -> tuple[str, str]:
42
+ """Return (emoji, description) for a WMO weather code."""
43
+ return WMO_CODES.get(code, ("🌡️", "Unknown"))
44
+
45
+
46
+ def get_temp_color(temp_val, unit="°F") -> str:
47
+ """Returns a Rich color tag based on the temperature and unit."""
48
+ try:
49
+ t = float(temp_val)
50
+ if unit == "°C":
51
+ # Celsius thresholds (approximate F to C conversion)
52
+ if t < 0:
53
+ return "bold cyan"
54
+ if t < 15:
55
+ return "bold blue"
56
+ if t < 26:
57
+ return "bold green"
58
+ if t < 35:
59
+ return "bold yellow"
60
+ return "bold red"
61
+ else:
62
+ # Fahrenheit thresholds
63
+ if t < 32:
64
+ return "bold cyan"
65
+ if t < 60:
66
+ return "bold blue"
67
+ if t < 80:
68
+ return "bold green"
69
+ if t < 95:
70
+ return "bold yellow"
71
+ return "bold red"
72
+ except (ValueError, TypeError):
73
+ return "white"
74
+
75
+
76
+ def get_precip_headline(weather_data: dict) -> str:
77
+ """Generate a single-line comfort summary for precip decisions."""
78
+ precip_prob = weather_data.get("precip_prob", 0)
79
+ rainfall = weather_data.get("rainfall", 0)
80
+ snowfall = weather_data.get("snowfall", 0)
81
+ units = weather_data.get("units", {})
82
+ unit_precip = units.get("precip", "in")
83
+ total_precip = rainfall + snowfall
84
+
85
+ # Use metric thresholds if unit is 'mm'
86
+ if unit_precip == "mm":
87
+ low_precip_limit = 2.5
88
+ med_precip_limit = 6.0
89
+ high_precip_limit = 19.0
90
+ snow_precip_limit = 6.0
91
+ else:
92
+ low_precip_limit = 0.1
93
+ med_precip_limit = 0.25
94
+ high_precip_limit = 0.75
95
+ snow_precip_limit = 0.25
96
+
97
+ if precip_prob < 30:
98
+ return "Dry conditions expected"
99
+ elif precip_prob < 60:
100
+ if total_precip < low_precip_limit:
101
+ return "Very low precip risk"
102
+ return "Possible light precip"
103
+ else:
104
+ if snowfall > rainfall and snowfall > snow_precip_limit:
105
+ return "Snowy conditions likely"
106
+ elif rainfall > high_precip_limit:
107
+ return "Heavy rain likely"
108
+ elif rainfall > med_precip_limit:
109
+ return "Moderate rain likely"
110
+ else:
111
+ return "Light rain likely"
112
+
113
+ return ""
114
+
115
+
116
+ def get_aqi_label(aqi_val: int) -> tuple[str, str]:
117
+ """Returns (label, color) for a US AQI value."""
118
+ if aqi_val <= 50:
119
+ return "Good", "bold green"
120
+ if aqi_val <= 100:
121
+ return "Moderate", "bold yellow"
122
+ if aqi_val <= 150:
123
+ return "Unhealthy (Sensitive)", "bold orange1"
124
+ if aqi_val <= 200:
125
+ return "Unhealthy", "bold red"
126
+ if aqi_val <= 300:
127
+ return "Very Unhealthy", "bold purple"
128
+ return "Hazardous", "bold dark_red"
129
+
130
+
131
+ def build_weather_table(weather_data: dict) -> Table:
132
+ """
133
+ Constructs the current conditions weather table.
134
+ """
135
+ city = weather_data.get("city", "Unknown Location")
136
+ temp = weather_data.get("temp", "--")
137
+ feels_like = weather_data.get("feels_like", "--")
138
+ wind_speed = weather_data.get("wind_speed", "--")
139
+ humidity = weather_data.get("humidity", "--")
140
+ precip_prob = weather_data.get("precip_prob", 0)
141
+ rainfall = weather_data.get("rainfall", 0)
142
+ snowfall = weather_data.get("snowfall", 0)
143
+ sunrise = weather_data.get("sunrise", "--")
144
+ sunset = weather_data.get("sunset", "--")
145
+ weather_code = weather_data.get("weather_code", 0)
146
+ units = weather_data.get("units", {"temp": "°F", "wind": "mph", "precip": "in"})
147
+
148
+ temp_color = get_temp_color(temp, units["temp"])
149
+ feels_color = get_temp_color(feels_like, units["temp"])
150
+ condition_icon, condition_desc = get_weather_icon(weather_code)
151
+
152
+ table = Table(
153
+ title=f"\n[bold white]{city} Weather[/bold white]",
154
+ show_header=False,
155
+ box=None,
156
+ padding=(0, 1),
157
+ )
158
+ table.add_column("Icon", justify="center")
159
+ table.add_column("Label", justify="left")
160
+ table.add_column("Value", justify="right")
161
+
162
+ table.add_row(condition_icon, "Conditions:", f"[bold]{condition_desc}[/bold]")
163
+ table.add_row(
164
+ "🌡️", "Temperature:", f"[{temp_color}]{temp}{units['temp']}[/{temp_color}]"
165
+ )
166
+ # Determine "Feels Like" label
167
+ feels_label = "Real Feel:"
168
+ try:
169
+ t_actual = float(temp)
170
+ t_feels = float(feels_like)
171
+ is_metric = units["temp"] == "°C"
172
+
173
+ if is_metric:
174
+ # Metric thresholds: Wind chill if <= 10°C, Heat index if >= 27°C
175
+ if t_actual <= 10 and t_feels < t_actual:
176
+ feels_label = "Wind Chill:"
177
+ elif t_actual >= 27 and t_feels > t_actual:
178
+ feels_label = "Heat Index:"
179
+ else:
180
+ # Imperial thresholds: Wind chill if <= 50°F, Heat index if >= 80°F
181
+ if t_actual <= 50 and t_feels < t_actual:
182
+ feels_label = "Wind Chill:"
183
+ elif t_actual >= 80 and t_feels > t_actual:
184
+ feels_label = "Heat Index:"
185
+ except (ValueError, TypeError):
186
+ pass
187
+
188
+ table.add_row(
189
+ "🤔", feels_label, f"[{feels_color}]{feels_like}{units['temp']}[/{feels_color}]"
190
+ )
191
+ table.add_row("💨", "Wind Speed:", f"{wind_speed} {units['wind']}")
192
+ table.add_row("💧", "Humidity:", f"{humidity}%")
193
+
194
+ aqi = weather_data.get("aqi")
195
+ if aqi is not None:
196
+ aqi_label, aqi_color = get_aqi_label(int(aqi))
197
+ table.add_row("😷", "Air Quality:", f"[{aqi_color}]{aqi} ({aqi_label})[/{aqi_color}]")
198
+
199
+ headline = get_precip_headline(weather_data)
200
+ precip_value = f"{precip_prob}% (6h)"
201
+ if headline:
202
+ precip_value += f" | [dim italic yellow]{headline}[/dim italic yellow]"
203
+ table.add_row("☔", "Precip Chance:", precip_value)
204
+
205
+ if rainfall > 0.01:
206
+ table.add_row("🌧️", "Rain Expected:", f"~{rainfall:.2f}{units['precip']}")
207
+ if snowfall > 0.01:
208
+ table.add_row("❄️", "Snow Expected:", f"~{snowfall:.2f}{units['precip']}")
209
+
210
+ table.add_row("🌅", "Sunrise:", f"[yellow]{sunrise}[/yellow]")
211
+ table.add_row("🌇", "Sunset:", f"[orange1]{sunset}[/orange1]")
212
+
213
+ return table
214
+
215
+
216
+ def display_weather(weather_data: dict):
217
+ """
218
+ Renders the current conditions weather card for a single city.
219
+ """
220
+ if not weather_data:
221
+ console.print("[bold red]No weather data available.[/bold red]")
222
+ return
223
+
224
+ # Show "Updated X min ago" if data is from cache
225
+ last_updated = weather_data.get("last_updated")
226
+ if last_updated:
227
+ minutes_ago = int((time.time() - last_updated) / 60)
228
+ if minutes_ago > 0:
229
+ console.print(f"[dim]Updated {minutes_ago} min ago[/dim]")
230
+
231
+ table = build_weather_table(weather_data)
232
+ console.print(table)
233
+ print()
234
+
235
+
236
+ def display_multi_weather(weather_list: list[dict]):
237
+ """
238
+ Renders multiple weather cards side-by-side using Columns.
239
+ """
240
+ tables = []
241
+ for weather in weather_list:
242
+ if weather:
243
+ tables.append(build_weather_table(weather))
244
+
245
+ if not tables:
246
+ console.print("[bold red]No weather data available.[/bold red]")
247
+ return
248
+
249
+ # Show cache info if any city was cached
250
+ times = [w.get("last_updated") for w in weather_list if w.get("last_updated")]
251
+ if times:
252
+ avg_last_updated = sum(times) / len(times)
253
+ minutes_ago = int((time.time() - avg_last_updated) / 60)
254
+ if minutes_ago > 0:
255
+ console.print(f"[dim]Cached data ~{minutes_ago} min ago[/dim]")
256
+
257
+ console.print(Columns(tables, equal=True, expand=False))
258
+ print()
259
+
260
+
261
+ def display_forecast(weather_data: dict):
262
+ """
263
+ Renders a 7-day forecast table below the current conditions card.
264
+ """
265
+ forecast = weather_data.get("forecast", [])
266
+ if not forecast:
267
+ console.print("[yellow]No forecast data available.[/yellow]")
268
+ return
269
+
270
+ city = weather_data.get("city", "Unknown Location")
271
+ units = weather_data.get("units", {"temp": "°F", "wind": "mph", "precip": "in"})
272
+
273
+ table = Table(
274
+ title=f"[bold white]7-Day Forecast — {city}[/bold white]",
275
+ show_header=True,
276
+ header_style="bold dim",
277
+ box=None,
278
+ padding=(0, 2),
279
+ )
280
+ table.add_column("Day", justify="left", min_width=10)
281
+ table.add_column("", justify="center") # emoji
282
+ table.add_column("Conditions", justify="left", min_width=18)
283
+ table.add_column("High", justify="right")
284
+ table.add_column("Low", justify="right")
285
+ table.add_column("Rain%", justify="right")
286
+
287
+ for i, day in enumerate(forecast):
288
+ # Parse date string to get weekday name
289
+ try:
290
+ dt = datetime.strptime(day["date"], "%Y-%m-%d")
291
+ day_label = "Today" if i == 0 else dt.strftime("%a %b %-d")
292
+ except ValueError:
293
+ day_label = day["date"]
294
+
295
+ icon, desc = get_weather_icon(int(day["weather_code"]))
296
+ high = day["high"]
297
+ low = day["low"]
298
+ precip = int(day["precip_prob"])
299
+
300
+ high_color = get_temp_color(high, units["temp"])
301
+ low_color = get_temp_color(low, units["temp"])
302
+ rain_color = "cyan" if precip >= 60 else "yellow" if precip >= 30 else "dim"
303
+
304
+ table.add_row(
305
+ f"[bold]{day_label}[/bold]" if i == 0 else day_label,
306
+ icon,
307
+ desc,
308
+ f"[{high_color}]{high}{units['temp']}[/{high_color}]",
309
+ f"[{low_color}]{low}{units['temp']}[/{low_color}]",
310
+ f"[{rain_color}]{precip}%[/{rain_color}]",
311
+ )
312
+
313
+ console.print(table)
314
+ print()
315
+
316
+
317
+ def display_hourly(weather_data: dict):
318
+ """
319
+ Renders a compact hourly forecast table for the next 12-24 hours.
320
+ """
321
+ hourly = weather_data.get("hourly", [])
322
+ if not hourly:
323
+ console.print("[yellow]No hourly data available.[/yellow]")
324
+ return
325
+
326
+ city = weather_data.get("city", "Unknown Location")
327
+ units = weather_data.get("units", {"temp": "°F", "wind": "mph", "precip": "in"})
328
+
329
+ table = Table(
330
+ title=f"[bold white]Hourly Forecast — {city}[/bold white]",
331
+ show_header=True,
332
+ header_style="bold dim",
333
+ box=None,
334
+ padding=(0, 2),
335
+ )
336
+ table.add_column("Time", justify="left")
337
+ table.add_column("", justify="center") # emoji
338
+ table.add_column("Temp", justify="right")
339
+ table.add_column("Rain%", justify="right")
340
+ table.add_column("Conditions", justify="left")
341
+
342
+ # Show only next 12 hours for compactness
343
+ for hour in hourly[:12]:
344
+ try:
345
+ dt = datetime.fromisoformat(hour["time"].replace("Z", "+00:00"))
346
+ time_label = dt.strftime("%-I %p")
347
+ except ValueError:
348
+ time_label = hour["time"]
349
+
350
+ icon, desc = get_weather_icon(int(hour["weather_code"]))
351
+ temp = hour["temp"]
352
+ precip = int(hour["precip_prob"])
353
+
354
+ temp_color = get_temp_color(temp, units["temp"])
355
+ rain_color = "cyan" if precip >= 60 else "yellow" if precip >= 30 else "dim"
356
+
357
+ table.add_row(
358
+ time_label,
359
+ icon,
360
+ f"[{temp_color}]{temp}{units['temp']}[/{temp_color}]",
361
+ f"[{rain_color}]{precip}%[/{rain_color}]",
362
+ f"[dim]{desc}[/dim]",
363
+ )
364
+
365
+ console.print(table)
366
+ print()
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: isobar-cli
3
+ Version: 1.0.0
4
+ Summary: A visually pleasing terminal weather tool focusing on Real Feel and Windchill.
5
+ Author: Beau Bremer / KnowOneActual
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/KnowOneActual/isobar-cli
8
+ Project-URL: Repository, https://github.com/KnowOneActual/isobar-cli
9
+ Project-URL: Issues, https://github.com/KnowOneActual/isobar-cli/issues
10
+ Project-URL: Changelog, https://github.com/KnowOneActual/isobar-cli/blob/main/CHANGELOG.md
11
+ Keywords: weather,cli,terminal,meteo,forecast
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: rich>=13.0.0
25
+ Requires-Dist: requests>=2.31.0
26
+ Requires-Dist: typer>=0.9.0
27
+ Requires-Dist: timezonefinder>=6.0.0
28
+ Provides-Extra: test
29
+ Requires-Dist: pytest>=7.0.0; extra == "test"
30
+ Requires-Dist: requests-mock>=1.11.0; extra == "test"
31
+ Requires-Dist: pytest-cov>=4.1.0; extra == "test"
32
+ Dynamic: license-file
33
+
34
+ # Isobar CLI
35
+
36
+ ![CI](https://github.com/KnowOneActual/isobar-cli/actions/workflows/ci.yml/badge.svg)
37
+ ![Coverage](https://img.shields.io/badge/coverage-98%25-green)
38
+ ![Version](https://img.shields.io/badge/version-0.6.3-blue)
39
+ ![Ruff](https://img.shields.io/badge/linting-ruff-purple)
40
+ ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
41
+ ![License](https://img.shields.io/badge/license-MIT-green)
42
+
43
+ A terminal weather tool designed to give you a fast, clean sense of what the weather **feels like** outside; right now and for the week ahead. Built with Python and Rich.
44
+
45
+ ## Philosophy
46
+
47
+ Isobar CLI answers a simple question: **"What does it feel like outside right now, and how do I prepare to be outside?"**
48
+
49
+ Most weather apps overwhelm you with data. Isobar strips away everything except what matters when you're deciding whether to grab a jacket.
50
+
51
+ ### Design Principles
52
+ - **Essential over comprehensive** — Show Real Feel, not 47 data points
53
+ - **Terminal-native** — Built for quick checks in your workflow
54
+ - **Zero friction** — No API keys, no config files
55
+ - **Information density** — Clean, borderless UI
56
+ - **Intentional features** — Each must answer: *"Does this help understand what it feels like outside?"*
57
+
58
+ ## ✨ Features
59
+
60
+ - **Auto-Location** — `isobar` detects your city automatically 🌍
61
+ - **Weather Condition Icons** — WMO-standard emoji + plain-English description (☀️ Clear sky, 🌨️ Moderate snow, ⛈️ Thunderstorm)
62
+ - **Real Feel** — Apparent temperature (what it *feels* like)
63
+ - **Air Quality Index** — US AQI with health classifications 😷
64
+ - **7-Day Forecast** — Full week outlook with color-coded highs/lows and daily rain %
65
+ - **Hourly Outlook** — Next 12 hours of temp and rain chance (`--hourly`)
66
+ - **Multiple Cities** — Compare weather across several cities at once (side-by-side)
67
+ - **Smart Suggestions** — Fuzzy city name matching if you make a typo
68
+ - **Shell Completion** — Tab-complete city names from your search history
69
+ - **Dynamic Timezone** — Sunrise/sunset always shown in the city's local time, not yours
70
+ - **Precipitation Forecast** — Next 6h rain/snow chance + totals
71
+ - **Smart Caching** — 15-min cache per city (`~/.cache/isobar/`)
72
+ - **Color-Coded Temps** — Cyan → Blue → Green → Yellow → Red
73
+ - **Metric Support** — `--metric` or `-m` for Celsius, km/h, and mm
74
+ - **No API Keys** — Free Open-Meteo + ip-api.com
75
+ - **Zero Config** — Works instantly after `pip install .`
76
+
77
+ ## 🚀 Installation
78
+
79
+ ```bash
80
+ git clone https://github.com/KnowOneActual/isobar-cli.git
81
+ cd isobar-cli
82
+ pip install .
83
+ ```
84
+
85
+ ## 📱 Usage
86
+
87
+ ```bash
88
+ # Auto-detect your location
89
+ isobar
90
+
91
+ # Specify a city
92
+ isobar Chicago
93
+ isobar London Tokyo Paris # Multiple cities
94
+ isobar "New York" # Use quotes for multi-word cities
95
+
96
+ # Hourly outlook (next 12h)
97
+ isobar --hourly
98
+ isobar -H
99
+
100
+ # 7-day forecast
101
+ isobar --forecast
102
+ isobar -f
103
+ isobar "San Francisco" --forecast
104
+ isobar -f Sydney
105
+
106
+ # Metric units (Celsius, km/h, mm)
107
+ isobar --metric
108
+ isobar -m
109
+ isobar Tokyo -m
110
+ ```
111
+
112
+ ## ⌨️ Shell Completion
113
+
114
+ Isobar supports tab-completion for city names based on your search history. To enable it for your shell:
115
+
116
+ **Zsh:**
117
+ ```bash
118
+ isobar --install-completion zsh
119
+ ```
120
+
121
+ **Bash:**
122
+ ```bash
123
+ isobar --install-completion bash
124
+ ```
125
+
126
+ *(Note: You may need to restart your terminal after installing completion).*
127
+
128
+ ## 🖥️ Example Output
129
+
130
+ ```
131
+ Chicago, Illinois Weather
132
+ ☀️ Conditions: Mainly clear
133
+ 🌡️ Temperature: 37.1°F
134
+ 🤔 Real Feel: 30.4°F
135
+ 💨 Wind Speed: 4.3 mph
136
+ 💧 Humidity: 58%
137
+ ☔ Precip Chance: 0% (6h) | Dry conditions expected
138
+ 🌅 Sunrise: 6:29 AM
139
+ 🌇 Sunset: 5:37 PM
140
+
141
+ 7-Day Forecast — Chicago, Illinois
142
+ Day Conditions High Low Rain%
143
+ Today ☁️ Overcast 41.7°F 23.9°F 1%
144
+ Fri Feb 27 ⛅ Partly cloudy 66.4°F 32.4°F 2%
145
+ Sat Feb 28 🌨️ Moderate snow 44.0°F 26.5°F 38%
146
+ Sun Mar 1 ☁️ Overcast 29.0°F 26.6°F 36%
147
+ Mon Mar 2 ☁️ Overcast 33.6°F 27.1°F 36%
148
+ Tue Mar 3 🌦️ Light drizzle 36.8°F 30.9°F 27%
149
+ Wed Mar 4 🌦️ Moderate drizzle 41.3°F 33.5°F 46%
150
+ ```
151
+
152
+ ## 🛠 Tech Stack
153
+
154
+ | Tool | Purpose |
155
+ |---|---|
156
+ | [Typer](https://typer.tiangolo.com/) | CLI framework |
157
+ | [Rich](https://github.com/Textualize/rich) | Terminal UI |
158
+ | [Open-Meteo](https://open-meteo.com/) | Weather + forecast data (free, no key) |
159
+ | [ip-api.com](http://ip-api.com/) | Auto-location detection (free) |
160
+ | [timezonefinder](https://github.com/jannikmi/timezonefinder) | Dynamic timezone from coordinates (offline) |
161
+ | [pytest](https://docs.pytest.org/) | Unit testing framework |
162
+ | [requests-mock](https://requests-mock.readthedocs.io/) | Mocking for API testing |
163
+ | [Ruff](https://docs.astral.sh/ruff/) | Linting + formatting |
164
+ | [pip-audit](https://github.com/pypa/pip-audit) | Dependency security scanning (CI) |
165
+
166
+ ## 📈 Project Status
167
+
168
+ ✅ **Phase 1 Complete** — Caching + Auto-Location
169
+ ✅ **Phase 2 Complete** — Precipitation, Sunrise/Sunset, Condition Icons
170
+ ✅ **Phase 3 Complete** — 7-Day Forecast, Dynamic Timezone, `--city` flag
171
+ ✅ **CI/CD** — Ruff lint + security audit on every push and PR
172
+ See [ROADMAP.md](ROADMAP.md) and [CHANGELOG.md](CHANGELOG.md)
173
+
174
+ ## 🤝 Contributing
175
+
176
+ See [CONTRIBUTING.md](CONTRIBUTING.md). New features must answer: **"Does this help understand what it feels like outside?"**
177
+
178
+ ## 📄 License
179
+
180
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ isobar_cli/__init__.py,sha256=jFlbxEJFS0G44LE-yXXVSwXACA1J_NyYDk5E20_2zpc,22
2
+ isobar_cli/api.py,sha256=tgVlubWMOU8Mk6gALdWOjby737ojW9x-jCDuSpKN0OE,8924
3
+ isobar_cli/location.py,sha256=1I26S15CxFkNU1cuOBdcSgnchqsusBgKUPNKxOFyMyM,731
4
+ isobar_cli/main.py,sha256=ZQYauXRGm0QsiD-84Dk81Ff3N4dwbLDumtDOZRa0ShQ,3992
5
+ isobar_cli/ui.py,sha256=NiHJ2-X301nFFqObYRTBMZbunB7wNsWUuYbg0-wvUI0,12439
6
+ isobar_cli-1.0.0.dist-info/licenses/LICENSE,sha256=rYmroasMTQgriaZcEQdHouEPuEgINHgFqIkFg3ERNhA,1084
7
+ isobar_cli-1.0.0.dist-info/METADATA,sha256=uM1Ob8aK-qa9H0lC9rfrDwi95_pE8C-3Fn7m-NbQDvM,7072
8
+ isobar_cli-1.0.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
9
+ isobar_cli-1.0.0.dist-info/entry_points.txt,sha256=4jlPgvi0Pd5I35xRD8FH9_YMCnNZagcWUy63343Ak7U,47
10
+ isobar_cli-1.0.0.dist-info/top_level.txt,sha256=qjt_sFiaC2Ljn9Lgrrh2aIHAhpltjhFD72RMUgISPGQ,11
11
+ isobar_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ isobar = isobar_cli.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Beau Bremer / KnowOneActual
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ isobar_cli