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