From 23747d8181e7b9c576d07c4b3d50a0ac8a60cd3e Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 14 Mar 2016 01:35:31 -0500 Subject: [PATCH 01/16] Add wunderground module This module tries to use as much of the same variable naming conventions that the ``weather`` module uses as possible, to make transitioning easier in the future in case we decide to make a base class for all modules which provide weather data. An API key is required to use this module, information on obtaining one can be found in the docstring. --- i3pystatus/wunderground.py | 219 +++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 i3pystatus/wunderground.py diff --git a/i3pystatus/wunderground.py b/i3pystatus/wunderground.py new file mode 100644 index 0000000..3299c33 --- /dev/null +++ b/i3pystatus/wunderground.py @@ -0,0 +1,219 @@ +from i3pystatus import IntervalModule +from i3pystatus.core.util import user_open, 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_LOOKUP_URL = 'http://api.wunderground.com/api/%s/conditions/q/%s.json' + + +class Wunderground(IntervalModule): + ''' + This module retrieves weather from the Weather Underground API. + + .. note:: + A Weather Underground API key is required to use this module, you can + sign up for one for a developer API key free at + https://www.wunderground.com/weather/api/ + + A developer API key is allowed 500 queries per day. + + 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, the location will be queried, and the closest + station will be used. When possible, it is recommended to use a PWS + location, as this will result in fewer API calls. + + .. rubric:: Available formatters + + * `{city}` — Location of weather observation + * `{conditon}` — Current condition (Rain, Snow, Overcast, etc.) + * `{observation_time}` — Time of weather observation (supports strftime format flags) + * `{current_temp}` — Current temperature, excluding unit + * `{degrees}` — ``°C`` if ``units`` is set to ``metric``, otherwise ``°F`` + * `{feelslike}` — Wunderground "Feels Like" temperature, excluding unit + * `{current_wind}` — Wind speed in mph/kph, excluding unit + * `{current_wind_direction}` — Wind direction + * `{current_wind_gust}` — Speed of wind gusts in mph/kph, excluding unit + * `{pressure_in}` — Barometric pressure (in inches), excluding unit + * `{pressure_mb}` — Barometric pressure (in millibars), excluding unit + * `{pressure_trend}` — ``+`` (rising) or ``-`` (falling) + * `{visibility}` — Visibility in mi/km, excluding unit + * `{humidity}` — Current humidity, excluding percentage symbol + * `{dewpoint}` — Dewpoint temperature, excluding unit + * `{uv_index}` — UV Index + + ''' + + interval = 300 + + settings = ( + ('api_key', 'Weather Underground API key'), + ('location_code', 'Location code from www.weather.com'), + ('units', 'Celsius (metric) or Fahrenheit (imperial)'), + ('use_pws', 'Set to False to use only airport stations'), + ('error_log', 'If set, tracebacks will be logged to this file'), + 'format', + ) + required = ('api_key', 'location_code') + + api_key = None + location_code = None + units = "metric" + format = "{current_temp}{degrees}" + use_pws = True + error_log = None + + station_id = None + forecast_url = None + + on_leftclick = 'open_wunderground' + + def open_wunderground(self): + ''' + Open the forecast URL, if one was retrieved + ''' + if self.forecast_url and self.forecast_url != 'N/A': + user_open(self.forecast_url) + + 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 + + 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 + + def query_station(self): + ''' + Query a specific station + ''' + # If necessary, do a geolookup to set the station_id + self.geolookup() + + query_url = STATION_LOOKUP_URL % (self.api_key, self.station_id) + try: + response = self.api_request(query_url)['current_observation'] + self.forecast_url = response.pop('forecast_url', None) + except KeyError: + raise Exception('No weather data found for %s' % self.station_id) + + def _find(key, data=None): + data = data or response + return data.get(key, 'N/A') + + if self.units == 'metric': + temp_unit = 'c' + speed_unit = 'kph' + distance_unit = 'km' + else: + temp_unit = 'f' + speed_unit = 'mph' + distance_unit = 'mi' + + try: + observation_time = int(_find('observation_epoch')) + except TypeError: + observation_time = 0 + + return dict( + forecast_url=_find('forecast_url'), + city=_find('city', response['observation_location']), + condition=_find('weather'), + observation_time=datetime.fromtimestamp(observation_time), + current_temp=_find('temp_' + temp_unit), + feelslike=_find('feelslike_' + temp_unit), + current_wind=_find('wind_' + speed_unit), + current_wind_direction=_find('wind_dir'), + current_wind_gust=_find('wind_gust_' + speed_unit), + pressure_in=_find('pressure_in'), + pressure_mb=_find('pressure_mb'), + pressure_trend=_find('pressure_trend'), + visibility=_find('visibility_' + distance_unit), + humidity=_find('relative_humidity').rstrip('%'), + dewpoint=_find('dewpoint_' + temp_unit), + uv_index=_find('uv'), + ) + + @require(internet) + def run(self): + try: + result = self.query_station() + except Exception as exc: + if self.error_log: + import traceback + with open(self.error_log, 'a') as f: + f.write('%s : An exception was raised:\n' % + datetime.isoformat(datetime.now())) + f.write(''.join(traceback.format_exc())) + f.write(80 * '-' + '\n') + raise + + result['degrees'] = '°%s' % ('C' if self.units == 'metric' else 'F') + + self.output = { + "full_text": self.format.format(**result), + # "color": self.color # TODO: add some sort of color effect + } From 048fd8f83db807b4a5fb346695faca53d855070b Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 14 Mar 2016 01:45:34 -0500 Subject: [PATCH 02/16] Add link to PWS stations --- i3pystatus/wunderground.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/i3pystatus/wunderground.py b/i3pystatus/wunderground.py index 3299c33..8a9b941 100644 --- a/i3pystatus/wunderground.py +++ b/i3pystatus/wunderground.py @@ -31,9 +31,11 @@ class Wunderground(IntervalModule): * **Latitude/Longitude** - 41.8301943,-87.6342619 * **Personal Weather Station (PWS)** - pws:KILCHICA30 - When not using a PWS, the location will be queried, and the closest - station will be used. When possible, it is recommended to use a PWS - location, as this will result in fewer API calls. + 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 .. rubric:: Available formatters From 3c955ac8977f46a9306aaae0f996a3130099b5b0 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 14 Mar 2016 01:50:05 -0500 Subject: [PATCH 03/16] Use ob_url instead of forecast_url, it is more accurate --- i3pystatus/wunderground.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/i3pystatus/wunderground.py b/i3pystatus/wunderground.py index 8a9b941..2edd8b5 100644 --- a/i3pystatus/wunderground.py +++ b/i3pystatus/wunderground.py @@ -158,7 +158,7 @@ class Wunderground(IntervalModule): query_url = STATION_LOOKUP_URL % (self.api_key, self.station_id) try: response = self.api_request(query_url)['current_observation'] - self.forecast_url = response.pop('forecast_url', None) + self.forecast_url = response.pop('ob_url', None) except KeyError: raise Exception('No weather data found for %s' % self.station_id) @@ -181,7 +181,6 @@ class Wunderground(IntervalModule): observation_time = 0 return dict( - forecast_url=_find('forecast_url'), city=_find('city', response['observation_location']), condition=_find('weather'), observation_time=datetime.fromtimestamp(observation_time), From 8d1646e92cabe2b471b797e74fb628f2f8d34a92 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 14 Mar 2016 02:17:07 -0500 Subject: [PATCH 04/16] Add myself to CONTRIBUTORS --- CONTRIBUTORS | 1 + 1 file changed, 1 insertion(+) 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 From dbfa2672360bb7a632fad84bb34b3393bce9aa86 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 22 Mar 2016 00:26:15 -0500 Subject: [PATCH 05/16] Link to weather backends --- docs/i3pystatus.rst | 9 +++++++++ 1 file changed, 9 insertions(+) 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' From f0d19aacec9bfb5249cf3413c3a726400e17ae97 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 22 Mar 2016 00:26:36 -0500 Subject: [PATCH 06/16] Initial commit of general weather module --- i3pystatus/weather/__init__.py | 141 +++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 i3pystatus/weather/__init__.py diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py new file mode 100644 index 0000000..c8fa2ed --- /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 == 'mostly cloudy': + condition = 'Cloudy' + elif condition_lc == 'clear': + condition = 'Fair' + elif condition_lc == 'rain': + 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, + } From 7f5338d772c78d2340ba75fa184db005aecaf925 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 22 Mar 2016 00:27:18 -0500 Subject: [PATCH 07/16] Move weather.py, wunderground.py to i3pystatus.weather --- i3pystatus/{ => weather}/weather.py | 0 i3pystatus/{ => weather}/wunderground.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename i3pystatus/{ => weather}/weather.py (100%) rename i3pystatus/{ => weather}/wunderground.py (100%) diff --git a/i3pystatus/weather.py b/i3pystatus/weather/weather.py similarity index 100% rename from i3pystatus/weather.py rename to i3pystatus/weather/weather.py diff --git a/i3pystatus/wunderground.py b/i3pystatus/weather/wunderground.py similarity index 100% rename from i3pystatus/wunderground.py rename to i3pystatus/weather/wunderground.py From 099ddc795c9007bb9759879e1cbcddb911cb9348 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 22 Mar 2016 00:27:48 -0500 Subject: [PATCH 08/16] Rename weather.py to weathercom.py With the addition of wunderground.py, this makes the naming of this module less general. --- i3pystatus/weather/{weather.py => weathercom.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename i3pystatus/weather/{weather.py => weathercom.py} (100%) diff --git a/i3pystatus/weather/weather.py b/i3pystatus/weather/weathercom.py similarity index 100% rename from i3pystatus/weather/weather.py rename to i3pystatus/weather/weathercom.py From c32e458514257c168619c0a577e732f96ed1f049 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 23 Mar 2016 00:50:33 -0500 Subject: [PATCH 09/16] Rework weather.com This alters the weather.com forecast code such that it is a backend for the new generalized weather module. --- i3pystatus/weather/weathercom.py | 188 +++++++++++++++++-------------- 1 file changed, 104 insertions(+), 84 deletions(-) diff --git a/i3pystatus/weather/weathercom.py b/i3pystatus/weather/weathercom.py index a71242d..5965a99 100644 --- a/i3pystatus/weather/weathercom.py +++ b/i3pystatus/weather/weathercom.py @@ -1,57 +1,75 @@ 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 Weather(IntervalModule): - """ - This module gets the weather from weather.com. - First, you need to get the code for the location from www.weather.com +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``). - .. rubric:: Available formatters + .. _weather-usage-weathercom: - * `{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 + .. rubric:: Usage example - """ + :: + from i3pystatus import Status + from i3pystatus.weather import weathercom - interval = 20 + 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"), - ("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", + ('location_code', 'Location code from www.weather.com'), + ('units', '\'metric\' or \'imperial\''), ) - required = ("location_code",) + 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.''' + 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: @@ -62,55 +80,57 @@ class Weather(IntervalModule): 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( - 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'), - ), + 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'), ) - - @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 - } From abf5b6ad1cd0582d0a518a11c5a12f64bdfd8567 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 00:47:17 -0500 Subject: [PATCH 10/16] update docs for weathercom --- i3pystatus/weather/weathercom.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/i3pystatus/weather/weathercom.py b/i3pystatus/weather/weathercom.py index 5965a99..5568a54 100644 --- a/i3pystatus/weather/weathercom.py +++ b/i3pystatus/weather/weathercom.py @@ -22,7 +22,8 @@ class Weathercom(Backend): .. rubric:: Usage example - :: + .. code-block:: python + from i3pystatus import Status from i3pystatus.weather import weathercom From f3f2b59c5b969a659b0979b7dc68d3705b99a2d3 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Sun, 27 Mar 2016 22:14:04 -0500 Subject: [PATCH 11/16] Rework wunderground module as a backend of i3pystatus.weather --- i3pystatus/weather/wunderground.py | 162 ++++++++++++++++------------- 1 file changed, 91 insertions(+), 71 deletions(-) diff --git a/i3pystatus/weather/wunderground.py b/i3pystatus/weather/wunderground.py index 2edd8b5..fcbb496 100644 --- a/i3pystatus/weather/wunderground.py +++ b/i3pystatus/weather/wunderground.py @@ -1,5 +1,5 @@ from i3pystatus import IntervalModule -from i3pystatus.core.util import user_open, internet, require +from i3pystatus.core.util import internet, require from datetime import datetime from urllib.request import urlopen @@ -7,19 +7,21 @@ import json import re GEOLOOKUP_URL = 'http://api.wunderground.com/api/%s/geolookup%s/q/%s.json' -STATION_LOOKUP_URL = 'http://api.wunderground.com/api/%s/conditions/q/%s.json' +STATION_QUERY_URL = 'http://api.wunderground.com/api/%s/%s/q/%s.json' class Wunderground(IntervalModule): ''' - This module retrieves weather from the Weather Underground API. + 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 one for a developer API key free at + 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. + 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: @@ -37,58 +39,60 @@ class Wunderground(IntervalModule): http://www.wunderground.com/weatherstation/ListStations.asp - .. rubric:: Available formatters + .. _weather-usage-wunderground: - * `{city}` — Location of weather observation - * `{conditon}` — Current condition (Rain, Snow, Overcast, etc.) - * `{observation_time}` — Time of weather observation (supports strftime format flags) - * `{current_temp}` — Current temperature, excluding unit - * `{degrees}` — ``°C`` if ``units`` is set to ``metric``, otherwise ``°F`` - * `{feelslike}` — Wunderground "Feels Like" temperature, excluding unit - * `{current_wind}` — Wind speed in mph/kph, excluding unit - * `{current_wind_direction}` — Wind direction - * `{current_wind_gust}` — Speed of wind gusts in mph/kph, excluding unit - * `{pressure_in}` — Barometric pressure (in inches), excluding unit - * `{pressure_mb}` — Barometric pressure (in millibars), excluding unit - * `{pressure_trend}` — ``+`` (rising) or ``-`` (falling) - * `{visibility}` — Visibility in mi/km, excluding unit - * `{humidity}` — Current humidity, excluding percentage symbol - * `{dewpoint}` — Dewpoint temperature, excluding unit - * `{uv_index}` — UV Index + .. 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 www.weather.com'), - ('units', 'Celsius (metric) or Fahrenheit (imperial)'), + ('location_code', 'Location code from wunderground.com'), + ('units', '\'metric\' or \'imperial\''), ('use_pws', 'Set to False to use only airport stations'), - ('error_log', 'If set, tracebacks will be logged to this file'), - 'format', + ('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" - format = "{current_temp}{degrees}" + units = 'metric' use_pws = True - error_log = None + forecast = False + # These will be set once weather data has been checked station_id = None forecast_url = None - on_leftclick = 'open_wunderground' - - def open_wunderground(self): - ''' - Open the forecast URL, if one was retrieved - ''' - if self.forecast_url and self.forecast_url != 'N/A': - user_open(self.forecast_url) - + @require(internet) def api_request(self, url): ''' Execute an HTTP POST to the specified URL and return the content @@ -106,6 +110,7 @@ class Wunderground(IntervalModule): pass return response + @require(internet) def geolookup(self): ''' Use the location_code to perform a geolookup and find the closest @@ -148,32 +153,63 @@ class Wunderground(IntervalModule): raise Exception('No icao entry for station') self.station_id = 'icao:%s' % nearest_airport - def query_station(self): + @require(internet) + def get_forecast(self): ''' - Query a specific station + 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_LOOKUP_URL % (self.api_key, self.station_id) + 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) - def _find(key, data=None): - data = data or response - return data.get(key, 'N/A') + 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')) @@ -185,36 +221,20 @@ class Wunderground(IntervalModule): 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), - current_wind=_find('wind_' + speed_unit), - current_wind_direction=_find('wind_dir'), - current_wind_gust=_find('wind_gust_' + speed_unit), - pressure_in=_find('pressure_in'), - pressure_mb=_find('pressure_mb'), + 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('%'), - dewpoint=_find('dewpoint_' + temp_unit), uv_index=_find('uv'), ) - - @require(internet) - def run(self): - try: - result = self.query_station() - except Exception as exc: - if self.error_log: - import traceback - with open(self.error_log, 'a') as f: - f.write('%s : An exception was raised:\n' % - datetime.isoformat(datetime.now())) - f.write(''.join(traceback.format_exc())) - f.write(80 * '-' + '\n') - raise - - result['degrees'] = '°%s' % ('C' if self.units == 'metric' else 'F') - - self.output = { - "full_text": self.format.format(**result), - # "color": self.color # TODO: add some sort of color effect - } From 19af60831293ccff759e7ad9afb2336d1e232b02 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Sun, 27 Mar 2016 22:25:19 -0500 Subject: [PATCH 12/16] Add i3pystatus.weather to packages list --- setup.py | 1 + 1 file changed, 1 insertion(+) 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": [ From 0ec2bf7b53b3ca53fb5b64882f81de31312a2903 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 00:45:21 -0500 Subject: [PATCH 13/16] Add docs/_build to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 89a63b8..9be1197 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/* *~ .i3pystatus-* ci-build +docs/_build From 66bc56b7a4c690cc3bb5be47c3d894911845cdde Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 00:46:00 -0500 Subject: [PATCH 14/16] Add reference to weather module to the formatp documentation --- docs/configuration.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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. :: From 2ac7c6af3de22f04dabe6ce176e578bd957d4165 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 30 Mar 2016 11:40:30 -0500 Subject: [PATCH 15/16] Support more types of "rain" conditions --- i3pystatus/weather/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py index c8fa2ed..e45e39d 100644 --- a/i3pystatus/weather/__init__.py +++ b/i3pystatus/weather/__init__.py @@ -118,7 +118,7 @@ class Weather(IntervalModule): condition = 'Cloudy' elif condition_lc == 'clear': condition = 'Fair' - elif condition_lc == 'rain': + elif 'rain' in condition_lc: condition = 'Rainy' elif 'thunder' in condition_lc: condition = 'Thunderstorm' From 0fafb1a652510f73c3bb3bc703341d6c950701a9 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 30 Mar 2016 13:19:38 -0500 Subject: [PATCH 16/16] Identify more kinds of cloudy weather --- i3pystatus/weather/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py index e45e39d..45ffe4e 100644 --- a/i3pystatus/weather/__init__.py +++ b/i3pystatus/weather/__init__.py @@ -114,10 +114,10 @@ class Weather(IntervalModule): and color that match. ''' condition_lc = condition.lower() - if condition_lc == 'mostly cloudy': - condition = 'Cloudy' - elif condition_lc == 'clear': + 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: