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: