#!/usr/bin/env python3 import datetime import json import requests import statistics import sys # TODO # - snowfall data # - weather warnings ### CONSTANTS ### # api key - get it at https://openweathermap.org/ API_KEY = None # latitude and longitude of the city you want to query # can be obtained through `./weather.py geocoding ` LATITUDE = None LONGITUDE = None # waybar colors GRAY = '#859289' DARK = '#5a6772' GREEN = '#a7c080' YELLOW = '#dbbc7f' ORANGE = '#e69875' RED = '#e67e80' PURPLE = '#d699b6' BLUE = '#7fbbb3' ### UTILITIES ### def print_error(msg: str): ''' print an error message with appropriate prefix. @param str: the message to print ''' print('\x1b[90m[\x1b[31merr\x1b[90m]\x1b[0m', msg) def print_help(): ''' print help message, to be used for the `--help` flag and as response to incorrect usage ''' print('usage: \x1b[33m./weather.py [options]\x1b[0m') print() print('available subcommands:') print(' - \x1b[32mgeocoding\x1b[0m : search for city to get its coordinates') print(' - \x1b[32mcurrent\x1b[0m : print current weather information') print(' - \x1b[32mforecast[-daily]\x1b[0m : print forecast for the next ~5 days') print(' - \x1b[32mforecast-detail\x1b[0m : print detailed forecast in 3h intervals') print(' - \x1b[32mwaybar\x1b[0m : get output for usage with waybar') def make_request(call: str) -> list | dict: ''' make a request to an openweathermap api and returns the response as either a list or a dictionary. the api key is added automatically. quits on error. @param call: the api path, e.g. `data/2.5/weather?...` @return response of the api request as a list or dict ''' try: req = requests.get(f'https://api.openweathermap.org/{call}&appid={API_KEY}') return req.json() except: print_error(f'failed to make request to `/{call}`') quit(1) def get_wind_direction(deg: int) -> str: ''' turn a wind direction specified by meteorological degrees into a human-readable form @param deg: degrees. expected to be in range [0..360] @return human-readable form (e.g. 'NE' for `deg == 45`) ''' if deg < 22.5: return 'N' if deg < 67.5: return 'NE' if deg < 112.5: return 'E' if deg < 157.5: return 'SE' if deg < 202.5: return 'S' if deg < 247.5: return 'SW' if deg < 292.5: return 'W' if deg < 337.5: return 'NW' else: return 'N' def print_entry(label: str, content: str, indent: int = 0, label_width: int = 8): ''' print an 'entry' that consists of a label (printed in gray) and some content. the labels are automatically filled with whitespace to align multiple lines properly. @param label: the label of the line @param content: content, printed after the label @param indent: number of spaces to indent with @param label_width: width of label ''' label_with_whitespace = label.ljust(label_width) print(f'{" " * indent}\x1b[90m{label_with_whitespace}\x1b[0m {content}') def get_weekday(date: str) -> str: ''' get weekday from date @param date: date as ISO-8601-formatted string (YYYY-mm-dd) @return weekday as lowercase string (e.g. 'monday') ''' return datetime.datetime.strptime(date, '%Y-%m-%d').strftime('%A').lower() def colorize(text: str, color: str) -> str: ''' wrap `text` with pango markup to colorize it for usage with waybar. @param text: the text to colorize @param color: the color as string ('#rrggbb') ''' return f'{text}' def waybar_entry(label: str, content: str, indent: int = 2, label_width: int = 9): ''' create an 'entry' for a waybar tooltip that consists of a label (printed in gray) and some content. the labels are automatically filled with whitespace to align multiple lines properly. @param label: the label of the line @param content: content, printed after the label @param indent: number of spaces to indent with @param label_width: width of label @return the entry for use within waybar ''' label_with_whitespace = label.ljust(label_width) return f'{" " * indent}{colorize(label_with_whitespace, GRAY)} {content}\n' ### GEOCODING ### def geocoding(search: str): ''' call openweathermap's geocoding api to find the coordinates of cities. can be used to find the values required for the `LATITUDE` and `LONGITUDE` constants within this script. the results are printed. @param: search term in the format 'city[,state][,country]' ''' res = make_request(f'geo/1.0/direct?q={search}&limit=5') num_results = len(res) if (num_results == 0): print_error('no results found') else: print(f'found {num_results} result{"" if num_results == 1 else "s"}:') for entry in res: # obtain data name = entry['name'] state = entry['state'] if 'state' in entry.keys() else None country = entry['country'] latitude = entry['lat'] longitude = entry['lon'] # print data print(f' - \x1b[32m{name}\x1b[90m', f'({f"{state}, " if state else ""}{country})\x1b[0m:', f'latitude = \x1b[35m{latitude}\x1b[0m,', f'longitude = \x1b[35m{longitude}\x1b[0m') ### CURRENT WEATHER ### def get_current_weather_data() -> dict: ''' get current weather data from openweathermap api. when the `rain` is not set (due to there not being any rain), it will be added with a rain amount of 0 mm over the last hour. @return api response ''' res = make_request(f'data/2.5/weather?lat={LATITUDE}&lon={LONGITUDE}&units=metric') if 'rain' not in res.keys(): res['rain'] = { '1h': 0 } return res def current_weather(): ''' print current weather information ''' data = get_current_weather_data() # collect relevant data weather = data['weather'][0]['description'].lower() temperature = round(data['main']['temp'], 1) temperature_felt = round(data['main']['feels_like'], 1) humidity = data['main']['humidity'] wind_speed = round(data['wind']['speed'], 1) wind_direction = get_wind_direction(data['wind']['deg']) rainfall = data['rain']['1h'] # print data print_entry('weather', f'\x1b[32m{weather}\x1b[0m') print_entry('temp', f'\x1b[33m{temperature} °C\x1b[90m, feels like \x1b[33m{temperature_felt} °C\x1b[0m') print_entry('humidity', f'\x1b[31m{humidity} % RH\x1b[0m') print_entry('wind', f'\x1b[35m{wind_speed} m/s\x1b[90m ({wind_direction})\x1b[0m') print_entry('rain', f'\x1b[34m{rainfall} mm\x1b[0m') ### FORECAST ### def get_forecast_data() -> dict: ''' get forecast data for the next ~5 days from the openweathermap api, with data points separated by 3 hours. the data is grouped by date (the api does not group the data by default and instead sends it as one sequence). @return api response grouped by date ''' res = make_request(f'data/2.5/forecast?lat={LATITUDE}&lon={LONGITUDE}&units=metric') days = dict() for i in res['list']: day = i['dt_txt'].split(' ')[0] if day not in days.keys(): days[day] = list() if 'rain' not in i.keys(): i['rain'] = { '3h': 0 } days[day].append(i) return days def get_daily_forecast_data() -> dict[dict]: ''' obtain forecast data for the next ~5 days, where values are grouped by day. since the api only provides the data in 3h intervals, the properties of different data points are combined in order to provide appropriate data for each day. @return the processed data as a dict with key = date and value = data as another dict ''' res = get_forecast_data() output = dict() for day in sorted(res): data = res[day] number_of_data_points = len(data) # collect relevant from data for each day temperatures = [i['main']['temp'] for i in data] weather_descriptions = [i['weather'][0]['description'].lower() for i in data] humidity = [i['main']['humidity'] for i in data] rainfall = [i['rain']['3h'] for i in data] precipitation_prob = [i['pop'] for i in data] wind_speeds = [i['wind']['speed'] for i in data] weekday = get_weekday(day) # min and max temperature for the day min_temperature = round(min(temperatures)) max_temperature = round(max(temperatures)) # obtain the average weather by finding the weather description with the highest number of occurances in `weather_descriptions` weather_count = {i: weather_descriptions.count(i) for i in set(weather_descriptions)} weather_count_max = max(weather_count.values()) weather_average = tuple(filter(lambda x: weather_count[x] == weather_count_max, weather_count.keys()))[0] # humidiy humidity_average = round(statistics.mean(humidity)) # total rainfall and probability of precipitation. also, estimate the total rainfall if not all data points for a day are available rainfall_total = round(sum(rainfall), 1) rainfall_total_estimated = round(rainfall_total / number_of_data_points * 8, 1) max_precipitation_prob = round(max(precipitation_prob) * 100) # average wind wind_average = round(statistics.mean(wind_speeds), 1) output[day] = { 'number_of_data_points': number_of_data_points, 'min_temperature': min_temperature, 'max_temperature': max_temperature, 'weather_average': weather_average, 'humidity_average': humidity_average, 'rainfall_total': rainfall_total, 'rainfall_total_estimated': rainfall_total_estimated, 'max_precipitation_prob': max_precipitation_prob, 'wind_average': wind_average, 'weekday': weekday, } return output def daily_forecast(): ''' print forecast data for the next ~5 days ''' daily_data = get_daily_forecast_data() for day in sorted(daily_data): data = daily_data[day] # only display data point if at least half of the data points are available if data['number_of_data_points'] < 4: continue # print data print(f'\x1b[1m{day}\x1b[0m ({data["weekday"]}):') print_entry('weather', f'\x1b[32m{data["weather_average"]}\x1b[0m', indent = 2) print_entry('temp', f'\x1b[33m{data["max_temperature"]} °C\x1b[0m / \x1b[33m{data["min_temperature"]} °C\x1b[0m', indent = 2) print_entry('humidity', f'\x1b[31m{data["humidity_average"]} % RH\x1b[0m', indent = 2) print_entry('wind', f'\x1b[35m{data["wind_average"]} m/s\x1b[0m', indent = 2) print_entry('rain', f'\x1b[34m{data["rainfall_total_estimated"]} mm\x1b[0m' + f'\x1b[90m{f""" ({data["max_precipitation_prob"]}%)""" if data["max_precipitation_prob"] > 0 else ""}' + f'{f" (estimated)" if data["rainfall_total"] != data["rainfall_total_estimated"] else ""}\x1b[0m\n', indent = 2) ### DETAILED FORECAST ### def detailed_forecast(): ''' print forecast data for the next ~5 days, where values are printed for every 3h interval provided by the api. some data (e.g. humidity or wind speeds) are omitted. ''' res = get_forecast_data() for day in sorted(res): weekday = get_weekday(day) print(f'\x1b[1m{day}\x1b[0m ({weekday})') for entry in res[day]: # collect data weather = entry['weather'][0]['description'].lower() temperature = round(entry['main']['temp'], 1) rainfall = entry['rain']['3h'] precipitation_prob = round(entry['pop'] * 100) time = f"{int(entry['dt_txt'].split(' ')[1].split(':')[0]):2}h" # print data output = '' output += f'\x1b[33m{temperature:4} °C\x1b[90m, ' output += f'\x1b[32m{weather}\x1b[0m' if rainfall > 0: output += f'\x1b[90m: \x1b[34m{rainfall} mm \x1b[90m({precipitation_prob}%)\x1b[0m' print_entry(time, output, indent = 2, label_width = 3) print() ### WAYBAR ### def waybar_widget(data: dict) -> str: ''' get the widget component of the waybar output. contains the current weather group and temperature. @param current weather data @return widget component ''' weather = data['weather'][0]['main'].lower() temperature = round(data['main']['temp']) return f'{colorize(weather, DARK)} {temperature}°' def waybar_current(data: dict) -> str: ''' get the current weather overview for the tooltip of the waybar output. @param current weather data @return formatted current weather overview ''' # retrieve relevant data weather = data['weather'][0]['description'].lower() temperature = round(data['main']['temp'], 1) temperature_felt = round(data['main']['feels_like'], 1) humidity = data['main']['humidity'] wind_speed = round(data['wind']['speed'], 1) wind_direction = get_wind_direction(data['wind']['deg']) rainfall = data['rain']['1h'] # generate output output = '' output += waybar_entry('weather', colorize(weather, YELLOW)) output += waybar_entry('temp', f'{colorize(f"{temperature} °C", ORANGE)}{colorize(", feels like ", GRAY)}{colorize(f"{temperature_felt} °C", ORANGE)}') output += waybar_entry('humidity', colorize(f'{humidity} % RH', RED)) output += waybar_entry('wind', f'{colorize(f"{wind_speed} m/s", PURPLE)} {colorize(f"({wind_direction})", GRAY)}') output += waybar_entry('rain', colorize(f"{rainfall} mm", BLUE)) return output def waybar_forecast(data: dict) -> str: ''' get the daily forecast for the tooltip of the waybar output. @param forecast weather data @return formatted daily forecast ''' output = '' daily_data = get_daily_forecast_data() for day in sorted(daily_data): data = daily_data[day] line_content = colorize(f'{data["max_temperature"]:2}°', ORANGE) + \ colorize(' / ', GRAY) + \ colorize(f'{data["min_temperature"]:2}°', ORANGE) + \ colorize(', ', GRAY) + \ colorize(data['weather_average'], YELLOW) if data['rainfall_total_estimated'] > 0: line_content += colorize(': ', GRAY) + \ colorize(f'{data["rainfall_total_estimated"]} mm ', BLUE) + \ colorize(f'({data["max_precipitation_prob"]}%)', GRAY) output += waybar_entry(data['weekday'], line_content) return output.rstrip() def waybar(): ''' get current and forecast weather data and output it formatted in a way that allows it to be included as a widget in waybar. only shows weather category and temperature in the widget, but reveals detailed weather information and a ~5 day forecast in the tooltip. ''' current_data = get_current_weather_data() forecast_data = get_forecast_data() widget = waybar_widget(current_data) current = waybar_current(current_data) forecast = waybar_forecast(forecast_data) tooltip = colorize('current weather', GREEN) + '\n' + current + '\n' + \ colorize('forecast', GREEN) + '\n' + forecast print(json.dumps({ 'text': widget, 'tooltip': tooltip, })) ### MAIN ### def main(): ''' main function ''' # no parameters or `--help` if len(sys.argv) == 1 or sys.argv[1] in ('help', '-h', '--help'): print_help() # >= 1 parameter provided else: # geocoding if sys.argv[1] == 'geocoding': if len(sys.argv) == 3: geocoding(sys.argv[2]) else: print_error('expected argument ``') print_help() # constants not set elif API_KEY is None or LATITUDE is None or LONGITUDE is None: print_error('please modify the constants within the script before use.') # current weather elif sys.argv[1] == 'current': current_weather() # daily forecast elif sys.argv[1] == 'forecast' or sys.argv[1] == 'forecast-daily': daily_forecast() # detailed forecast elif sys.argv[1] == 'forecast-detail': detailed_forecast() # waybar elif sys.argv[1] == 'waybar': waybar() # unknown command else: print_error(f'unknown command `{sys.argv[1]}`') print_help() if __name__ == '__main__': main()