API - refacto weather utils

This commit is contained in:
Sam 2022-11-17 00:13:43 +01:00
parent 64d770a016
commit 940f0a8416
10 changed files with 581 additions and 141 deletions

View File

@ -34,11 +34,7 @@ export SENDER_EMAIL=
# export MAP_ATTRIBUTION= # export MAP_ATTRIBUTION=
# export DEFAULT_STATICMAP=False # export DEFAULT_STATICMAP=False
# Define one of the following API key values to lookup weather conditions # Weather
# for the start and end of each workout: # available weather API providers: darksky, visualcrossing
# export WEATHER_API_PROVIDER=
# DarkSky weather API key (deprecated):
# export WEATHER_API_KEY= # export WEATHER_API_KEY=
# VisualCrossing.com weather API key:
# export VC_WEATHER_API_KEY=

View File

@ -1,6 +1,7 @@
import os import os
import shutil import shutil
from typing import Generator, Optional, Union from typing import Generator, Iterator, Optional, Union
from unittest.mock import patch
import pytest import pytest
from flask import current_app from flask import current_app
@ -8,6 +9,13 @@ from flask import current_app
from fittrackee import create_app, db, limiter from fittrackee import create_app, db, limiter
from fittrackee.application.models import AppConfig from fittrackee.application.models import AppConfig
from fittrackee.application.utils import update_app_config_from_database from fittrackee.application.utils import update_app_config_from_database
from fittrackee.workouts.utils.gpx import weather_service
@pytest.fixture(autouse=True)
def default_weather_service(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
with patch.object(weather_service, 'get_weather', return_value=None):
yield
def get_app_config( def get_app_config(
@ -79,7 +87,6 @@ def get_app(
@pytest.fixture @pytest.fixture
def app(monkeypatch: pytest.MonkeyPatch) -> Generator: def app(monkeypatch: pytest.MonkeyPatch) -> Generator:
monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025') monkeypatch.setenv('EMAIL_URL', 'smtp://none:none@0.0.0.0:1025')
monkeypatch.setenv('WEATHER_API_KEY', '')
if os.getenv('TILE_SERVER_URL'): if os.getenv('TILE_SERVER_URL'):
monkeypatch.delenv('TILE_SERVER_URL') monkeypatch.delenv('TILE_SERVER_URL')
if os.getenv('STATICMAP_SUBDOMAINS'): if os.getenv('STATICMAP_SUBDOMAINS'):

View File

@ -0,0 +1,344 @@
from datetime import datetime
from typing import Dict, Optional
from unittest.mock import Mock, patch, sentinel
import pytest
import pytz
import requests
from gpxpy.gpx import GPXTrackPoint
from fittrackee.tests.mixins import CallArgsMixin
from fittrackee.tests.utils import random_string
from fittrackee.workouts.utils.weather.dark_sky import DarkSky
from fittrackee.workouts.utils.weather.visual_crossing import VisualCrossing
from fittrackee.workouts.utils.weather.weather_service import WeatherService
VISUAL_CROSSING_RESPONSE = {
"queryCost": 1,
"latitude": 48.866667,
"longitude": 2.333333,
"resolvedAddress": "48.866667,2.333333",
"address": "48.866667,2.333333",
"timezone": "Europe/Paris",
"tzoffset": 1.0,
"days": [
{
"datetime": "2022-11-15",
"datetimeEpoch": 1668466800,
"temp": 10.4,
"humidity": 93.3,
"windspeed": 18.9,
"winddir": 179.4,
"conditions": "Rain, Partially cloudy",
"description": "Partly cloudy throughout the day with rain.",
"icon": "rain",
}
],
"currentConditions": {
"datetime": "13:00:00",
"datetimeEpoch": 1668513600,
"temp": 11.3,
"humidity": 93.1,
"windspeed": 14.0,
"winddir": 161.9,
"conditions": "Rain, Overcast",
"icon": "rain",
},
}
class WeatherTestCase:
api_key = random_string()
@staticmethod
def get_gpx_point(time: Optional[datetime] = None) -> GPXTrackPoint:
return GPXTrackPoint(latitude=48.866667, longitude=2.333333, time=time)
class TestDarksky(WeatherTestCase):
def test_it_calls_forecast_io_with_datetime_in_utc_when_naive_datetime_is_provided( # noqa
self,
) -> None:
naive_point_time = datetime(
year=2022, month=6, day=11, hour=10, minute=23, second=00
)
point = self.get_gpx_point(naive_point_time)
darksky = DarkSky(api_key=self.api_key)
with patch(
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
) as forecast_io_mock:
darksky.get_weather(point)
forecast_io_mock.load_forecast.assert_called_with(
self.api_key,
point.latitude,
point.longitude,
time=datetime(
year=2022,
month=6,
day=11,
hour=10,
minute=23,
second=00,
tzinfo=pytz.utc,
),
units='si',
)
def test_it_calls_forecast_io_with_provided_datetime_when_with_timezone(
self,
) -> None:
paris_point_time = datetime(
year=2022,
month=6,
day=11,
hour=10,
minute=23,
second=00,
).astimezone(pytz.timezone('Europe/Paris'))
point = self.get_gpx_point(paris_point_time)
darksky = DarkSky(api_key=self.api_key)
with patch(
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
) as forecast_io_mock:
darksky.get_weather(point)
forecast_io_mock.load_forecast.assert_called_with(
self.api_key,
point.latitude,
point.longitude,
time=paris_point_time,
units='si',
)
def test_it_returns_forecast_currently_data(self) -> None:
darksky = DarkSky(api_key=self.api_key)
with patch(
'fittrackee.workouts.utils.weather.dark_sky.forecastio'
) as forecast_io_mock:
forecast_io_mock.load_forecast().currently.return_value = sentinel
weather_data = darksky.get_weather(
self.get_gpx_point(datetime.utcnow())
)
assert weather_data == {
'icon': sentinel.icon,
'temperature': sentinel.temperature,
'humidity': sentinel.humidity,
'wind': sentinel.windSpeed,
'windBearing': sentinel.windBearing,
}
class TestVisualCrossingGetTimestamp(WeatherTestCase):
def test_it_returns_expected_timestamp_as_integer(self) -> None:
time = datetime.utcnow()
visual_crossing = VisualCrossing(api_key=self.api_key)
timestamp = visual_crossing._get_timestamp(time)
assert isinstance(timestamp, int)
@pytest.mark.parametrize(
'input_datetime,expected_datetime',
[
('2020-12-15T13:00:00', '2020-12-15T13:00:00'),
('2020-12-15T13:29:59', '2020-12-15T13:00:00'),
('2020-12-15T13:30:00', '2020-12-15T14:00:00'),
('2020-12-15T13:59:59', '2020-12-15T14:00:00'),
],
)
def test_it_returns_rounded_time(
self, input_datetime: str, expected_datetime: str
) -> None:
time = datetime.strptime(input_datetime, '%Y-%m-%dT%H:%M:%S')
visual_crossing = VisualCrossing(api_key=self.api_key)
timestamp = visual_crossing._get_timestamp(time)
assert (
timestamp
== datetime.strptime(
expected_datetime, '%Y-%m-%dT%H:%M:%S'
).timestamp()
)
class TestVisualCrossingGetWeather(WeatherTestCase, CallArgsMixin):
@staticmethod
def get_response() -> Mock:
response_mock = Mock()
response_mock.raise_for_status = Mock()
response_mock.json = Mock()
response_mock.json.return_value = VISUAL_CROSSING_RESPONSE
return response_mock
def test_it_calls_api_with_time_and_point_location(self) -> None:
time = datetime(
year=2022,
month=11,
day=15,
hour=12,
minute=00,
second=00,
tzinfo=pytz.utc,
)
point = self.get_gpx_point(time)
visual_crossing = VisualCrossing(api_key=self.api_key)
with patch.object(requests, 'get') as get_mock:
visual_crossing.get_weather(point)
args = self.get_args(get_mock.call_args)
assert args[0] == (
'https://weather.visualcrossing.com/VisualCrossingWebServices/'
f'rest/services/timeline/{point.latitude},{point.longitude}/'
f'{int(point.time.timestamp())}' # type: ignore
)
def test_it_calls_api_with_expected_params(self) -> None:
visual_crossing = VisualCrossing(api_key=self.api_key)
with patch.object(requests, 'get') as get_mock:
visual_crossing.get_weather(self.get_gpx_point(datetime.utcnow()))
kwargs = self.get_kwargs(get_mock.call_args)
assert kwargs.get('params') == {
'key': self.api_key,
'iconSet': 'icons1',
'unitGroup': 'metric',
'contentType': 'json',
'elements': (
'datetime,datetimeEpoch,temp,humidity,windspeed,'
'winddir,conditions,description,icon'
),
'include': 'current',
}
def test_it_returns_data_from_current_conditions(self) -> None:
point = self.get_gpx_point(
datetime(
year=2022,
month=11,
day=15,
hour=13,
minute=00,
second=00,
tzinfo=pytz.utc,
).astimezone(pytz.timezone('Europe/Paris'))
)
visual_crossing = VisualCrossing(api_key=self.api_key)
with patch.object(requests, 'get', return_value=self.get_response()):
weather_data = visual_crossing.get_weather(point)
current_conditions: Dict = VISUAL_CROSSING_RESPONSE[ # type: ignore
'currentConditions'
]
assert weather_data == {
'icon': current_conditions['icon'],
'temperature': current_conditions['temp'],
'humidity': current_conditions['humidity'] / 100,
'wind': (current_conditions['windspeed'] * 1000) / 3600,
'windBearing': current_conditions['winddir'],
}
class TestWeatherService(WeatherTestCase):
@pytest.mark.parametrize(
'input_api_key,input_provider',
[
('', 'darksky'),
('valid_api_key', ''),
('valid_api_key', 'invalid_provider'),
],
)
def test_weather_api_is_none_when_configuration_is_invalid(
self,
monkeypatch: pytest.MonkeyPatch,
input_api_key: str,
input_provider: str,
) -> None:
monkeypatch.setenv('WEATHER_API_KEY', input_api_key)
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
weather_service = WeatherService()
assert weather_service.weather_api is None
@pytest.mark.parametrize(
'input_provider',
['darksky', 'DARKSKY'],
)
def test_weather_api_is_darksky_when_configured(
self,
monkeypatch: pytest.MonkeyPatch,
input_provider: str,
) -> None:
monkeypatch.setenv('WEATHER_API_KEY', 'valid_api_key')
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
weather_service = WeatherService()
assert isinstance(weather_service.weather_api, DarkSky)
@pytest.mark.parametrize(
'input_provider',
['visualcrossing', 'VisualCrossing'],
)
def test_weather_api_is_visualcrossing_when_configured(
self,
monkeypatch: pytest.MonkeyPatch,
input_provider: str,
) -> None:
monkeypatch.setenv('WEATHER_API_KEY', 'valid_api_key')
monkeypatch.setenv('WEATHER_API_PROVIDER', input_provider)
weather_service = WeatherService()
assert isinstance(weather_service.weather_api, VisualCrossing)
def test_it_returns_none_when_no_weather_api(self) -> None:
weather_service = WeatherService()
weather_service.weather_api = None
point = self.get_gpx_point(datetime.utcnow())
weather_data = weather_service.get_weather(point)
assert weather_data is None
def test_it_returns_none_when_point_time_is_none(self) -> None:
weather_service = WeatherService()
weather_service.weather_api = DarkSky('api_key')
point = self.get_gpx_point(None)
weather_data = weather_service.get_weather(point)
assert weather_data is None
def test_it_returns_none_when_weather_api_raises_exception(self) -> None:
weather_api = Mock()
weather_api.get_weather = Mock()
weather_api.get_weather.side_effect = Exception()
weather_service = WeatherService()
weather_service.weather_api = weather_api
point = self.get_gpx_point(datetime.utcnow())
weather_data = weather_service.get_weather(point)
assert weather_data is None
def test_it_returns_weather_data(self) -> None:
weather_api = Mock()
weather_api.get_weather = Mock()
weather_api.get_weather.return_value = sentinel
weather_service = WeatherService()
weather_service.weather_api = weather_api
point = self.get_gpx_point(datetime.utcnow())
weather_data = weather_service.get_weather(point)
assert weather_data == sentinel

View File

@ -4,7 +4,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union
import gpxpy.gpx import gpxpy.gpx
from ..exceptions import WorkoutGPXException from ..exceptions import WorkoutGPXException
from .weather import get_weather from .weather import WeatherService
weather_service = WeatherService()
def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]: def open_gpx_file(gpx_file: str) -> Optional[gpxpy.gpx.GPX]:
@ -99,7 +101,7 @@ def get_gpx_info(
if start is None: if start is None:
start = point.time start = point.time
if point.time and update_weather_data: if point.time and update_weather_data:
weather_data.append(get_weather(point)) weather_data.append(weather_service.get_weather(point))
# if a previous segment exists, calculate stopped time between # if a previous segment exists, calculate stopped time between
# the two segments # the two segments
@ -114,7 +116,7 @@ def get_gpx_info(
# last gpx point => get weather # last gpx point => get weather
if segment_idx == (segments_nb - 1) and update_weather_data: if segment_idx == (segments_nb - 1) and update_weather_data:
weather_data.append(get_weather(point)) weather_data.append(weather_service.get_weather(point))
if update_map_data: if update_map_data:
map_data.append([point.longitude, point.latitude]) map_data.append([point.longitude, point.latitude])

View File

@ -1,129 +0,0 @@
import os
from datetime import datetime, timedelta
from typing import Dict, Optional, Union
import forecastio
import pytz
import requests
from gpxpy.gpx import GPXTrackPoint
from fittrackee import appLog
API_KEY = os.getenv('WEATHER_API_KEY')
VC_API_KEY = os.getenv('VC_WEATHER_API_KEY')
def get_weather(point: GPXTrackPoint) -> Optional[Dict]:
try:
if not point.time:
# if there's no time associated with the point;
# we cannot get weather
return None
else:
point_time = (
pytz.utc.localize(point.time)
if point.time.tzinfo is None
else point.time.astimezone(pytz.utc)
)
if API_KEY:
# if darksky api key is present, use that
forecast = forecastio.load_forecast(
API_KEY,
point.latitude,
point.longitude,
time=point_time,
units='si',
)
weather = forecast.currently()
return {
'summary': weather.summary,
'icon': weather.icon,
'temperature': weather.temperature,
'humidity': weather.humidity,
'wind': weather.windSpeed,
'windBearing': weather.windBearing,
}
elif VC_API_KEY:
# if visualcrossing API key is present, use that
return get_visual_crossing_weather(
VC_API_KEY, point.latitude, point.longitude, point.time
)
else:
return None
except Exception as e:
appLog.error(e)
return None
def get_visual_crossing_weather(
api_key: str, latitude: float, longitude: float, time: datetime
) -> Dict[str, Union[str, float]]:
# All requests to the Timeline Weather API use the following the form:
# https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services
# /timeline/[location]/[date1]/[date2]?key=YOUR_API_KEY
# location (required) is the address, partial address or
# latitude,longitude location for
# which to retrieve weather data. You can also use US ZIP Codes.
# date1 (optional) is the start date for which to retrieve weather data.
# You can also request the information for a specific time for a single
# date by including time into the date1 field using the format
# yyyy-MM-ddTHH:mm:ss. For example 2020-10-19T13:00:00.
# The results are returned in the currentConditions field and are
# truncated to the hour requested (i.e. 2020-10-19T13:59:00 will return
# data at 2020-10-19T13:00:00).
# first, round datetime to nearest hour by truncating, and then adding an
# hour if the "real" time's number of minutes is 30 or more (we do this
# since the API only truncates)
trunc_time = time.replace(
second=0, microsecond=0, minute=0, hour=time.hour
) + timedelta(hours=time.minute // 30)
appLog.debug(
f'VC_weather: truncated time {time} ({time.timestamp()}) to '
f'{trunc_time} ({trunc_time.timestamp()})'
)
base_url = (
'https://weather.visualcrossing.com/'
+ 'VisualCrossingWebServices/rest/services'
)
url = (
f"{base_url}/timeline/{latitude},{longitude}"
f"/{int(trunc_time.timestamp())}?key={api_key}"
)
params = {
"unitGroup": "metric",
"contentType": "json",
"elements": (
"datetime,datetimeEpoch,temp,humidity,windspeed,"
"winddir,conditions,description,icon"
),
"include": "current",
}
appLog.debug(
f'VC_weather: getting weather from {url}'.replace(api_key, '*****')
)
r = requests.get(url, params=params)
r.raise_for_status()
res = r.json()
weather = res['currentConditions']
# FitTrackee expects the following units:
# temp: Celsius,
# humidity: in fraction (rather than percent)
# windSpeed: m/s
# windBearing: direction wind is from in degrees (0 is north)
# VC provides humidity in percent, wind in km/h
data = {
'summary': weather['conditions'],
'icon': f"vc-{weather['icon']}",
'temperature': weather['temp'],
'humidity': weather['humidity'] / 100,
'wind': weather['windspeed'] * 1000 / (60 * 60), # km/h to m/s
'windBearing': weather['winddir'],
}
return data

View File

@ -0,0 +1,5 @@
from .weather_service import WeatherService
__all__ = [
'WeatherService',
]

View File

@ -0,0 +1,50 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Dict, Optional
from gpxpy.gpx import GPXTrackPoint
class BaseWeather(ABC):
def __init__(self, api_key: str) -> None:
self.api_key: str = api_key
@abstractmethod
def _get_data(
self, latitude: float, longitude: float, time: datetime
) -> Optional[Dict]:
# Expected dict:
# {
# "humidity": 0.69,
# "icon": "partly-cloudy-day",
# "temperature": 12.26,
# "wind": 3.49,
# "windBearing": 315
# }
#
# FitTrackee expects the following units:
# temperature: Celsius,
# humidity: in fraction (rather than percent)
# windSpeed: m/s
# windBearing: direction wind is from in degrees (0 is north)
#
# Expected icon values (for UI):
# - "clear-day",
# - "clear-night",
# - "cloudy",
# - "fog",
# - "partly-cloudy-day",
# - "partly-cloudy-night",
# - "rain",
# - "sleet",
# - "snow",
# - "wind"
pass
def get_weather(self, point: GPXTrackPoint) -> Optional[Dict]:
if not point.time:
# if there's no time associated with the point,
# we cannot get weather
return None
return self._get_data(point.latitude, point.longitude, point.time)

View File

@ -0,0 +1,37 @@
from datetime import datetime
from typing import Dict, Optional
import forecastio
import pytz
from .base_weather import BaseWeather
class DarkSky(BaseWeather):
# Deprecated (API will end on March 31st, 2023)
def _get_data(
self, latitude: float, longitude: float, time: datetime
) -> Optional[Dict]:
# get point time in UTC
point_time = (
pytz.utc.localize(time)
if time.tzinfo is None # naive datetime
else time
)
forecast = forecastio.load_forecast(
self.api_key,
latitude,
longitude,
time=point_time,
units='si',
)
weather = forecast.currently()
return {
'humidity': weather.humidity,
'icon': weather.icon,
'temperature': weather.temperature,
'wind': weather.windSpeed,
'windBearing': weather.windBearing,
}

View File

@ -0,0 +1,84 @@
from datetime import datetime, timedelta
from typing import Dict, Optional
import requests
from fittrackee import appLog
from .base_weather import BaseWeather
class VisualCrossing(BaseWeather):
def __init__(self, api_key: str):
super().__init__(api_key)
self.base_url = (
'https://weather.visualcrossing.com/'
'VisualCrossingWebServices/rest/services'
)
self.params = {
"key": self.api_key,
"iconSet": "icons1", # default value, same as Darksky
"unitGroup": "metric",
"contentType": "json",
"elements": (
"datetime,datetimeEpoch,temp,humidity,windspeed,"
"winddir,conditions,description,icon"
),
"include": "current", # to get only specific time data
}
@staticmethod
def _get_timestamp(time: datetime) -> int:
# The results are returned in the currentConditions field and are
# truncated to the hour requested (i.e. 2020-10-19T13:59:00 will return
# data at 2020-10-19T13:00:00).
# first, round datetime to nearest hour by truncating, and then adding
# an hour if the "real" time's number of minutes is 30 or more (we do
# this since the API only truncates)
trunc_time = time.replace(
second=0, microsecond=0, minute=0, hour=time.hour
) + timedelta(hours=time.minute // 30)
appLog.debug(
f'VC_weather: truncated time {time} ({time.timestamp()})'
f' to {trunc_time} ({trunc_time.timestamp()})'
)
return int(trunc_time.timestamp())
def _get_data(
self, latitude: float, longitude: float, time: datetime
) -> Optional[Dict]:
# All requests to the Timeline Weather API use the following the form:
# https://weather.visualcrossing.com/VisualCrossingWebServices/rest
# /services/timeline/[location]/[date1]/[date2]?key=YOUR_API_KEY
# location (required) is the address, partial address or
# latitude,longitude location for
# which to retrieve weather data. You can also use US ZIP Codes.
# date1 (optional) is the start date for which to retrieve weather
# data. All dates and times are in local time of the **location**
# specified.
url = (
f"{self.base_url}/timeline/{latitude},{longitude}"
f"/{self._get_timestamp(time)}"
)
appLog.debug(
f'VC_weather: getting weather from {url}'.replace(
self.api_key, '*****'
)
)
r = requests.get(url, params=self.params)
r.raise_for_status()
res = r.json()
weather = res['currentConditions']
data = {
'icon': weather['icon'],
'temperature': weather['temp'],
'humidity': weather['humidity'] / 100,
'wind': weather['windspeed'] * 1000 / (60 * 60), # km/h to m/s
'windBearing': weather['winddir'],
}
return data

View File

@ -0,0 +1,44 @@
import os
from typing import Dict, Optional, Union
from gpxpy.gpx import GPXTrackPoint
from fittrackee import appLog
from .dark_sky import DarkSky
from .visual_crossing import VisualCrossing
class WeatherService:
"""
Available API:
- DarkSky (deprecated, will end on March 31st, 2023)
- VisualCrossing
"""
def __init__(self) -> None:
self.weather_api = self._get_weather_api()
@staticmethod
def _get_weather_api() -> Union[DarkSky, VisualCrossing, None]:
weather_api_key: str = os.getenv('WEATHER_API_KEY', '')
weather_api_provider: str = os.getenv(
'WEATHER_API_PROVIDER', ''
).lower()
if not weather_api_key:
return None
if weather_api_provider == 'darksky': # deprecated
return DarkSky(weather_api_key)
if weather_api_provider == 'visualcrossing':
return VisualCrossing(weather_api_key)
return None
def get_weather(self, point: GPXTrackPoint) -> Optional[Dict]:
if not self.weather_api:
return None
try:
return self.weather_api.get_weather(point)
except Exception as e:
appLog.error(e)
return None