diff --git a/NuWeather/__init__.py b/NuWeather/__init__.py index b276433..b7c7fbc 100644 --- a/NuWeather/__init__.py +++ b/NuWeather/__init__.py @@ -54,12 +54,13 @@ __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, formatter, plugin +from . import config, formatter, plugin, request_cache from importlib import reload # In case we're being reloaded. reload(config) reload(formatter) reload(plugin) +reload(request_cache) from .local import accountsdb reload(accountsdb) diff --git a/NuWeather/config.py b/NuWeather/config.py index f3d1ff1..84b03c7 100644 --- a/NuWeather/config.py +++ b/NuWeather/config.py @@ -77,8 +77,12 @@ conf.registerChannelValue(NuWeather.units, 'speed', $mi = mph, $km = km/h, $m = m/s."""))) # List of supported backends for weather & geocode. This is reused by plugin.py -BACKENDS = ('openweathermap', 'darksky', 'weatherstack') +BACKENDS = ('openweathermap', 'darksky', 'weatherstack', 'wwis') GEOCODE_BACKENDS = ('nominatim', 'googlemaps', 'opencage', 'weatherstack') + +def backend_requires_apikey(backend): + return backend not in ('wwis', 'nominatim') + class NuWeatherBackend(registry.OnlySomeStrings): validStrings = BACKENDS class NuWeatherGeocode(registry.OnlySomeStrings): @@ -90,16 +94,11 @@ conf.registerChannelValue(NuWeather, 'defaultBackend', conf.registerChannelValue(NuWeather, 'geocodeBackend', NuWeatherGeocode(GEOCODE_BACKENDS[0], _("""Determines the default geocode backend."""))) -for backend in BACKENDS: - conf.registerGlobalValue(NuWeather.apikeys, backend, - registry.String("", _("""Sets the API key for %s.""") % backend, private=True)) -for backend in GEOCODE_BACKENDS: - if backend != 'nominatim': - # nominatim doesn't require an API key +for backend in BACKENDS + GEOCODE_BACKENDS: + if backend_requires_apikey(backend): conf.registerGlobalValue(NuWeather.apikeys, backend, registry.String("", _("""Sets the API key for %s.""") % backend, private=True)) - DEFAULT_FORMAT = ('\x02$location\x02 :: $c__condition $c__temperature ' '(Humidity: $c__humidity) | \x02Feels like:\x02 $c__feels_like ' '| \x02Wind\x02: $c__wind $c__wind_dir | \x02Wind gust\x02: $c__wind_gust ' diff --git a/NuWeather/formatter.py b/NuWeather/formatter.py index 8c1a489..63701c5 100644 --- a/NuWeather/formatter.py +++ b/NuWeather/formatter.py @@ -27,11 +27,9 @@ # 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 +from supybot import ircutils, log try: import pendulum @@ -45,12 +43,6 @@ try: 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.""" @@ -80,7 +72,7 @@ def flatten_subdicts(dicts, flat=None): else: return dicts -def format_temp(f=None, c=None): +def format_temp(displaymode, f=None, c=None): """ Colorizes temperatures and formats them to show either Fahrenheit, Celsius, or both. """ @@ -112,7 +104,6 @@ def format_temp(f=None, c=None): 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': @@ -125,15 +116,6 @@ def format_temp(f=None, c=None): 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 @@ -178,7 +160,7 @@ def format_precip(mm=None, inches=None): return _('%smm/%sin') % (mm, inches) -def format_distance(mi=None, km=None, speed=False): +def format_distance(displaymode, 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') @@ -186,20 +168,18 @@ def format_distance(mi=None, km=None, speed=False): return '0' # Don't bother with multiple units if the value is 0 if mi is None: - mi = round(km / 1.609, 1) + mi = km / 1.609344 elif km is None: - km = round(mi * 1.609, 1) + km = mi * 1.609344 if speed: m = f'{round(km / 3.6, 1)}m/s' - mi = f'{mi}mph' - km = f'{km}km/h' - displaymode = _registryValue('units.speed', channel=_channel_context) + mi = f'{round(mi, 1)}mph' + km = f'{round(km, 1)}km/h' else: m = f'{round(km * 1000, 1)}m' - mi = f'{mi}mi' - km = f'{km}km' - displaymode = _registryValue('units.distance', channel=_channel_context) + mi = f'{round(mi, 1)}mi' + km = f'{round(km, 1)}km' return string.Template(displaymode).safe_substitute( {'mi': mi, 'km': km, 'm': m} ) @@ -215,7 +195,7 @@ def format_percentage(value): else: return 'N/A' -def get_dayname(ts, idx, *, tz=None): +def get_dayname(ts, idx, *, tz=None, fallback=None): """ Returns the day name given a Unix timestamp, day index and (optionally) a timezone. """ @@ -223,6 +203,8 @@ def get_dayname(ts, idx, *, tz=None): p = pendulum.from_timestamp(ts, tz=tz) return p.format('dddd') else: + if fallback: + return fallback # Fallback if idx == 0: return 'Today' @@ -231,29 +213,4 @@ def get_dayname(ts, idx, *, tz=None): 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 4788504..e9059de 100644 --- a/NuWeather/plugin.py +++ b/NuWeather/plugin.py @@ -28,6 +28,7 @@ ### import json import os +import string from supybot import utils, plugins, ircutils, callbacks, world, conf, log from supybot.commands import * @@ -40,8 +41,10 @@ except ImportError: _ = lambda x: x from .config import BACKENDS, GEOCODE_BACKENDS +from .config import DEFAULT_FORMAT, DEFAULT_FORECAST_FORMAT, DEFAULT_FORMAT_CURRENTONLY from .local import accountsdb -from . import formatter +from . import formatter, request_cache as cache + HEADERS = { 'User-agent': 'Mozilla/5.0 (compatible; Supybot/Limnoria %s; NuWeather weather plugin)' % conf.version @@ -57,19 +60,19 @@ class NuWeather(callbacks.Plugin): self.db = accountsdb.AccountsDB("NuWeather", 'NuWeather.db', self.registryValue(accountsdb.CONFIG_OPTION_NAME)) geocode_db_filename = conf.supybot.directories.data.dirize("NuWeather-geocode.json") if os.path.exists(geocode_db_filename): - with open(geocode_db_filename) as f: + with open(geocode_db_filename, encoding='utf-8') as f: self.geocode_db = json.load(f) else: self.log.info("NuWeather: Creating new geocode DB") self.geocode_db = {} world.flushers.append(self.db.flush) world.flushers.append(self._flush_geocode_db) - # this is hacky but less annoying than navigating the registry ourselves - formatter._registryValue = self.registryValue + + self._last_channel = None def _flush_geocode_db(self): geocode_db_filename = conf.supybot.directories.data.dirize("NuWeather-geocode.json") - with open(geocode_db_filename, 'w') as f: + with open(geocode_db_filename, 'w', encoding='utf-8') as f: json.dump(self.geocode_db, f) def die(self): @@ -88,7 +91,7 @@ class NuWeather(callbacks.Plugin): try: f = utils.web.getUrl(url, headers=HEADERS).decode('utf-8') data = json.loads(f) - except utils.web.Error as e: + except utils.web.Error: log.debug('NuWeather: error searching for %r from Nominatim backend:', location, exc_info=True) data = None if not data: @@ -103,8 +106,8 @@ class NuWeather(callbacks.Plugin): display_name_parts.pop(-2) display_name = ', '.join([display_name_parts[0]] + display_name_parts[-2:]) - lat = data['lat'] - lon = data['lon'] + lat = float(data['lat']) + lon = float(data['lon']) osm_id = data.get('osm_id') self.log.debug('NuWeather: saving %s,%s (osm_id %s, %s) for location %s from OSM/Nominatim', lat, lon, osm_id, display_name, location) @@ -188,14 +191,18 @@ class NuWeather(callbacks.Plugin): return result def _geocode(self, location, geobackend=None): - geocode_backend = geobackend or self.registryValue('geocodeBackend', channel=formatter._channel_context) + geocode_backend = geobackend or self.registryValue('geocodeBackend', channel=self._last_channel) if geocode_backend not in GEOCODE_BACKENDS: raise callbacks.Error(_("Unknown geocode backend %r. Valid ones are: %s") % (geocode_backend, ', '.join(GEOCODE_BACKENDS))) result_pair = str((location, geocode_backend)) # escape for json purposes if result_pair in self.geocode_db: - self.log.debug('NuWeather: using cached latlon %s for location %r', self.geocode_db[result_pair], location) - return self.geocode_db[result_pair] + # 2022-05-24: fix Nominatim returning the wrong type + if not isinstance(result_pair[0], float): + del self.geocode_db[result_pair] + else: + self.log.debug('NuWeather: using cached latlon %s for location %r', self.geocode_db[result_pair], location) + return self.geocode_db[result_pair] elif location in self.geocode_db: # Old DBs from < 2019-03-14 only had one field storing location, and always # used OSM/Nominatim. Remove these old entries and regenerate them. @@ -207,6 +214,17 @@ class NuWeather(callbacks.Plugin): self.geocode_db[result_pair] = result # Cache result persistently return result + def _format_tmpl_temp(self, *args, **kwargs): + displaymode = self.registryValue('units.temperature', channel=self._last_channel) + return formatter.format_temp(displaymode, *args, **kwargs) + + def _format_tmpl_distance(self, *args, **kwargs): + if kwargs.get('speed'): + displaymode = self.registryValue('units.speed', channel=self._last_channel) + else: + displaymode = self.registryValue('units.distance', channel=self._last_channel) + return formatter.format_distance(displaymode, *args, **kwargs) + def _weatherstack_fetcher(self, location, geobackend=None): """Grabs weather data from weatherstack (formerly Apixu).""" apikey = self.registryValue('apikeys.weatherstack') @@ -232,17 +250,128 @@ class NuWeather(callbacks.Plugin): 'url': '', 'current': { 'condition': currentdata['weather_descriptions'][0], - 'temperature': formatter.format_temp(f=currentdata['temperature']), - 'feels_like': formatter.format_temp(f=currentdata['feelslike']), + 'temperature': self._format_tmpl_temp(f=currentdata['temperature']), + 'feels_like': self._format_tmpl_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': self._format_tmpl_distance(mi=currentdata['wind_speed'], speed=True), 'wind_dir': currentdata['wind_dir'], 'uv': formatter.format_uv(currentdata['uv_index']), - 'visibility': formatter.format_distance(mi=currentdata.get('visibility')), + 'visibility': self._format_tmpl_distance(mi=currentdata.get('visibility')), } } + _WWIS_CITIES_REFRESH_INTERVAL = 2592000 # 30 days + _wwis_cities = {} + def _wwis_load_cities(self): + wwis_cities_cache_path = conf.supybot.directories.data.dirize("wwis-cities.json") + if cache.check_cache_outdated(wwis_cities_cache_path, self._WWIS_CITIES_REFRESH_INTERVAL): + # FIXME: support other languages? + url = 'https://worldweather.wmo.int/en/json/Country_en.json' + wwis_cities_raw = cache.get_json_save_cache(url, wwis_cities_cache_path, HEADERS) + elif self._wwis_cities: + # already loaded and up to date; nothing to do + return + else: + wwis_cities_raw = cache.load_json_cache(wwis_cities_cache_path) + + self._wwis_cities.clear() + # Process WWIS data to map (lat, lon) -> (cityId, cityName) + for _membid, member_info in wwis_cities_raw['member'].items(): + if not isinstance(member_info, dict): + continue + for city in member_info['city']: + lat, lon = float(city['cityLatitude']), float(city['cityLongitude']) + self._wwis_cities[(lat, lon)] = city['cityId'] + + def _wwis_get_closest_city(self, location, geobackend=None): + # WWIS equivalent of geocode - finding the closest major city + try: + import haversine + except ImportError as e: + raise callbacks.Error("This feature requires the 'haversine' Python module - see https://pypi.org/project/haversine/") from e + + latlon = self._geocode(location, geobackend=geobackend) + if not latlon: + raise callbacks.Error("Unknown location %s." % location) + + lat, lon, _display_name, _geocodeid, geocode_backend = latlon + self._wwis_load_cities() + + closest_cities = sorted(self._wwis_cities, key=lambda k: haversine.haversine((lat, lon), k)) + return self._wwis_cities[closest_cities[0]], geocode_backend + + def _wwis_get_current(self): + # Load current conditions (wind, humidity, ...) + # These are served from a separate endpoint with all(!) locations at once! + wwis_current_cache_path = conf.supybot.directories.data.dirize("wwis-current.json") + + if cache.check_cache_outdated(wwis_current_cache_path, self._WWIS_CURRENT_REFRESH_INTERVAL): + url = 'https://worldweather.wmo.int/en/json/present.json' + return cache.get_json_save_cache(url, wwis_current_cache_path, HEADERS) + return cache.load_json_cache(wwis_current_cache_path) + + _WWIS_CURRENT_REFRESH_INTERVAL = 300 # 5 minutes + _wwis_current = None + def _wwis_fetcher(self, location, geobackend=None): + """Grabs weather data from the World Weather Information Service.""" + cityid, geocode_backend = self._wwis_get_closest_city(location, geobackend=geobackend) + + # Load forecast and city metadata (name, country, etc.) + # I don't bother caching these because they're unique to every city + city_url = f'https://worldweather.wmo.int/en/json/{cityid}_en.json' + log.debug('NuWeather: fetching city info & forecasts for %r from %s', location, city_url) + city_data = utils.web.getUrl(city_url, headers=HEADERS).decode('utf-8') + city_data = json.loads(city_data) + city_data = city_data['city'] + + # Load current conditions (wind, humidity, ...) + # These are served from a separate endpoint with all(!) locations at once! + # The file altogether is sizable (~1MB), so I cached them to disk + wwis_current_cache_path = conf.supybot.directories.data.dirize("wwis-current.json") + if cache.check_cache_outdated(wwis_current_cache_path, self._WWIS_CURRENT_REFRESH_INTERVAL): + url = 'https://worldweather.wmo.int/en/json/present.json' + self._wwis_current = cache.get_json_save_cache(url, wwis_current_cache_path, HEADERS) + elif not self._wwis_current: + # First run, e.g. after reload + self._wwis_current = cache.load_json_cache(wwis_current_cache_path) + current_data = self._wwis_current + + display_name = f"{city_data['cityName']}, " \ + f"{city_data['member']['shortMemName'] or city_data['member']['memName']}" + + current_data_city = None + for current_data_city in current_data['present'].values(): + # FIXME: This is really inefficient; I have no idea why current city info isn't already + # indexed by city ID ... + if current_data_city['cityId'] == cityid: + break + if not current_data_city: + raise ValueError(f"Could not find current conditions for cityID {cityid} ({display_name})") + return { + 'location': display_name, + 'poweredby': 'WWIS+' + geocode_backend, + 'url': f'https://worldweather.wmo.int/en/city.html?cityId={cityid}', + 'current': { + 'condition': current_data_city["wxdesc"], + 'temperature': self._format_tmpl_temp(c=current_data_city['temp']) if current_data_city['temp'] else _("N/A"), + 'feels_like': _("N/A"), + 'humidity': formatter.format_percentage(current_data_city['rh']) if current_data_city['rh'] else _("N/A"), + 'precip': _("N/A"), + 'wind': self._format_tmpl_distance(km=float(current_data_city['ws'])*3.6, speed=True) if current_data_city['ws'] else _("N/A"), + 'wind_gust': _("N/A"), + 'wind_dir': current_data_city['wd'], + 'uv': _("N/A"), + 'visibility': _("N/A"), + }, + 'forecast': [{'dayname': formatter.get_dayname(forecastdata['forecastDate'], -1, + fallback=forecastdata['forecastDate']), + 'max': self._format_tmpl_temp(c=int(forecastdata['maxTemp']) if forecastdata['maxTemp'] else None), + 'min': self._format_tmpl_temp(c=int(forecastdata['minTemp']) if forecastdata['minTemp'] else None), + 'summary': forecastdata.get('weather', 'N/A')} + for forecastdata in city_data['forecast']['forecastDay']] + } + def _darksky_fetcher(self, location, geobackend=None): """Grabs weather data from Dark Sky.""" apikey = self.registryValue('apikeys.darksky') @@ -272,19 +401,19 @@ class NuWeather(callbacks.Plugin): 'url': 'https://darksky.net/forecast/%s,%s' % (lat, lon), 'current': { 'condition': currentdata.get('summary', 'N/A'), - 'temperature': formatter.format_temp(f=currentdata.get('temperature')), - 'feels_like': formatter.format_temp(f=currentdata.get('apparentTemperature')), + 'temperature': self._format_tmpl_temp(f=currentdata.get('temperature')), + 'feels_like': self._format_tmpl_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': self._format_tmpl_distance(mi=currentdata.get('windSpeed', 0), speed=True), + 'wind_gust': self._format_tmpl_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')), + 'visibility': self._format_tmpl_distance(mi=currentdata.get('visibility')), }, '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')), + 'max': self._format_tmpl_temp(f=forecastdata.get('temperatureHigh')), + 'min': self._format_tmpl_temp(f=forecastdata.get('temperatureLow')), 'summary': forecastdata.get('summary', 'N/A').rstrip('.')} for idx, forecastdata in enumerate(data['daily']['data'])] } @@ -331,27 +460,54 @@ class NuWeather(callbacks.Plugin): }), 'current': { 'condition': currentdata['weather'][0]['description'], - 'temperature': formatter.format_temp(f=currentdata['temp']), - 'feels_like': formatter.format_temp(f=currentdata['feels_like']), + 'temperature': self._format_tmpl_temp(f=currentdata['temp']), + 'feels_like': self._format_tmpl_temp(f=currentdata['feels_like']), 'humidity': formatter.format_percentage(currentdata['humidity']), 'precip': precip, - 'wind': formatter.format_distance(mi=currentdata['wind_speed'], speed=True), + 'wind': self._format_tmpl_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), + 'wind_gust': self._format_tmpl_distance(mi=currentdata.get('wind_gust'), speed=True), 'uv': formatter.format_uv(currentdata.get('uvi')), - 'visibility': formatter.format_distance(km=currentdata['visibility']/1000), + 'visibility': self._format_tmpl_distance(km=currentdata['visibility']/1000), } } output['forecast'] = [ {'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']), + 'max': self._format_tmpl_temp(f=forecast['temp']['max']), + 'min': self._format_tmpl_temp(f=forecast['temp']['min']), 'summary': forecast['weather'][0]['description']} for idx, forecast in enumerate(data['daily']) ] return output + def _format_weather(self, data, channel, 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 = formatter.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=channel) 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=channel) or DEFAULT_FORMAT + else: + fmt = self.registryValue('outputFormat.currentOnly', channel=channel) or DEFAULT_FORMAT_CURRENTONLY + template = string.Template(fmt) + + return template.safe_substitute(flat_data) + @wrap([getopts({'user': 'nick', 'backend': None, 'weather-backend': None, 'geocode-backend': None, 'forecast': ''}), additional('text')]) def weather(self, irc, msg, args, optlist, location): """[--user ] [--weather-backend/--backend ] [--geocode-backend ] [--forecast] [] @@ -365,6 +521,7 @@ class NuWeather(callbacks.Plugin): If either --weather-backend/--backend or --geocode-backend is specified, will override the default backends if provided backend is available. """ optlist = dict(optlist) + self._last_channel = msg.channel # Default to the caller if optlist.get('user'): @@ -385,11 +542,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])) - formatter._channel_context = msg.channel backend_func = getattr(self, '_%s_fetcher' % weather_backend) raw_data = backend_func(location, geocode_backend) - s = formatter.format_weather(raw_data, forecast='forecast' in optlist) + s = self._format_weather(raw_data, msg.channel, forecast='forecast' in optlist) + irc.reply(s) @wrap([getopts({'user': 'nick', 'backend': None}), 'text']) @@ -399,6 +556,7 @@ class NuWeather(callbacks.Plugin): Looks up using a geocoding backend. """ optlist = dict(optlist) + self._last_channel = msg.channel geocode_backend = optlist.get('backend', self.registryValue('geocodeBackend', msg.args[0])) data = self._geocode(location, geobackend=geocode_backend) diff --git a/NuWeather/request_cache.py b/NuWeather/request_cache.py new file mode 100644 index 0000000..2148dc2 --- /dev/null +++ b/NuWeather/request_cache.py @@ -0,0 +1,27 @@ +"""Helpers to manage cached JSON queries""" + +import json +import os.path +import time + +from supybot import log, utils + +def check_cache_outdated(cache_path, cache_ttl): + if not os.path.exists(cache_path) or \ + (time.time() - os.path.getmtime(cache_path)) > cache_ttl: + log.debug('NuWeather.request_cache: cache file %s is missing or out of date (TTL=%s)', cache_path, cache_ttl) + return True + return False + +def get_json_save_cache(url, cache_path, headers): + log.debug('NuWeather.request_cache: fetching %s', url) + data_text = utils.web.getUrl(url, headers=headers).decode('utf-8') + with open(cache_path, 'w', encoding='utf-8') as f: + log.debug('NuWeather.request_cache: saving %s to %s', url, cache_path) + f.write(data_text) + return json.loads(data_text) + +def load_json_cache(cache_path): + with open(cache_path, encoding='utf-8') as f: + log.debug('NuWeather.request_cache: reloading existing %s', cache_path) + return json.load(f) diff --git a/NuWeather/test.py b/NuWeather/test.py index 6a079be..b510d17 100644 --- a/NuWeather/test.py +++ b/NuWeather/test.py @@ -1,5 +1,5 @@ ### -# Copyright (c) 2019-2020, James Lu +# Copyright (c) 2019-2022, James Lu # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -33,66 +33,71 @@ import unittest from supybot.test import * from supybot import log +from .config import BACKENDS, backend_requires_apikey + NO_NETWORK_REASON = "Network-based tests are disabled by --no-network" -class NuWeatherTestCase(): +class NuWeatherTestCase(PluginTestCase): plugins = ('NuWeather',) # These tests are not meant to be exhaustive, since I don't want to hit my free tier # API limits :( - def setUp(self): - PluginTestCase.setUp(self) - self.myVerbose = verbosity.MESSAGES # Enable verbose logging of messages - - if not network: - return # Nothing to do if we've disabled network access - - # Fetch our API key - varname = 'NUWEATHER_APIKEY_%s' % self.BACKEND.upper() - apikey = os.environ.get(varname) - if apikey: - log.info('NuWeather: Set API key for %s from env var %s', self.BACKEND, varname) - conf.supybot.plugins.NuWeather.apikeys.get(self.BACKEND).setValue(apikey) - else: - raise RuntimeError("Please set the %r environment variable to run this test" % varname) + @staticmethod + def _set_backend(backend): + if backend_requires_apikey(backend): + varname = 'NUWEATHER_APIKEY_%s' % backend.upper() + apikey = os.environ.get(varname) + if apikey: + log.info('NuWeather: Set API key for %s from env var %s', backend, varname) + conf.supybot.plugins.NuWeather.apikeys.get(backend).setValue(apikey) + else: + raise RuntimeError(f"Please set the {varname} environment variable to run this test") # Update default backend - conf.supybot.plugins.NuWeather.defaultbackend.setValue(self.BACKEND) + conf.supybot.plugins.NuWeather.defaultbackend.setValue(backend) @unittest.skipUnless(network, NO_NETWORK_REASON) def testWeather(self): - self.assertRegexp('weather Vancouver', 'Vancouver,') - self.assertRegexp('weather LAX', 'Los Angeles') - #self.assertRegexp('weather 76010', 'Arlington') # US ZIP codes not supported by Nominatim (default) - self.assertError('weather InvalidLocationTest') + for backend in BACKENDS: + with self.subTest(msg=f"{backend} backend"): + self._set_backend(backend) + self.assertRegexp('weather Vancouver', 'Vancouver,') + self.assertRegexp('weather LAX', 'Los Angeles') + #self.assertRegexp('weather 76010', 'Arlington') # US ZIP codes not supported by Nominatim (default) @unittest.skipUnless(network, NO_NETWORK_REASON) def testSavedLocation(self): + self._set_backend(BACKENDS[0]) self.assertError('weather') # No location set self.assertNotError('setweather Berlin') self.assertRegexp('weather', 'Berlin') -class NuWeatherDarkSkyTestCase(NuWeatherTestCase, PluginTestCase): - BACKEND = 'darksky' - -class NuWeatherWeatherstackTestCase(NuWeatherTestCase, PluginTestCase): - BACKEND = 'weatherstack' - -class NuWeatherOpenWeatherMapTestCase(NuWeatherTestCase, PluginTestCase): - BACKEND = 'openweathermap' +# TODO: test geolookup code, using the separate command from . import formatter -class NuWeatherFormatterTestCase(unittest.TestCase): +class NuWeatherFormatterTestCase(PluginTestCase): + plugins = ('NuWeather',) + + def setUp(self, nick='test', forceSetup=True): + super().setUp(nick=nick, forceSetup=forceSetup) + cb = self.irc.getCallback('NuWeather') + + # These helpers pull the display template from Limnoria config + self._format_temp = cb._format_tmpl_temp + self._format_distance = cb._format_tmpl_distance + self._format_speed = lambda *args, **kwargs: self._format_distance(*args, speed=True, **kwargs) + self._format_weather = cb._format_weather + def test_format_temp(self): - func = formatter.format_temp + func = self._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 + func = self._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'): @@ -103,7 +108,7 @@ class NuWeatherFormatterTestCase(unittest.TestCase): self.assertEqual(func(f=72), '\x030872.0F\x03') def test_format_distance(self): - func = formatter.format_distance + func = self._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') @@ -111,7 +116,7 @@ class NuWeatherFormatterTestCase(unittest.TestCase): self.assertEqual(func(), 'N/A') def test_format_distance_speed(self): - func = lambda *args, **kwargs: formatter.format_distance(*args, speed=True, **kwargs) + func = self._format_speed self.assertEqual(func(mi=123), '123mph / 197.9km/h') self.assertEqual(func(km=42.6), '26.5mph / 42.6km/h') self.assertEqual(func(mi=26, km=42), '26mph / 42km/h') @@ -119,15 +124,15 @@ class NuWeatherFormatterTestCase(unittest.TestCase): self.assertEqual(func(), 'N/A') def test_format_distance_displaymode(self): - func = formatter.format_distance + func = self._format_distance with conf.supybot.plugins.NuWeather.units.distance.context('$mi / $km / $m'): - self.assertEqual(func(mi=123), '123mi / 197.9km / 197900.0m') + self.assertEqual(func(mi=123), '123mi / 197.9km / 197949.3m') self.assertEqual(func(km=42.6), '26.5mi / 42.6km / 42600.0m') with conf.supybot.plugins.NuWeather.units.distance.context('$m/$km'): self.assertEqual(func(km=2), '2000m/2km') def test_format_distance_speed_displaymode(self): - func = lambda *args, **kwargs: formatter.format_distance(*args, speed=True, **kwargs) + func = self._format_speed with conf.supybot.plugins.NuWeather.units.speed.context('$mi / $km / $m'): self.assertEqual(func(mi=123), '123mph / 197.9km/h / 55.0m/s') with conf.supybot.plugins.NuWeather.units.speed.context('$m / $km'): @@ -139,25 +144,25 @@ class NuWeatherFormatterTestCase(unittest.TestCase): 'url': 'http://dummy.invalid/api/', 'current': { 'condition': 'Sunny', - 'temperature': formatter.format_temp(f=80), - 'feels_like': formatter.format_temp(f=85), + 'temperature': self._format_temp(f=80), + 'feels_like': self._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': self._format_distance(mi=12, speed=True), + 'wind_gust': self._format_distance(mi=20, speed=True), 'wind_dir': formatter.wind_direction(15), 'uv': formatter.format_uv(6), - 'visibility': formatter.format_distance(mi=1000), + 'visibility': self._format_distance(mi=1000), }, 'forecast': [{'dayname': 'Today', - 'max': formatter.format_temp(f=100), - 'min': formatter.format_temp(f=60), + 'max': self._format_temp(f=100), + 'min': self._format_temp(f=60), 'summary': 'Cloudy'}, {'dayname': 'Tomorrow', - 'max': formatter.format_temp(f=70), - 'min': formatter.format_temp(f=55), + 'max': self._format_temp(f=70), + 'min': self._format_temp(f=55), 'summary': 'Light rain'}]} - self.assertEqual(formatter.format_weather(data), + self.assertEqual(self._format_weather(data, None, False), '\x02Narnia\x02 :: Sunny \x030780.0F/26.7C\x03 (Humidity: 80%) | ' '\x02Feels like:\x02 \x030785.0F/29.4C\x03 | ' '\x02Wind\x02: 12mph / 19.3km/h NNE | ' @@ -173,35 +178,33 @@ class NuWeatherFormatterTestCase(unittest.TestCase): 'url': 'http://dummy.invalid/api/', 'current': { 'condition': 'Sunny', - 'temperature': formatter.format_temp(f=80), - 'feels_like': formatter.format_temp(f=85), + 'temperature': self._format_temp(f=80), + 'feels_like': self._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': self._format_distance(mi=12, speed=True), + 'wind_gust': self._format_distance(mi=20, speed=True), 'wind_dir': formatter.wind_direction(15), 'uv': formatter.format_uv(6), - 'visibility': formatter.format_distance(mi=1000), + 'visibility': self._format_distance(mi=1000), }, 'forecast': [{'dayname': 'Today', - 'max': formatter.format_temp(f=100), - 'min': formatter.format_temp(f=60), + 'max': self._format_temp(f=100), + 'min': self._format_temp(f=60), 'summary': 'Cloudy'}, {'dayname': 'Tomorrow', - 'max': formatter.format_temp(f=70), - 'min': formatter.format_temp(f=55), + 'max': self._format_temp(f=70), + 'min': self._format_temp(f=55), 'summary': 'Light rain'}, {'dayname': 'Tomorrow', - 'max': formatter.format_temp(f=56), - 'min': formatter.format_temp(f=40), + 'max': self._format_temp(f=56), + 'min': self._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 + self._format_weather(data, None, True)) + #print(repr(self._format_weather(data, None, True))) -# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: +# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=120: