Merge branch 'nuweather-wwis'

This commit is contained in:
James Lu 2022-06-19 15:28:13 -07:00
commit 388e13acc5
6 changed files with 304 additions and 159 deletions

View File

@ -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)

View File

@ -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 '

View File

@ -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)

View File

@ -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 <othernick>] [--weather-backend/--backend <weather backend>] [--geocode-backend <geocode backend>] [--forecast] [<location>]
@ -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 <location> 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)

View File

@ -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)

View File

@ -1,5 +1,5 @@
###
# Copyright (c) 2019-2020, James Lu <james@overdrivenetworks.com>
# Copyright (c) 2019-2022, James Lu <james@overdrivenetworks.com>
# 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: