Classroom Glossary Public page

Lab 12: Weather-Report CLI

780 words

~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 an HTTPError if 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 no results key (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.get with timeout= (required) and params= (recommended)
  • Either --city OR (--lat and --lon) work as input
  • Geocode city names to lat/lon via Open-Meteo's geocoding API
  • Catch Timeout, ConnectionError, and general RequestException separately 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:

  1. Network reachable but DNS broken. Specific case of ConnectionError; the message you print should hint at "check internet / DNS."
  2. 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-After header tells you how long).
  3. 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.
  4. 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.
  5. Empty or null values. A field like precipitation_sum could be null for a location with no data. The current code crashes on f'{null:.2f}'. Defensive: check for None before 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-encode city. A city like "São Paulo" with a space breaks the URL. Use params= 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 raises JSONDecodeError. Check response.status_code first.
  • Hardcoding an API key. Not relevant for THIS lab (key-free API) but relevant for your capstone. Always os.environ for secrets; never in source.

Stretch (optional)

  1. Multi-day forecast. Add --days N (default 1) and print today + N-1 future days. Open-Meteo supports up to 16-day forecasts.
  2. Disambiguate geocode results. When multiple results match, print them and ask the user to specify (--city "Madison, AL" syntax). Stretch.
  3. Cache results. Use requests-cache (third-party) so repeated queries do not hit the API. Useful for rate-limited APIs.
  4. JSON output. Add --format json so the tool can be piped to jq or other JSON tools.
  5. 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.
  6. Add forecast emoji. Open-Meteo has a weather_code field; 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.