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. # This is a url where the most recent plugin package can be downloaded.
__url__ = 'https://github.com/jlu5/SupyPlugins/tree/master/NuWeather' __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 from importlib import reload
# In case we're being reloaded. # In case we're being reloaded.
reload(config) reload(config)
reload(formatter) reload(formatter)
reload(plugin) reload(plugin)
reload(request_cache)
from .local import accountsdb from .local import accountsdb
reload(accountsdb) reload(accountsdb)

View File

@ -77,8 +77,12 @@ conf.registerChannelValue(NuWeather.units, 'speed',
$mi = mph, $km = km/h, $m = m/s."""))) $mi = mph, $km = km/h, $m = m/s.""")))
# List of supported backends for weather & geocode. This is reused by plugin.py # 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') GEOCODE_BACKENDS = ('nominatim', 'googlemaps', 'opencage', 'weatherstack')
def backend_requires_apikey(backend):
return backend not in ('wwis', 'nominatim')
class NuWeatherBackend(registry.OnlySomeStrings): class NuWeatherBackend(registry.OnlySomeStrings):
validStrings = BACKENDS validStrings = BACKENDS
class NuWeatherGeocode(registry.OnlySomeStrings): class NuWeatherGeocode(registry.OnlySomeStrings):
@ -90,15 +94,10 @@ conf.registerChannelValue(NuWeather, 'defaultBackend',
conf.registerChannelValue(NuWeather, 'geocodeBackend', conf.registerChannelValue(NuWeather, 'geocodeBackend',
NuWeatherGeocode(GEOCODE_BACKENDS[0], _("""Determines the default geocode backend."""))) NuWeatherGeocode(GEOCODE_BACKENDS[0], _("""Determines the default geocode backend.""")))
for backend in BACKENDS: for backend in BACKENDS + GEOCODE_BACKENDS:
if backend_requires_apikey(backend):
conf.registerGlobalValue(NuWeather.apikeys, backend, conf.registerGlobalValue(NuWeather.apikeys, backend,
registry.String("", _("""Sets the API key for %s.""") % backend, private=True)) 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
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 ' DEFAULT_FORMAT = ('\x02$location\x02 :: $c__condition $c__temperature '
'(Humidity: $c__humidity) | \x02Feels like:\x02 $c__feels_like ' '(Humidity: $c__humidity) | \x02Feels like:\x02 $c__feels_like '

View File

@ -27,11 +27,9 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
### ###
import re
import string import string
from supybot import callbacks, conf, ircutils, log, utils from supybot import ircutils, log
try: try:
import pendulum import pendulum
@ -45,12 +43,6 @@ try:
except ImportError: except ImportError:
_ = lambda x: x _ = 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 # Based off https://github.com/ProgVal/Supybot-plugins/blob/master/GitHub/plugin.py
def flatten_subdicts(dicts, flat=None): def flatten_subdicts(dicts, flat=None):
"""Flattens a dict containing dicts or lists of dicts. Useful for string formatting.""" """Flattens a dict containing dicts or lists of dicts. Useful for string formatting."""
@ -80,7 +72,7 @@ def flatten_subdicts(dicts, flat=None):
else: else:
return dicts 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. 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 c = '%.1f' % c
f = '%.1f' % f f = '%.1f' % f
displaymode = _registryValue('units.temperature', channel=_channel_context)
if displaymode == 'F/C': if displaymode == 'F/C':
s = '%sF/%sC' % (f, c) s = '%sF/%sC' % (f, c)
elif displaymode == 'C/F': elif displaymode == 'C/F':
@ -125,15 +116,6 @@ def format_temp(f=None, c=None):
raise ValueError("Unknown display mode for temperature.") raise ValueError("Unknown display mode for temperature.")
return ircutils.mircColor(s, color) 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): def wind_direction(angle):
"""Returns wind direction (N, W, S, E, etc.) given an angle.""" """Returns wind direction (N, W, S, E, etc.) given an angle."""
# Adapted from https://stackoverflow.com/a/7490772 # Adapted from https://stackoverflow.com/a/7490772
@ -178,7 +160,7 @@ def format_precip(mm=None, inches=None):
return _('%smm/%sin') % (mm, inches) 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""" """Formats distance or speed values in miles and kilometers"""
if mi is None and km is None: if mi is None and km is None:
return _('N/A') 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 return '0' # Don't bother with multiple units if the value is 0
if mi is None: if mi is None:
mi = round(km / 1.609, 1) mi = km / 1.609344
elif km is None: elif km is None:
km = round(mi * 1.609, 1) km = mi * 1.609344
if speed: if speed:
m = f'{round(km / 3.6, 1)}m/s' m = f'{round(km / 3.6, 1)}m/s'
mi = f'{mi}mph' mi = f'{round(mi, 1)}mph'
km = f'{km}km/h' km = f'{round(km, 1)}km/h'
displaymode = _registryValue('units.speed', channel=_channel_context)
else: else:
m = f'{round(km * 1000, 1)}m' m = f'{round(km * 1000, 1)}m'
mi = f'{mi}mi' mi = f'{round(mi, 1)}mi'
km = f'{km}km' km = f'{round(km, 1)}km'
displaymode = _registryValue('units.distance', channel=_channel_context)
return string.Template(displaymode).safe_substitute( return string.Template(displaymode).safe_substitute(
{'mi': mi, 'km': km, 'm': m} {'mi': mi, 'km': km, 'm': m}
) )
@ -215,7 +195,7 @@ def format_percentage(value):
else: else:
return 'N/A' 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. 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) p = pendulum.from_timestamp(ts, tz=tz)
return p.format('dddd') return p.format('dddd')
else: else:
if fallback:
return fallback
# Fallback # Fallback
if idx == 0: if idx == 0:
return 'Today' return 'Today'
@ -231,29 +213,4 @@ def get_dayname(ts, idx, *, tz=None):
else: else:
return 'Day_%d' % idx 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 json
import os import os
import string
from supybot import utils, plugins, ircutils, callbacks, world, conf, log from supybot import utils, plugins, ircutils, callbacks, world, conf, log
from supybot.commands import * from supybot.commands import *
@ -40,8 +41,10 @@ except ImportError:
_ = lambda x: x _ = lambda x: x
from .config import BACKENDS, GEOCODE_BACKENDS from .config import BACKENDS, GEOCODE_BACKENDS
from .config import DEFAULT_FORMAT, DEFAULT_FORECAST_FORMAT, DEFAULT_FORMAT_CURRENTONLY
from .local import accountsdb from .local import accountsdb
from . import formatter from . import formatter, request_cache as cache
HEADERS = { HEADERS = {
'User-agent': 'Mozilla/5.0 (compatible; Supybot/Limnoria %s; NuWeather weather plugin)' % conf.version '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)) self.db = accountsdb.AccountsDB("NuWeather", 'NuWeather.db', self.registryValue(accountsdb.CONFIG_OPTION_NAME))
geocode_db_filename = conf.supybot.directories.data.dirize("NuWeather-geocode.json") geocode_db_filename = conf.supybot.directories.data.dirize("NuWeather-geocode.json")
if os.path.exists(geocode_db_filename): 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) self.geocode_db = json.load(f)
else: else:
self.log.info("NuWeather: Creating new geocode DB") self.log.info("NuWeather: Creating new geocode DB")
self.geocode_db = {} self.geocode_db = {}
world.flushers.append(self.db.flush) world.flushers.append(self.db.flush)
world.flushers.append(self._flush_geocode_db) 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): def _flush_geocode_db(self):
geocode_db_filename = conf.supybot.directories.data.dirize("NuWeather-geocode.json") 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) json.dump(self.geocode_db, f)
def die(self): def die(self):
@ -88,7 +91,7 @@ class NuWeather(callbacks.Plugin):
try: try:
f = utils.web.getUrl(url, headers=HEADERS).decode('utf-8') f = utils.web.getUrl(url, headers=HEADERS).decode('utf-8')
data = json.loads(f) 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) log.debug('NuWeather: error searching for %r from Nominatim backend:', location, exc_info=True)
data = None data = None
if not data: if not data:
@ -103,8 +106,8 @@ class NuWeather(callbacks.Plugin):
display_name_parts.pop(-2) display_name_parts.pop(-2)
display_name = ', '.join([display_name_parts[0]] + display_name_parts[-2:]) display_name = ', '.join([display_name_parts[0]] + display_name_parts[-2:])
lat = data['lat'] lat = float(data['lat'])
lon = data['lon'] lon = float(data['lon'])
osm_id = data.get('osm_id') 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) 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,12 +191,16 @@ class NuWeather(callbacks.Plugin):
return result return result
def _geocode(self, location, geobackend=None): 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: if geocode_backend not in GEOCODE_BACKENDS:
raise callbacks.Error(_("Unknown geocode backend %r. Valid ones are: %s") % (geocode_backend, ', '.join(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 result_pair = str((location, geocode_backend)) # escape for json purposes
if result_pair in self.geocode_db: if result_pair in self.geocode_db:
# 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) self.log.debug('NuWeather: using cached latlon %s for location %r', self.geocode_db[result_pair], location)
return self.geocode_db[result_pair] return self.geocode_db[result_pair]
elif location in self.geocode_db: elif location in self.geocode_db:
@ -207,6 +214,17 @@ class NuWeather(callbacks.Plugin):
self.geocode_db[result_pair] = result # Cache result persistently self.geocode_db[result_pair] = result # Cache result persistently
return result 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): def _weatherstack_fetcher(self, location, geobackend=None):
"""Grabs weather data from weatherstack (formerly Apixu).""" """Grabs weather data from weatherstack (formerly Apixu)."""
apikey = self.registryValue('apikeys.weatherstack') apikey = self.registryValue('apikeys.weatherstack')
@ -232,17 +250,128 @@ class NuWeather(callbacks.Plugin):
'url': '', 'url': '',
'current': { 'current': {
'condition': currentdata['weather_descriptions'][0], 'condition': currentdata['weather_descriptions'][0],
'temperature': formatter.format_temp(f=currentdata['temperature']), 'temperature': self._format_tmpl_temp(f=currentdata['temperature']),
'feels_like': formatter.format_temp(f=currentdata['feelslike']), 'feels_like': self._format_tmpl_temp(f=currentdata['feelslike']),
'humidity': formatter.format_percentage(currentdata['humidity']), 'humidity': formatter.format_percentage(currentdata['humidity']),
'precip': formatter.format_precip(inches=currentdata['precip']), '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'], 'wind_dir': currentdata['wind_dir'],
'uv': formatter.format_uv(currentdata['uv_index']), '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): def _darksky_fetcher(self, location, geobackend=None):
"""Grabs weather data from Dark Sky.""" """Grabs weather data from Dark Sky."""
apikey = self.registryValue('apikeys.darksky') apikey = self.registryValue('apikeys.darksky')
@ -272,19 +401,19 @@ class NuWeather(callbacks.Plugin):
'url': 'https://darksky.net/forecast/%s,%s' % (lat, lon), 'url': 'https://darksky.net/forecast/%s,%s' % (lat, lon),
'current': { 'current': {
'condition': currentdata.get('summary', 'N/A'), 'condition': currentdata.get('summary', 'N/A'),
'temperature': formatter.format_temp(f=currentdata.get('temperature')), 'temperature': self._format_tmpl_temp(f=currentdata.get('temperature')),
'feels_like': formatter.format_temp(f=currentdata.get('apparentTemperature')), 'feels_like': self._format_tmpl_temp(f=currentdata.get('apparentTemperature')),
'humidity': formatter.format_percentage(currentdata.get('humidity')), 'humidity': formatter.format_percentage(currentdata.get('humidity')),
'precip': formatter.format_precip(mm=currentdata.get('precipIntensity')), 'precip': formatter.format_precip(mm=currentdata.get('precipIntensity')),
'wind': formatter.format_distance(mi=currentdata.get('windSpeed', 0), speed=True), 'wind': self._format_tmpl_distance(mi=currentdata.get('windSpeed', 0), speed=True),
'wind_gust': formatter.format_distance(mi=currentdata.get('windGust', 0), speed=True), 'wind_gust': self._format_tmpl_distance(mi=currentdata.get('windGust', 0), speed=True),
'wind_dir': formatter.wind_direction(currentdata.get('windBearing')), 'wind_dir': formatter.wind_direction(currentdata.get('windBearing')),
'uv': formatter.format_uv(currentdata.get('uvIndex')), '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']), 'forecast': [{'dayname': formatter.get_dayname(forecastdata['time'], idx, tz=data['timezone']),
'max': formatter.format_temp(f=forecastdata.get('temperatureHigh')), 'max': self._format_tmpl_temp(f=forecastdata.get('temperatureHigh')),
'min': formatter.format_temp(f=forecastdata.get('temperatureLow')), 'min': self._format_tmpl_temp(f=forecastdata.get('temperatureLow')),
'summary': forecastdata.get('summary', 'N/A').rstrip('.')} for idx, forecastdata in enumerate(data['daily']['data'])] 'summary': forecastdata.get('summary', 'N/A').rstrip('.')} for idx, forecastdata in enumerate(data['daily']['data'])]
} }
@ -331,27 +460,54 @@ class NuWeather(callbacks.Plugin):
}), }),
'current': { 'current': {
'condition': currentdata['weather'][0]['description'], 'condition': currentdata['weather'][0]['description'],
'temperature': formatter.format_temp(f=currentdata['temp']), 'temperature': self._format_tmpl_temp(f=currentdata['temp']),
'feels_like': formatter.format_temp(f=currentdata['feels_like']), 'feels_like': self._format_tmpl_temp(f=currentdata['feels_like']),
'humidity': formatter.format_percentage(currentdata['humidity']), 'humidity': formatter.format_percentage(currentdata['humidity']),
'precip': precip, '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_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')), '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'] = [ output['forecast'] = [
{'dayname': formatter.get_dayname(forecast['dt'], idx, tz=data['timezone']), {'dayname': formatter.get_dayname(forecast['dt'], idx, tz=data['timezone']),
'max': formatter.format_temp(f=forecast['temp']['max']), 'max': self._format_tmpl_temp(f=forecast['temp']['max']),
'min': formatter.format_temp(f=forecast['temp']['min']), 'min': self._format_tmpl_temp(f=forecast['temp']['min']),
'summary': forecast['weather'][0]['description']} 'summary': forecast['weather'][0]['description']}
for idx, forecast in enumerate(data['daily']) for idx, forecast in enumerate(data['daily'])
] ]
return output 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')]) @wrap([getopts({'user': 'nick', 'backend': None, 'weather-backend': None, 'geocode-backend': None, 'forecast': ''}), additional('text')])
def weather(self, irc, msg, args, optlist, location): def weather(self, irc, msg, args, optlist, location):
"""[--user <othernick>] [--weather-backend/--backend <weather backend>] [--geocode-backend <geocode backend>] [--forecast] [<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. If either --weather-backend/--backend or --geocode-backend is specified, will override the default backends if provided backend is available.
""" """
optlist = dict(optlist) optlist = dict(optlist)
self._last_channel = msg.channel
# Default to the caller # Default to the caller
if optlist.get('user'): 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) 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])) 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) backend_func = getattr(self, '_%s_fetcher' % weather_backend)
raw_data = backend_func(location, geocode_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) irc.reply(s)
@wrap([getopts({'user': 'nick', 'backend': None}), 'text']) @wrap([getopts({'user': 'nick', 'backend': None}), 'text'])
@ -399,6 +556,7 @@ class NuWeather(callbacks.Plugin):
Looks up <location> using a geocoding backend. Looks up <location> using a geocoding backend.
""" """
optlist = dict(optlist) optlist = dict(optlist)
self._last_channel = msg.channel
geocode_backend = optlist.get('backend', self.registryValue('geocodeBackend', msg.args[0])) geocode_backend = optlist.get('backend', self.registryValue('geocodeBackend', msg.args[0]))
data = self._geocode(location, geobackend=geocode_backend) 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. # All rights reserved.
# #
# Redistribution and use in source and binary forms, with or without # Redistribution and use in source and binary forms, with or without
@ -33,66 +33,71 @@ import unittest
from supybot.test import * from supybot.test import *
from supybot import log from supybot import log
from .config import BACKENDS, backend_requires_apikey
NO_NETWORK_REASON = "Network-based tests are disabled by --no-network" NO_NETWORK_REASON = "Network-based tests are disabled by --no-network"
class NuWeatherTestCase(): class NuWeatherTestCase(PluginTestCase):
plugins = ('NuWeather',) plugins = ('NuWeather',)
# These tests are not meant to be exhaustive, since I don't want to hit my free tier # These tests are not meant to be exhaustive, since I don't want to hit my free tier
# API limits :( # API limits :(
def setUp(self): @staticmethod
PluginTestCase.setUp(self) def _set_backend(backend):
self.myVerbose = verbosity.MESSAGES # Enable verbose logging of messages if backend_requires_apikey(backend):
varname = 'NUWEATHER_APIKEY_%s' % backend.upper()
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) apikey = os.environ.get(varname)
if apikey: if apikey:
log.info('NuWeather: Set API key for %s from env var %s', self.BACKEND, varname) log.info('NuWeather: Set API key for %s from env var %s', backend, varname)
conf.supybot.plugins.NuWeather.apikeys.get(self.BACKEND).setValue(apikey) conf.supybot.plugins.NuWeather.apikeys.get(backend).setValue(apikey)
else: else:
raise RuntimeError("Please set the %r environment variable to run this test" % varname) raise RuntimeError(f"Please set the {varname} environment variable to run this test")
# Update default backend # Update default backend
conf.supybot.plugins.NuWeather.defaultbackend.setValue(self.BACKEND) conf.supybot.plugins.NuWeather.defaultbackend.setValue(backend)
@unittest.skipUnless(network, NO_NETWORK_REASON) @unittest.skipUnless(network, NO_NETWORK_REASON)
def testWeather(self): def testWeather(self):
for backend in BACKENDS:
with self.subTest(msg=f"{backend} backend"):
self._set_backend(backend)
self.assertRegexp('weather Vancouver', 'Vancouver,') self.assertRegexp('weather Vancouver', 'Vancouver,')
self.assertRegexp('weather LAX', 'Los Angeles') self.assertRegexp('weather LAX', 'Los Angeles')
#self.assertRegexp('weather 76010', 'Arlington') # US ZIP codes not supported by Nominatim (default) #self.assertRegexp('weather 76010', 'Arlington') # US ZIP codes not supported by Nominatim (default)
self.assertError('weather InvalidLocationTest')
@unittest.skipUnless(network, NO_NETWORK_REASON) @unittest.skipUnless(network, NO_NETWORK_REASON)
def testSavedLocation(self): def testSavedLocation(self):
self._set_backend(BACKENDS[0])
self.assertError('weather') # No location set self.assertError('weather') # No location set
self.assertNotError('setweather Berlin') self.assertNotError('setweather Berlin')
self.assertRegexp('weather', 'Berlin') self.assertRegexp('weather', 'Berlin')
class NuWeatherDarkSkyTestCase(NuWeatherTestCase, PluginTestCase): # TODO: test geolookup code, using the separate command
BACKEND = 'darksky'
class NuWeatherWeatherstackTestCase(NuWeatherTestCase, PluginTestCase):
BACKEND = 'weatherstack'
class NuWeatherOpenWeatherMapTestCase(NuWeatherTestCase, PluginTestCase):
BACKEND = 'openweathermap'
from . import formatter 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): 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=50, c=10), '\x030950.0F/10.0C\x03')
self.assertEqual(func(f=100), '\x0304100.0F/37.8C\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(c=25.55), '\x030878.0F/25.6C\x03')
self.assertEqual(func(), 'N/A') self.assertEqual(func(), 'N/A')
def test_format_temp_displaymode(self): def test_format_temp_displaymode(self):
func = formatter.format_temp func = self._format_temp
with conf.supybot.plugins.NuWeather.units.temperature.context('F/C'): with conf.supybot.plugins.NuWeather.units.temperature.context('F/C'):
self.assertEqual(func(c=-5.3), '\x031022.5F/-5.3C\x03') self.assertEqual(func(c=-5.3), '\x031022.5F/-5.3C\x03')
with conf.supybot.plugins.NuWeather.units.temperature.context('C/F'): 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') self.assertEqual(func(f=72), '\x030872.0F\x03')
def test_format_distance(self): def test_format_distance(self):
func = formatter.format_distance func = self._format_distance
self.assertEqual(func(mi=123), '123mi / 197.9km') self.assertEqual(func(mi=123), '123mi / 197.9km')
self.assertEqual(func(km=42.6), '26.5mi / 42.6km') self.assertEqual(func(km=42.6), '26.5mi / 42.6km')
self.assertEqual(func(mi=26, km=42), '26mi / 42km') self.assertEqual(func(mi=26, km=42), '26mi / 42km')
@ -111,7 +116,7 @@ class NuWeatherFormatterTestCase(unittest.TestCase):
self.assertEqual(func(), 'N/A') self.assertEqual(func(), 'N/A')
def test_format_distance_speed(self): 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(mi=123), '123mph / 197.9km/h')
self.assertEqual(func(km=42.6), '26.5mph / 42.6km/h') self.assertEqual(func(km=42.6), '26.5mph / 42.6km/h')
self.assertEqual(func(mi=26, km=42), '26mph / 42km/h') self.assertEqual(func(mi=26, km=42), '26mph / 42km/h')
@ -119,15 +124,15 @@ class NuWeatherFormatterTestCase(unittest.TestCase):
self.assertEqual(func(), 'N/A') self.assertEqual(func(), 'N/A')
def test_format_distance_displaymode(self): 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'): 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') self.assertEqual(func(km=42.6), '26.5mi / 42.6km / 42600.0m')
with conf.supybot.plugins.NuWeather.units.distance.context('$m/$km'): with conf.supybot.plugins.NuWeather.units.distance.context('$m/$km'):
self.assertEqual(func(km=2), '2000m/2km') self.assertEqual(func(km=2), '2000m/2km')
def test_format_distance_speed_displaymode(self): 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'): with conf.supybot.plugins.NuWeather.units.speed.context('$mi / $km / $m'):
self.assertEqual(func(mi=123), '123mph / 197.9km/h / 55.0m/s') self.assertEqual(func(mi=123), '123mph / 197.9km/h / 55.0m/s')
with conf.supybot.plugins.NuWeather.units.speed.context('$m / $km'): with conf.supybot.plugins.NuWeather.units.speed.context('$m / $km'):
@ -139,25 +144,25 @@ class NuWeatherFormatterTestCase(unittest.TestCase):
'url': 'http://dummy.invalid/api/', 'url': 'http://dummy.invalid/api/',
'current': { 'current': {
'condition': 'Sunny', 'condition': 'Sunny',
'temperature': formatter.format_temp(f=80), 'temperature': self._format_temp(f=80),
'feels_like': formatter.format_temp(f=85), 'feels_like': self._format_temp(f=85),
'humidity': formatter.format_percentage(0.8), 'humidity': formatter.format_percentage(0.8),
'precip': formatter.format_precip(mm=90), 'precip': formatter.format_precip(mm=90),
'wind': formatter.format_distance(mi=12, speed=True), 'wind': self._format_distance(mi=12, speed=True),
'wind_gust': formatter.format_distance(mi=20, speed=True), 'wind_gust': self._format_distance(mi=20, speed=True),
'wind_dir': formatter.wind_direction(15), 'wind_dir': formatter.wind_direction(15),
'uv': formatter.format_uv(6), 'uv': formatter.format_uv(6),
'visibility': formatter.format_distance(mi=1000), 'visibility': self._format_distance(mi=1000),
}, },
'forecast': [{'dayname': 'Today', 'forecast': [{'dayname': 'Today',
'max': formatter.format_temp(f=100), 'max': self._format_temp(f=100),
'min': formatter.format_temp(f=60), 'min': self._format_temp(f=60),
'summary': 'Cloudy'}, 'summary': 'Cloudy'},
{'dayname': 'Tomorrow', {'dayname': 'Tomorrow',
'max': formatter.format_temp(f=70), 'max': self._format_temp(f=70),
'min': formatter.format_temp(f=55), 'min': self._format_temp(f=55),
'summary': 'Light rain'}]} '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%) | ' '\x02Narnia\x02 :: Sunny \x030780.0F/26.7C\x03 (Humidity: 80%) | '
'\x02Feels like:\x02 \x030785.0F/29.4C\x03 | ' '\x02Feels like:\x02 \x030785.0F/29.4C\x03 | '
'\x02Wind\x02: 12mph / 19.3km/h NNE | ' '\x02Wind\x02: 12mph / 19.3km/h NNE | '
@ -173,35 +178,33 @@ class NuWeatherFormatterTestCase(unittest.TestCase):
'url': 'http://dummy.invalid/api/', 'url': 'http://dummy.invalid/api/',
'current': { 'current': {
'condition': 'Sunny', 'condition': 'Sunny',
'temperature': formatter.format_temp(f=80), 'temperature': self._format_temp(f=80),
'feels_like': formatter.format_temp(f=85), 'feels_like': self._format_temp(f=85),
'humidity': formatter.format_percentage(0.8), 'humidity': formatter.format_percentage(0.8),
'precip': formatter.format_precip(mm=90), 'precip': formatter.format_precip(mm=90),
'wind': formatter.format_distance(mi=12, speed=True), 'wind': self._format_distance(mi=12, speed=True),
'wind_gust': formatter.format_distance(mi=20, speed=True), 'wind_gust': self._format_distance(mi=20, speed=True),
'wind_dir': formatter.wind_direction(15), 'wind_dir': formatter.wind_direction(15),
'uv': formatter.format_uv(6), 'uv': formatter.format_uv(6),
'visibility': formatter.format_distance(mi=1000), 'visibility': self._format_distance(mi=1000),
}, },
'forecast': [{'dayname': 'Today', 'forecast': [{'dayname': 'Today',
'max': formatter.format_temp(f=100), 'max': self._format_temp(f=100),
'min': formatter.format_temp(f=60), 'min': self._format_temp(f=60),
'summary': 'Cloudy'}, 'summary': 'Cloudy'},
{'dayname': 'Tomorrow', {'dayname': 'Tomorrow',
'max': formatter.format_temp(f=70), 'max': self._format_temp(f=70),
'min': formatter.format_temp(f=55), 'min': self._format_temp(f=55),
'summary': 'Light rain'}, 'summary': 'Light rain'},
{'dayname': 'Tomorrow', {'dayname': 'Tomorrow',
'max': formatter.format_temp(f=56), 'max': self._format_temp(f=56),
'min': formatter.format_temp(f=40), 'min': self._format_temp(f=40),
'summary': 'Heavy rain'}]} 'summary': 'Heavy rain'}]}
self.assertIn('\x02Testville\x02 :: \x02Today\x02: Cloudy (\x030360.0F/15.6C\x03 to \x0304100.0F/37.8C\x03) | ' 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: 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)', '\x02Tomorrow\x02: Heavy rain (\x030240.0F/4.4C\x03 to \x030956.0F/13.3C\x03)',
formatter.format_weather(data, True)) self._format_weather(data, None, True))
#print(repr(formatter.format_weather(data, True))) #print(repr(self._format_weather(data, None, True)))
# FIXME: test geocode backends
# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=120: