Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ LOGGING_LEVEL=DEBUG
API_KEY_APILAYER=
# https://openweathermap.org/price#weather
API_KEY_OPENWEATHER=
# https://newsapi.org
API_KEY_NEWSPORTAL=

# время актуальности данных о странах (в секундах)
CACHE_TTL_COUNTRY=31_536_000
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Install the appropriate software:
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_NEWSPORTAL` - for NewsAPI access token

2. Build the container using Docker Compose:
```shell
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions Report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

- [Что было сделано](#Что-было-сделано)
- [Таблица](#Таблица)
- [Тесты](#Тесты)

## Что было сделано

- Реализован сбор новостей
- Сделан вывод в таблицу
- Добавлены автотесты
- И pytest.ini добавлен в контейнер
- Обновлена документация
![](https://raw.githubusercontent.com/makspepe/python-course-country-directory/hw2/media/1.PNG)

### Таблица
![](https://raw.githubusercontent.com/makspepe/python-course-country-directory/hw2/media/2.PNG)
![](https://raw.githubusercontent.com/makspepe/python-course-country-directory/hw2/media/3.PNG)

### Тесты
![](https://raw.githubusercontent.com/makspepe/python-course-country-directory/hw2/media/4.PNG)
4 changes: 4 additions & 0 deletions media/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
*.*
!.gitignore
!1.PNG
!2.PNG
!3.PNG
!4.PNG
Binary file added media/1.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/2.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/3.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/4.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ mypy>=0.971,<1.0

# автоматическое форматирование кода
black>=22.8.0,<22.9.0

# таблицы
tabulate>=0.8.0,<1.0
4 changes: 2 additions & 2 deletions src/clients/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ async def _request(self, endpoint: str) -> Optional[dict]:
Формирование и выполнение запроса.

:param endpoint:
:return:
"""
:return: JSON
"""
2 changes: 1 addition & 1 deletion src/clients/country.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def get_countries(self, bloc: str = "eu") -> Optional[dict]:
Получение данных о странах.

:param bloc: Регион
:return:
:return: JSON о странах региона
"""

return await self._request(f"{await self.get_base_url()}/regional_bloc/{bloc}")
2 changes: 1 addition & 1 deletion src/clients/currency.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def get_rates(self, base: str = "rub") -> Optional[dict]:
Получение данных о курсах валют.

:param base: Базовая валюта
:return:
:return: JSON о курсах валют
"""

return await self._request(f"{await self.get_base_url()}?base={base}")
45 changes: 45 additions & 0 deletions src/clients/news.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""
Функции для взаимодействия с внешним сервисом-провайдером новостей.
"""
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_NEWSPORTAL


class NewsClient(BaseClient):
"""
Реализация функций для взаимодействия с внешним сервисом-провайдером новостей.
"""

async def get_base_url(self) -> str:
"""
Возвращает базовый URL для API новостей.
:return:
"""
return "https://newsapi.org/v2/everything"

async def _request(self, endpoint: str) -> Optional[dict]:
"""
Отправляет запрос HTTP GET на указанную конечную точку и возвращает ответ в виде JSON.
"""

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: str) -> Optional[dict]:
"""
Получение новостей по заданной стране, опубликованных сегодня.
:param country: Страна с которой собирается новость
:return: JSON о новостях
"""

endpoint = f"{await self.get_base_url()}?q={country}&sortBy=publishedAt&apiKey={API_KEY_NEWSPORTAL}"
return await self._request(endpoint)
2 changes: 1 addition & 1 deletion src/clients/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async def get_weather(self, location: str) -> Optional[dict]:
Получение данных о погоде.

:param location: Город и страна
:return:
:return: JSON о погоде в заданном городе
"""

return await self._request(
Expand Down
15 changes: 12 additions & 3 deletions src/collectors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,26 @@ class BaseCollector(ABC):

@abstractmethod
async def collect(self, **kwargs: Any) -> Optional[Iterable[Any]]:
...
"""
Получение данных из JSON.
:return: Коллекция данных
"""

@staticmethod
@abstractmethod
async def get_file_path(**kwargs: Any) -> str:
...
"""
Получение пути к файлу.
:return:
"""

@staticmethod
@abstractmethod
async def get_cache_ttl() -> int:
...
"""
Получение времени жизни кэша.
:return:
"""

async def cache_invalid(self, **kwargs: Any) -> bool:
"""
Expand Down
98 changes: 96 additions & 2 deletions src/collectors/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,22 @@

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,
NewsDTO,
CurrencyInfoDTO,
WeatherInfoDTO,
)
from settings import (
MEDIA_PATH,
CACHE_TTL_COUNTRY,
CACHE_TTL_CURRENCY_RATES,
CACHE_TTL_NEWS,
CACHE_TTL_WEATHER,
)

Expand All @@ -47,6 +50,9 @@ async def get_cache_ttl() -> int:
return CACHE_TTL_COUNTRY

async def collect(self, **kwargs: Any) -> Optional[FrozenSet[LocationDTO]]:
"""
Получение данных из JSON.
"""
if await self.cache_invalid():
# если кэш уже невалиден, то актуализируем его
result = await self.client.get_countries()
Expand All @@ -63,6 +69,7 @@ async def collect(self, **kwargs: Any) -> Optional[FrozenSet[LocationDTO]]:
if result:
locations = frozenset(
LocationDTO(
country=item["name"],
capital=item["capital"],
alpha2code=item["alpha2code"],
)
Expand Down Expand Up @@ -90,16 +97,19 @@ async def read(cls) -> Optional[list[CountryDTO]]:
for item in items:
result_list.append(
CountryDTO(
name=item["name"],
capital=item["capital"],
capital_latitude=item["latitude"],
capital_longitude=item["longitude"],
alpha2code=item["alpha2code"],
alt_spellings=item["alt_spellings"],
currencies={
CurrencyInfoDTO(code=currency["code"])
for currency in item["currencies"]
},
flag=item["flag"],
area=item["area"],
languages=item["languages"],
name=item["name"],
population=item["population"],
subregion=item["subregion"],
timezones=item["timezones"],
Expand Down Expand Up @@ -203,7 +213,7 @@ async def read(cls, location: LocationDTO) -> Optional[WeatherInfoDTO]:
"""
Чтение данных из кэша.

:param location:
:param location: Город для получения данных
:return:
"""

Expand All @@ -214,17 +224,24 @@ async def read(cls, location: LocationDTO) -> Optional[WeatherInfoDTO]:
result = json.loads(content)
if result:
return WeatherInfoDTO(
date_time=result["dt"],
utc_timezone=result["timezone"],
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"],
)

return None


class Collectors:
"""
Сборщик данных.
"""

@staticmethod
async def gather() -> tuple:
return await asyncio.gather(
Expand All @@ -238,7 +255,84 @@ 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:
loop.close()


class NewsCollector(BaseCollector):
"""
Собирает новости по указанным странам.
"""

def __init__(self) -> None:
self.client = NewsClient()

@staticmethod
async def get_file_path(filename: str = "", **kwargs: Any) -> str:
"""
Возвращает путь к файлу для хранения данных новостей JSON.
"""
return f"{MEDIA_PATH}/news/{filename}.json"

@staticmethod
async def get_cache_ttl() -> int:
"""
Возвращает TTL (время жизни) кэша для новостных данных.
"""
return CACHE_TTL_NEWS

async def collect(
self, locations: FrozenSet[LocationDTO] = frozenset(), **kwargs: Any
) -> None:
"""
Собирает данные о новостях для указанных стран и сохраняет их в JSON.
"""
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.country}".lower()

# если кэш уже невалиден, то актуализируем его
if await self.cache_invalid(filename=filename):
result = await self.client.get_news(location.country)
if result:
# сохраняем данные в JSON
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, number: int) -> Optional[NewsDTO]:
"""
Чтение данных из кэша.
:param location: Локация для получения данных
:param number: Порядковый номер новости
:return:
"""
filename = f"{location.country}".lower()

# читаем данные из JSON
async with aiofiles.open(await cls.get_file_path(filename), mode="r") as file:
content = await file.read()

result = json.loads(content)
if result:
# возвращаем данные о новости
article = result["articles"][number]
return NewsDTO(
source=article["source"]["name"],
author=article["author"],
published_at=article["publishedAt"],
title=article["title"],
description=article["description"],
)

return None
Loading