Compare commits
14 Commits
dda5753f8c
...
b98c696498
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b98c696498 | ||
|
|
7ade3147cf | ||
|
|
d20957ac5f | ||
|
|
4b61909ebc | ||
|
|
4e816d5998 | ||
|
|
b21237b042 | ||
|
|
0fa467d40e | ||
|
|
a1b72ecbcf | ||
|
|
00ec2eac0e | ||
|
|
ab0039661a | ||
|
|
5a2ff483a1 | ||
|
|
cd25d63f5b | ||
|
|
be0aa5211a | ||
|
|
02b6b10ea3 |
28
bot.py
28
bot.py
@ -11,13 +11,13 @@ from typing import TYPE_CHECKING
|
|||||||
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||||
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
||||||
|
|
||||||
import discord
|
import discord # noqa: E402
|
||||||
from discord.ext import commands
|
from discord.ext import commands # noqa: E402
|
||||||
from discord.ext.commands import CommandNotFound
|
from discord.ext.commands import CommandNotFound # noqa: E402
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv # noqa: E402
|
||||||
|
|
||||||
from commands import ALL_COMMANDS
|
from commands import ALL_COMMANDS # noqa: E402
|
||||||
from utils.morning_runner import Scheduler
|
from utils.morning_runner import Scheduler # noqa: E402
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from utils.morning_runner import Scheduler as SchedulerType
|
from utils.morning_runner import Scheduler as SchedulerType
|
||||||
@ -74,7 +74,9 @@ class BotRunner:
|
|||||||
morning_time = os.getenv("MORNING_TIME", "07:00")
|
morning_time = os.getenv("MORNING_TIME", "07:00")
|
||||||
self.scheduler = Scheduler(self.bot, morning_time)
|
self.scheduler = Scheduler(self.bot, morning_time)
|
||||||
self.bot._scheduler = self.scheduler
|
self.bot._scheduler = self.scheduler
|
||||||
logger.info("Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name)
|
logger.info(
|
||||||
|
"Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name
|
||||||
|
)
|
||||||
|
|
||||||
@self.bot.event
|
@self.bot.event
|
||||||
async def on_command_error(ctx: commands.Context, error: Exception) -> None:
|
async def on_command_error(ctx: commands.Context, error: Exception) -> None:
|
||||||
@ -84,7 +86,9 @@ class BotRunner:
|
|||||||
# Терминал — детали для разработчика
|
# Терминал — детали для разработчика
|
||||||
cmd_name = ctx.command.name if ctx and ctx.command else "?"
|
cmd_name = ctx.command.name if ctx and ctx.command else "?"
|
||||||
logger.error(
|
logger.error(
|
||||||
"Ошибка команды %s: %s", cmd_name, error,
|
"Ошибка команды %s: %s",
|
||||||
|
cmd_name,
|
||||||
|
error,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -146,7 +150,9 @@ class BotRunner:
|
|||||||
logger.critical(
|
logger.critical(
|
||||||
"Непредвиденная ошибка при запуске бота: %s", e, exc_info=True
|
"Непредвиденная ошибка при запуске бота: %s", e, exc_info=True
|
||||||
)
|
)
|
||||||
logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__)
|
logger.error(
|
||||||
|
"Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
finally:
|
finally:
|
||||||
# Context manager (async with self.bot) закрывает бота автоматически
|
# Context manager (async with self.bot) закрывает бота автоматически
|
||||||
@ -174,9 +180,7 @@ def _validate_config() -> None:
|
|||||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
logger.error(
|
logger.error("Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time)
|
||||||
"Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
channel_id = os.getenv("MORNING_CHANNEL_ID")
|
channel_id = os.getenv("MORNING_CHANNEL_ID")
|
||||||
|
|||||||
@ -15,14 +15,13 @@ class Cat(commands.Cog):
|
|||||||
"""Получить случайного котика"""
|
"""Получить случайного котика"""
|
||||||
url = await fetch_cat()
|
url = await fetch_cat()
|
||||||
if url is None:
|
if url is None:
|
||||||
logger.warning("%s: !cat — не удалось получить котика (API вернул None)", ctx.author)
|
logger.warning(
|
||||||
|
"%s: !cat — не удалось получить котика (API вернул None)", ctx.author
|
||||||
|
)
|
||||||
await ctx.send("Не удалось получить котика. Попробуйте позже.")
|
await ctx.send("Не удалось получить котика. Попробуйте позже.")
|
||||||
return
|
return
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(title="Котик для тебя!", color=discord.Color.orange())
|
||||||
title="Котик для тебя!",
|
|
||||||
color=discord.Color.orange()
|
|
||||||
)
|
|
||||||
embed.set_image(url=url)
|
embed.set_image(url=url)
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
logger.info("%s: !cat выполнена", ctx.author)
|
logger.info("%s: !cat выполнена", ctx.author)
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import discord
|
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
|
|
||||||
@ -28,5 +27,3 @@ class Help(commands.Cog):
|
|||||||
message += "\n\n" + "=" * 40
|
message += "\n\n" + "=" * 40
|
||||||
|
|
||||||
await ctx.send(message)
|
await ctx.send(message)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import discord
|
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from utils.morning_runner import run_morning
|
from utils.morning_runner import run_morning
|
||||||
|
|||||||
@ -20,7 +20,9 @@ class News(commands.Cog):
|
|||||||
"""Топ-5 свежих статей и новостей по AI с Habr"""
|
"""Топ-5 свежих статей и новостей по AI с Habr"""
|
||||||
articles = await fetch_rss(RSS_URL_ARTICLES)
|
articles = await fetch_rss(RSS_URL_ARTICLES)
|
||||||
if articles is None:
|
if articles is None:
|
||||||
logger.warning("%s: !nw — не удалось получить статьи (API вернул None)", ctx.author)
|
logger.warning(
|
||||||
|
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
|
||||||
|
)
|
||||||
await ctx.send("Не удалось получить новости. Попробуйте позже.")
|
await ctx.send("Не удалось получить новости. Попробуйте позже.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -29,9 +31,11 @@ class News(commands.Cog):
|
|||||||
await ctx.send("Новостей пока нет.")
|
await ctx.send("Новостей пока нет.")
|
||||||
return
|
return
|
||||||
|
|
||||||
articles_text = format_articles(articles,
|
articles_text = format_articles(
|
||||||
|
articles,
|
||||||
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
|
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
|
||||||
|
)
|
||||||
|
|
||||||
posts = await fetch_rss(RSS_URL_POSTS)
|
posts = await fetch_rss(RSS_URL_POSTS)
|
||||||
|
|
||||||
@ -47,16 +51,20 @@ class News(commands.Cog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if posts is None:
|
if posts is None:
|
||||||
logger.warning("%s: !nw — не удалось получить посты (API вернул None)", ctx.author)
|
logger.warning(
|
||||||
|
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
|
||||||
|
)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Новости",
|
name="Новости",
|
||||||
value="Не удалось получить новости.",
|
value="Не удалось получить новости.",
|
||||||
inline=False,
|
inline=False,
|
||||||
)
|
)
|
||||||
elif posts:
|
elif posts:
|
||||||
posts_text = format_articles(posts,
|
posts_text = format_articles(
|
||||||
|
posts,
|
||||||
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
|
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
|
||||||
|
)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Новости",
|
name="Новости",
|
||||||
value=truncate_embed_field("\n".join(posts_text)),
|
value=truncate_embed_field("\n".join(posts_text)),
|
||||||
@ -71,4 +79,9 @@ class News(commands.Cog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
logger.info("%s: !nw выполнена (статей: %d, постов: %d)", ctx.author, len(articles), len(posts) if posts else 0)
|
logger.info(
|
||||||
|
"%s: !nw выполнена (статей: %d, постов: %d)",
|
||||||
|
ctx.author,
|
||||||
|
len(articles),
|
||||||
|
len(posts) if posts else 0,
|
||||||
|
)
|
||||||
|
|||||||
@ -16,7 +16,9 @@ class Pg(commands.Cog):
|
|||||||
"""Прогноз погоды в Магнитогорске"""
|
"""Прогноз погоды в Магнитогорске"""
|
||||||
data = await fetch_weather(self.api_url)
|
data = await fetch_weather(self.api_url)
|
||||||
if data is None:
|
if data is None:
|
||||||
logger.warning("%s: !pg — не удалось получить погоду (API вернул None)", ctx.author)
|
logger.warning(
|
||||||
|
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
|
||||||
|
)
|
||||||
await ctx.send("Не удалось получить данные о погоде.")
|
await ctx.send("Не удалось получить данные о погоде.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,13 @@ class Stats(commands.Cog):
|
|||||||
guilds = ctx.bot.guilds
|
guilds = ctx.bot.guilds
|
||||||
total_guilds = len(guilds)
|
total_guilds = len(guilds)
|
||||||
total_channels = sum(
|
total_channels = sum(
|
||||||
len([ch for ch in guild.channels if not isinstance(ch, discord.CategoryChannel)])
|
len(
|
||||||
|
[
|
||||||
|
ch
|
||||||
|
for ch in guild.channels
|
||||||
|
if not isinstance(ch, discord.CategoryChannel)
|
||||||
|
]
|
||||||
|
)
|
||||||
for guild in guilds
|
for guild in guilds
|
||||||
)
|
)
|
||||||
total_members = sum(guild.member_count or 0 for guild in guilds)
|
total_members = sum(guild.member_count or 0 for guild in guilds)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
discord.py>=2.3.2
|
discord.py>=2.3.2
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
defusedxml>=7.0.0
|
defusedxml>=0.7.0
|
||||||
|
|||||||
@ -42,7 +42,9 @@ class TestBotErrorHandling:
|
|||||||
import bot
|
import bot
|
||||||
|
|
||||||
runner = bot.BotRunner()
|
runner = bot.BotRunner()
|
||||||
with patch.object(runner.bot, "start", side_effect=discord.LoginFailure("bad token")):
|
with patch.object(
|
||||||
|
runner.bot, "start", side_effect=discord.LoginFailure("bad token")
|
||||||
|
):
|
||||||
with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
|
with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
|
||||||
with patch.object(runner.bot, "__aexit__", return_value=None):
|
with patch.object(runner.bot, "__aexit__", return_value=None):
|
||||||
with patch("sys.exit") as mock_exit:
|
with patch("sys.exit") as mock_exit:
|
||||||
@ -55,7 +57,11 @@ class TestBotErrorHandling:
|
|||||||
|
|
||||||
runner = bot.BotRunner()
|
runner = bot.BotRunner()
|
||||||
mock_response = MagicMock(status=502)
|
mock_response = MagicMock(status=502)
|
||||||
with patch.object(runner.bot, "start", side_effect=discord.HTTPException(mock_response, "Bad Gateway")):
|
with patch.object(
|
||||||
|
runner.bot,
|
||||||
|
"start",
|
||||||
|
side_effect=discord.HTTPException(mock_response, "Bad Gateway"),
|
||||||
|
):
|
||||||
with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
|
with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
|
||||||
with patch.object(runner.bot, "__aexit__", return_value=None):
|
with patch.object(runner.bot, "__aexit__", return_value=None):
|
||||||
with patch("sys.exit") as mock_exit:
|
with patch("sys.exit") as mock_exit:
|
||||||
@ -75,17 +81,25 @@ class TestBotErrorHandling:
|
|||||||
runner = bot.BotRunner()
|
runner = bot.BotRunner()
|
||||||
# Проверяем, что _on_shutdown и _on_shutdown_async методы существуют
|
# Проверяем, что _on_shutdown и _on_shutdown_async методы существуют
|
||||||
assert hasattr(runner, "_on_shutdown"), "Метод _on_shutdown должен существовать"
|
assert hasattr(runner, "_on_shutdown"), "Метод _on_shutdown должен существовать"
|
||||||
assert hasattr(runner, "_on_shutdown_async"), "Метод _on_shutdown_async должен существовать"
|
assert hasattr(runner, "_on_shutdown_async"), (
|
||||||
|
"Метод _on_shutdown_async должен существовать"
|
||||||
|
)
|
||||||
# Проверяем, что signal модуль НЕ импортирован в bot.py
|
# Проверяем, что signal модуль НЕ импортирован в bot.py
|
||||||
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
assert "signal.signal" not in content, "Не должно быть signal.signal — используется on_shutdown"
|
assert "signal.signal" not in content, (
|
||||||
|
"Не должно быть signal.signal — используется on_shutdown"
|
||||||
|
)
|
||||||
|
|
||||||
def test_code_uses_async_bot_pattern(self) -> None:
|
def test_code_uses_async_bot_pattern(self) -> None:
|
||||||
"""Проверка, что bot.py использует async with / asyncio.run."""
|
"""Проверка, что bot.py использует async with / asyncio.run."""
|
||||||
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
assert "async with self.bot" in content, "Должен быть паттерн 'async with self.bot'"
|
assert "async with self.bot" in content, (
|
||||||
|
"Должен быть паттерн 'async with self.bot'"
|
||||||
|
)
|
||||||
assert "asyncio.run(main())" in content, "Должен быть вызов asyncio.run()"
|
assert "asyncio.run(main())" in content, "Должен быть вызов asyncio.run()"
|
||||||
assert "bot.run(token)" not in content, "Не должно быть bot.run(token) — это антипаттерн"
|
assert "bot.run(token)" not in content, (
|
||||||
|
"Не должно быть bot.run(token) — это антипаттерн"
|
||||||
|
)
|
||||||
|
|||||||
@ -176,7 +176,9 @@ class TestPgCommand:
|
|||||||
"""Описание погоды на русском должно корректно переводиться."""
|
"""Описание погоды на русском должно корректно переводиться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}])
|
weather = self._make_weather_data(
|
||||||
|
weatherDesc=[{"value": "Переменная облачность"}]
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
"""Тесты для команды !stats."""
|
"""Тесты для команды !stats."""
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from commands.stats import Stats
|
from commands.stats import Stats
|
||||||
|
|
||||||
|
|
||||||
class TestStatsCommand:
|
class TestStatsCommand:
|
||||||
"""Тесты Discord-команды stats."""
|
"""Тесты Discord-команды stats."""
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"""Тесты для команды !status."""
|
"""Тесты для команды !status."""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
from commands.status import Status
|
from commands.status import Status
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import json
|
|
||||||
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
|
||||||
@ -63,7 +61,9 @@ class TestFetchCat:
|
|||||||
async def test_fetch_cat_json_parse_error(self, mock_get) -> None:
|
async def test_fetch_cat_json_parse_error(self, mock_get) -> None:
|
||||||
"""Ошибка парсинга JSON должна вернуть None."""
|
"""Ошибка парсинга JSON должна вернуть None."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0)
|
mock_response.json.side_effect = requests.JSONDecodeError(
|
||||||
|
"Expecting value", "", 0
|
||||||
|
)
|
||||||
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_cat()
|
result = await fetch_cat()
|
||||||
@ -90,7 +90,9 @@ class TestFetchCat:
|
|||||||
async def test_fetch_cat_url_with_special_chars(self, mock_get) -> None:
|
async def test_fetch_cat_url_with_special_chars(self, mock_get) -> None:
|
||||||
"""URL со спецсимволами должен вернуться как есть."""
|
"""URL со спецсимволами должен вернуться как есть."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}]
|
mock_response.json.return_value = [
|
||||||
|
{"url": "https://example.com/cat?w=100&h=200"}
|
||||||
|
]
|
||||||
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_cat()
|
result = await fetch_cat()
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
import pytest
|
|
||||||
import requests
|
import requests
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.news import fetch_rss
|
from utils.news import fetch_rss
|
||||||
@ -158,12 +156,14 @@ class TestFetchRss:
|
|||||||
</item>"""
|
</item>"""
|
||||||
for i in range(15)
|
for i in range(15)
|
||||||
)
|
)
|
||||||
rss_content = (f"""<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = (
|
||||||
|
f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
{items}
|
{items}
|
||||||
</channel>
|
</channel>
|
||||||
</rss>""").encode()
|
</rss>"""
|
||||||
|
).encode()
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.content = rss_content
|
mock_response.content = rss_content
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
@ -409,4 +409,10 @@ class TestFetchRss:
|
|||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = await fetch_rss("https://example.com/rss")
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result[0]["tags"] == ["AI", "ML", "Deep Learning", "NLP", "Computer Vision"]
|
assert result[0]["tags"] == [
|
||||||
|
"AI",
|
||||||
|
"ML",
|
||||||
|
"Deep Learning",
|
||||||
|
"NLP",
|
||||||
|
"Computer Vision",
|
||||||
|
]
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
@ -108,7 +107,16 @@ class TestFetchOpenMeteo:
|
|||||||
async def test_fetch_open_meteo_custom_coords(self, mock_get) -> None:
|
async def test_fetch_open_meteo_custom_coords(self, mock_get) -> None:
|
||||||
"""Кастомные координаты должны быть в URL."""
|
"""Кастомные координаты должны быть в URL."""
|
||||||
mock_response = MagicMock()
|
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}}
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
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_open_meteo(lat=55.7558, lon=37.6173)
|
||||||
@ -127,13 +135,15 @@ class TestFetchOpenMeteo:
|
|||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
result = await fetch_open_meteo()
|
result = await fetch_open_meteo()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
|
assert result["current_condition"][0]["weatherDesc"] == [
|
||||||
|
{"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_open_meteo_ssl_error(self, mock_get) -> None:
|
||||||
"""SSLError → вернуть None."""
|
"""SSLError → вернуть None."""
|
||||||
mock_get.side_effect = SSLError("SSL Error")
|
mock_get.side_effect = SSLError("SSL Error")
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
with patch("utils.pogoda.fetch_open_meteo"):
|
||||||
# Внутренний fallback тоже падает, проверяем что возвращается None
|
# Внутренний fallback тоже падает, проверяем что возвращается None
|
||||||
pass
|
pass
|
||||||
result = await fetch_open_meteo()
|
result = await fetch_open_meteo()
|
||||||
@ -174,7 +184,16 @@ class TestFetchOpenMeteo:
|
|||||||
async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None:
|
async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None:
|
||||||
"""Retry: первая попытка падает, вторая успешна."""
|
"""Retry: первая попытка падает, вторая успешна."""
|
||||||
success_response = MagicMock()
|
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}}
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
success_response.raise_for_status = MagicMock()
|
success_response.raise_for_status = MagicMock()
|
||||||
mock_get.side_effect = [ConnectionError("fail"), success_response]
|
mock_get.side_effect = [ConnectionError("fail"), success_response]
|
||||||
result = await fetch_open_meteo(max_retries=2)
|
result = await fetch_open_meteo(max_retries=2)
|
||||||
@ -184,7 +203,11 @@ class TestFetchOpenMeteo:
|
|||||||
@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_open_meteo_all_retries_fail(self, mock_get) -> None:
|
||||||
"""Все попытки неудачны → None."""
|
"""Все попытки неудачны → None."""
|
||||||
mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")]
|
mock_get.side_effect = [
|
||||||
|
ConnectionError("fail"),
|
||||||
|
ConnectionError("fail"),
|
||||||
|
ConnectionError("fail"),
|
||||||
|
]
|
||||||
result = await fetch_open_meteo(max_retries=3)
|
result = await fetch_open_meteo(max_retries=3)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert mock_get.call_count == 3
|
assert mock_get.call_count == 3
|
||||||
|
|||||||
@ -9,7 +9,11 @@ class TestTruncateTitle:
|
|||||||
"title, max_len, expected",
|
"title, max_len, expected",
|
||||||
[
|
[
|
||||||
("Короткий заголовок", 60, "Короткий заголовок"),
|
("Короткий заголовок", 60, "Короткий заголовок"),
|
||||||
("Заголовок ровно в 60 символов1234567890", 60, "Заголовок ровно в 60 символов1234567890"),
|
(
|
||||||
|
"Заголовок ровно в 60 символов1234567890",
|
||||||
|
60,
|
||||||
|
"Заголовок ровно в 60 символов1234567890",
|
||||||
|
),
|
||||||
("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения
|
("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения
|
||||||
("", 60, ""),
|
("", 60, ""),
|
||||||
("A" * 100, 100, "A" * 100),
|
("A" * 100, 100, "A" * 100),
|
||||||
@ -60,7 +64,9 @@ class TestParseDate:
|
|||||||
def test_parse_date_invalid(self) -> None:
|
def test_parse_date_invalid(self) -> None:
|
||||||
"""Невалидная дата должна вернуть первые 10 символов."""
|
"""Невалидная дата должна вернуть первые 10 символов."""
|
||||||
result = _parse_date("invalid-date-string")
|
result = _parse_date("invalid-date-string")
|
||||||
assert result == "invalid.da" # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
assert (
|
||||||
|
result == "invalid.da"
|
||||||
|
) # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
||||||
|
|
||||||
|
|
||||||
class TestFormatArticles:
|
class TestFormatArticles:
|
||||||
@ -93,7 +99,13 @@ class TestFormatArticles:
|
|||||||
def test_format_articles_limit_to_5(self) -> None:
|
def test_format_articles_limit_to_5(self) -> None:
|
||||||
"""Больше 5 статей должно быть обрезано до 5."""
|
"""Больше 5 статей должно быть обрезано до 5."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": f"Статья {i}",
|
||||||
|
"link": f"https://habr.com/{i}",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
for i in range(10)
|
for i in range(10)
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
@ -131,7 +143,13 @@ class TestFormatArticles:
|
|||||||
"""Длинный заголовок должен быть обрезан до 60 символов с '...'."""
|
"""Длинный заголовок должен быть обрезан до 60 символов с '...'."""
|
||||||
long_title = "A" * 100
|
long_title = "A" * 100
|
||||||
articles = [
|
articles = [
|
||||||
{"title": long_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": long_title,
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
|
assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
|
||||||
@ -141,7 +159,13 @@ class TestFormatArticles:
|
|||||||
"""Короткий заголовок должен остаться без изменений."""
|
"""Короткий заголовок должен остаться без изменений."""
|
||||||
short_title = "Кот"
|
short_title = "Кот"
|
||||||
articles = [
|
articles = [
|
||||||
{"title": short_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": short_title,
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1].split("\n")[0] == "Кот"
|
assert result[1].split("\n")[0] == "Кот"
|
||||||
@ -150,7 +174,13 @@ class TestFormatArticles:
|
|||||||
"""Заголовок ровно 60 символов не должен обрезаться."""
|
"""Заголовок ровно 60 символов не должен обрезаться."""
|
||||||
exact_title = "A" * 60
|
exact_title = "A" * 60
|
||||||
articles = [
|
articles = [
|
||||||
{"title": exact_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": exact_title,
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1].split("\n")[0] == exact_title
|
assert result[1].split("\n")[0] == exact_title
|
||||||
@ -173,7 +203,13 @@ class TestFormatArticles:
|
|||||||
def test_format_articles_empty_date(self) -> None:
|
def test_format_articles_empty_date(self) -> None:
|
||||||
"""Пустая дата должна быть пустой строкой."""
|
"""Пустая дата должна быть пустой строкой."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "https://habr.com/1", "pub_date": "", "creator": "", "tags": []}
|
{
|
||||||
|
"title": "Статья",
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": "",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1] == "Статья\n <https://habr.com/1>"
|
assert result[1] == "Статья\n <https://habr.com/1>"
|
||||||
@ -181,7 +217,13 @@ class TestFormatArticles:
|
|||||||
def test_format_articles_none_date(self) -> None:
|
def test_format_articles_none_date(self) -> None:
|
||||||
"""None дата должна быть пустой строкой."""
|
"""None дата должна быть пустой строкой."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "https://habr.com/1", "pub_date": None, "creator": "", "tags": []}
|
{
|
||||||
|
"title": "Статья",
|
||||||
|
"link": "https://habr.com/1",
|
||||||
|
"pub_date": None,
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1] == "Статья\n <https://habr.com/1>"
|
assert result[1] == "Статья\n <https://habr.com/1>"
|
||||||
@ -189,7 +231,13 @@ class TestFormatArticles:
|
|||||||
def test_format_articles_empty_link(self) -> None:
|
def test_format_articles_empty_link(self) -> None:
|
||||||
"""Пустая ссылка должна быть пустой строкой в угловых скобках."""
|
"""Пустая ссылка должна быть пустой строкой в угловых скобках."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": "Статья",
|
||||||
|
"link": "",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1].endswith(" <>")
|
assert result[1].endswith(" <>")
|
||||||
@ -211,7 +259,13 @@ class TestFormatArticles:
|
|||||||
def test_format_articles_exact_5_articles(self) -> None:
|
def test_format_articles_exact_5_articles(self) -> None:
|
||||||
"""Ровно 5 статей должно быть включено."""
|
"""Ровно 5 статей должно быть включено."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": f"Статья {i}",
|
||||||
|
"link": f"https://habr.com/{i}",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
for i in range(5)
|
for i in range(5)
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
@ -221,7 +275,13 @@ class TestFormatArticles:
|
|||||||
def test_format_articles_6th_article_excluded(self) -> None:
|
def test_format_articles_6th_article_excluded(self) -> None:
|
||||||
"""6-я статья должна быть исключена."""
|
"""6-я статья должна быть исключена."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{
|
||||||
|
"title": f"Статья {i}",
|
||||||
|
"link": f"https://habr.com/{i}",
|
||||||
|
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
for i in range(6)
|
for i in range(6)
|
||||||
]
|
]
|
||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
|
|||||||
@ -40,7 +40,9 @@ class TestHelpCommandDiscord:
|
|||||||
mock_ctx.bot.commands = [
|
mock_ctx.bot.commands = [
|
||||||
self._make_mock_command("pg", "Прогноз погоды в Магнитогорске"),
|
self._make_mock_command("pg", "Прогноз погоды в Магнитогорске"),
|
||||||
self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
|
self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
|
||||||
self._make_mock_command("morning", "Утренний дайджест: погода + новости + котик"),
|
self._make_mock_command(
|
||||||
|
"morning", "Утренний дайджест: погода + новости + котик"
|
||||||
|
),
|
||||||
self._make_mock_command("cat", "Случайный котик"),
|
self._make_mock_command("cat", "Случайный котик"),
|
||||||
]
|
]
|
||||||
mock_ctx.send = AsyncMock(side_effect=send_side_effect)
|
mock_ctx.send = AsyncMock(side_effect=send_side_effect)
|
||||||
@ -61,7 +63,7 @@ class TestHelpCommandDiscord:
|
|||||||
assert cmd in message, f"Команда {cmd} не найдена"
|
assert cmd in message, f"Команда {cmd} не найдена"
|
||||||
|
|
||||||
# Проверяем разделение тире между командой и описанием
|
# Проверяем разделение тире между командой и описанием
|
||||||
lines = [l.strip() for l in message.split("\n") if "—" in l]
|
lines = [line.strip() for line in message.split("\n") if "—" in line]
|
||||||
assert len(lines) >= 4
|
assert len(lines) >= 4
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
@ -20,6 +19,7 @@ async def loaded_bot():
|
|||||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
from commands import ALL_COMMANDS
|
from commands import ALL_COMMANDS
|
||||||
|
|
||||||
for cog_class in ALL_COMMANDS:
|
for cog_class in ALL_COMMANDS:
|
||||||
await bot.add_cog(cog_class())
|
await bot.add_cog(cog_class())
|
||||||
return bot
|
return bot
|
||||||
@ -32,6 +32,7 @@ class TestCogLoading:
|
|||||||
async def test_all_cogs_load(self, loaded_bot) -> None:
|
async def test_all_cogs_load(self, loaded_bot) -> None:
|
||||||
"""Все ког-модули должны загружаться без ошибок."""
|
"""Все ког-модули должны загружаться без ошибок."""
|
||||||
from commands import ALL_COMMANDS
|
from commands import ALL_COMMANDS
|
||||||
|
|
||||||
assert len(loaded_bot.cogs) == len(ALL_COMMANDS)
|
assert len(loaded_bot.cogs) == len(ALL_COMMANDS)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -92,6 +93,7 @@ class TestCommandFlow:
|
|||||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
from commands.stats import Stats
|
from commands.stats import Stats
|
||||||
|
|
||||||
await bot.add_cog(Stats())
|
await bot.add_cog(Stats())
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""Тесты для utils/logger.py — проверка настройки логирования."""
|
"""Тесты для utils/logger.py — проверка настройки логирования."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
@ -123,9 +124,6 @@ def test_log_message_format() -> None:
|
|||||||
assert "test message" in output
|
assert "test message" in output
|
||||||
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _isolated_logger():
|
def _isolated_logger():
|
||||||
"""Создать изолированный root-логгер без handlers из других тестов."""
|
"""Создать изолированный root-логгер без handlers из других тестов."""
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
"""Тесты для utils/morning_runner.py — Scheduler и run_morning."""
|
"""Тесты для utils/morning_runner.py — Scheduler и run_morning."""
|
||||||
|
|
||||||
import asyncio
|
from datetime import datetime
|
||||||
from datetime import date, datetime
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
|
||||||
|
|
||||||
import discord
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from utils.morning_runner import Scheduler, run_morning
|
from utils.morning_runner import Scheduler, run_morning
|
||||||
@ -31,7 +29,7 @@ class TestSchedulerInit:
|
|||||||
"""Инициализация должна вызывать _start_scheduler."""
|
"""Инициализация должна вызывать _start_scheduler."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||||
scheduler = Scheduler(bot)
|
Scheduler(bot)
|
||||||
mock_start.assert_called_once()
|
mock_start.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@ -81,8 +79,12 @@ class TestSchedulerStartStop:
|
|||||||
def test_stop_stops_task(self) -> None:
|
def test_stop_stops_task(self) -> None:
|
||||||
"""stop() должен остановить task."""
|
"""stop() должен остановить task."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task"):
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
|
scheduler._running = True
|
||||||
|
mock_task = MagicMock()
|
||||||
|
mock_task.done.return_value = False
|
||||||
|
scheduler._task = mock_task
|
||||||
scheduler.stop()
|
scheduler.stop()
|
||||||
assert scheduler._running is False
|
assert scheduler._running is False
|
||||||
|
|
||||||
@ -109,16 +111,50 @@ class TestRunMorning:
|
|||||||
|
|
||||||
weather_data = {
|
weather_data = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{"temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013"}
|
{
|
||||||
|
"temp_C": "20",
|
||||||
|
"FeelsLikeC": "22",
|
||||||
|
"weatherDesc": [{"value": "Clear"}],
|
||||||
|
"humidity": "50",
|
||||||
|
"windspeedKmph": "10",
|
||||||
|
"pressure": "1013",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
articles = [{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}]
|
articles = [
|
||||||
posts = [{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}]
|
{
|
||||||
|
"title": "Test",
|
||||||
|
"link": "http://test.com",
|
||||||
|
"pub_date": "Mon, 01 Jan 2026 00:00:00 GMT",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
posts = [
|
||||||
|
{
|
||||||
|
"title": "Test",
|
||||||
|
"link": "http://test.com",
|
||||||
|
"pub_date": "Mon, 01 Jan 2026 00:00:00 GMT",
|
||||||
|
"creator": "",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \
|
with (
|
||||||
patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[articles, posts])), \
|
patch(
|
||||||
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value="http://cat.jpg")), \
|
"utils.morning_runner.fetch_weather",
|
||||||
patch("utils.morning_runner.discord.Embed") as mock_embed:
|
new=AsyncMock(return_value=weather_data),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"utils.morning_runner.fetch_rss",
|
||||||
|
new=AsyncMock(side_effect=[articles, posts]),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"utils.morning_runner.fetch_cat",
|
||||||
|
new=AsyncMock(return_value="http://cat.jpg"),
|
||||||
|
),
|
||||||
|
patch("utils.morning_runner.discord.Embed"),
|
||||||
|
):
|
||||||
await run_morning(bot, channel)
|
await run_morning(bot, channel)
|
||||||
|
|
||||||
channel.send.assert_called_once()
|
channel.send.assert_called_once()
|
||||||
@ -140,11 +176,14 @@ class TestRunMorningWithFallback:
|
|||||||
channel.permissions_for.return_value.send_messages = True
|
channel.permissions_for.return_value.send_messages = True
|
||||||
|
|
||||||
# Все API возвращают None/пусто
|
# Все API возвращают None/пусто
|
||||||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)), \
|
with (
|
||||||
patch("utils.morning_runner.fetch_rss", new=AsyncMock(return_value=None)), \
|
patch(
|
||||||
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)), \
|
"utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)
|
||||||
patch("utils.morning_runner.discord.Embed") as mock_embed_class:
|
),
|
||||||
|
patch("utils.morning_runner.fetch_rss", new=AsyncMock(return_value=None)),
|
||||||
|
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)),
|
||||||
|
patch("utils.morning_runner.discord.Embed") as mock_embed_class,
|
||||||
|
):
|
||||||
embed_mock = AsyncMock()
|
embed_mock = AsyncMock()
|
||||||
mock_embed_class.return_value = embed_mock
|
mock_embed_class.return_value = embed_mock
|
||||||
|
|
||||||
@ -170,13 +209,28 @@ class TestRunMorningWithFallback:
|
|||||||
|
|
||||||
weather_data = {
|
weather_data = {
|
||||||
"current_condition": [
|
"current_condition": [
|
||||||
{"temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013"}
|
{
|
||||||
|
"temp_C": "20",
|
||||||
|
"FeelsLikeC": "22",
|
||||||
|
"weatherDesc": [{"value": "Clear"}],
|
||||||
|
"humidity": "50",
|
||||||
|
"windspeedKmph": "10",
|
||||||
|
"pressure": "1013",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \
|
with (
|
||||||
patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[None, None])), \
|
patch(
|
||||||
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)):
|
"utils.morning_runner.fetch_weather",
|
||||||
|
new=AsyncMock(return_value=weather_data),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"utils.morning_runner.fetch_rss",
|
||||||
|
new=AsyncMock(side_effect=[None, None]),
|
||||||
|
),
|
||||||
|
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)),
|
||||||
|
):
|
||||||
await run_morning(bot, channel)
|
await run_morning(bot, channel)
|
||||||
|
|
||||||
channel.send.assert_called_once()
|
channel.send.assert_called_once()
|
||||||
@ -186,4 +240,6 @@ class TestRunMorningWithFallback:
|
|||||||
# Проверяем, что в embed есть только погода и нет fallback сообщения
|
# Проверяем, что в embed есть только погода и нет fallback сообщения
|
||||||
embed_description = call_args["embed"].description
|
embed_description = call_args["embed"].description
|
||||||
assert "Погода в Магнитогорске" in embed_description
|
assert "Погода в Магнитогорске" in embed_description
|
||||||
assert "Не удалось получить данные из внешних источников" not in embed_description
|
assert (
|
||||||
|
"Не удалось получить данные из внешних источников" not in embed_description
|
||||||
|
)
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from utils.pogoda import translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console
|
from utils.pogoda import (
|
||||||
|
translate_weather,
|
||||||
|
pressure_to_mmhg,
|
||||||
|
wmo_to_russian,
|
||||||
|
format_weather_data_for_console,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestFormatWeatherDataForConsole:
|
class TestFormatWeatherDataForConsole:
|
||||||
@ -8,14 +13,16 @@ class TestFormatWeatherDataForConsole:
|
|||||||
def test_format_valid_data(self) -> None:
|
def test_format_valid_data(self) -> None:
|
||||||
"""Полные данные должны быть отформатированы корректно."""
|
"""Полные данные должны быть отформатированы корректно."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [
|
||||||
|
{
|
||||||
"temp_C": "25",
|
"temp_C": "25",
|
||||||
"FeelsLikeC": "26",
|
"FeelsLikeC": "26",
|
||||||
"weatherDesc": [{"value": "Clear"}],
|
"weatherDesc": [{"value": "Clear"}],
|
||||||
"humidity": "45",
|
"humidity": "45",
|
||||||
"windspeedKmph": "10",
|
"windspeedKmph": "10",
|
||||||
"pressure": "1013",
|
"pressure": "1013",
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
@ -30,9 +37,7 @@ class TestFormatWeatherDataForConsole:
|
|||||||
|
|
||||||
def test_format_empty_data(self) -> None:
|
def test_format_empty_data(self) -> None:
|
||||||
"""Пустые данные должны возвращать None."""
|
"""Пустые данные должны возвращать None."""
|
||||||
data = {
|
data = {"current_condition": [{}]}
|
||||||
"current_condition": [{}]
|
|
||||||
}
|
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
|
|
||||||
@ -49,14 +54,16 @@ class TestFormatWeatherDataForConsole:
|
|||||||
def test_format_with_dashes(self) -> None:
|
def test_format_with_dashes(self) -> None:
|
||||||
"""Неизвестные значения должны отображаться как '—'."""
|
"""Неизвестные значения должны отображаться как '—'."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [
|
||||||
|
{
|
||||||
"temp_C": "—",
|
"temp_C": "—",
|
||||||
"FeelsLikeC": "—",
|
"FeelsLikeC": "—",
|
||||||
"weatherDesc": [{"value": "—"}],
|
"weatherDesc": [{"value": "—"}],
|
||||||
"humidity": "—",
|
"humidity": "—",
|
||||||
"windspeedKmph": "—",
|
"windspeedKmph": "—",
|
||||||
"pressure": "—",
|
"pressure": "—",
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
@ -71,14 +78,16 @@ class TestFormatWeatherDataForConsole:
|
|||||||
def test_format_wind_conversion(self) -> None:
|
def test_format_wind_conversion(self) -> None:
|
||||||
"""Проверка конвертации ветра из км/ч в м/с."""
|
"""Проверка конвертации ветра из км/ч в м/с."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [
|
||||||
|
{
|
||||||
"temp_C": "20",
|
"temp_C": "20",
|
||||||
"FeelsLikeC": "19",
|
"FeelsLikeC": "19",
|
||||||
"weatherDesc": [{"value": "Cloudy"}],
|
"weatherDesc": [{"value": "Cloudy"}],
|
||||||
"humidity": "60",
|
"humidity": "60",
|
||||||
"windspeedKmph": "36",
|
"windspeedKmph": "36",
|
||||||
"pressure": "1000",
|
"pressure": "1000",
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
@ -88,14 +97,16 @@ class TestFormatWeatherDataForConsole:
|
|||||||
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": "Snow"}],
|
||||||
"humidity": "80",
|
"humidity": "80",
|
||||||
"windspeedKmph": "20",
|
"windspeedKmph": "20",
|
||||||
"pressure": "980",
|
"pressure": "980",
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
result = format_weather_data_for_console(data)
|
result = format_weather_data_for_console(data)
|
||||||
@ -105,7 +116,6 @@ class TestFormatWeatherDataForConsole:
|
|||||||
|
|
||||||
|
|
||||||
class TestTranslateWeather:
|
class TestTranslateWeather:
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"english, expected",
|
"english, expected",
|
||||||
[
|
[
|
||||||
@ -121,7 +131,10 @@ class TestTranslateWeather:
|
|||||||
("Light rain", "Небольшой дождь"),
|
("Light rain", "Небольшой дождь"),
|
||||||
("Moderate rain", "Умеренный дождь"),
|
("Moderate rain", "Умеренный дождь"),
|
||||||
("Heavy rain", "Сильный дождь"),
|
("Heavy rain", "Сильный дождь"),
|
||||||
("Moderate or heavy rain at times", "Дождь"), # длинный ключ проверяется первым
|
(
|
||||||
|
"Moderate or heavy rain at times",
|
||||||
|
"Дождь",
|
||||||
|
), # длинный ключ проверяется первым
|
||||||
("Heavy rain at times", "Сильный дождь"),
|
("Heavy rain at times", "Сильный дождь"),
|
||||||
("Light snow", "Небольшой снег"),
|
("Light snow", "Небольшой снег"),
|
||||||
("Moderate snow", "Умеренный снег"),
|
("Moderate snow", "Умеренный снег"),
|
||||||
|
|||||||
@ -30,6 +30,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@dataclass
|
@dataclass
|
||||||
class MorningData:
|
class MorningData:
|
||||||
"""Собранные данные для утреннего дайджеста."""
|
"""Собранные данные для утреннего дайджеста."""
|
||||||
|
|
||||||
weather: Optional[dict]
|
weather: Optional[dict]
|
||||||
articles: Optional[list]
|
articles: Optional[list]
|
||||||
posts: Optional[list]
|
posts: Optional[list]
|
||||||
@ -81,9 +82,11 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
|
|||||||
if data.articles is not None:
|
if data.articles is not None:
|
||||||
if data.articles:
|
if data.articles:
|
||||||
has_real_data = True
|
has_real_data = True
|
||||||
lines = format_articles(data.articles,
|
lines = format_articles(
|
||||||
|
data.articles,
|
||||||
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
|
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
|
||||||
|
)
|
||||||
description_lines.append("\n".join(lines))
|
description_lines.append("\n".join(lines))
|
||||||
else:
|
else:
|
||||||
description_lines.append("Новостей пока нет.")
|
description_lines.append("Новостей пока нет.")
|
||||||
@ -96,9 +99,11 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
|
|||||||
if data.posts is not None:
|
if data.posts is not None:
|
||||||
if data.posts:
|
if data.posts:
|
||||||
has_real_data = True
|
has_real_data = True
|
||||||
lines = format_articles(data.posts,
|
lines = format_articles(
|
||||||
|
data.posts,
|
||||||
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
||||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
|
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
|
||||||
|
)
|
||||||
description_lines.append("\n".join(lines))
|
description_lines.append("\n".join(lines))
|
||||||
else:
|
else:
|
||||||
description_lines.append("Новостей пока нет.")
|
description_lines.append("Новостей пока нет.")
|
||||||
@ -109,7 +114,7 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
|
|||||||
if not has_real_data:
|
if not has_real_data:
|
||||||
description_lines = [
|
description_lines = [
|
||||||
"Не удалось получить данные из внешних источников.",
|
"Не удалось получить данные из внешних источников.",
|
||||||
"Проверьте доступность API и повторите попытку позже."
|
"Проверьте доступность API и повторите попытку позже.",
|
||||||
]
|
]
|
||||||
|
|
||||||
description = "\n".join(description_lines)
|
description = "\n".join(description_lines)
|
||||||
@ -144,7 +149,9 @@ class Scheduler:
|
|||||||
try:
|
try:
|
||||||
self._target_channel_id = int(channel_id_str)
|
self._target_channel_id = int(channel_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str)
|
logger.warning(
|
||||||
|
"Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str
|
||||||
|
)
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._start_scheduler()
|
self._start_scheduler()
|
||||||
@ -229,10 +236,14 @@ class Scheduler:
|
|||||||
await run_morning(self.bot, channel)
|
await run_morning(self.bot, channel)
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка отправки в канал %s: %s", self._target_channel_id, e)
|
logger.error(
|
||||||
|
"Ошибка отправки в канал %s: %s", self._target_channel_id, e
|
||||||
|
)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
logger.warning("Канал с ID %s не текстовый — fallback", self._target_channel_id)
|
logger.warning(
|
||||||
|
"Канал с ID %s не текстовый — fallback", self._target_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
# Fallback: первый канал с правами send_messages
|
# Fallback: первый канал с правами send_messages
|
||||||
sent = False
|
sent = False
|
||||||
|
|||||||
@ -9,8 +9,12 @@ from utils.rate_limiter import habr_rss_limiter
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RSS_URL_ARTICLES = "https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru"
|
RSS_URL_ARTICLES = (
|
||||||
RSS_URL_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru"
|
"https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru"
|
||||||
|
)
|
||||||
|
RSS_URL_POSTS = (
|
||||||
|
"https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru"
|
||||||
|
)
|
||||||
|
|
||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
|
|
||||||
@ -62,13 +66,15 @@ async def fetch_rss(url: str) -> Optional[list[dict]]:
|
|||||||
creator = creator_el.text if creator_el is not None else ""
|
creator = creator_el.text if creator_el is not None else ""
|
||||||
tags = [cat.text for cat in categories if cat.text] if categories else []
|
tags = [cat.text for cat in categories if cat.text] if categories else []
|
||||||
|
|
||||||
articles.append({
|
articles.append(
|
||||||
|
{
|
||||||
"title": title,
|
"title": title,
|
||||||
"link": link,
|
"link": link,
|
||||||
"pub_date": pub_date,
|
"pub_date": pub_date,
|
||||||
"creator": creator,
|
"creator": creator,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return articles[:10]
|
return articles[:10]
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error("Ошибка при получении RSS (%s): %s", url, e)
|
logger.error("Ошибка при получении RSS (%s): %s", url, e)
|
||||||
|
|||||||
@ -14,7 +14,9 @@ API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
|||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -> Optional[dict]:
|
async def fetch_weather(
|
||||||
|
api_url: str, timeout: int = 10, max_retries: int = 3
|
||||||
|
) -> Optional[dict]:
|
||||||
"""Получить данные о погоде с retry."""
|
"""Получить данные о погоде с retry."""
|
||||||
await weather_limiter.acquire()
|
await weather_limiter.acquire()
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
@ -25,7 +27,9 @@ async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -
|
|||||||
except (SSLError, ConnectionError, Timeout):
|
except (SSLError, ConnectionError, Timeout):
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = 2**attempt
|
delay = 2**attempt
|
||||||
logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
|
logger.warning(
|
||||||
|
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
@ -37,7 +41,9 @@ async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -
|
|||||||
return await fetch_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]:
|
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."""
|
"""Fallback на Open-Meteo API."""
|
||||||
await open_meteo_limiter.acquire()
|
await open_meteo_limiter.acquire()
|
||||||
url = (
|
url = (
|
||||||
@ -56,19 +62,23 @@ async def fetch_open_meteo(lat: float = 53.4069, lon: float = 58.9797, timeout:
|
|||||||
weather_code = current.get("weather_code", None)
|
weather_code = current.get("weather_code", None)
|
||||||
desc = wmo_to_russian(weather_code)
|
desc = wmo_to_russian(weather_code)
|
||||||
return {
|
return {
|
||||||
"current_condition": [{
|
"current_condition": [
|
||||||
|
{
|
||||||
"temp_C": current.get("temperature", "—"),
|
"temp_C": current.get("temperature", "—"),
|
||||||
"FeelsLikeC": current.get("apparent_temperature", "—"),
|
"FeelsLikeC": current.get("apparent_temperature", "—"),
|
||||||
"weatherDesc": [{"value": desc}],
|
"weatherDesc": [{"value": desc}],
|
||||||
"humidity": current.get("relative_humidity_2m", "—"),
|
"humidity": current.get("relative_humidity_2m", "—"),
|
||||||
"windspeedKmph": current.get("wind_speed_10m", "—"),
|
"windspeedKmph": current.get("wind_speed_10m", "—"),
|
||||||
"pressure": current.get("pressure_msl", "—"),
|
"pressure": current.get("pressure_msl", "—"),
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
except (SSLError, ConnectionError, Timeout):
|
except (SSLError, ConnectionError, Timeout):
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = 2**attempt
|
delay = 2**attempt
|
||||||
logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
|
logger.warning(
|
||||||
|
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
@ -84,18 +94,33 @@ def wmo_to_russian(code: Optional[int]) -> str:
|
|||||||
"""Перевод WMO weather code в русский."""
|
"""Перевод WMO weather code в русский."""
|
||||||
mapping = {
|
mapping = {
|
||||||
0: "Ясно",
|
0: "Ясно",
|
||||||
1: "Ясно", 2: "Переменная облачность",
|
1: "Ясно",
|
||||||
|
2: "Переменная облачность",
|
||||||
3: "Пасмурно",
|
3: "Пасмурно",
|
||||||
45: "Туман", 48: "Туман",
|
45: "Туман",
|
||||||
51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось",
|
48: "Туман",
|
||||||
56: "Ледяная морось", 57: "Сильная ледяная морось",
|
51: "Лёгкая морось",
|
||||||
61: "Небольшой дождь", 63: "Дождь", 65: "Сильный дождь",
|
53: "Морось",
|
||||||
66: "Ледяной дождь", 67: "Сильный ледяной дождь",
|
55: "Сильная морось",
|
||||||
71: "Небольшой снег", 73: "Снег", 75: "Сильный снег",
|
56: "Ледяная морось",
|
||||||
|
57: "Сильная ледяная морось",
|
||||||
|
61: "Небольшой дождь",
|
||||||
|
63: "Дождь",
|
||||||
|
65: "Сильный дождь",
|
||||||
|
66: "Ледяной дождь",
|
||||||
|
67: "Сильный ледяной дождь",
|
||||||
|
71: "Небольшой снег",
|
||||||
|
73: "Снег",
|
||||||
|
75: "Сильный снег",
|
||||||
77: "Снежная крупа",
|
77: "Снежная крупа",
|
||||||
80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень",
|
80: "Небольшой ливень",
|
||||||
85: "Снежный ливень", 86: "Сильный снежный ливень",
|
81: "Ливень",
|
||||||
95: "Гроза", 96: "Гроза с градом", 99: "Сильная гроза с градом",
|
82: "Сильный ливень",
|
||||||
|
85: "Снежный ливень",
|
||||||
|
86: "Сильный снежный ливень",
|
||||||
|
95: "Гроза",
|
||||||
|
96: "Гроза с градом",
|
||||||
|
99: "Сильная гроза с градом",
|
||||||
}
|
}
|
||||||
return mapping.get(code, "Неизвестно")
|
return mapping.get(code, "Неизвестно")
|
||||||
|
|
||||||
@ -169,7 +194,9 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
|
|||||||
|
|
||||||
temp = current.get("temp_C", "—")
|
temp = current.get("temp_C", "—")
|
||||||
feels_like = current.get("FeelsLikeC", "—")
|
feels_like = current.get("FeelsLikeC", "—")
|
||||||
description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—"))
|
description = translate_weather(
|
||||||
|
current.get("weatherDesc", [{}])[0].get("value", "—")
|
||||||
|
)
|
||||||
humidity = current.get("humidity", "—")
|
humidity = current.get("humidity", "—")
|
||||||
wind_kmh = current.get("windspeedKmph", "—")
|
wind_kmh = current.get("windspeedKmph", "—")
|
||||||
try:
|
try:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user