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 +1 -0
- isobar_cli/api.py +248 -0
- isobar_cli/location.py +30 -0
- isobar_cli/main.py +130 -0
- isobar_cli/ui.py +366 -0
- isobar_cli-1.0.0.dist-info/METADATA +180 -0
- isobar_cli-1.0.0.dist-info/RECORD +11 -0
- isobar_cli-1.0.0.dist-info/WHEEL +5 -0
- isobar_cli-1.0.0.dist-info/entry_points.txt +2 -0
- isobar_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- isobar_cli-1.0.0.dist-info/top_level.txt +1 -0
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}¤t=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
|
+

|
|
37
|
+

|
|
38
|
+

|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
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,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
|