From e83ce7128c3526226042f5ba7771a3cd2aaff83f Mon Sep 17 00:00:00 2001 From: NCat Date: Thu, 16 Feb 2023 19:06:12 +0500 Subject: [PATCH 1/3] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=201-8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 2 + cron/crontab.sh | 1 - docs/make.bat | 4 +- docs/source/conf.py | 6 +- src/clients/currency.py | 1 - src/clients/news.py | 42 +++++++ src/collectors/collector.py | 86 +++++++++++++- src/collectors/models.py | 68 +++++++++++ src/reader.py | 17 ++- src/renderer.py | 164 ++++++++++++++++++++++++-- src/settings.py | 3 + src/tests/clients/test_country.py | 2 + src/tests/clients/test_currency.py | 28 +++++ src/tests/clients/test_news.py | 31 +++++ src/tests/clients/test_weather.py | 30 +++++ src/tests/collectors/test_country.py | 29 +++++ src/tests/collectors/test_currency.py | 31 +++++ src/tests/collectors/test_news.py | 38 ++++++ src/tests/collectors/test_weather.py | 36 ++++++ src/tests/test_reader.py | 73 ++++++++++++ src/tests/test_renderer.py | 138 ++++++++++++++++++++++ 21 files changed, 812 insertions(+), 18 deletions(-) create mode 100644 src/clients/news.py create mode 100644 src/tests/clients/test_news.py create mode 100644 src/tests/collectors/test_news.py diff --git a/.env.sample b/.env.sample index 9fd2e4b..0821bbd 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_NEWSAPI= # время актуальности данных о странах (в секундах) CACHE_TTL_COUNTRY=31_536_000 diff --git a/cron/crontab.sh b/cron/crontab.sh index 27f6628..21cba81 100644 --- a/cron/crontab.sh +++ b/cron/crontab.sh @@ -5,7 +5,6 @@ touch /logs/crontab.log # обеспечение прав на выполнение файла chmod a+x /src/collect.py - # добавление правила периодического задания для cron # * * * * * – выполнение задания один раз в каждую минуту echo "* * * * * /usr/local/bin/python /src/collect.py >> /logs/crontab.log 2>&1" > /etc/crontab diff --git a/docs/make.bat b/docs/make.bat index 954237b..dc1312a 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -7,8 +7,8 @@ REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) -set SOURCEDIR=. -set BUILDDIR=_build +set SOURCEDIR=source +set BUILDDIR=build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( diff --git a/docs/source/conf.py b/docs/source/conf.py index cffffde..a86e0dd 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,8 +17,8 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "Справочник стран" -copyright = f"{date.today().year}, Michael" -author = "Michael" +copyright = f"{date.today().year}, Vladislav" +author = "Vladislav" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -32,4 +32,4 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = "sphinx_rtd_theme" +html_theme = "classic" diff --git a/src/clients/currency.py b/src/clients/currency.py index 3c89d8f..3f03a7e 100644 --- a/src/clients/currency.py +++ b/src/clients/currency.py @@ -28,7 +28,6 @@ async def _request(self, endpoint: str) -> Optional[dict]: 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]: diff --git a/src/clients/news.py b/src/clients/news.py new file mode 100644 index 0000000..e572035 --- /dev/null +++ b/src/clients/news.py @@ -0,0 +1,42 @@ +""" +Функции для взаимодействия с внешним сервисом-провайдером данных о новостях. +""" +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_NEWSAPI + + +class NewsClient(BaseClient): + """ + Реализация функций для взаимодействия с внешним сервисом-провайдером данных о новостях. + """ + + async def get_base_url(self) -> str: + return "https://newsapi.org/v2" + + 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_news(self, country_code: str = "ru") -> Optional[dict]: + """ + Получение данных о новостях. + + :param country_code: код страны + :return: + """ + + return await self._request( + f"{await self.get_base_url()}/top-headlines?country={country_code}&apiKey={API_KEY_NEWSAPI}" + ) diff --git a/src/collectors/collector.py b/src/collectors/collector.py index ebadf7e..04c6456 100644 --- a/src/collectors/collector.py +++ b/src/collectors/collector.py @@ -14,19 +14,23 @@ 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, CountryDTO, + CoordInfoDTO, CurrencyRatesDTO, CurrencyInfoDTO, WeatherInfoDTO, + NewsInfoDTO, ) from settings import ( MEDIA_PATH, CACHE_TTL_COUNTRY, CACHE_TTL_CURRENCY_RATES, CACHE_TTL_WEATHER, + CACHE_TTL_NEWS, ) @@ -103,6 +107,7 @@ async def read(cls) -> Optional[list[CountryDTO]]: population=item["population"], subregion=item["subregion"], timezones=item["timezones"], + area=item["area"], ) ) @@ -214,16 +219,88 @@ async def read(cls, location: LocationDTO) -> Optional[WeatherInfoDTO]: result = json.loads(content) if result: return WeatherInfoDTO( + coord=CoordInfoDTO( + lon=result["coord"]["lon"], + lat=result["coord"]["lat"], + ), 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"], + timezone=result["timezone"], ) 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[NewsInfoDTO]]: + """ + Чтение данных из кэша. + + :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) + if result: + return [ + NewsInfoDTO( + source=article["source"]["name"], + author=article["author"], + title=article["title"], + description=article["description"], + url=article["url"], + published_at=article["publishedAt"], + ) + for article in result["articles"] + ] + return None + + class Collectors: @staticmethod async def gather() -> tuple: @@ -232,12 +309,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..5a290a0 100644 --- a/src/collectors/models.py +++ b/src/collectors/models.py @@ -3,6 +3,7 @@ """ from pydantic import Field, BaseModel +from pydantic.schema import Optional class HashableBaseModel(BaseModel): @@ -93,6 +94,9 @@ class CountryDTO(BaseModel): timezones=[ "UTC+02:00", ], + area = 1580.0 + latitude = 4.0, + longitude = -53.0 ) """ @@ -106,6 +110,7 @@ class CountryDTO(BaseModel): population: int subregion: str timezones: list[str] + area: Optional[float] class CurrencyRatesDTO(BaseModel): @@ -128,6 +133,22 @@ class CurrencyRatesDTO(BaseModel): rates: dict[str, float] +class CoordInfoDTO(BaseModel): + """ + Модель координат. + + .. code-block:: + + CoordInfoDTO( + lon = -0.1257, + lat = 51.5085, + ) + """ + + lon: float + lat: float + + class WeatherInfoDTO(BaseModel): """ Модель данных о погоде. @@ -135,19 +156,54 @@ class WeatherInfoDTO(BaseModel): .. code-block:: WeatherInfoDTO( + coord=CoordInfoDTO( + lon = -0.1257, + lat = 51.5085, + ), temp=13.92, pressure=1023, humidity=54, wind_speed=4.63, description="scattered clouds", + visibility=2500, + timezone=0 ) """ + coord: CoordInfoDTO temp: float pressure: int humidity: int wind_speed: float description: str + visibility: int + timezone: int + + +class NewsInfoDTO(BaseModel): + """ + Модель данных о новости. + + .. code-block:: + + NewsInfoDTO( + source="CNN" + author = "Luke McGee, Jack Guy", + title = "Nicola Sturgeon unexpectedly quits as first minister of Scotland amid swirl of political setbacks, + citing 'brutality' of public life - CNN", + description="Nicola Sturgeon, the figurehead of the Scottish independence movement, dramatically announced + on Wednesday that she would resign after eight years as Scotland's first minister.", + url="https://www.cnn.com/2023/02/15/uk/nicola-sturgeon-resigns-scotland-intl/index.html", + publishedAt = "2023-02-15T12:18:00Z" + ) + """ + + source: Optional[str] + author: Optional[str] + title: Optional[str] + description: Optional[str] + url: Optional[str] + published_at: Optional[str] class LocationInfoDTO(BaseModel): @@ -195,9 +251,21 @@ class LocationInfoDTO(BaseModel): currency_rates={ "EUR": 0.016503, }, + news = [ + NewsInfoDTO( + source="CNN" + author = "Luke McGee, Jack Guy", + title = "Nicola Sturgeon unexpectedly quits as first minister of Scotland amid swirl of political + setbacks, citing 'brutality' of public life - CNN", + description="Nicola Sturgeon, the figurehead of the Scottish independence movement, dramatically + announced on Wednesday that she would resign after eight years as Scotland's first minister.", + url="https://www.cnn.com/2023/02/15/uk/nicola-sturgeon-resigns-scotland-intl/index.html", + publishedAt = "2023-02-15T12:18:00Z" + ),] ) """ location: CountryDTO weather: WeatherInfoDTO currency_rates: dict[str, float] + news: Optional[list[NewsInfoDTO]] diff --git a/src/reader.py b/src/reader.py index dd1a74d..bafec6f 100644 --- a/src/reader.py +++ b/src/reader.py @@ -8,6 +8,7 @@ from collectors.collector import ( CountryCollector, CurrencyRatesCollector, + NewsCollector, WeatherCollector, ) from collectors.models import ( @@ -15,6 +16,7 @@ CurrencyInfoDTO, LocationDTO, LocationInfoDTO, + NewsInfoDTO, WeatherInfoDTO, ) @@ -38,11 +40,14 @@ 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 +80,16 @@ async def get_weather(location: LocationDTO) -> Optional[WeatherInfoDTO]: """ return await WeatherCollector.read(location=location) + @staticmethod + async def get_news(location: LocationDTO) -> Optional[list[NewsInfoDTO]]: + """ + Чтение и формирование новостей. + + :param currencies: Объект локации для получения данных + :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 8b90dcc..55f1c05 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -2,7 +2,9 @@ Функции для формирования выходной информации. """ +from datetime import datetime, timedelta, tzinfo from decimal import ROUND_HALF_UP, Decimal +from typing import Optional from collectors.models import LocationInfoDTO @@ -21,22 +23,108 @@ def __init__(self, location_info: LocationInfoDTO) -> None: self.location_info = location_info + def decimal_to_dms_with_direction(self, decimal: float, direct: list) -> str: + """ + Преобразование числа в координаты. + + :param decimal: координата в числовом формате. + :param direct: список координат направлений + :return: Координаты в формате (Градусы минтуы секунды) + """ + if decimal >= 0: + direction = 0 if decimal != 0 else 2 + else: + direction = 1 + degrees = int(abs(decimal)) + decimal_minutes = (abs(decimal) - degrees) * 60 + minutes = int(decimal_minutes) + seconds = (decimal_minutes - minutes) * 60 + return f"{degrees}° {minutes}’{seconds:.2f}” {direct[direction]}" + 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", + lon = self.decimal_to_dms_with_direction( + self.location_info.weather.coord.lon, ["E", "W", ""] ) + lat = self.decimal_to_dms_with_direction( + self.location_info.weather.coord.lat, ["N", "S", ""] + ) + inf = { + "Страна": self.location_info.location.name, + "Столица": self.location_info.location.capital, + "Площадь": self.location_info.location.area, + "Регион": self.location_info.location.subregion, + "Координаты столицы": f"{lat}, {lon}", + "Языки": f"{await self._format_languages()}", + "Население страны": f"{await self._format_population()} чел.", + "Курсы валют": f"{await self._format_currency_rates()}", + } + wether = { + "Температура": f"{self.location_info.weather.temp} °C", + "Описание погоды": f"{self.location_info.weather.description} ", + "Видемость": f"{self.location_info.weather.visibility} м.", + "Скорость ветра": f"{self.location_info.weather.wind_speed} м/с", + } + timezone = TimezoneInfo(offset=self.location_info.weather.timezone, name=None) + time = { + "Часовой пояс": f"{timezone.utc()}", + "Текущее время": f"{datetime.now(timezone).strftime('%d.%m.%Y %H:%M')}", + } + formatted_values = [] + first_column_width = max(len(key) for key in inf) + 1 + second_column_width = max(len(str(value)) for value in inf.values()) + 1 + + async def _format_str(data: dict, data_name: str) -> None: + formatted_values.append( + ( + data_name + + "-" + * (first_column_width + second_column_width + 3 - len(data_name)) + ) + ) + extend = [] + for key, value in data.items(): + string = str(value) + extend.append( + f"|{key:<{first_column_width}}|{string[:second_column_width]:<{second_column_width}}|" + ) + next_lines = string[second_column_width:] + while len(str(next_lines)) != 0: + extend.append( + f"|{'':<{first_column_width}}|{next_lines[:second_column_width]:<{second_column_width}}|" + ) + next_lines = next_lines[second_column_width:] + formatted_values.extend(extend) + + await _format_str(inf, "Общая информация") + await _format_str(time, "Время") + await _format_str(wether, "Погода") + news_id = 1 + if self.location_info.news is not None: + for article in self.location_info.news: + news = { + "Источник": "Нет данных" + if article.source is None + else article.source, + "Автор": "Нет данных" if article.author is None else article.author, + "Заголовок": "Нет данных" + if article.title is None + else article.title, + "Описание": "Нет данных" + if article.description is None + else article.description, + "Ссылка": "Нет данных" if article.url is None else article.url, + "Дата публикации": "Нет данных" + if article.published_at is None + else article.published_at, + } + await _format_str(news, f"Новость #{news_id}") + news_id += 1 + return tuple(formatted_values) async def _format_languages(self) -> str: """ @@ -71,3 +159,61 @@ 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() ) + + +class TimezoneInfo(tzinfo): + """ + Собственная таймзона. + """ + + def __init__(self, offset: int, name: str | None) -> None: + """ + Конструктор. + + :param offset: секунды для таймзоны, + :param name: название времяной зоны + :return: + """ + self.offset = timedelta(seconds=offset) + self.seconds = offset + self.name = name or self.__class__.__name__ + + def utc(self) -> str: + """ + Текстовое представление в UTC. + + :return: + """ + sign = "-" if self.seconds < 0 else "+" + absolute_seconds = abs(self.seconds) + hours = absolute_seconds // 3600 + minutes = (absolute_seconds % 3600) // 60 + return f"UTC {sign}{hours:02d}:{minutes:02d}" + + def utcoffset(self, dt: Optional[datetime]) -> timedelta: + """ + Возвращение offset для UTC. + + :param dt: время. + :return: + """ + return self.offset + + def tzname(self, dt: Optional[datetime]) -> str: + """ + Возвращение имени. + + :param dt: время. + :return: + """ + return self.name + + def dst(self, dt: Optional[datetime]) -> timedelta: + """ + Возвращение разница во времени. + + + :param dt: время. + :return: + """ + return timedelta(0) diff --git a/src/settings.py b/src/settings.py index 4559f84..89a5bc0 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_NEWSAPI: Optional[str] = os.getenv("API_KEY_NEWSAPI") # время актуальности данных о странах (в секундах), по умолчанию – один год 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", "10_700")) diff --git a/src/tests/clients/test_country.py b/src/tests/clients/test_country.py index b04944d..57aff2b 100644 --- a/src/tests/clients/test_country.py +++ b/src/tests/clients/test_country.py @@ -19,9 +19,11 @@ class TestClientCountry: def client(self): return CountryClient() + @pytest.mark.asyncio async def test_get_base_url(self, client): assert await client.get_base_url() == self.base_url + @pytest.mark.asyncio async def test_get_countries(self, mocker, client): mocker.patch("clients.country.CountryClient._request") await client.get_countries() diff --git a/src/tests/clients/test_currency.py b/src/tests/clients/test_currency.py index 104d612..8fbf9aa 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() + + @pytest.mark.asyncio + async def test_get_base_url(self, client): + assert await client.get_base_url() == self.base_url + + @pytest.mark.asyncio + async def test_get_currency(self, mocker, client): + mocker.patch("clients.base.BaseClient._request") + await client.get_rates() + await client._request(f"{self.base_url}?base=rub") + await client.get_rates("test") + await client._request(f"{self.base_url}?base=test") diff --git a/src/tests/clients/test_news.py b/src/tests/clients/test_news.py new file mode 100644 index 0000000..f0ffe8d --- /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_NEWSAPI + + +class TestClientNews: + """ + Тестирование клиента для получения информации о странах. + """ + + base_url = "https://newsapi.org/v2" + + @pytest.fixture + def client(self): + return NewsClient() + + @pytest.mark.asyncio + async def test_get_base_url(self, client): + assert await client.get_base_url() == self.base_url + + @pytest.mark.asyncio + async def test_get_countries(self, mocker, client): + mocker.patch("clients.base.BaseClient._request") + await client.get_news("ru") + await client._request( + f"{self.base_url}/top-headlines?country=ru&apiKey={API_KEY_NEWSAPI}" + ) diff --git a/src/tests/clients/test_weather.py b/src/tests/clients/test_weather.py index 3c14430..af182fa 100644 --- a/src/tests/clients/test_weather.py +++ b/src/tests/clients/test_weather.py @@ -1,3 +1,33 @@ """ Тестирование функций клиента для получения информации о погоде. """ + + +import pytest + +from clients.weather import WeatherClient +from settings import API_KEY_OPENWEATHER + + +class TestClientWeather: + """ + Тестирование клиента для получения информации о странах. + """ + + base_url = "https://api.openweathermap.org/data/2.5/weather" + + @pytest.fixture + def client(self): + return WeatherClient() + + @pytest.mark.asyncio + async def test_get_base_url(self, client): + assert await client.get_base_url() == self.base_url + + @pytest.mark.asyncio + async def test_get_countries(self, mocker, client): + mocker.patch("clients.base.BaseClient._request") + await client.get_weather("test") + await client._request( + f"{self.base_url}?units=metric&q=test&appid={API_KEY_OPENWEATHER}" + ) diff --git a/src/tests/collectors/test_country.py b/src/tests/collectors/test_country.py index 325936d..2ce74e2 100644 --- a/src/tests/collectors/test_country.py +++ b/src/tests/collectors/test_country.py @@ -1,3 +1,32 @@ """ Тестирование функций сбора информации о странах. """ +import pytest + +from collectors.collector import CountryCollector + + +class TestCountryCollector: + """ + Тестирование функций сбора информации о странах. + """ + + @pytest.fixture(autouse=True) + def setup(self): + self.collector = CountryCollector() + + @pytest.mark.asyncio + async def test_collect_country_success(self, mocker): + """ + Тестирование получения информации о стране. + """ + countries = await self.collector.collect() + assert len(countries) == 49 + + @pytest.mark.asyncio + async def test_read_country_success(self, mocker): + """ + Тестирование чтения информации о стране. + """ + 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..d9b7c80 100644 --- a/src/tests/collectors/test_currency.py +++ b/src/tests/collectors/test_currency.py @@ -1,3 +1,34 @@ """ Тестирование функций сбора информации о курсах валют. """ + +import pytest + +from collectors.collector import CurrencyRatesCollector + + +class TestCurrencyCollector: + """ + Тестирование функций сбора информации о курсах валют. + """ + + @pytest.fixture(autouse=True) + def setup(self): + self.collector = CurrencyRatesCollector() + + @pytest.mark.asyncio + async def test_collect_currency_success(self, mocker): + """ + Тестирование получения информации о курсе валют. + """ + await self.collector.collect() + + @pytest.mark.asyncio + async def test_read_currency_success(self, mocker): + """ + Тестирование чтения информации о курсе валют. + """ + 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..9767f1b --- /dev/null +++ b/src/tests/collectors/test_news.py @@ -0,0 +1,38 @@ +""" +Тестирование клиента для получения информации о новостях +""" + +import pytest + +from collectors.collector import NewsCollector +from collectors.models import LocationDTO + + +@pytest.mark.asyncio +class TestNewsCollector: + """ + Тестирование клиента для получения информации о новостях. + """ + + location = LocationDTO( + capital="Moscow", + alpha2code="RU", + ) + + @pytest.fixture(autouse=True) + def setup(self, mocker): + self.collector = NewsCollector() + + @pytest.mark.asyncio + async def test_collect_news_success(self, mocker): + """ + Тестирование получения информации о новостях. + """ + await self.collector.collect(frozenset([self.location])) + + async def test_read_news_success(self, mocker): + """ + Тестирование чтения информации о новостях. + """ + news = await self.collector.read(self.location) + assert len(news) == 20 diff --git a/src/tests/collectors/test_weather.py b/src/tests/collectors/test_weather.py index 55b8796..507f730 100644 --- a/src/tests/collectors/test_weather.py +++ b/src/tests/collectors/test_weather.py @@ -1,3 +1,39 @@ """ Тестирование функций сбора информации о погоде. """ + +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, mocker): + self.collector = WeatherCollector() + + @pytest.mark.asyncio + async def test_collect_weather_success(self, mocker): + """ + Тестирование получения информации о погоде. + """ + await self.collector.collect(frozenset([self.location])) + + @pytest.mark.asyncio + async def test_read_weather_success(self, mocker): + """ + Тестирование чтения информации о погоде. + """ + weather = await self.collector.read(self.location) + assert weather is not None + assert weather.timezone == 10800 diff --git a/src/tests/test_reader.py b/src/tests/test_reader.py index 0ee6c46..288566a 100644 --- a/src/tests/test_reader.py +++ b/src/tests/test_reader.py @@ -1,3 +1,76 @@ """ Тестирование функций поиска (чтения) собранной информации в файлах. """ +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() + + @pytest.mark.asyncio + async def test_find(self, reader): + location = await reader.find("Moscow") + 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 == 146599183 + assert location.location.area == 17124442 + assert location.weather.coord.lon == 37.6156 + assert location.weather.coord.lat == 55.7522 + assert len(location.location.alt_spellings) == 5 + assert location.location.subregion == "Eastern Europe" + assert type(location.weather) == WeatherInfoDTO + assert location.weather.timezone == 10800 + assert len(location.news) == 20 + assert len(location.currency_rates) == 1 + + @pytest.mark.asyncio + async def test_find_not_found(self, reader): + location = await reader.find("test") + assert location is None + + @pytest.mark.asyncio + async def test_get_weather(self, reader): + weather = await reader.get_weather(self.location) + assert type(weather) == WeatherInfoDTO + assert weather.timezone == 10800 + assert weather.coord.lon == 37.6156 + assert weather.coord.lat == 55.7522 + + @pytest.mark.asyncio + async def test_get_news(self, reader): + news = await reader.get_news(self.location) + assert len(news) == 20 + assert type(news[0]) == NewsInfoDTO + + @pytest.mark.asyncio + async def test_find_country(self, reader): + country = await reader.find_country("Russia") + assert type(country) == CountryDTO + assert country.name == "Russian Federation" + assert country.capital == "Moscow" + + @pytest.mark.asyncio + async def test_find_country_none(self, 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..c0d4ada 100644 --- a/src/tests/test_renderer.py +++ b/src/tests/test_renderer.py @@ -1,3 +1,141 @@ """ Тестирование функций генерации выходных данных. """ +import datetime + +import pytest +from collectors.models import ( + CountryDTO, + CurrencyInfoDTO, + LanguagesInfoDTO, + LocationInfoDTO, + NewsInfoDTO, + WeatherInfoDTO, + CoordInfoDTO, +) +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( + coord=CoordInfoDTO( + lon=-0.1257, + lat=51.5085, + ), + temp=13.92, + pressure=1023, + humidity=54, + wind_speed=4.63, + description="scattered clouds", + visibility=2500, + timezone=0, + ), + currency_rates={"USD": 1.0}, + news=[ + NewsInfoDTO( + source="test", + published_at="test", + title="test", + description="test", + url="test", + url_to_image="test", + ) + ], + ) + + @pytest.mark.asyncio + async def test_render(self): + renderer = Renderer(self.location) + results = await renderer.render() + assert len(results) == 24 + first_column = [ + "", + "Страна", + "Столица", + "Площадь", + "Регион", + "Координатыстолицы", + "Языки", + "Населениестраны", + "Курсывалют", + "", + "Часовойпояс", + "Текущеевремя", + "", + "Температура", + "Описаниепогоды", + "Видемость", + "Скоростьветра", + "", + "Источник", + "Автор", + "Заголовок", + "Описание", + "Ссылка", + "Датапубликации", + ] + second_column = [ + None, + "Russia", + "Moscow", + "3.0", + "test", + "51°30’30.60”N,0°7’32.52”W", + "Russian(Русский)", + "3чел.", + "USD=1.00руб.", + None, + "UTC+00:00", + datetime.datetime.utcnow().strftime("%d.%m.%Y %H:%M").replace(" ", ""), + None, + "13.92°C", + "scatteredclouds", + "2500м.", + "4.63м/с", + None, + "test", + "Нетданных", + "test", + "test", + "test", + ] + for result, first_col, second_col in zip(results, first_column, second_column): + if "-" in result: + 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}" + + @pytest.mark.asyncio + async def test_format_languages(self): + renderer = Renderer(self.location) + result = await renderer._format_languages() + assert result == "Russian (Русский)" + + @pytest.mark.asyncio + async def test_format_currencies_rates(self): + renderer = Renderer(self.location) + result = await renderer._format_currency_rates() + assert result == "USD = 1.00 руб." + + @pytest.mark.asyncio + async def test_format_population(self): + renderer = Renderer(self.location) + result = await renderer._format_population() + assert result == "3" From a213c65616e4787ea68e6993400f7c2eae14c7cd Mon Sep 17 00:00:00 2001 From: NCat Date: Thu, 16 Feb 2023 19:12:45 +0500 Subject: [PATCH 2/3] README UPDT --- README.md | 6 ++++-- docs/source/index.rst | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b731e52..add749e 100644 --- a/README.md +++ b/README.md @@ -33,11 +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 - News Free Plan(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_NEWSAPI` – for NewsAPI access token 2. Build the container using Docker Compose: ```shell docker compose build @@ -62,6 +63,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 data 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..a77873b 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 - News Free Plan(https://newsapi.org) Задайте полученные токены доступа в качестве значений переменных окружения (в файле `.env`): * `API_KEY_APILAYER` – для токена доступа к APILayer * `API_KEY_OPENWEATHER` – для токена доступа к OpenWeather + * `API_KEY_NEWSAPI` – для токена доступа к NewsAPI 2. Соберите Docker-контейнер с помощью Docker Compose: .. code-block:: console @@ -92,6 +94,7 @@ * `CACHE_TTL_COUNTRY` (время актуальности данных о странах) * `CACHE_TTL_CURRENCY_RATES` (время актуальности данных о курсах валют) * `CACHE_TTL_WEATHER` (время актуальности данных о погоде) + * `CACHE_TTL_NEWS` (время актуальности данных о новостях) Значение для этих переменных указывается в секундах (они определяются в файле `.env`). From ec04a62fbf93e38c58aca88e277b6188b7b47797 Mon Sep 17 00:00:00 2001 From: NCat Date: Thu, 16 Feb 2023 21:36:40 +0500 Subject: [PATCH 3/3] =?UTF-8?q?collector=20=D0=B8=20renderer=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/collectors/collector.py | 2 +- src/renderer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/collectors/collector.py b/src/collectors/collector.py index 04c6456..30e4321 100644 --- a/src/collectors/collector.py +++ b/src/collectors/collector.py @@ -296,7 +296,7 @@ async def read(cls, location: LocationDTO) -> Optional[list[NewsInfoDTO]]: url=article["url"], published_at=article["publishedAt"], ) - for article in result["articles"] + for article in result["articles"][:3] ] return None diff --git a/src/renderer.py b/src/renderer.py index 55f1c05..050157e 100644 --- a/src/renderer.py +++ b/src/renderer.py @@ -29,7 +29,7 @@ def decimal_to_dms_with_direction(self, decimal: float, direct: list) -> str: :param decimal: координата в числовом формате. :param direct: список координат направлений - :return: Координаты в формате (Градусы минтуы секунды) + :return: Координаты в формате (Градусы минуты секунды) """ if decimal >= 0: direction = 0 if decimal != 0 else 2