๐ค๏ธ Project 04: Real-time Weather CLI
Difficulty: ๐ Professional
Description
Build a professional command-line weather application that fetches real-time weather data from the Open-Meteo API (free, no API key needed!) and displays it beautifully in the terminal. This project combines API consumption, decorators, type hinting, error handling, and testing.
Requirements
- Fetch current weather data for any city using the Open-Meteo geocoding + weather API
- Display temperature, humidity, wind speed, and weather condition
- Support multiple cities in a single query
- Cache API responses using a decorator to avoid repeated requests
- Use type hints throughout the codebase
- Handle network errors and invalid city names gracefully
- Display weather data in a formatted, visually appealing way
Concepts You'll Practice
- HTTP Requests (
urllibโ no external dependencies!) - Decorators (caching)
- Type Hinting
- Error Handling (network, API, data)
- JSON parsing
- String formatting and CLI design
Example Output
๐ค๏ธ WEATHER CLI
===============
Enter city name: Gaza
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๐ Gaza, Palestinian Territory โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฃ
โ ๐ก๏ธ Temperature: 28.3ยฐC โ
โ ๐ง Humidity: 65% โ
โ ๐จ Wind Speed: 12.5 km/h โ
โ โ๏ธ Condition: Clear sky โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโHow to Run
cd projects/04_weather_cli
python solution.pyNote: This project uses the free Open-Meteo API โ no API key or sign-up required!
Bonus Challenges
- Add 5-day forecast display
- Add temperature unit conversion (ยฐC / ยฐF)
- Save favorite cities to a config file
Solution
"""
================================================================================
Project 04: Real-time Weather CLI
Difficulty: ๐ Professional
================================================================================
A professional command-line weather application that fetches real-time data
from the Open-Meteo API (free, no API key needed) and displays it beautifully.
Concepts Used:
- HTTP Requests (urllib โ no external dependencies)
- Decorators (caching)
- Type Hinting
- Error Handling (network, API, data)
- JSON parsing
- String formatting and CLI design
================================================================================
"""
import json
import urllib.error
import urllib.parse
import urllib.request
from functools import wraps
from typing import Any, Optional, TypedDict
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
class CityInfo(TypedDict):
name: str
country: str
latitude: float
longitude: float
class WeatherData(TypedDict):
temperature: float
wind_speed: float
weather_code: int
humidity: Optional[int]
# WMO Weather interpretation codes
WMO_CODES: dict[int, tuple[str, str]] = {
0: ("โ๏ธ", "Clear sky"),
1: ("๐ค๏ธ", "Mainly clear"),
2: ("โ
", "Partly cloudy"),
3: ("โ๏ธ", "Overcast"),
45: ("๐ซ๏ธ", "Fog"),
48: ("๐ซ๏ธ", "Depositing rime fog"),
51: ("๐ฆ๏ธ", "Light drizzle"),
53: ("๐ฆ๏ธ", "Moderate drizzle"),
55: ("๐ง๏ธ", "Dense drizzle"),
61: ("๐ง๏ธ", "Slight rain"),
63: ("๐ง๏ธ", "Moderate rain"),
65: ("๐ง๏ธ", "Heavy rain"),
71: ("๐จ๏ธ", "Slight snow"),
73: ("๐จ๏ธ", "Moderate snow"),
75: ("โ๏ธ", "Heavy snow"),
80: ("๐ง๏ธ", "Slight rain showers"),
81: ("๐ง๏ธ", "Moderate rain showers"),
82: ("โ๏ธ", "Violent rain showers"),
95: ("โ๏ธ", "Thunderstorm"),
96: ("โ๏ธ", "Thunderstorm with slight hail"),
99: ("โ๏ธ", "Thunderstorm with heavy hail"),
}
# -----------------------------------------------------------------------------
# Caching Decorator
# -----------------------------------------------------------------------------
def cache_response(func):
"""
Decorator that caches function responses based on arguments.
This avoids making redundant API calls for the same city
within a single session.
"""
_cache: dict[str, Any] = {}
@wraps(func)
def wrapper(*args, **kwargs):
# Create a cache key from the arguments
key = str(args) + str(sorted(kwargs.items()))
if key not in _cache:
_cache[key] = func(*args, **kwargs)
else:
print(" โก Using cached data...")
return _cache[key]
# Expose cache for testing / inspection
wrapper.cache = _cache
return wrapper
# -----------------------------------------------------------------------------
# API Functions
# -----------------------------------------------------------------------------
def fetch_json(url: str) -> dict[str, Any]:
"""Fetch JSON data from a URL using urllib (no external deps)."""
try:
req = urllib.request.Request(url, headers={"User-Agent": "PythonByExample/1.0"})
with urllib.request.urlopen(req, timeout=10) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.URLError as e:
raise ConnectionError(f"Network error: {e}")
except json.JSONDecodeError:
raise ValueError("Failed to parse API response.")
@cache_response
def geocode_city(city_name: str) -> Optional[CityInfo]:
"""
Look up a city's coordinates using the Open-Meteo Geocoding API.
Returns dict with name, country, latitude, longitude or None if not found.
"""
params = urllib.parse.urlencode({"name": city_name, "count": 1, "language": "en"})
url = f"{GEOCODING_URL}?{params}"
data = fetch_json(url)
if "results" not in data or not data["results"]:
return None
result = data["results"][0]
return {
"name": result.get("name", city_name),
"country": result.get("country", "Unknown"),
"latitude": result["latitude"],
"longitude": result["longitude"],
}
@cache_response
def get_weather(latitude: float, longitude: float) -> WeatherData:
"""
Fetch current weather data for given coordinates.
Returns dict with temperature, humidity, wind_speed, and weather_code.
"""
params = urllib.parse.urlencode({
"latitude": latitude,
"longitude": longitude,
"current_weather": "true",
"hourly": "relativehumidity_2m",
"timezone": "auto",
})
url = f"{WEATHER_URL}?{params}"
data = fetch_json(url)
current = data.get("current_weather", {})
hourly = data.get("hourly", {})
# Get current hour's humidity
humidity = None
if "relativehumidity_2m" in hourly and hourly["relativehumidity_2m"]:
from datetime import datetime
try:
current_time = datetime.fromisoformat(current.get("time", ""))
times = hourly.get("time", [])
for i, t in enumerate(times):
if datetime.fromisoformat(t) >= current_time:
humidity = hourly["relativehumidity_2m"][i]
break
except (ValueError, IndexError):
humidity = hourly["relativehumidity_2m"][0] if hourly["relativehumidity_2m"] else None
return {
"temperature": current.get("temperature", 0),
"wind_speed": current.get("windspeed", 0),
"weather_code": current.get("weathercode", 0),
"humidity": humidity,
}
# -----------------------------------------------------------------------------
# Display Functions
# -----------------------------------------------------------------------------
def get_weather_description(code: int) -> tuple[str, str]:
"""Get weather icon and description from WMO code."""
return WMO_CODES.get(code, ("โ", "Unknown"))
def display_weather(city_info: CityInfo, weather: WeatherData) -> None:
"""Display weather data in a beautiful formatted box."""
icon, condition = get_weather_description(weather["weather_code"])
location = f"{city_info['name']}, {city_info['country']}"
# Calculate box width based on content
box_width = max(38, len(location) + 8)
inner = box_width - 4
print()
print(f" โ{'โ' * (box_width - 2)}โ")
print(f" โ ๐ {location:<{inner - 4}} โ")
print(f" โ {'โ' * (box_width - 2)}โฃ")
print(f" โ ๐ก๏ธ Temperature: {weather['temperature']:>6.1f}ยฐC{' ' * (inner - 26)}โ")
if weather["humidity"] is not None:
print(f" โ ๐ง Humidity: {weather['humidity']:>5}%{' ' * (inner - 25)}โ")
else:
print(f" โ ๐ง Humidity: N/A{' ' * (inner - 23)}โ")
print(f" โ ๐จ Wind Speed: {weather['wind_speed']:>6.1f} km/h{' ' * (inner - 29)}โ")
print(f" โ {icon} Condition: {condition:<{inner - 17}}โ")
print(f" โ{'โ' * (box_width - 2)}โ")
def display_welcome() -> None:
"""Display the welcome banner."""
print("""
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๐ค๏ธ WEATHER CLI ๐ค๏ธ โ
โ Real-time weather at your terminal โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Powered by Open-Meteo API (free, no API key needed!)
Type 'quit' to exit.
""")
# -----------------------------------------------------------------------------
# Main Application
# -----------------------------------------------------------------------------
def main() -> None:
"""Main entry point for the Weather CLI."""
display_welcome()
while True:
city_name = input("\n ๐ Enter city name: ").strip()
if not city_name:
print(" โ ๏ธ Please enter a city name.")
continue
if city_name.lower() in ("quit", "exit", "q"):
print("\n ๐ Goodbye! Stay dry out there!")
break
try:
# Step 1: Geocode the city
print(f" ๐ Looking up '{city_name}'...")
city_info = geocode_city(city_name)
if city_info is None:
print(f" โ City '{city_name}' not found. Try a different name.")
continue
# Step 2: Fetch weather
weather = get_weather(city_info["latitude"], city_info["longitude"])
# Step 3: Display
display_weather(city_info, weather)
except ConnectionError as e:
print(f" โ Connection error: {e}")
print(" ๐ก Check your internet connection and try again.")
except Exception as e:
print(f" โ An unexpected error occurred: {e}")
if __name__ == "__main__":
main()