diff --git a/Weather/.travis.yml b/Weather/.travis.yml index 10aa0e7..a3fab92 100644 --- a/Weather/.travis.yml +++ b/Weather/.travis.yml @@ -1,6 +1,8 @@ language: python python: - "2.7" + - "3.2" + - "3.3" - "3.4" - pypy - pypy3 diff --git a/Weather/LICENSE.txt b/Weather/LICENSE.txt index 41e9e07..83d7492 100644 --- a/Weather/LICENSE.txt +++ b/Weather/LICENSE.txt @@ -1,6 +1,7 @@ The MIT License (MIT) Copyright (c) 2014 spline +Copyright (c) 2014-2015 James Lu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/Weather/README.md b/Weather/README.md index f3ab61f..27d9f4d 100644 --- a/Weather/README.md +++ b/Weather/README.md @@ -2,56 +2,33 @@ # Limnoria plugin for Weather Underground (GLolol's fork) -## Introduction +## Installation -I made this plugin because quite a few Weather plugins didn't work well and WunderWeather, which uses -this API, is on their older XML api that they don't have documented anymore and, one would assume, will -be deprecated at some point. +You will need a working Limnoria bot on Python 2.7/3.2+ for this to work. -## Install - -You will need a working Limnoria bot on Python 2.7/3.4 for this to work. - -Go into your Limnoria plugin dir, usually `~/supybot/plugins` and run: +1) Go into your Limnoria plugin dir, usually `~/supybot/plugins` and run: ``` git clone https://github.com/GLolol/Supybot-Weather ``` -To install additional requirements, run: +Alternatively, you can fetch this plugin (albeit a slightly older version) via Limnoria's PluginDownloader using: `install GLolol Weather`. -``` -pip install -r requirements.txt -``` - -or if you don't have or don't want to use root, - -``` -pip install -r requirements.txt --user -``` - - -Next, load the plugin: +2) Load the plugin: ``` /msg bot load Weather ``` -[Fetch an API key for Wunderground](http://www.wunderground.com/weather/api/) by signing up (free). +3) [Fetch an API key from Wunderground](http://www.wunderground.com/weather/api/) by signing up (free). Once getting this key, you will need to set it on your bot before things will work. -Reload once you perform this operation to start using it. + ``` -/msg bot config plugins.Weather.apiKey +/msg config plugins.Weather.apiKey ``` -Now, reload the bot and you should be good to go: - -``` -/msg bot reload Weather -``` - -*Optional:* There are some config variables that can be set for the bot. They mainly control output stuff. +4) *Optional:* There are some config variables that can be set for the bot. They mainly control output stuff. ``` /msg bot config search Weather @@ -59,9 +36,12 @@ Now, reload the bot and you should be good to go: ## Example Usage +When calling the `wunderground` command, you can use zip codes (10002), cities (New York, NY), etc. Weather Underground is pretty +intelligent here. + ``` @wunderground 10002 - New York, NY :: Rain :: 52F | Visibility: 4.0mi | Saturday: Rain. High around 55F. ... + New York, NY :: Rain :: 52F | Visibility: 4.0mi | Saturday: Rain. High around 55F... ``` ## Features @@ -72,14 +52,8 @@ There are a ton of options to configure. You can find these via: /msg bot config search Weather ``` -Many of these are also available via --help when calling the wunderground command. - Users can also have their location remembered by the plugin's internal database so that -they will not have to continually type in their location. NOTE: It uses their nick only, -so if they are on a different nick, even with an identical hostmask, it will not match. - -You can use zipcodes (10002), cities (New York, NY), etc. Weather Underground is pretty -intelligent here. +they will not have to continually type in their location. ``` @setweather 10002 @@ -93,7 +67,7 @@ This now allows a user to type in the weather command w/o any arguments: Manchester, NH :: Rain :: 45F | Visibility: 10.0mi | Saturday: Occasional light rain. High 56F. ... ``` -Users can also have the bot remember their preferred options, such as using Metric when displaying weather: +Users can also have the bot remember their preferred options, such as using metric units when displaying weather: ``` @setuser metric False diff --git a/Weather/config.py b/Weather/config.py index df40c0a..f3eb810 100644 --- a/Weather/config.py +++ b/Weather/config.py @@ -17,21 +17,27 @@ def configure(advanced): from supybot.questions import expect, anything, something, yn conf.registerPlugin('Weather', True) - Weather = conf.registerPlugin('Weather') -conf.registerGlobalValue(Weather,'apiKey', registry.String('', ("""Your wunderground.com API key."""), private=True)) -conf.registerChannelValue(Weather,'useImperial', registry.Boolean(True, ("""Use imperial units? Defaults to yes."""))) -conf.registerChannelValue(Weather,'disableColoredTemp', registry.Boolean(False, """If True, this will disable coloring temperatures based on values.""")) -# conf.registerChannelValue(Weather,'useWeatherSymbols', registry.Boolean(False, """Use unicode symbols with weather conditions and for wind direction.""")) -conf.registerGlobalValue(Weather,'forecast', registry.Boolean(True, ("""Display forecast in output by default?"""))) -conf.registerGlobalValue(Weather,'alerts', registry.Boolean(False, ("""Display alerts by default?"""))) -conf.registerGlobalValue(Weather,'almanac', registry.Boolean(False, ("""Display almanac by default?"""))) -conf.registerGlobalValue(Weather,'astronomy', registry.Boolean(False, ("""Display astronomy by default?"""))) -conf.registerGlobalValue(Weather,'showPressure', registry.Boolean(False, ("""Show pressure in output?"""))) -conf.registerGlobalValue(Weather,'showWind', registry.Boolean(False, ("""Show wind in output?"""))) -conf.registerGlobalValue(Weather,'showUpdated', registry.Boolean(False, ("""Show updated in output?"""))) -conf.registerChannelValue(Weather,'showImperialAndMetric', registry.Boolean(True, ("""In channel, display output with Imperial and Metric?"""))) -conf.registerGlobalValue(Weather,'lang', registry.String('EN', ("""language to use. See docs for available codes."""))) +conf.registerGlobalValue(Weather, 'apiKey', + registry.String('', ("""Sets the API key for the plugin. You can obtain an API key at http://www.wunderground.com/weather/api/."""), private=True)) +conf.registerChannelValue(Weather, 'useImperial', + registry.Boolean(True, ("""Determines whether imperial units (Fahrenheit, etc.) will be used."""))) +conf.registerGlobalValue(Weather,'forecast', + registry.Boolean(True, ("""Determines whether forecasts will be displayed by default."""))) +conf.registerGlobalValue(Weather,'alerts', + registry.Boolean(False, ("""Determines whether forecasts will be displayed by default."""))) +conf.registerGlobalValue(Weather, 'almanac', + registry.Boolean(False, ("""Determines whether almanac will be displayed by default."""))) +conf.registerGlobalValue(Weather, 'astronomy', + registry.Boolean(False, ("""Determines whether astronomy will be displayed by default."""))) +conf.registerGlobalValue(Weather, 'showPressure', + registry.Boolean(False, ("""Determines whether pressure will be displayed by default."""))) +conf.registerGlobalValue(Weather, 'showWind', + registry.Boolean(False, ("""Determines whether winde will be displayed by default."""))) +conf.registerGlobalValue(Weather, 'showUpdated', + registry.Boolean(False, ("""Determines whether the bot will show the data's "last updated" time by default."""))) +conf.registerGlobalValue(Weather, 'lang', + registry.String('EN', ("""Determines the language used by the plugin."""))) # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=250: diff --git a/Weather/plugin.py b/Weather/plugin.py index 38e1da4..d362105 100644 --- a/Weather/plugin.py +++ b/Weather/plugin.py @@ -1,21 +1,38 @@ # -*- coding: utf-8 -*- ### # Copyright (c) 2012-2014, spline +# Copyright (c) 2014-2015, James Lu # All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +# the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ### -# my libs + + from __future__ import unicode_literals -import json # json. -from math import floor # for wind. -import sqlite3 # userdb. +import json +from math import floor +import sqlite3 try: from itertools import izip -except ImportError: # python3 +except ImportError: # Python 3 izip = zip -# extra supybot libs import supybot.conf as conf import supybot.log as log -# supybot libs import supybot.utils as utils from supybot.commands import * import supybot.plugins as plugins @@ -43,14 +60,13 @@ class WeatherDB(): def makeDb(self): """Create our DB.""" - self.log.info("WeatherDB: Checking/Creating DB.") + self.log.info("Weather: Checking/Creating DB.") with self._conn as conn: cursor = conn.cursor() cursor.execute("""CREATE TABLE IF NOT EXISTS users ( nick TEXT PRIMARY KEY, location TEXT NOT NULL, metric INTEGER DEFAULT 0, - colortemp INTEGER DEFAULT 1, alerts INTEGER DEFAULT 0, almanac INTEGER DEFAULT 0, astronomy INTEGER DEFAULT 0, @@ -67,7 +83,7 @@ class WeatherDB(): cursor = conn.cursor() # the old table is 4. tablelength = len([l[1] for l in cursor.execute("pragma table_info('users')").fetchall()]) if tablelength == 4: # old table is 4: users, location, metric, colortemp. - self.log.info("Table length is 4. We need to upgrade.") + self.log.info("Weather: Upgrading database version.") columns = ['alerts', 'almanac', 'astronomy', 'forecast', 'pressure', 'wind', 'uv', 'visibility', 'dewpoint', 'humidity', 'updated'] for column in columns: try: @@ -129,19 +145,14 @@ class WeatherDB(): class Weather(callbacks.Plugin): - """Add the help for "@plugin help Weather" here - This should describe *how* to use this plugin.""" + """This plugin provides access to information from Weather Underground.""" threaded = True def __init__(self, irc): self.__parent = super(Weather, self) self.__parent.__init__(irc) - self.APIKEY = self.registryValue('apiKey') self.db = WeatherDB() - def die(self): - self.__parent.die() - ############## # FORMATTING # ############## @@ -152,9 +163,6 @@ class Weather(callbacks.Plugin): def _bu(self, string): return ircutils.underline(ircutils.bold(string)) - def _strip(self, string): - return ircutils.stripFormatting(string) - ############################ # INTERNAL WEATHER HELPERS # ############################ @@ -162,105 +170,64 @@ class Weather(callbacks.Plugin): def _weatherSymbol(self, code): """Return a unicode symbol based on weather status.""" - table = {'partlycloudy':'~☁', - 'cloudy':'☁', - 'tstorms':'⚡', - 'sunny':'☀', - 'snow':'❄', - 'sleet':'☄', - 'rain':'☔', - 'mostlysunny':'~☀', - 'mostlycloudy':'~☁', - 'hazy':'♒', - 'fog':'♒', - 'flurries':'❄', - 'clear':'☼', - 'chanceflurries':'?❄', - 'chancerain':'?☔', - 'chancesleet':'?❄', - 'chancesnow':'?❄', - 'chancetstorms':'?☔' } + table = {'partlycloudy': '~☁', + 'cloudy': '☁', + 'tstorms': '⚡', + 'sunny': '☀', + 'snow': '❄', + 'sleet': '☄', + 'rain': '☔', + 'mostlysunny': '~☀', + 'mostlycloudy': '~☁', + 'hazy': '♒', + 'fog': '♒', + 'flurries': '❄', + 'clear': '☼', + 'chanceflurries': '?❄', + 'chancerain': '?☔', + 'chancesleet': '?❄', + 'chancesnow': '?❄', + 'chancetstorms': '?☔'} # return symbol from table. try: return table[code] except KeyError: return "unknown" - def _moonphase(self, phase): - """Returns a moon phase based on the %.""" - - # depending on the phase float, we have an ascii picture+text to represent it. - if phase < 0.05: - symbol = "[ ( ) ] (fullmoon)" - elif phase < 0.20: - symbol = "[ C ] (decreasing moon)" - elif phase < 0.30: - symbol = "[ C ] (half moon)" - elif phase < 0.45: - symbol = "[ ( ] (decreasing moon)" - elif phase < 0.65: - symbol = "[ ] (new moon)" - elif phase < 0.80: - symbol = "[ ) ] (waxing moon)" - elif phase < 0.80: - symbol = "[ D ] (half moon)" - else: - symbol = "[ D ] (waxing moon)" - # return. - return symbol - - def _temp(self, x): + def _temp(self, f, c=None): """Returns a colored string based on the temperature.""" # lets be safe and wrap in a try/except because we can't always trust data purity. try: - if x.startswith('NA'): # Wunderground sends a field that's not available - return x - # first, convert into F so we only have one table. - if x.endswith('C'): # c. - x = float(x[:-1]) * 9 / 5 + 32 # remove C + math into float(F). - unit = "C" - else: # f. - x = float(x[:-1]) # remove F. str->float. - unit = "F" + if str(f).startswith('NA'): # Wunderground sends a field that's not available + return f + f = int(f) + if not c: + c = int((f - 32) * 5/9) # determine color. - if x < 10.0: + if f < 10.0: color = 'light blue' - elif 10.0 <= x <= 32.0: + elif 10.0 <= f <= 32.0: color = 'teal' - elif 32.1 <= x <= 50.0: + elif 32.1 <= f <= 50.0: color = 'blue' - elif 50.1 <= x <= 60.0: + elif 50.1 <= f <= 60.0: color = 'light green' - elif 60.1 <= x <= 70.0: + elif 60.1 <= f <= 70.0: color = 'green' - elif 70.1 <= x <= 80.0: + elif 70.1 <= f <= 80.0: color = 'yellow' - elif 80.1 <= x <= 90.0: + elif 80.1 <= f <= 90.0: color = 'orange' - elif x > 90.0: + elif f > 90.0: color = 'red' else: color = 'light grey' # return. - if unit == "F": # no need to convert back. - return ircutils.mircColor(("{0:.0f}F".format(x)), color) - else: # temp is in F and we need to go back to C. - return ircutils.mircColor(("{0:.0f}C".format((x - 32) * 5 / 9)),color) - except Exception as e: # rutroh. something went wrong. - self.log.info("_temp: ERROR trying to convert temp: {0} message: {1}".format(x, e)) - return x - - def _tw(self, bol, x): - """This is a convenience handle that wraps _temp.""" - - # make sure we have 'bol', which should come in from args['nocolortemp']. - # since the option is a negation, we assume NO. - if not bol: # COLOR IT. - x = self._temp(x) - return x - else: - return x + return ircutils.mircColor(("{0}F/{1}C".format(f, c)), color) + except ValueError as e: + self.log.info("Weather: ValueError trying to convert temp: {0} message: {1}".format(f, e)) + return f def _wind(self, angle, useSymbols=False): """Converts degrees to direction for wind. Can optionally return a symbol.""" @@ -285,8 +252,8 @@ class Weather(callbacks.Plugin): """ Sets a user's to True or False. - Settings: alerts, almanac, astronomy, forecast, pressure, wind, uv, visibility, dewpoint, humidity, updated - Ex: metric True or colortemp False + Valid settings include: alerts, almanac, astronomy, forecast, pressure, + wind, uv, visibility, dewpoint, humidity, and updated. """ # first, lower @@ -294,19 +261,18 @@ class Weather(callbacks.Plugin): # grab a list of valid settings. validset = self.db.getsettings() if optset not in validset: - irc.error("'{0}' is an invalid setting. Must be one of: {1}".format(optset, " | ".join(sorted([i for i in validset]))), Raise=True) - return - # setting value True/False + irc.error(format("%r is an invalid setting. Must be one of: %L.", optset, + sorted(validset)), Raise=True) if optbool: # True. value = 1 else: # False. value = 0 # check user first. if not self.db.getuser(msg.nick.lower()): # user exists - irc.error("You're not in the database. You must setweather first.", Raise=True) + irc.error("You are not in the database; you must use 'setweather' first.", Raise=True) else: # user is valid. perform the op. self.db.setsetting(msg.nick.lower(), optset, value) - irc.reply("I have changed {0}'s {1} setting to {2}".format(msg.nick, optset, value)) + irc.replySuccess() setuser = wrap(setuser, [('somethingWithoutSpaces'), ('boolean')]) @@ -317,10 +283,8 @@ class Weather(callbacks.Plugin): Use your zip/postal code to keep it simple. Ex: setweather 10012 """ - - # set the weather id based on nick. This will update or set. self.db.setweather(msg.nick.lower(), optlocation) - irc.reply("I have changed {0}'s weather ID to {1}".format(msg.nick.lower(), optlocation)) + irc.replySuccess() setweather = wrap(setweather, [('text')]) @@ -332,217 +296,135 @@ class Weather(callbacks.Plugin): """Internal helper to find a location via Wunderground's autocomplete API.""" url = 'http://autocomplete.wunderground.com/aq?query=%s' % utils.web.urlquote(q) - #self.log.info("WUAC URL: {0}".format(url)) - # try and fetch. + self.log.debug("Autocomplete URL: %s", url) try: page = utils.web.getUrl(url) - except Exception as e: # something didn't work. - self.log.info("_wuac: ERROR: Trying to open {0} message: {1}".format(url, e)) + except Exception as e: + self.log.info("Weather: (_wuac) Error trying to open {0} message: {1}".format(url, e)) return None - # now process json and return. try: data = json.loads(page.decode('utf-8')) - loc = data['RESULTS'][0]['zmw'] # find the first zmw. - loc = "zmw:%s" % loc # return w/zmw: attached. + # ZMW is in some ways a lot like Wunderground's location ID Codes, for when locations + # are too ambiguous. (e.g. looking up "France", which is a country with many different + # locations!) + for item in data['RESULTS']: + # Sometimes the autocomplete will lead us to more disambiguation pages... + # which cause lots of errors in processing! + if item['tz'] != 'MISSING': + loc = "zmw:%s" % item['zmw'] + break return loc except Exception as e: - self.log.info("_wuac: ERROR processing json in {0} :: {1}".format(url, e)) + self.log.info("Weather: (_wuac) Error processing JSON in {0} :: {1}".format(url, e)) return None def _wunderjson(self, url, location): """Fetch wunderground JSON and return.""" # first, construct the url properly. - if url.endswith('/'): # cheap way to strip the tailing / + if url.endswith('/'): # cheap way to strip the trailing / url = '%sq/%s.json' % (url, utils.web.urlquote(location)) else: url = '%s/q/%s.json' % (url, utils.web.urlquote(location)) # now actually fetch the url. try: - self.log.info("URL: {0}".format(url)) + self.log.debug("Weather URL: {0}".format(url)) page = utils.web.getUrl(url) return page except Exception as e: # something didn't work. - self.log.info("_wunderjson: ERROR Trying to open {0} message: {1}".format(url, e)) + self.log.info("Weather: (_wunderjson) Error trying to open {0} message: {1}".format(url, e)) return None #################### # PUBLIC FUNCTIONS # #################### - def wunderground(self, irc, msg, args, optlist, optinput): + def wunderground(self, irc, msg, args, optinput): """[--options] - Fetch weather and forcast information for + Fetches weather and forecast information for . - Location must be one of: US state/city (CA/San_Francisco), zipcode, country/city (Australia/Sydney), airport code (KJFK) - Use --help to list all options. + Location must be one of: US state/city (CA/San_Francisco), zip code, country/city (Australia/Sydney), or an airport code (KJFK). Ex: 10021 or Sydney, Australia or KJFK """ - # first, check if we have an API key. Useless w/o this. - if len(self.APIKEY) < 1 or not self.APIKEY or self.APIKEY == "Not set": - irc.error("Need a Wunderground API key. Set config plugins.Weather.apiKey and reload Weather.", Raise=True) + apikey = self.registryValue('apiKey') + if not apikey: + irc.error("No Wunderground API key was defined. Set 'config plugins.Weather.apiKey' and reload the plugin.", + Raise=True) # urlargs will be used to build the url to query the API. # besides lang, these are unmutable values that should not be changed. - urlArgs = {'features':['conditions', 'forecast'], - 'lang':self.registryValue('lang'), - 'bestfct':'1', - 'pws':'0' } - # now, figure out the rest of the options for fetching and displaying weather. - # some of these are for the query and the others are for output. - # the order will always go global->channel (supybot config) -> user. + urlArgs = {'features': ['conditions', 'forecast'], + 'lang': self.registryValue('lang'), + 'bestfct': '1', + 'pws': '0' } loc = None - args = {'imperial':self.registryValue('useImperial', msg.args[0]), - 'nocolortemp':self.registryValue('disableColoredTemp', msg.args[0]), - 'alerts':self.registryValue('alerts'), - 'almanac':self.registryValue('almanac'), - 'astronomy':self.registryValue('astronomy'), - 'pressure':self.registryValue('showPressure'), - 'wind':self.registryValue('showWind'), - 'updated':self.registryValue('showUpdated'), - 'showImperialAndMetric':self.registryValue('showImperialAndMetric', msg.args[0]), - 'forecast':False, - 'humidity':False, - 'strip':False, - 'uv':False, - 'visibility':False, - 'dewpoint':False } + args = {'imperial': self.registryValue('useImperial', msg.args[0]), + 'alerts': self.registryValue('alerts'), + 'almanac': self.registryValue('almanac'), + 'astronomy': self.registryValue('astronomy'), + 'pressure': self.registryValue('showPressure'), + 'wind': self.registryValue('showWind'), + 'updated': self.registryValue('showUpdated'), + 'forecast': False, + 'humidity': False, + 'uv': False, + 'visibility': False, + 'dewpoint': False} - # instead of doing optlist, we need to handle the location/options to set initially. - # first, check if there is a user so we can grab their settings. - usersetting = self.db.getweather(msg.nick.lower()) # check the db. - if usersetting: # user is found. lets grab their location and settings. - for (k, v) in usersetting.items(): # iterate over settings dict returned from getweather row. - # set specific settings based on keys that won't 1:1 match. - if k == 'location': # location. look down below this for how the logic is handled. - loc = v # copy over their location from the DB to loc. - elif k == 'metric': # metric - if v == 1: # true. - args['imperial'] = False - else: # 0 = false. - args['imperial'] = True - elif k == 'colortemp': # colortemp. - if v == 1: # true. - args['nocolortemp'] = False - else: # false. the 'nocolortemp' values are inverse. - args['nocolortemp'] = True - else: # rest of them are 1:1. - if v == 1: # if value is 1, or true. - args[k] = True - else: # argument is 0 or False. - args[k] = False - else: # user was not found. + usersetting = self.db.getweather(msg.nick.lower()) + if usersetting: + for (k, v) in usersetting.items(): + args[k] = v + loc = usersetting["location"] + args['imperial'] = (not usersetting["metric"]) + else: if not optinput: # location was also not specified, so we must bail. - irc.error("I did not find a preset location for you. Set via setweather ", Raise=True) + irc.error("I did not find a preset location for you. Set one via 'setweather '.", Raise=True) - # handle optlist (getopts). this will manipulate output via args dict. - # we must do this after the dblookup for users as it would always override. - if optlist: - for (key, value) in optlist: - if key == "metric": - args['imperial'] = False - if key == 'alerts': - args['alerts'] = True - if key == 'forecast': - args['forecast'] = True - if key == 'almanac': - args['almanac'] = True - if key == 'pressure': - args['pressure'] = True - if key == 'humidity': - args['humidity'] = True - if key == 'wind': - args['wind'] = True - if key == 'uv': - args['uv'] = True - if key == 'visibility': - args['visibility'] = True - if key == 'dewpoint': - args['dewpoint'] = True - if key == 'astronomy': - args['astronomy'] = True - if key == 'nocolortemp': - args['nocolortemp'] = True - if key == 'help': # make shift help because the docstring is overloaded above. - irc.reply("Options: --metric --alerts --forecast --almanac --pressure --wind --uv --visibility --dewpoint --astronomy --nocolortemp") - irc.reply("WeatherDB options: setweather (set user's location). setmetric True/False (set metric option) setcolortemp True/False (display color temp?") - return - - # now that we're done with 'input things' - # we need to decide on how to handle the location. - # optinput = user specified location, regardless if they're known or not. - # loc = the location that can come back if a user is known and this is set. - # both of these might not be valid locations. however, if a user specifies a location, we should look it up. - if optinput: # if we have optinput, regardless if the user is known or not, autocomplete it. + if optinput: wloc = self._wuac(optinput) - if not wloc: # error looking up the location. + if not wloc: irc.error("I could not find a valid location for: {0}".format(optinput), Raise=True) - elif loc and not optinput: # user is known. location is set. no optinput. - wloc = loc # set wloc as their location. worst case, the user gets an error for setting it wrong. + elif loc: # user is known. location is set. no optinput. + wloc = loc else: # no optinput. no location. error out. this should happen above but lets be redundant. irc.error("You must specify a city to search for weather.", Raise=True) - # build url now. first, apikey. then, iterate over urlArgs and insert. - url = 'http://api.wunderground.com/api/%s/' % (self.APIKEY) # first part of url, w/APIKEY - # now we need to set certain things for urlArgs based on args. + url = 'http://api.wunderground.com/api/%s/' % (apikey) for check in ['alerts', 'almanac', 'astronomy']: - if args[check]: # if args['value'] is True, either via config or getopts. + if args[check]: urlArgs['features'].append(check) # append to dict->key (list) # now, we use urlArgs dict to append to url. for (key, value) in urlArgs.items(): if key == "features": # will always be at least conditions. url += "".join([item + '/' for item in value]) # listcmp the features/ - if key == "lang" or key == "bestfct" or key == "pws": # rest added with key:value + if key in ("lang", "bestfct", "pws"): # rest added with key:value url += "{0}:{1}/".format(key, value) - # now that we're done, lets finally make our API call. page = self._wunderjson(url, wloc) - if not page: - irc.error("Failed to load Wunderground API. Check the logs for more information.", Raise=True) - - # process json. try: data = json.loads(page.decode('utf-8')) except Exception as e: - self.log.error("ERROR: could not process JSON from: {0} :: {1}".format(url, e)) + self.log.error("Weather: Error processing JSON from: {0} :: {1}".format(url, e)) irc.error("Could not process JSON from Weather Underground. Check the logs.", Raise=True) - # now, a series of sanity checks before we process. - if 'error' in data['response']: # check if there are errors. - errortype = data['response']['error']['type'] # type. description is below. - errordesc = data['response']['error'].get('description', 'no description') - irc.error("I got an error searching '{0}'. ({1}: {2})".format(loc, errortype, errordesc), Raise=True) - # if there is more than one city matching (Ambiguous Results). we now go with the first (best?) match. - # this should no longer be the case with our autocomplete routine above but we'll keep this anyways. - if 'results' in data['response']: # we grab the first location's "ZMW" which then gets constructed as location. - first = 'zmw:%s' % data['response']['results'][0]['zmw'] # grab the "first" location and create the - # grab this first location and search again. - page = self._wunderjson(url, first) - if not page: - irc.error("Failed to load Wunderground API.", Raise=True) - # we're here if we got the second search (best?) now lets reload the json and continue. - data = json.loads(page.decode('utf-8')) + outdata = {'weather': data['current_observation']['weather'], + 'location': data['current_observation']['display_location']['full'], + 'humidity': data['current_observation']['relative_humidity'], + 'uv': data['current_observation']['UV']} - # no errors so we start the main part of processing. - outdata = {} - outdata['weather'] = data['current_observation']['weather'] - outdata['location'] = data['current_observation']['display_location']['full'] - outdata['humidity'] = data['current_observation']['relative_humidity'] - outdata['uv'] = data['current_observation']['UV'] - - # handle wind. check if there is none first. if data['current_observation']['wind_mph'] < 1: # no wind. outdata['wind'] = "None" - else: # we do have wind. process differently. - if args['imperial']: # imperial units for wind. + else: + if args['imperial']: outdata['wind'] = "{0}@{1}mph".format(self._wind(data['current_observation']['wind_degrees']), data['current_observation']['wind_mph']) - if int(data['current_observation']['wind_gust_mph']) > 0: # gusts? + if int(data['current_observation']['wind_gust_mph']) > 0: outdata['wind'] += " ({0}mph gusts)".format(data['current_observation']['wind_gust_mph']) - else: # handle metric units for wind. + else: outdata['wind'] = "{0}@{1}kph".format(self._wind(data['current_observation']['wind_degrees']),data['current_observation']['wind_kph']) - if int(data['current_observation']['wind_gust_kph']) > 0: # gusts? + if int(data['current_observation']['wind_gust_kph']) > 0: outdata['wind'] += " ({0}kph gusts)".format(data['current_observation']['wind_gust_kph']) # handle the time. concept/method from WunderWeather plugin. @@ -565,118 +447,55 @@ class Weather(callbacks.Plugin): outdata['observation'] = '1hr ago' else: outdata['observation'] = '{0}hrs ago'.format(s/3600) - - # handle basics like temp/pressure/dewpoint. big conditional here - # as we can display Imperial + Metric, or one or the other. - if args['showImperialAndMetric']: - # lets put C and F into strings to make it easier. - tf = str(data['current_observation']['temp_f']) + 'F' - tc = str(data['current_observation']['temp_c']) + 'C' - outdata['temp'] = "{0}/{1}".format(self._tw(args['nocolortemp'], tf), self._tw(args['nocolortemp'], tc)) - # now lets do pressure. - pin = str(data['current_observation']['pressure_in']) + 'in' - pmb = str(data['current_observation']['pressure_mb']) + 'mb' - outdata['pressure'] = "{0}/{1}".format(pin, pmb) - # dewpoint. - dpf = str(data['current_observation']['dewpoint_f']) + 'F' - dpc = str(data['current_observation']['dewpoint_c']) + 'C' - outdata['dewpoint'] = "{0}/{1}".format(self._tw(args['nocolortemp'], dpf), self._tw(args['nocolortemp'], dpc)) - # heatindex. - hif = str(data['current_observation']['heat_index_f']) + 'F' - hic = str(data['current_observation']['heat_index_c']) + 'C' - outdata['heatindex'] = "{0}/{1}".format(self._tw(args['nocolortemp'], hif), self._tw(args['nocolortemp'], hic)) - # windchill. - wcf = str(data['current_observation']['windchill_f']) + 'F' - wcc = str(data['current_observation']['windchill_c']) + 'C' - outdata['windchill'] = "{0}/{1}".format(self._tw(args['nocolortemp'], wcf), self._tw(args['nocolortemp'], wcc)) - # feels like - flf = str(data['current_observation']['feelslike_f']) + 'F' - flc = str(data['current_observation']['feelslike_c']) + 'C' - outdata['feelslike'] = "{0}/{1}".format(self._tw(args['nocolortemp'], flf), self._tw(args['nocolortemp'], flc)) - # visibility. - vmi = str(data['current_observation']['visibility_mi']) + 'mi' - vkm = str(data['current_observation']['visibility_km']) + 'km' - outdata['visibility'] = "{0}/{1}".format(vmi, vkm) - else: # don't display both (default) - if args['imperial']: # assigns the symbol based on metric. - outdata['temp'] = self._tw(args['nocolortemp'], str(data['current_observation']['temp_f']) + 'F') - outdata['pressure'] = str(data['current_observation']['pressure_in']) + 'in' - outdata['dewpoint'] = self._tw(args['nocolortemp'], str(data['current_observation']['dewpoint_f']) + 'F') - outdata['heatindex'] = self._tw(args['nocolortemp'], str(data['current_observation']['heat_index_f']) + 'F') - outdata['windchill'] = self._tw(args['nocolortemp'], str(data['current_observation']['windchill_f']) + 'F') - outdata['feelslike'] = self._tw(args['nocolortemp'], str(data['current_observation']['feelslike_f']) + 'F') - outdata['visibility'] = str(data['current_observation']['visibility_mi']) + 'mi' - else: # metric. - outdata['temp'] = self._tw(args['nocolortemp'], str(data['current_observation']['temp_c']) + 'C') - outdata['pressure'] = str(data['current_observation']['pressure_mb']) + 'mb' - outdata['dewpoint'] = self._tw(args['nocolortemp'], str(data['current_observation']['dewpoint_c']) + 'C') - outdata['heatindex'] = self._tw(args['nocolortemp'], str(data['current_observation']['heat_index_c']) + 'C') - outdata['windchill'] = self._tw(args['nocolortemp'], str(data['current_observation']['windchill_c']) + 'C') - outdata['feelslike'] = self._tw(args['nocolortemp'], str(data['current_observation']['feelslike_c']) + 'C') - outdata['visibility'] = str(data['current_observation']['visibility_km']) + 'km' + outdata['temp'] = self._temp(data['current_observation']['temp_f']) + # pressure. + pin = str(data['current_observation']['pressure_in']) + 'in' + pmb = str(data['current_observation']['pressure_mb']) + 'mb' + outdata['pressure'] = "{0}/{1}".format(pin, pmb) + # dewpoint. + outdata['dewpoint'] = self._temp(data['current_observation']['dewpoint_f']) + # heatindex. + outdata['heatindex'] = self._temp(data['current_observation']['heat_index_f']) + # windchill. + outdata['windchill'] = self._temp(data['current_observation']['windchill_f']) + # feels like + outdata['feelslike'] = self._temp(data['current_observation']['feelslike_f']) + # visibility. + vmi = str(data['current_observation']['visibility_mi']) + 'mi' + vkm = str(data['current_observation']['visibility_km']) + 'km' + outdata['visibility'] = "{0}/{1}".format(vmi, vkm) # handle forecast data part. output will be below. (not --forecast) forecastdata = {} # key = int(day), value = forecast dict. for forecastday in data['forecast']['txt_forecast']['forecastday']: - tmpdict = {} - tmpdict['day'] = forecastday['title'] - # tmpdict['symbol'] = self._weatherSymbol(forecastday['icon']) - if args['imperial']: # imperial. - tmpdict['text'] = forecastday['fcttext'] - else: # metric. - tmpdict['text'] = forecastday['fcttext_metric'] - forecastdata[int(forecastday['period'])] = tmpdict + if args['imperial']: + text = forecastday['fcttext'] + else: + text = forecastday['fcttext_metric'] + forecastdata[int(forecastday['period'])] = {'day': forecastday['title'], + 'text': text} - # now this is the --forecast part. - if args['forecast']: # only if we get this in getopts. - fullforecastdata = {} # key = day (int), value = dict of forecast data. - for forecastday in data['forecast']['simpleforecast']['forecastday']: - tmpdict = {} - tmpdict['day'] = forecastday['date']['weekday_short'] - tmpdict['symbol'] = self._weatherSymbol(forecastday['icon']) - tmpdict['text'] = forecastday['conditions'] - if args['imperial']: # imperial. - tmpdict['high'] = forecastday['high']['fahrenheit'] + "F" - tmpdict['low'] = forecastday['low']['fahrenheit'] + "F" - else: # metric. - tmpdict['high'] = forecastday['high']['celsius'] + "C" - tmpdict['low'] = forecastday['low']['celsius'] + "C" - fullforecastdata[int(forecastday['period'])] = tmpdict - - # handle almanac - if args['almanac']: - outdata['highyear'] = data['almanac']['temp_high'].get('recordyear', 'NA') - outdata['lowyear'] = data['almanac']['temp_low'].get('recordyear', 'NA') - if args['imperial']: # imperial. - outdata['highnormal'] = data['almanac']['temp_high']['normal']['F'] + "F" - outdata['lownormal'] = data['almanac']['temp_low']['normal']['F'] + "F" - if outdata['highyear'] != "NA" and outdata['lowyear'] != "NA": - outdata['highrecord'] = data['almanac']['temp_high']['record']['F'] - outdata['lowrecord'] = data['almanac']['temp_low']['record']['F'] - else: - outdata['highrecord'] = "NA" - outdata['lowrecord'] = "NA" - else: # metric. - outdata['highnormal'] = data['almanac']['temp_high']['normal']['C'] + "C" - outdata['lownormal'] = data['almanac']['temp_low']['normal']['C'] + "C" - if outdata['highyear'] != "NA" and outdata['lowyear'] != "NA": - outdata['highrecord'] = data['almanac']['temp_high']['record']['C'] - outdata['lowrecord'] = data['almanac']['temp_low']['record']['C'] - else: - outdata['highrecord'] = "NA" - outdata['lowrecord'] = "NA" - - # handle astronomy - if args['astronomy']: - outdata['moonilluminated'] = data['moon_phase']['percentIlluminated'] - outdata['moonage'] = data['moon_phase']['ageOfMoon'] - sunriseh = data['moon_phase']['sunrise']['hour'] - sunrisem = data['moon_phase']['sunrise']['minute'] - sunseth = data['moon_phase']['sunset']['hour'] - sunsetm = data['moon_phase']['sunset']['minute'] - outdata['sunrise'] = "{0}:{1}".format(sunriseh, sunrisem) # construct sunrise. - outdata['sunset'] = "{0}:{1}".format(sunseth, sunsetm) # construct sunset. calc "time of day" below. - outdata['lengthofday'] = "%dh%dm" % divmod((((int(sunseth)-int(sunriseh))+float((int(sunsetm)-int(sunrisem))/60.0))*60),60) + output = "{0} :: {1} ::".format(self._bold(outdata['location']), outdata['weather']) + output += " {0} ".format(outdata['temp']) + # humidity. + if args['humidity']: + output += " (Humidity: {0}) ".format(outdata['humidity']) + # windchill/heatindex are conditional on season but test with startswith to see what to include + if not outdata['windchill'].startswith("NA"): + output += "| {0} {1} ".format(self._bold('Wind Chill:'), outdata['windchill']) + if not outdata['heatindex'].startswith("NA"): + output += "| {0} {1} ".format(self._bold('Heat Index:'), outdata['heatindex']) + # now get into the args dict for what to include (extras) + for k in ('wind', 'visibility', 'uv', 'pressure', 'dewpoint'): + if args[k]: + output += "| {0}: {1} ".format(self._bold(k.title()), outdata[k]) + # add in the first two forecast item in conditions + updated time. + output += "| {0}: {1}".format(self._bold(forecastdata[0]['day']), forecastdata[0]['text']) + output += " {0}: {1}".format(self._bold(forecastdata[1]['day']), forecastdata[1]['text']) + if args['updated']: + output += " | {0} {1}".format(self._bold('Updated:'), outdata['observation']) + # finally, output the basic weather. + irc.reply(output) # handle alerts if args['alerts']: # only look for alerts if there. @@ -686,86 +505,65 @@ class Weather(callbacks.Plugin): outdata['alerts'] = utils.str.normalizeWhitespace(outdata['alerts']) # fix pesky double whitespacing. else: # no alerts found (empty). outdata['alerts'] = "No alerts." + irc.reply("{0} {1}".format(self._bu("Alerts:"), outdata['alerts'])) - # OUTPUT. - # we go step-by-step to build the proper string. ° u" \u00B0C" - output = "{0} :: {1} ::".format(self._bold(outdata['location']), outdata['weather']) - # add in temperature. - output += " {0}".format(outdata['temp']) - # humidity. - if args['humidity']: # display humidity? - output += " (Humidity: {0}) ".format(outdata['humidity']) - else: - output += " " - # windchill/heatindex are conditional on season but test with startswith to see what to include - if not outdata['windchill'].startswith("NA"): # windchill. - output += "| {0} {1} ".format(self._bold('Wind Chill:'), outdata['windchill']) - if not outdata['heatindex'].startswith("NA"): # heatindex. - output += "| {0} {1} ".format(self._bold('Heat Index:'), outdata['heatindex']) - # now get into the args dict for what to include (extras) - for (k, v) in args.items(): - if k in ['wind', 'visibility', 'uv', 'pressure', 'dewpoint']: # if key is in extras - if v: # if that key's value is True, we add it. - output += "| {0}: {1} ".format(self._bold(k.title()), outdata[k]) - # add in the first two forecast item in conditions + updated time. - output += "| {0}: {1}".format(self._bold(forecastdata[0]['day']), forecastdata[0]['text']) - output += " {0}: {1}".format(self._bold(forecastdata[1]['day']), forecastdata[1]['text']) - # show Updated? - if args['updated']: - output += " | {0} {1}".format(self._bold('Updated:'), outdata['observation']) - # finally, output the basic weather. - irc.reply(output) - - # next, for outputting, handle the extras like alerts, almanac, astronomy, forecast. - if args['alerts']: # if --alerts issued. - irc.reply("{0} :: {1}".format(self._bu("Alerts:"), outdata['alerts'])) - # handle almanac if --almanac is given. + # handle almanac if args['almanac']: - if args['nocolortemp']: # disable colored temp? - output = "{0} :: Normal High: {1} (Record: {2} in {3}) | Normal Low: {4} (Record: {5} in {6})".format(\ - self._bu('Almanac:'), outdata['highnormal'], outdata['highrecord'], outdata['highyear'],\ - outdata['lownormal'], outdata['lowrecord'], outdata['lowyear']) - else: # colored temp. - output = "{0} :: Normal High: {1} (Record: {2} in {3}) | Normal Low: {4} (Record: {5} in {6})".format(\ - self._bu('Almanac:'), self._temp(outdata['highnormal']), self._temp(outdata['highrecord']),\ - outdata['highyear'], self._temp(outdata['lownormal']), self._temp(outdata['lowrecord']), outdata['lowyear']) - # now output to irc. + try: + outdata['highyear'] = data['almanac']['temp_high'].get('recordyear') + outdata['lowyear'] = data['almanac']['temp_low'].get('recordyear') + outdata['highaverage'] = self._temp(data['almanac']['temp_high']['average']['F']) + outdata['lowaverage'] = self._temp(data['almanac']['temp_low']['average']['F']) + if outdata['highyear'] != "NA" and outdata['lowyear'] != "NA": + outdata['highrecord'] = self._temp(data['almanac']['temp_high']['record']['F']) + outdata['lowrecord'] = self._temp(data['almanac']['temp_low']['record']['F']) + else: + outdata['highrecord'] = outdata['lowrecord'] = "NA" + except KeyError: + output = "%s Not available." % self._bu('Almanac:') + else: + output = ("{0} Average High: {1} (Record: {2} in {3}) | Average Low: {4} (Record: {5} in {6})".format( + self._bu('Almanac:'), outdata['highaverage'], outdata['highrecord'], outdata['highyear'], + outdata['lowaverage'], outdata['lowrecord'], outdata['lowyear'])) irc.reply(output) - # handle astronomy if --astronomy is given. + # handle astronomy if args['astronomy']: - output = "{0} :: Moon illum: {1}% Moon age: {2}d Sunrise: {3} Sunset: {4} Length of Day: {5}".format(\ - self._bu('Astronomy:'), outdata['moonilluminated'], outdata['moonage'],outdata['sunrise'],\ - outdata['sunset'], outdata['lengthofday']) - # irc output now. + sunriseh = data['moon_phase']['sunrise']['hour'] + sunrisem = data['moon_phase']['sunrise']['minute'] + sunseth = data['moon_phase']['sunset']['hour'] + sunsetm = data['moon_phase']['sunset']['minute'] + sunrise = "{0}:{1}".format(sunriseh, sunrisem) + sunset = "{0}:{1}".format(sunseth, sunsetm) + # Oh god, this one-liner... -GLolol + lengthofday = "%dh%dm" % divmod((((int(sunseth)-int(sunriseh))+float((int(sunsetm)-int(sunrisem))/60.0))*60 ),60) + astronomy = {'Moon illum:': str(data['moon_phase']['percentIlluminated']) + "%", + 'Moon age:': str(data['moon_phase']['ageOfMoon']) + "d", + 'Sunrise:': sunrise, + 'Sunset:': sunset, + 'Length of Day:': lengthofday} + output = [format('%s %s', self._bold(k), v) for k, v in sorted(astronomy.items())] + output = format("%s %s", self._bu('Astronomy:'), " | ".join(output)) irc.reply(output) - # handle main forecast if --forecast is given. + # handle forecast if args['forecast']: + fullforecastdata = {} # key = day (int), value = dict of forecast data. + for forecastday in data['forecast']['simpleforecast']['forecastday']: + high = self._temp(forecastday['high']['fahrenheit']) + low = self._temp(forecastday['low']['fahrenheit']) + tmpdict = {'day': forecastday['date']['weekday_short'], + 'symbol': self._weatherSymbol(forecastday['icon']), + 'text': forecastday['conditions'], + 'low': low, + 'high': high} + fullforecastdata[int(forecastday['period'])] = tmpdict outforecast = [] # prep string for output. for (k, v) in fullforecastdata.items(): # iterate through forecast data. - if args['nocolortemp']: - outforecast.append("{0}: {1} ({2}/{3})".format(self._bold(v['day']),\ + outforecast.append("{0}: {1} (High: {2} Low: {3})".format(self._bold(v['day']), v['text'], v['high'], v['low'])) - else: - outforecast.append("{0}: {1} ({2}/{3})".format(self._bold(v['day']),\ - v['text'], self._temp(v['high']), self._temp(v['low']))) - # construct our string to output. - output = "{0} :: {1}".format(self._bu('Forecast:'), " | ".join(outforecast)) - # now output to irc. + output = "{0} {1}".format(self._bu('Forecast:'), " | ".join(outforecast)) irc.reply(output) - wunderground = wrap(wunderground, [getopts({'alerts':'', - 'almanac':'', - 'astronomy':'', - 'forecast':'', - 'pressure':'', - 'wind':'', - 'uv':'', - 'visibility':'', - 'dewpoint':'', - 'humidity':'', - 'metric':'', - 'nocolortemp':'', - 'help':''}), optional('text')]) + wunderground = wrap(wunderground, [optional('text')]) Class = Weather diff --git a/Weather/test.py b/Weather/test.py index 965658f..ba628f6 100644 --- a/Weather/test.py +++ b/Weather/test.py @@ -13,19 +13,18 @@ class WeatherTestCase(PluginTestCase): def setUp(self): PluginTestCase.setUp(self) - apiKey = os.environ.get('apiKey') + apiKey = os.environ.get('weather_apikey') if not apiKey: e = """The Wunderground API key has not been set. - please set this value correctly and try again: - 'export apiKey=' for bash users""" + please set this value correctly via the environment variable + "weather_apikey".""" raise Exception(e) conf.supybot.plugins.Weather.apiKey.setValue(apiKey) def testWeather(self): - self.assertSnarfResponse('reload Weather', 'The operation succeeded.') self.assertRegexp('wunderground 10002', 'New York, NY') - self.assertSnarfResponse('setweather 10002', "I have changed test's weather ID to 10002") - self.assertSnarfResponse('setuser metric True', "I have changed test's metric setting to 1") + self.assertNotError('setweather 10002') + self.assertNotError('setuser metric True') self.assertRegexp('wunderground', 'New York, NY') # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: