diff --git a/.gitignore b/.gitignore index 89a63b8..9be1197 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/* *~ .i3pystatus-* ci-build +docs/_build diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 22d9d7a..97aef19 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -17,6 +17,7 @@ David Garcia Quintas David Wahlstrom dubwoc eBrnd +Erik Johnson enkore facetoe Frank Tackitt diff --git a/docs/configuration.rst b/docs/configuration.rst index 6c8a0c8..30ade98 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -175,9 +175,9 @@ decimal dot formatp ~~~~~~~ -Some modules use an extended format string syntax (the :py:mod:`.mpd` -module, for example). Given the format string below the output adapts -itself to the available data. +Some modules use an extended format string syntax (the :py:mod:`.mpd` and +:py:mod:`.weather` modules, for example). Given the format string below the +output adapts itself to the available data. :: diff --git a/docs/i3pystatus.rst b/docs/i3pystatus.rst index a49f031..b9bda22 100644 --- a/docs/i3pystatus.rst +++ b/docs/i3pystatus.rst @@ -52,3 +52,12 @@ Update Backends .. autogen:: i3pystatus.updates SettingsBase .. nothin' + +.. _weatherbackends: + +Weather Backends +---------------- + +.. autogen:: i3pystatus.weather SettingsBase + + .. nothin' diff --git a/i3pystatus/weather.py b/i3pystatus/weather.py deleted file mode 100644 index a71242d..0000000 --- a/i3pystatus/weather.py +++ /dev/null @@ -1,116 +0,0 @@ -from i3pystatus import IntervalModule -from i3pystatus.core.util import internet, require - -from urllib.request import urlopen -import re -import xml.etree.ElementTree as ElementTree - -WEATHER_COM_URL = 'http://wxdata.weather.com/wxdata/weather/local/%s?unit=%s&dayf=1&cc=*' - - -class Weather(IntervalModule): - """ - This module gets the weather from weather.com. - First, you need to get the code for the location from www.weather.com - - .. rubric:: Available formatters - - * `{current_temp}` — current temperature including unit (and symbol if colorize is true) - * `{min_temp}` — today's minimum temperature including unit - * `{max_temp}` — today's maximum temperature including unit - * `{current_wind}` — current wind direction, speed including unit - * `{humidity}` — current humidity excluding percentage symbol - - """ - - interval = 20 - - settings = ( - ("location_code", "Location code from www.weather.com"), - ("colorize", "Enable color with temperature and UTF-8 icons."), - ("color_icons", "Dictionary mapping weather conditions to tuples " - "containing a UTF-8 code for the icon, and the color " - "to be used."), - ("units", "Celsius (metric) or Fahrenheit (imperial)"), - "format", - ) - required = ("location_code",) - - location_code = None - units = "metric" - format = "{current_temp}" - colorize = False - color_icons = { - "Fair": (u"\u2600", "#FFCC00"), - "Cloudy": (u"\u2601", "#F8F8FF"), - "Partly Cloudy": (u"\u2601", "#F8F8FF"), # \u26c5 is not in many fonts - "Rainy": (u"\u2614", "#CBD2C0"), - "Sunny": (u"\u263C", "#FFFF00"), - "Snow": (u"\u2603", "#FFFFFF"), - "default": ("", None), - } - - def fetch_weather(self): - '''Fetches the current weather from wxdata.weather.com service.''' - unit = '' if self.units == 'imperial' or self.units == '' else 'm' - url = WEATHER_COM_URL % (self.location_code, unit) - with urlopen(url) as handler: - try: - content_type = dict(handler.getheaders())['Content-Type'] - charset = re.search(r'charset=(.*)', content_type).group(1) - except AttributeError: - charset = 'utf-8' - xml = handler.read().decode(charset) - doc = ElementTree.XML(xml) - return dict( - current_conditions=dict( - text=doc.findtext('cc/t'), - temperature=doc.findtext('cc/tmp'), - humidity=doc.findtext('cc/hmid'), - wind=dict( - text=doc.findtext('cc/wind/t'), - speed=doc.findtext('cc/wind/s'), - ), - ), - today=dict( - min_temperature=doc.findtext('dayf/day[@d="0"]/low'), - max_temperature=doc.findtext('dayf/day[@d="0"]/hi'), - ), - units=dict( - temperature=doc.findtext('head/ut'), - speed=doc.findtext('head/us'), - ), - ) - - @require(internet) - def run(self): - result = self.fetch_weather() - conditions = result["current_conditions"] - temperature = conditions["temperature"] - humidity = conditions["humidity"] - wind = conditions["wind"] - units = result["units"] - color = None - current_temp = "{t}°{d}".format(t=temperature, d=units["temperature"]) - min_temp = "{t}°{d}".format(t=result["today"]["min_temperature"], d=units["temperature"]) - max_temp = "{t}°{d}".format(t=result["today"]["max_temperature"], d=units["temperature"]) - current_wind = "{t} {s}{d}".format(t=wind["text"], s=wind["speed"], d=units["speed"]) - - if self.colorize: - icon, color = self.color_icons.get(conditions["text"], - self.color_icons["default"]) - current_temp = "{t}°{d} {i}".format(t=temperature, - d=units["temperature"], - i=icon) - color = color - - self.output = { - "full_text": self.format.format( - current_temp=current_temp, - current_wind=current_wind, - humidity=humidity, - min_temp=min_temp, - max_temp=max_temp, - ), - "color": color - } diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py new file mode 100644 index 0000000..45ffe4e --- /dev/null +++ b/i3pystatus/weather/__init__.py @@ -0,0 +1,141 @@ +from i3pystatus import SettingsBase, IntervalModule, formatp +from i3pystatus.core.util import user_open, internet, require + + +class Backend(SettingsBase): + settings = () + + +class Weather(IntervalModule): + ''' + This is a generic weather-checker which must use a configured weather + backend. For list of all available backends see :ref:`weatherbackends`. + + Left clicking on the module will launch the forecast page for the location + being checked. + + .. _weather-formatters: + + .. rubric:: Available formatters + + * `{city}` — Location of weather observation + * `{condition}` — Current weather condition (Rain, Snow, Overcast, etc.) + * `{icon}` — Icon representing the current weather condition + * `{observation_time}` — Time of weather observation (supports strftime format flags) + * `{current_temp}` — Current temperature, excluding unit + * `{low_temp}` — Forecasted low temperature, excluding unit + * `{high_temp}` — Forecasted high temperature, excluding unit (may be + empty in the late afternoon) + * `{temp_unit}` — Either ``°C`` or ``°F``, depending on whether metric or + * `{feelslike}` — "Feels Like" temperature, excluding unit + * `{dewpoint}` — Dewpoint temperature, excluding unit + imperial units are being used + * `{wind_speed}` — Wind speed, excluding unit + * `{wind_unit}` — Either ``kph`` or ``mph``, depending on whether metric or + imperial units are being used + * `{wind_direction}` — Wind direction + * `{wind_gust}` — Speed of wind gusts in mph/kph, excluding unit + * `{pressure}` — Barometric pressure, excluding unit + * `{pressure_unit}` — ``mb`` or ``in``, depending on whether metric or + imperial units are being used + * `{pressure_trend}` — ``+`` if rising, ``-`` if falling, or an empty + string if the pressure is steady (neither rising nor falling) + * `{visibility}` — Visibility distance, excluding unit + * `{visibility_unit}` — Either ``km`` or ``mi``, depending on whether + metric or imperial units are being used + * `{humidity}` — Current humidity, excluding percentage symbol + * `{uv_index}` — UV Index + + This module supports the :ref:`formatp ` extended string format + syntax. This allows for values to be hidden when they evaluate as False. + This comes in handy for the :py:mod:`weathercom <.weather.weathercom>` + backend, which at a certain point in the afternoon will have a blank + ``{high_temp}`` value. Using the following snippet in your format string + will only display the high temperature information if it is not blank: + + :: + + {current_temp}{temp_unit}[ Hi: {high_temp}[{temp_unit}]] Lo: {low_temp}{temp_unit} + + Brackets are evaluated from the outside-in, so the fact that the only + formatter in the outer block (``{high_temp}``) is empty would keep the + inner block from being evaluated at all, and entire block would not be + displayed. + + See the following links for usage examples for the available weather + backends: + + - :ref:`Weather.com ` + - :ref:`Weather Underground ` + ''' + + settings = ( + ('colorize', 'Vary the color depending on the current conditions.'), + ('color_icons', 'Dictionary mapping weather conditions to tuples ' + 'containing a UTF-8 code for the icon, and the color ' + 'to be used.'), + ('color', 'Display color (or fallback color if ``colorize`` is True). ' + 'If not specified, falls back to default i3bar color.'), + ('backend', 'Weather backend instance'), + 'interval', + 'format', + ) + required = ('backend',) + + colorize = False + color_icons = { + 'Fair': (u'\u2600', '#ffcc00'), + 'Cloudy': (u'\u2601', '#f8f8ff'), + 'Partly Cloudy': (u'\u2601', '#f8f8ff'), # \u26c5 is not in many fonts + 'Rainy': (u'\u26c8', '#cbd2c0'), + 'Thunderstorm': (u'\u03de', '#cbd2c0'), + 'Sunny': (u'\u263c', '#ffff00'), + 'Snow': (u'\u2603', '#ffffff'), + 'default': ('', None), + } + + color = None + backend = None + interval = 1800 + format = '{current_temp}{temp_unit}' + + on_leftclick = 'open_forecast_url' + + def open_forecast_url(self): + if self.backend.forecast_url and self.backend.forecast_url != 'N/A': + user_open(self.backend.forecast_url) + + def init(self): + pass + + def get_color_data(self, condition): + ''' + Disambiguate similarly-named weather conditions, and return the icon + and color that match. + ''' + condition_lc = condition.lower() + if condition_lc == 'clear': + condition = 'Fair' + if 'cloudy' in condition_lc: + condition = 'Cloudy' + elif 'rain' in condition_lc: + condition = 'Rainy' + elif 'thunder' in condition_lc: + condition = 'Thunderstorm' + elif 'snow' in condition_lc: + condition = 'Snow' + + return self.color_icons['default'] \ + if condition not in self.color_icons \ + else self.color_icons[condition] + + @require(internet) + def run(self): + data = self.backend.weather_data() + data['icon'], condition_color = self.get_color_data(data['condition']) + color = condition_color if self.colorize else self.color + + self.output = { + 'full_text': formatp(self.format, **data).strip(), + 'color': color, + } diff --git a/i3pystatus/weather/weathercom.py b/i3pystatus/weather/weathercom.py new file mode 100644 index 0000000..5568a54 --- /dev/null +++ b/i3pystatus/weather/weathercom.py @@ -0,0 +1,137 @@ +from i3pystatus import IntervalModule +from i3pystatus.core.util import internet, require +from i3pystatus.weather import Backend + +from datetime import datetime +from urllib.request import urlopen +import re +import xml.etree.ElementTree as ElementTree + +WEATHER_COM_URL = 'http://wxdata.weather.com/wxdata/weather/local/%s?unit=%s&dayf=1&cc=*' +ON_LEFTCLICK_URL = 'https://weather.com/weather/today/l/%s' + + +class Weathercom(Backend): + ''' + This module gets the weather from weather.com. The ``location_code`` + parameter should be set to the location code from weather.com. To obtain + this code, search for the location on weather.com, and the location code + will be everything after the last slash (e.g. ``94107:4:US``). + + .. _weather-usage-weathercom: + + .. rubric:: Usage example + + .. code-block:: python + + from i3pystatus import Status + from i3pystatus.weather import weathercom + + status = Status() + + status.register( + 'weather', + format='{condition} {current_temp}{temp_unit}{icon}[ Hi: {high_temp}] Lo: {low_temp}', + colorize=True, + backend=weathercom.Weathercom( + location_code='94107:4:US', + units='imperial', + ), + ) + + status.run() + + See :ref:`here ` for a list of formatters which can be + used. + ''' + settings = ( + ('location_code', 'Location code from www.weather.com'), + ('units', '\'metric\' or \'imperial\''), + ) + required = ('location_code',) + + location_code = None + + units = 'metric' + + # This will be set once weather data has been checked + forecast_url = None + + @require(internet) + def weather_data(self): + ''' + Fetches the current weather from wxdata.weather.com service. + ''' + if self.forecast_url is None and ':' in self.location_code: + # Set the URL so that clicking the weather will launch the + # weather.com forecast page. Only set it though if there is a colon + # in the location_code. Technically, the weather.com API will + # successfully return weather data if a U.S. ZIP code is used as + # the location_code (e.g. 94107), but if substituted in + # ON_LEFTCLICK_URL it may or may not result in a valid URL. + self.forecast_url = ON_LEFTCLICK_URL % self.location_code + + unit = '' if self.units == 'imperial' or self.units == '' else 'm' + url = WEATHER_COM_URL % (self.location_code, unit) + with urlopen(url) as handler: + try: + content_type = dict(handler.getheaders())['Content-Type'] + charset = re.search(r'charset=(.*)', content_type).group(1) + except AttributeError: + charset = 'utf-8' + xml = handler.read().decode(charset) + doc = ElementTree.XML(xml) + + # Cut off the timezone from the end of the string (it's after the last + # space, hence the use of rpartition). International timezones (or ones + # outside the system locale) don't seem to be handled well by + # datetime.datetime.strptime(). + observation_time_str = doc.findtext('cc/lsup').rpartition(' ')[0] + try: + observation_time = datetime.strptime(observation_time_str, + '%m/%d/%y %I:%M %p') + except ValueError: + observation_time = datetime.fromtimestamp(0) + + pressure_trend_str = doc.findtext('cc/bar/d').lower() + if pressure_trend_str == 'rising': + pressure_trend = '+' + elif pressure_trend_str == 'falling': + pressure_trend = '-' + else: + pressure_trend = '' + + if not doc.findtext('dayf/day[@d="0"]/part[@p="d"]/icon').strip(): + # If the "d" (day) part of today's forecast's keys are empty, there + # is no high temp anymore (this happens in the afternoon), but + # instead of handling things in a sane way and setting the high + # temp to an empty string or something like that, the API returns + # the current temp as the high temp, which is incorrect. This if + # statement catches it so that we can correctly report that there + # is no high temp at this point of the day. + high_temp = '' + else: + high_temp = doc.findtext('dayf/day[@d="0"]/hi') + + return dict( + city=doc.findtext('loc/dnam'), + condition=doc.findtext('cc/t'), + observation_time=observation_time, + current_temp=doc.findtext('cc/tmp'), + low_temp=doc.findtext('dayf/day[@d="0"]/low'), + high_temp=high_temp, + temp_unit='°' + doc.findtext('head/ut').upper(), + feelslike=doc.findtext('cc/flik'), + dewpoint=doc.findtext('cc/dewp'), + wind_speed=doc.findtext('cc/wind/s'), + wind_unit=doc.findtext('head/us'), + wind_direction=doc.findtext('cc/wind/t'), + wind_gust=doc.findtext('cc/wind/gust'), + pressure=doc.findtext('cc/bar/r'), + pressure_unit=doc.findtext('head/up'), + pressure_trend=pressure_trend, + visibility=doc.findtext('cc/vis'), + visibility_unit=doc.findtext('head/ud'), + humidity=doc.findtext('cc/hmid'), + uv_index=doc.findtext('cc/uv/i'), + ) diff --git a/i3pystatus/weather/wunderground.py b/i3pystatus/weather/wunderground.py new file mode 100644 index 0000000..fcbb496 --- /dev/null +++ b/i3pystatus/weather/wunderground.py @@ -0,0 +1,240 @@ +from i3pystatus import IntervalModule +from i3pystatus.core.util import internet, require + +from datetime import datetime +from urllib.request import urlopen +import json +import re + +GEOLOOKUP_URL = 'http://api.wunderground.com/api/%s/geolookup%s/q/%s.json' +STATION_QUERY_URL = 'http://api.wunderground.com/api/%s/%s/q/%s.json' + + +class Wunderground(IntervalModule): + ''' + This module retrieves weather data using the Weather Underground API. + + .. note:: + A Weather Underground API key is required to use this module, you can + sign up for a developer API key free at + https://www.wunderground.com/weather/api/ + + A developer API key is allowed 500 queries per day, and no more than 10 + in a given minute. Therefore, it is recommended to be conservative when + setting the update interval. + + Valid values for ``location_code`` include: + + * **State/City_Name** - CA/San_Francisco + * **Country/City** - France/Paris + * **Geolocation by IP** - autoip + * **Zip or Postal Code** - 60616 + * **ICAO Airport Code** - icao:LAX + * **Latitude/Longitude** - 41.8301943,-87.6342619 + * **Personal Weather Station (PWS)** - pws:KILCHICA30 + + When not using a ``pws`` or ``icao`` station ID, the location will be + queried, and the closest station will be used. For a list of PWS + station IDs, visit the following URL: + + http://www.wunderground.com/weatherstation/ListStations.asp + + .. _weather-usage-wunderground: + + .. rubric:: Usage example + + .. code-block:: python + + from i3pystatus import Status + from i3pystatus.weather import wunderground + + status = Status() + + status.register( + 'weather', + format='{condition} {current_temp}{temp_unit}{icon}[ Hi: {high_temp}] Lo: {low_temp}', + colorize=True, + backend=wunderground.Wunderground( + api_key='dbafe887d56ba4ad', + location_code='pws:MAT645', + units='imperial', + ), + ) + + status.run() + + See :ref:`here ` for a list of formatters which can be + used. + ''' + + interval = 300 + + settings = ( + ('api_key', 'Weather Underground API key'), + ('location_code', 'Location code from wunderground.com'), + ('units', '\'metric\' or \'imperial\''), + ('use_pws', 'Set to False to use only airport stations'), + ('forecast', 'Set to ``True`` to check forecast (generates one ' + 'additional API request per weather update). If set to ' + '``False``, then the ``low_temp`` and ``high_temp`` ' + 'formatters will be set to empty strings.'), + ) + + required = ('api_key', 'location_code') + + api_key = None + location_code = None + units = 'metric' + use_pws = True + forecast = False + + # These will be set once weather data has been checked + station_id = None + forecast_url = None + + @require(internet) + def api_request(self, url): + ''' + Execute an HTTP POST to the specified URL and return the content + ''' + with urlopen(url) as content: + try: + content_type = dict(content.getheaders())['Content-Type'] + charset = re.search(r'charset=(.*)', content_type).group(1) + except AttributeError: + charset = 'utf-8' + response = json.loads(content.read().decode(charset)) + try: + raise Exception(response['response']['error']['description']) + except KeyError: + pass + return response + + @require(internet) + def geolookup(self): + ''' + Use the location_code to perform a geolookup and find the closest + station. If the location is a pws or icao station ID, no lookup will be + peformed. + ''' + if self.station_id is None: + try: + for no_lookup in ('pws', 'icao'): + sid = self.location_code.partition(no_lookup + ':')[-1] + if sid: + self.station_id = self.location_code + return + except AttributeError: + # Numeric or some other type, either way we'll just stringify + # it below and perform a lookup. + pass + + extra_opts = '/pws:0' if not self.use_pws else '' + api_url = GEOLOOKUP_URL % (self.api_key, + extra_opts, + self.location_code) + response = self.api_request(api_url) + station_type = 'pws' if self.use_pws else 'airport' + try: + stations = response['location']['nearby_weather_stations'] + nearest = stations[station_type]['station'][0] + except (KeyError, IndexError): + raise Exception('No locations matched location_code %s' + % self.location_code) + + if self.use_pws: + nearest_pws = nearest.get('id', '') + if not nearest_pws: + raise Exception('No id entry for station') + self.station_id = 'pws:%s' % nearest_pws + else: + nearest_airport = nearest.get('icao', '') + if not nearest_airport: + raise Exception('No icao entry for station') + self.station_id = 'icao:%s' % nearest_airport + + @require(internet) + def get_forecast(self): + ''' + If configured to do so, make an API request to retrieve the forecast + data for the configured/queried weather station, and return the low and + high temperatures. Otherwise, return two empty strings. + ''' + if self.forecast: + query_url = STATION_QUERY_URL % (self.api_key, + 'forecast', + self.station_id) + try: + response = self.api_request(query_url)['forecast'] + response = response['simpleforecast']['forecastday'][0] + except (KeyError, IndexError, TypeError): + raise Exception('No forecast data found for %s' % self.station_id) + + unit = 'celsius' if self.units == 'metric' else 'fahrenheit' + low_temp = response.get('low', {}).get(unit, '') + high_temp = response.get('high', {}).get(unit, '') + return low_temp, high_temp + else: + return '', '' + + @require(internet) + def weather_data(self): + ''' + Query the configured/queried station and return the weather data + ''' + # If necessary, do a geolookup to set the station_id + self.geolookup() + + query_url = STATION_QUERY_URL % (self.api_key, + 'conditions', + self.station_id) + try: + response = self.api_request(query_url)['current_observation'] + self.forecast_url = response.pop('ob_url', None) + except KeyError: + raise Exception('No weather data found for %s' % self.station_id) + + low_temp, high_temp = self.get_forecast() + + if self.units == 'metric': + temp_unit = 'c' + speed_unit = 'kph' + distance_unit = 'km' + pressure_unit = 'mb' + else: + temp_unit = 'f' + speed_unit = 'mph' + distance_unit = 'mi' + pressure_unit = 'in' + + def _find(key, data=None): + data = data or response + return data.get(key, 'N/A') + + try: + observation_time = int(_find('observation_epoch')) + except TypeError: + observation_time = 0 + + return dict( + city=_find('city', response['observation_location']), + condition=_find('weather'), + observation_time=datetime.fromtimestamp(observation_time), + current_temp=_find('temp_' + temp_unit), + low_temp=low_temp, + high_temp=high_temp, + temp_unit='°' + temp_unit.upper(), + feelslike=_find('feelslike_' + temp_unit), + dewpoint=_find('dewpoint_' + temp_unit), + wind_speed=_find('wind_' + speed_unit), + wind_unit=speed_unit, + wind_direction=_find('wind_dir'), + wind_gust=_find('wind_gust_' + speed_unit), + pressure=_find('pressure_' + pressure_unit), + pressure_unit=pressure_unit, + pressure_trend=_find('pressure_trend'), + visibility=_find('visibility_' + distance_unit), + visibility_unit=distance_unit, + humidity=_find('relative_humidity').rstrip('%'), + uv_index=_find('uv'), + ) diff --git a/setup.py b/setup.py index 2af4da2..a91b397 100755 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ setup(name="i3pystatus", "i3pystatus.mail", "i3pystatus.pulseaudio", "i3pystatus.updates", + "i3pystatus.weather", ], entry_points={ "console_scripts": [