From 38a1d0d98e5da17feafe30349ce798739b7243b9 Mon Sep 17 00:00:00 2001 From: rattatwinko Date: Sun, 1 Jun 2025 15:57:40 +0200 Subject: [PATCH] initial --- .gitea/workflows/exec.yaml | 36 +++ main.py | 538 +++++++++++++++++++++++++++++++++++++ requirements.txt | 4 + weather_icon.svg | 5 + 4 files changed, 583 insertions(+) create mode 100644 .gitea/workflows/exec.yaml create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 weather_icon.svg diff --git a/.gitea/workflows/exec.yaml b/.gitea/workflows/exec.yaml new file mode 100644 index 0000000..f67c551 --- /dev/null +++ b/.gitea/workflows/exec.yaml @@ -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/ \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..36a36ab --- /dev/null +++ b/main.py @@ -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_()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1f9344a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PyQt5>=5.15.0 +PyQt5-Qt5>=5.15.0 +PyQt5-sip>=12.8.0 +requests>=2.20.0 diff --git a/weather_icon.svg b/weather_icon.svg new file mode 100644 index 0000000..97ef80a --- /dev/null +++ b/weather_icon.svg @@ -0,0 +1,5 @@ + + + + +