Project: Weather CLI
1 · The lesson
readYou'll build a command-line tool that fetches the forecast for any city — python weather.py "Bangalore" and you get a clean temperature readout. By the end you'll have geocoding, multi-day forecasts, disk caching, and friendly error handling. All stdlib, no API key, no pip install.
What you'll practice: HTTP with urllib, JSON parsing, argparse, pathlib, disk caching with TTLs, exception design.
Step 1 — The Bare-Bones Version
Hardcode the coordinates, hit Open-Meteo's free forecast endpoint, dump the JSON. No API key required.
import json import urllib.request # Bangalore LAT, LON = 12.97, 77.59 url = ( f"https://api.open-meteo.com/v1/forecast" f"?latitude={LAT}&longitude={LON}¤t=temperature_2m" ) with urllib.request.urlopen(url, timeout=10) as resp: data = json.loads(resp.read()) print(json.dumps(data, indent=2))
urllib.request ships with Python — no requests install needed. The endpoint returns a current object with temperature_2m. Run it; you should see real numbers.
Step 2 — Pretty Output
Raw JSON is for robots. Pull out the fields you actually care about and format them.
import json import urllib.request LAT, LON = 12.97, 77.59 url = ( f"https://api.open-meteo.com/v1/forecast" f"?latitude={LAT}&longitude={LON}" f"¤t=temperature_2m,wind_speed_10m,weather_code" ) with urllib.request.urlopen(url, timeout=10) as resp: data = json.loads(resp.read()) cur = data["current"] temp = cur["temperature_2m"] wind = cur["wind_speed_10m"] code = cur["weather_code"] # Tiny subset of Open-Meteo's WMO weather codes WEATHER = { 0: "clear", 1: "mostly clear", 2: "partly cloudy", 3: "overcast", 45: "fog", 51: "drizzle", 61: "rain", 71: "snow", 80: "showers", 95: "thunderstorm", } summary = WEATHER.get(code, f"code {code}") print(f"Now: {temp}°C — {summary}, wind {wind} km/h")
°C is just a Unicode char — Python source files are UTF-8 by default, so it's fine to type directly.
Step 3 — Geocoding (City Name → Coordinates)
Hardcoding lat/lng doesn't scale. Open-Meteo has a free geocoding endpoint too — give it a city name, get back coordinates.
import json import urllib.request import urllib.parse def get_coords(city): """Resolve a city name to (latitude, longitude, display_name).""" q = urllib.parse.quote(city) url = f"https://geocoding-api.open-meteo.com/v1/search?name={q}&count=1" with urllib.request.urlopen(url, timeout=10) as resp: data = json.loads(resp.read()) results = data.get("results") or [] if not results: raise ValueError(f"unknown city: {city}") r = results[0] return r["latitude"], r["longitude"], f"{r['name']}, {r.get('country', '')}" # Test for city in ["Bangalore", "Tokyo", "Reykjavík"]: lat, lon, name = get_coords(city) print(f" {name:<30} ({lat:.2f}, {lon:.2f})")
urllib.parse.quote escapes spaces and Unicode — "São Paulo" becomes "S%C3%A3o%20Paulo". Forgetting this is a classic source of mysterious 400 errors.
Step 4 — Real CLI Args
Hardcoded city is a step backward. Use argparse — Python's stdlib argument parser.
import argparse def parse_args(): p = argparse.ArgumentParser(description="Show weather for a city.") p.add_argument("city", help="city name, e.g. Bangalore") p.add_argument("--days", type=int, default=1, help="forecast days (1-7)") p.add_argument("--units", choices=["celsius", "fahrenheit"], default="celsius") return p.parse_args() # In real code: args = parse_args() # For demo, simulate: import shlex args = parse_args.__wrapped__() if False else argparse.Namespace( city="Bangalore", days=3, units="celsius" ) print(f"City: {args.city}, days: {args.days}, units: {args.units}")
Run it from your shell:
python weather.py "Bangalore" --days 3 python weather.py "New York" --units fahrenheit python weather.py --help # argparse generates this for free
The --help output is the most underrated feature of argparse. Every flag, default, and description shows up automatically.
Step 5 — Disk Caching (10-Minute TTL)
If you check the weather twice in 30 seconds, hitting the API twice is wasteful. Cache the response on disk; refresh only if it's older than 10 minutes.
import json import time from pathlib import Path CACHE_DIR = Path.home() / ".cache" / "weather" TTL_SECONDS = 600 # 10 minutes def cached_fetch(city, fetch_fn): """Return cached JSON if fresh, else call fetch_fn() and cache it.""" CACHE_DIR.mkdir(parents=True, exist_ok=True) # Sanitise city name for use as a filename safe = "".join(c if c.isalnum() else "_" for c in city.lower()) cache_file = CACHE_DIR / f"{safe}.json" if cache_file.exists(): age = time.time() - cache_file.stat().st_mtime if age < TTL_SECONDS: return json.loads(cache_file.read_text(encoding="utf-8")) # Stale or missing — fetch fresh data = fetch_fn() cache_file.write_text(json.dumps(data), encoding="utf-8") return data # Demo def fake_fetch(): print(" (fetching from API...)") return {"temperature": 24.5, "wind": 8.2} print("first call:") print(cached_fetch("Bangalore", fake_fetch)) print("second call (within 10 min):") print(cached_fetch("Bangalore", fake_fetch)) # no "fetching" print
stat().st_mtime gives the file's modification time. The TTL pattern — check age, refetch if stale — applies anywhere from web caching to data pipelines.
Step 6 — Polished Final Version
Everything stitched together: CLI args, geocoding, caching, multi-day forecast, unit conversion, error handling.
import argparse import json import sys import time import urllib.error import urllib.parse import urllib.request from pathlib import Path CACHE_DIR = Path.home() / ".cache" / "weather" TTL_SECONDS = 600 WEATHER = { 0: "clear", 1: "mostly clear", 2: "partly cloudy", 3: "overcast", 45: "fog", 51: "drizzle", 61: "rain", 71: "snow", 80: "showers", 95: "thunderstorm", } def http_get_json(url): try: with urllib.request.urlopen(url, timeout=10) as resp: return json.loads(resp.read()) except urllib.error.URLError as e: raise RuntimeError(f"network error: {e.reason}") from e def get_coords(city): q = urllib.parse.quote(city) data = http_get_json( f"https://geocoding-api.open-meteo.com/v1/search?name={q}&count=1" ) if not data.get("results"): raise ValueError(f"unknown city: {city!r}") r = data["results"][0] return r["latitude"], r["longitude"], f"{r['name']}, {r.get('country', '')}" def fetch_forecast(lat, lon, days, units): unit_param = "fahrenheit" if units == "fahrenheit" else "celsius" url = ( f"https://api.open-meteo.com/v1/forecast" f"?latitude={lat}&longitude={lon}" f"¤t=temperature_2m,wind_speed_10m,weather_code" f"&daily=temperature_2m_max,temperature_2m_min,weather_code" f"&forecast_days={days}&temperature_unit={unit_param}" ) return http_get_json(url) def cached(city, fn): CACHE_DIR.mkdir(parents=True, exist_ok=True) safe = "".join(c if c.isalnum() else "_" for c in city.lower()) cf = CACHE_DIR / f"{safe}.json" if cf.exists() and time.time() - cf.stat().st_mtime < TTL_SECONDS: return json.loads(cf.read_text(encoding="utf-8")) data = fn() cf.write_text(json.dumps(data), encoding="utf-8") return data def render(city, data, units): unit = "°F" if units == "fahrenheit" else "°C" cur = data["current"] print(f"\n {city}") print(f" Now: {cur['temperature_2m']}{unit} — " f"{WEATHER.get(cur['weather_code'], '?')}, " f"wind {cur['wind_speed_10m']} km/h\n") daily = data["daily"] print(f" {'Date':<12}{'Min':>8}{'Max':>8} Conditions") for date, lo, hi, code in zip( daily["time"], daily["temperature_2m_min"], daily["temperature_2m_max"], daily["weather_code"] ): print(f" {date:<12}{lo:>7}{unit}{hi:>7}{unit} {WEATHER.get(code, '?')}") def main(): p = argparse.ArgumentParser(description="Show weather for a city.") p.add_argument("city") p.add_argument("--days", type=int, default=3) p.add_argument("--units", choices=["celsius", "fahrenheit"], default="celsius") args = p.parse_args() try: lat, lon, display = get_coords(args.city) data = cached( f"{args.city}_{args.days}_{args.units}", lambda: fetch_forecast(lat, lon, args.days, args.units), ) render(display, data, args.units) except ValueError as e: print(f"error: {e}", file=sys.stderr) sys.exit(1) except RuntimeError as e: print(f"error: {e}", file=sys.stderr) sys.exit(2) if __name__ == "__main__": main()
Three exit codes (0 success, 1 user error, 2 network) so this script behaves nicely in shell pipelines. The cache key includes days and units because the same city with different params is a different result.
Stretch Goals
1. Rain alerts: scan the daily weather_code list and warn if any day is in the rain/storm set — "⚠️ rain expected Thursday".
2. Watch mode: add --watch 5 to poll every 5 minutes, clearing the screen each redraw (os.system("clear") on Unix, cls on Windows).
3. Rich/colour output: swap print for the rich library — colour-coded temperatures, a real table, and progress spinners while fetching.
4. Compare two cities: python weather.py "Bangalore" --vs "Tokyo" prints both side-by-side.
5. CSV export: --export logs/weather.csv appends one row per run so you can build a week-long log and plot it later.
🎯 Your Turn — weather_summary(daily_data)
The API returns a daily dict with parallel lists (time, temperature_2m_max, etc.). Build a tiny ASCII bar chart of the daily highs across the forecast window — the kind of thing you'd glance at in a terminal and immediately understand.
def weather_summary(daily): """Return a multi-line string with one line per day: Mon 28° ████████████████ Tue 31° ███████████████████ Wed 24° █████████████ Bars are scaled to the max temperature in the window. """ dates = daily["time"] # ["2026-05-14", ...] highs = daily["temperature_2m_max"] # [28.1, 31.0, 24.3, ...] # TODO 1: compute the max value (for scaling bars to a fixed width) # TODO 2: pick a width (e.g. 20 chars for the longest bar) # TODO 3: for each (date, temp), build a line: # - the weekday name (hint: datetime.fromisoformat(date).strftime("%a")) # - the rounded temp with a degree sign # - a bar of "█" chars proportional to temp/max # TODO 4: join the lines with "\n" and return pass # Test daily = { "time": ["2026-05-14", "2026-05-15", "2026-05-16", "2026-05-17"], "temperature_2m_max": [28.1, 31.0, 24.3, 29.7], } print(weather_summary(daily))
Hint 1 — Weekday from an ISO date
from datetime import datetime
datetime.fromisoformat("2026-05-14").strftime("%a") → "Thu".
Hint 2 — Scaling the bar
bar_len = round(temp / max_temp * width), then "█" * bar_len. Use a single block char — easy to read in any terminal.
Show full solution
from datetime import datetime def weather_summary(daily): dates = daily["time"] highs = daily["temperature_2m_max"] peak = max(highs) or 1 # avoid divide-by-zero width = 20 lines = [] for date, temp in zip(dates, highs): day = datetime.fromisoformat(date).strftime("%a") bar = "█" * round(temp / peak * width) lines.append(f" {day} {round(temp):>2}° {bar}") return "\n".join(lines) daily = { "time": ["2026-05-14", "2026-05-15", "2026-05-16", "2026-05-17"], "temperature_2m_max": [28.1, 31.0, 24.3, 29.7], } print(weather_summary(daily))
Output:
Thu 28° ██████████████████ Fri 31° ████████████████████ Sat 24° ████████████████ Sun 30° ███████████████████
Tiny terminal data-viz with zero dependencies. The same shape works for sales numbers, CPU usage, or any per-bucket metric.
What You Learned
urllib.request— stdlib HTTP. Slower API thanrequests, but zero dependencies.urllib.parse.quote— always escape URL parameters that contain user input.argparse— the canonical CLI parser;--helpgeneration is free.- TTL-based file caching —
mtimecomparison is the simplest possible cache. pathlibas the cross-platform home for cache directories (Path.home() / ".cache" / ...).- Exit codes as the contract between your script and the shell.
The Open-Meteo API has no key — but the same shape works for any REST API. Swap the URLs, add auth headers in urllib.request.Request, and you've got a NASA / GitHub / Spotify CLI.
Next: URL Shortener — local-first, no servers, your own bit.ly in 60 lines.