diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..6951de8 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,6 @@ +[FORMAT] +max-line-length=120 +good-names=ip,f,i,e,s + +[MESSAGES CONTROL] +disable=missing-class-docstring,missing-function-docstring,invalid-name diff --git a/NuWeather/__init__.py b/NuWeather/__init__.py index 543e03b..b276433 100644 --- a/NuWeather/__init__.py +++ b/NuWeather/__init__.py @@ -54,14 +54,11 @@ __contributors__ = {} # This is a url where the most recent plugin package can be downloaded. __url__ = 'https://github.com/jlu5/SupyPlugins/tree/master/NuWeather' -from . import config -from . import plugin -if sys.version_info >= (3, 4): - from importlib import reload -else: - from imp import reload +from . import config, formatter, plugin +from importlib import reload # In case we're being reloaded. reload(config) +reload(formatter) reload(plugin) from .local import accountsdb diff --git a/NuWeather/formatter.py b/NuWeather/formatter.py new file mode 100644 index 0000000..8fa9278 --- /dev/null +++ b/NuWeather/formatter.py @@ -0,0 +1,250 @@ +### +# Copyright (c) 2011-2014, Valentin Lorentz +# Copyright (c) 2018-2022, James Lu +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import re +import string + +from supybot import callbacks, conf, ircutils, log, utils + +try: + import pendulum +except ImportError: + pendulum = None + log.warning('NuWeather: pendulum is not installed; extended forecasts will not be formatted properly') + +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('NuWeather') +except ImportError: + _ = lambda x: x + +from .config import DEFAULT_FORMAT, DEFAULT_FORECAST_FORMAT, DEFAULT_FORMAT_CURRENTONLY + +_channel_context = None +# dummy fallback for testing +_registryValue = lambda *args, **kwargs: '' + +# Based off https://github.com/ProgVal/Supybot-plugins/blob/master/GitHub/plugin.py +def flatten_subdicts(dicts, flat=None): + """Flattens a dict containing dicts or lists of dicts. Useful for string formatting.""" + if flat is None: + # Instanciate the dictionnary when the function is run and now when it + # is declared; otherwise the same dictionnary instance will be kept and + # it will have side effects (memory exhaustion, ...) + flat = {} + if isinstance(dicts, list): + return flatten_subdicts(dict(enumerate(dicts))) + elif isinstance(dicts, dict): + for key, value in dicts.items(): + if isinstance(value, dict): + value = dict(flatten_subdicts(value)) + for subkey, subvalue in value.items(): + flat['%s__%s' % (key, subkey)] = subvalue + elif isinstance(value, list): + for num, subvalue in enumerate(value): + if isinstance(subvalue, dict): + for subkey, subvalue in subvalue.items(): + flat['%s__%s__%s' % (key, num, subkey)] = subvalue + else: + flat['%s__%s' % (key, num)] = subvalue + else: + flat[key] = value + return flat + else: + return dicts + +def format_temp(f=None, c=None): + """ + Colorizes temperatures and formats them to show either Fahrenheit, Celsius, or both. + """ + if f is None and c is None: + return _('N/A') + if f is None: + f = c * 9/5 + 32 + elif c is None: + c = (f - 32) * 5/9 + + f = float(f) + if f < 10: + color = 'light blue' + elif f < 32: + color = 'teal' + elif f < 50: + color = 'blue' + elif f < 60: + color = 'light green' + elif f < 70: + color = 'green' + elif f < 80: + color = 'yellow' + elif f < 90: + color = 'orange' + else: + color = 'red' + # Show temp values to one decimal place + c = '%.1f' % c + f = '%.1f' % f + + displaymode = _registryValue('units.temperature', channel=_channel_context) + if displaymode == 'F/C': + s = '%sF/%sC' % (f, c) + elif displaymode == 'C/F': + s = '%sC/%sF' % (c, f) + elif displaymode == 'F': + s = '%sF' % f + elif displaymode == 'C': + s = '%sC' % c + else: + raise ValueError("Unknown display mode for temperature.") + return ircutils.mircColor(s, color) + +_TEMPERATURES_RE = re.compile(r'((\d+)°?F)') # Only need FtoC conversion so far +def mangle_temperatures(forecast): + """Runs _format_temp() on temperature values embedded within forecast strings.""" + if not forecast: + return forecast + for (text, value) in set(_TEMPERATURES_RE.findall(forecast)): + forecast = forecast.replace(text, format_temp(f=value)) + return forecast + +def wind_direction(angle): + """Returns wind direction (N, W, S, E, etc.) given an angle.""" + # Adapted from https://stackoverflow.com/a/7490772 + directions = ('N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW') + if angle is None: + return directions[0] # dummy output + angle = int(angle) + idx = int((angle/(360/len(directions)))+.5) + return directions[idx % len(directions)] + +def format_uv(uv): + """Formats UV levels with IRC colouring""" + if uv is None: + return _('N/A') + # From https://en.wikipedia.org/wiki/Ultraviolet_index#Index_usage 2018-12-30 + uv = float(uv) + if uv <= 2.9: + color, risk = 'green', 'Low' + elif uv <= 5.9: + color, risk = 'yellow', 'Moderate' + elif uv <= 7.9: + color, risk = 'orange', 'High' + elif uv <= 10.9: + color, risk = 'red', 'Very high' + else: + # Closest we have to violet + color, risk = 'pink', 'Extreme' + s = '%d (%s)' % (uv, risk) + return ircutils.mircColor(s, color) + +def format_precip(mm=None, inches=None): + """Formats precipitation to mm/in format""" + if mm is None and inches is None: + return _('N/A') + elif mm == 0 or inches == 0: + return '0' # Don't bother with 2 units if the value is 0 + + if mm is None: + mm = round(inches * 25.4, 1) + elif inches is None: + inches = round(mm / 25.4, 1) + + return _('%smm/%sin') % (mm, inches) + +def format_distance(mi=None, km=None, speed=False): + """Formats distance or speed values in miles and kilometers""" + if mi is None and km is None: + return _('N/A') + elif mi == 0 or km == 0: + return '0' # Don't bother with 2 units if the value is 0 + + if mi is None: + mi = round(km / 1.609, 1) + elif km is None: + km = round(mi * 1.609, 1) + + if speed: + return _('%smph/%skph') % (mi, km) + else: + return _('%smi/%skm') % (mi, km) + +def format_percentage(value): + """ + Formats percentage values given either as an int (value%) or float (0 <= value <= 1). + """ + if isinstance(value, float): + return '%.0f%%' % (value * 100) + elif isinstance(value, int): + return '%d%%' % value + else: + return 'N/A' + +def get_dayname(ts, idx, *, tz=None): + """ + Returns the day name given a Unix timestamp, day index and (optionally) a timezone. + """ + if pendulum is not None: + p = pendulum.from_timestamp(ts, tz=tz) + return p.format('dddd') + else: + # Fallback + if idx == 0: + return 'Today' + elif idx == 1: + return 'Tomorrow' + else: + return 'Day_%d' % idx + +def format_weather(data, forecast=False): + """ + Formats and returns current conditions. + """ + # Work around IRC length limits for config opts... + data['c'] = data['current'] + data['f'] = data.get('forecast') + + flat_data = flatten_subdicts(data) + if flat_data.get('url'): + flat_data['url'] = utils.str.url(flat_data['url']) + + forecast_available = bool(data.get('forecast')) + if forecast: # --forecast option was given + if forecast_available: + fmt = _registryValue('outputFormat.forecast', channel=_channel_context) or DEFAULT_FORECAST_FORMAT + else: + raise callbacks.Error(_("Extended forecast info is not available from this backend.")) + else: + if forecast_available: + fmt = _registryValue('outputFormat', channel=_channel_context) or DEFAULT_FORMAT + else: + fmt = _registryValue('outputFormat.currentOnly', channel=_channel_context) or DEFAULT_FORMAT_CURRENTONLY + template = string.Template(fmt) + + return template.safe_substitute(flat_data) diff --git a/NuWeather/plugin.py b/NuWeather/plugin.py index a29b5ff..4788504 100644 --- a/NuWeather/plugin.py +++ b/NuWeather/plugin.py @@ -1,6 +1,5 @@ ### -# Copyright (c) 2011-2014, Valentin Lorentz -# Copyright (c) 2018-2020, James Lu +# Copyright (c) 2018-2022, James Lu # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -29,8 +28,6 @@ ### import json import os -import re -import string from supybot import utils, plugins, ircutils, callbacks, world, conf, log from supybot.commands import * @@ -42,48 +39,14 @@ except ImportError: # without the i18n module _ = lambda x: x -try: - import pendulum -except ImportError: - pendulum = None - log.warning('NuWeather: pendulum is not installed; extended forecasts will not be formatted properly') - -from .config import BACKENDS, GEOCODE_BACKENDS, DEFAULT_FORMAT, DEFAULT_FORECAST_FORMAT, DEFAULT_FORMAT_CURRENTONLY +from .config import BACKENDS, GEOCODE_BACKENDS from .local import accountsdb +from . import formatter HEADERS = { 'User-agent': 'Mozilla/5.0 (compatible; Supybot/Limnoria %s; NuWeather weather plugin)' % conf.version } -# Based off https://github.com/ProgVal/Supybot-plugins/blob/master/GitHub/plugin.py -def flatten_subdicts(dicts, flat=None): - """Flattens a dict containing dicts or lists of dicts. Useful for string formatting.""" - if flat is None: - # Instanciate the dictionnary when the function is run and now when it - # is declared; otherwise the same dictionnary instance will be kept and - # it will have side effects (memory exhaustion, ...) - flat = {} - if isinstance(dicts, list): - return flatten_subdicts(dict(enumerate(dicts))) - elif isinstance(dicts, dict): - for key, value in dicts.items(): - if isinstance(value, dict): - value = dict(flatten_subdicts(value)) - for subkey, subvalue in value.items(): - flat['%s__%s' % (key, subkey)] = subvalue - elif isinstance(value, list): - for num, subvalue in enumerate(value): - if isinstance(subvalue, dict): - for subkey, subvalue in subvalue.items(): - flat['%s__%s__%s' % (key, num, subkey)] = subvalue - else: - flat['%s__%s' % (key, num)] = subvalue - else: - flat[key] = value - return flat - else: - return dicts - class NuWeather(callbacks.Plugin): """Weather plugin for Limnoria""" threaded = True @@ -101,7 +64,8 @@ class NuWeather(callbacks.Plugin): self.geocode_db = {} world.flushers.append(self.db.flush) world.flushers.append(self._flush_geocode_db) - self._channel_context = None + # this is hacky but less annoying than navigating the registry ourselves + formatter._registryValue = self.registryValue def _flush_geocode_db(self): geocode_db_filename = conf.supybot.directories.data.dirize("NuWeather-geocode.json") @@ -115,150 +79,6 @@ class NuWeather(callbacks.Plugin): self._flush_geocode_db() super().die() - def _format_temp(self, f, c=None): - """ - Colorizes temperatures and formats them to show either Fahrenheit, Celsius, or both. - """ - if f is None: - return _('N/A') - - f = float(f) - if f < 10: - color = 'light blue' - elif f < 32: - color = 'teal' - elif f < 50: - color = 'blue' - elif f < 60: - color = 'light green' - elif f < 70: - color = 'green' - elif f < 80: - color = 'yellow' - elif f < 90: - color = 'orange' - else: - color = 'red' - # Round to nearest tenth for display purposes - if c is None: - c = round((f - 32) * 5/9, 1) - else: - c = round(c, 1) - f = round(f, 1) - - displaymode = self.registryValue('units.temperature', channel=self._channel_context) - if displaymode == 'F/C': - string = '%sF/%sC' % (f, c) - elif displaymode == 'C/F': - string = '%sC/%sF' % (c, f) - elif displaymode == 'F': - string = '%sF' % f - elif displaymode == 'C': - string = '%sC' % c - else: - raise ValueError("Unknown display mode for temperature.") - return ircutils.mircColor(string, color) - - _temperatures_re = re.compile(r'((\d+)°?F)') # Only need FtoC conversion so far - def _mangle_temperatures(self, forecast): - """Runs _format_temp() on temperature values embedded within forecast strings.""" - if not forecast: - return forecast - for (text, value) in set(self._temperatures_re.findall(forecast)): - forecast = forecast.replace(text, self._format_temp(f=value)) - return forecast - - @staticmethod - def _wind_direction(angle): - """Returns wind direction (N, W, S, E, etc.) given an angle.""" - # Adapted from https://stackoverflow.com/a/7490772 - directions = ('N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW') - if angle is None: - return directions[0] # dummy output - angle = int(angle) - idx = int((angle/(360/len(directions)))+.5) - return directions[idx % len(directions)] - - @staticmethod - def _format_uv(uv): - if uv is None: - return _('N/A') - # From https://en.wikipedia.org/wiki/Ultraviolet_index#Index_usage 2018-12-30 - uv = float(uv) - if uv <= 2.9: - color, risk = 'green', 'Low' - elif uv <= 5.9: - color, risk = 'yellow', 'Moderate' - elif uv <= 7.9: - color, risk = 'orange', 'High' - elif uv <= 10.9: - color, risk = 'red', 'Very high' - else: - # Closest we have to violet - color, risk = 'pink', 'Extreme' - string = '%d (%s)' % (uv, risk) - return ircutils.mircColor(string, color) - - @staticmethod - def _format_precip(mm=None, inches=None): - if mm is None and inches is None: - return _('N/A') - elif mm == 0 or inches == 0: - return '0' # Don't bother with 2 units if the value is 0 - - if mm is None: - mm = round(inches * 25.4, 1) - elif inches is None: - inches = round(mm / 25.4, 1) - - return _('%smm/%sin') % (mm, inches) - - @staticmethod - def _format_distance(mi=None, km=None, speed=False): - if mi is None and km is None: - return _('N/A') - elif mi == 0 or km == 0: - return '0' # Don't bother with 2 units if the value is 0 - - if mi is None: - mi = round(km / 1.609, 1) - elif km is None: - km = round(mi * 1.609, 1) - - if speed: - return _('%smph/%skph') % (mi, km) - else: - return _('%smi/%skm') % (mi, km) - - @staticmethod - def _format_percentage(value): - """ - Formats percentage values given either as an int (value%) or float (0 <= value <= 1). - """ - if isinstance(value, float): - return '%.0f%%' % (value * 100) - elif isinstance(value, int): - return '%d%%' % value - else: - return 'N/A' - - @staticmethod - def _get_dayname(ts, idx, *, tz=None): - """ - Returns the day name given a Unix timestamp, day index and (optionally) a timezone. - """ - if pendulum is not None: - p = pendulum.from_timestamp(ts, tz=tz) - return p.format('dddd') - else: - # Fallback - if idx == 0: - return 'Today' - elif idx == 1: - return 'Tomorrow' - else: - return 'Day_%d' % idx - def _nominatim_geocode(self, location): location = location.lower() @@ -368,7 +188,7 @@ class NuWeather(callbacks.Plugin): return result def _geocode(self, location, geobackend=None): - geocode_backend = geobackend or self.registryValue('geocodeBackend', channel=self._channel_context) + geocode_backend = geobackend or self.registryValue('geocodeBackend', channel=formatter._channel_context) if geocode_backend not in GEOCODE_BACKENDS: raise callbacks.Error(_("Unknown geocode backend %r. Valid ones are: %s") % (geocode_backend, ', '.join(GEOCODE_BACKENDS))) @@ -387,33 +207,6 @@ class NuWeather(callbacks.Plugin): self.geocode_db[result_pair] = result # Cache result persistently return result - def _format(self, data, forecast=False): - """ - Formats and returns current conditions. - """ - # Work around IRC length limits for config opts... - data['c'] = data['current'] - data['f'] = data.get('forecast') - - flat_data = flatten_subdicts(data) - if flat_data.get('url'): - flat_data['url'] = utils.str.url(flat_data['url']) - - forecast_available = bool(data.get('forecast')) - if forecast: # --forecast option was given - if forecast_available: - fmt = self.registryValue('outputFormat.forecast', channel=self._channel_context) or DEFAULT_FORECAST_FORMAT - else: - raise callbacks.Error(_("Extended forecast info is not available from this backend.")) - else: - if forecast_available: - fmt = self.registryValue('outputFormat', channel=self._channel_context) or DEFAULT_FORMAT - else: - fmt = self.registryValue('outputFormat.currentOnly', channel=self._channel_context) or DEFAULT_FORMAT_CURRENTONLY - template = string.Template(fmt) - - return template.safe_substitute(flat_data) - def _weatherstack_fetcher(self, location, geobackend=None): """Grabs weather data from weatherstack (formerly Apixu).""" apikey = self.registryValue('apikeys.weatherstack') @@ -439,14 +232,14 @@ class NuWeather(callbacks.Plugin): 'url': '', 'current': { 'condition': currentdata['weather_descriptions'][0], - 'temperature': self._format_temp(f=currentdata['temperature']), - 'feels_like': self._format_temp(f=currentdata['feelslike']), - 'humidity': self._format_percentage(currentdata['humidity']), - 'precip': self._format_precip(inches=currentdata['precip']), - 'wind': self._format_distance(mi=currentdata['wind_speed'], speed=True), + 'temperature': formatter.format_temp(f=currentdata['temperature']), + 'feels_like': formatter.format_temp(f=currentdata['feelslike']), + 'humidity': formatter.format_percentage(currentdata['humidity']), + 'precip': formatter.format_precip(inches=currentdata['precip']), + 'wind': formatter.format_distance(mi=currentdata['wind_speed'], speed=True), 'wind_dir': currentdata['wind_dir'], - 'uv': self._format_uv(currentdata['uv_index']), - 'visibility': self._format_distance(mi=currentdata.get('visibility')), + 'uv': formatter.format_uv(currentdata['uv_index']), + 'visibility': formatter.format_distance(mi=currentdata.get('visibility')), } } @@ -479,19 +272,19 @@ class NuWeather(callbacks.Plugin): 'url': 'https://darksky.net/forecast/%s,%s' % (lat, lon), 'current': { 'condition': currentdata.get('summary', 'N/A'), - 'temperature': self._format_temp(f=currentdata.get('temperature')), - 'feels_like': self._format_temp(f=currentdata.get('apparentTemperature')), - 'humidity': self._format_percentage(currentdata.get('humidity')), - 'precip': self._format_precip(mm=currentdata.get('precipIntensity')), - 'wind': self._format_distance(mi=currentdata.get('windSpeed', 0), speed=True), - 'wind_gust': self._format_distance(mi=currentdata.get('windGust', 0), speed=True), - 'wind_dir': self._wind_direction(currentdata.get('windBearing')), - 'uv': self._format_uv(currentdata.get('uvIndex')), - 'visibility': self._format_distance(mi=currentdata.get('visibility')), + 'temperature': formatter.format_temp(f=currentdata.get('temperature')), + 'feels_like': formatter.format_temp(f=currentdata.get('apparentTemperature')), + 'humidity': formatter.format_percentage(currentdata.get('humidity')), + 'precip': formatter.format_precip(mm=currentdata.get('precipIntensity')), + 'wind': formatter.format_distance(mi=currentdata.get('windSpeed', 0), speed=True), + 'wind_gust': formatter.format_distance(mi=currentdata.get('windGust', 0), speed=True), + 'wind_dir': formatter.wind_direction(currentdata.get('windBearing')), + 'uv': formatter.format_uv(currentdata.get('uvIndex')), + 'visibility': formatter.format_distance(mi=currentdata.get('visibility')), }, - 'forecast': [{'dayname': self._get_dayname(forecastdata['time'], idx, tz=data['timezone']), - 'max': self._format_temp(f=forecastdata.get('temperatureHigh')), - 'min': self._format_temp(f=forecastdata.get('temperatureLow')), + 'forecast': [{'dayname': formatter.get_dayname(forecastdata['time'], idx, tz=data['timezone']), + 'max': formatter.format_temp(f=forecastdata.get('temperatureHigh')), + 'min': formatter.format_temp(f=forecastdata.get('temperatureLow')), 'summary': forecastdata.get('summary', 'N/A').rstrip('.')} for idx, forecastdata in enumerate(data['daily']['data'])] } @@ -522,9 +315,9 @@ class NuWeather(callbacks.Plugin): # XXX: are the units for this consistent across APIs? if currentdata.get('snow'): - precip = self._format_precip(mm=currentdata['snow']['1h'] * 10) + precip = formatter.format_precip(mm=currentdata['snow']['1h'] * 10) elif currentdata.get('rain'): - precip = self._format_precip(mm=currentdata['rain']['1h']) + precip = formatter.format_precip(mm=currentdata['rain']['1h']) else: precip = 'N/A' @@ -538,22 +331,22 @@ class NuWeather(callbacks.Plugin): }), 'current': { 'condition': currentdata['weather'][0]['description'], - 'temperature': self._format_temp(f=currentdata['temp']), - 'feels_like': self._format_temp(f=currentdata['feels_like']), - 'humidity': self._format_percentage(currentdata['humidity']), + 'temperature': formatter.format_temp(f=currentdata['temp']), + 'feels_like': formatter.format_temp(f=currentdata['feels_like']), + 'humidity': formatter.format_percentage(currentdata['humidity']), 'precip': precip, - 'wind': self._format_distance(mi=currentdata['wind_speed'], speed=True), - 'wind_dir': self._wind_direction(currentdata['wind_deg']), - 'wind_gust': self._format_distance(mi=currentdata.get('wind_gust'), speed=True), - 'uv': self._format_uv(currentdata.get('uvi')), - 'visibility': self._format_distance(km=currentdata['visibility']/1000), + 'wind': formatter.format_distance(mi=currentdata['wind_speed'], speed=True), + 'wind_dir': formatter.wind_direction(currentdata['wind_deg']), + 'wind_gust': formatter.format_distance(mi=currentdata.get('wind_gust'), speed=True), + 'uv': formatter.format_uv(currentdata.get('uvi')), + 'visibility': formatter.format_distance(km=currentdata['visibility']/1000), } } output['forecast'] = [ - {'dayname': self._get_dayname(forecast['dt'], idx, tz=data['timezone']), - 'max': self._format_temp(f=forecast['temp']['max']), - 'min': self._format_temp(f=forecast['temp']['min']), + {'dayname': formatter.get_dayname(forecast['dt'], idx, tz=data['timezone']), + 'max': formatter.format_temp(f=forecast['temp']['max']), + 'min': formatter.format_temp(f=forecast['temp']['min']), 'summary': forecast['weather'][0]['description']} for idx, forecast in enumerate(data['daily']) ] @@ -592,11 +385,11 @@ class NuWeather(callbacks.Plugin): irc.error(_("Unknown weather backend %s. Valid ones are: %s") % (weather_backend, ', '.join(BACKENDS)), Raise=True) geocode_backend = optlist.get('geocode-backend', self.registryValue('geocodeBackend', msg.args[0])) - self._channel_context = msg.channel + formatter._channel_context = msg.channel backend_func = getattr(self, '_%s_fetcher' % weather_backend) raw_data = backend_func(location, geocode_backend) - s = self._format(raw_data, forecast='forecast' in optlist) + s = formatter.format_weather(raw_data, forecast='forecast' in optlist) irc.reply(s) @wrap([getopts({'user': 'nick', 'backend': None}), 'text']) diff --git a/NuWeather/test.py b/NuWeather/test.py index 95af323..c1cff51 100644 --- a/NuWeather/test.py +++ b/NuWeather/test.py @@ -34,7 +34,6 @@ from supybot.test import * from supybot import log NO_NETWORK_REASON = "Network-based tests are disabled by --no-network" - class NuWeatherTestCase(): plugins = ('NuWeather',) @@ -82,6 +81,103 @@ class NuWeatherWeatherstackTestCase(NuWeatherTestCase, PluginTestCase): class NuWeatherOpenWeatherMapTestCase(NuWeatherTestCase, PluginTestCase): BACKEND = 'openweathermap' +from . import formatter + +class NuWeatherFormatterTestCase(unittest.TestCase): + def test_format_temp(self): + func = formatter.format_temp + self.assertEqual(func(f=50, c=10), '\x030950.0F/10.0C\x03') + self.assertEqual(func(f=100), '\x0304100.0F/37.8C\x03') + self.assertEqual(func(c=25.55), '\x030878.0F/25.6C\x03') + self.assertEqual(func(), 'N/A') + + def test_format_temp_displaymode(self): + func = formatter.format_temp + with conf.supybot.plugins.NuWeather.units.temperature.context('F/C'): + self.assertEqual(func(c=-5.3), '\x031022.5F/-5.3C\x03') + with conf.supybot.plugins.NuWeather.units.temperature.context('C/F'): + self.assertEqual(func(f=50, c=10), '\x030910.0C/50.0F\x03') + with conf.supybot.plugins.NuWeather.units.temperature.context('C'): + self.assertEqual(func(c=36), '\x030436.0C\x03') + with conf.supybot.plugins.NuWeather.units.temperature.context('F'): + self.assertEqual(func(f=72), '\x030872.0F\x03') + + def test_format_distance_speed(self): + func = formatter.format_distance + self.assertEqual(func(mi=123), '123mi/197.9km') + self.assertEqual(func(km=42.6), '26.5mi/42.6km') + self.assertEqual(func(mi=26, km=42), '26mi/42km') + self.assertEqual(func(mi=0), '0') # special case + self.assertEqual(func(), 'N/A') + + def test_format_default(self): + data = {'location': "Narnia", + 'poweredby': 'Dummy', + 'url': 'http://dummy.invalid/api/', + 'current': { + 'condition': 'Sunny', + 'temperature': formatter.format_temp(f=80), + 'feels_like': formatter.format_temp(f=85), + 'humidity': formatter.format_percentage(0.8), + 'precip': formatter.format_precip(mm=90), + 'wind': formatter.format_distance(mi=12, speed=True), + 'wind_gust': formatter.format_distance(mi=20, speed=True), + 'wind_dir': formatter.wind_direction(15), + 'uv': formatter.format_uv(6), + 'visibility': formatter.format_distance(mi=1000), + }, + 'forecast': [{'dayname': 'Today', + 'max': formatter.format_temp(f=100), + 'min': formatter.format_temp(f=60), + 'summary': 'Cloudy'}, + {'dayname': 'Tomorrow', + 'max': formatter.format_temp(f=70), + 'min': formatter.format_temp(f=55), + 'summary': 'Light rain'}]} + self.assertEqual(formatter.format_weather(data), + '\x02Narnia\x02 :: Sunny \x030780.0F/26.7C\x03 (Humidity: 80%) | ' + '\x02Feels like:\x02 \x030785.0F/29.4C\x03 | ' + '\x02Wind\x02: 12mph/19.3kph NNE | ' + '\x02Wind gust\x02: 20mph/32.2kph | ' + '\x02Today\x02: Cloudy. High \x0304100.0F/37.8C\x03. Low \x030360.0F/15.6C\x03. | ' + '\x02Tomorrow\x02: Light rain. High \x030870.0F/21.1C\x03. Low \x030955.0F/12.8C\x03. | ' + 'Powered by \x02Dummy\x02 ') + #print(repr(formatter.format_weather(data))) + + def test_format_forecast(self): + data = {'location': "Testville", + 'poweredby': 'Dummy', + 'url': 'http://dummy.invalid/api/', + 'current': { + 'condition': 'Sunny', + 'temperature': formatter.format_temp(f=80), + 'feels_like': formatter.format_temp(f=85), + 'humidity': formatter.format_percentage(0.8), + 'precip': formatter.format_precip(mm=90), + 'wind': formatter.format_distance(mi=12, speed=True), + 'wind_gust': formatter.format_distance(mi=20, speed=True), + 'wind_dir': formatter.wind_direction(15), + 'uv': formatter.format_uv(6), + 'visibility': formatter.format_distance(mi=1000), + }, + 'forecast': [{'dayname': 'Today', + 'max': formatter.format_temp(f=100), + 'min': formatter.format_temp(f=60), + 'summary': 'Cloudy'}, + {'dayname': 'Tomorrow', + 'max': formatter.format_temp(f=70), + 'min': formatter.format_temp(f=55), + 'summary': 'Light rain'}, + {'dayname': 'Tomorrow', + 'max': formatter.format_temp(f=56), + 'min': formatter.format_temp(f=40), + 'summary': 'Heavy rain'}]} + self.assertIn('\x02Testville\x02 :: \x02Today\x02: Cloudy (\x030360.0F/15.6C\x03 to \x0304100.0F/37.8C\x03) | ' + '\x02Tomorrow\x02: Light rain (\x030955.0F/12.8C\x03 to \x030870.0F/21.1C\x03) | ' + '\x02Tomorrow\x02: Heavy rain (\x030240.0F/4.4C\x03 to \x030956.0F/13.3C\x03)', + formatter.format_weather(data, True)) + #print(repr(formatter.format_weather(data, True))) + # FIXME: test geocode backends