Replace wttr.in/Open-Meteo with Yandex Weather API
- Switch from wttr.in + Open-Meteo fallback to Yandex Weather API - Add YANDEX_WEATHER_API_KEY to .env and config validation - Enrich weather output: wind gusts, wind direction (Russian) - Pressure now in mmHg directly from Yandex (no conversion needed) - Update rate limiter: weather_limiter + open_meteo_limiter → yandex_weather_limiter - Remove dead code: API_URL_WEATHER, fetch_open_meteo, translate_weather - Update all tests (271 passed)
This commit is contained in:
parent
ee04f67506
commit
48e64c2bc8
@ -3,11 +3,10 @@ MORNING_TIME=07:00
|
||||
MORNING_CHANNEL_ID=channel_id
|
||||
LOG_LEVEL=INFO
|
||||
CAT_API_KEY=your_cat_api_key_here
|
||||
YANDEX_WEATHER_API_KEY=your_yandex_weather_api_key_here
|
||||
CAT_API_RATE=1
|
||||
CAT_API_BURST=3
|
||||
WEATHER_API_RATE=1
|
||||
WEATHER_API_BURST=3
|
||||
OPEN_METEO_API_RATE=2
|
||||
OPEN_METEO_API_BURST=5
|
||||
YANDEX_WEATHER_API_RATE=1
|
||||
YANDEX_WEATHER_API_BURST=3
|
||||
HABR_RSS_RATE=1
|
||||
HABR_RSS_BURST=2
|
||||
|
||||
5
bot.py
5
bot.py
@ -249,6 +249,11 @@ def _validate_config() -> None:
|
||||
logger.error("Токен Discord не найден в .env")
|
||||
sys.exit(1)
|
||||
|
||||
yandex_key = os.getenv("YANDEX_WEATHER_API_KEY")
|
||||
if not yandex_key:
|
||||
logger.error("YANDEX_WEATHER_API_KEY не найден в .env")
|
||||
sys.exit(1)
|
||||
|
||||
morning_time = os.getenv("MORNING_TIME", "07:00")
|
||||
try:
|
||||
hour, minute = map(int, morning_time.split(":"))
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from discord.ext import commands
|
||||
from utils.pogoda import API_URL_WEATHER, fetch_weather, format_weather_data_for_console
|
||||
from utils.pogoda import fetch_weather, format_weather_data_for_console
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -11,7 +11,7 @@ class Pg(commands.Cog):
|
||||
@commands.command(name="pg")
|
||||
async def pg(self, ctx: commands.Context) -> None:
|
||||
"""Прогноз погоды в Магнитогорске"""
|
||||
data = await fetch_weather(API_URL_WEATHER)
|
||||
data = await fetch_weather()
|
||||
if data is None:
|
||||
logger.warning(
|
||||
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
|
||||
|
||||
@ -7,18 +7,14 @@ from commands.pg import Pg
|
||||
class TestPgInit:
|
||||
"""Тесты инициализации Cog Pg."""
|
||||
|
||||
def test_uses_api_url_constant(self) -> None:
|
||||
"""Pg использует API_URL_WEATHER напрямую (без инстанс-переменной)."""
|
||||
from utils.pogoda import API_URL_WEATHER
|
||||
|
||||
def test_cog_initialized(self) -> None:
|
||||
"""Cog инициализируется без ошибок."""
|
||||
cog = Pg()
|
||||
# Pg не хранит api_url как инстанс-переменную — использует константу напрямую
|
||||
assert not hasattr(cog, "api_url")
|
||||
assert API_URL_WEATHER == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
||||
assert cog is not None
|
||||
|
||||
|
||||
class TestPgCommand:
|
||||
"""Тесты команды !pogoda."""
|
||||
"""Тесты команды !pg."""
|
||||
|
||||
def _make_cog(self):
|
||||
return Pg()
|
||||
@ -29,16 +25,25 @@ class TestPgCommand:
|
||||
return ctx
|
||||
|
||||
def _make_weather_data(self, **extra):
|
||||
"""Создать mock weather data с дефолтными полями."""
|
||||
"""Создать mock weather data (Яндекс Погода формат).
|
||||
|
||||
windspeedKmph — м/с от Яндекса.
|
||||
wind_gust — порывы ветра в м/с.
|
||||
wind_dir — направление ветра.
|
||||
pressure — уже в мм рт. ст.
|
||||
weatherDesc — на русском (из yandex_condition_to_russian).
|
||||
"""
|
||||
defaults = {
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "22",
|
||||
"FeelsLikeC": "24",
|
||||
"weatherDesc": [{"value": "Clear"}],
|
||||
"humidity": "45",
|
||||
"windspeedKmph": "18",
|
||||
"pressure": "1013",
|
||||
"temp_C": 22,
|
||||
"FeelsLikeC": 24,
|
||||
"weatherDesc": [{"value": "Ясно"}],
|
||||
"humidity": 45,
|
||||
"windspeedKmph": 5.0, # м/с
|
||||
"wind_gust": 8.0,
|
||||
"wind_dir": "n",
|
||||
"pressure": 735.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -47,7 +52,7 @@ class TestPgCommand:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_success(self) -> None:
|
||||
"""Успешный запрос погоды должен отправить embed с данными."""
|
||||
"""Успешный запрос погоды должен отправить сообщение с данными."""
|
||||
cog = self._make_cog()
|
||||
ctx = self._make_ctx()
|
||||
weather = self._make_weather_data()
|
||||
@ -62,7 +67,7 @@ class TestPgCommand:
|
||||
assert "Описание: Ясно" in args
|
||||
assert "Влажность: 45%" in args
|
||||
assert "Ветер: 5.0 м/с" in args
|
||||
assert "Давление: 759.8 мм рт. ст." in args
|
||||
assert "Давление: 735.0 мм рт. ст." in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_fetch_returns_none(self) -> None:
|
||||
@ -130,7 +135,7 @@ class TestPgCommand:
|
||||
"""windspeedKmph = 0 — wind должен быть 0.0."""
|
||||
cog = self._make_cog()
|
||||
ctx = self._make_ctx()
|
||||
weather = self._make_weather_data(windspeedKmph="0")
|
||||
weather = self._make_weather_data(windspeedKmph=0)
|
||||
|
||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||
await cog.pg.callback(cog, ctx)
|
||||
@ -162,22 +167,9 @@ class TestPgCommand:
|
||||
assert "Влажность: —%" in args
|
||||
assert "Давление: — мм рт. ст." in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_translate_unknown_weather(self) -> None:
|
||||
"""Неизвестное описание погоды должно возвращать оригинал."""
|
||||
cog = self._make_cog()
|
||||
ctx = self._make_ctx()
|
||||
weather = self._make_weather_data(weatherDesc=[{"value": "UnknownXYZ"}])
|
||||
|
||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||
await cog.pg.callback(cog, ctx)
|
||||
|
||||
args = ctx.send.call_args[0][0]
|
||||
assert "Описание: UnknownXYZ" in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_russian_weather_description(self) -> None:
|
||||
"""Описание погоды на русском должно корректно переводиться."""
|
||||
"""Описание погоды на русском должно корректно отображаться."""
|
||||
cog = self._make_cog()
|
||||
ctx = self._make_ctx()
|
||||
weather = self._make_weather_data(
|
||||
@ -191,27 +183,58 @@ class TestPgCommand:
|
||||
assert "Описание: Переменная облачность" in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_negative_pressure(self) -> None:
|
||||
"""Отрицательное давление должно конвертироваться."""
|
||||
async def test_pg_with_wind_gust(self) -> None:
|
||||
"""Порывы ветра должны отображаться."""
|
||||
cog = self._make_cog()
|
||||
ctx = self._make_ctx()
|
||||
weather = self._make_weather_data(pressure="-50")
|
||||
weather = self._make_weather_data(
|
||||
windspeedKmph=3.0,
|
||||
wind_gust=10.5,
|
||||
wind_dir="n",
|
||||
)
|
||||
|
||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||
await cog.pg.callback(cog, ctx)
|
||||
|
||||
args = ctx.send.call_args[0][0]
|
||||
assert "Давление: -37.5 мм рт. ст." in args
|
||||
assert "Ветер: 3.0 м/с (порывы 10.5 м/с), северный" in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_high_wind(self) -> None:
|
||||
"""Большая скорость ветра должна корректно округляться."""
|
||||
async def test_pg_with_wind_direction(self) -> None:
|
||||
"""Направление ветра должно переводиться."""
|
||||
cog = self._make_cog()
|
||||
ctx = self._make_ctx()
|
||||
weather = self._make_weather_data(windspeedKmph="123")
|
||||
weather = self._make_weather_data(
|
||||
windspeedKmph=5.0,
|
||||
wind_gust=8.0,
|
||||
wind_dir="se",
|
||||
)
|
||||
|
||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||
await cog.pg.callback(cog, ctx)
|
||||
|
||||
args = ctx.send.call_args[0][0]
|
||||
assert "Ветер: 34.2 м/с" in args
|
||||
assert "юго-восточный" in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_float_values(self) -> None:
|
||||
"""Float значения из Яндекс Погоды должны работать."""
|
||||
cog = self._make_cog()
|
||||
ctx = self._make_ctx()
|
||||
weather = self._make_weather_data(
|
||||
temp_C=17.3,
|
||||
FeelsLikeC=16.8,
|
||||
humidity=87.5,
|
||||
windspeedKmph=2.1, # м/с
|
||||
wind_gust=5.5,
|
||||
wind_dir="n",
|
||||
pressure=750.1,
|
||||
)
|
||||
|
||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||
await cog.pg.callback(cog, ctx)
|
||||
|
||||
args = ctx.send.call_args[0][0]
|
||||
assert "Температура: 17.3°C" in args
|
||||
assert "Ветер: 2.1 м/с" in args
|
||||
assert "Давление: 750.1 мм рт. ст." in args
|
||||
|
||||
@ -2,124 +2,70 @@ import pytest
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||
from unittest.mock import patch, MagicMock
|
||||
from utils.pogoda import fetch_weather, fetch_open_meteo
|
||||
from utils.pogoda import fetch_weather
|
||||
|
||||
|
||||
class TestFetchWeather:
|
||||
"""Тесты функции fetch_weather() — получение погоды с retry-логикой."""
|
||||
"""Тесты функции fetch_weather() — Яндекс Погода API с retry-логикой."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_api_key(self) -> None:
|
||||
"""Мокаем _get_api_key для всех тестов в классе."""
|
||||
with patch("utils.pogoda._get_api_key", return_value="test-key"):
|
||||
yield
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_success(self, mock_get) -> None:
|
||||
"""Успешный ответ должен вернуть JSON-данные."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"current_condition": [{"temp_C": 20}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_weather("https://test.example.com")
|
||||
assert result == {"current_condition": [{"temp_C": 20}]}
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_fallback_on_ssl_error(self, mock_get) -> None:
|
||||
"""SSLError на первой попытке → fallback на Open-Meteo."""
|
||||
mock_get.side_effect = [
|
||||
SSLError("SSL Error"),
|
||||
MagicMock(json=MagicMock(return_value={"result": "fallback"})),
|
||||
]
|
||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||
mock_fallback.return_value = {"result": "fallback"}
|
||||
result = await fetch_weather("https://test.example.com")
|
||||
assert result == {"result": "fallback"}
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_fallback_on_connection_error(self, mock_get) -> None:
|
||||
"""ConnectionError → fallback на Open-Meteo."""
|
||||
mock_get.side_effect = ConnectionError("No connection")
|
||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||
mock_fallback.return_value = {"result": "fallback"}
|
||||
result = await fetch_weather("https://test.example.com")
|
||||
assert result == {"result": "fallback"}
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_fallback_on_timeout(self, mock_get) -> None:
|
||||
"""Timeout → fallback на Open-Meteo."""
|
||||
mock_get.side_effect = Timeout("Timed out")
|
||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||
mock_fallback.return_value = {"result": "fallback"}
|
||||
result = await fetch_weather("https://test.example.com")
|
||||
assert result == {"result": "fallback"}
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_all_retries_fail(self, mock_get) -> None:
|
||||
"""Все попытки не удались → fallback на Open-Meteo."""
|
||||
mock_get.side_effect = ConnectionError("No connection")
|
||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||
mock_fallback.return_value = None
|
||||
result = await fetch_weather("https://test.example.com")
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_request_exception(self, mock_get) -> None:
|
||||
"""Общий RequestException → fallback на Open-Meteo."""
|
||||
mock_get.side_effect = requests.RequestException("Generic error")
|
||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||
mock_fallback.return_value = {"result": "fallback"}
|
||||
result = await fetch_weather("https://test.example.com")
|
||||
assert result == {"result": "fallback"}
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_http_error_no_fallback(self, mock_get) -> None:
|
||||
"""HTTP-ошибка (raise_for_status) не ловится, падает."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
|
||||
mock_get.return_value = mock_response
|
||||
with pytest.raises(Exception):
|
||||
await fetch_weather("https://test.example.com")
|
||||
|
||||
|
||||
class TestFetchOpenMeteo:
|
||||
"""Тесты функции fetch_open_meteo() — fallback на Open-Meteo API."""
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_success(self, mock_get) -> None:
|
||||
"""Успешный ответ должен вернуть данные в формате current_condition."""
|
||||
"""Успешный ответ должен вернуть данные в унифицированном формате."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"current": {
|
||||
"temperature": 15,
|
||||
"apparent_temperature": 12,
|
||||
"weather_code": 3,
|
||||
"wind_speed_10m": 5.5,
|
||||
"relative_humidity_2m": 65,
|
||||
"pressure_msl": 1013,
|
||||
"fact": {
|
||||
"temp": 15,
|
||||
"feels_like": 12,
|
||||
"condition": "cloudy",
|
||||
"wind_speed": 5.5,
|
||||
"wind_gust": 8.0,
|
||||
"wind_dir": "n",
|
||||
"humidity": 65,
|
||||
"pressure_mm": 735.0,
|
||||
"pressure_pa": 980,
|
||||
}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_open_meteo()
|
||||
|
||||
result = await fetch_weather()
|
||||
|
||||
assert result is not None
|
||||
assert "current_condition" in result
|
||||
assert result["current_condition"][0]["temp_C"] == 15
|
||||
assert result["current_condition"][0]["FeelsLikeC"] == 12
|
||||
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Облачно"}]
|
||||
assert result["current_condition"][0]["humidity"] == 65
|
||||
assert result["current_condition"][0]["pressure"] == 1013
|
||||
assert result["current_condition"][0]["pressure"] == 735.0
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_custom_coords(self, mock_get) -> None:
|
||||
async def test_fetch_weather_custom_coords(self, mock_get) -> None:
|
||||
"""Кастомные координаты должны быть в URL."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"current": {
|
||||
"temperature": 25,
|
||||
"apparent_temperature": 22,
|
||||
"weather_code": 0,
|
||||
"wind_speed_10m": 3,
|
||||
"relative_humidity_2m": 50,
|
||||
"pressure_msl": 1020,
|
||||
"fact": {
|
||||
"temp": 25,
|
||||
"feels_like": 22,
|
||||
"condition": "clear",
|
||||
"wind_speed": 3,
|
||||
"wind_gust": 5,
|
||||
"wind_dir": "s",
|
||||
"humidity": 50,
|
||||
"pressure_mm": 760.0,
|
||||
"pressure_pa": 1013,
|
||||
}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_open_meteo(lat=55.7558, lon=37.6173)
|
||||
|
||||
result = await fetch_weather(lat=55.7558, lon=37.6173)
|
||||
|
||||
assert result is not None
|
||||
mock_get.assert_called_once()
|
||||
call_url = mock_get.call_args[0][0]
|
||||
@ -127,117 +73,215 @@ class TestFetchOpenMeteo:
|
||||
assert "37.6173" in call_url
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_missing_weather_code(self, mock_get) -> None:
|
||||
"""Отсутствующий weather_code → 'Неизвестно'."""
|
||||
async def test_fetch_weather_missing_condition(self, mock_get) -> None:
|
||||
"""Отсутствующий condition → 'Неизвестно'."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"current": {"temperature": 10}}
|
||||
mock_response.json.return_value = {"fact": {"temp": 10}}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_open_meteo()
|
||||
|
||||
result = await fetch_weather()
|
||||
|
||||
assert result is not None
|
||||
assert result["current_condition"][0]["weatherDesc"] == [
|
||||
{"value": "Неизвестно"}
|
||||
]
|
||||
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None:
|
||||
"""SSLError → вернуть None."""
|
||||
mock_get.side_effect = SSLError("SSL Error")
|
||||
with patch("utils.pogoda.fetch_open_meteo"):
|
||||
# Внутренний fallback тоже падает, проверяем что возвращается None
|
||||
pass
|
||||
result = await fetch_open_meteo()
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_connection_error(self, mock_get) -> None:
|
||||
"""ConnectionError → вернуть None."""
|
||||
mock_get.side_effect = ConnectionError("No connection")
|
||||
result = await fetch_open_meteo()
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_timeout(self, mock_get) -> None:
|
||||
"""Timeout → вернуть None."""
|
||||
mock_get.side_effect = Timeout("Timed out")
|
||||
result = await fetch_open_meteo()
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_request_exception(self, mock_get) -> None:
|
||||
"""Общий RequestException → вернуть None."""
|
||||
mock_get.side_effect = requests.RequestException("Error")
|
||||
result = await fetch_open_meteo()
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_json_parse_error(self, mock_get) -> None:
|
||||
"""Ошибка парсинга JSON → вернуть None."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.side_effect = requests.RequestException("JSON Error")
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_open_meteo()
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None:
|
||||
"""Retry: первая попытка падает, вторая успешна."""
|
||||
async def test_fetch_weather_ssl_error_retry(self, mock_get) -> None:
|
||||
"""SSLError на первой попытке → retry → успех."""
|
||||
success_response = MagicMock()
|
||||
success_response.json.return_value = {
|
||||
"current": {
|
||||
"temperature": 20,
|
||||
"apparent_temperature": 18,
|
||||
"weather_code": 1,
|
||||
"wind_speed_10m": 4,
|
||||
"relative_humidity_2m": 60,
|
||||
"pressure_msl": 1015,
|
||||
"fact": {
|
||||
"temp": 20,
|
||||
"feels_like": 18,
|
||||
"condition": "partly_cloudy",
|
||||
"wind_speed": 4,
|
||||
"wind_gust": 6,
|
||||
"wind_dir": "w",
|
||||
"humidity": 60,
|
||||
"pressure_mm": 740.0,
|
||||
"pressure_pa": 987,
|
||||
}
|
||||
}
|
||||
success_response.raise_for_status = MagicMock()
|
||||
mock_get.side_effect = [ConnectionError("fail"), success_response]
|
||||
result = await fetch_open_meteo(max_retries=2)
|
||||
mock_get.side_effect = [SSLError("SSL Error"), success_response]
|
||||
|
||||
result = await fetch_weather(max_retries=2)
|
||||
|
||||
assert result is not None
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> None:
|
||||
"""Все попытки неудачны → None."""
|
||||
async def test_fetch_weather_connection_error_retry(self, mock_get) -> None:
|
||||
"""ConnectionError → retry."""
|
||||
success_response = MagicMock()
|
||||
success_response.json.return_value = {
|
||||
"fact": {
|
||||
"temp": 20,
|
||||
"feels_like": 18,
|
||||
"condition": "partly_cloudy",
|
||||
"wind_speed": 4,
|
||||
"wind_gust": 6,
|
||||
"wind_dir": "w",
|
||||
"humidity": 60,
|
||||
"pressure_mm": 740.0,
|
||||
"pressure_pa": 987,
|
||||
}
|
||||
}
|
||||
success_response.raise_for_status = MagicMock()
|
||||
mock_get.side_effect = [ConnectionError("No connection"), success_response]
|
||||
|
||||
result = await fetch_weather(max_retries=2)
|
||||
|
||||
assert result is not None
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_timeout_retry(self, mock_get) -> None:
|
||||
"""Timeout → retry."""
|
||||
success_response = MagicMock()
|
||||
success_response.json.return_value = {
|
||||
"fact": {
|
||||
"temp": 20,
|
||||
"feels_like": 18,
|
||||
"condition": "partly_cloudy",
|
||||
"wind_speed": 4,
|
||||
"wind_gust": 6,
|
||||
"wind_dir": "w",
|
||||
"humidity": 60,
|
||||
"pressure_mm": 740.0,
|
||||
"pressure_pa": 987,
|
||||
}
|
||||
}
|
||||
success_response.raise_for_status = MagicMock()
|
||||
mock_get.side_effect = [Timeout("Timed out"), success_response]
|
||||
|
||||
result = await fetch_weather(max_retries=2)
|
||||
|
||||
assert result is not None
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_all_retries_fail(self, mock_get) -> None:
|
||||
"""Все попытки не удались → None."""
|
||||
mock_get.side_effect = [
|
||||
ConnectionError("fail"),
|
||||
ConnectionError("fail"),
|
||||
ConnectionError("fail"),
|
||||
]
|
||||
result = await fetch_open_meteo(max_retries=3)
|
||||
|
||||
result = await fetch_weather(max_retries=3)
|
||||
|
||||
assert result is None
|
||||
assert mock_get.call_count == 3
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_http_error(self, mock_get) -> None:
|
||||
async def test_fetch_weather_request_exception(self, mock_get) -> None:
|
||||
"""Общий RequestException → None."""
|
||||
mock_get.side_effect = requests.RequestException("Generic error")
|
||||
|
||||
result = await fetch_weather(max_retries=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_http_error(self, mock_get) -> None:
|
||||
"""HTTP 404 → raise_for_status бросит исключение → None."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_open_meteo()
|
||||
|
||||
result = await fetch_weather(max_retries=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_wind_speed_0(self, mock_get) -> None:
|
||||
async def test_fetch_weather_json_parse_error(self, mock_get) -> None:
|
||||
"""Ошибка парсинга JSON → None."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.side_effect = requests.RequestException("JSON Error")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = await fetch_weather(max_retries=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_wind_speed_0(self, mock_get) -> None:
|
||||
"""Нулевая скорость ветра должна корректно обрабатываться."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"current": {
|
||||
"temperature": 0,
|
||||
"apparent_temperature": -2,
|
||||
"weather_code": 45,
|
||||
"wind_speed_10m": 0,
|
||||
"relative_humidity_2m": 95,
|
||||
"pressure_msl": 1000,
|
||||
"fact": {
|
||||
"temp": 0,
|
||||
"feels_like": -2,
|
||||
"condition": "fog",
|
||||
"wind_speed": 0,
|
||||
"wind_gust": 0,
|
||||
"wind_dir": "n",
|
||||
"humidity": 95,
|
||||
"pressure_mm": 750.0,
|
||||
"pressure_pa": 1000,
|
||||
}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_open_meteo()
|
||||
|
||||
result = await fetch_weather()
|
||||
|
||||
assert result is not None
|
||||
assert result["current_condition"][0]["windspeedKmph"] == 0
|
||||
assert result["current_condition"][0]["pressure"] == 1000
|
||||
assert result["current_condition"][0]["pressure"] == 750.0
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_negative_temp(self, mock_get) -> None:
|
||||
"""Отрицательная температура."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"fact": {
|
||||
"temp": -15,
|
||||
"feels_like": -22,
|
||||
"condition": "heavy_snow",
|
||||
"wind_speed": 8,
|
||||
"wind_gust": 12,
|
||||
"wind_dir": "n",
|
||||
"humidity": 90,
|
||||
"pressure_mm": 720.0,
|
||||
"pressure_pa": 960,
|
||||
}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = await fetch_weather()
|
||||
|
||||
assert result is not None
|
||||
assert result["current_condition"][0]["temp_C"] == -15
|
||||
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Сильный снег"}]
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_weather_includes_headers(self, mock_get) -> None:
|
||||
"""Запрос должен содержать X-Yandex-API-Key заголовок."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"fact": {
|
||||
"temp": 20,
|
||||
"feels_like": 18,
|
||||
"condition": "clear",
|
||||
"wind_speed": 3,
|
||||
"wind_gust": 5,
|
||||
"wind_dir": "s",
|
||||
"humidity": 50,
|
||||
"pressure_mm": 760.0,
|
||||
"pressure_pa": 1013,
|
||||
}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with patch("utils.pogoda._get_api_key", return_value="test-key"):
|
||||
await fetch_weather()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
assert "X-Yandex-API-Key" in call_kwargs["headers"]
|
||||
assert call_kwargs["headers"]["X-Yandex-API-Key"] == "test-key"
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import pytest
|
||||
from utils.pogoda import (
|
||||
translate_weather,
|
||||
pressure_to_mmhg,
|
||||
wmo_to_russian,
|
||||
yandex_condition_to_russian,
|
||||
format_weather_data_for_console,
|
||||
get_weather_description,
|
||||
)
|
||||
|
||||
|
||||
@ -15,12 +16,14 @@ class TestFormatWeatherDataForConsole:
|
||||
data = {
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "25",
|
||||
"FeelsLikeC": "26",
|
||||
"weatherDesc": [{"value": "Clear"}],
|
||||
"humidity": "45",
|
||||
"windspeedKmph": "10",
|
||||
"pressure": "1013",
|
||||
"temp_C": 25,
|
||||
"FeelsLikeC": 26,
|
||||
"weatherDesc": [{"value": "Ясно"}],
|
||||
"humidity": 45,
|
||||
"windspeedKmph": 5.0, # м/с от Яндекса
|
||||
"wind_gust": 8.0,
|
||||
"wind_dir": "n",
|
||||
"pressure": 735.0, # уже в мм рт. ст.
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -32,8 +35,8 @@ class TestFormatWeatherDataForConsole:
|
||||
assert "Температура: 25°C (ощущается как 26°C)" in result[0]
|
||||
assert "Описание: Ясно" in result[1]
|
||||
assert "Влажность: 45%" in result[2]
|
||||
assert "Ветер: 2.8 м/с" in result[3] # 10 / 3.6 = 2.777... ≈ 2.8
|
||||
assert "Давление: 759.8 мм рт. ст." in result[4]
|
||||
assert "Ветер: 5.0 м/с (порывы 8.0 м/с), северный" in result[3]
|
||||
assert "Давление: 735.0 мм рт. ст." in result[4]
|
||||
|
||||
def test_format_empty_data(self) -> None:
|
||||
"""Пустые данные должны возвращать None."""
|
||||
@ -52,16 +55,16 @@ class TestFormatWeatherDataForConsole:
|
||||
assert result is None, "Отсутствие current_condition должно вернуть None"
|
||||
|
||||
def test_format_with_dashes(self) -> None:
|
||||
"""Неизвестные значения должны отображаться как '—'."""
|
||||
"""None значения должны отображаться как '—'."""
|
||||
data = {
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "—",
|
||||
"FeelsLikeC": "—",
|
||||
"weatherDesc": [{"value": "—"}],
|
||||
"humidity": "—",
|
||||
"windspeedKmph": "—",
|
||||
"pressure": "—",
|
||||
"temp_C": None,
|
||||
"FeelsLikeC": None,
|
||||
"weatherDesc": [{"value": "Неизвестно"}],
|
||||
"humidity": None,
|
||||
"windspeedKmph": None,
|
||||
"pressure": None,
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -70,41 +73,24 @@ class TestFormatWeatherDataForConsole:
|
||||
|
||||
assert isinstance(result, list), "Результат должен быть списком строк"
|
||||
assert "Температура: —°C (ощущается как —°C)" in result[0]
|
||||
assert "Описание: —" in result[1]
|
||||
assert "Описание: Неизвестно" in result[1]
|
||||
assert "Влажность: —%" in result[2]
|
||||
assert "Ветер: — м/с" in result[3]
|
||||
assert "Давление: — мм рт. ст." in result[4]
|
||||
|
||||
def test_format_wind_conversion(self) -> None:
|
||||
"""Проверка конвертации ветра из км/ч в м/с."""
|
||||
data = {
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "20",
|
||||
"FeelsLikeC": "19",
|
||||
"weatherDesc": [{"value": "Cloudy"}],
|
||||
"humidity": "60",
|
||||
"windspeedKmph": "36",
|
||||
"pressure": "1000",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = format_weather_data_for_console(data)
|
||||
# 36 / 3.6 = 10.0
|
||||
assert "Ветер: 10.0 м/с" in result[3]
|
||||
|
||||
def test_format_negative_temperature(self) -> None:
|
||||
"""Отрицательная температура должна отображаться корректно."""
|
||||
data = {
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "-5",
|
||||
"FeelsLikeC": "-10",
|
||||
"weatherDesc": [{"value": "Snow"}],
|
||||
"humidity": "80",
|
||||
"windspeedKmph": "20",
|
||||
"pressure": "980",
|
||||
"temp_C": -5,
|
||||
"FeelsLikeC": -10,
|
||||
"weatherDesc": [{"value": "Снег"}],
|
||||
"humidity": 80,
|
||||
"windspeedKmph": 5,
|
||||
"wind_gust": 10,
|
||||
"wind_dir": "n",
|
||||
"pressure": 720.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -114,88 +100,54 @@ class TestFormatWeatherDataForConsole:
|
||||
assert isinstance(result, list), "Результат должен быть списком строк"
|
||||
assert "Температура: -5°C (ощущается как -10°C)" in result[0]
|
||||
|
||||
def test_format_without_gust_and_dir(self) -> None:
|
||||
"""Без порывов и направления ветра — базовый формат."""
|
||||
data = {
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": 17.3,
|
||||
"FeelsLikeC": 16.8,
|
||||
"weatherDesc": [{"value": "Облачно"}],
|
||||
"humidity": 87.5,
|
||||
"windspeedKmph": 2.1, # м/с
|
||||
"pressure": 750.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
class TestTranslateWeather:
|
||||
@pytest.mark.parametrize(
|
||||
"english, expected",
|
||||
[
|
||||
("Clear", "Ясно"),
|
||||
("Sunny", "Ясно"),
|
||||
("Partly cloudy", "Переменная облачность"),
|
||||
("Cloudy", "Облачно"),
|
||||
("Overcast", "Пасмурно"),
|
||||
("Fog", "Туман"),
|
||||
("Foggy", "Туманно"),
|
||||
("Mist", "Туман"),
|
||||
("Haze", "Дымка"),
|
||||
("Light rain", "Небольшой дождь"),
|
||||
("Moderate rain", "Умеренный дождь"),
|
||||
("Heavy rain", "Сильный дождь"),
|
||||
(
|
||||
"Moderate or heavy rain at times",
|
||||
"Дождь",
|
||||
), # длинный ключ проверяется первым
|
||||
("Heavy rain at times", "Сильный дождь"),
|
||||
("Light snow", "Небольшой снег"),
|
||||
("Moderate snow", "Умеренный снег"),
|
||||
("Heavy snow", "Сильный снег"),
|
||||
("Blowing snow", "Метель"),
|
||||
("Light freezing rain", "Лёгкий ледяной дождь"),
|
||||
("Heavy freezing rain", "Сильный ледяной дождь"),
|
||||
("Moderate or heavy freezing rain", "Сильный ледяной дождь"),
|
||||
("Light sleet", "Light sleet"),
|
||||
("Moderate or heavy sleet", "Moderate or heavy sleet"),
|
||||
("Thundery outbreaks in nearby", "Гроза вблизи"),
|
||||
("Patchy rain nearby", "Местами дождь"),
|
||||
("Patchy snow nearby", "Местами снег"),
|
||||
("Patchy sleet nearby", "Местами слякоть"),
|
||||
("Patchy light drizzle", "Местами лёгкая морось"),
|
||||
("Moderate or heavy snow in area", "Снег"),
|
||||
("Moderate or heavy rain in area", "Дождь"),
|
||||
],
|
||||
)
|
||||
def test_translate_known(self, english, expected) -> None:
|
||||
"""Известные переводы должны возвращать ожидаемый результат."""
|
||||
assert translate_weather(english) == expected
|
||||
result = format_weather_data_for_console(data)
|
||||
assert "Температура: 17.3°C" in result[0]
|
||||
assert "Ветер: 2.1 м/с" in result[3]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_value, expected",
|
||||
[
|
||||
("", "—"),
|
||||
(None, "—"),
|
||||
(" ", "—"), # строка из пробелов тоже считается пустой
|
||||
],
|
||||
)
|
||||
def test_translate_empty(self, input_value, expected) -> None:
|
||||
"""Пустой, None или только пробелы должен возвращать '—'."""
|
||||
assert translate_weather(input_value) == expected
|
||||
def test_format_all_wind_directions(self) -> None:
|
||||
"""Все направления ветра должны переводиться корректно."""
|
||||
directions = [
|
||||
("n", "северный"),
|
||||
("ne", "северо-восточный"),
|
||||
("e", "восточный"),
|
||||
("se", "юго-восточный"),
|
||||
("s", "южный"),
|
||||
("sw", "юго-западный"),
|
||||
("w", "западный"),
|
||||
("nw", "северо-западный"),
|
||||
]
|
||||
|
||||
def test_translate_unknown_returns_original(self) -> None:
|
||||
"""Неизвестный перевод должен возвращать оригинальный текст."""
|
||||
unknown_text = "Unknown weather condition XYZ"
|
||||
assert translate_weather(unknown_text) == unknown_text
|
||||
|
||||
def test_translate_partial_match(self) -> None:
|
||||
"""Частичное совпадение ключа в тексте должно сработать."""
|
||||
# "Moderate or heavy rain in area" должно найтись в "Light Moderate or heavy rain in area"
|
||||
text_with_prefix = "Light Moderate or heavy rain in area"
|
||||
assert translate_weather(text_with_prefix) == "Дождь"
|
||||
|
||||
def test_translate_longer_key_priority(self) -> None:
|
||||
"""Длинные ключи проверяются первыми (_WEATHER_MAPPING отсортирован по убыванию длины).
|
||||
"Moderate or heavy rain at times" проверится до "Heavy rain"."""
|
||||
text = "Moderate or heavy rain at times"
|
||||
assert translate_weather(text) == "Дождь"
|
||||
|
||||
def test_translate_case_insensitive(self) -> None:
|
||||
"""Перевод должен быть регистронезависимым."""
|
||||
assert translate_weather("CLEAR") == "Ясно"
|
||||
assert translate_weather("partly cloudy") == "Переменная облачность"
|
||||
assert translate_weather("HEAVY RAIN") == "Сильный дождь"
|
||||
|
||||
def test_translate_with_whitespace(self) -> None:
|
||||
"""Текст с пробелами по краям должен корректно переводиться."""
|
||||
assert translate_weather(" Clear ") == "Ясно"
|
||||
for code, expected in directions:
|
||||
data = {
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": 20,
|
||||
"FeelsLikeC": 18,
|
||||
"weatherDesc": [{"value": "Ясно"}],
|
||||
"humidity": 50,
|
||||
"windspeedKmph": 3.0,
|
||||
"wind_dir": code,
|
||||
"pressure": 750.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
result = format_weather_data_for_console(data)
|
||||
assert expected in result[3], f"Направление {code} → {expected} не найдено"
|
||||
|
||||
|
||||
class TestPressureToMMHG:
|
||||
@ -260,17 +212,20 @@ class TestPressureToMMHG:
|
||||
|
||||
|
||||
class TestWmoToRussian:
|
||||
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды."""
|
||||
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды.
|
||||
|
||||
Оставлен для обратной совместимости.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code, expected",
|
||||
[
|
||||
(0, "Ясно"),
|
||||
(1, "Ясно"),
|
||||
(1, "Преимущественно ясно"),
|
||||
(2, "Переменная облачность"),
|
||||
(3, "Пасмурно"),
|
||||
(45, "Туман"),
|
||||
(48, "Туман"),
|
||||
(48, "Изморозь"),
|
||||
(51, "Лёгкая морось"),
|
||||
(53, "Морось"),
|
||||
(55, "Сильная морось"),
|
||||
@ -318,3 +273,68 @@ class TestWmoToRussian:
|
||||
def test_wmo_float_code(self) -> None:
|
||||
"""Дробный код — не найдётся в mapping."""
|
||||
assert wmo_to_russian(1.5) == "Неизвестно"
|
||||
|
||||
|
||||
class TestYandexConditionToRussian:
|
||||
"""Тесты функции yandex_condition_to_russian() — перевод Яндекс condition."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition, expected",
|
||||
[
|
||||
("clear", "Ясно"),
|
||||
("partly_cloudy", "Переменная облачность"),
|
||||
("cloudy", "Облачно"),
|
||||
("overcast", "Пасмурно"),
|
||||
("light_rain", "Небольшой дождь"),
|
||||
("rain", "Дождь"),
|
||||
("heavy_rain", "Сильный дождь"),
|
||||
("drizzle", "Морось"),
|
||||
("heavy_showers", "Сильные осадки"),
|
||||
("thunderstorm", "Гроза"),
|
||||
("thunderstorm_with_rain", "Гроза с дождём"),
|
||||
("thunderstorm_with_heavy_rain", "Сильная гроза с дождём"),
|
||||
("thunderstorm_with_hail", "Гроза с градом"),
|
||||
("snow_showers", "Снежные осадки"),
|
||||
("light_snow", "Небольшой снег"),
|
||||
("snow", "Снег"),
|
||||
("heavy_snow", "Сильный снег"),
|
||||
("snowstorm", "Метель"),
|
||||
("blizzard", "Буран"),
|
||||
("fog", "Туман"),
|
||||
],
|
||||
)
|
||||
def test_yandex_known(self, condition, expected) -> None:
|
||||
"""Известные Яндекс condition должны возвращать ожидаемый перевод."""
|
||||
assert yandex_condition_to_russian(condition) == expected
|
||||
|
||||
def test_yandex_unknown(self) -> None:
|
||||
"""Неизвестный condition должен возвращать 'Неизвестно'."""
|
||||
assert yandex_condition_to_russian("unknown_condition") == "Неизвестно"
|
||||
|
||||
def test_yandex_none(self) -> None:
|
||||
"""None должен возвращать 'Неизвестно'."""
|
||||
assert yandex_condition_to_russian(None) == "Неизвестно"
|
||||
|
||||
|
||||
class TestGetWeatherDescription:
|
||||
"""Тесты функции get_weather_description()."""
|
||||
|
||||
def test_valid_description(self) -> None:
|
||||
"""Нормальный weatherDesc -> значение."""
|
||||
current = {"weatherDesc": [{"value": "Ясно"}]}
|
||||
assert get_weather_description(current) == "Ясно"
|
||||
|
||||
def test_empty_current(self) -> None:
|
||||
"""Пустой current -> '—'."""
|
||||
current = {}
|
||||
assert get_weather_description(current) == "—"
|
||||
|
||||
def test_weather_desc_none(self) -> None:
|
||||
"""weatherDesc с None -> '—'."""
|
||||
current = {"weatherDesc": None}
|
||||
assert get_weather_description(current) == "—"
|
||||
|
||||
def test_empty_value(self) -> None:
|
||||
"""Пустая строка в value -> '—'."""
|
||||
current = {"weatherDesc": [{"value": ""}]}
|
||||
assert get_weather_description(current) == "—"
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
from .pogoda import (
|
||||
API_URL_WEATHER,
|
||||
_session as _weather_session,
|
||||
fetch_weather,
|
||||
fetch_open_meteo,
|
||||
format_weather_data_for_console,
|
||||
format_weather_for_message,
|
||||
pressure_to_mmhg,
|
||||
translate_weather,
|
||||
wmo_to_russian,
|
||||
yandex_condition_to_russian,
|
||||
)
|
||||
from .news import ( # noqa: E402
|
||||
_session as _news_session,
|
||||
@ -24,14 +22,12 @@ from .cat import ( # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
# Погода
|
||||
"API_URL_WEATHER",
|
||||
"fetch_weather",
|
||||
"fetch_open_meteo",
|
||||
"format_weather_data_for_console",
|
||||
"format_weather_for_message",
|
||||
"pressure_to_mmhg",
|
||||
"translate_weather",
|
||||
"wmo_to_russian",
|
||||
"yandex_condition_to_russian",
|
||||
# Новости
|
||||
"RSS_URL_ARTICLES",
|
||||
"RSS_URL_POSTS",
|
||||
@ -56,4 +52,3 @@ def close_all_sessions() -> None:
|
||||
session.close()
|
||||
except Exception:
|
||||
pass # Cleanup — игнорируем ошибки
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@ import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from utils.pogoda import (
|
||||
API_URL_WEATHER,
|
||||
fetch_weather,
|
||||
format_weather_for_message,
|
||||
)
|
||||
@ -40,7 +39,7 @@ class MorningData:
|
||||
async def gather_morning() -> MorningData:
|
||||
"""Собрать все данные для утреннего дайджеста параллельно."""
|
||||
weather_data, articles, posts, cat_url = await asyncio.gather(
|
||||
fetch_weather(API_URL_WEATHER),
|
||||
fetch_weather(),
|
||||
fetch_rss(RSS_URL_ARTICLES),
|
||||
fetch_rss(RSS_URL_POSTS),
|
||||
fetch_cat(),
|
||||
|
||||
397
utils/pogoda.py
397
utils/pogoda.py
@ -1,75 +1,76 @@
|
||||
"""Погода через Яндекс Погоду API.
|
||||
|
||||
Яндекс Погода: актуальные данные для городов России и мира,
|
||||
текущие условия + прогноз. Требуется API-ключ в YANDEX_WEATHER_API_KEY.
|
||||
|
||||
https://yandex.ru/dev/weather/
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||
|
||||
from utils.rate_limiter import weather_limiter, open_meteo_limiter
|
||||
from utils.rate_limiter import yandex_weather_limiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
||||
# Координаты Магнитогорска
|
||||
_LATITUDE: float = 53.40716
|
||||
_LONGITUDE: float = 58.980289
|
||||
|
||||
_session = requests.Session()
|
||||
|
||||
|
||||
def _get_api_key() -> str:
|
||||
"""Получить API-ключ Яндекс Погоды из переменных окружения."""
|
||||
key = os.getenv("YANDEX_WEATHER_API_KEY")
|
||||
if not key:
|
||||
raise EnvironmentError(
|
||||
"YANDEX_WEATHER_API_KEY не найден в переменных окружения"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
async def fetch_weather(
|
||||
api_url: str, timeout: int = 10, max_retries: int = 3
|
||||
lat: float = _LATITUDE,
|
||||
lon: float = _LONGITUDE,
|
||||
timeout: int = 10,
|
||||
max_retries: int = 3,
|
||||
) -> Optional[dict]:
|
||||
"""Получить данные о погоде с retry."""
|
||||
await weather_limiter.acquire()
|
||||
"""Получить текущую погоду через Яндекс Погоду API.
|
||||
|
||||
Возвращает dict в унифицированном формате для форматирования:
|
||||
{"current_condition": [{"temp_C": ..., "weatherDesc": ..., ...}]}
|
||||
"""
|
||||
await yandex_weather_limiter.acquire()
|
||||
url = f"https://api.weather.yandex.ru/v1/informers?lat={lat}&lon={lon}"
|
||||
headers = {"X-Yandex-API-Key": _get_api_key()}
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = await asyncio.to_thread(_session.get, api_url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (SSLError, ConnectionError, Timeout):
|
||||
if attempt < max_retries - 1:
|
||||
delay = 2**attempt
|
||||
logger.warning(
|
||||
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
break
|
||||
except requests.RequestException as e:
|
||||
logger.error("Ошибка при получении данных: %s", e)
|
||||
break
|
||||
|
||||
logger.warning("Все попытки wttr.in не удались, переход на Open-Meteo")
|
||||
return await fetch_open_meteo()
|
||||
|
||||
|
||||
async def fetch_open_meteo(
|
||||
lat: float = 53.4069, lon: float = 58.9797, timeout: int = 10, max_retries: int = 3
|
||||
) -> Optional[dict]:
|
||||
"""Fallback на Open-Meteo API."""
|
||||
await open_meteo_limiter.acquire()
|
||||
url = (
|
||||
f"https://api.open-meteo.com/v1/forecast?"
|
||||
f"latitude={lat}&longitude={lon}¤t=temperature,"
|
||||
f"apparent_temperature,weather_code,wind_speed_10m,"
|
||||
f"relative_humidity_2m,pressure_msl&timezone=Asia/Chelyabinsk"
|
||||
)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = await asyncio.to_thread(_session.get, url, timeout=timeout)
|
||||
response = await asyncio.to_thread(
|
||||
_session.get, url, headers=headers, timeout=timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
current = data.get("current", {})
|
||||
# weather_code WMO код -> перевод (https://open-meteo.com/en/docs)
|
||||
weather_code = current.get("weather_code", None)
|
||||
desc = wmo_to_russian(weather_code)
|
||||
fact = data.get("fact", {})
|
||||
|
||||
return {
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": current.get("temperature", "—"),
|
||||
"FeelsLikeC": current.get("apparent_temperature", "—"),
|
||||
"weatherDesc": [{"value": desc}],
|
||||
"humidity": current.get("relative_humidity_2m", "—"),
|
||||
"windspeedKmph": current.get("wind_speed_10m", "—"),
|
||||
"pressure": current.get("pressure_msl", "—"),
|
||||
"temp_C": fact.get("temp"),
|
||||
"FeelsLikeC": fact.get("feels_like"),
|
||||
"weatherDesc": [{"value": yandex_condition_to_russian(fact.get("condition"))}],
|
||||
"humidity": fact.get("humidity"),
|
||||
"windspeedKmph": fact.get("wind_speed"),
|
||||
"wind_gust": fact.get("wind_gust"),
|
||||
"wind_dir": fact.get("wind_dir"),
|
||||
"pressure": fact.get("pressure_mm"),
|
||||
"pressure_pa": fact.get("pressure_pa"),
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -83,20 +84,171 @@ async def fetch_open_meteo(
|
||||
continue
|
||||
break
|
||||
except requests.RequestException as e:
|
||||
logger.error("Ошибка при получении данных: %s", e)
|
||||
return None
|
||||
logger.error("Ошибка Яндекс Погоды: %s", e)
|
||||
break
|
||||
|
||||
logger.warning("Все попытки Open-Meteo не удались")
|
||||
logger.warning("Все попытки Яндекс Погоды не удались")
|
||||
return None
|
||||
|
||||
|
||||
# Яндекс condition → русский перевод
|
||||
_YANDEX_CONDITION_MAPPING: dict[str, str] = {
|
||||
"clear": "Ясно",
|
||||
"partly_cloudy": "Переменная облачность",
|
||||
"cloudy": "Облачно",
|
||||
"overcast": "Пасмурно",
|
||||
"light_rain": "Небольшой дождь",
|
||||
"rain": "Дождь",
|
||||
"heavy_rain": "Сильный дождь",
|
||||
"drizzle": "Морось",
|
||||
"heavy_showers": "Сильные осадки",
|
||||
"thunderstorm": "Гроза",
|
||||
"thunderstorm_with_rain": "Гроза с дождём",
|
||||
"thunderstorm_with_heavy_rain": "Сильная гроза с дождём",
|
||||
"thunderstorm_with_hail": "Гроза с градом",
|
||||
"snow_showers": "Снежные осадки",
|
||||
"light_snow": "Небольшой снег",
|
||||
"snow": "Снег",
|
||||
"heavy_snow": "Сильный снег",
|
||||
"snowstorm": "Метель",
|
||||
"blizzard": "Буран",
|
||||
"fog": "Туман",
|
||||
}
|
||||
|
||||
|
||||
def yandex_condition_to_russian(condition: Optional[str]) -> str:
|
||||
"""Перевод Яндекс condition в русский."""
|
||||
if condition is None:
|
||||
return "Неизвестно"
|
||||
return _YANDEX_CONDITION_MAPPING.get(condition, "Неизвестно")
|
||||
|
||||
|
||||
def get_weather_description(current: dict) -> str:
|
||||
"""Извлечь описание погоды из weatherDesc."""
|
||||
return (current.get("weatherDesc") or [{}])[0].get("value") or "—"
|
||||
|
||||
|
||||
def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]:
|
||||
"""
|
||||
Форматировать погодные данные для консольного вывода.
|
||||
|
||||
:param data: Ответ от API (dict)
|
||||
:return: Строки с отформатированной погодой
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
current_condition_list = data.get("current_condition", [])
|
||||
if not current_condition_list or not current_condition_list[0]:
|
||||
return None
|
||||
current = current_condition_list[0]
|
||||
|
||||
temp = current.get("temp_C")
|
||||
if temp is None:
|
||||
temp = "—"
|
||||
feels_like = current.get("FeelsLikeC")
|
||||
if feels_like is None:
|
||||
feels_like = "—"
|
||||
description = get_weather_description(current)
|
||||
humidity = current.get("humidity")
|
||||
if humidity is None:
|
||||
humidity = "—"
|
||||
|
||||
# Яндекс возвращает ветер в м/с, но для совместимости с унифицированным
|
||||
# форматом WindspeedKmph содержит м/с — конвертируем обратно в м/с для вывода
|
||||
wind_mps = current.get("windspeedKmph")
|
||||
if wind_mps is None:
|
||||
wind = "—"
|
||||
else:
|
||||
try:
|
||||
# windspeedKmph на самом деле хранит м/с от Яндекса
|
||||
wind = round(float(wind_mps), 1)
|
||||
except (ValueError, TypeError):
|
||||
wind = "—"
|
||||
|
||||
# Порывы ветра (м/с)
|
||||
wind_gust = current.get("wind_gust")
|
||||
if wind_gust is not None:
|
||||
try:
|
||||
wind = f"{wind} м/с (порывы {round(float(wind_gust), 1)} м/с)"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Направление ветра
|
||||
wind_dir = current.get("wind_dir")
|
||||
if wind_dir is not None:
|
||||
wind_dir_ru = _wind_dir_to_russian(wind_dir)
|
||||
if wind != "—":
|
||||
wind = f"{wind}, {wind_dir_ru}"
|
||||
|
||||
# Давление — Яндекс уже возвращает в мм рт. ст.
|
||||
pressure_mm = current.get("pressure")
|
||||
if pressure_mm is None:
|
||||
pressure_mm = "—"
|
||||
elif pressure_mm != "—":
|
||||
try:
|
||||
pressure_mm = round(float(pressure_mm), 1)
|
||||
except (ValueError, TypeError):
|
||||
pressure_mm = "—"
|
||||
|
||||
lines = [
|
||||
f"Температура: {temp}°C (ощущается как {feels_like}°C)",
|
||||
f"Описание: {description}",
|
||||
f"Влажность: {humidity}%",
|
||||
f"Ветер: {wind} м/с" if not wind_dir or wind_dir in ("—", None) else f"Ветер: {wind}",
|
||||
f"Давление: {pressure_mm} мм рт. ст.",
|
||||
]
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _wind_dir_to_russian(direction: str) -> str:
|
||||
"""Перевод направления ветра из кода в русский."""
|
||||
mapping = {
|
||||
"n": "северный",
|
||||
"ne": "северо-восточный",
|
||||
"e": "восточный",
|
||||
"se": "юго-восточный",
|
||||
"s": "южный",
|
||||
"sw": "юго-западный",
|
||||
"w": "западный",
|
||||
"nw": "северо-западный",
|
||||
}
|
||||
return mapping.get(direction, direction)
|
||||
|
||||
|
||||
def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
|
||||
"""Форматировать погоду для plain text сообщения (с заголовком)."""
|
||||
if data is None:
|
||||
return None
|
||||
lines = format_weather_data_for_console(data)
|
||||
if not lines:
|
||||
return None
|
||||
return "Погода в Магнитогорске:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def pressure_to_mmhg(mb: float | int | str | None) -> float | str:
|
||||
"""Конвертировать давление из гПа/мб в мм рт. ст.
|
||||
|
||||
Для совместимости с существующими тестами.
|
||||
Яндекс Погода уже возвращает давление в мм рт. ст.,
|
||||
но функция оставлена для обратной совместимости.
|
||||
"""
|
||||
if mb == "—" or mb is None or mb == "":
|
||||
return "—"
|
||||
try:
|
||||
return round(float(mb) * 0.750062, 1)
|
||||
except (ValueError, TypeError):
|
||||
return "—"
|
||||
|
||||
|
||||
# WMO mapping оставлен для обратной совместимости (используется в тестах)
|
||||
_WMO_MAPPING: dict[int, str] = {
|
||||
0: "Ясно",
|
||||
1: "Ясно",
|
||||
1: "Преимущественно ясно",
|
||||
2: "Переменная облачность",
|
||||
3: "Пасмурно",
|
||||
45: "Туман",
|
||||
48: "Туман",
|
||||
48: "Изморозь",
|
||||
51: "Лёгкая морось",
|
||||
53: "Морось",
|
||||
55: "Сильная морось",
|
||||
@ -123,137 +275,8 @@ _WMO_MAPPING: dict[int, str] = {
|
||||
|
||||
|
||||
def wmo_to_russian(code: Optional[int]) -> str:
|
||||
"""Перевод WMO weather code в русский."""
|
||||
"""Перевод WMO weather code в русский.
|
||||
|
||||
Оставлен для обратной совместимости с тестами.
|
||||
"""
|
||||
return _WMO_MAPPING.get(code, "Неизвестно")
|
||||
|
||||
|
||||
_WEATHER_MAPPING = [
|
||||
# Отсортировано по убыванию длины ключа для корректного substring-matching:
|
||||
# более длинные фразы проверяются первыми, чтобы "Light rain" не совпал
|
||||
# раньше "Light rain shower".
|
||||
("Moderate or heavy freezing rain at a distance", "Ледяной дождь"),
|
||||
("Moderate or heavy freezing rain in area", "Ледяной дождь"),
|
||||
("Moderate or heavy sleet at a distance", "Слякоть"),
|
||||
("Moderate or heavy sleet in area", "Слякоть"),
|
||||
("Moderate or heavy rain at times", "Дождь"),
|
||||
("Moderate or heavy snow at times", "Снег"),
|
||||
("Moderate or heavy snow in area", "Снег"),
|
||||
("Moderate or heavy rain in area", "Дождь"),
|
||||
("Thundery outbreaks in nearby", "Гроза вблизи"),
|
||||
("Moderate or light sleet", "Слякоть"),
|
||||
("Moderate rain at times", "Умеренный дождь"),
|
||||
("Patchy light drizzle", "Местами лёгкая морось"),
|
||||
("Heavy freezing rain", "Сильный ледяной дождь"),
|
||||
("Light freezing rain", "Лёгкий ледяной дождь"),
|
||||
("Patchy sleet nearby", "Местами слякоть"),
|
||||
("Heavy rain at times", "Сильный дождь"),
|
||||
("Patchy rain nearby", "Местами дождь"),
|
||||
("Patchy snow nearby", "Местами снег"),
|
||||
("Patchy light snow", "Местами лёгкий снег"),
|
||||
("Light rain shower", "Небольшой дождь"),
|
||||
("Heavy rain shower", "Сильный дождь"),
|
||||
("Moderate rain", "Умеренный дождь"),
|
||||
("Moderate snow", "Умеренный снег"),
|
||||
("Partly cloudy", "Переменная облачность"),
|
||||
("Blowing snow", "Метель"),
|
||||
("Light rain", "Небольшой дождь"),
|
||||
("Heavy rain", "Сильный дождь"),
|
||||
("Light snow", "Небольшой снег"),
|
||||
("Heavy snow", "Сильный снег"),
|
||||
("Overcast", "Пасмурно"),
|
||||
("Cloudy", "Облачно"),
|
||||
("Foggy", "Туманно"),
|
||||
("Clear", "Ясно"),
|
||||
("Sunny", "Ясно"),
|
||||
("Mist", "Туман"),
|
||||
("Haze", "Дымка"),
|
||||
("Fog", "Туман"),
|
||||
]
|
||||
|
||||
|
||||
def translate_weather(en: Optional[str]) -> str:
|
||||
if not en or not en.strip():
|
||||
return "—"
|
||||
en_stripped = en.strip()
|
||||
en_lower = en_stripped.lower()
|
||||
|
||||
# Сначала проверяем точное совпадение (приоритет над substring)
|
||||
for key, value in _WEATHER_MAPPING:
|
||||
if key.lower() == en_lower:
|
||||
return value
|
||||
|
||||
# Fallback: substring-matching (отсортировано по убыванию длины ключа)
|
||||
for key, value in _WEATHER_MAPPING:
|
||||
if key.lower() in en_lower:
|
||||
return value
|
||||
|
||||
return en
|
||||
|
||||
|
||||
def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]:
|
||||
"""
|
||||
Форматировать погодные данные для консольного вывода.
|
||||
|
||||
:param data: Ответ от API (dict)
|
||||
:return: Строки с отформатированной погодой
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
current_condition_list = data.get("current_condition", [])
|
||||
if not current_condition_list:
|
||||
return None
|
||||
current = current_condition_list[0]
|
||||
if not current:
|
||||
return None
|
||||
|
||||
# dict.get() возвращает None если ключ существует со значением null;
|
||||
# используем проверку на None для корректного fallback
|
||||
temp = current.get("temp_C")
|
||||
if temp is None:
|
||||
temp = "—"
|
||||
feels_like = current.get("FeelsLikeC")
|
||||
if feels_like is None:
|
||||
feels_like = "—"
|
||||
weather_desc = current.get("weatherDesc", [{}])[0].get("value")
|
||||
if weather_desc is None:
|
||||
weather_desc = "—"
|
||||
description = translate_weather(weather_desc)
|
||||
humidity = current.get("humidity")
|
||||
if humidity is None:
|
||||
humidity = "—"
|
||||
wind_kmh = current.get("windspeedKmph")
|
||||
if wind_kmh is None:
|
||||
wind_kmh = "—"
|
||||
try:
|
||||
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "—" else "—"
|
||||
except (ValueError, TypeError):
|
||||
wind = "—"
|
||||
pressure_mb = current.get("pressure", "—")
|
||||
pressure_mm = pressure_to_mmhg(pressure_mb)
|
||||
|
||||
return [
|
||||
f"Температура: {temp}°C (ощущается как {feels_like}°C)",
|
||||
f"Описание: {description}",
|
||||
f"Влажность: {humidity}%",
|
||||
f"Ветер: {wind} м/с",
|
||||
f"Давление: {pressure_mm} мм рт. ст.",
|
||||
]
|
||||
|
||||
|
||||
def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
|
||||
"""Форматировать погоду для plain text сообщения (с заголовком)."""
|
||||
if data is None:
|
||||
return None
|
||||
lines = format_weather_data_for_console(data)
|
||||
if not lines:
|
||||
return None
|
||||
return "Погода в Магнитогорске:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def pressure_to_mmhg(mb: float | int | str | None) -> float | str:
|
||||
if mb == "—" or mb is None or mb == "":
|
||||
return "—"
|
||||
try:
|
||||
return round(float(mb) * 0.750062, 1)
|
||||
except (ValueError, TypeError):
|
||||
return "—"
|
||||
|
||||
@ -66,13 +66,9 @@ class RateLimiter:
|
||||
_CAT_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
|
||||
_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3"))
|
||||
|
||||
# wttr.in: без ключа, 1 req/sec, burst 3
|
||||
_WEATHER_RATE: Final[float] = float(os.getenv("WEATHER_API_RATE", "1"))
|
||||
_WEATHER_BURST: Final[int] = int(os.getenv("WEATHER_API_BURST", "3"))
|
||||
|
||||
# Open-Meteo: fallback, 2 req/sec, burst 5
|
||||
_OPEN_METEO_RATE: Final[float] = float(os.getenv("OPEN_METEO_API_RATE", "2"))
|
||||
_OPEN_METEO_BURST: Final[int] = int(os.getenv("OPEN_METEO_API_BURST", "5"))
|
||||
# Яндекс Погода: 1 req/sec, burst 3
|
||||
_YANDEX_WEATHER_RATE: Final[float] = float(os.getenv("YANDEX_WEATHER_API_RATE", "1"))
|
||||
_YANDEX_WEATHER_BURST: Final[int] = int(os.getenv("YANDEX_WEATHER_API_BURST", "3"))
|
||||
|
||||
# Habr RSS: 1 req/sec, burst 2
|
||||
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1"))
|
||||
@ -84,14 +80,9 @@ def make_cat_limiter() -> RateLimiter:
|
||||
return RateLimiter(_CAT_RATE, _CAT_BURST)
|
||||
|
||||
|
||||
def make_weather_limiter() -> RateLimiter:
|
||||
"""Создать лимитер для wttr.in."""
|
||||
return RateLimiter(_WEATHER_RATE, _WEATHER_BURST)
|
||||
|
||||
|
||||
def make_open_meteo_limiter() -> RateLimiter:
|
||||
"""Создать лимитер для Open-Meteo."""
|
||||
return RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST)
|
||||
def make_yandex_weather_limiter() -> RateLimiter:
|
||||
"""Создать лимитер для Яндекс Погоды."""
|
||||
return RateLimiter(_YANDEX_WEATHER_RATE, _YANDEX_WEATHER_BURST)
|
||||
|
||||
|
||||
def make_habr_rss_limiter() -> RateLimiter:
|
||||
@ -104,6 +95,5 @@ def make_habr_rss_limiter() -> RateLimiter:
|
||||
# Factory-функции (make_*_limiter) используются для создания
|
||||
# изолированных экземпляров в тестах с контролируемым временем.
|
||||
cat_limiter: RateLimiter = make_cat_limiter()
|
||||
weather_limiter: RateLimiter = make_weather_limiter()
|
||||
open_meteo_limiter: RateLimiter = make_open_meteo_limiter()
|
||||
yandex_weather_limiter: RateLimiter = make_yandex_weather_limiter()
|
||||
habr_rss_limiter: RateLimiter = make_habr_rss_limiter()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user