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
|
MORNING_CHANNEL_ID=channel_id
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
CAT_API_KEY=your_cat_api_key_here
|
CAT_API_KEY=your_cat_api_key_here
|
||||||
|
YANDEX_WEATHER_API_KEY=your_yandex_weather_api_key_here
|
||||||
CAT_API_RATE=1
|
CAT_API_RATE=1
|
||||||
CAT_API_BURST=3
|
CAT_API_BURST=3
|
||||||
WEATHER_API_RATE=1
|
YANDEX_WEATHER_API_RATE=1
|
||||||
WEATHER_API_BURST=3
|
YANDEX_WEATHER_API_BURST=3
|
||||||
OPEN_METEO_API_RATE=2
|
|
||||||
OPEN_METEO_API_BURST=5
|
|
||||||
HABR_RSS_RATE=1
|
HABR_RSS_RATE=1
|
||||||
HABR_RSS_BURST=2
|
HABR_RSS_BURST=2
|
||||||
|
|||||||
5
bot.py
5
bot.py
@ -249,6 +249,11 @@ def _validate_config() -> None:
|
|||||||
logger.error("Токен Discord не найден в .env")
|
logger.error("Токен Discord не найден в .env")
|
||||||
sys.exit(1)
|
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")
|
morning_time = os.getenv("MORNING_TIME", "07:00")
|
||||||
try:
|
try:
|
||||||
hour, minute = map(int, morning_time.split(":"))
|
hour, minute = map(int, morning_time.split(":"))
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from discord.ext import commands
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ class Pg(commands.Cog):
|
|||||||
@commands.command(name="pg")
|
@commands.command(name="pg")
|
||||||
async def pg(self, ctx: commands.Context) -> None:
|
async def pg(self, ctx: commands.Context) -> None:
|
||||||
"""Прогноз погоды в Магнитогорске"""
|
"""Прогноз погоды в Магнитогорске"""
|
||||||
data = await fetch_weather(API_URL_WEATHER)
|
data = await fetch_weather()
|
||||||
if data is None:
|
if data is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
|
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
|
||||||
|
|||||||
@ -7,18 +7,14 @@ from commands.pg import Pg
|
|||||||
class TestPgInit:
|
class TestPgInit:
|
||||||
"""Тесты инициализации Cog Pg."""
|
"""Тесты инициализации Cog Pg."""
|
||||||
|
|
||||||
def test_uses_api_url_constant(self) -> None:
|
def test_cog_initialized(self) -> None:
|
||||||
"""Pg использует API_URL_WEATHER напрямую (без инстанс-переменной)."""
|
"""Cog инициализируется без ошибок."""
|
||||||
from utils.pogoda import API_URL_WEATHER
|
|
||||||
|
|
||||||
cog = Pg()
|
cog = Pg()
|
||||||
# Pg не хранит api_url как инстанс-переменную — использует константу напрямую
|
assert cog is not None
|
||||||
assert not hasattr(cog, "api_url")
|
|
||||||
assert API_URL_WEATHER == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPgCommand:
|
class TestPgCommand:
|
||||||
"""Тесты команды !pogoda."""
|
"""Тесты команды !pg."""
|
||||||
|
|
||||||
def _make_cog(self):
|
def _make_cog(self):
|
||||||
return Pg()
|
return Pg()
|
||||||
@ -29,16 +25,25 @@ class TestPgCommand:
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def _make_weather_data(self, **extra):
|
def _make_weather_data(self, **extra):
|
||||||
"""Создать mock weather data с дефолтными полями."""
|
"""Создать mock weather data (Яндекс Погода формат).
|
||||||
|
|
||||||
|
windspeedKmph — м/с от Яндекса.
|
||||||
|
wind_gust — порывы ветра в м/с.
|
||||||
|
wind_dir — направление ветра.
|
||||||
|
pressure — уже в мм рт. ст.
|
||||||
|
weatherDesc — на русском (из yandex_condition_to_russian).
|
||||||
|
"""
|
||||||
defaults = {
|
defaults = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{
|
{
|
||||||
"temp_C": "22",
|
"temp_C": 22,
|
||||||
"FeelsLikeC": "24",
|
"FeelsLikeC": 24,
|
||||||
"weatherDesc": [{"value": "Clear"}],
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
"humidity": "45",
|
"humidity": 45,
|
||||||
"windspeedKmph": "18",
|
"windspeedKmph": 5.0, # м/с
|
||||||
"pressure": "1013",
|
"wind_gust": 8.0,
|
||||||
|
"wind_dir": "n",
|
||||||
|
"pressure": 735.0,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -47,7 +52,7 @@ class TestPgCommand:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_success(self) -> None:
|
async def test_pg_success(self) -> None:
|
||||||
"""Успешный запрос погоды должен отправить embed с данными."""
|
"""Успешный запрос погоды должен отправить сообщение с данными."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data()
|
weather = self._make_weather_data()
|
||||||
@ -62,7 +67,7 @@ class TestPgCommand:
|
|||||||
assert "Описание: Ясно" in args
|
assert "Описание: Ясно" in args
|
||||||
assert "Влажность: 45%" in args
|
assert "Влажность: 45%" in args
|
||||||
assert "Ветер: 5.0 м/с" in args
|
assert "Ветер: 5.0 м/с" in args
|
||||||
assert "Давление: 759.8 мм рт. ст." in args
|
assert "Давление: 735.0 мм рт. ст." in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_fetch_returns_none(self) -> None:
|
async def test_pg_fetch_returns_none(self) -> None:
|
||||||
@ -130,7 +135,7 @@ class TestPgCommand:
|
|||||||
"""windspeedKmph = 0 — wind должен быть 0.0."""
|
"""windspeedKmph = 0 — wind должен быть 0.0."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
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)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
await cog.pg.callback(cog, ctx)
|
await cog.pg.callback(cog, ctx)
|
||||||
@ -162,22 +167,9 @@ class TestPgCommand:
|
|||||||
assert "Влажность: —%" in args
|
assert "Влажность: —%" in args
|
||||||
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
|
@pytest.mark.asyncio
|
||||||
async def test_pg_russian_weather_description(self) -> None:
|
async def test_pg_russian_weather_description(self) -> None:
|
||||||
"""Описание погоды на русском должно корректно переводиться."""
|
"""Описание погоды на русском должно корректно отображаться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data(
|
weather = self._make_weather_data(
|
||||||
@ -191,27 +183,58 @@ class TestPgCommand:
|
|||||||
assert "Описание: Переменная облачность" in args
|
assert "Описание: Переменная облачность" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_negative_pressure(self) -> None:
|
async def test_pg_with_wind_gust(self) -> None:
|
||||||
"""Отрицательное давление должно конвертироваться."""
|
"""Порывы ветра должны отображаться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
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)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
await cog.pg.callback(cog, ctx)
|
await cog.pg.callback(cog, ctx)
|
||||||
|
|
||||||
args = ctx.send.call_args[0][0]
|
args = ctx.send.call_args[0][0]
|
||||||
assert "Давление: -37.5 мм рт. ст." in args
|
assert "Ветер: 3.0 м/с (порывы 10.5 м/с), северный" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_high_wind(self) -> None:
|
async def test_pg_with_wind_direction(self) -> None:
|
||||||
"""Большая скорость ветра должна корректно округляться."""
|
"""Направление ветра должно переводиться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
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)):
|
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||||
await cog.pg.callback(cog, ctx)
|
await cog.pg.callback(cog, ctx)
|
||||||
|
|
||||||
args = ctx.send.call_args[0][0]
|
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
|
import requests
|
||||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.pogoda import fetch_weather, fetch_open_meteo
|
from utils.pogoda import fetch_weather
|
||||||
|
|
||||||
|
|
||||||
class TestFetchWeather:
|
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")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_weather_success(self, mock_get) -> None:
|
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 = MagicMock()
|
||||||
mock_response.json.return_value = {
|
mock_response.json.return_value = {
|
||||||
"current": {
|
"fact": {
|
||||||
"temperature": 15,
|
"temp": 15,
|
||||||
"apparent_temperature": 12,
|
"feels_like": 12,
|
||||||
"weather_code": 3,
|
"condition": "cloudy",
|
||||||
"wind_speed_10m": 5.5,
|
"wind_speed": 5.5,
|
||||||
"relative_humidity_2m": 65,
|
"wind_gust": 8.0,
|
||||||
"pressure_msl": 1013,
|
"wind_dir": "n",
|
||||||
|
"humidity": 65,
|
||||||
|
"pressure_mm": 735.0,
|
||||||
|
"pressure_pa": 980,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = await fetch_open_meteo()
|
|
||||||
|
result = await fetch_weather()
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "current_condition" in result
|
assert "current_condition" in result
|
||||||
assert result["current_condition"][0]["temp_C"] == 15
|
assert result["current_condition"][0]["temp_C"] == 15
|
||||||
assert result["current_condition"][0]["FeelsLikeC"] == 12
|
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]["humidity"] == 65
|
||||||
assert result["current_condition"][0]["pressure"] == 1013
|
assert result["current_condition"][0]["pressure"] == 735.0
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@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."""
|
"""Кастомные координаты должны быть в URL."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {
|
mock_response.json.return_value = {
|
||||||
"current": {
|
"fact": {
|
||||||
"temperature": 25,
|
"temp": 25,
|
||||||
"apparent_temperature": 22,
|
"feels_like": 22,
|
||||||
"weather_code": 0,
|
"condition": "clear",
|
||||||
"wind_speed_10m": 3,
|
"wind_speed": 3,
|
||||||
"relative_humidity_2m": 50,
|
"wind_gust": 5,
|
||||||
"pressure_msl": 1020,
|
"wind_dir": "s",
|
||||||
|
"humidity": 50,
|
||||||
|
"pressure_mm": 760.0,
|
||||||
|
"pressure_pa": 1013,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
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
|
assert result is not None
|
||||||
mock_get.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
call_url = mock_get.call_args[0][0]
|
call_url = mock_get.call_args[0][0]
|
||||||
@ -127,117 +73,215 @@ class TestFetchOpenMeteo:
|
|||||||
assert "37.6173" in call_url
|
assert "37.6173" in call_url
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_missing_weather_code(self, mock_get) -> None:
|
async def test_fetch_weather_missing_condition(self, mock_get) -> None:
|
||||||
"""Отсутствующий weather_code → 'Неизвестно'."""
|
"""Отсутствующий condition → 'Неизвестно'."""
|
||||||
mock_response = MagicMock()
|
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_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = await fetch_open_meteo()
|
|
||||||
|
result = await fetch_weather()
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["current_condition"][0]["weatherDesc"] == [
|
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
|
||||||
{"value": "Неизвестно"}
|
|
||||||
]
|
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None:
|
async def test_fetch_weather_ssl_error_retry(self, mock_get) -> None:
|
||||||
"""SSLError → вернуть None."""
|
"""SSLError на первой попытке → retry → успех."""
|
||||||
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: первая попытка падает, вторая успешна."""
|
|
||||||
success_response = MagicMock()
|
success_response = MagicMock()
|
||||||
success_response.json.return_value = {
|
success_response.json.return_value = {
|
||||||
"current": {
|
"fact": {
|
||||||
"temperature": 20,
|
"temp": 20,
|
||||||
"apparent_temperature": 18,
|
"feels_like": 18,
|
||||||
"weather_code": 1,
|
"condition": "partly_cloudy",
|
||||||
"wind_speed_10m": 4,
|
"wind_speed": 4,
|
||||||
"relative_humidity_2m": 60,
|
"wind_gust": 6,
|
||||||
"pressure_msl": 1015,
|
"wind_dir": "w",
|
||||||
|
"humidity": 60,
|
||||||
|
"pressure_mm": 740.0,
|
||||||
|
"pressure_pa": 987,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
success_response.raise_for_status = MagicMock()
|
success_response.raise_for_status = MagicMock()
|
||||||
mock_get.side_effect = [ConnectionError("fail"), success_response]
|
mock_get.side_effect = [SSLError("SSL Error"), success_response]
|
||||||
result = await fetch_open_meteo(max_retries=2)
|
|
||||||
|
result = await fetch_weather(max_retries=2)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert mock_get.call_count == 2
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> None:
|
async def test_fetch_weather_connection_error_retry(self, mock_get) -> None:
|
||||||
"""Все попытки неудачны → 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 = [
|
mock_get.side_effect = [
|
||||||
ConnectionError("fail"),
|
ConnectionError("fail"),
|
||||||
ConnectionError("fail"),
|
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 result is None
|
||||||
assert mock_get.call_count == 3
|
assert mock_get.call_count == 3
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@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."""
|
"""HTTP 404 → raise_for_status бросит исключение → None."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
|
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = await fetch_open_meteo()
|
|
||||||
|
result = await fetch_weather(max_retries=1)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@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 = MagicMock()
|
||||||
mock_response.json.return_value = {
|
mock_response.json.return_value = {
|
||||||
"current": {
|
"fact": {
|
||||||
"temperature": 0,
|
"temp": 0,
|
||||||
"apparent_temperature": -2,
|
"feels_like": -2,
|
||||||
"weather_code": 45,
|
"condition": "fog",
|
||||||
"wind_speed_10m": 0,
|
"wind_speed": 0,
|
||||||
"relative_humidity_2m": 95,
|
"wind_gust": 0,
|
||||||
"pressure_msl": 1000,
|
"wind_dir": "n",
|
||||||
|
"humidity": 95,
|
||||||
|
"pressure_mm": 750.0,
|
||||||
|
"pressure_pa": 1000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = await fetch_open_meteo()
|
|
||||||
|
result = await fetch_weather()
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["current_condition"][0]["windspeedKmph"] == 0
|
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
|
import pytest
|
||||||
from utils.pogoda import (
|
from utils.pogoda import (
|
||||||
translate_weather,
|
|
||||||
pressure_to_mmhg,
|
pressure_to_mmhg,
|
||||||
wmo_to_russian,
|
wmo_to_russian,
|
||||||
|
yandex_condition_to_russian,
|
||||||
format_weather_data_for_console,
|
format_weather_data_for_console,
|
||||||
|
get_weather_description,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -15,12 +16,14 @@ class TestFormatWeatherDataForConsole:
|
|||||||
data = {
|
data = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{
|
{
|
||||||
"temp_C": "25",
|
"temp_C": 25,
|
||||||
"FeelsLikeC": "26",
|
"FeelsLikeC": 26,
|
||||||
"weatherDesc": [{"value": "Clear"}],
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
"humidity": "45",
|
"humidity": 45,
|
||||||
"windspeedKmph": "10",
|
"windspeedKmph": 5.0, # м/с от Яндекса
|
||||||
"pressure": "1013",
|
"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 "Температура: 25°C (ощущается как 26°C)" in result[0]
|
||||||
assert "Описание: Ясно" in result[1]
|
assert "Описание: Ясно" in result[1]
|
||||||
assert "Влажность: 45%" in result[2]
|
assert "Влажность: 45%" in result[2]
|
||||||
assert "Ветер: 2.8 м/с" in result[3] # 10 / 3.6 = 2.777... ≈ 2.8
|
assert "Ветер: 5.0 м/с (порывы 8.0 м/с), северный" in result[3]
|
||||||
assert "Давление: 759.8 мм рт. ст." in result[4]
|
assert "Давление: 735.0 мм рт. ст." in result[4]
|
||||||
|
|
||||||
def test_format_empty_data(self) -> None:
|
def test_format_empty_data(self) -> None:
|
||||||
"""Пустые данные должны возвращать None."""
|
"""Пустые данные должны возвращать None."""
|
||||||
@ -52,16 +55,16 @@ class TestFormatWeatherDataForConsole:
|
|||||||
assert result is None, "Отсутствие current_condition должно вернуть None"
|
assert result is None, "Отсутствие current_condition должно вернуть None"
|
||||||
|
|
||||||
def test_format_with_dashes(self) -> None:
|
def test_format_with_dashes(self) -> None:
|
||||||
"""Неизвестные значения должны отображаться как '—'."""
|
"""None значения должны отображаться как '—'."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{
|
{
|
||||||
"temp_C": "—",
|
"temp_C": None,
|
||||||
"FeelsLikeC": "—",
|
"FeelsLikeC": None,
|
||||||
"weatherDesc": [{"value": "—"}],
|
"weatherDesc": [{"value": "Неизвестно"}],
|
||||||
"humidity": "—",
|
"humidity": None,
|
||||||
"windspeedKmph": "—",
|
"windspeedKmph": None,
|
||||||
"pressure": "—",
|
"pressure": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -70,41 +73,24 @@ class TestFormatWeatherDataForConsole:
|
|||||||
|
|
||||||
assert isinstance(result, list), "Результат должен быть списком строк"
|
assert isinstance(result, list), "Результат должен быть списком строк"
|
||||||
assert "Температура: —°C (ощущается как —°C)" in result[0]
|
assert "Температура: —°C (ощущается как —°C)" in result[0]
|
||||||
assert "Описание: —" in result[1]
|
assert "Описание: Неизвестно" in result[1]
|
||||||
assert "Влажность: —%" in result[2]
|
assert "Влажность: —%" in result[2]
|
||||||
assert "Ветер: — м/с" in result[3]
|
assert "Ветер: — м/с" in result[3]
|
||||||
assert "Давление: — мм рт. ст." in result[4]
|
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:
|
def test_format_negative_temperature(self) -> None:
|
||||||
"""Отрицательная температура должна отображаться корректно."""
|
"""Отрицательная температура должна отображаться корректно."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{
|
{
|
||||||
"temp_C": "-5",
|
"temp_C": -5,
|
||||||
"FeelsLikeC": "-10",
|
"FeelsLikeC": -10,
|
||||||
"weatherDesc": [{"value": "Snow"}],
|
"weatherDesc": [{"value": "Снег"}],
|
||||||
"humidity": "80",
|
"humidity": 80,
|
||||||
"windspeedKmph": "20",
|
"windspeedKmph": 5,
|
||||||
"pressure": "980",
|
"wind_gust": 10,
|
||||||
|
"wind_dir": "n",
|
||||||
|
"pressure": 720.0,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -114,88 +100,54 @@ class TestFormatWeatherDataForConsole:
|
|||||||
assert isinstance(result, list), "Результат должен быть списком строк"
|
assert isinstance(result, list), "Результат должен быть списком строк"
|
||||||
assert "Температура: -5°C (ощущается как -10°C)" in result[0]
|
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:
|
result = format_weather_data_for_console(data)
|
||||||
@pytest.mark.parametrize(
|
assert "Температура: 17.3°C" in result[0]
|
||||||
"english, expected",
|
assert "Ветер: 2.1 м/с" in result[3]
|
||||||
[
|
|
||||||
("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
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_format_all_wind_directions(self) -> None:
|
||||||
"input_value, expected",
|
"""Все направления ветра должны переводиться корректно."""
|
||||||
[
|
directions = [
|
||||||
("", "—"),
|
("n", "северный"),
|
||||||
(None, "—"),
|
("ne", "северо-восточный"),
|
||||||
(" ", "—"), # строка из пробелов тоже считается пустой
|
("e", "восточный"),
|
||||||
],
|
("se", "юго-восточный"),
|
||||||
)
|
("s", "южный"),
|
||||||
def test_translate_empty(self, input_value, expected) -> None:
|
("sw", "юго-западный"),
|
||||||
"""Пустой, None или только пробелы должен возвращать '—'."""
|
("w", "западный"),
|
||||||
assert translate_weather(input_value) == expected
|
("nw", "северо-западный"),
|
||||||
|
]
|
||||||
|
|
||||||
def test_translate_unknown_returns_original(self) -> None:
|
for code, expected in directions:
|
||||||
"""Неизвестный перевод должен возвращать оригинальный текст."""
|
data = {
|
||||||
unknown_text = "Unknown weather condition XYZ"
|
"current_condition": [
|
||||||
assert translate_weather(unknown_text) == unknown_text
|
{
|
||||||
|
"temp_C": 20,
|
||||||
def test_translate_partial_match(self) -> None:
|
"FeelsLikeC": 18,
|
||||||
"""Частичное совпадение ключа в тексте должно сработать."""
|
"weatherDesc": [{"value": "Ясно"}],
|
||||||
# "Moderate or heavy rain in area" должно найтись в "Light Moderate or heavy rain in area"
|
"humidity": 50,
|
||||||
text_with_prefix = "Light Moderate or heavy rain in area"
|
"windspeedKmph": 3.0,
|
||||||
assert translate_weather(text_with_prefix) == "Дождь"
|
"wind_dir": code,
|
||||||
|
"pressure": 750.0,
|
||||||
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"
|
result = format_weather_data_for_console(data)
|
||||||
assert translate_weather(text) == "Дождь"
|
assert expected in result[3], f"Направление {code} → {expected} не найдено"
|
||||||
|
|
||||||
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 ") == "Ясно"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPressureToMMHG:
|
class TestPressureToMMHG:
|
||||||
@ -260,17 +212,20 @@ class TestPressureToMMHG:
|
|||||||
|
|
||||||
|
|
||||||
class TestWmoToRussian:
|
class TestWmoToRussian:
|
||||||
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды."""
|
"""Тесты функции wmo_to_russian() — перевод WMO кодов погоды.
|
||||||
|
|
||||||
|
Оставлен для обратной совместимости.
|
||||||
|
"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"code, expected",
|
"code, expected",
|
||||||
[
|
[
|
||||||
(0, "Ясно"),
|
(0, "Ясно"),
|
||||||
(1, "Ясно"),
|
(1, "Преимущественно ясно"),
|
||||||
(2, "Переменная облачность"),
|
(2, "Переменная облачность"),
|
||||||
(3, "Пасмурно"),
|
(3, "Пасмурно"),
|
||||||
(45, "Туман"),
|
(45, "Туман"),
|
||||||
(48, "Туман"),
|
(48, "Изморозь"),
|
||||||
(51, "Лёгкая морось"),
|
(51, "Лёгкая морось"),
|
||||||
(53, "Морось"),
|
(53, "Морось"),
|
||||||
(55, "Сильная морось"),
|
(55, "Сильная морось"),
|
||||||
@ -318,3 +273,68 @@ class TestWmoToRussian:
|
|||||||
def test_wmo_float_code(self) -> None:
|
def test_wmo_float_code(self) -> None:
|
||||||
"""Дробный код — не найдётся в mapping."""
|
"""Дробный код — не найдётся в mapping."""
|
||||||
assert wmo_to_russian(1.5) == "Неизвестно"
|
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 (
|
from .pogoda import (
|
||||||
API_URL_WEATHER,
|
|
||||||
_session as _weather_session,
|
_session as _weather_session,
|
||||||
fetch_weather,
|
fetch_weather,
|
||||||
fetch_open_meteo,
|
|
||||||
format_weather_data_for_console,
|
format_weather_data_for_console,
|
||||||
format_weather_for_message,
|
format_weather_for_message,
|
||||||
pressure_to_mmhg,
|
pressure_to_mmhg,
|
||||||
translate_weather,
|
|
||||||
wmo_to_russian,
|
wmo_to_russian,
|
||||||
|
yandex_condition_to_russian,
|
||||||
)
|
)
|
||||||
from .news import ( # noqa: E402
|
from .news import ( # noqa: E402
|
||||||
_session as _news_session,
|
_session as _news_session,
|
||||||
@ -24,14 +22,12 @@ from .cat import ( # noqa: E402
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Погода
|
# Погода
|
||||||
"API_URL_WEATHER",
|
|
||||||
"fetch_weather",
|
"fetch_weather",
|
||||||
"fetch_open_meteo",
|
|
||||||
"format_weather_data_for_console",
|
"format_weather_data_for_console",
|
||||||
"format_weather_for_message",
|
"format_weather_for_message",
|
||||||
"pressure_to_mmhg",
|
"pressure_to_mmhg",
|
||||||
"translate_weather",
|
|
||||||
"wmo_to_russian",
|
"wmo_to_russian",
|
||||||
|
"yandex_condition_to_russian",
|
||||||
# Новости
|
# Новости
|
||||||
"RSS_URL_ARTICLES",
|
"RSS_URL_ARTICLES",
|
||||||
"RSS_URL_POSTS",
|
"RSS_URL_POSTS",
|
||||||
@ -56,4 +52,3 @@ def close_all_sessions() -> None:
|
|||||||
session.close()
|
session.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Cleanup — игнорируем ошибки
|
pass # Cleanup — игнорируем ошибки
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import discord
|
|||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from utils.pogoda import (
|
from utils.pogoda import (
|
||||||
API_URL_WEATHER,
|
|
||||||
fetch_weather,
|
fetch_weather,
|
||||||
format_weather_for_message,
|
format_weather_for_message,
|
||||||
)
|
)
|
||||||
@ -40,7 +39,7 @@ class MorningData:
|
|||||||
async def gather_morning() -> MorningData:
|
async def gather_morning() -> MorningData:
|
||||||
"""Собрать все данные для утреннего дайджеста параллельно."""
|
"""Собрать все данные для утреннего дайджеста параллельно."""
|
||||||
weather_data, articles, posts, cat_url = await asyncio.gather(
|
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_ARTICLES),
|
||||||
fetch_rss(RSS_URL_POSTS),
|
fetch_rss(RSS_URL_POSTS),
|
||||||
fetch_cat(),
|
fetch_cat(),
|
||||||
|
|||||||
395
utils/pogoda.py
395
utils/pogoda.py
@ -1,75 +1,76 @@
|
|||||||
|
"""Погода через Яндекс Погоду API.
|
||||||
|
|
||||||
|
Яндекс Погода: актуальные данные для городов России и мира,
|
||||||
|
текущие условия + прогноз. Требуется API-ключ в YANDEX_WEATHER_API_KEY.
|
||||||
|
|
||||||
|
https://yandex.ru/dev/weather/
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
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__)
|
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()
|
_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(
|
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]:
|
) -> Optional[dict]:
|
||||||
"""Получить данные о погоде с retry."""
|
"""Получить текущую погоду через Яндекс Погоду API.
|
||||||
await weather_limiter.acquire()
|
|
||||||
|
Возвращает 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):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
response = await asyncio.to_thread(_session.get, api_url, timeout=timeout)
|
response = await asyncio.to_thread(
|
||||||
response.raise_for_status()
|
_session.get, url, headers=headers, timeout=timeout
|
||||||
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.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
current = data.get("current", {})
|
fact = data.get("fact", {})
|
||||||
# weather_code WMO код -> перевод (https://open-meteo.com/en/docs)
|
|
||||||
weather_code = current.get("weather_code", None)
|
|
||||||
desc = wmo_to_russian(weather_code)
|
|
||||||
return {
|
return {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{
|
{
|
||||||
"temp_C": current.get("temperature", "—"),
|
"temp_C": fact.get("temp"),
|
||||||
"FeelsLikeC": current.get("apparent_temperature", "—"),
|
"FeelsLikeC": fact.get("feels_like"),
|
||||||
"weatherDesc": [{"value": desc}],
|
"weatherDesc": [{"value": yandex_condition_to_russian(fact.get("condition"))}],
|
||||||
"humidity": current.get("relative_humidity_2m", "—"),
|
"humidity": fact.get("humidity"),
|
||||||
"windspeedKmph": current.get("wind_speed_10m", "—"),
|
"windspeedKmph": fact.get("wind_speed"),
|
||||||
"pressure": current.get("pressure_msl", "—"),
|
"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
|
continue
|
||||||
break
|
break
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.error("Ошибка при получении данных: %s", e)
|
logger.error("Ошибка Яндекс Погоды: %s", e)
|
||||||
return None
|
break
|
||||||
|
|
||||||
logger.warning("Все попытки Open-Meteo не удались")
|
logger.warning("Все попытки Яндекс Погоды не удались")
|
||||||
return None
|
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] = {
|
_WMO_MAPPING: dict[int, str] = {
|
||||||
0: "Ясно",
|
0: "Ясно",
|
||||||
1: "Ясно",
|
1: "Преимущественно ясно",
|
||||||
2: "Переменная облачность",
|
2: "Переменная облачность",
|
||||||
3: "Пасмурно",
|
3: "Пасмурно",
|
||||||
45: "Туман",
|
45: "Туман",
|
||||||
48: "Туман",
|
48: "Изморозь",
|
||||||
51: "Лёгкая морось",
|
51: "Лёгкая морось",
|
||||||
53: "Морось",
|
53: "Морось",
|
||||||
55: "Сильная морось",
|
55: "Сильная морось",
|
||||||
@ -123,137 +275,8 @@ _WMO_MAPPING: dict[int, str] = {
|
|||||||
|
|
||||||
|
|
||||||
def wmo_to_russian(code: Optional[int]) -> str:
|
def wmo_to_russian(code: Optional[int]) -> str:
|
||||||
"""Перевод WMO weather code в русский."""
|
"""Перевод WMO weather code в русский.
|
||||||
|
|
||||||
|
Оставлен для обратной совместимости с тестами.
|
||||||
|
"""
|
||||||
return _WMO_MAPPING.get(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_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
|
||||||
_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3"))
|
_CAT_BURST: Final[int] = int(os.getenv("CAT_API_BURST", "3"))
|
||||||
|
|
||||||
# wttr.in: без ключа, 1 req/sec, burst 3
|
# Яндекс Погода: 1 req/sec, burst 3
|
||||||
_WEATHER_RATE: Final[float] = float(os.getenv("WEATHER_API_RATE", "1"))
|
_YANDEX_WEATHER_RATE: Final[float] = float(os.getenv("YANDEX_WEATHER_API_RATE", "1"))
|
||||||
_WEATHER_BURST: Final[int] = int(os.getenv("WEATHER_API_BURST", "3"))
|
_YANDEX_WEATHER_BURST: Final[int] = int(os.getenv("YANDEX_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"))
|
|
||||||
|
|
||||||
# Habr RSS: 1 req/sec, burst 2
|
# Habr RSS: 1 req/sec, burst 2
|
||||||
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1"))
|
_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)
|
return RateLimiter(_CAT_RATE, _CAT_BURST)
|
||||||
|
|
||||||
|
|
||||||
def make_weather_limiter() -> RateLimiter:
|
def make_yandex_weather_limiter() -> RateLimiter:
|
||||||
"""Создать лимитер для wttr.in."""
|
"""Создать лимитер для Яндекс Погоды."""
|
||||||
return RateLimiter(_WEATHER_RATE, _WEATHER_BURST)
|
return RateLimiter(_YANDEX_WEATHER_RATE, _YANDEX_WEATHER_BURST)
|
||||||
|
|
||||||
|
|
||||||
def make_open_meteo_limiter() -> RateLimiter:
|
|
||||||
"""Создать лимитер для Open-Meteo."""
|
|
||||||
return RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST)
|
|
||||||
|
|
||||||
|
|
||||||
def make_habr_rss_limiter() -> RateLimiter:
|
def make_habr_rss_limiter() -> RateLimiter:
|
||||||
@ -104,6 +95,5 @@ def make_habr_rss_limiter() -> RateLimiter:
|
|||||||
# Factory-функции (make_*_limiter) используются для создания
|
# Factory-функции (make_*_limiter) используются для создания
|
||||||
# изолированных экземпляров в тестах с контролируемым временем.
|
# изолированных экземпляров в тестах с контролируемым временем.
|
||||||
cat_limiter: RateLimiter = make_cat_limiter()
|
cat_limiter: RateLimiter = make_cat_limiter()
|
||||||
weather_limiter: RateLimiter = make_weather_limiter()
|
yandex_weather_limiter: RateLimiter = make_yandex_weather_limiter()
|
||||||
open_meteo_limiter: RateLimiter = make_open_meteo_limiter()
|
|
||||||
habr_rss_limiter: RateLimiter = make_habr_rss_limiter()
|
habr_rss_limiter: RateLimiter = make_habr_rss_limiter()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user