initial
All checks were successful
Build Executable / build-and-package (push) Successful in 53s

This commit is contained in:
rattatwinko
2025-06-01 15:57:40 +02:00
commit 38a1d0d98e
4 changed files with 583 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
name: Build Executable
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install PyInstaller
run: pip install pyinstaller
- name: Build executable with PyInstaller
run: pyinstaller --onefile --windowed --add-data "weather_icon.svg:." main.py
- name: Upload executable artifact
uses: actions/upload-artifact@v3
with:
name: executable
path: dist/

538
main.py Normal file
View File

@@ -0,0 +1,538 @@
#!/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_())

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
PyQt5>=5.15.0
PyQt5-Qt5>=5.15.0
PyQt5-sip>=12.8.0
requests>=2.20.0

5
weather_icon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="32" r="30" fill="#3A3F4B"/>
<path d="M20 38c0-6.6 5.4-12 12-12 4.2 0 8 2.2 10.2 5.8C44.9 32 48 35.1 48 39.3 48 43.9 44.4 48 39.5 48H22c-4.4 0-8-3.6-8-8s3.6-8 8-8z" fill="#FFFFFF"/>
<path d="M30 50l-4 8h8l-4 8" stroke="#FFCC00" stroke-width="3" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 375 B