From 9d69c631c2a73c4401189cbb8f29ef8d280477b2 Mon Sep 17 00:00:00 2001 From: Evgeny <940893+freekode@users.noreply.github.com> Date: Tue, 18 Jun 2024 19:07:54 +0200 Subject: [PATCH] Add support of free api (#7) * freemium * add freemium * freemium * fix style * fix --- src/pyopenweathermap/__init__.py | 3 +- .../client/freemium_client.py | 44 +++++++++ src/pyopenweathermap/client/onecall_client.py | 48 ++++++++++ .../client/owm_abstract_client.py | 18 ++++ .../client/owm_client_factory.py | 18 ++++ src/pyopenweathermap/data_converter.py | 47 +++++++++- src/pyopenweathermap/http_client.py | 36 ++++++++ src/pyopenweathermap/owm_client.py | 91 ------------------- tests/freemium_current.json | 49 ++++++++++ tests/freemium_forecast.json | 39 ++++++++ tests/onecall_current.json | 23 +++++ tests/onecall_hourly.json | 23 +++++ tests/test_all.py | 83 +++++++++++++---- 13 files changed, 411 insertions(+), 111 deletions(-) create mode 100644 src/pyopenweathermap/client/freemium_client.py create mode 100644 src/pyopenweathermap/client/onecall_client.py create mode 100644 src/pyopenweathermap/client/owm_abstract_client.py create mode 100644 src/pyopenweathermap/client/owm_client_factory.py create mode 100644 src/pyopenweathermap/http_client.py delete mode 100644 src/pyopenweathermap/owm_client.py create mode 100644 tests/freemium_current.json create mode 100644 tests/freemium_forecast.json create mode 100644 tests/onecall_current.json create mode 100644 tests/onecall_hourly.json diff --git a/src/pyopenweathermap/__init__.py b/src/pyopenweathermap/__init__.py index 7ffc8eb..f2a01b0 100644 --- a/src/pyopenweathermap/__init__.py +++ b/src/pyopenweathermap/__init__.py @@ -3,7 +3,8 @@ UnauthorizedError, TooManyRequestsError ) -from .owm_client import OWMClient +from .client.owm_abstract_client import OWMClient +from .client.owm_client_factory import OWMClientFactory from .weather import ( CurrentWeather, HourlyWeatherForecast, DailyWeatherForecast, WeatherReport, DailyTemperature, WeatherCondition ) diff --git a/src/pyopenweathermap/client/freemium_client.py b/src/pyopenweathermap/client/freemium_client.py new file mode 100644 index 0000000..20904ec --- /dev/null +++ b/src/pyopenweathermap/client/freemium_client.py @@ -0,0 +1,44 @@ +from .owm_abstract_client import OWMClient +from ..data_converter import DataConverter +from ..exception import UnauthorizedError +from ..weather import WeatherReport + +CURRENT_WEATHER_API_URL = 'https://api.openweathermap.org/data/2.5/weather' +FORECAST_API_URL = 'https://api.openweathermap.org/data/2.5/forecast' + + +class OWMFreemiumClient(OWMClient): + def __init__(self, api_key, api_type, units="metric", lang='en'): + super().__init__() + self.api_key = api_key + self.api_type = api_type + self.units = units + self.lang = lang + + async def get_weather(self, lat, lon) -> WeatherReport: + url = self._get_url(lat, lon) + json_response = await self.http_client.request(url) + + current, hourly = None, [] + if self.api_type == 'current': + current = DataConverter.freemium_to_current_weather(json_response) + else: + hourly = [DataConverter.freemium_to_hourly_weather_forecast(item) for item in json_response['list']] + return WeatherReport(current, hourly, []) + + async def validate_key(self) -> bool: + url = self._get_url(50.06, 14.44) + try: + await self.http_client.request(url) + return True + except UnauthorizedError: + return False + + def _get_url(self, lat, lon): + url = CURRENT_WEATHER_API_URL if self.api_type == 'current' else FORECAST_API_URL + return (f"{url}?" + f"lat={lat}&" + f"lon={lon}&" + f"appid={self.api_key}&" + f"units={self.units}&" + f"lang={self.lang}") diff --git a/src/pyopenweathermap/client/onecall_client.py b/src/pyopenweathermap/client/onecall_client.py new file mode 100644 index 0000000..787e97c --- /dev/null +++ b/src/pyopenweathermap/client/onecall_client.py @@ -0,0 +1,48 @@ +from .owm_abstract_client import OWMClient +from ..data_converter import DataConverter +from ..exception import UnauthorizedError +from ..weather import WeatherReport + +V30_API_URL = 'https://api.openweathermap.org/data/3.0/onecall' +V25_API_URL = 'https://api.openweathermap.org/data/2.5/onecall' + + +class OWMOneCallClient(OWMClient): + def __init__(self, api_key, api_version, units="metric", lang='en'): + super().__init__() + self.api_key = api_key + self.api_version = api_version + self.units = units + self.lang = lang + + async def get_weather(self, lat, lon) -> WeatherReport: + url = self._get_url(lat, lon) + json_response = await self.http_client.request(url) + + current, hourly, daily = None, [], [] + if json_response.get('current') is not None: + current = DataConverter.onecall_to_current_weather(json_response['current']) + if json_response.get('hourly') is not None: + hourly = [DataConverter.onecall_to_hourly_weather_forecast(item) for item in json_response['hourly']] + if json_response.get('daily') is not None: + daily = [DataConverter.onecall_to_daily_weather_forecast(item) for item in json_response['daily']] + + return WeatherReport(current, hourly, daily) + + async def validate_key(self) -> bool: + url = (f"{self._get_url(50.06, 14.44)}" + f"&exclude=current,minutely,hourly,daily,alerts)") + try: + await self.http_client.request(url) + return True + except UnauthorizedError: + return False + + def _get_url(self, lat, lon): + url = V30_API_URL if self.api_version == 'v3.0' else V25_API_URL + return (f"{url}?" + f"lat={lat}&" + f"lon={lon}&" + f"appid={self.api_key}&" + f"units={self.units}&" + f"lang={self.lang}") diff --git a/src/pyopenweathermap/client/owm_abstract_client.py b/src/pyopenweathermap/client/owm_abstract_client.py new file mode 100644 index 0000000..fbad888 --- /dev/null +++ b/src/pyopenweathermap/client/owm_abstract_client.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from ..weather import WeatherReport +from ..http_client import HttpClient + + +class OWMClient(ABC): + http_client: HttpClient + + def __init__(self) -> None: + self.http_client = HttpClient() + + @abstractmethod + async def get_weather(self, lat, lon) -> WeatherReport: + pass + + @abstractmethod + async def validate_key(self) -> bool: + pass diff --git a/src/pyopenweathermap/client/owm_client_factory.py b/src/pyopenweathermap/client/owm_client_factory.py new file mode 100644 index 0000000..faa6e00 --- /dev/null +++ b/src/pyopenweathermap/client/owm_client_factory.py @@ -0,0 +1,18 @@ +import logging + +from .freemium_client import OWMFreemiumClient +from .onecall_client import OWMOneCallClient + + +class OWMClientFactory: + @staticmethod + def get_client(api_key, api_type, units="metric", lang='en'): + logger = logging.getLogger(__name__) + + logger.info('Initializing OWMClient with api type: ' + str(api_type)) + if api_type == 'v3.0' or api_type == 'v2.5': + return OWMOneCallClient(api_key, api_type, units, lang) + if api_type == 'current' or api_type == 'forecast': + return OWMFreemiumClient(api_key, api_type, units, lang) + else: + raise Exception('Unsupported API type ' + str(api_type)) diff --git a/src/pyopenweathermap/data_converter.py b/src/pyopenweathermap/data_converter.py index d46dd57..d6cea6b 100644 --- a/src/pyopenweathermap/data_converter.py +++ b/src/pyopenweathermap/data_converter.py @@ -5,7 +5,7 @@ class DataConverter: @staticmethod - def to_current_weather(json): + def onecall_to_current_weather(json): return CurrentWeather( date_time=datetime.fromtimestamp(json['dt'], tz=UTC), temperature=json['temp'], @@ -25,7 +25,7 @@ def to_current_weather(json): ) @staticmethod - def to_hourly_weather_forecast(json): + def onecall_to_hourly_weather_forecast(json): return HourlyWeatherForecast( date_time=datetime.fromtimestamp(json['dt'], tz=UTC), temperature=json['temp'], @@ -46,7 +46,7 @@ def to_hourly_weather_forecast(json): ) @staticmethod - def to_daily_weather_forecast(json): + def onecall_to_daily_weather_forecast(json): return DailyWeatherForecast( date_time=datetime.fromtimestamp(json['dt'], tz=UTC), summary=json.get('summary'), @@ -65,6 +65,47 @@ def to_daily_weather_forecast(json): snow=json.get('snow', 0), condition=DataConverter._to_weather_condition(json['weather'][0]), ) + + @staticmethod + def freemium_to_current_weather(json): + return CurrentWeather( + date_time=datetime.fromtimestamp(json['dt'], tz=UTC), + temperature=json['main']['temp'], + feels_like=json['main']['feels_like'], + pressure=json['main']['pressure'], + humidity=json['main']['humidity'], + dew_point=None, + uv_index=None, + cloud_coverage=json['clouds'], + visibility=json['visibility'], + wind_speed=json['wind']['speed'], + wind_gust=json['wind'].get('gust'), + wind_bearing=json['wind']['deg'], + rain=json.get('rain', {}), + snow=json.get('snow', {}), + condition=DataConverter._to_weather_condition(json['weather'][0]), + ) + + @staticmethod + def freemium_to_hourly_weather_forecast(json): + return HourlyWeatherForecast( + date_time=datetime.fromtimestamp(json['dt'], tz=UTC), + temperature=json['main']['temp'], + feels_like=json['main']['feels_like'], + pressure=json['main']['pressure'], + humidity=json['main']['humidity'], + dew_point=None, + uv_index=None, + cloud_coverage=json['clouds']['all'], + visibility=json.get('visibility', None), + wind_speed=json['wind']['speed'], + wind_gust=json['wind'].get('gust'), + wind_bearing=json['wind']['deg'], + precipitation_probability=json.get('pop', 0), + rain=json.get('rain', {}), + snow=json.get('snow', {}), + condition=DataConverter._to_weather_condition(json['weather'][0]), + ) @staticmethod def _to_weather_condition(json): diff --git a/src/pyopenweathermap/http_client.py b/src/pyopenweathermap/http_client.py new file mode 100644 index 0000000..1f47c83 --- /dev/null +++ b/src/pyopenweathermap/http_client.py @@ -0,0 +1,36 @@ +from aiohttp import ClientSession + +from .exception import UnauthorizedError, RequestError, TooManyRequestsError +import logging + +class HttpClient: + request_timeout: int + logger = logging.getLogger(__name__) + + def __init__(self, request_timeout=20): + self.request_timeout = request_timeout + + async def request(self, url): + self.logger.debug('Requesting url: ' + url) + async with ClientSession() as session: + try: + async with session.get(url=url, timeout=self.request_timeout) as response: + response_json = await response.json() + if response.status == 200: + return response_json + elif response.status == 400: + raise RequestError(response_json.get('message')) + elif response.status == 401: + raise UnauthorizedError(response_json.get('message')) + elif response.status == 404: + raise RequestError(response_json.get('message')) + elif response.status == 429: + raise TooManyRequestsError(response_json.get('message')) + else: + raise RequestError("Unknown status code: {}".format(response.status)) + except RequestError as error: + raise error + except TimeoutError: + raise RequestError("Request timeout") + except Exception as error: + raise RequestError(error) from error diff --git a/src/pyopenweathermap/owm_client.py b/src/pyopenweathermap/owm_client.py deleted file mode 100644 index 052b50f..0000000 --- a/src/pyopenweathermap/owm_client.py +++ /dev/null @@ -1,91 +0,0 @@ -from aiohttp import ClientSession - -from .data_converter import DataConverter -from .exception import UnauthorizedError, RequestError, TooManyRequestsError -from .weather import WeatherReport -import logging - -API_V30_URL = 'https://api.openweathermap.org/data/3.0/onecall' -API_V25_URL = 'https://api.openweathermap.org/data/2.5/onecall' -WEATHER_TYPES = {'current', 'minutely', 'hourly', 'daily', 'alerts'} - - -class OWMClient: - session: ClientSession | None = None - request_timeout: int - logger = logging.getLogger(__name__) - - def __init__(self, api_key, api_version, units="metric", lang='en', request_timeout=20): - self.logger.info('Initializing OWMClient with api version: ' + str(api_version)) - if api_version == 'v3.0': - self.main_url = API_V30_URL - elif api_version == 'v2.5': - self.main_url = API_V25_URL - else: - raise Exception('Unsupported API version ' + str(api_version)) - self.api_key = api_key - self.units = units - self.lang = lang - self.request_timeout = request_timeout - - async def get_weather(self, lat, lon, weather_types=None) -> WeatherReport: - if weather_types is None: - exclude_weather_types = {} - else: - exclude_weather_types = WEATHER_TYPES - set(weather_types) - - url = self._get_url(lat, lon, exclude_weather_types) - json_response = await self._request(url) - - current, hourly, daily = None, [], [] - if json_response.get('current') is not None: - current = DataConverter.to_current_weather(json_response['current']) - if json_response.get('hourly') is not None: - hourly = [DataConverter.to_hourly_weather_forecast(item) for item in json_response['hourly']] - if json_response.get('daily') is not None: - daily = [DataConverter.to_daily_weather_forecast(item) for item in json_response['daily']] - - return WeatherReport(current, hourly, daily) - - async def validate_key(self) -> bool: - url = self._get_url(50.06, 14.44, WEATHER_TYPES) - try: - - await self._request(url) - return True - except UnauthorizedError: - return False - - async def _request(self, url): - self.logger.debug('Requesting url: ' + url) - async with ClientSession() as session: - try: - async with session.get(url=url, timeout=self.request_timeout) as response: - response_json = await response.json() - if response.status == 200: - return response_json - elif response.status == 400: - raise RequestError(response_json.get('message')) - elif response.status == 401: - raise UnauthorizedError(response_json.get('message')) - elif response.status == 404: - raise RequestError(response_json.get('message')) - elif response.status == 429: - raise TooManyRequestsError(response_json.get('message')) - else: - raise RequestError("Unknown status code: {}".format(response.status)) - except RequestError as error: - raise error - except TimeoutError: - raise RequestError("Request timeout") - except Exception as error: - raise RequestError(error) from error - - def _get_url(self, lat, lon, exclude): - return (f"{self.main_url}?" - f"lat={lat}&" - f"lon={lon}&" - f"exclude={','.join(exclude)}&" - f"appid={self.api_key}&" - f"units={self.units}&" - f"lang={self.lang}") diff --git a/tests/freemium_current.json b/tests/freemium_current.json new file mode 100644 index 0000000..525fa25 --- /dev/null +++ b/tests/freemium_current.json @@ -0,0 +1,49 @@ +{ + "coord": { + "lon": 10.99, + "lat": 44.34 + }, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "base": "stations", + "main": { + "temp": 298.48, + "feels_like": 298.74, + "temp_min": 297.56, + "temp_max": 300.05, + "pressure": 1015, + "humidity": 64, + "sea_level": 1015, + "grnd_level": 933 + }, + "visibility": 10000, + "wind": { + "speed": 0.62, + "deg": 349, + "gust": 1.18 + }, + "rain": { + "1h": 3.16 + }, + "clouds": { + "all": 100 + }, + "dt": 1661870592, + "sys": { + "type": 2, + "id": 2075663, + "country": "IT", + "sunrise": 1661834187, + "sunset": 1661882248 + }, + "timezone": 7200, + "id": 3163858, + "name": "Zocca", + "cod": 200 +} diff --git a/tests/freemium_forecast.json b/tests/freemium_forecast.json new file mode 100644 index 0000000..a69870c --- /dev/null +++ b/tests/freemium_forecast.json @@ -0,0 +1,39 @@ +{ + "dt": 1661871600, + "main": { + "temp": 296.76, + "feels_like": 296.98, + "temp_min": 296.76, + "temp_max": 297.87, + "pressure": 1015, + "sea_level": 1015, + "grnd_level": 933, + "humidity": 69, + "temp_kf": -1.11 + }, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "clouds": { + "all": 100 + }, + "wind": { + "speed": 0.62, + "deg": 349, + "gust": 1.18 + }, + "visibility": 10000, + "pop": 0.32, + "rain": { + "3h": 0.26 + }, + "sys": { + "pod": "d" + }, + "dt_txt": "2022-08-30 15:00:00" +} diff --git a/tests/onecall_current.json b/tests/onecall_current.json new file mode 100644 index 0000000..219e3bb --- /dev/null +++ b/tests/onecall_current.json @@ -0,0 +1,23 @@ +{ + "dt": 1714063536, + "sunrise": 1714018842, + "sunset": 1714071341, + "temp": 6.84, + "feels_like": 2.07, + "pressure": 1000, + "humidity": 82, + "dew_point": 3.99, + "uvi": 0.13, + "clouds": 75, + "visibility": 10000, + "wind_speed": 9.83, + "wind_deg": 199, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] +} diff --git a/tests/onecall_hourly.json b/tests/onecall_hourly.json new file mode 100644 index 0000000..14eb07e --- /dev/null +++ b/tests/onecall_hourly.json @@ -0,0 +1,23 @@ +{ + "dt": 1714168800, + "temp": 5.82, + "feels_like": 5.82, + "pressure": 1007, + "humidity": 87, + "dew_point": 3.85, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.02, + "wind_deg": 167, + "wind_gust": 1.39, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ], + "pop": 0.79 +} diff --git a/tests/test_all.py b/tests/test_all.py index 65850d2..8200be5 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -1,7 +1,8 @@ import os +import json import pytest -from pyopenweathermap import RequestError, OWMClient +from pyopenweathermap import RequestError, OWMClient, OWMClientFactory from pyopenweathermap.data_converter import DataConverter LATITUDE = '52.3731339' @@ -12,50 +13,100 @@ @pytest.mark.asyncio async def test_api_30(): api_key = os.getenv('OWM_API_KEY') - client = OWMClient(api_key, 'v3.0') - report = await client.get_weather(LATITUDE, LONGITUDE, ['current', 'hourly', 'daily']) + client = OWMClientFactory.get_client(api_key, 'v3.0') + report = await client.get_weather(LATITUDE, LONGITUDE) assert report.current.date_time is not None assert report.hourly_forecast[0].condition.id is not None assert report.daily_forecast[0].condition.id is not None + + +@pytest.mark.network +@pytest.mark.asyncio +async def test_api_25(): + api_key = os.getenv('OWM_API_KEY') + client = OWMClientFactory.get_client(api_key, 'v2.5') + report = await client.get_weather(LATITUDE, LONGITUDE) + assert report.current.date_time is not None + assert report.hourly_forecast[0].condition.id is not None + assert report.daily_forecast[0].condition.id is not None + + +@pytest.mark.network +@pytest.mark.asyncio +async def test_freemium_current_weather(): + api_key = os.getenv('OWM_API_KEY') + client = OWMClientFactory.get_client(api_key, 'current') + report = await client.get_weather(LATITUDE, LONGITUDE) + assert report.current.date_time is not None + assert len(report.hourly_forecast) is 0 + assert len(report.daily_forecast) is 0 + + +@pytest.mark.network +@pytest.mark.asyncio +async def test_freemium_forecast_weather(): + api_key = os.getenv('OWM_API_KEY') + client = OWMClientFactory.get_client(api_key, 'forecast') + report = await client.get_weather(LATITUDE, LONGITUDE) + assert report.current is None + assert report.hourly_forecast[0].temperature is not None + assert len(report.daily_forecast) is 0 @pytest.mark.network @pytest.mark.asyncio async def test_api_25_validate_key(): - client = OWMClient('123', 'v2.5') + client = OWMClientFactory.get_client('123', 'v2.5') assert await client.validate_key() is False @pytest.mark.asyncio async def test_request_error(): api_key = os.getenv('OWM_API_KEY') - client = OWMClient(api_key, 'v3.0') + client = OWMClientFactory.get_client(api_key, 'v3.0') with pytest.raises(RequestError) as error: - await client.get_weather('100', LONGITUDE, ['current', 'hourly', 'daily']) + await client.get_weather('100', LONGITUDE) assert error is not None @pytest.mark.network @pytest.mark.asyncio async def test_api_key_validation(): - client = OWMClient('123', 'v3.0') + client = OWMClientFactory.get_client('123', 'v3.0') assert await client.validate_key() is False def test_current_weather_converter(): - data = {'dt': 1714063536, 'sunrise': 1714018842, 'sunset': 1714071341, 'temp': 6.84, 'feels_like': 2.07, - 'pressure': 1000, 'humidity': 82, 'dew_point': 3.99, 'uvi': 0.13, 'clouds': 75, 'visibility': 10000, - 'wind_speed': 9.83, 'wind_deg': 199, - 'weather': [{'id': 803, 'main': 'Clouds', 'description': 'broken clouds', 'icon': '04d'}]} - weather = DataConverter.to_current_weather(data) + data = None + with open('tests/onecall_current.json') as f: + data = json.load(f) + weather = DataConverter.onecall_to_current_weather(data) assert weather.date_time is not None assert weather.condition.id is not None def test_hourly_weather_deserialization(): - data = {'dt': 1714168800, 'temp': 5.82, 'feels_like': 5.82, 'pressure': 1007, 'humidity': 87, 'dew_point': 3.85, - 'uvi': 0, 'clouds': 100, 'visibility': 10000, 'wind_speed': 1.02, 'wind_deg': 167, 'wind_gust': 1.39, - 'weather': [{'id': 500, 'main': 'Rain', 'description': 'light rain', 'icon': '10n'}], 'pop': 0.79} - weather = DataConverter.to_hourly_weather_forecast(data) + data = None + with open('tests/onecall_hourly.json') as f: + data = json.load(f) + weather = DataConverter.onecall_to_hourly_weather_forecast(data) + assert weather.date_time is not None + assert weather.condition.id is not None + + +def test_weather_deserialization(): + data = None + with open('tests/freemium_current.json') as f: + data = json.load(f) + weather = DataConverter.freemium_to_current_weather(data) + assert weather.date_time is not None + assert weather.condition.id is not None + + +def test_forecast_deserialization(): + data = None + with open('tests/freemium_forecast.json') as f: + data = json.load(f) + weather = DataConverter.freemium_to_hourly_weather_forecast(data) assert weather.date_time is not None assert weather.condition.id is not None