mirror of
https://github.com/jlu5/SupyPlugins.git
synced 2025-04-27 05:21:10 -05:00
Merge branch 'nuweather-wwis'
This commit is contained in:
commit
388e13acc5
@ -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)
|
||||||
|
@ -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 '
|
||||||
|
@ -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)
|
|
||||||
|
@ -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)
|
||||||
|
27
NuWeather/request_cache.py
Normal file
27
NuWeather/request_cache.py
Normal 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)
|
@ -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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user