from __future__ import annotations import httpx from typing import Any, Dict, List, Optional WEATHER_ICONS: Dict[int, str] = { 113: "\u2600\ufe0f", # Clear/Sunny 116: "\u26c5", # Partly Cloudy 119: "\u2601\ufe0f", # Cloudy 122: "\u2601\ufe0f", # Overcast 143: "\ud83c\udf2b\ufe0f", # Mist 176: "\ud83c\udf26\ufe0f", # Patchy rain nearby 179: "\ud83c\udf28\ufe0f", # Patchy snow nearby 182: "\ud83c\udf28\ufe0f", # Patchy sleet nearby 185: "\ud83c\udf28\ufe0f", # Patchy freezing drizzle nearby 200: "\u26c8\ufe0f", # Thundery outbreaks nearby 227: "\ud83c\udf28\ufe0f", # Blowing snow 230: "\u2744\ufe0f", # Blizzard 248: "\ud83c\udf2b\ufe0f", # Fog 260: "\ud83c\udf2b\ufe0f", # Freezing fog 263: "\ud83c\udf26\ufe0f", # Patchy light drizzle 266: "\ud83c\udf27\ufe0f", # Light drizzle 281: "\ud83c\udf28\ufe0f", # Freezing drizzle 284: "\ud83c\udf28\ufe0f", # Heavy freezing drizzle 293: "\ud83c\udf26\ufe0f", # Patchy light rain 296: "\ud83c\udf27\ufe0f", # Light rain 299: "\ud83c\udf27\ufe0f", # Moderate rain at times 302: "\ud83c\udf27\ufe0f", # Moderate rain 305: "\ud83c\udf27\ufe0f", # Heavy rain at times 308: "\ud83c\udf27\ufe0f", # Heavy rain 311: "\ud83c\udf28\ufe0f", # Light freezing rain 314: "\ud83c\udf28\ufe0f", # Moderate or heavy freezing rain 317: "\ud83c\udf28\ufe0f", # Light sleet 320: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet 323: "\ud83c\udf28\ufe0f", # Patchy light snow 326: "\u2744\ufe0f", # Light snow 329: "\u2744\ufe0f", # Patchy moderate snow 332: "\u2744\ufe0f", # Moderate snow 335: "\u2744\ufe0f", # Patchy heavy snow 338: "\u2744\ufe0f", # Heavy snow 350: "\ud83c\udf28\ufe0f", # Ice pellets 353: "\ud83c\udf26\ufe0f", # Light rain shower 356: "\ud83c\udf27\ufe0f", # Moderate or heavy rain shower 359: "\ud83c\udf27\ufe0f", # Torrential rain shower 362: "\ud83c\udf28\ufe0f", # Light sleet showers 365: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet showers 368: "\u2744\ufe0f", # Light snow showers 371: "\u2744\ufe0f", # Moderate or heavy snow showers 374: "\ud83c\udf28\ufe0f", # Light showers of ice pellets 377: "\ud83c\udf28\ufe0f", # Moderate or heavy showers of ice pellets 386: "\u26c8\ufe0f", # Patchy light rain with thunder 389: "\u26c8\ufe0f", # Moderate or heavy rain with thunder 392: "\u26c8\ufe0f", # Patchy light snow with thunder 395: "\u26c8\ufe0f", # Moderate or heavy snow with thunder } def _get_weather_icon(code: int) -> str: """Map a WWO weather code to an emoji icon.""" return WEATHER_ICONS.get(code, "\ud83c\udf24\ufe0f") def _parse_current_condition(condition: Dict[str, Any], location: str) -> Dict[str, Any]: """Parse a single current_condition entry from the wttr.in JSON.""" weather_code = int(condition.get("weatherCode", 113)) descriptions = condition.get("weatherDesc", []) description = descriptions[0].get("value", "Unknown") if descriptions else "Unknown" return { "location": location, "temp": int(condition.get("temp_C", 0)), "feels_like": int(condition.get("FeelsLikeC", 0)), "humidity": int(condition.get("humidity", 0)), "wind_kmh": int(condition.get("windspeedKmph", 0)), "description": description, "icon": _get_weather_icon(weather_code), } def _parse_forecast_day(day: Dict[str, Any]) -> Dict[str, Any]: """Parse a single forecast day from the wttr.in weather array.""" date = day.get("date", "") max_temp = int(day.get("maxtempC", 0)) min_temp = int(day.get("mintempC", 0)) astronomy = day.get("astronomy", []) sunrise = astronomy[0].get("sunrise", "") if astronomy else "" sunset = astronomy[0].get("sunset", "") if astronomy else "" hourly = day.get("hourly", []) if hourly: midday = hourly[len(hourly) // 2] weather_code = int(midday.get("weatherCode", 113)) descs = midday.get("weatherDesc", []) description = descs[0].get("value", "Unknown") if descs else "Unknown" else: weather_code = 113 description = "Unknown" return { "date": date, "max_temp": max_temp, "min_temp": min_temp, "icon": _get_weather_icon(weather_code), "description": description, "sunrise": sunrise, "sunset": sunset, } async def fetch_weather(location: str) -> Dict[str, Any]: """Fetch current weather and 3-day forecast from wttr.in. Args: location: City name or coordinates (e.g. "Berlin" or "52.52,13.405"). Returns: Dictionary with current conditions and 3-day forecast. """ fallback: Dict[str, Any] = { "location": location, "temp": 0, "feels_like": 0, "humidity": 0, "wind_kmh": 0, "description": "Unavailable", "icon": "\u2753", "forecast_3day": [], "error": None, } try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"https://wttr.in/{location}", params={"format": "j1"}, headers={"Accept": "application/json"}, ) resp.raise_for_status() data = resp.json() except httpx.HTTPStatusError as exc: fallback["error"] = f"HTTP {exc.response.status_code}" return fallback except httpx.RequestError as exc: fallback["error"] = f"Request failed: {exc}" return fallback except Exception as exc: fallback["error"] = str(exc) return fallback current_conditions = data.get("current_condition", []) if not current_conditions: fallback["error"] = "No current condition data" return fallback result = _parse_current_condition(current_conditions[0], location) weather_days = data.get("weather", []) forecast_3day: List[Dict[str, Any]] = [] for day in weather_days[:3]: forecast_3day.append(_parse_forecast_day(day)) result["forecast_3day"] = forecast_3day result["error"] = None return result async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]: """Fetch hourly forecast for the current day from wttr.in. Returns the next 8 hourly slots from the current day's forecast. Args: location: City name or coordinates. Returns: List of hourly forecast dicts with time, temp, icon, and precip_chance. """ try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( f"https://wttr.in/{location}", params={"format": "j1"}, headers={"Accept": "application/json"}, ) resp.raise_for_status() data = resp.json() except Exception: return [] weather_days = data.get("weather", []) if not weather_days: return [] all_hourly: List[Dict[str, Any]] = [] for day in weather_days[:2]: hourly_entries = day.get("hourly", []) for entry in hourly_entries: time_raw = entry.get("time", "0") time_value = int(time_raw) hours = time_value // 100 minutes = time_value % 100 time_str = f"{hours:02d}:{minutes:02d}" weather_code = int(entry.get("weatherCode", 113)) descs = entry.get("weatherDesc", []) description = descs[0].get("value", "Unknown") if descs else "Unknown" all_hourly.append({ "time": time_str, "temp": int(entry.get("tempC", 0)), "icon": _get_weather_icon(weather_code), "description": description, "precip_chance": int(entry.get("chanceofrain", 0)), "wind_kmh": int(entry.get("windspeedKmph", 0)), }) from datetime import datetime now_hour = datetime.now().hour upcoming: List[Dict[str, Any]] = [] found_start = False for slot in all_hourly: slot_hour = int(slot["time"].split(":")[0]) if not found_start: if slot_hour >= now_hour: found_start = True else: continue upcoming.append(slot) if len(upcoming) >= 8: break return upcoming