539 lines
19 KiB
Python
539 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Advanced Weather App using Python and PyQt5
|
|
Production-ready weather application with caching, error handling, and beautiful UI
|
|
Uses Open-Meteo API (completely free, no API key required)
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import pickle
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple, Dict, Any
|
|
|
|
import requests
|
|
from PyQt5.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QLineEdit, QPushButton, QMessageBox, QFrame,
|
|
QScrollArea, QGridLayout, QProgressBar, QSystemTrayIcon, QMenu
|
|
)
|
|
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
|
|
from PyQt5.QtGui import QFont, QPixmap, QPalette, QColor, QIcon
|
|
|
|
# Setup logging
|
|
logging.basicConfig(
|
|
filename='weather_app.log',
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
)
|
|
|
|
class WeatherAPI:
|
|
"""Handle all weather API interactions with Open-Meteo"""
|
|
|
|
BASE_URL = "https://api.open-meteo.com/v1/forecast"
|
|
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
|
|
|
@staticmethod
|
|
def get_coordinates(city_name: str) -> Optional[Tuple[float, float, str, str]]:
|
|
"""Get coordinates for a city name using Open-Meteo geocoding"""
|
|
try:
|
|
params = {
|
|
"name": city_name,
|
|
"count": 1,
|
|
"language": "en",
|
|
"format": "json"
|
|
}
|
|
response = requests.get(WeatherAPI.GEOCODING_URL, params=params, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
results = data.get("results", [])
|
|
|
|
if results:
|
|
location = results[0]
|
|
return (
|
|
location['latitude'],
|
|
location['longitude'],
|
|
location['name'],
|
|
location.get('country', 'Unknown')
|
|
)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logging.error(f"Geocoding error for {city_name}: {e}")
|
|
raise Exception(f"Could not find location: {city_name}")
|
|
|
|
@staticmethod
|
|
def get_weather_data(lat: float, lon: float) -> Dict[str, Any]:
|
|
"""Get current weather and 7-day forecast"""
|
|
try:
|
|
params = {
|
|
"latitude": lat,
|
|
"longitude": lon,
|
|
"current_weather": "true",
|
|
"daily": "temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max",
|
|
"timezone": "auto",
|
|
"forecast_days": 7
|
|
}
|
|
|
|
response = requests.get(WeatherAPI.BASE_URL, params=params, timeout=15)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
except Exception as e:
|
|
logging.error(f"Weather API error for {lat}, {lon}: {e}")
|
|
raise Exception("Failed to fetch weather data")
|
|
|
|
class WeatherWorker(QThread):
|
|
"""Background thread for API calls"""
|
|
data_ready = pyqtSignal(dict, str, str)
|
|
error_occurred = pyqtSignal(str)
|
|
|
|
def __init__(self, city_name: str):
|
|
super().__init__()
|
|
self.city_name = city_name
|
|
|
|
def run(self):
|
|
try:
|
|
# Get coordinates
|
|
coords = WeatherAPI.get_coordinates(self.city_name)
|
|
if not coords:
|
|
self.error_occurred.emit(f"Location '{self.city_name}' not found")
|
|
return
|
|
|
|
lat, lon, city, country = coords
|
|
|
|
# Get weather data
|
|
weather_data = WeatherAPI.get_weather_data(lat, lon)
|
|
self.data_ready.emit(weather_data, city, country)
|
|
|
|
except Exception as e:
|
|
self.error_occurred.emit(str(e))
|
|
|
|
class WeatherCache:
|
|
"""Handle caching of weather data"""
|
|
|
|
def __init__(self, cache_dir: str = "weather_cache"):
|
|
self.cache_dir = Path(cache_dir)
|
|
self.cache_dir.mkdir(exist_ok=True)
|
|
self.cache_duration = timedelta(minutes=30) # Cache for 30 minutes
|
|
|
|
def get_cache_key(self, city: str) -> str:
|
|
return f"{city.lower().replace(' ', '_')}.pkl"
|
|
|
|
def save_data(self, city: str, data: Dict[str, Any]):
|
|
"""Save weather data to cache"""
|
|
try:
|
|
cache_data = {
|
|
'timestamp': datetime.now(),
|
|
'data': data
|
|
}
|
|
cache_file = self.cache_dir / self.get_cache_key(city)
|
|
with open(cache_file, 'wb') as f:
|
|
pickle.dump(cache_data, f)
|
|
except Exception as e:
|
|
logging.error(f"Cache save error: {e}")
|
|
|
|
def load_data(self, city: str) -> Optional[Dict[str, Any]]:
|
|
"""Load weather data from cache if recent enough"""
|
|
try:
|
|
cache_file = self.cache_dir / self.get_cache_key(city)
|
|
if not cache_file.exists():
|
|
return None
|
|
|
|
with open(cache_file, 'rb') as f:
|
|
cache_data = pickle.load(f)
|
|
|
|
# Check if cache is still valid
|
|
if datetime.now() - cache_data['timestamp'] < self.cache_duration:
|
|
return cache_data['data']
|
|
else:
|
|
cache_file.unlink() # Remove expired cache
|
|
return None
|
|
|
|
except Exception as e:
|
|
logging.error(f"Cache load error: {e}")
|
|
return None
|
|
|
|
class WeatherCard(QFrame):
|
|
"""Widget for displaying daily weather forecast"""
|
|
|
|
def __init__(self, date: str, temp_max: float, temp_min: float, weather_code: int):
|
|
super().__init__()
|
|
self.setFrameStyle(QFrame.StyledPanel)
|
|
self.setStyleSheet("""
|
|
QFrame {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
border-radius: 10px;
|
|
padding: 10px;
|
|
margin: 5px;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout()
|
|
|
|
# Date
|
|
date_label = QLabel(date)
|
|
date_label.setAlignment(Qt.AlignCenter)
|
|
date_label.setFont(QFont("Segoe UI", 10, QFont.Bold))
|
|
|
|
# Weather icon (text representation)
|
|
icon_label = QLabel(self.get_weather_icon(weather_code))
|
|
icon_label.setAlignment(Qt.AlignCenter)
|
|
icon_label.setFont(QFont("Segoe UI Emoji", 24))
|
|
|
|
# Temperature
|
|
temp_label = QLabel(f"{int(temp_max)}° / {int(temp_min)}°")
|
|
temp_label.setAlignment(Qt.AlignCenter)
|
|
temp_label.setFont(QFont("Segoe UI", 11))
|
|
|
|
layout.addWidget(date_label)
|
|
layout.addWidget(icon_label)
|
|
layout.addWidget(temp_label)
|
|
|
|
self.setLayout(layout)
|
|
self.setFixedSize(120, 150)
|
|
|
|
@staticmethod
|
|
def get_weather_icon(weather_code: int) -> str:
|
|
"""Map weather codes to emoji icons"""
|
|
icons = {
|
|
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
|
|
95: "⛈️", # Thunderstorm
|
|
96: "⛈️", # Thunderstorm with hail
|
|
99: "⛈️" # Heavy thunderstorm with hail
|
|
}
|
|
return icons.get(weather_code, "🌡️")
|
|
|
|
class WeatherApp(QMainWindow):
|
|
"""Main weather application window"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.cache = WeatherCache()
|
|
self.current_city = None
|
|
self.setup_ui()
|
|
self.setup_tray()
|
|
self.setup_auto_refresh()
|
|
|
|
def setup_ui(self):
|
|
"""Setup the main user interface"""
|
|
self.setWindowTitle("Advanced Weather App")
|
|
self.setGeometry(100, 100, 800, 600)
|
|
self.setStyleSheet("""
|
|
QMainWindow {
|
|
background-color: #1e1e2f;
|
|
}
|
|
QLabel {
|
|
color: #f0f0f0;
|
|
font-family: 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif;
|
|
}
|
|
QLineEdit {
|
|
padding: 10px;
|
|
border-radius: 8px;
|
|
border: 1px solid #555;
|
|
background-color: #2c2c3c;
|
|
color: #f0f0f0;
|
|
font-size: 14px;
|
|
}
|
|
QPushButton {
|
|
padding: 10px 20px;
|
|
border-radius: 8px;
|
|
border: none;
|
|
background-color: #5e60ce;
|
|
color: white;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #4ea8de;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #3a86ff;
|
|
}
|
|
QProgressBar {
|
|
border-radius: 5px;
|
|
height: 12px;
|
|
background-color: #2e2e38;
|
|
}
|
|
QProgressBar::chunk {
|
|
background-color: #5e60ce;
|
|
border-radius: 5px;
|
|
}
|
|
QScrollArea {
|
|
border: none;
|
|
}
|
|
""")
|
|
|
|
|
|
central_widget = QWidget()
|
|
self.setCentralWidget(central_widget)
|
|
|
|
main_layout = QVBoxLayout()
|
|
|
|
# Search section
|
|
search_layout = QHBoxLayout()
|
|
self.city_input = QLineEdit()
|
|
self.city_input.setPlaceholderText("Enter city name...")
|
|
self.city_input.returnPressed.connect(self.search_weather)
|
|
|
|
self.search_button = QPushButton("Search")
|
|
self.search_button.clicked.connect(self.search_weather)
|
|
|
|
search_layout.addWidget(self.city_input)
|
|
search_layout.addWidget(self.search_button)
|
|
|
|
# Progress bar
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setVisible(False)
|
|
|
|
# Current weather section
|
|
self.current_weather_frame = QFrame()
|
|
self.current_weather_frame.setStyleSheet("""
|
|
QFrame {
|
|
background-color: #2e2e3e;
|
|
border-radius: 12px;
|
|
padding: 15px;
|
|
color: #f0f0f0;
|
|
}
|
|
""")
|
|
|
|
current_layout = QVBoxLayout()
|
|
|
|
self.location_label = QLabel("Search for a city to see weather")
|
|
self.location_label.setAlignment(Qt.AlignCenter)
|
|
self.location_label.setFont(QFont("Arial", 18, QFont.Bold))
|
|
|
|
self.temperature_label = QLabel("")
|
|
self.temperature_label.setAlignment(Qt.AlignCenter)
|
|
self.temperature_label.setFont(QFont("Arial", 48, QFont.Bold))
|
|
|
|
self.weather_icon_label = QLabel("")
|
|
self.weather_icon_label.setAlignment(Qt.AlignCenter)
|
|
self.weather_icon_label.setFont(QFont("Arial", 60))
|
|
|
|
self.description_label = QLabel("")
|
|
self.description_label.setAlignment(Qt.AlignCenter)
|
|
self.description_label.setFont(QFont("Arial", 16))
|
|
|
|
current_layout.addWidget(self.location_label)
|
|
current_layout.addWidget(self.weather_icon_label)
|
|
current_layout.addWidget(self.temperature_label)
|
|
current_layout.addWidget(self.description_label)
|
|
|
|
self.current_weather_frame.setLayout(current_layout)
|
|
|
|
# Forecast section
|
|
forecast_label = QLabel("7-Day Forecast")
|
|
forecast_label.setAlignment(Qt.AlignCenter)
|
|
forecast_label.setFont(QFont("Arial", 16, QFont.Bold))
|
|
|
|
# Scrollable forecast area
|
|
scroll_area = QScrollArea()
|
|
scroll_widget = QWidget()
|
|
self.forecast_layout = QHBoxLayout()
|
|
scroll_widget.setLayout(self.forecast_layout)
|
|
scroll_area.setWidget(scroll_widget)
|
|
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
scroll_area.setFixedHeight(200)
|
|
|
|
# Add all widgets to main layout
|
|
main_layout.addLayout(search_layout)
|
|
main_layout.addWidget(self.progress_bar)
|
|
main_layout.addWidget(self.current_weather_frame)
|
|
main_layout.addWidget(forecast_label)
|
|
main_layout.addWidget(scroll_area)
|
|
|
|
central_widget.setLayout(main_layout)
|
|
|
|
# Status bar
|
|
self.statusBar().showMessage("Ready")
|
|
|
|
def setup_tray(self):
|
|
"""Setup system tray icon"""
|
|
if QSystemTrayIcon.isSystemTrayAvailable():
|
|
self.tray_icon = QSystemTrayIcon(self)
|
|
self.tray_icon.setIcon(self.style().standardIcon(self.style().SP_ComputerIcon))
|
|
|
|
tray_menu = QMenu(self)
|
|
show_action = tray_menu.addAction("Show")
|
|
show_action.triggered.connect(self.show)
|
|
quit_action = tray_menu.addAction("Quit")
|
|
quit_action.triggered.connect(self.close)
|
|
|
|
self.tray_icon.setContextMenu(tray_menu)
|
|
self.tray_icon.show()
|
|
|
|
def setup_auto_refresh(self):
|
|
"""Setup automatic weather refresh every 30 minutes"""
|
|
self.refresh_timer = QTimer()
|
|
self.refresh_timer.timeout.connect(self.auto_refresh_weather)
|
|
self.refresh_timer.start(30 * 60 * 1000) # 30 minutes
|
|
|
|
def auto_refresh_weather(self):
|
|
"""Automatically refresh weather for current city"""
|
|
if self.current_city:
|
|
self.search_weather()
|
|
|
|
def search_weather(self):
|
|
"""Search for weather data"""
|
|
city = self.city_input.text().strip()
|
|
if not city:
|
|
QMessageBox.warning(self, "Warning", "Please enter a city name")
|
|
return
|
|
|
|
# Check cache first
|
|
cached_data = self.cache.load_data(city)
|
|
if cached_data:
|
|
self.display_weather(cached_data, city, "")
|
|
self.statusBar().showMessage(f"Showing cached data for {city}")
|
|
return
|
|
|
|
# Show loading state
|
|
self.progress_bar.setVisible(True)
|
|
self.search_button.setEnabled(False)
|
|
self.statusBar().showMessage(f"Searching weather for {city}...")
|
|
|
|
# Start background API call
|
|
self.worker = WeatherWorker(city)
|
|
self.worker.data_ready.connect(self.on_weather_data_ready)
|
|
self.worker.error_occurred.connect(self.on_weather_error)
|
|
self.worker.start()
|
|
|
|
def on_weather_data_ready(self, data: Dict[str, Any], city: str, country: str):
|
|
"""Handle successful weather data retrieval"""
|
|
self.progress_bar.setVisible(False)
|
|
self.search_button.setEnabled(True)
|
|
|
|
# Cache the data
|
|
self.cache.save_data(city, data)
|
|
|
|
# Display the weather
|
|
self.display_weather(data, city, country)
|
|
self.current_city = city
|
|
|
|
self.statusBar().showMessage(f"Weather updated for {city}")
|
|
logging.info(f"Weather data retrieved for {city}")
|
|
|
|
def on_weather_error(self, error_message: str):
|
|
"""Handle weather data retrieval errors"""
|
|
self.progress_bar.setVisible(False)
|
|
self.search_button.setEnabled(True)
|
|
|
|
QMessageBox.critical(self, "Error", error_message)
|
|
self.statusBar().showMessage("Error occurred")
|
|
logging.error(f"Weather error: {error_message}")
|
|
|
|
def display_weather(self, data: Dict[str, Any], city: str, country: str):
|
|
"""Display weather data in the UI"""
|
|
try:
|
|
current = data['current_weather']
|
|
daily = data['daily']
|
|
|
|
# Update current weather
|
|
location_text = f"{city}"
|
|
if country:
|
|
location_text += f", {country}"
|
|
self.location_label.setText(location_text)
|
|
|
|
temp = int(current['temperature'])
|
|
self.temperature_label.setText(f"{temp}°C")
|
|
|
|
weather_code = current['weathercode']
|
|
icon = WeatherCard.get_weather_icon(weather_code)
|
|
self.weather_icon_label.setText(icon)
|
|
|
|
# Weather description
|
|
descriptions = {
|
|
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
|
|
45: "Foggy", 48: "Depositing rime fog", 51: "Light drizzle",
|
|
53: "Moderate drizzle", 55: "Dense drizzle", 61: "Light rain",
|
|
63: "Moderate rain", 65: "Heavy rain", 71: "Light snow",
|
|
73: "Moderate snow", 75: "Heavy snow", 95: "Thunderstorm",
|
|
96: "Thunderstorm with hail", 99: "Heavy thunderstorm"
|
|
}
|
|
description = descriptions.get(weather_code, "Unknown")
|
|
wind_speed = current.get('windspeed', 0)
|
|
self.description_label.setText(f"{description} • Wind: {int(wind_speed)} km/h")
|
|
|
|
# Clear existing forecast
|
|
for i in reversed(range(self.forecast_layout.count())):
|
|
child = self.forecast_layout.itemAt(i).widget()
|
|
if child:
|
|
child.setParent(None)
|
|
|
|
# Add forecast cards
|
|
dates = daily['time']
|
|
temp_max = daily['temperature_2m_max']
|
|
temp_min = daily['temperature_2m_min']
|
|
weather_codes = daily['weathercode']
|
|
|
|
for i in range(len(dates)):
|
|
date_str = datetime.fromisoformat(dates[i]).strftime("%a\n%m/%d")
|
|
card = WeatherCard(date_str, temp_max[i], temp_min[i], weather_codes[i])
|
|
self.forecast_layout.addWidget(card)
|
|
|
|
# Update tray tooltip if available
|
|
if hasattr(self, 'tray_icon'):
|
|
self.tray_icon.setToolTip(f"{city}: {temp}°C - {description}")
|
|
|
|
except Exception as e:
|
|
logging.error(f"Display error: {e}")
|
|
QMessageBox.critical(self, "Error", "Failed to display weather data")
|
|
|
|
def main():
|
|
"""Main application entry point"""
|
|
app = QApplication(sys.argv)
|
|
app.setApplicationName("Advanced Weather App")
|
|
app.setQuitOnLastWindowClosed(False) # Keep running in system tray
|
|
|
|
# Set application icon
|
|
app.setWindowIcon(app.style().standardIcon(app.style().SP_ComputerIcon))
|
|
|
|
window = WeatherApp()
|
|
window.show()
|
|
|
|
sys.exit(app.exec_())
|
|
|
|
if __name__ == "__main__":
|
|
app = QApplication(sys.argv)
|
|
app.setStyle("Fusion")
|
|
|
|
# Optional dark Fusion palette
|
|
palette = QPalette()
|
|
palette.setColor(QPalette.Window, QColor(44, 47, 59))
|
|
palette.setColor(QPalette.WindowText, Qt.white)
|
|
palette.setColor(QPalette.Base, QColor(25, 25, 25))
|
|
palette.setColor(QPalette.AlternateBase, QColor(44, 47, 59))
|
|
palette.setColor(QPalette.ToolTipBase, Qt.white)
|
|
palette.setColor(QPalette.ToolTipText, Qt.white)
|
|
palette.setColor(QPalette.Text, Qt.white)
|
|
palette.setColor(QPalette.Button, QColor(70, 70, 90))
|
|
palette.setColor(QPalette.ButtonText, Qt.white)
|
|
palette.setColor(QPalette.BrightText, Qt.red)
|
|
palette.setColor(QPalette.Link, QColor(42, 130, 218))
|
|
palette.setColor(QPalette.Highlight, QColor(42, 130, 218))
|
|
palette.setColor(QPalette.HighlightedText, Qt.black)
|
|
app.setPalette(palette)
|
|
|
|
window = WeatherApp()
|
|
window.show()
|
|
sys.exit(app.exec_())
|