~90 minutes. Build a CLI tool that takes a city or coordinates and prints today's forecast using the Open-Meteo API (free, no key). Practice requests.get, JSON parsing, and graceful failure handling.
Goal: ship lab-12-weather.py that prints a today's-forecast summary for a passed location. Uses Open-Meteo's free API.
Estimated time: 90 minutes
Prerequisites: Week 12 lecture. Internet connection. All prior labs.
Setup
mkdir -p ~/fnd-102/lab-12
cd ~/fnd-102/lab-12
Install the requests library (the only third-party dependency this lab needs):
python3 -m pip install --user requests
# OR if you're using a virtual environment:
# python3 -m pip install requests
Verify:
python3 -c "import requests; print(requests.__version__)"
# Should print a version number (anything 2.20+ is fine)
Part A: Hit the API once (15 min)
The Open-Meteo API needs latitude and longitude. Try a single call in the REPL:
import requests
# Madison, WI: 43.0731 N, 89.4012 W
response = requests.get(
'https://api.open-meteo.com/v1/forecast',
params={
'latitude': 43.0731,
'longitude': -89.4012,
'daily': 'temperature_2m_max,temperature_2m_min,precipitation_sum',
'timezone': 'auto',
'temperature_unit': 'fahrenheit',
'precipitation_unit': 'inch',
},
timeout=10
)
print(response.status_code)
data = response.json()
print(data)
You should see a 200 status and a JSON response with a daily key. The forecast for today is at index 0 of each list (max temp, min temp, precip).
The Open-Meteo docs are at https://open-meteo.com/en/docs. Read at least the "Forecast API" section to see what parameters are available.
Part B: A geocoder for city names (20 min)
Open-Meteo takes lat/lon; users want to say "Madison" not "43.0731,-89.4012". Open-Meteo's companion geocoding API resolves names:
def geocode(city):
"""Return (lat, lon, formatted_name) for a city name.
Raises ValueError if the city is not found.
"""
response = requests.get(
'https://geocoding-api.open-meteo.com/v1/search',
params={'name': city, 'count': 1, 'language': 'en', 'format': 'json'},
timeout=10
)
response.raise_for_status() # raises on 4xx/5xx
data = response.json()
results = data.get('results') or []
if not results:
raise ValueError(f'no city found matching {city!r}')
r = results[0]
return (r['latitude'], r['longitude'], f"{r['name']}, {r.get('admin1', '')}, {r['country']}")
Notice:
response.raise_for_status()raises anHTTPErrorif the response is 4xx or 5xx. Saves you a status-code check for the "any error is fatal" case.data.get('results') or []defensively handles the case where the API returns noresultskey (or an empty/null one).- Takes the first result. Some cities are ambiguous (Madison is a city in WI, AL, IN, MS, ME, NJ, NC, OH, SD, WI, ... and Madison Square Garden in NY). The first result is usually the largest matching city; stretch exercise: list all results.
Test:
>>> geocode('Madison')
(43.07305, -89.40123, 'Madison, Wisconsin, United States')
>>> geocode('Athens')
(37.98376, 23.72784, 'Athens, Attiki, Greece')
>>> geocode('NotARealPlace12345')
ValueError: no city found matching 'NotARealPlace12345'
Part C: The forecast call with proper error handling (25 min)
import sys
from requests.exceptions import RequestException, Timeout, ConnectionError as ReqConnectionError
def fetch_forecast(lat, lon):
"""Return today's forecast dict (with keys max_f, min_f, precip_in) for the given lat/lon.
Returns None on any error; prints a user-friendly message to stderr.
"""
try:
response = requests.get(
'https://api.open-meteo.com/v1/forecast',
params={
'latitude': lat,
'longitude': lon,
'daily': 'temperature_2m_max,temperature_2m_min,precipitation_sum',
'timezone': 'auto',
'temperature_unit': 'fahrenheit',
'precipitation_unit': 'inch',
},
timeout=10
)
except Timeout:
print('Error: weather API timed out (10s). Try again later.', file=sys.stderr)
return None
except ReqConnectionError:
print('Error: cannot reach weather API. Check your internet connection.', file=sys.stderr)
return None
except RequestException as e:
print(f'Error: unexpected request error: {e}', file=sys.stderr)
return None
if response.status_code != 200:
print(f'Error: weather API returned status {response.status_code}', file=sys.stderr)
return None
try:
data = response.json()
except ValueError:
print('Error: weather API returned invalid JSON', file=sys.stderr)
return None
try:
daily = data['daily']
return {
'max_f': daily['temperature_2m_max'][0],
'min_f': daily['temperature_2m_min'][0],
'precip_in': daily['precipitation_sum'][0],
}
except (KeyError, IndexError) as e:
print(f'Error: unexpected API response shape: {e}', file=sys.stderr)
return None
The verbose error handling distinguishes user-facing categories: timeout, no internet, server error, malformed response. Each gets a clear message instead of a stack trace. This is what makes a CLI tool feel professional.
Part D: Wire up the CLI (15 min)
import argparse
def build_parser():
parser = argparse.ArgumentParser(
description='Today\'s weather forecast from Open-Meteo (free, no API key).'
)
parser.add_argument(
'--city',
type=str,
help='city name (e.g., "Madison")'
)
parser.add_argument(
'--lat',
type=float,
help='latitude (decimal degrees)'
)
parser.add_argument(
'--lon',
type=float,
help='longitude (decimal degrees)'
)
return parser
def main():
args = build_parser().parse_args()
# Resolve lat/lon: either from --city (geocode), or from --lat/--lon (direct).
if args.city:
try:
lat, lon, name = geocode(args.city)
print(f'Location: {name} ({lat:.4f}, {lon:.4f})')
except (ValueError, RequestException) as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
elif args.lat is not None and args.lon is not None:
lat, lon = args.lat, args.lon
print(f'Location: ({lat:.4f}, {lon:.4f})')
else:
print('Error: pass either --city CITY, or both --lat LAT --lon LON', file=sys.stderr)
sys.exit(2)
forecast = fetch_forecast(lat, lon)
if forecast is None:
sys.exit(1)
print(f'Today\'s forecast:')
print(f' High: {forecast["max_f"]:.1f} F')
print(f' Low: {forecast["min_f"]:.1f} F')
print(f' Precipitation: {forecast["precip_in"]:.2f} in')
if __name__ == '__main__':
main()
Test the call paths:
python3 lab-12-weather.py --city Madison
# Location: Madison, Wisconsin, United States (43.0731, -89.4012)
# Today's forecast:
# High: 78.5 F
# Low: 56.2 F
# Precipitation: 0.10 in
python3 lab-12-weather.py --lat 51.5074 --lon -0.1278 # London
python3 lab-12-weather.py --city NotARealPlace
# Error: no city found matching 'NotARealPlace'
python3 lab-12-weather.py # missing args
# Error: pass either --city CITY, or both --lat LAT --lon LON
Test offline (turn off WiFi):
python3 lab-12-weather.py --city Madison
# Error: cannot reach weather API. Check your internet connection.
The error is clean, not a multi-screen stack trace. That is the discipline.
Part E: Commit your work (10 min)
cd ~/fnd-102/lab-12
git add lab-12-weather.py
git commit -m "lab-12: weather CLI using Open-Meteo (no key); geocoder for --city; graceful failure handling"
Expected output / artifact
lab-12-weather.py should:
- Use
requests.getwithtimeout=(required) andparams=(recommended) - Either
--cityOR (--latand--lon) work as input - Geocode city names to lat/lon via Open-Meteo's geocoding API
- Catch
Timeout,ConnectionError, and generalRequestExceptionseparately with distinct error messages - Catch malformed JSON and unexpected response shapes
- Exit 0 on success, 1 on runtime error, 2 on usage error
- Print results to stdout, errors to stderr
Files committed: lab-12-weather.py.
What's the failure mode?
This tool's likely failure modes:
- Network reachable but DNS broken. Specific case of ConnectionError; the message you print should hint at "check internet / DNS."
- API rate-limited. Open-Meteo allows 10,000 requests/day per IP. Unlikely to hit during the lab, but a runaway script could. If you ever see a 429, the right response is to wait (the
Retry-Afterheader tells you how long). - City name resolves to wrong location. "Athens" returns Athens, Greece; if you wanted Athens, Georgia, you need to disambiguate. The stretch exercise lists multiple results.
- Time zone confusion. The API returns the day's forecast in the LOCAL timezone of the requested coords (because we passed
timezone=auto). If you assumed UTC, the "today" you got might be tomorrow in your local time. - Empty or null values. A field like
precipitation_sumcould be null for a location with no data. The current code crashes onf'{null:.2f}'. Defensive: check forNonebefore formatting.
Common pitfalls
- Forgetting
timeout=. Without it, a hanging server hangs your tool. ALWAYS pass one. - Manual URL construction.
f'...?city={city}'does not URL-encodecity. A city like "São Paulo" with a space breaks the URL. Useparams=instead. - Catching bare
except:. Catches everything including KeyboardInterrupt; you cannot abort with Ctrl-C. Always name the exception class. response.json()after a failed request. A 500 response often has a non-JSON body (HTML error page). Calling.json()on it raisesJSONDecodeError. Checkresponse.status_codefirst.- Hardcoding an API key. Not relevant for THIS lab (key-free API) but relevant for your capstone. Always
os.environfor secrets; never in source.
Stretch (optional)
- Multi-day forecast. Add
--days N(default 1) and print today + N-1 future days. Open-Meteo supports up to 16-day forecasts. - Disambiguate geocode results. When multiple results match, print them and ask the user to specify (
--city "Madison, AL"syntax). Stretch. - Cache results. Use
requests-cache(third-party) so repeated queries do not hit the API. Useful for rate-limited APIs. - JSON output. Add
--format jsonso the tool can be piped tojqor other JSON tools. - Retries with backoff. Use
tenacity(third-party) or hand-roll a retry loop with exponential backoff. The lab's 10-second timeout means transient failures are still fatal; retries make them survivable. - Add forecast emoji. Open-Meteo has a
weather_codefield; map it to an emoji (sun, cloud, rain, snow). Just remember the academy's voice-guide does not use emoji in formal output; this is for fun.
Lab 12 v0.1.