From 81f0a538aad374514dd5ae1b3669869bbc42a2cd Mon Sep 17 00:00:00 2001 From: Danil Date: Sat, 9 Mar 2024 22:11:31 +0500 Subject: [PATCH 1/4] item 1-5 --- src/collectors/collector.py | 6 +++ src/collectors/models.py | 13 +++++++ src/renderer.py | 77 ++++++++++++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/collectors/collector.py b/src/collectors/collector.py index ebadf7e..0096b2f 100644 --- a/src/collectors/collector.py +++ b/src/collectors/collector.py @@ -91,6 +91,8 @@ async def read(cls) -> Optional[list[CountryDTO]]: result_list.append( CountryDTO( capital=item["capital"], + capital_latitude=item["latitude"], + capital_longitude=item["longitude"], alpha2code=item["alpha2code"], alt_spellings=item["alt_spellings"], currencies={ @@ -103,6 +105,7 @@ async def read(cls) -> Optional[list[CountryDTO]]: population=item["population"], subregion=item["subregion"], timezones=item["timezones"], + area=item["area"], ) ) @@ -219,6 +222,9 @@ async def read(cls, location: LocationDTO) -> Optional[WeatherInfoDTO]: humidity=result["main"]["humidity"], wind_speed=result["wind"]["speed"], description=result["weather"][0]["description"], + visibility=result["visibility"], + timezone=result["timezone"], + dt=result["dt"], ) return None diff --git a/src/collectors/models.py b/src/collectors/models.py index 7e36198..5c4b168 100644 --- a/src/collectors/models.py +++ b/src/collectors/models.py @@ -3,6 +3,7 @@ """ from pydantic import Field, BaseModel +from typing import Optional class HashableBaseModel(BaseModel): @@ -68,6 +69,8 @@ class CountryDTO(BaseModel): CountryDTO( capital="Mariehamn", + capital_latitude=60.116667, + capital_longitude=19.9, alpha2code="AX", alt_spellings=[ "AX", @@ -93,10 +96,13 @@ class CountryDTO(BaseModel): timezones=[ "UTC+02:00", ], + area=1580 ) """ capital: str + capital_latitude: float + capital_longitude: float alpha2code: str alt_spellings: list[str] currencies: set[CurrencyInfoDTO] @@ -106,6 +112,7 @@ class CountryDTO(BaseModel): population: int subregion: str timezones: list[str] + area: Optional[int] class CurrencyRatesDTO(BaseModel): @@ -140,6 +147,9 @@ class WeatherInfoDTO(BaseModel): humidity=54, wind_speed=4.63, description="scattered clouds", + visibility=10000, + timezone=-21600, + dt=1709996768 ) """ @@ -148,6 +158,9 @@ class WeatherInfoDTO(BaseModel): humidity: int wind_speed: float description: str + visibility: int + timezone: int + dt: int class LocationInfoDTO(BaseModel): diff --git a/src/renderer.py b/src/renderer.py index 8b90dcc..14df33d 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -6,6 +6,8 @@ from collectors.models import LocationInfoDTO +import datetime as dt + class Renderer: """ @@ -28,15 +30,58 @@ async def render(self) -> tuple[str, ...]: :return: Результат форматирования """ - return ( - f"Страна: {self.location_info.location.name}", - f"Столица: {self.location_info.location.capital}", - f"Регион: {self.location_info.location.subregion}", - f"Языки: {await self._format_languages()}", - f"Население страны: {await self._format_population()} чел.", - f"Курсы валют: {await self._format_currency_rates()}", - f"Погода: {self.location_info.weather.temp} °C", - ) + country_part = { + "Страна": f"{self.location_info.location.name}", + "Регион": f"{self.location_info.location.subregion}", + "Площадь страны": f"{self.location_info.location.area} км²", + "Языки": f"{await self._format_languages()}", + "Население страны": f"{await self._format_population()} чел.", + "Курсы валют": f"{await self._format_currency_rates()}", + } + + capital_part = { + "Столица": f"{self.location_info.location.capital}", + "Широта столицы": f"{self.location_info.location.capital_latitude}", + "Долгота столицы": f"{self.location_info.location.capital_longitude}", + "Погода в столице": f"{self.location_info.weather.temp} °C", + "Описание погоды в столице": f"{self.location_info.weather.description}", + "Скорость ветра в столице": f"{self.location_info.weather.wind_speed} м/с", + "Видимость в столице": f"{self.location_info.weather.visibility} м", + "Часовой пояс столицы": f"{await self._format_timezone()}", + "Время в столице": + f"{dt.datetime.fromtimestamp(self.location_info.weather.dt + self.location_info.weather.timezone)}", + } + + max_key_length, max_val_length = 0, 0 + + for key in country_part: + max_key_length = max(max_key_length, len(key)) + max_val_length = max(max_val_length, len(country_part[key])) + + for key in capital_part: + max_key_length = max(max_key_length, len(key)) + max_val_length = max(max_val_length, len(capital_part[key])) + + country_part_header, capital_part_header = "Информация о стране", "Информация о столице" + result = ["_" * (max_key_length + max_val_length + 9) + "\n", + "| " + country_part_header + " " * (max_key_length + max_val_length + 5 - len(country_part_header)) + + " |\n", "|" + "-" * (max_key_length + max_val_length + 7) + "|\n"] + + for key in country_part: + result.append("| " + key + " " * (max_key_length - len(key)) + + " | " + country_part[key] + " " * (max_val_length - len(country_part[key]) + 2) + " |\n") + result.append("|" + "-" * (max_key_length + max_val_length + 7) + "|\n") + + result.append("| " + capital_part_header + " " * (max_key_length + max_val_length + 5 - len(capital_part_header)) + + " |\n") + result.append("|" + "-" * (max_key_length + max_val_length + 7) + "|\n") + + for key in capital_part: + result.append("| " + key + " " * (max_key_length - len(key)) + + " | " + capital_part[key] + " " * (max_val_length - len(capital_part[key]) + 2) + " |\n") + result.append("|" + "-" * (max_key_length + max_val_length + 7) + "|\n") + + return tuple(result) async def _format_languages(self) -> str: """ @@ -71,3 +116,17 @@ async def _format_currency_rates(self) -> str: f"{currency} = {Decimal(rates).quantize(exp=Decimal('.01'), rounding=ROUND_HALF_UP)} руб." for currency, rates in self.location_info.currency_rates.items() ) + + async def _format_timezone(self) -> str: + """ + Форматирование информации о курсах валют. + + :return: + """ + + base = "UTC" + + if self.location_info.weather.timezone >= 0: + base += "+" + + return base + f"{self.location_info.weather.timezone // 3600}" From c7c18974569064cfb5656cdacdd3af7ccfe6de56 Mon Sep 17 00:00:00 2001 From: Danil Date: Sat, 9 Mar 2024 23:44:32 +0500 Subject: [PATCH 2/4] get latest news by country --- .env.sample | 4 ++ src/clients/news.py | 41 ++++++++++++++++++++ src/collectors/collector.py | 74 +++++++++++++++++++++++++++++++++++++ src/collectors/models.py | 22 +++++++++++ src/reader.py | 16 ++++++++ src/renderer.py | 18 ++++++++- src/settings.py | 3 ++ 7 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/clients/news.py diff --git a/.env.sample b/.env.sample index 9fd2e4b..12621cf 100644 --- a/.env.sample +++ b/.env.sample @@ -13,6 +13,8 @@ LOGGING_LEVEL=DEBUG API_KEY_APILAYER= # https://openweathermap.org/price#weather API_KEY_OPENWEATHER= +# https://newsapi.org/ +API_KEY_NEWS= # время актуальности данных о странах (в секундах) CACHE_TTL_COUNTRY=31_536_000 @@ -20,3 +22,5 @@ CACHE_TTL_COUNTRY=31_536_000 CACHE_TTL_CURRENCY_RATES=86_400 # время актуальности данных о погоде (в секундах) CACHE_TTL_WEATHER=10_700 +# время актуальности данных о новостях (в секундах) +CACHE_TTL_NEWS=3_600 diff --git a/src/clients/news.py b/src/clients/news.py new file mode 100644 index 0000000..0bd96e8 --- /dev/null +++ b/src/clients/news.py @@ -0,0 +1,41 @@ +""" +Функции для взаимодействия с внешним сервисом-провайдером данных о новостях. +""" +from http import HTTPStatus +from typing import Optional + +import aiohttp + +from clients.base import BaseClient +from logger import trace_config +from settings import API_KEY_NEWS + + +class NewsClient(BaseClient): + """ + Реализация функций для взаимодействия с внешним сервисом-провайдером данных о новостях. + """ + + async def get_base_url(self) -> str: + return "https://newsapi.org/v2/top-headlines" + + async def _request(self, endpoint: str) -> Optional[dict]: + + async with aiohttp.ClientSession(trace_configs=[trace_config]) as session: + async with session.get(endpoint) as response: + if response.status == HTTPStatus.OK: + return (await response.json())["articles"] + + return None + + async def get_news(self, location: str) -> Optional[dict]: + """ + Получение данных о новостях. + + :param location: Код страны (например: us, ru, ...) + :return: + """ + + return await self._request( + f"{await self.get_base_url()}?pageSize=3&page=1&country={location}&apiKey={API_KEY_NEWS}" + ) diff --git a/src/collectors/collector.py b/src/collectors/collector.py index 0096b2f..a6fed55 100644 --- a/src/collectors/collector.py +++ b/src/collectors/collector.py @@ -14,6 +14,7 @@ from clients.country import CountryClient from clients.currency import CurrencyClient from clients.weather import WeatherClient +from clients.news import NewsClient from collectors.base import BaseCollector from collectors.models import ( LocationDTO, @@ -21,12 +22,14 @@ CurrencyRatesDTO, CurrencyInfoDTO, WeatherInfoDTO, + CountryNewsDTO, ) from settings import ( MEDIA_PATH, CACHE_TTL_COUNTRY, CACHE_TTL_CURRENCY_RATES, CACHE_TTL_WEATHER, + CACHE_TTL_NEWS ) @@ -230,6 +233,76 @@ async def read(cls, location: LocationDTO) -> Optional[WeatherInfoDTO]: return None +class NewsCollector(BaseCollector): + """ + Сбор информации о новостях. + """ + + def __init__(self) -> None: + self.client = NewsClient() + + @staticmethod + async def get_file_path(filename: str = "", **kwargs: Any) -> str: + return f"{MEDIA_PATH}/news/{filename}.json" + + @staticmethod + async def get_cache_ttl() -> int: + return CACHE_TTL_NEWS + + async def collect( + self, locations: FrozenSet[LocationDTO] = frozenset(), **kwargs: Any + ) -> None: + + target_dir_path = f"{MEDIA_PATH}/news" + # если целевой директории еще не существует, то она создается + if not await aiofiles.os.path.exists(target_dir_path): + await aiofiles.os.mkdir(target_dir_path) + + for location in locations: + filename = f"{location.alpha2code}".lower() + if await self.cache_invalid(filename=filename): + # если кэш уже невалиден, то актуализируем его + result = await self.client.get_news( + f"{location.alpha2code}" + ) + if result: + result_str = json.dumps(result) + async with aiofiles.open( + await self.get_file_path(filename), mode="w" + ) as file: + await file.write(result_str) + + @classmethod + async def read(cls, location: LocationDTO) -> Optional[list[CountryNewsDTO]]: + """ + Чтение данных из кэша. + + :param location: + :return: + """ + + filename = f"{location.alpha2code}".lower() + async with aiofiles.open(await cls.get_file_path(filename), mode="r") as file: + content = await file.read() + + result = json.loads(content) + result_arr = [] + + if result: + for news_item in result: + result_arr.append(CountryNewsDTO( + title=news_item["title"], + description=news_item["description"], + url=news_item["url"], + published_at=news_item["publishedAt"], + ) + ) + + return result_arr + + return None + + class Collectors: @staticmethod async def gather() -> tuple: @@ -244,6 +317,7 @@ def collect() -> None: try: results = loop.run_until_complete(Collectors.gather()) loop.run_until_complete(WeatherCollector().collect(results[1])) + loop.run_until_complete(NewsCollector().collect(results[1])) loop.run_until_complete(loop.shutdown_asyncgens()) finally: diff --git a/src/collectors/models.py b/src/collectors/models.py index 5c4b168..79da8ec 100644 --- a/src/collectors/models.py +++ b/src/collectors/models.py @@ -163,6 +163,27 @@ class WeatherInfoDTO(BaseModel): dt: int +class CountryNewsDTO(BaseModel): + """ + Модель данных о новостях. + + .. code-block:: + + CountryNewsDTO( + title="Fortnite was down all day Friday, but now the 'Myths & Mortals' update is here - The Verge" + description="Fortnite’s Chapter 5 Season 2 launch was unexpectedly delayed for extended server maintenance. + Now v29.00 is live with the “Myths & Mortals” theme." + url="https://www.theverge.com/2024/3/8/24094877/fortnite-down-outage-chapter-5-season-2-update-download"" + published_at="2024-03-09T14:14:00Z" + ) + """ + + title: str + description: str | None + url: str | None + published_at: str | None + + class LocationInfoDTO(BaseModel): """ Модель данных для представления общей информации о месте. @@ -214,3 +235,4 @@ class LocationInfoDTO(BaseModel): location: CountryDTO weather: WeatherInfoDTO currency_rates: dict[str, float] + news: list[CountryNewsDTO] diff --git a/src/reader.py b/src/reader.py index dd1a74d..5dda196 100644 --- a/src/reader.py +++ b/src/reader.py @@ -9,6 +9,7 @@ CountryCollector, CurrencyRatesCollector, WeatherCollector, + NewsCollector, ) from collectors.models import ( CountryDTO, @@ -16,6 +17,7 @@ LocationDTO, LocationInfoDTO, WeatherInfoDTO, + CountryNewsDTO, ) @@ -38,11 +40,15 @@ async def find(self, location: str) -> Optional[LocationInfoDTO]: LocationDTO(capital=country.capital, alpha2code=country.alpha2code) ) currency_rates = await self.get_currency_rates(country.currencies) + news = await self.get_news( + LocationDTO(capital=country.capital, alpha2code=country.alpha2code) + ) return LocationInfoDTO( location=country, weather=weather, currency_rates=currency_rates, + news=news ) return None @@ -75,6 +81,16 @@ async def get_weather(location: LocationDTO) -> Optional[WeatherInfoDTO]: """ return await WeatherCollector.read(location=location) + @staticmethod + async def get_news(location: LocationDTO) -> Optional[CountryNewsDTO]: + """ + Получение данных о новостях. + + :param location: Объект локации для получения данных + :return: + """ + return await NewsCollector.read(location=location) + async def find_country(self, search: str) -> Optional[CountryDTO]: """ Поиск страны. diff --git a/src/renderer.py b/src/renderer.py index 14df33d..d3b8dd5 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -81,6 +81,8 @@ async def render(self) -> tuple[str, ...]: + " | " + capital_part[key] + " " * (max_val_length - len(capital_part[key]) + 2) + " |\n") result.append("|" + "-" * (max_key_length + max_val_length + 7) + "|\n") + result.append(f"\n\n{await self._format_news()}") + return tuple(result) async def _format_languages(self) -> str: @@ -119,7 +121,7 @@ async def _format_currency_rates(self) -> str: async def _format_timezone(self) -> str: """ - Форматирование информации о курсах валют. + Форматирование информации о часовом поясе. :return: """ @@ -130,3 +132,17 @@ async def _format_timezone(self) -> str: base += "+" return base + f"{self.location_info.weather.timezone // 3600}" + + async def _format_news(self) -> str: + """ + Форматирование информации о новостях. + + :return: + """ + + result = "Новости по данной стране\n" + + for news_item in self.location_info.news: + result += f"{news_item.title}\n{news_item.description}\n{news_item.url}\n{news_item.published_at}\n\n" + + return result diff --git a/src/settings.py b/src/settings.py index 4559f84..1a0ec98 100644 --- a/src/settings.py +++ b/src/settings.py @@ -20,6 +20,7 @@ # ключи для доступа к API API_KEY_APILAYER: Optional[str] = os.getenv("API_KEY_APILAYER") API_KEY_OPENWEATHER: Optional[str] = os.getenv("API_KEY_OPENWEATHER") +API_KEY_NEWS: Optional[str] = os.getenv("API_KEY_NEWS") # время актуальности данных о странах (в секундах), по умолчанию – один год CACHE_TTL_COUNTRY: int = int(os.getenv("CACHE_TTL_COUNTRY", "31_536_000")) @@ -27,3 +28,5 @@ CACHE_TTL_CURRENCY_RATES: int = int(os.getenv("CACHE_TTL_CURRENCY_RATES", "86_400")) # время актуальности данных о погоде (в секундах), по умолчанию ~ три часа CACHE_TTL_WEATHER: int = int(os.getenv("CACHE_TTL_WEATHER", "10_700")) +# время актуальности данных о новостях (в секундах) +CACHE_TTL_NEWS: int = int(os.getenv("CACHE_TTL_NEWS", "3_600")) From 6e67cc96e3e65aa0f9de55a596c3e4736e536735 Mon Sep 17 00:00:00 2001 From: Danil Date: Sun, 10 Mar 2024 01:38:43 +0500 Subject: [PATCH 3/4] tests & some fixes --- src/clients/news.py | 5 +- src/tests/clients/test_currency.py | 28 +++++++++ src/tests/clients/test_news.py | 31 ++++++++++ src/tests/clients/test_weather.py | 28 +++++++++ src/tests/collectors/test_country.py | 25 ++++++++ src/tests/collectors/test_currency.py | 25 ++++++++ src/tests/collectors/test_news.py | 33 ++++++++++ src/tests/collectors/test_weather.py | 32 ++++++++++ src/tests/test_reader.py | 89 +++++++++++++++++++++++++++ src/tests/test_renderer.py | 89 +++++++++++++++++++++++++++ 10 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/tests/clients/test_news.py create mode 100644 src/tests/collectors/test_news.py diff --git a/src/clients/news.py b/src/clients/news.py index 0bd96e8..54804ae 100644 --- a/src/clients/news.py +++ b/src/clients/news.py @@ -28,14 +28,15 @@ async def _request(self, endpoint: str) -> Optional[dict]: return None - async def get_news(self, location: str) -> Optional[dict]: + async def get_news(self, location: str, news_count: int = 3) -> Optional[dict]: """ Получение данных о новостях. :param location: Код страны (например: us, ru, ...) + :param news_count: Максимальное количество новостей :return: """ return await self._request( - f"{await self.get_base_url()}?pageSize=3&page=1&country={location}&apiKey={API_KEY_NEWS}" + f"{await self.get_base_url()}?pageSize={news_count}&page=1&country={location}&apiKey={API_KEY_NEWS}" ) diff --git a/src/tests/clients/test_currency.py b/src/tests/clients/test_currency.py index 104d612..fbd09b6 100644 --- a/src/tests/clients/test_currency.py +++ b/src/tests/clients/test_currency.py @@ -1,3 +1,31 @@ """ Тестирование функций клиента для получения информации о курсах валют. """ + +import pytest + +from clients.currency import CurrencyClient + + +@pytest.mark.asyncio +class TestClientCurrency: + """ + Тестирование клиента для получения информации о валютах. + """ + + base_url = "https://api.apilayer.com/fixer/latest" + + @pytest.fixture + def client(self): + return CurrencyClient() + + async def test_get_base_url(self, client): + assert await client.get_base_url() == self.base_url + + async def test_get_currency(self, mocker, client): + mocker.patch("clients.currency.CurrencyClient._request") + await client.get_rates() + client._request.assert_called_once_with(f"{self.base_url}?base=rub") + + await client.get_rates("usd") + client._request.assert_called_with(f"{self.base_url}?base=usd") diff --git a/src/tests/clients/test_news.py b/src/tests/clients/test_news.py new file mode 100644 index 0000000..29c4830 --- /dev/null +++ b/src/tests/clients/test_news.py @@ -0,0 +1,31 @@ +""" +Тестирование функций клиента для получения информации о новостях. +""" + +import pytest + +from clients.news import NewsClient +from settings import API_KEY_NEWS + + +@pytest.mark.asyncio +class TestClientNews: + """ + Тестирование клиента для получения информации о новостях. + """ + + base_url = "https://newsapi.org/v2/top-headlines" + + @pytest.fixture + def client(self): + return NewsClient() + + async def test_get_base_url(self, client): + assert await client.get_base_url() == self.base_url + + async def test_get_news(self, mocker, client): + mocker.patch("clients.news.NewsClient._request") + await client.get_news("ru") + client._request.assert_called_once_with( + f"{self.base_url}?pageSize=3&page=1&country=ru&apiKey={API_KEY_NEWS}" + ) diff --git a/src/tests/clients/test_weather.py b/src/tests/clients/test_weather.py index 3c14430..cb7beff 100644 --- a/src/tests/clients/test_weather.py +++ b/src/tests/clients/test_weather.py @@ -1,3 +1,31 @@ """ Тестирование функций клиента для получения информации о погоде. """ + +import pytest + +from clients.weather import WeatherClient +from settings import API_KEY_OPENWEATHER + + +@pytest.mark.asyncio +class TestClientWeather: + """ + Тестирование клиента для получения информации о погоде. + """ + + base_url = "https://api.openweathermap.org/data/2.5/weather" + + @pytest.fixture + def client(self): + return WeatherClient() + + async def test_get_base_url(self, client): + assert await client.get_base_url() == self.base_url + + async def test_get_countries(self, mocker, client): + mocker.patch("clients.weather.WeatherClient._request") + await client.get_weather("Moscow") + client._request.assert_called_with( + f"{self.base_url}?units=metric&q=Moscow&appid={API_KEY_OPENWEATHER}" + ) \ No newline at end of file diff --git a/src/tests/collectors/test_country.py b/src/tests/collectors/test_country.py index 325936d..14c75b4 100644 --- a/src/tests/collectors/test_country.py +++ b/src/tests/collectors/test_country.py @@ -1,3 +1,28 @@ """ Тестирование функций сбора информации о странах. """ + +import pytest + +from collectors.collector import CountryCollector + + +@pytest.mark.asyncio +class TestClientCountry: + """ + Тестирование клиента для получения информации о странах. + """ + + @pytest.fixture + def collector(self): + return CountryCollector() + + async def test_collect_countries(self, collector): + result = await collector.collect() + + assert len(result) == 49 + + async def test_read_countries(self, collector): + result = await collector.read() + + assert len(result) == 49 diff --git a/src/tests/collectors/test_currency.py b/src/tests/collectors/test_currency.py index f8982e0..e696d0e 100644 --- a/src/tests/collectors/test_currency.py +++ b/src/tests/collectors/test_currency.py @@ -1,3 +1,28 @@ """ Тестирование функций сбора информации о курсах валют. """ + +import pytest + +from collectors.collector import CurrencyRatesCollector + + +@pytest.mark.asyncio +class TestClientCountry: + """ + Тестирование клиента для получения информации о курсах валют. + """ + + @pytest.fixture + def collector(self): + return CurrencyRatesCollector() + + async def test_collect_currency_rates(self, collector): + await collector.collect() + + async def test_read_currency_rates(self, collector): + result = await collector.read() + + assert result is not None + assert result.base == "RUB" + assert len(result.rates) == 170 diff --git a/src/tests/collectors/test_news.py b/src/tests/collectors/test_news.py new file mode 100644 index 0000000..ef0850e --- /dev/null +++ b/src/tests/collectors/test_news.py @@ -0,0 +1,33 @@ +""" +Тестирование функций сбора информации о новостях. +""" + +import pytest + +from collectors.collector import NewsCollector +from collectors.models import LocationDTO + + +@pytest.mark.asyncio +class TestClientCountry: + """ + Тестирование клиента для получения информации о новостях. + """ + + @pytest.fixture + def collector(self): + return NewsCollector() + + @pytest.fixture + def location(self): + return LocationDTO( + capital="Moscow", + alpha2code="ru", + ) + + async def test_collect_news(self, collector, location): + await collector.collect(frozenset([location])) + + async def test_read_news(self, collector, location): + result = await collector.read(location) + assert len(result) == 3 diff --git a/src/tests/collectors/test_weather.py b/src/tests/collectors/test_weather.py index 55b8796..758bbc7 100644 --- a/src/tests/collectors/test_weather.py +++ b/src/tests/collectors/test_weather.py @@ -1,3 +1,35 @@ """ Тестирование функций сбора информации о погоде. """ + +import pytest + +from collectors.collector import WeatherCollector +from collectors.models import LocationDTO + + +@pytest.mark.asyncio +class TestClientCountry: + """ + Тестирование клиента для получения информации о погоде. + """ + + @pytest.fixture + def collector(self): + return WeatherCollector() + + @pytest.fixture + def location(self): + return LocationDTO( + capital="London", + alpha2code="uk", + ) + + async def test_collect_weather_data(self, collector, location): + await collector.collect(frozenset([location])) + + async def test_read_weather_data(self, collector, location): + result = await collector.read(location) + + assert result is not None + assert result.timezone == 0 diff --git a/src/tests/test_reader.py b/src/tests/test_reader.py index 0ee6c46..fe0e55b 100644 --- a/src/tests/test_reader.py +++ b/src/tests/test_reader.py @@ -1,3 +1,92 @@ """ Тестирование функций поиска (чтения) собранной информации в файлах. """ + +import pytest +from collectors.models import ( + CountryDTO, + LocationDTO, + LocationInfoDTO, + CountryNewsDTO, + WeatherInfoDTO, +) +from reader import Reader + + +@pytest.mark.asyncio +class TestReader: + @pytest.fixture + def reader(self): + return Reader() + + @pytest.fixture + def location(self): + return LocationDTO( + capital="Moscow", + alpha2code="ru", + ) + + async def test_find_by_country(self, reader, location): + result = await reader.find("Russia") + + assert isinstance(result, LocationInfoDTO) + + assert result.location.name == "Russian Federation" + assert result.location.subregion == "Eastern Europe" + assert result.location.area == 17124442 + assert len(result.location.languages) == 1 + assert result.location.population > 145934462 + assert len(result.location.currencies) == 1 + assert len(result.location.timezones) == 9 + assert result.location.alpha2code == "RU" + assert len(result.currency_rates) == 1 + assert len(result.location.alt_spellings) == 5 + + assert result.location.capital == "Moscow" + assert result.location.capital_longitude == 100.0 + assert result.location.capital_latitude == 60.0 + + assert isinstance(result.weather, WeatherInfoDTO) + assert result.weather.timezone == 10800 + + async def test_find_by_capital(self, reader, location): + result = await reader.find("Moscow") + + assert isinstance(result, LocationInfoDTO) + + assert result.location.name == "Russian Federation" + assert result.location.subregion == "Eastern Europe" + assert result.location.area == 17124442 + assert len(result.location.languages) == 1 + assert result.location.population > 145934462 + assert len(result.location.currencies) == 1 + assert len(result.location.timezones) == 9 + assert result.location.alpha2code == "RU" + assert len(result.currency_rates) == 1 + assert len(result.location.alt_spellings) == 5 + + assert result.location.capital == "Moscow" + assert result.location.capital_longitude == 100.0 + assert result.location.capital_latitude == 60.0 + + assert isinstance(result.weather, WeatherInfoDTO) + assert result.weather.timezone == 10800 + + async def test_get_weather(self, reader, location): + result = await reader.get_weather(location) + + assert isinstance(result, WeatherInfoDTO) + assert result.timezone == 10800 + + async def test_get_news(self, reader, location): + result = await reader.get_news(location) + + assert len(result) == 3 + assert isinstance(result[0], CountryNewsDTO) + + async def test_find_country(self, reader): + result = await reader.find_country("Russia") + + assert isinstance(result, CountryDTO) + assert result.name == "Russian Federation" + assert result.capital == "Moscow" diff --git a/src/tests/test_renderer.py b/src/tests/test_renderer.py index 5300cdc..cfb5957 100644 --- a/src/tests/test_renderer.py +++ b/src/tests/test_renderer.py @@ -1,3 +1,92 @@ """ Тестирование функций генерации выходных данных. """ + +import pytest + +from collectors.models import ( + CountryDTO, + CurrencyInfoDTO, + LanguagesInfoDTO, + LocationDTO, + LocationInfoDTO, + CountryNewsDTO, + WeatherInfoDTO, + CountryNewsDTO, +) +from renderer import Renderer + + +@pytest.mark.asyncio +class TestRenderer: + location = LocationInfoDTO( + location=CountryDTO( + capital="Moscow", + capital_latitude=25, + capital_longitude=50, + alpha2code="RU", + alt_spellings=["Россия"], + currencies={CurrencyInfoDTO(code="RUB")}, + flag="x", + languages={LanguagesInfoDTO(name="Russian", native_name="Русский")}, + name="Russia", + population=146000000, + subregion="europe", + timezones=[1, 10800, 3, 4], + area=100000000, + ), + weather=WeatherInfoDTO( + timezone=10800, + temp=27, + pressure=842, + humidity=35, + wind_speed=7, + visibility=120, + dt=1709996768, + description="sunny", + ), + currency_rates={"USD": 95.0}, + news=[ + CountryNewsDTO( + title="title1", + description="desc1", + url="url1", + published_at="2024-03-09T14:14:00Z", + ) + ], + ) + + @pytest.fixture + def renderer(self): + return Renderer(self.location) + + async def test_render_count_row(self, renderer): + result = await renderer.render() + + assert len(result) == 36 + + async def test_render_format_languages(self, renderer): + result = await renderer.render() + + assert "Russian (Русский)" in result[9] + + async def test_render_format_population(self, renderer): + result = await renderer.render() + + assert "146.000.000 чел." in result[11] + + async def test_render_format_currency_rates(self, renderer): + result = await renderer.render() + + assert "95.0" in result[13] + + async def test_render_format_timezone(self, renderer): + result = await renderer.render() + + assert "UTC+3" in result[31] + + async def test_render_format_news(self, renderer): + result = await renderer.render() + + assert len(result) == 36 + assert result[35].startswith("\n\nНовости по данной стране\n") From 19d90a2d7434bff682a154f3ff6e408a82b44ec5 Mon Sep 17 00:00:00 2001 From: Danil Date: Sun, 10 Mar 2024 02:11:57 +0500 Subject: [PATCH 4/4] small fixes, update docs & format code --- README.md | 3 ++ docs/source/index.rst | 3 ++ src/collectors/collector.py | 9 ++-- src/collectors/models.py | 2 +- src/reader.py | 10 ++--- src/renderer.py | 69 +++++++++++++++++++++---------- src/tests/clients/test_weather.py | 2 +- src/tests/test_renderer.py | 2 - 8 files changed, 65 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index b731e52..9966a1d 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,12 @@ Install the appropriate software: To access the API, visit the appropriate resources and obtain an access token: - APILayer – Geography API (https://apilayer.com/marketplace/geo-api) - OpenWeather – Weather Free Plan (https://openweathermap.org/price#weather) + - NewsAPI - Free News API for all countries (https://newsapi.org/) Set received access tokens as environment variable values (in `.env` file): - `API_KEY_APILAYER` – for APILayer access token - `API_KEY_OPENWEATHER` – for OpenWeather access token + - `API_KEY_NEWS` – for NewsAPI access token 2. Build the container using Docker Compose: ```shell @@ -62,6 +64,7 @@ Install the appropriate software: - `CACHE_TTL_COUNTRY` (country data up-to-date time in seconds) - `CACHE_TTL_CURRENCY_RATES` (currency rates data up-to-date time in seconds) - `CACHE_TTL_WEATHER` (weather data up-to-date time in seconds) + - `CACHE_TTL_NEWS` (news up-to-date time in seconds) 5. After collecting all the data, you can query the country information by executing the command: ```shell diff --git a/docs/source/index.rst b/docs/source/index.rst index faf8d10..66d4e87 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -60,11 +60,13 @@ * APILayer — Geography API (https://apilayer.com/marketplace/geo-api) * OpenWeather – Weather Free Plan (https://openweathermap.org/price#weather) + * NewsAPI - Free News API for all countries (https://newsapi.org/) Задайте полученные токены доступа в качестве значений переменных окружения (в файле `.env`): * `API_KEY_APILAYER` – для токена доступа к APILayer * `API_KEY_OPENWEATHER` – для токена доступа к OpenWeather + * `API_KEY_NEWS` – for NewsAPI access token 2. Соберите Docker-контейнер с помощью Docker Compose: .. code-block:: console @@ -92,6 +94,7 @@ * `CACHE_TTL_COUNTRY` (время актуальности данных о странах) * `CACHE_TTL_CURRENCY_RATES` (время актуальности данных о курсах валют) * `CACHE_TTL_WEATHER` (время актуальности данных о погоде) + * `CACHE_TTL_NEWS` (news up-to-date time in seconds) Значение для этих переменных указывается в секундах (они определяются в файле `.env`). diff --git a/src/collectors/collector.py b/src/collectors/collector.py index a6fed55..f08b0c8 100644 --- a/src/collectors/collector.py +++ b/src/collectors/collector.py @@ -29,7 +29,7 @@ CACHE_TTL_COUNTRY, CACHE_TTL_CURRENCY_RATES, CACHE_TTL_WEATHER, - CACHE_TTL_NEWS + CACHE_TTL_NEWS, ) @@ -262,9 +262,7 @@ async def collect( filename = f"{location.alpha2code}".lower() if await self.cache_invalid(filename=filename): # если кэш уже невалиден, то актуализируем его - result = await self.client.get_news( - f"{location.alpha2code}" - ) + result = await self.client.get_news(f"{location.alpha2code}") if result: result_str = json.dumps(result) async with aiofiles.open( @@ -290,7 +288,8 @@ async def read(cls, location: LocationDTO) -> Optional[list[CountryNewsDTO]]: if result: for news_item in result: - result_arr.append(CountryNewsDTO( + result_arr.append( + CountryNewsDTO( title=news_item["title"], description=news_item["description"], url=news_item["url"], diff --git a/src/collectors/models.py b/src/collectors/models.py index 79da8ec..e1a3561 100644 --- a/src/collectors/models.py +++ b/src/collectors/models.py @@ -2,8 +2,8 @@ Описание моделей данных (DTO). """ -from pydantic import Field, BaseModel from typing import Optional +from pydantic import Field, BaseModel class HashableBaseModel(BaseModel): diff --git a/src/reader.py b/src/reader.py index 5dda196..4d7cd45 100644 --- a/src/reader.py +++ b/src/reader.py @@ -3,21 +3,21 @@ """ from difflib import SequenceMatcher -from typing import Optional +from typing import List, Optional from collectors.collector import ( CountryCollector, CurrencyRatesCollector, - WeatherCollector, NewsCollector, + WeatherCollector, ) from collectors.models import ( CountryDTO, + CountryNewsDTO, CurrencyInfoDTO, LocationDTO, LocationInfoDTO, WeatherInfoDTO, - CountryNewsDTO, ) @@ -48,7 +48,7 @@ async def find(self, location: str) -> Optional[LocationInfoDTO]: location=country, weather=weather, currency_rates=currency_rates, - news=news + news=news, ) return None @@ -82,7 +82,7 @@ async def get_weather(location: LocationDTO) -> Optional[WeatherInfoDTO]: return await WeatherCollector.read(location=location) @staticmethod - async def get_news(location: LocationDTO) -> Optional[CountryNewsDTO]: + async def get_news(location: LocationDTO) -> Optional[List[CountryNewsDTO]]: """ Получение данных о новостях. diff --git a/src/renderer.py b/src/renderer.py index d3b8dd5..c23396f 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -2,12 +2,11 @@ Функции для формирования выходной информации. """ +import datetime as dt from decimal import ROUND_HALF_UP, Decimal from collectors.models import LocationInfoDTO -import datetime as dt - class Renderer: """ @@ -29,6 +28,9 @@ async def render(self) -> tuple[str, ...]: :return: Результат форматирования """ + time_with_timezone = ( + self.location_info.weather.dt + self.location_info.weather.timezone + ) country_part = { "Страна": f"{self.location_info.location.name}", @@ -48,37 +50,62 @@ async def render(self) -> tuple[str, ...]: "Скорость ветра в столице": f"{self.location_info.weather.wind_speed} м/с", "Видимость в столице": f"{self.location_info.weather.visibility} м", "Часовой пояс столицы": f"{await self._format_timezone()}", - "Время в столице": - f"{dt.datetime.fromtimestamp(self.location_info.weather.dt + self.location_info.weather.timezone)}", + "Время в столице": f"{dt.datetime.fromtimestamp(time_with_timezone)}", } max_key_length, max_val_length = 0, 0 - for key in country_part: + for key, val in country_part.items(): max_key_length = max(max_key_length, len(key)) - max_val_length = max(max_val_length, len(country_part[key])) + max_val_length = max(max_val_length, len(val)) - for key in capital_part: + for key, val in capital_part.items(): max_key_length = max(max_key_length, len(key)) - max_val_length = max(max_val_length, len(capital_part[key])) + max_val_length = max(max_val_length, len(val)) - country_part_header, capital_part_header = "Информация о стране", "Информация о столице" - result = ["_" * (max_key_length + max_val_length + 9) + "\n", - "| " + country_part_header + " " * (max_key_length + max_val_length + 5 - len(country_part_header)) - + " |\n", "|" + "-" * (max_key_length + max_val_length + 7) + "|\n"] - - for key in country_part: - result.append("| " + key + " " * (max_key_length - len(key)) - + " | " + country_part[key] + " " * (max_val_length - len(country_part[key]) + 2) + " |\n") + country_part_header, capital_part_header = ( + "Информация о стране", + "Информация о столице", + ) + result = [ + "_" * (max_key_length + max_val_length + 9) + "\n", + "| " + + country_part_header + + " " * (max_key_length + max_val_length + 5 - len(country_part_header)) + + " |\n", + "|" + "-" * (max_key_length + max_val_length + 7) + "|\n", + ] + + for key, val in country_part.items(): + result.append( + "| " + + key + + " " * (max_key_length - len(key)) + + " | " + + val + + " " * (max_val_length - len(val) + 2) + + " |\n" + ) result.append("|" + "-" * (max_key_length + max_val_length + 7) + "|\n") - result.append("| " + capital_part_header + " " * (max_key_length + max_val_length + 5 - len(capital_part_header)) - + " |\n") + result.append( + "| " + + capital_part_header + + " " * (max_key_length + max_val_length + 5 - len(capital_part_header)) + + " |\n" + ) result.append("|" + "-" * (max_key_length + max_val_length + 7) + "|\n") - for key in capital_part: - result.append("| " + key + " " * (max_key_length - len(key)) - + " | " + capital_part[key] + " " * (max_val_length - len(capital_part[key]) + 2) + " |\n") + for key, val in capital_part.items(): + result.append( + "| " + + key + + " " * (max_key_length - len(key)) + + " | " + + val + + " " * (max_val_length - len(val) + 2) + + " |\n" + ) result.append("|" + "-" * (max_key_length + max_val_length + 7) + "|\n") result.append(f"\n\n{await self._format_news()}") diff --git a/src/tests/clients/test_weather.py b/src/tests/clients/test_weather.py index cb7beff..437be21 100644 --- a/src/tests/clients/test_weather.py +++ b/src/tests/clients/test_weather.py @@ -28,4 +28,4 @@ async def test_get_countries(self, mocker, client): await client.get_weather("Moscow") client._request.assert_called_with( f"{self.base_url}?units=metric&q=Moscow&appid={API_KEY_OPENWEATHER}" - ) \ No newline at end of file + ) diff --git a/src/tests/test_renderer.py b/src/tests/test_renderer.py index cfb5957..66bf0a4 100644 --- a/src/tests/test_renderer.py +++ b/src/tests/test_renderer.py @@ -8,9 +8,7 @@ CountryDTO, CurrencyInfoDTO, LanguagesInfoDTO, - LocationDTO, LocationInfoDTO, - CountryNewsDTO, WeatherInfoDTO, CountryNewsDTO, )