diff --git a/bot.py b/bot.py
index b24e870..e52d3a0 100644
--- a/bot.py
+++ b/bot.py
@@ -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")
diff --git a/commands/cat.py b/commands/cat.py
index 8e98043..4d736f0 100644
--- a/commands/cat.py
+++ b/commands/cat.py
@@ -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)
diff --git a/commands/help.py b/commands/help.py
index 6b4ae11..ff2af43 100644
--- a/commands/help.py
+++ b/commands/help.py
@@ -27,5 +27,3 @@ class Help(commands.Cog):
message += "\n\n" + "=" * 40
await ctx.send(message)
-
-
diff --git a/commands/news.py b/commands/news.py
index 726f3ec..4d87582 100644
--- a/commands/news.py
+++ b/commands/news.py
@@ -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,
- "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
- "https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
+ articles_text = format_articles(
+ articles,
+ "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
+ "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,
- "Лучшие новости за сутки / Искусственный интеллект / Хабr",
- "https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
+ posts_text = format_articles(
+ posts,
+ "Лучшие новости за сутки / Искусственный интеллект / Хабr",
+ "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,
+ )
diff --git a/commands/pg.py b/commands/pg.py
index 7df8137..8137dd2 100644
--- a/commands/pg.py
+++ b/commands/pg.py
@@ -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
diff --git a/commands/stats.py b/commands/stats.py
index 5aece5a..044364f 100644
--- a/commands/stats.py
+++ b/commands/stats.py
@@ -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)
diff --git a/tests/test_bot.py b/tests/test_bot.py
index 76e79f9..324efa3 100644
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -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) — это антипаттерн"
+ )
diff --git a/tests/test_commands_pg.py b/tests/test_commands_pg.py
index 1e813fc..2072b31 100644
--- a/tests/test_commands_pg.py
+++ b/tests/test_commands_pg.py
@@ -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)
diff --git a/tests/test_commands_stats.py b/tests/test_commands_stats.py
index 65d90a7..eefe07c 100644
--- a/tests/test_commands_stats.py
+++ b/tests/test_commands_stats.py
@@ -1,8 +1,10 @@
"""Тесты для команды !stats."""
+
from unittest.mock import AsyncMock, MagicMock
from commands.stats import Stats
+
class TestStatsCommand:
"""Тесты Discord-команды stats."""
diff --git a/tests/test_commands_status.py b/tests/test_commands_status.py
index 1fec71a..725b9c9 100644
--- a/tests/test_commands_status.py
+++ b/tests/test_commands_status.py
@@ -1,4 +1,5 @@
"""Тесты для команды !status."""
+
import time
from unittest.mock import AsyncMock, MagicMock
diff --git a/tests/test_fetch_cat.py b/tests/test_fetch_cat.py
index ac7f291..9f31afe 100644
--- a/tests/test_fetch_cat.py
+++ b/tests/test_fetch_cat.py
@@ -61,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()
@@ -88,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()
diff --git a/tests/test_fetch_rss.py b/tests/test_fetch_rss.py
index 5f78697..9e28391 100644
--- a/tests/test_fetch_rss.py
+++ b/tests/test_fetch_rss.py
@@ -1,4 +1,3 @@
-
import requests
from unittest.mock import patch, MagicMock
from utils.news import fetch_rss
@@ -157,12 +156,14 @@ class TestFetchRss:
"""
for i in range(15)
)
- rss_content = (f"""
+ rss_content = (
+ f"""
{items}
-""").encode()
+"""
+ ).encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
@@ -408,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",
+ ]
diff --git a/tests/test_fetch_weather.py b/tests/test_fetch_weather.py
index 6d42dd0..d014d12 100644
--- a/tests/test_fetch_weather.py
+++ b/tests/test_fetch_weather.py
@@ -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,7 +135,9 @@ 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:
@@ -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
diff --git a/tests/test_format_articles.py b/tests/test_format_articles.py
index 4602516..0115613 100644
--- a/tests/test_format_articles.py
+++ b/tests/test_format_articles.py
@@ -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 "
@@ -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 "
@@ -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")
diff --git a/tests/test_help_discord.py b/tests/test_help_discord.py
index f7a7324..ebdabfc 100644
--- a/tests/test_help_discord.py
+++ b/tests/test_help_discord.py
@@ -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)
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 84b8721..7640e67 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -19,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
@@ -31,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
@@ -91,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()
diff --git a/tests/test_morning_runner.py b/tests/test_morning_runner.py
index ec9d226..c8c0808 100644
--- a/tests/test_morning_runner.py
+++ b/tests/test_morning_runner.py
@@ -79,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
@@ -107,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"):
+ 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()
@@ -138,25 +176,28 @@ 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
-
+
await run_morning(bot, channel)
# Убедимся, что send был вызван
channel.send.assert_called_once()
call_args = channel.send.call_args[1]
assert "embed" in call_args
-
+
# Проверяем, что description содержит fallback сообщение
embed_description = call_args["embed"].description
assert "Не удалось получить данные из внешних источников" in embed_description
-
+
@pytest.mark.asyncio
async def test_run_morning_only_weather_data(self) -> None:
"""run_morning должен корректно обрабатывать только погоду без новостей."""
@@ -168,20 +209,37 @@ 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()
call_args = channel.send.call_args[1]
assert "embed" in call_args
-
+
# Проверяем, что в embed есть только погода и нет fallback сообщения
embed_description = call_args["embed"].description
assert "Погода в Магнитогорске" in embed_description
- assert "Не удалось получить данные из внешних источников" not in embed_description
+ assert (
+ "Не удалось получить данные из внешних источников" not in embed_description
+ )
diff --git a/tests/test_pogoda.py b/tests/test_pogoda.py
index 7a1b146..d6486ab 100644
--- a/tests/test_pogoda.py
+++ b/tests/test_pogoda.py
@@ -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": [{
- "temp_C": "25",
- "FeelsLikeC": "26",
- "weatherDesc": [{"value": "Clear"}],
- "humidity": "45",
- "windspeedKmph": "10",
- "pressure": "1013",
- }]
+ "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": [{
- "temp_C": "—",
- "FeelsLikeC": "—",
- "weatherDesc": [{"value": "—"}],
- "humidity": "—",
- "windspeedKmph": "—",
- "pressure": "—",
- }]
+ "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": [{
- "temp_C": "20",
- "FeelsLikeC": "19",
- "weatherDesc": [{"value": "Cloudy"}],
- "humidity": "60",
- "windspeedKmph": "36",
- "pressure": "1000",
- }]
+ "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": [{
- "temp_C": "-5",
- "FeelsLikeC": "-10",
- "weatherDesc": [{"value": "Snow"}],
- "humidity": "80",
- "windspeedKmph": "20",
- "pressure": "980",
- }]
+ "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", "Умеренный снег"),
diff --git a/utils/morning_runner.py b/utils/morning_runner.py
index 46afb41..63fc5a1 100644
--- a/utils/morning_runner.py
+++ b/utils/morning_runner.py
@@ -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,
- "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
- "https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
+ lines = format_articles(
+ data.articles,
+ "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
+ "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,
- "Лучшие новости за сутки / Искусственный интеллект / Хабr",
- "https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
+ lines = format_articles(
+ data.posts,
+ "Лучшие новости за сутки / Искусственный интеллект / Хабr",
+ "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
diff --git a/utils/news.py b/utils/news.py
index b937a22..8177d43 100644
--- a/utils/news.py
+++ b/utils/news.py
@@ -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({
- "title": title,
- "link": link,
- "pub_date": pub_date,
- "creator": creator,
- "tags": tags,
- })
+ 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]:
diff --git a/utils/pogoda.py b/utils/pogoda.py
index 9cb1b45..8818f9e 100644
--- a/utils/pogoda.py
+++ b/utils/pogoda.py
@@ -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": [{
- "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", "—"),
- }]
+ "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, "Неизвестно")
@@ -154,7 +179,7 @@ def translate_weather(en: Optional[str]) -> str:
def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]:
"""
Форматировать погодные данные для консольного вывода.
-
+
:param data: Ответ от API (dict)
:return: Строки с отформатированной погодой
"""
@@ -166,10 +191,12 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
current = current_condition_list[0]
if not current:
return None
-
+
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: