Project: Weather CLI
1 · The lesson
readYou'll build a command-line tool that gets the weather forecast for any city. Run python weather.py "Bengaluru", and it prints the temperature in a clear format. By the end, the tool will have geocoding, multi-day forecasts, disk caching and clear error messages. It uses only the standard library: no API key and no pip install.
What you'll practise: HTTP with urllib, JSON parsing, argparse, pathlib, disk caching with TTLs, exception design.
Step 1: The Simplest Version
Write the coordinates directly in the code, call Open-Meteo's free forecast endpoint and print the JSON. No API key is required.
import json import urllib.request # Bengaluru 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 comes with Python, so you don't need to install requests. The endpoint returns a current object with temperature_2m. Run it, and you should see real numbers.
Step 2: Readable Output
Raw JSON is hard for people to read. Take out the fields you need 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")
° is a Unicode character. Python source files are UTF-8 by default, so you can type it directly in your code.
Step 3: Geocoding (City Name → Coordinates)
Typing the latitude and longitude for every city is not practical. Open-Meteo also has a free geocoding endpoint: you send it a city name, and it returns the 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 ["Bengaluru", "Tokyo", "Reykjavík"]: lat, lon, name = get_coords(city) print(f" {name:<30} ({lat:.2f}, {lon:.2f})")
urllib.parse.quote escapes spaces and non-ASCII characters: "São Paulo" becomes "S%C3%A3o%20Paulo". If you forget it, a city name with a space or an accent makes urlopen fail with a confusing error.
Step 4: Real CLI Args
The city should not be fixed in the code. Use argparse, the argument parser in Python's standard library, to read it from the command line.
import argparse def parse_args(): p = argparse.ArgumentParser(description="Show weather for a city.") p.add_argument("city", help="city name, for example Bengaluru") 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 this demo, build the same kind of object by hand: args = argparse.Namespace(city="Bengaluru", days=3, units="celsius") print(f"City: {args.city}, days: {args.days}, units: {args.units}")
Run it from your shell:
python weather.py "Bengaluru" --days 3 python weather.py "New York" --units fahrenheit python weather.py --help # argparse generates this for free
The --help output is one of the most useful features of argparse. It lists every flag, default and description automatically.
Step 5: Disk Caching (10-Minute TTL)
If you check the weather twice in 30 seconds, calling the API twice is wasteful. Save (cache) the response on disk, and fetch a new one only if the saved one is 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")) # Too old or missing, so fetch new data 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("Bengaluru", fake_fetch)) print("second call (within 10 min):") print(cached_fetch("Bengaluru", fake_fetch)) # no "fetching" print
stat().st_mtime gives the file's modification time. The TTL (time to live) pattern is: check the age of the cached data, and fetch it again if it is too old. Web caches and data pipelines use the same pattern.
Step 6: Polished Final Version
This version puts everything together: CLI arguments, geocoding, caching, a multi-day forecast, unit conversion and 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(3) if __name__ == "__main__": main()
The script uses three exit codes (0 success, 1 user error, 3 network error), so a shell script that runs it can check what went wrong. It does not use 2, because argparse already exits with 2 when the command line is wrong. The cache key includes days and units, because the same city with different parameters gives a different result.
Stretch Goals
1. Rain alerts: scan the daily weather_code list and warn if any day has a rain or storm code, for example "⚠️ 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: replace print with the rich library to get colour-coded temperatures, a real table and a progress spinner while the data loads.
4. Compare two cities: python weather.py "Bengaluru" --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 small text bar chart of the daily highs for the forecast period. You should be able to understand it with one look at the terminal.
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 (for example, 20 characters 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 "█" characters, its length in proportion 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 character, which is 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° ███████████████████
This is a small chart in the terminal with no extra libraries. The same code works for sales numbers, CPU usage or any other value per day or per group.
What You Learned
urllib.request: HTTP from the standard library. It is less convenient thanrequests, but you don't need to install anything.urllib.parse.quote: always escape URL parameters that contain user input.argparse: the standard CLI parser. It creates the--helpoutput for you.- TTL-based file caching: comparing the file's
mtimewith the current time is the simplest kind of cache. pathlibas the cross-platform home for cache directories (Path.home() / ".cache" / ...).- Exit codes to tell the shell whether your script succeeded.
The Open-Meteo API needs no key, but the same code structure works for other REST APIs. Change the URLs and add authentication headers with urllib.request.Request, and you can build a command-line tool for the GitHub or Spotify API.
Next: URL Shortener, your own small version of bit.ly that runs on your computer.
Found this useful? Share it with a friend who’s learning Python.