""" ================================================================================ 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.request import urllib.parse from typing import Optional from functools import wraps # ----------------------------------------------------------------------------- # Constants # ----------------------------------------------------------------------------- GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" WEATHER_URL = "https://api.open-meteo.com/v1/forecast" # 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: """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[dict]: """ 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) -> dict: """ 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: dict, weather: dict) -> 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()