Skip to content
This repository has been archived by the owner on Oct 23, 2019. It is now read-only.

Add French-language weather support 🇫🇷 #323

Open
wants to merge 2 commits into
base: gonzobot
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 148 additions & 68 deletions plugins/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@
from cloudbot.util import web, database


class APIError(Exception):
pass


# Define database table

# Define a database table to store the last-searched location
table = Table(
"weather",
database.metadata,
Expand All @@ -19,34 +14,50 @@ class APIError(Exception):
PrimaryKeyConstraint('nick')
)

location_cache = {}

# Define some constants
google_base = 'https://maps.googleapis.com/maps/api/'
geocode_api = google_base + 'geocode/json'

wunder_api = "http://api.wunderground.com/api/{}/forecast/geolookup/conditions/q/{}.json"
wunder_api = "http://api.wunderground.com/api/{}/forecast/lang:{}/geolookup/conditions/q/{}.json"

# Change this to a ccTLD code (eg. uk, nz) to make results more targeted towards that specific country.
# <https://developers.google.com/maps/documentation/geocoding/#RegionCodes>
bias = None


def check_status(status):
"""
A little helper function that checks an API error code and returns a nice message.
Returns None if no errors found
"""
if status == 'REQUEST_DENIED':
return 'The geocode API is off in the Google Developers Console.'
elif status == 'ZERO_RESULTS':
return 'No results found.'
elif status == 'OVER_QUERY_LIMIT':
return 'The geocode API quota has run out.'
elif status == 'UNKNOWN_ERROR':
return 'Unknown Error.'
elif status == 'INVALID_REQUEST':
return 'Invalid Request.'
elif status == 'OK':
return None
class GeocodeAPIError(Exception):
"""Raised when the geocode api returns an error message.
This helps error messages optionally be returned *en francais*."""
def __init__(self, status):
super()
self._status = status

def __str__(self):
if self._status == 'REQUEST_DENIED':
return 'The geocode API is off in the Google Developers Console.'
if self._status == 'ZERO_RESULTS':
return 'No results found.'
if self._status == 'OVER_QUERY_LIMIT':
return 'The geocode API quota has run out.'
if self._status == 'UNKNOWN_ERROR':
return 'Unknown Error.'
if self._status == 'INVALID_REQUEST':
return 'Invalid Request.'
return repr(self._status)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't override __str__ on an exception subclass to set the text, use super().__init__("exception text") to do it. This is consistent with the rest of the exception API.


def en_francais(self):
if self._status == 'REQUEST_DENIED':
return "L'API de géocodage est désactivée dans la console des développeurs Google."
if self._status == 'ZERO_RESULTS':
return "Aucun resultat n'a été trouvé."
if self._status == 'OVER_QUERY_LIMIT':
return "Le quota de API de géocodage est épuisé."
if self._status == 'UNKNOWN_ERROR':
return 'Quelque chose a mal tourné.'
if self._status == 'INVALID_REQUEST':
return 'Il y a eu une demande invalide.'
return 'La France a été trahie! {!r}'.format(self._status)


def find_location(location):
Expand All @@ -63,61 +74,83 @@ def find_location(location):
request.raise_for_status()

json = request.json()
error = check_status(json['status'])
if error:
raise APIError(error)

if json['status'] != 'OK':
raise GeocodeAPIError(json['status'])

return json['results'][0]['geometry']['location']


def load_cache(db):
global location_cache
location_cache = []
"""
:type db: sqlalchemy.orm.Session
"""
for row in db.execute(table.select()):
nick = row["nick"]
location = row["loc"]
location_cache.append((nick, location))
location_cache[nick] = location

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear the cache before reloading it, as you could have old user data still there.



def add_location(nick, location, db):
test = dict(location_cache)
location = str(location)
if nick.lower() in test:
db.execute(table.update().values(loc=location.lower()).where(table.c.nick == nick.lower()))
db.commit()
load_cache(db)
def set_location(nick, location, db):
"""
:type nick: str
:type location: str
:type db: sqlalchemy.orm.Session
"""
nick, location = nick.lower(), location.lower()
if nick in location_cache:
statement = table.update().values(loc=location).where(table.c.nick == nick)
else:
db.execute(table.insert().values(nick=nick.lower(), loc=location.lower()))
db.commit()
load_cache(db)
statement = table.insert().values(nick=nick, loc=location)
db.execute(statement)
db.commit()
load_cache(db)


def get_location(nick):
"""looks in location_cache for a saved location"""
return location_cache.get(nick.lower(), None)


@hook.on_start
def on_start(bot, db):
""" Loads API keys """
""" Loads API keys
:type bot: cloudbot.bot.Cloudbot
:type db: sqlalchemy.orm.Session
"""
global dev_key, wunder_key
dev_key = bot.config.get("api_keys", {}).get("google_dev_key", None)
wunder_key = bot.config.get("api_keys", {}).get("wunderground", None)
load_cache(db)


def get_location(nick):
"""looks in location_cache for a saved location"""
location = [row[1] for row in location_cache if nick.lower() == row[0]]
if not location:
return
else:
location = location[0]
return location
class APIKeyMissing(Exception):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please group class declarations together above hook functions in the file.

"""Raised when an API key is missing.
This helps error messages optionally be returned *en francais*."""
def __init__(self, name):
super()
self._name = name

def __str__(self):
return 'This command requires a {} API key.'.format(self._name)

@hook.command("weather", "we", autohelp=False)
def weather(text, reply, db, nick, notice_doc):
"""<location> - Gets weather data for <location>."""
def en_francais(self):
return 'Cette commande nécessite une clé API {}.'.format(self._name)


def get_weather_data(text, db, nick, notice_doc, language='EN'):
"""Get weather data from Weather Underground.
:type text: str
:type db: sqlalchemy.orm.Session
:type nick: str
:type notice_doc: Callable
:param str language: two-letter language code
(see https://www.wunderground.com/weather/api/d/docs?d=language-support)
"""
if not wunder_key:
return "This command requires a Weather Underground API key."
raise APIKeyMissing('Weather Underground')
if not dev_key:
return "This command requires a Google Developers Console API key."
raise APIKeyMissing('Google Developers Console')

# If no input try the db
if not text:
Expand All @@ -129,15 +162,11 @@ def weather(text, reply, db, nick, notice_doc):
location = text

# use find_location to get location data from the user input
try:
location_data = find_location(location)
except APIError as e:
reply(str(e))
raise
location_data = find_location(location)

formatted_location = "{lat},{lng}".format(**location_data)

url = wunder_api.format(wunder_key, formatted_location)
url = wunder_api.format(wunder_key, language, formatted_location)
request = requests.get(url)
request.raise_for_status()

Expand All @@ -149,7 +178,7 @@ def weather(text, reply, db, nick, notice_doc):

forecast = response["forecast"]["simpleforecast"]["forecastday"]
if not forecast:
return "Unable to retrieve forecast data."
return 'Unable to retrieve forecast data.'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strings shown to a user should be enclosed in double-quotes.


forecast_today = forecast[0]
forecast_tomorrow = forecast[1]
Expand Down Expand Up @@ -192,11 +221,62 @@ def weather(text, reply, db, nick, notice_doc):

weather_data['url'] = web.try_shorten(url)

reply("{place} - \x02Current:\x02 {conditions}, {temp_f}F/{temp_c}C, {humidity}, "
"Wind: {wind_mph}MPH/{wind_kph}KPH {wind_direction}, \x02Today:\x02 {today_conditions}, "
"High: {today_high_f}F/{today_high_c}C, Low: {today_low_f}F/{today_low_c}C. "
"\x02Tomorrow:\x02 {tomorrow_conditions}, High: {tomorrow_high_f}F/{tomorrow_high_c}C, "
if text:
set_location(nick, location, db)

return weather_data


@hook.command("weather", "we", autohelp=False)
def weather(text, reply, db, nick, notice_doc):
"""<location> - Gets weather data for <location>.
:type text: str
:type reply: Callable
:type db: sqlalchemy.orm.Session
:type nick: str
:type notice_doc: Callable
"""
try:
weather_data = get_weather_data(text, db, nick, notice_doc)
except (APIKeyMissing, GeocodeAPIError) as e:
return str(e)
if not isinstance(weather_data, dict):
return weather_data
reply("{place} - \x02Current:\x02 {conditions}, "
"{temp_f}F/{temp_c}C, {humidity}, "
"Wind: {wind_mph}MPH/{wind_kph}KPH {wind_direction}, "
"\x02Today:\x02 {today_conditions}, "
"High: {today_high_f}F/{today_high_c}C, "
"Low: {today_low_f}F/{today_low_c}C. "
"\x02Tomorrow:\x02 {tomorrow_conditions}, "
"High: {tomorrow_high_f}F/{tomorrow_high_c}C, "
"Low: {tomorrow_low_f}F/{tomorrow_low_c}C - {url}".format_map(weather_data))

if text:
add_location(nick, location, db)

# 'oui' is a pun, see https://github.com/snoonetIRC/CloudBot/issues/271
@hook.command('météo', 'meteo', 'oui', autohelp=False)
def meteo(text, reply, db, nick, notice_doc):
"""<lieu> - Quel temps fait-il à <lieu>?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional parameters should be enclosed in square-brackets [lieu]

:type text: str
:type reply: Callable
:type db: sqlalchemy.orm.Session
:type nick: str
:type notice_doc: Callable
"""
try:
weather_data = get_weather_data(text, db, nick, notice_doc, language='FR')
except (APIKeyMissing, GeocodeAPIError) as e:
return e.en_francais()
if not isinstance(weather_data, dict):
if weather_data == 'Unable to retrieve forecast data.':
weather_data = 'Impossible de récupérer les données météorologiques.'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not throw an error?

return weather_data
reply("{place} - \x02Actuelle:\x02 {conditions}, "
"{temp_f}F/{temp_c}C, {humidity}, "
"Vent: {wind_mph}MPH/{wind_kph}KPH {wind_direction}, "
"\x02Aujourd'hui:\x02 {today_conditions}, "
"Haute: {today_high_f}F/{today_high_c}C, "
"Basse: {today_low_f}F/{today_low_c}C. "
"\x02Demain:\x02 {tomorrow_conditions}, "
"Haute: {tomorrow_high_f}F/{tomorrow_high_c}C, "
"Basse: {tomorrow_low_f}F/{tomorrow_low_c}C - {url}".format_map(weather_data))