PythonMastery
intermediate 25 min read · lesson 11 of 15 in Projects

Project: Weather CLI

1 · The lesson

read

You'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.

python
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}&current=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.

python
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"&current=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.

python
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.

python
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:

bash
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.

python
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.

python
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"&current=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.

python
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
python
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:

python
  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 than requests, but zero dependencies.
  • urllib.parse.quote — always escape URL parameters that contain user input.
  • argparse — the canonical CLI parser; --help generation is free.
  • TTL-based file caching — mtime comparison is the simplest possible cache.
  • pathlib as 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.