Build a Python weather application using the OpenWeatherMap API. Fetch real-time weather data, create a 5-day forecast display, build a CLI and tkinter GUI version, with error handling and caching.
Build a weather application that fetches real-time data from the OpenWeatherMap API and displays current conditions and forecasts.
Project Overview
What you'll build: A CLI + GUI weather app with real API integration
Skills practiced: requests, JSON, API keys, error handling, tkinter, matplotlib
Estimated time: 3–4 hours
API: Free tier at openweathermap.org
Setup
pip install requests python-dotenv
Get your free API key at openweathermap.org/api → "Current Weather Data" → free tier.
# .env file (NEVER commit this!)
OPENWEATHER_API_KEY=your_api_key_here
Step 1: API Client
# weather_api.py — OpenWeatherMap API client
import requests
import json
import os
import time
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
class WeatherAPIError(Exception):
pass
class WeatherAPI:
"""OpenWeatherMap API client with caching."""
BASE_URL = "https://api.openweathermap.org/data/2.5"
GEO_URL = "https://api.openweathermap.org/geo/1.0"
CACHE_FILE = Path('cache/weather_cache.json')
CACHE_TTL = 600 # Cache for 10 minutes
def __init__(self, api_key=None):
self.api_key = api_key or os.environ.get('OPENWEATHER_API_KEY')
if not self.api_key:
raise WeatherAPIError("No API key found. Set OPENWEATHER_API_KEY in .env")
self.cache = {}
self.CACHE_FILE.parent.mkdir(exist_ok=True)
self._load_cache()
def _load_cache(self):
if self.CACHE_FILE.exists():
with open(self.CACHE_FILE) as f:
self.cache = json.load(f)
def _save_cache(self):
with open(self.CACHE_FILE, 'w') as f:
json.dump(self.cache, f)
def _get_cached(self, key):
if key in self.cache:
entry = self.cache[key]
if time.time() - entry['timestamp'] < self.CACHE_TTL:
return entry['data']
return None
def _set_cache(self, key, data):
self.cache[key] = {'data': data, 'timestamp': time.time()}
self._save_cache()
def _make_request(self, endpoint, params):
"""Make an API request with error handling."""
params['appid'] = self.api_key
params['units'] = 'metric' # Celsius
try:
response = requests.get(f"{self.BASE_URL}/{endpoint}", params=params, timeout=10)
response.raise_for_status()
return response.json()
except requests.exceptions.ConnectionError:
raise WeatherAPIError("No internet connection.")
except requests.exceptions.Timeout:
raise WeatherAPIError("Request timed out. Try again.")
except requests.exceptions.HTTPError as e:
code = e.response.status_code
if code == 401:
raise WeatherAPIError("Invalid API key. Check your .env file.")
elif code == 404:
raise WeatherAPIError("City not found. Check the city name.")
elif code == 429:
raise WeatherAPIError("API rate limit exceeded. Wait a minute.")
raise WeatherAPIError(f"HTTP Error: {code}")
except requests.exceptions.RequestException as e:
raise WeatherAPIError(f"Request failed: {e}")
def get_current_weather(self, city, country_code=None):
"""Fetch current weather for a city."""
query = f"{city},{country_code}" if country_code else city
cache_key = f"current_{query.lower()}"
cached = self._get_cached(cache_key)
if cached:
print(" (from cache)")
return cached
data = self._make_request('weather', {'q': query})
# Parse into a clean structure
weather = {
'city': data['name'],
'country': data['sys']['country'],
'temp': round(data['main']['temp'], 1),
'feels_like': round(data['main']['feels_like'], 1),
'temp_min': round(data['main']['temp_min'], 1),
'temp_max': round(data['main']['temp_max'], 1),
'humidity': data['main']['humidity'],
'pressure': data['main']['pressure'],
'description': data['weather'][0]['description'].title(),
'icon': data['weather'][0]['icon'],
'wind_speed': round(data['wind']['speed'] * 3.6, 1), # m/s to km/h
'wind_dir': data['wind'].get('deg', 0),
'visibility': data.get('visibility', 0) / 1000, # m to km
'clouds': data['clouds']['all'],
'sunrise': datetime.fromtimestamp(data['sys']['sunrise']).strftime('%H:%M'),
'sunset': datetime.fromtimestamp(data['sys']['sunset']).strftime('%H:%M'),
'timezone_offset': data['timezone'],
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'coords': {'lat': data['coord']['lat'], 'lon': data['coord']['lon']},
}
self._set_cache(cache_key, weather)
return weather
def get_forecast(self, city, country_code=None, days=5):
"""Get 5-day / 3-hour forecast."""
query = f"{city},{country_code}" if country_code else city
cache_key = f"forecast_{query.lower()}"
cached = self._get_cached(cache_key)
if cached:
return cached
data = self._make_request('forecast', {'q': query, 'cnt': days * 8})
# Group by day
from collections import defaultdict
daily = defaultdict(list)
for entry in data['list']:
day = datetime.fromtimestamp(entry['dt']).strftime('%Y-%m-%d')
daily[day].append({
'time': datetime.fromtimestamp(entry['dt']).strftime('%H:%M'),
'temp': round(entry['main']['temp'], 1),
'feels_like': round(entry['main']['feels_like'], 1),
'description': entry['weather'][0]['description'].title(),
'icon': entry['weather'][0]['icon'],
'humidity': entry['main']['humidity'],
'wind': round(entry['wind']['speed'] * 3.6, 1),
'pop': round(entry.get('pop', 0) * 100), # Precipitation probability
})
# Summarize each day
forecast = []
for date_str, entries in daily.items():
temps = [e['temp'] for e in entries]
forecast.append({
'date': datetime.strptime(date_str, '%Y-%m-%d'),
'date_str': date_str,
'day_name': datetime.strptime(date_str, '%Y-%m-%d').strftime('%A'),
'temp_min': min(temps),
'temp_max': max(temps),
'temp_avg': round(sum(temps) / len(temps), 1),
'description': entries[len(entries)//2]['description'],
'humidity_avg': round(sum(e['humidity'] for e in entries) / len(entries)),
'max_rain_chance': max(e['pop'] for e in entries),
'hourly': entries[:4],
})
self._set_cache(cache_key, forecast)
return forecast
Step 2: Display & CLI
# weather_display.py — Display and CLI interface
def get_wind_direction(degrees):
"""Convert wind degrees to compass direction."""
directions = ['N','NNE','NE','ENE','E','ESE','SE','SSE',
'S','SSW','SW','WSW','W','WNW','NW','NNW']
idx = round(degrees / (360 / len(directions))) % len(directions)
return directions[idx]
def get_weather_emoji(description, icon):
"""Get emoji for weather condition."""
d = description.lower()
if 'clear' in d: return '☀️'
if 'cloud' in d: return '☁️'
if 'rain' in d: return '🌧️'
if 'thunder' in d: return '⛈️'
if 'snow' in d: return '❄️'
if 'mist' in d or 'fog' in d: return '🌫️'
if 'wind' in d: return '💨'
return '🌤️'
def print_current_weather(weather):
"""Print formatted current weather."""
emoji = get_weather_emoji(weather['description'], weather['icon'])
wind_dir = get_wind_direction(weather['wind_dir'])
print(f"\n{'='*55}")
print(f" {emoji} Weather in {weather['city']}, {weather['country']}")
print(f" Updated: {weather['timestamp']}")
print(f"{'='*55}")
print(f"\n 🌡️ Temperature: {weather['temp']}°C (feels like {weather['feels_like']}°C)")
print(f" 📊 Range: {weather['temp_min']}°C — {weather['temp_max']}°C")
print(f" 💬 Condition: {weather['description']}")
print(f" 💧 Humidity: {weather['humidity']}%")
print(f" 🌬️ Wind: {weather['wind_speed']} km/h {wind_dir}")
print(f" 👁️ Visibility: {weather['visibility']:.1f} km")
print(f" ☁️ Cloud cover: {weather['clouds']}%")
print(f" 🌡️ Pressure: {weather['pressure']} hPa")
print(f"\n 🌅 Sunrise: {weather['sunrise']} 🌇 Sunset: {weather['sunset']}")
def print_forecast(forecast, days=5):
"""Print 5-day forecast."""
print(f"\n{'='*55}")
print(f" 📅 {days}-Day Forecast")
print(f"{'='*55}")
print(f" {'DAY':<12} {'MIN':>6} {'MAX':>6} {'RAIN':>6} {'DESCRIPTION'}")
print(f" {'-'*52}")
for day in forecast[:days]:
emoji = get_weather_emoji(day['description'], '')
rain_bar = '💧' * (day['max_rain_chance'] // 25) if day['max_rain_chance'] > 0 else ''
print(f" {day['day_name']:<12} {day['temp_min']:>5}° {day['temp_max']:>5}° "
f"{day['max_rain_chance']:>4}% {emoji} {day['description'][:20]}")
def run_weather_cli():
"""Main CLI weather app."""
from weather_api import WeatherAPI, WeatherAPIError
print("=" * 55)
print(" 🌤️ Python Weather App")
print("=" * 55)
api = None
try:
api = WeatherAPI()
print(" ✅ API connected\n")
except WeatherAPIError as e:
print(f" ❌ {e}")
return
while True:
print("\n 1. Current weather")
print(" 2. 5-day forecast")
print(" 3. Both")
print(" 4. Quit")
choice = input("\n > ").strip()
if choice == '4':
print(" 👋 Goodbye!")
break
if choice not in ['1', '2', '3']:
print(" ❌ Invalid choice")
continue
city = input(" Enter city name: ").strip()
if not city:
print(" ❌ City name cannot be empty")
continue
try:
if choice in ['1', '3']:
print(" Fetching current weather...")
weather = api.get_current_weather(city)
print_current_weather(weather)
if choice in ['2', '3']:
print(" Fetching forecast...")
forecast = api.get_forecast(city)
print_forecast(forecast)
except WeatherAPIError as e:
print(f" ❌ Error: {e}")
if __name__ == '__main__':
run_weather_cli()
Step 3: Visualization
# weather_visualizer.py — Charts for weather data
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import matplotlib.patches as mpatches
import numpy as np
from datetime import datetime
def plot_forecast(weather_current, forecast):
"""Create a weather dashboard chart."""
fig = plt.figure(figsize=(14, 8))
fig.suptitle(f"Weather Dashboard — {weather_current['city']}, {weather_current['country']}",
fontsize=14, fontweight='bold')
gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.4, wspace=0.3)
days = [d['day_name'][:3] for d in forecast[:5]]
temp_min = [d['temp_min'] for d in forecast[:5]]
temp_max = [d['temp_max'] for d in forecast[:5]]
humidity = [d['humidity_avg'] for d in forecast[:5]]
rain_chance = [d['max_rain_chance'] for d in forecast[:5]]
# 1. Temperature range bar chart
ax1 = fig.add_subplot(gs[0, 0])
x = np.arange(len(days))
ax1.bar(x, temp_max, label='Max', color='#FF6B6B', alpha=0.8, width=0.5)
ax1.bar(x, temp_min, label='Min', color='#4ECDC4', alpha=0.8, width=0.5, bottom=0)
ax1.set_xticks(x)
ax1.set_xticklabels(days)
ax1.set_ylabel('Temperature (°C)')
ax1.set_title('Temperature Range')
ax1.legend()
ax1.grid(axis='y', alpha=0.3)
# Add value labels
for xi, (lo, hi) in enumerate(zip(temp_min, temp_max)):
ax1.text(xi, hi + 0.5, f'{hi}°', ha='center', va='bottom', fontsize=8)
ax1.text(xi, lo - 1, f'{lo}°', ha='center', va='top', fontsize=8)
# 2. Rain probability
ax2 = fig.add_subplot(gs[0, 1])
bar_colors = ['#2196F3' if r > 50 else '#90CAF9' for r in rain_chance]
bars = ax2.bar(days, rain_chance, color=bar_colors, alpha=0.85, edgecolor='white')
ax2.set_ylabel('Probability (%)')
ax2.set_title('Rain Probability')
ax2.set_ylim(0, 100)
ax2.grid(axis='y', alpha=0.3)
ax2.axhline(y=50, color='r', linestyle='--', alpha=0.5, label='50% threshold')
ax2.legend()
for bar, val in zip(bars, rain_chance):
ax2.text(bar.get_x() + bar.get_width()/2, val + 1, f'{val}%',
ha='center', va='bottom', fontsize=9)
# 3. Temperature trend line
ax3 = fig.add_subplot(gs[1, 0])
ax3.fill_between(days, temp_min, temp_max, alpha=0.3, color='orange', label='Temperature range')
ax3.plot(days, temp_min, 'b-o', linewidth=2, markersize=7, label='Min')
ax3.plot(days, temp_max, 'r-o', linewidth=2, markersize=7, label='Max')
ax3.set_ylabel('Temperature (°C)')
ax3.set_title('5-Day Temperature Trend')
ax3.legend()
ax3.grid(True, alpha=0.3)
# 4. Current conditions summary
ax4 = fig.add_subplot(gs[1, 1])
ax4.axis('off')
conditions = (
f"📍 {weather_current['city']}, {weather_current['country']}\n\n"
f"🌡️ Temperature\n {weather_current['temp']}°C "
f"(feels {weather_current['feels_like']}°C)\n\n"
f"☁️ {weather_current['description']}\n\n"
f"💧 Humidity: {weather_current['humidity']}%\n"
f"🌬️ Wind: {weather_current['wind_speed']} km/h\n"
f"👁️ Visibility: {weather_current['visibility']:.1f} km\n\n"
f"🌅 Sunrise: {weather_current['sunrise']}\n"
f"🌇 Sunset: {weather_current['sunset']}"
)
ax4.text(0.05, 0.95, conditions, transform=ax4.transAxes, fontsize=10,
verticalalignment='top', fontfamily='monospace',
bbox=dict(boxstyle='round,pad=0.8', facecolor='lightyellow', alpha=0.8))
ax4.set_title('Current Conditions', fontweight='bold')
filename = f"weather_{weather_current['city'].replace(' ','_').lower()}.png"
plt.savefig(filename, dpi=150, bbox_inches='tight')
print(f" 📊 Chart saved to {filename}")
plt.show()
Step 4: tkinter GUI
# weather_gui.py — tkinter weather GUI
import tkinter as tk
from tkinter import ttk, messagebox
import threading
from weather_api import WeatherAPI, WeatherAPIError
from weather_display import get_weather_emoji
class WeatherGUI:
def __init__(self, root):
self.root = root
self.root.title("🌤️ Python Weather App")
self.root.geometry("420x600")
self.root.configure(bg='#1565C0')
self.root.resizable(False, False)
self.api = None
try:
self.api = WeatherAPI()
except WeatherAPIError:
messagebox.showerror("API Error", "Set OPENWEATHER_API_KEY in .env file!")
self.root.destroy()
return
self._build_ui()
def _build_ui(self):
# Search bar
search_frame = tk.Frame(self.root, bg='#1565C0', pady=15)
search_frame.pack(fill='x', padx=20)
self.city_var = tk.StringVar()
city_entry = tk.Entry(search_frame, textvariable=self.city_var,
font=('Arial', 14), width=20, relief='flat',
bg='white', fg='#333')
city_entry.pack(side='left', ipady=8, padx=(0, 10))
city_entry.bind('<Return>', lambda e: self._search())
search_btn = tk.Button(search_frame, text='🔍', command=self._search,
font=('Arial', 14), bg='#0D47A1', fg='white',
relief='flat', cursor='hand2', padx=10)
search_btn.pack(side='left')
# Main weather display
self.weather_frame = tk.Frame(self.root, bg='#1565C0')
self.weather_frame.pack(fill='both', expand=True, padx=20)
self.city_label = tk.Label(self.weather_frame, text="Enter a city",
font=('Arial', 18, 'bold'), bg='#1565C0', fg='white')
self.city_label.pack(pady=(20, 5))
self.emoji_label = tk.Label(self.weather_frame, text="🌤️",
font=('Arial', 60), bg='#1565C0')
self.emoji_label.pack()
self.temp_label = tk.Label(self.weather_frame, text="--°C",
font=('Arial', 42, 'bold'), bg='#1565C0', fg='white')
self.temp_label.pack()
self.desc_label = tk.Label(self.weather_frame, text="--",
font=('Arial', 16), bg='#1565C0', fg='#BBDEFB')
self.desc_label.pack()
# Details grid
details_frame = tk.Frame(self.root, bg='#1976D2', padx=15, pady=15)
details_frame.pack(fill='x', padx=20, pady=10)
self.detail_labels = {}
details = [
('feels_like', '🌡️ Feels Like', '--'),
('humidity', '💧 Humidity', '--'),
('wind', '🌬️ Wind', '--'),
('visibility', '👁️ Visibility', '--'),
('sunrise', '🌅 Sunrise', '--'),
('sunset', '🌇 Sunset', '--'),
]
for i, (key, label_text, default) in enumerate(details):
row, col = divmod(i, 2)
frame = tk.Frame(details_frame, bg='#1976D2')
frame.grid(row=row, column=col, padx=10, pady=5, sticky='w')
tk.Label(frame, text=label_text, font=('Arial', 10), bg='#1976D2', fg='#BBDEFB').pack()
val_lbl = tk.Label(frame, text=default, font=('Arial', 11, 'bold'), bg='#1976D2', fg='white')
val_lbl.pack()
self.detail_labels[key] = val_lbl
# Status bar
self.status = tk.Label(self.root, text="Ready", font=('Arial', 9),
bg='#0D47A1', fg='white')
self.status.pack(fill='x', side='bottom')
def _search(self):
city = self.city_var.get().strip()
if not city:
messagebox.showwarning("Input Required", "Please enter a city name!")
return
self.status.config(text=f"Loading weather for {city}...")
self.root.update()
# Run in thread to prevent UI freeze
thread = threading.Thread(target=self._fetch_weather, args=(city,), daemon=True)
thread.start()
def _fetch_weather(self, city):
try:
weather = self.api.get_current_weather(city)
self.root.after(0, lambda: self._update_display(weather))
except WeatherAPIError as e:
self.root.after(0, lambda: messagebox.showerror("Error", str(e)))
self.root.after(0, lambda: self.status.config(text="Error loading weather"))
def _update_display(self, weather):
emoji = get_weather_emoji(weather['description'], weather['icon'])
self.city_label.config(text=f"{weather['city']}, {weather['country']}")
self.emoji_label.config(text=emoji)
self.temp_label.config(text=f"{weather['temp']}°C")
self.desc_label.config(text=weather['description'])
self.detail_labels['feels_like'].config(text=f"{weather['feels_like']}°C")
self.detail_labels['humidity'].config(text=f"{weather['humidity']}%")
self.detail_labels['wind'].config(text=f"{weather['wind_speed']} km/h")
self.detail_labels['visibility'].config(text=f"{weather['visibility']:.1f} km")
self.detail_labels['sunrise'].config(text=weather['sunrise'])
self.detail_labels['sunset'].config(text=weather['sunset'])
self.status.config(text=f"Updated: {weather['timestamp']}")
if __name__ == '__main__':
root = tk.Tk()
app = WeatherGUI(root)
root.mainloop()
Summary
In this project, you built:
- ✅ API client with caching and error handling
- ✅ Current weather display (CLI)
- ✅ 5-day forecast display
- ✅ Matplotlib weather dashboard
- ✅ tkinter GUI with live data
Key skills practiced:
requests library for HTTP API calls- JSON parsing and data extraction
- API error handling (network errors, auth, rate limits)
- File caching to avoid unnecessary API calls
- Threading to keep GUI responsive
- Matplotlib data visualization from API data
*Next Project: Face Detection →*