Compare commits

..

14 Commits

Author SHA1 Message Date
deadzilla
b98c696498 Исправлена версия defusedxml: 7.0.0 -> 0.7.0 (версии 7.0.0 не существует) 2026-07-09 00:07:01 +05:00
deadzilla
7ade3147cf форматирование: применён ruff format ко всем файлам проекта (21 файл) 2026-07-08 00:44:22 +05:00
deadzilla
d20957ac5f исправлено: добавлены noqa: E402 для импортов после monkey-patch в bot.py 2026-07-08 00:40:37 +05:00
deadzilla
4b61909ebc исправлено: убраны неиспользуемые импорты и переменные из tests/test_morning_runner.py 2026-07-08 00:40:08 +05:00
deadzilla
4e816d5998 исправлено: перемещён import contextlib в начало файла 2026-07-08 00:39:43 +05:00
deadzilla
b21237b042 исправлено: убран неиспользуемый import requests из tests/test_integration.py 2026-07-08 00:39:27 +05:00
deadzilla
0fa467d40e исправлено: переименована неоднозначная переменная l в line 2026-07-08 00:39:12 +05:00
deadzilla
a1b72ecbcf исправлено: убрана неиспользуемая переменная mock_fallback в test_fetch_open_meteo_ssl_error 2026-07-08 00:38:59 +05:00
deadzilla
00ec2eac0e исправлено: убран неиспользуемый import pytest из tests/test_fetch_rss.py 2026-07-08 00:38:16 +05:00
deadzilla
ab0039661a исправлено: убраны неиспользуемые импорты json и pytest из tests/test_fetch_cat.py 2026-07-08 00:37:35 +05:00
deadzilla
5a2ff483a1 исправлено: убраны неиспользуемые импорты pytest и patch из tests/test_commands_status.py 2026-07-08 00:37:16 +05:00
deadzilla
cd25d63f5b исправлено: убран неиспользуемый import pytest из tests/test_commands_stats.py 2026-07-08 00:37:04 +05:00
deadzilla
be0aa5211a исправлено: убран неиспользуемый import discord из commands/morning.py 2026-07-08 00:36:50 +05:00
deadzilla
02b6b10ea3 исправлено: убран неиспользуемый import discord из commands/help.py 2026-07-08 00:36:41 +05:00
24 changed files with 431 additions and 188 deletions

28
bot.py
View File

@ -11,13 +11,13 @@ from typing import TYPE_CHECKING
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
import discord
from discord.ext import commands
from discord.ext.commands import CommandNotFound
from dotenv import load_dotenv
import discord # noqa: E402
from discord.ext import commands # noqa: E402
from discord.ext.commands import CommandNotFound # noqa: E402
from dotenv import load_dotenv # noqa: E402
from commands import ALL_COMMANDS
from utils.morning_runner import Scheduler
from commands import ALL_COMMANDS # noqa: E402
from utils.morning_runner import Scheduler # noqa: E402
if TYPE_CHECKING:
from utils.morning_runner import Scheduler as SchedulerType
@ -74,7 +74,9 @@ class BotRunner:
morning_time = os.getenv("MORNING_TIME", "07:00")
self.scheduler = Scheduler(self.bot, morning_time)
self.bot._scheduler = self.scheduler
logger.info("Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name)
logger.info(
"Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name
)
@self.bot.event
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 "?"
logger.error(
"Ошибка команды %s: %s", cmd_name, error,
"Ошибка команды %s: %s",
cmd_name,
error,
exc_info=True,
)
@ -146,7 +150,9 @@ class BotRunner:
logger.critical(
"Непредвиденная ошибка при запуске бота: %s", e, exc_info=True
)
logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__)
logger.error(
"Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__
)
sys.exit(1)
finally:
# Context manager (async with self.bot) закрывает бота автоматически
@ -174,9 +180,7 @@ def _validate_config() -> None:
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError
except (ValueError, AttributeError):
logger.error(
"Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time
)
logger.error("Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time)
sys.exit(1)
channel_id = os.getenv("MORNING_CHANNEL_ID")

View File

@ -15,14 +15,13 @@ class Cat(commands.Cog):
"""Получить случайного котика"""
url = await fetch_cat()
if url is None:
logger.warning("%s: !cat — не удалось получить котика (API вернул None)", ctx.author)
logger.warning(
"%s: !cat — не удалось получить котика (API вернул None)", ctx.author
)
await ctx.send("Не удалось получить котика. Попробуйте позже.")
return
embed = discord.Embed(
title="Котик для тебя!",
color=discord.Color.orange()
)
embed = discord.Embed(title="Котик для тебя!", color=discord.Color.orange())
embed.set_image(url=url)
await ctx.send(embed=embed)
logger.info("%s: !cat выполнена", ctx.author)

View File

@ -1,4 +1,3 @@
import discord
from discord.ext import commands
@ -28,5 +27,3 @@ class Help(commands.Cog):
message += "\n\n" + "=" * 40
await ctx.send(message)

View File

@ -1,5 +1,4 @@
import logging
import discord
from discord.ext import commands
from utils.morning_runner import run_morning

View File

@ -20,7 +20,9 @@ class News(commands.Cog):
"""Топ-5 свежих статей и новостей по AI с Habr"""
articles = await fetch_rss(RSS_URL_ARTICLES)
if articles is None:
logger.warning("%s: !nw — не удалось получить статьи (API вернул None)", ctx.author)
logger.warning(
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
)
await ctx.send("Не удалось получить новости. Попробуйте позже.")
return
@ -29,9 +31,11 @@ class News(commands.Cog):
await ctx.send("Новостей пока нет.")
return
articles_text = format_articles(articles,
articles_text = format_articles(
articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хаб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)
@ -47,16 +51,20 @@ class News(commands.Cog):
)
if posts is None:
logger.warning("%s: !nw — не удалось получить посты (API вернул None)", ctx.author)
logger.warning(
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
)
embed.add_field(
name="Новости",
value="Не удалось получить новости.",
inline=False,
)
elif posts:
posts_text = format_articles(posts,
posts_text = format_articles(
posts,
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
)
embed.add_field(
name="Новости",
value=truncate_embed_field("\n".join(posts_text)),
@ -71,4 +79,9 @@ class News(commands.Cog):
)
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,
)

View File

@ -16,7 +16,9 @@ class Pg(commands.Cog):
"""Прогноз погоды в Магнитогорске"""
data = await fetch_weather(self.api_url)
if data is None:
logger.warning("%s: !pg — не удалось получить погоду (API вернул None)", ctx.author)
logger.warning(
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
)
await ctx.send("Не удалось получить данные о погоде.")
return

View File

@ -14,7 +14,13 @@ class Stats(commands.Cog):
guilds = ctx.bot.guilds
total_guilds = len(guilds)
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
)
total_members = sum(guild.member_count or 0 for guild in guilds)

View File

@ -1,4 +1,4 @@
discord.py>=2.3.2
python-dotenv>=1.0.0
requests>=2.31.0
defusedxml>=7.0.0
defusedxml>=0.7.0

View File

@ -42,7 +42,9 @@ class TestBotErrorHandling:
import bot
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, "__aexit__", return_value=None):
with patch("sys.exit") as mock_exit:
@ -55,7 +57,11 @@ class TestBotErrorHandling:
runner = bot.BotRunner()
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, "__aexit__", return_value=None):
with patch("sys.exit") as mock_exit:
@ -75,17 +81,25 @@ class TestBotErrorHandling:
runner = bot.BotRunner()
# Проверяем, что _on_shutdown и _on_shutdown_async методы существуют
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
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
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:
"""Проверка, что bot.py использует async with / asyncio.run."""
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
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 "bot.run(token)" not in content, "Не должно быть bot.run(token) — это антипаттерн"
assert "bot.run(token)" not in content, (
"Не должно быть bot.run(token) — это антипаттерн"
)

View File

@ -176,7 +176,9 @@ class TestPgCommand:
"""Описание погоды на русском должно корректно переводиться."""
cog = self._make_cog()
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)):
await cog.pg.callback(cog, ctx)

View File

@ -1,9 +1,10 @@
"""Тесты для команды !stats."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from commands.stats import Stats
class TestStatsCommand:
"""Тесты Discord-команды stats."""

View File

@ -1,8 +1,8 @@
"""Тесты для команды !status."""
import time
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
from commands.status import Status

View File

@ -1,5 +1,3 @@
import json
import pytest
import requests
from requests.exceptions import ConnectionError, Timeout, SSLError
from unittest.mock import patch, MagicMock
@ -63,7 +61,9 @@ class TestFetchCat:
async def test_fetch_cat_json_parse_error(self, mock_get) -> None:
"""Ошибка парсинга JSON должна вернуть None."""
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_get.return_value = mock_response
result = await fetch_cat()
@ -90,7 +90,9 @@ class TestFetchCat:
async def test_fetch_cat_url_with_special_chars(self, mock_get) -> None:
"""URL со спецсимволами должен вернуться как есть."""
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_get.return_value = mock_response
result = await fetch_cat()

View File

@ -1,5 +1,3 @@
import pytest
import requests
from unittest.mock import patch, MagicMock
from utils.news import fetch_rss
@ -158,12 +156,14 @@ class TestFetchRss:
</item>"""
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">
<channel>
{items}
</channel>
</rss>""").encode()
</rss>"""
).encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
@ -409,4 +409,10 @@ class TestFetchRss:
mock_get.return_value = mock_response
result = await fetch_rss("https://example.com/rss")
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",
]

View File

@ -1,4 +1,3 @@
import pytest
import requests
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:
"""Кастомные координаты должны быть в URL."""
mock_response = MagicMock()
mock_response.json.return_value = {"current": {"temperature": 25, "apparent_temperature": 22, "weather_code": 0, "wind_speed_10m": 3, "relative_humidity_2m": 50, "pressure_msl": 1020}}
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_get.return_value = mock_response
result = await fetch_open_meteo(lat=55.7558, lon=37.6173)
@ -127,13 +135,15 @@ class TestFetchOpenMeteo:
mock_get.return_value = mock_response
result = await fetch_open_meteo()
assert result is not None
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
assert result["current_condition"][0]["weatherDesc"] == [
{"value": "Неизвестно"}
]
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None:
"""SSLError → вернуть None."""
mock_get.side_effect = SSLError("SSL Error")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
with patch("utils.pogoda.fetch_open_meteo"):
# Внутренний fallback тоже падает, проверяем что возвращается None
pass
result = await fetch_open_meteo()
@ -174,7 +184,16 @@ class TestFetchOpenMeteo:
async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None:
"""Retry: первая попытка падает, вторая успешна."""
success_response = MagicMock()
success_response.json.return_value = {"current": {"temperature": 20, "apparent_temperature": 18, "weather_code": 1, "wind_speed_10m": 4, "relative_humidity_2m": 60, "pressure_msl": 1015}}
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()
mock_get.side_effect = [ConnectionError("fail"), success_response]
result = await fetch_open_meteo(max_retries=2)
@ -184,7 +203,11 @@ class TestFetchOpenMeteo:
@patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> 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)
assert result is None
assert mock_get.call_count == 3

View File

@ -9,7 +9,11 @@ class TestTruncateTitle:
"title, max_len, expected",
[
("Короткий заголовок", 60, "Короткий заголовок"),
("Заголовок ровно в 60 символов1234567890", 60, "Заголовок ровно в 60 символов1234567890"),
(
"Заголовок ровно в 60 символов1234567890",
60,
"Заголовок ровно в 60 символов1234567890",
),
("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения
("", 60, ""),
("A" * 100, 100, "A" * 100),
@ -60,7 +64,9 @@ class TestParseDate:
def test_parse_date_invalid(self) -> None:
"""Невалидная дата должна вернуть первые 10 символов."""
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:
@ -93,7 +99,13 @@ class TestFormatArticles:
def test_format_articles_limit_to_5(self) -> None:
"""Больше 5 статей должно быть обрезано до 5."""
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)
]
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
@ -131,7 +143,13 @@ class TestFormatArticles:
"""Длинный заголовок должен быть обрезан до 60 символов с '...'."""
long_title = "A" * 100
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")
assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
@ -141,7 +159,13 @@ class TestFormatArticles:
"""Короткий заголовок должен остаться без изменений."""
short_title = "Кот"
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")
assert result[1].split("\n")[0] == "Кот"
@ -150,7 +174,13 @@ class TestFormatArticles:
"""Заголовок ровно 60 символов не должен обрезаться."""
exact_title = "A" * 60
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")
assert result[1].split("\n")[0] == exact_title
@ -173,7 +203,13 @@ class TestFormatArticles:
def test_format_articles_empty_date(self) -> None:
"""Пустая дата должна быть пустой строкой."""
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")
assert result[1] == "Статья\n <https://habr.com/1>"
@ -181,7 +217,13 @@ class TestFormatArticles:
def test_format_articles_none_date(self) -> None:
"""None дата должна быть пустой строкой."""
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")
assert result[1] == "Статья\n <https://habr.com/1>"
@ -189,7 +231,13 @@ class TestFormatArticles:
def test_format_articles_empty_link(self) -> None:
"""Пустая ссылка должна быть пустой строкой в угловых скобках."""
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")
assert result[1].endswith(" <>")
@ -211,7 +259,13 @@ class TestFormatArticles:
def test_format_articles_exact_5_articles(self) -> None:
"""Ровно 5 статей должно быть включено."""
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)
]
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
@ -221,7 +275,13 @@ class TestFormatArticles:
def test_format_articles_6th_article_excluded(self) -> None:
"""6-я статья должна быть исключена."""
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)
]
result = format_articles(articles, "Заголовок", "https://habr.com/feed")

View File

@ -40,7 +40,9 @@ class TestHelpCommandDiscord:
mock_ctx.bot.commands = [
self._make_mock_command("pg", "Прогноз погоды в Магнитогорске"),
self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
self._make_mock_command("morning", "Утренний дайджест: погода + новости + котик"),
self._make_mock_command(
"morning", "Утренний дайджест: погода + новости + котик"
),
self._make_mock_command("cat", "Случайный котик"),
]
mock_ctx.send = AsyncMock(side_effect=send_side_effect)
@ -61,7 +63,7 @@ class TestHelpCommandDiscord:
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

View File

@ -5,7 +5,6 @@
"""
import pytest
import requests
from unittest.mock import AsyncMock, MagicMock, patch
@ -20,6 +19,7 @@ async def loaded_bot():
bot = commands.Bot(command_prefix="!", intents=intents)
from commands import ALL_COMMANDS
for cog_class in ALL_COMMANDS:
await bot.add_cog(cog_class())
return bot
@ -32,6 +32,7 @@ class TestCogLoading:
async def test_all_cogs_load(self, loaded_bot) -> None:
"""Все ког-модули должны загружаться без ошибок."""
from commands import ALL_COMMANDS
assert len(loaded_bot.cogs) == len(ALL_COMMANDS)
@pytest.mark.asyncio
@ -92,6 +93,7 @@ class TestCommandFlow:
bot = commands.Bot(command_prefix="!", intents=intents)
from commands.stats import Stats
await bot.add_cog(Stats())
mock_ctx = MagicMock()

View File

@ -1,5 +1,6 @@
"""Тесты для utils/logger.py — проверка настройки логирования."""
import contextlib
import io
import logging
import logging.handlers
@ -123,9 +124,6 @@ def test_log_message_format() -> None:
assert "test message" in output
import contextlib
@contextlib.contextmanager
def _isolated_logger():
"""Создать изолированный root-логгер без handlers из других тестов."""

View File

@ -1,10 +1,8 @@
"""Тесты для utils/morning_runner.py — Scheduler и run_morning."""
import asyncio
from datetime import date, datetime
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import discord
import pytest
from utils.morning_runner import Scheduler, run_morning
@ -31,7 +29,7 @@ class TestSchedulerInit:
"""Инициализация должна вызывать _start_scheduler."""
bot = AsyncMock()
with patch.object(Scheduler, "_start_scheduler") as mock_start:
scheduler = Scheduler(bot)
Scheduler(bot)
mock_start.assert_called_once()
@ -81,8 +79,12 @@ class TestSchedulerStartStop:
def test_stop_stops_task(self) -> None:
"""stop() должен остановить task."""
bot = AsyncMock()
with patch("asyncio.create_task"):
with patch.object(Scheduler, "_start_scheduler"):
scheduler = Scheduler(bot)
scheduler._running = True
mock_task = MagicMock()
mock_task.done.return_value = False
scheduler._task = mock_task
scheduler.stop()
assert scheduler._running is False
@ -109,16 +111,50 @@ class TestRunMorning:
weather_data = {
"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": []}]
posts = [{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}]
articles = [
{
"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)), \
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") as mock_embed:
with (
patch(
"utils.morning_runner.fetch_weather",
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)
channel.send.assert_called_once()
@ -140,11 +176,14 @@ class TestRunMorningWithFallback:
channel.permissions_for.return_value.send_messages = True
# Все API возвращают None/пусто
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)), \
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:
with (
patch(
"utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)
),
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()
mock_embed_class.return_value = embed_mock
@ -170,13 +209,28 @@ class TestRunMorningWithFallback:
weather_data = {
"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)), \
patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[None, None])), \
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)):
with (
patch(
"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)
channel.send.assert_called_once()
@ -186,4 +240,6 @@ class TestRunMorningWithFallback:
# Проверяем, что в embed есть только погода и нет fallback сообщения
embed_description = call_args["embed"].description
assert "Погода в Магнитогорске" in embed_description
assert "Не удалось получить данные из внешних источников" not in embed_description
assert (
"Не удалось получить данные из внешних источников" not in embed_description
)

View File

@ -1,5 +1,10 @@
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:
@ -8,14 +13,16 @@ class TestFormatWeatherDataForConsole:
def test_format_valid_data(self) -> None:
"""Полные данные должны быть отформатированы корректно."""
data = {
"current_condition": [{
"current_condition": [
{
"temp_C": "25",
"FeelsLikeC": "26",
"weatherDesc": [{"value": "Clear"}],
"humidity": "45",
"windspeedKmph": "10",
"pressure": "1013",
}]
}
]
}
result = format_weather_data_for_console(data)
@ -30,9 +37,7 @@ class TestFormatWeatherDataForConsole:
def test_format_empty_data(self) -> None:
"""Пустые данные должны возвращать None."""
data = {
"current_condition": [{}]
}
data = {"current_condition": [{}]}
result = format_weather_data_for_console(data)
@ -49,14 +54,16 @@ class TestFormatWeatherDataForConsole:
def test_format_with_dashes(self) -> None:
"""Неизвестные значения должны отображаться как ''."""
data = {
"current_condition": [{
"current_condition": [
{
"temp_C": "",
"FeelsLikeC": "",
"weatherDesc": [{"value": ""}],
"humidity": "",
"windspeedKmph": "",
"pressure": "",
}]
}
]
}
result = format_weather_data_for_console(data)
@ -71,14 +78,16 @@ class TestFormatWeatherDataForConsole:
def test_format_wind_conversion(self) -> None:
"""Проверка конвертации ветра из км/ч в м/с."""
data = {
"current_condition": [{
"current_condition": [
{
"temp_C": "20",
"FeelsLikeC": "19",
"weatherDesc": [{"value": "Cloudy"}],
"humidity": "60",
"windspeedKmph": "36",
"pressure": "1000",
}]
}
]
}
result = format_weather_data_for_console(data)
@ -88,14 +97,16 @@ class TestFormatWeatherDataForConsole:
def test_format_negative_temperature(self) -> None:
"""Отрицательная температура должна отображаться корректно."""
data = {
"current_condition": [{
"current_condition": [
{
"temp_C": "-5",
"FeelsLikeC": "-10",
"weatherDesc": [{"value": "Snow"}],
"humidity": "80",
"windspeedKmph": "20",
"pressure": "980",
}]
}
]
}
result = format_weather_data_for_console(data)
@ -105,7 +116,6 @@ class TestFormatWeatherDataForConsole:
class TestTranslateWeather:
@pytest.mark.parametrize(
"english, expected",
[
@ -121,7 +131,10 @@ class TestTranslateWeather:
("Light rain", "Небольшой дождь"),
("Moderate rain", "Умеренный дождь"),
("Heavy rain", "Сильный дождь"),
("Moderate or heavy rain at times", "Дождь"), # длинный ключ проверяется первым
(
"Moderate or heavy rain at times",
"Дождь",
), # длинный ключ проверяется первым
("Heavy rain at times", "Сильный дождь"),
("Light snow", "Небольшой снег"),
("Moderate snow", "Умеренный снег"),

View File

@ -30,6 +30,7 @@ logger = logging.getLogger(__name__)
@dataclass
class MorningData:
"""Собранные данные для утреннего дайджеста."""
weather: Optional[dict]
articles: 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:
has_real_data = True
lines = format_articles(data.articles,
lines = format_articles(
data.articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хаб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))
else:
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:
has_real_data = True
lines = format_articles(data.posts,
lines = format_articles(
data.posts,
"Лучшие новости за сутки / Искусственный интеллект / Хаб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))
else:
description_lines.append("Новостей пока нет.")
@ -109,7 +114,7 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
if not has_real_data:
description_lines = [
"Не удалось получить данные из внешних источников.",
"Проверьте доступность API и повторите попытку позже."
"Проверьте доступность API и повторите попытку позже.",
]
description = "\n".join(description_lines)
@ -144,7 +149,9 @@ class Scheduler:
try:
self._target_channel_id = int(channel_id_str)
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._running = False
self._start_scheduler()
@ -229,10 +236,14 @@ class Scheduler:
await run_morning(self.bot, channel)
return
except Exception as e:
logger.error("Ошибка отправки в канал %s: %s", self._target_channel_id, e)
logger.error(
"Ошибка отправки в канал %s: %s", self._target_channel_id, e
)
return
else:
logger.warning("Канал с ID %s не текстовый — fallback", self._target_channel_id)
logger.warning(
"Канал с ID %s не текстовый — fallback", self._target_channel_id
)
# Fallback: первый канал с правами send_messages
sent = False

View File

@ -9,8 +9,12 @@ from utils.rate_limiter import habr_rss_limiter
logger = logging.getLogger(__name__)
RSS_URL_ARTICLES = "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"
RSS_URL_ARTICLES = (
"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()
@ -62,13 +66,15 @@ async def fetch_rss(url: str) -> Optional[list[dict]]:
creator = creator_el.text if creator_el is not None else ""
tags = [cat.text for cat in categories if cat.text] if categories else []
articles.append({
articles.append(
{
"title": title,
"link": link,
"pub_date": pub_date,
"creator": creator,
"tags": tags,
})
}
)
return articles[:10]
except requests.exceptions.RequestException as e:
logger.error("Ошибка при получении RSS (%s): %s", url, e)
@ -98,14 +104,14 @@ def truncate_embed_text(text: str, max_len: int = 4096) -> str:
"""Обрезать текст для embed.description (лимит Discord: 4096 символов)."""
if len(text) <= max_len:
return text
return text[:max_len - 3] + "..."
return text[: max_len - 3] + "..."
def truncate_embed_field(text: str, max_len: int = 1024) -> str:
"""Обрезать текст для embed field value (лимит Discord: 1024 символа)."""
if len(text) <= max_len:
return text
return text[:max_len - 3] + "..."
return text[: max_len - 3] + "..."
def format_articles(articles: list[dict], title: str, link: str) -> list[str]:

View File

@ -14,7 +14,9 @@ API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
_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."""
await weather_limiter.acquire()
for attempt in range(max_retries):
@ -24,8 +26,10 @@ async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -
return response.json()
except (SSLError, ConnectionError, Timeout):
if attempt < max_retries - 1:
delay = 2 ** attempt
logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
delay = 2**attempt
logger.warning(
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
)
await asyncio.sleep(delay)
continue
break
@ -37,7 +41,9 @@ async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -
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."""
await open_meteo_limiter.acquire()
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)
desc = wmo_to_russian(weather_code)
return {
"current_condition": [{
"current_condition": [
{
"temp_C": current.get("temperature", ""),
"FeelsLikeC": current.get("apparent_temperature", ""),
"weatherDesc": [{"value": desc}],
"humidity": current.get("relative_humidity_2m", ""),
"windspeedKmph": current.get("wind_speed_10m", ""),
"pressure": current.get("pressure_msl", ""),
}]
}
]
}
except (SSLError, ConnectionError, Timeout):
if attempt < max_retries - 1:
delay = 2 ** attempt
logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
delay = 2**attempt
logger.warning(
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
)
await asyncio.sleep(delay)
continue
break
@ -84,18 +94,33 @@ def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский."""
mapping = {
0: "Ясно",
1: "Ясно", 2: "Переменная облачность",
1: "Ясно",
2: "Переменная облачность",
3: "Пасмурно",
45: "Туман", 48: "Туман",
51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось",
56: "Ледяная морось", 57: "Сильная ледяная морось",
61: "Небольшой дождь", 63: "Дождь", 65: "Сильный дождь",
66: "Ледяной дождь", 67: "Сильный ледяной дождь",
71: "Небольшой снег", 73: "Снег", 75: "Сильный снег",
45: "Туман",
48: "Туман",
51: "Лёгкая морось",
53: "Морось",
55: "Сильная морось",
56: "Ледяная морось",
57: "Сильная ледяная морось",
61: "Небольшой дождь",
63: "Дождь",
65: "Сильный дождь",
66: "Ледяной дождь",
67: "Сильный ледяной дождь",
71: "Небольшой снег",
73: "Снег",
75: "Сильный снег",
77: "Снежная крупа",
80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень",
85: "Снежный ливень", 86: "Сильный снежный ливень",
95: "Гроза", 96: "Гроза с градом", 99: "Сильная гроза с градом",
80: "Небольшой ливень",
81: "Ливень",
82: "Сильный ливень",
85: "Снежный ливень",
86: "Сильный снежный ливень",
95: "Гроза",
96: "Гроза с градом",
99: "Сильная гроза с градом",
}
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", "")
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", "")
wind_kmh = current.get("windspeedKmph", "")
try: