feat(weather): кэширование погоды с TTL 1 час

- fetch_weather кэширует результат на 3600 сек (WEATHER_CACHE_TTL)
- Провал запроса тоже кэшируется — не долбит API повторно
- bypass_cache=True для форсирования свежего запроса
- clear_weather_cache() для сброса кэша
- asyncio.Lock для thread-safety
- 5 новых тестов (cache hit, bypass, failure cache, clear, TTL expiry)
This commit is contained in:
deadzilla 2026-07-21 00:08:09 +05:00
parent b8b1801f23
commit f7df0f4900
3 changed files with 184 additions and 4 deletions

View File

@ -2,12 +2,17 @@ import pytest
import requests
from requests.exceptions import ConnectionError, Timeout, SSLError
from unittest.mock import patch, MagicMock
from utils.pogoda import fetch_weather
from utils.pogoda import fetch_weather, clear_weather_cache
class TestFetchWeather:
"""Тесты функции fetch_weather() — Яндекс Погода API с retry-логикой."""
@pytest.fixture(autouse=True)
def _clear_cache(self) -> None:
"""Очистить кэш перед каждым тестом."""
clear_weather_cache()
@pytest.fixture(autouse=True)
def _mock_api_key(self) -> None:
"""Мокаем _get_api_key для всех тестов в классе."""
@ -299,3 +304,143 @@ class TestFetchWeather:
result = await fetch_weather(max_retries=1)
assert result is None
class TestWeatherCache:
"""Тесты кэширования погоды."""
@pytest.fixture(autouse=True)
def _clear_cache(self) -> None:
clear_weather_cache()
@pytest.fixture(autouse=True)
def _mock_api_key(self) -> None:
with patch("utils.pogoda._get_api_key", return_value="test-key"):
yield
@patch("utils.pogoda._session.get")
async def test_cache_returns_cached_data(self, mock_get) -> None:
"""Второй запрос в течение TTL возвращает кэш без вызова API."""
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
result1 = await fetch_weather()
result2 = await fetch_weather()
assert result1 is not None
assert result2 is not None
assert result1 == result2
# API вызван только один раз
assert mock_get.call_count == 1
@patch("utils.pogoda._session.get")
async def test_bypass_cache_forces_new_request(self, mock_get) -> None:
"""bypass_cache=True делает новый запрос к API."""
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
await fetch_weather()
await fetch_weather(bypass_cache=True)
assert mock_get.call_count == 2
@patch("utils.pogoda._session.get")
async def test_cache_stores_failure(self, mock_get) -> None:
"""Провал запроса кэшируется — повторный вызов не долбит API."""
mock_get.side_effect = ConnectionError("fail")
result1 = await fetch_weather(max_retries=1)
result2 = await fetch_weather()
assert result1 is None
assert result2 is None # из кэша
# API вызван только один раз (первый раз)
assert mock_get.call_count == 1
@patch("utils.pogoda._session.get")
async def test_clear_cache_invalidates(self, mock_get) -> None:
"""clear_weather_cache сбрасывает кэш."""
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
await fetch_weather()
clear_weather_cache()
await fetch_weather()
assert mock_get.call_count == 2
@patch("utils.pogoda._session.get")
async def test_cache_ttl_expiry(self, mock_get) -> None:
"""После истечения TTL кэш инвалидируется."""
import utils.pogoda as pogoda_module
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
# Устанавливаем TTL = 0, чтобы кэш сразу истёк
original_ttl = pogoda_module._WEATHER_CACHE_TTL
pogoda_module._WEATHER_CACHE_TTL = 0.0
try:
await fetch_weather()
await fetch_weather()
assert mock_get.call_count == 2
finally:
pogoda_module._WEATHER_CACHE_TTL = original_ttl

View File

@ -1,5 +1,6 @@
from .pogoda import (
_session as _weather_session,
clear_weather_cache,
fetch_weather,
format_weather_data_for_console,
format_weather_for_message,
@ -22,6 +23,7 @@ from .cat import ( # noqa: E402
__all__ = [
# Погода
"clear_weather_cache",
"fetch_weather",
"format_weather_data_for_console",
"format_weather_for_message",

View File

@ -10,6 +10,7 @@ import asyncio
import json
import logging
import os
import time
from typing import Optional
import requests
@ -25,6 +26,12 @@ _LONGITUDE: float = 58.980289
_session = requests.Session()
# Кэш погоды: (data, timestamp) или (None, timestamp) при провале запроса
_weather_cache: tuple[Optional[dict], float] = (None, 0.0)
_weather_cache_lock = asyncio.Lock()
# TTL в секундах — по умолчанию 1 час, переопределяется WEATHER_CACHE_TTL
_WEATHER_CACHE_TTL: float = float(os.getenv("WEATHER_CACHE_TTL", "3600"))
def _get_api_key() -> str:
"""Получить API-ключ Яндекс Погоды из переменных окружения."""
@ -36,17 +43,37 @@ def _get_api_key() -> str:
return key
def clear_weather_cache() -> None:
"""Очистить кэш погоды. Используется в тестах."""
global _weather_cache
_weather_cache = (None, 0.0)
async def fetch_weather(
lat: float = _LATITUDE,
lon: float = _LONGITUDE,
timeout: int = 10,
max_retries: int = 3,
bypass_cache: bool = False,
) -> Optional[dict]:
"""Получить текущую погоду через Яндекс Погоду API.
Возвращает dict в унифицированном формате для форматирования:
{"current_condition": [{"temp_C": ..., "weatherDesc": ..., ...}]}
Результат кэшируется на _WEATHER_CACHE_TTL секунд (по умолчанию 1 ч).
Повторные вызовы в течение TTL возвращают кэшированные данные без запроса к API.
:param bypass_cache: Пропустить кэш и сделать свежий запрос.
:return: dict в унифицированном формате или None при ошибке.
"""
global _weather_cache
# Проверка кэша
if not bypass_cache:
async with _weather_cache_lock:
cached_data, cached_time = _weather_cache
if time.monotonic() - cached_time < _WEATHER_CACHE_TTL:
logger.debug("Погода: возвращены данные из кэша (TTL %.0f сек)", _WEATHER_CACHE_TTL)
return cached_data
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()}
@ -60,7 +87,7 @@ async def fetch_weather(
data = response.json()
fact = data.get("fact", {})
return {
result = {
"current_condition": [
{
"temp_C": fact.get("temp"),
@ -75,6 +102,9 @@ async def fetch_weather(
}
]
}
async with _weather_cache_lock:
_weather_cache = (result, time.monotonic())
return result
except (SSLError, ConnectionError, Timeout):
if attempt < max_retries - 1:
delay = 2**attempt
@ -89,6 +119,9 @@ async def fetch_weather(
break
logger.warning("Все попытки Яндекс Погоды не удались")
# Сохраняем провал в кэш, чтобы не долбить API повторно
async with _weather_cache_lock:
_weather_cache = (None, time.monotonic())
return None