diff --git a/.env.sample b/.env.sample index 9fd2e4b..799aa87 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/docs/get-started +API_KEY_NEWSAPI= # время актуальности данных о странах (в секундах) CACHE_TTL_COUNTRY=31_536_000 @@ -20,3 +22,7 @@ CACHE_TTL_COUNTRY=31_536_000 CACHE_TTL_CURRENCY_RATES=86_400 # время актуальности данных о погоде (в секундах) CACHE_TTL_WEATHER=10_700 +# время актуальности данных о новостях (в секундах) +CACHE_TTL_NEWS=3_600 +# количество новостей для отображения +NEWS_COUNT= 3 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 7b9b8b0..2df4865 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN apt-get update && apt-get install -y \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* COPY ./requirements.txt ./setup.cfg ./black.toml ./.pylintrc / +COPY ./pytest.ini /pytest.ini RUN --mount=type=cache,target=/root/.cache/pip \ pip install --upgrade pip -r /requirements.txt diff --git a/src/clients/base.py b/src/clients/base.py index cf8f5b0..ed1b6e8 100644 --- a/src/clients/base.py +++ b/src/clients/base.py @@ -3,7 +3,15 @@ """ from abc import ABC, abstractmethod -from typing import Optional +from http import HTTPStatus +from logging import getLogger +from typing import Any + +import aiohttp + +from logger import trace_config + +logger = getLogger(__name__) class BaseClient(ABC): @@ -11,19 +19,29 @@ class BaseClient(ABC): Базовый класс, реализующий интерфейс для клиентов. """ + params: dict[str, Any] = {} + headers: dict[str, Any] = {} + @abstractmethod async def get_base_url(self) -> str: """ Получение базового URL для запросов. - :return: """ - @abstractmethod - async def _request(self, endpoint: str) -> Optional[dict]: + async def _request(self, endpoint: str) -> dict | None: """ Формирование и выполнение запроса. - :param endpoint: :return: """ + async with aiohttp.ClientSession(trace_configs=[trace_config]) as session: + async with session.get( + endpoint, params=self.params, headers=self.headers + ) as response: + if response.status == HTTPStatus.OK: + return await response.json() + logger.error( + "Error: %s %s %s", response.url, response.status, response.reason + ) + return None diff --git a/src/clients/country.py b/src/clients/country.py index 65a30b0..279cd01 100644 --- a/src/clients/country.py +++ b/src/clients/country.py @@ -1,13 +1,7 @@ """ Функции для взаимодействия с внешним сервисом-провайдером данных о странах. """ -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_APILAYER @@ -16,27 +10,16 @@ class CountryClient(BaseClient): Реализация функций для взаимодействия с внешним сервисом-провайдером данных о странах. """ + headers = {"apikey": API_KEY_APILAYER} + async def get_base_url(self) -> str: return "https://api.apilayer.com/geo/country" - async def _request(self, endpoint: str) -> Optional[dict]: - - # формирование заголовков запроса - headers = {"apikey": API_KEY_APILAYER} - - async with aiohttp.ClientSession(trace_configs=[trace_config]) as session: - async with session.get(endpoint, headers=headers) as response: - if response.status == HTTPStatus.OK: - return await response.json() - - return None - - async def get_countries(self, bloc: str = "eu") -> Optional[dict]: + async def get_countries(self, bloc: str = "eu") -> dict | None: """ Получение данных о странах. :param bloc: Регион :return: """ - return await self._request(f"{await self.get_base_url()}/regional_bloc/{bloc}") diff --git a/src/clients/currency.py b/src/clients/currency.py index 3c89d8f..469e992 100644 --- a/src/clients/currency.py +++ b/src/clients/currency.py @@ -1,13 +1,7 @@ """ Функции для взаимодействия с внешним сервисом-провайдером данных о курсах валют. """ -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_APILAYER @@ -16,27 +10,17 @@ class CurrencyClient(BaseClient): Реализация функций для взаимодействия с внешним сервисом-провайдером данных о курсах валют. """ + headers = {"apikey": API_KEY_APILAYER} + async def get_base_url(self) -> str: return "https://api.apilayer.com/fixer/latest" - async def _request(self, endpoint: str) -> Optional[dict]: - - # формирование заголовков запроса - headers = {"apikey": API_KEY_APILAYER} - - async with aiohttp.ClientSession(trace_configs=[trace_config]) as session: - async with session.get(endpoint, headers=headers) as response: - if response.status == HTTPStatus.OK: - return await response.json() - - return None - - async def get_rates(self, base: str = "rub") -> Optional[dict]: + async def get_rates(self, base: str = "rub") -> dict | None: """ - Получение данных о курсах валют. + Получение данных о курсах валют. :param base: Базовая валюта :return: """ - - return await self._request(f"{await self.get_base_url()}?base={base}") + self.params["base"] = base + return await self._request(await self.get_base_url()) diff --git a/src/clients/news.py b/src/clients/news.py new file mode 100644 index 0000000..a4dd24a --- /dev/null +++ b/src/clients/news.py @@ -0,0 +1,26 @@ +""" +Функции для взаимодействия с внешним сервисом-провайдером данных о новостях. +""" +from clients.base import BaseClient +from settings import API_KEY_NEWSAPI, NEWS_COUNT + + +class NewsClient(BaseClient): + """ + Реализация функций для взаимодействия с внешним сервисом-провайдером данных о новостях. + """ + + params = {"apiKey": API_KEY_NEWSAPI, "pageSize": NEWS_COUNT} + + async def get_base_url(self) -> str: + return "https://newsapi.org/v2/everything" + + async def get_news(self, location: str) -> dict | None: + """ + Получение новостей по стране + + :param location: Город и страна + :return: + """ + self.params["q"] = location + return await self._request(await self.get_base_url()) diff --git a/src/clients/weather.py b/src/clients/weather.py index b39eebf..7550151 100644 --- a/src/clients/weather.py +++ b/src/clients/weather.py @@ -1,13 +1,7 @@ """ Функции для взаимодействия с внешним сервисом-провайдером данных о погоде. """ -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_OPENWEATHER @@ -16,26 +10,17 @@ class WeatherClient(BaseClient): Реализация функций для взаимодействия с внешним сервисом-провайдером данных о погоде. """ + params = {"appid": API_KEY_OPENWEATHER, "units": "metric"} + async def get_base_url(self) -> str: return "https://api.openweathermap.org/data/2.5/weather" - 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() - - return None - - async def get_weather(self, location: str) -> Optional[dict]: + async def get_weather(self, location: str) -> dict | None: """ Получение данных о погоде. :param location: Город и страна :return: """ - - return await self._request( - f"{await self.get_base_url()}?units=metric&q={location}&appid={API_KEY_OPENWEATHER}" - ) + self.params["q"] = location + return await self._request(await self.get_base_url()) diff --git a/src/collectors/collector.py b/src/collectors/collector.py index ebadf7e..27eeb0f 100644 --- a/src/collectors/collector.py +++ b/src/collectors/collector.py @@ -6,27 +6,30 @@ import asyncio import json -from typing import Any, Optional, FrozenSet +from typing import Any import aiofiles import aiofiles.os from clients.country import CountryClient from clients.currency import CurrencyClient +from clients.news import NewsClient from clients.weather import WeatherClient from collectors.base import BaseCollector from collectors.models import ( - LocationDTO, CountryDTO, - CurrencyRatesDTO, CurrencyInfoDTO, + CurrencyRatesDTO, + LocationDTO, + NewsInfoDTO, WeatherInfoDTO, ) from settings import ( - MEDIA_PATH, CACHE_TTL_COUNTRY, CACHE_TTL_CURRENCY_RATES, + CACHE_TTL_NEWS, CACHE_TTL_WEATHER, + MEDIA_PATH, ) @@ -46,7 +49,7 @@ async def get_file_path(**kwargs: Any) -> str: async def get_cache_ttl() -> int: return CACHE_TTL_COUNTRY - async def collect(self, **kwargs: Any) -> Optional[FrozenSet[LocationDTO]]: + async def collect(self, **kwargs: Any) -> frozenset[LocationDTO] | None: if await self.cache_invalid(): # если кэш уже невалиден, то актуализируем его result = await self.client.get_countries() @@ -60,55 +63,54 @@ async def collect(self, **kwargs: Any) -> Optional[FrozenSet[LocationDTO]]: content = await file.read() result = json.loads(content) - if result: - locations = frozenset( - LocationDTO( - capital=item["capital"], - alpha2code=item["alpha2code"], - ) - for item in result + if not result: + return None + locations = frozenset( + LocationDTO( + capital=item["capital"], + alpha2code=item["alpha2code"], ) + for item in result + ) - return locations - - return None + return locations @classmethod - async def read(cls) -> Optional[list[CountryDTO]]: + async def read(cls) -> list[CountryDTO] | None: """ Чтение данных из кэша. - :return: """ async with aiofiles.open(await cls.get_file_path(), mode="r") as file: content = await file.read() - if content: - items = json.loads(content) - result_list = [] - for item in items: - result_list.append( - CountryDTO( - capital=item["capital"], - alpha2code=item["alpha2code"], - alt_spellings=item["alt_spellings"], - currencies={ - CurrencyInfoDTO(code=currency["code"]) - for currency in item["currencies"] - }, - flag=item["flag"], - languages=item["languages"], - name=item["name"], - population=item["population"], - subregion=item["subregion"], - timezones=item["timezones"], - ) - ) - - return result_list - - return None + if not content: + return None + items = json.loads(content) + result_list = [ + CountryDTO( + capital=item["capital"], + alpha2code=item["alpha2code"], + alt_spellings=item["alt_spellings"], + currencies={ + CurrencyInfoDTO(code=currency["code"]) + for currency in item["currencies"] + }, + flag=item["flag"], + languages=item["languages"], + name=item["name"], + population=item["population"], + subregion=item["subregion"], + timezones=item["timezones"], + area=item["area"], + latitude=item["latitude"], + longitude=item["longitude"], + ) + for item in items + ] + + return result_list class CurrencyRatesCollector(BaseCollector): @@ -131,32 +133,31 @@ async def collect(self, **kwargs: Any) -> None: if await self.cache_invalid(): # если кэш уже невалиден, то актуализируем его result = await self.client.get_rates() - if result: - result_str = json.dumps(result) - async with aiofiles.open(await self.get_file_path(), mode="w") as file: - await file.write(result_str) + if not result: + return + result_str = json.dumps(result) + async with aiofiles.open(await self.get_file_path(), mode="w") as file: + await file.write(result_str) @classmethod - async def read(cls) -> Optional[CurrencyRatesDTO]: + async def read(cls) -> CurrencyRatesDTO | None: """ Чтение данных из кэша. - :return: """ async with aiofiles.open(await cls.get_file_path(), mode="r") as file: content = await file.read() - if content: - result = json.loads(content) - - return CurrencyRatesDTO( - base=result["base"], - date=result["date"], - rates=result["rates"], - ) + if not content: + return None + result = json.loads(content) - return None + return CurrencyRatesDTO( + base=result["base"], + date=result["date"], + rates=result["rates"], + ) class WeatherCollector(BaseCollector): @@ -176,7 +177,7 @@ async def get_cache_ttl() -> int: return CACHE_TTL_WEATHER async def collect( - self, locations: FrozenSet[LocationDTO] = frozenset(), **kwargs: Any + self, locations: frozenset[LocationDTO] = frozenset(), **kwargs: Any ) -> None: target_dir_path = f"{MEDIA_PATH}/weather" @@ -186,24 +187,99 @@ async def collect( for location in locations: filename = f"{location.capital}_{location.alpha2code}".lower() - if await self.cache_invalid(filename=filename): - # если кэш уже невалиден, то актуализируем его - result = await self.client.get_weather( - f"{location.capital},{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) + if not await self.cache_invalid(filename=filename): + continue + + # если кэш уже невалиден, то актуализируем его + result = await self.client.get_weather( + f"{location.capital},{location.alpha2code}" + ) + if not result: + continue + 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[WeatherInfoDTO]: + async def read(cls, location: LocationDTO) -> WeatherInfoDTO | None: """ Чтение данных из кэша. + :param location: Страна и/или город для которых нужно получить прогноз погоды. + :return: + """ + + filename = f"{location.capital}_{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) + if not result: + return None + return WeatherInfoDTO( + temp=result["main"]["temp"], + pressure=result["main"]["pressure"], + humidity=result["main"]["humidity"], + wind_speed=result["wind"]["speed"], + description=result["weather"][0]["description"], + visibility=result["visibility"], + dt=result["dt"], + timezone=result["timezone"] // 3600, + ) + + +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: + """ + Сбор информации о новостях. + :param locations: Страна и/или города, для которых нужно собрать новости. + :return: + """ + + 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.capital}_{location.alpha2code}".lower() + if not await self.cache_invalid(filename=filename): + continue + + # если кэш уже невалиден, то актуализируем его + result = await self.client.get_news(location.capital) + if not result: + continue + + result_str = json.dumps(result) + async with aiofiles.open( + await self.get_file_path(filename), mode="w" + ) as file: + await file.write(result_str) - :param location: + @classmethod + async def read(cls, location: LocationDTO) -> list[NewsInfoDTO] | None: + """ + Чтение данных из кэша. + :param location: Страна и/или город для которых нужно получить новости. :return: """ @@ -212,16 +288,19 @@ async def read(cls, location: LocationDTO) -> Optional[WeatherInfoDTO]: content = await file.read() result = json.loads(content) - if result: - return WeatherInfoDTO( - temp=result["main"]["temp"], - pressure=result["main"]["pressure"], - humidity=result["main"]["humidity"], - wind_speed=result["wind"]["speed"], - description=result["weather"][0]["description"], + if not result: + return None + return [ + NewsInfoDTO( + source=article["source"]["name"], + title=article["title"], + description=article["description"], + url=article["url"], + published_at=article["publishedAt"], + content=article["content"], ) - - return None + for article in result["articles"] + ] class Collectors: @@ -232,12 +311,19 @@ async def gather() -> tuple: CountryCollector().collect(), ) + @staticmethod + async def gather_items(locations: frozenset[LocationDTO]) -> tuple: + return await asyncio.gather( + WeatherCollector().collect(locations), + NewsCollector().collect(locations), + ) + @staticmethod def collect() -> None: loop = asyncio.get_event_loop() try: results = loop.run_until_complete(Collectors.gather()) - loop.run_until_complete(WeatherCollector().collect(results[1])) + loop.run_until_complete(Collectors.gather_items(results[1])) loop.run_until_complete(loop.shutdown_asyncgens()) finally: diff --git a/src/collectors/models.py b/src/collectors/models.py index 7e36198..33d461d 100644 --- a/src/collectors/models.py +++ b/src/collectors/models.py @@ -1,8 +1,9 @@ """ Описание моделей данных (DTO). """ +from datetime import datetime -from pydantic import Field, BaseModel +from pydantic import BaseModel, Field class HashableBaseModel(BaseModel): @@ -17,9 +18,7 @@ def __hash__(self) -> int: class LocationDTO(HashableBaseModel): """ Модель локации для получения сведений о погоде. - .. code-block:: - LocationDTO( capital="Mariehamn", alpha2code="AX", @@ -33,9 +32,7 @@ class LocationDTO(HashableBaseModel): class CurrencyInfoDTO(HashableBaseModel): """ Модель данных о валюте. - .. code-block:: - CurrencyInfoDTO( code="EUR", ) @@ -47,9 +44,7 @@ class CurrencyInfoDTO(HashableBaseModel): class LanguagesInfoDTO(HashableBaseModel): """ Модель данных о языке. - .. code-block:: - LanguagesInfoDTO( name="Swedish", native_name="svenska" @@ -63,36 +58,25 @@ class LanguagesInfoDTO(HashableBaseModel): class CountryDTO(BaseModel): """ Модель данных о стране. - .. code-block:: - CountryDTO( capital="Mariehamn", alpha2code="AX", - alt_spellings=[ - "AX", - "Aaland", - "Aland", - "Ahvenanmaa" - ], + alt_spellings=["AX", "Aaland", "Aland", "Ahvenanmaa"], currencies={ - CurrencyInfoDTO( - code="EUR", - ) + CurrencyInfoDTO(code="EUR"), }, - flag="http://assets.promptapi.com/flags/AX.svg", + flag="https://restcountries.eu/data/ala.svg", languages={ - LanguagesInfoDTO( - name="Swedish", - native_name="svenska" - ) + LanguagesInfoDTO(name="Swedish", native_name="svenska"), }, - name="\u00c5land Islands", + name="Åland Islands", population=28875, subregion="Northern Europe", - timezones=[ - "UTC+02:00", - ], + timezones=["UTC+02:00"], + area=1580.0, + latitude=60.116667, + longitude=19.9, ) """ @@ -106,14 +90,15 @@ class CountryDTO(BaseModel): population: int subregion: str timezones: list[str] + area: float | None + latitude: float | None + longitude: float | None class CurrencyRatesDTO(BaseModel): """ Модель данных о курсах валют. - .. code-block:: - CurrencyRatesDTO( base="RUB", date="2022-09-14", @@ -131,15 +116,16 @@ class CurrencyRatesDTO(BaseModel): class WeatherInfoDTO(BaseModel): """ Модель данных о погоде. - .. code-block:: - WeatherInfoDTO( - temp=13.92, - pressure=1023, - humidity=54, - wind_speed=4.63, - description="scattered clouds", + temp=5.0, + pressure=1013, + humidity=93, + wind_speed=1.03, + description="light rain", + visibility=10000, + dt=datetime.datetime(2021, 9, 14, 20, 0), + timezone=0, ) """ @@ -148,14 +134,37 @@ class WeatherInfoDTO(BaseModel): humidity: int wind_speed: float description: str + visibility: int + dt: datetime + timezone: int + + +class NewsInfoDTO(BaseModel): + """ + Модель данных о новости. + .. code-block:: + NewsDTO( + source="CNN", + title="The latest news about the coronavirus pandemic", + description="The latest news about the coronavirus pandemic", + url="https://www.cnn.com/world/live-news/coronavirus-pandemic-09-14-21-intl/index.html", + published_at="2021-09-14T20:00:00Z", + content="The latest news about the coronavirus pandemic", + ) + """ + + source: str + title: str + description: str + url: str + published_at: datetime + content: str class LocationInfoDTO(BaseModel): """ Модель данных для представления общей информации о месте. - .. code-block:: - LocationInfoDTO( location=CountryDTO( capital="Mariehamn", @@ -195,9 +204,20 @@ class LocationInfoDTO(BaseModel): currency_rates={ "EUR": 0.016503, }, + news=[ + NewsDTO( + source="CNN", + title="The latest news about the coronavirus pandemic", + description="The latest news about the coronavirus pandemic", + url="https://www.cnn.com/world/live-news/coronavirus-pandemic-09-14-21-intl/index.html", + published_at="2021-09-14T20:00:00Z", + content="The latest news about the coronavirus pandemic", + ) + ] ) """ location: CountryDTO weather: WeatherInfoDTO currency_rates: dict[str, float] + news: list[NewsInfoDTO] | None diff --git a/src/reader.py b/src/reader.py index dd1a74d..fbaece7 100644 --- a/src/reader.py +++ b/src/reader.py @@ -3,11 +3,11 @@ """ from difflib import SequenceMatcher -from typing import Optional from collectors.collector import ( CountryCollector, CurrencyRatesCollector, + NewsCollector, WeatherCollector, ) from collectors.models import ( @@ -15,6 +15,7 @@ CurrencyInfoDTO, LocationDTO, LocationInfoDTO, + NewsInfoDTO, WeatherInfoDTO, ) @@ -24,34 +25,34 @@ class Reader: Чтение сохраненных данных. """ - async def find(self, location: str) -> Optional[LocationInfoDTO]: + async def find(self, location: str) -> LocationInfoDTO | None: """ Поиск данных о стране по строке. - :param location: Строка для поиска :return: """ country = await self.find_country(location) - if country: - weather = await self.get_weather( - LocationDTO(capital=country.capital, alpha2code=country.alpha2code) - ) - currency_rates = await self.get_currency_rates(country.currencies) - - return LocationInfoDTO( - location=country, - weather=weather, - currency_rates=currency_rates, - ) - - return None + if not country: + return None + location_dto = LocationDTO( + capital=country.capital, alpha2code=country.alpha2code + ) + weather = await self.get_weather(location_dto) + currency_rates = await self.get_currency_rates(country.currencies) + news = await self.get_news(location_dto) + + return LocationInfoDTO( + location=country, + weather=weather, + currency_rates=currency_rates, + news=news, + ) @staticmethod async def get_currency_rates(currencies: set[CurrencyInfoDTO]) -> dict[str, float]: """ Чтение и формирование информации о курсах валют. - :param currencies: Множество с данными о курсах валют :return: """ @@ -66,19 +67,25 @@ async def get_currency_rates(currencies: set[CurrencyInfoDTO]) -> dict[str, floa return result @staticmethod - async def get_weather(location: LocationDTO) -> Optional[WeatherInfoDTO]: + async def get_weather(location: LocationDTO) -> WeatherInfoDTO | None: """ Получение данных о погоде. - :param location: Объект локации для получения данных :return: """ return await WeatherCollector.read(location=location) - async def find_country(self, search: str) -> Optional[CountryDTO]: + async def get_news(self, location: LocationDTO) -> list[NewsInfoDTO] | None: """ - Поиск страны. + Получение новостей по стране. + :param location: + :return: + """ + return await NewsCollector.read(location=location) + async def find_country(self, search: str) -> CountryDTO | None: + """ + Поиск страны. :param search: Строка для поиска :return: """ @@ -94,7 +101,6 @@ async def find_country(self, search: str) -> Optional[CountryDTO]: async def _match(search: str, country: CountryDTO) -> bool: """ Получение факта сходства между переданными строками для поиска страны. - :param search: Строка для сравнения :param CountryDTO country: Данные о стране :return: diff --git a/src/renderer.py b/src/renderer.py index 8b90dcc..d326902 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -4,7 +4,7 @@ from decimal import ROUND_HALF_UP, Decimal -from collectors.models import LocationInfoDTO +from collectors.models import LocationInfoDTO, NewsInfoDTO class Renderer: @@ -15,7 +15,6 @@ class Renderer: def __init__(self, location_info: LocationInfoDTO) -> None: """ Конструктор. - :param location_info: Данные о географическом месте. """ @@ -24,24 +23,50 @@ def __init__(self, location_info: LocationInfoDTO) -> None: 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", + values = { + "Страна": self.location_info.location.name, + "Регион": self.location_info.location.subregion, + "Языки": await self._format_languages(), + "Население страны": await self._format_population(), + "Курсы валют": await self._format_currency_rates(), + "Площадь": self.location_info.location.area, + "Столица": self.location_info.location.capital, + "Широта": self.location_info.location.latitude, + "Долгота": self.location_info.location.longitude, + "Время": self.location_info.weather.dt.strftime("%d.%m.%Y %H:%M"), + "Часовой пояс": self.location_info.weather.timezone, + "Погода": self.location_info.weather.temp, + "Описание погоды": self.location_info.weather.description, + "Видимость": self.location_info.weather.visibility, + "Влажность": self.location_info.weather.humidity, + "Скорость ветра": self.location_info.weather.wind_speed, + "Давление": self.location_info.weather.pressure, + } + + first_column_width = max(len(key) for key in values) + 1 + second_column_width = max(len(str(value)) for value in values.values()) + 1 + formatted_values = [("-" * (first_column_width + second_column_width + 3))] + formatted_values.extend( + [ + f"|{key:<{first_column_width}}|{value:>{second_column_width}}|" + for key, value in values.items() + ] ) + formatted_values.append("-" * (first_column_width + second_column_width + 3)) + formatted_values.extend( + await self._format_news( + self.location_info.news, first_column_width, second_column_width + ) + ) + + return tuple(formatted_values) async def _format_languages(self) -> str: """ Форматирование информации о языках. - :return: """ @@ -53,17 +78,15 @@ async def _format_languages(self) -> str: async def _format_population(self) -> str: """ Форматирование информации о населении. - :return: """ # pylint: disable=C0209 - return "{:,}".format(self.location_info.location.population).replace(",", ".") + return f"{self.location_info.location.population:,}".replace(",", ".") async def _format_currency_rates(self) -> str: """ Форматирование информации о курсах валют. - :return: """ @@ -71,3 +94,77 @@ 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_news_line( + self, + first_col_name: str, + content: str, + first_column_width: int, + second_column_width: int, + ) -> list[str]: + """ + Форматирование информации о новостях. + :param first_col_name: Название первой колонки. + :param content: Содержимое второй колонки + :param first_column_width: Ширина первой колонки. + :param second_column_width: Ширина второй колонки. + :return: + """ + content = content.replace("\n", " ").replace("\r", " ") + values = [ + f"|{first_col_name:<{first_column_width}}|{content[:second_column_width]:>{second_column_width}}|" + ] + + values.extend( + [ + f"|{'':<{first_column_width}}|{content[line:line + second_column_width]:<{second_column_width}}|" + for line in range( + second_column_width, len(content), second_column_width + ) + ] + ) + return values + + async def _format_news( + self, + news: list[NewsInfoDTO] | None, + first_column_width: int, + second_column_width: int, + ) -> list[str]: + """ + Форматирование информации о новостях. + :param news: Список новостей. + :param first_column_width: Ширина первой колонки. + :param second_column_width: Ширина второй колонки. + :return: + """ + if news is None: + return [] + values = [] + first_column_names = [ + "Источник", + "Новость", + "Ссылка", + "Дата", + "Описание", + "Текст", + ] + for item in news: + for first_col_name, content in zip( + first_column_names, + [ + item.source, + item.title, + item.url, + item.published_at.strftime("%d.%m.%Y %H:%M"), + item.description, + item.content, + ], + ): + values.extend( + await self._format_news_line( + first_col_name, content, first_column_width, second_column_width + ) + ) + values.append("-" * (first_column_width + second_column_width + 3)) + return values diff --git a/src/settings.py b/src/settings.py index 4559f84..2380239 100644 --- a/src/settings.py +++ b/src/settings.py @@ -3,7 +3,6 @@ """ import os -from typing import Optional # путь к директории для сохранения файлов MEDIA_PATH: str = os.getenv("MEDIA_PATH", "../media") @@ -18,8 +17,9 @@ LOGGING_LEVEL: str = os.getenv("LOGGING_LEVEL", "INFO") # ключи для доступа к API -API_KEY_APILAYER: Optional[str] = os.getenv("API_KEY_APILAYER") -API_KEY_OPENWEATHER: Optional[str] = os.getenv("API_KEY_OPENWEATHER") +API_KEY_APILAYER: str | None = os.getenv("API_KEY_APILAYER") +API_KEY_OPENWEATHER: str | None = os.getenv("API_KEY_OPENWEATHER") +API_KEY_NEWSAPI: str | None = os.getenv("API_KEY_NEWSAPI") # время актуальности данных о странах (в секундах), по умолчанию – один год CACHE_TTL_COUNTRY: int = int(os.getenv("CACHE_TTL_COUNTRY", "31_536_000")) @@ -27,3 +27,7 @@ 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")) +# количество новостой для отображения +NEWS_COUNT: int = int(os.getenv("NEWS_COUNT", "3")) diff --git a/src/tests/clients/test_currency.py b/src/tests/clients/test_currency.py index 104d612..7ac258e 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 + + +class TestClientCountry: + """ + Тестирование клиента для получения информации о валютах. + """ + + 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_countries(self, mocker, client): + mocker.patch("clients.base.BaseClient._request") + await client.get_rates() + client._request.assert_called_once_with(self.base_url) + assert client.params == {"base": "rub"} + + await client.get_rates("test") + client._request.assert_called_with(self.base_url) + assert client.params == {"base": "test"} diff --git a/src/tests/clients/test_news.py b/src/tests/clients/test_news.py new file mode 100644 index 0000000..55e131c --- /dev/null +++ b/src/tests/clients/test_news.py @@ -0,0 +1,27 @@ +""" +Тестирование клиента для получения информации о новостях. +""" +import pytest + +from clients.news import NewsClient + + +class TestClientCountry: + """ + Тестирование клиента для получения информации о странах. + """ + + base_url = "https://newsapi.org/v2/everything" + + @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_countries(self, mocker, client): + mocker.patch("clients.base.BaseClient._request") + await client.get_news("test") + client._request.assert_called_once_with(self.base_url) + assert client.params["q"] == "test" diff --git a/src/tests/clients/test_weather.py b/src/tests/clients/test_weather.py index 3c14430..6c76659 100644 --- a/src/tests/clients/test_weather.py +++ b/src/tests/clients/test_weather.py @@ -1,3 +1,27 @@ """ Тестирование функций клиента для получения информации о погоде. """ +import pytest + +from clients.weather import WeatherClient + + +class TestClientCountry: + """ + Тестирование клиента для получения информации о странах. + """ + + 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.base.BaseClient._request") + await client.get_weather("test") + client._request.assert_called_with(self.base_url) + assert client.params["q"] == "test" diff --git a/src/tests/collectors/confest.py b/src/tests/collectors/confest.py new file mode 100644 index 0000000..5da28be --- /dev/null +++ b/src/tests/collectors/confest.py @@ -0,0 +1,14 @@ +import pytest + + +async def mock_cache_invalid(x, **kwargs): + return False + + +@pytest.fixture(autouse=True) +async def mock_requests(mocker): + mocker.patch("clients.base.BaseClient._request") + mocker.patch("collectors.base.BaseCollector.cache_invalid", mock_cache_invalid) + mocker.patch( + "collectors.collector.WeatherCollector.cache_invalid", mock_cache_invalid + ) diff --git a/src/tests/collectors/test_country.py b/src/tests/collectors/test_country.py index 325936d..0654af6 100644 --- a/src/tests/collectors/test_country.py +++ b/src/tests/collectors/test_country.py @@ -1,3 +1,30 @@ """ Тестирование функций сбора информации о странах. """ +import pytest + +from collectors.collector import CountryCollector + + +class TestCountryCollector: + """ + Тестирование функций сбора информации о странах. + """ + + @pytest.fixture(autouse=True) + def setup(self): + self.collector = CountryCollector() + + async def test_collect_country_success(self): + """ + Тестирование получения информации о стране. + """ + countries = await self.collector.collect() + assert len(countries) == 49 + + async def test_read_country_success(self): + """ + Тестирование чтения информации о стране. + """ + countries = await self.collector.read() + assert len(countries) == 49 diff --git a/src/tests/collectors/test_currency.py b/src/tests/collectors/test_currency.py index f8982e0..3747cc5 100644 --- a/src/tests/collectors/test_currency.py +++ b/src/tests/collectors/test_currency.py @@ -1,3 +1,32 @@ """ Тестирование функций сбора информации о курсах валют. """ + +import pytest + +from collectors.collector import CurrencyRatesCollector + + +class TestCurrencyCollector: + """ + Тестирование функций сбора информации о курсах валют. + """ + + @pytest.fixture(autouse=True) + def setup(self): + self.collector = CurrencyRatesCollector() + + async def test_collect_currency_success(self): + """ + Тестирование получения информации о курсе валют. + """ + await self.collector.collect() + + async def test_read_currency_success(self): + """ + Тестирование чтения информации о курсе валют. + """ + currencies = await self.collector.read() + assert currencies is not None + assert currencies.base == "RUB" + assert len(currencies.rates) == 170 diff --git a/src/tests/collectors/test_news.py b/src/tests/collectors/test_news.py new file mode 100644 index 0000000..4eab378 --- /dev/null +++ b/src/tests/collectors/test_news.py @@ -0,0 +1,37 @@ +""" +Тестирование клиента для получения информации о новостях +""" + +import pytest + +from collectors.collector import NewsCollector +from collectors.models import LocationDTO + + +@pytest.mark.asyncio +class TestClientNews: + """ + Тестирование клиента для получения информации о новостях. + """ + + location = LocationDTO( + capital="Moscow", + alpha2code="RU", + ) + + @pytest.fixture(autouse=True) + def setup(self): + self.collector = NewsCollector() + + async def test_collect_news_success(self): + """ + Тестирование получения информации о погоде. + """ + await self.collector.collect(frozenset([self.location])) + + async def test_read_news_success(self): + """ + Тестирование чтения информации о погоде. + """ + news = await self.collector.read(self.location) + assert len(news) == 3 diff --git a/src/tests/collectors/test_weather.py b/src/tests/collectors/test_weather.py index 55b8796..5bab737 100644 --- a/src/tests/collectors/test_weather.py +++ b/src/tests/collectors/test_weather.py @@ -1,3 +1,37 @@ """ Тестирование функций сбора информации о погоде. """ + +import pytest + +from collectors.collector import WeatherCollector +from collectors.models import LocationDTO + + +class TestWeatherCollector: + """ + Тестирование функций сбора информации о погоде. + """ + + location = LocationDTO( + capital="Moscow", + alpha2code="RU", + ) + + @pytest.fixture(autouse=True) + def setup(self): + self.collector = WeatherCollector() + + async def test_collect_weather_success(self): + """ + Тестирование получения информации о погоде. + """ + await self.collector.collect(frozenset([self.location])) + + async def test_read_weather_success(self): + """ + Тестирование чтения информации о погоде. + """ + weather = await self.collector.read(self.location) + assert weather is not None + assert weather.timezone == 3 diff --git a/src/tests/test_reader.py b/src/tests/test_reader.py index 0ee6c46..a147e93 100644 --- a/src/tests/test_reader.py +++ b/src/tests/test_reader.py @@ -1,3 +1,68 @@ """ Тестирование функций поиска (чтения) собранной информации в файлах. """ +import pytest + +from collectors.models import ( + CountryDTO, + LocationDTO, + LocationInfoDTO, + NewsInfoDTO, + WeatherInfoDTO, +) +from reader import Reader + + +class TestReader: + location = LocationDTO( + alpha2code="RU", + capital="Moscow", + ) + + @pytest.fixture + def reader(self): + return Reader() + + async def test_find(self, reader): + location = await reader.find("Russia") + assert type(location) == LocationInfoDTO + assert location.location.name == "Russian Federation" + assert location.location.capital == "Moscow" + assert location.location.alpha2code == "RU" + assert len(location.location.currencies) == 1 + assert len(location.location.languages) == 1 + assert len(location.location.timezones) == 9 + assert location.location.population > 145934462 + assert location.location.area == 17124442 + assert location.location.longitude == 100.0 + assert location.location.latitude == 60.0 + assert len(location.location.alt_spellings) == 5 + assert location.location.subregion == "Eastern Europe" + assert type(location.weather) == WeatherInfoDTO + assert location.weather.timezone == 3 + assert len(location.news) == 3 + assert len(location.currency_rates) == 1 + + async def test_find_not_found(self, reader): + location = await reader.find("test") + assert location is None + + async def test_get_weather(self, mocker, reader): + weather = await reader.get_weather(self.location) + assert type(weather) == WeatherInfoDTO + assert weather.timezone == 3 + + async def test_get_news(self, mocker, reader): + news = await reader.get_news(self.location) + assert len(news) == 3 + assert type(news[0]) == NewsInfoDTO + + async def test_find_country(self, mocker, reader): + country = await reader.find_country("Russia") + assert type(country) == CountryDTO + assert country.name == "Russian Federation" + assert country.capital == "Moscow" + + async def test_find_country_none(self, mocker, reader): + country = await reader.find_country("test") + assert country is None diff --git a/src/tests/test_renderer.py b/src/tests/test_renderer.py index 5300cdc..9af58bd 100644 --- a/src/tests/test_renderer.py +++ b/src/tests/test_renderer.py @@ -1,3 +1,169 @@ """ Тестирование функций генерации выходных данных. """ +from collectors.models import ( + CountryDTO, + CurrencyInfoDTO, + LanguagesInfoDTO, + LocationInfoDTO, + NewsInfoDTO, + WeatherInfoDTO, +) +from renderer import Renderer + + +class TestRenderer: + location = LocationInfoDTO( + location=CountryDTO( + alpha2code="RU", + capital="Moscow", + currencies={CurrencyInfoDTO(code="USD")}, + languages={LanguagesInfoDTO(name="Russian", native_name="Русский")}, + flag="test", + subregion="test", + name="Russia", + population=3, + area=3, + longitude=3, + latitude=3, + alt_spellings=["test"], + timezones=[3], + ), + weather=WeatherInfoDTO( + timezone=3, + temp=3, + pressure=3, + humidity=3, + wind_speed=3, + visibility=3, + dt=1, + description="test", + ), + currency_rates={"USD": 1.0}, + news=[ + NewsInfoDTO( + source="test", + published_at=0, + title="test", + content="test", + description="test", + url="test", + url_to_image="test", + ) + ], + ) + + async def test_render(self): + renderer = Renderer(self.location) + results = await renderer.render() + assert len(results) == 26 + first_column = [ + "", + "Страна", + "Регион", + "Языки", + "Населениестраны", + "Курсывалют", + "Площадь", + "Столица", + "Широта", + "Долгота", + "Время", + "Часовойпояс", + "Погода", + "Описаниепогоды", + "Видимость", + "Влажность", + "Скоростьветра", + "Давление", + "", + "Источник", + "Новость", + "Ссылка", + "Дата", + "Описание", + "Текст", + ] + second_column = [ + None, + "Russia", + "test", + "Russian(Русский)", + "3", + "USD=1.00руб.", + "3.0", + "Moscow", + "3.0", + "3.0", + "01.01.197000:00", + "3", + "3.0", + "test", + "3", + ] + for result, first_col, second_col in zip(results, first_column, second_column): + if result[0] == "-": + continue + result = result.replace(" ", "").split("|") + assert result[1] == first_col, f"{result[1]} != {first_col}" + assert result[2] == second_col, f"{result[2]} != {second_col}" + + async def test_format_languages(self): + renderer = Renderer(self.location) + result = await renderer._format_languages() + assert result == "Russian (Русский)" + + async def test_format_currencies_rates(self): + renderer = Renderer(self.location) + result = await renderer._format_currency_rates() + assert result == "USD = 1.00 руб." + + async def test_format_news(self): + renderer = Renderer(self.location) + results = await renderer._format_news(self.location.news, 10, 20) + assert len(results) == 7 + first_column = [ + "Источник", + "Новость", + "Ссылка", + "Дата", + "Описание", + "Текст", + "", + ] + second_column = [ + "test", + "test", + "test", + "01.01.197000:00", + "test", + "test", + None, + ] + for result, first_col, second_col in zip(results, first_column, second_column): + if result[0] == "-": + continue + result = result.replace(" ", "").split("|") + assert result[1] == first_col, f"{result[1]} != {first_col}" + assert result[2] == second_col, f"{result[2]} != {second_col}" + + async def test_format_population(self): + renderer = Renderer(self.location) + result = await renderer._format_population() + assert result == "3" + + async def test_format_news_line(self): + renderer = Renderer(self.location) + result = await renderer._format_news_line("test", "test", 10, 10) + assert result == ["|test | test|"] + + async def test_format_news_line_long(self): + renderer = Renderer(self.location) + result = await renderer._format_news_line("test", "test" * 10, 10, 10) + assert len(result) == 4 + assert result == [ + "|test |testtestte|", + "| |sttesttest|", + "| |testtestte|", + "| |sttesttest|", + ]