Project Goals
3 min- Use
open-meteo.com— a free no-key weather API. - Geocode a city name → latitude/longitude, then fetch the forecast.
- Format a friendly multi-line summary.
- Cache responses for 10 minutes in
cache.json.
Warm-Up · Two APIs, One App
5 minStep 1: city name → lat/long
https://geocoding-api.open-meteo.com/v1/search?name=Kuala%20Lumpur
Step 2: lat/long → weather
https://api.open-meteo.com/v1/forecast?latitude=3.14&longitude=101.69
¤t=temperature_2m,relative_humidity_2m,wind_speed_10mTwo calls. The first translates "Kuala Lumpur" → coordinates. The second asks the weather service for the conditions at those coordinates. This pattern — search-then-fetch — appears across half the APIs you'll meet.
Composing two API calls is no harder than one. Cache the boring bits (geocoding doesn't change) so the second run feels instant.
Plan · Geocode, Fetch, Format, Cache
14 minGeocode
import requests def geocode(city): r = requests.get( "https://geocoding-api.open-meteo.com/v1/search", params={"name": city, "count": 1, "language": "en"}, timeout=5, ) r.raise_for_status() hits = r.json().get("results") if not hits: return None h = hits[0] return {"name": h["name"], "lat": h["latitude"], "lng": h["longitude"], "country": h.get("country", "?")}
Fetch the forecast
def forecast(lat, lng): r = requests.get( "https://api.open-meteo.com/v1/forecast", params={ "latitude": lat, "longitude": lng, "current": "temperature_2m,relative_humidity_2m," "apparent_temperature,wind_speed_10m,weather_code", "timezone": "Asia/Kuala_Lumpur", }, timeout=5, ) r.raise_for_status() return r.json()["current"]
Weather codes → emoji + words
open-meteo returns a numeric WMO weather code. A small table makes it human:
WMO = { 0: ("☀️", "clear"), 1: ("🌤️", "mostly clear"), 2: ("⛅", "partly cloudy"), 3: ("☁️", "overcast"), 45: ("🌫️", "fog"), 61: ("🌧️", "light rain"), 63: ("🌧️", "rain"), 65: ("🌧️", "heavy rain"), 80: ("🌦️", "showers"), 95: ("⛈️", "thunderstorm"), } def describe(code): return WMO.get(code, ("❓", f"code {code}"))
Cache — 10-minute freshness
import json, time from pathlib import Path CACHE = Path("cache.json") FRESH_S = 600 # 10 minutes def load_cache(): if CACHE.exists(): return json.loads(CACHE.read_text()) return {} def save_cache(c): CACHE.write_text(json.dumps(c, indent=2)) def from_cache(city): c = load_cache() entry = c.get(city) if entry and time.time() - entry["ts"] < FRESH_S: return entry["data"] return None def put_cache(city, data): c = load_cache() c[city] = {"ts": time.time(), "data": data} save_cache(c)
Build · weather.py
12 min# weather.py — Malaysian (or any) city weather import json, time, requests from pathlib import Path CACHE = Path("cache.json") FRESH_S = 600 WMO = { 0: ("☀️", "clear"), 1: ("🌤️", "mostly clear"), 2: ("⛅", "partly cloudy"), 3: ("☁️", "overcast"), 45: ("🌫️", "fog"), 61: ("🌧️", "light rain"), 63: ("🌧️", "rain"), 65: ("🌧️", "heavy rain"), 80: ("🌦️", "showers"), 95: ("⛈️", "thunderstorm"), } def geocode(city): r = requests.get( "https://geocoding-api.open-meteo.com/v1/search", params={"name": city, "count": 1, "language": "en"}, timeout=5, ) r.raise_for_status() hits = r.json().get("results") if not hits: return None h = hits[0] return {"name": h["name"], "lat": h["latitude"], "lng": h["longitude"], "country": h.get("country", "?")} def forecast(lat, lng): r = requests.get( "https://api.open-meteo.com/v1/forecast", params={ "latitude": lat, "longitude": lng, "current": "temperature_2m,relative_humidity_2m," "apparent_temperature,wind_speed_10m,weather_code", "timezone": "Asia/Kuala_Lumpur", }, timeout=5, ) r.raise_for_status() return r.json()["current"] def describe(code): return WMO.get(code, ("❓", f"code {code}")) def fetch_weather(city): cache = json.loads(CACHE.read_text()) if CACHE.exists() else {} if city in cache and time.time() - cache[city]["ts"] < FRESH_S: return cache[city]["data"], True # cache hit loc = geocode(city) if loc is None: return None, False cur = forecast(loc["lat"], loc["lng"]) data = {"loc": loc, "cur": cur} cache[city] = {"ts": time.time(), "data": data} CACHE.write_text(json.dumps(cache, indent=2)) return data, False def main(): city = input("city: ").strip() or "Kuala Lumpur" data, cached = fetch_weather(city) if data is None: print(f"❌ couldn't find {city!r}") return loc, cur = data["loc"], data["cur"] emoji, words = describe(cur["weather_code"]) print(f"\n📍 {loc['name']}, {loc['country']} {'(cached)' if cached else ''}") print(f" {emoji} {words}") print(f" 🌡️ {cur['temperature_2m']:.1f}°C " f"(feels {cur['apparent_temperature']:.1f}°C)") print(f" 💧 {cur['relative_humidity_2m']}% humidity") print(f" 💨 {cur['wind_speed_10m']:.1f} km/h wind") if __name__ == "__main__": main()
Sample run
city: Penang 📍 George Town, Malaysia ⛅ partly cloudy 🌡️ 31.4°C (feels 36.2°C) 💧 72% humidity 💨 9.5 km/h wind $ python weather.py city: Penang 📍 George Town, Malaysia (cached) ⛅ partly cloudy ...
Extensions
13 minLoop over ["Kuala Lumpur", "Penang", "Johor Bahru", "Kota Kinabalu"] and print a one-line summary for each.
The same API supports hourly=temperature_2m. Pull the next 12 hours, print them as a tiny ASCII bar chart (one row per hour).
If the next-3-hours precipitation_probability max is > 50%, print 🌂 advice. Otherwise 🕶️ advice.
Stretch · Hottest in Malaysia Right Now
8 minBuild hottest.py. It loops over ten Malaysian cities, fetches the current temp, finds the hottest, prints a ranked list 1–10. Respect the 10-minute cache — and add a 0.3 s sleep between calls.
Show one possible solution
# hottest.py import time CITIES = ["Kuala Lumpur", "Penang", "Johor Bahru", "Kota Kinabalu", "Kuching", "Ipoh", "Melaka", "Alor Setar", "Kuantan", "Kota Bharu"] rows = [] for c in CITIES: data, _ = fetch_weather(c) if data: rows.append((c, data["cur"]["temperature_2m"])) time.sleep(0.3) rows.sort(key=lambda r: r[1], reverse=True) print("🌡️ Malaysia right now") for i, (city, t) in enumerate(rows, 1): print(f" {i:>2}. {city:<14} {t:.1f}°C")
Non-negotiables: caches, polite sleeps, sorted ranking. Reuse fetch_weather — don't rewrite the network code.
Recap
3 minTwo APIs composed cleanly: geocode then fetch. The lookup table turned WMO codes into emoji + words. The cache saved you from making the same request 100 times in a row. This shape — "search → fetch → present → cache" — is genuinely how production weather widgets and shop pages work. Tomorrow we shift from APIs to scraping pages that don't offer an API.
Homework
4 minAdd three production touches to weather.py:
- If the cache file is corrupted (not valid JSON), recover by treating it as empty rather than crashing.
- If
fetch_weatherraises aRequestExceptionmid-network, fall back to the cached value if it's less than 24 hours old. - Print the timestamp of the data so the user knows how fresh it is.
Sample · resilience upgrades
def load_cache(): if not CACHE.exists(): return {} try: return json.loads(CACHE.read_text()) except json.JSONDecodeError: return {} def fetch_weather(city): cache = load_cache() entry = cache.get(city) fresh_enough = entry and time.time() - entry["ts"] < FRESH_S if fresh_enough: return entry["data"], True try: loc = geocode(city) if loc is None: return None, False cur = forecast(loc["lat"], loc["lng"]) data = {"loc": loc, "cur": cur, "ts": time.time()} cache[city] = {"ts": data["ts"], "data": data} CACHE.write_text(json.dumps(cache, indent=2)) return data, False except requests.exceptions.RequestException: # network failed — use 24h-stale cache as fallback if entry and time.time() - entry["ts"] < 86400: return entry["data"], True raise
Non-negotiables: corrupt cache survived, network outage falls back to stale cache, freshness shown to user.