Compare commits

..

No commits in common. "0d98d31e6173f2f8d65bade10dbe11c92d80dcc0" and "d4b457f94e0d0ae8b9510bbb6b0c5338382b34fb" have entirely different histories.

14 changed files with 51 additions and 89 deletions

View File

@ -2,7 +2,6 @@ DISCORD_TOKEN=your_bot_token_here
MORNING_TIME=07:00 MORNING_TIME=07:00
MORNING_CHANNEL_ID=channel_id MORNING_CHANNEL_ID=channel_id
LOG_LEVEL=INFO LOG_LEVEL=INFO
CAT_API_KEY=your_cat_api_key_here
CAT_API_RATE=1 CAT_API_RATE=1
CAT_API_BURST=3 CAT_API_BURST=3
WEATHER_API_RATE=1 WEATHER_API_RATE=1

View File

@ -56,15 +56,15 @@
### Средние ### Средние
- [x] ~~**`fromstring` импортирован внутри функции `fetch_rss`**~~ — вынесен на уровень модуля (`utils/news.py`) - [ ] **`fromstring` импортирован внутри функции `fetch_rss`** — `from defusedxml.ElementTree import fromstring` на строке 24, скрывает зависимость от линтеров и добавляет оверхед (`utils/news.py`)
- [x] ~~**`Scheduler._start_scheduler()` создаёт task синхронно**~~`__init__` больше не создаёт task; `start()` стал async-методом (`utils/morning_runner.py`, `bot.py`) - [ ] **`Scheduler._start_scheduler()` создаёт task синхронно** — `asyncio.create_task()` в `__init__` зависит от активного event loop неявно (`utils/morning_runner.py`, строка 119)
- [x] ~~**`Pg.__init__` хранит `self.api_url` как инстанс-переменную**~~ — удалён `__init__`, используется `API_URL_WEATHER` напрямую (`commands/pg.py`) - [ ] **`Pg.__init__` хранит `self.api_url` как инстанс-переменную** — копия константы `API_URL_WEATHER` без необходимости (`commands/pg.py`, строки 13-14)
- [x] ~~**`fetch_cat()` не передаёт `x-api-key`**~~ — добавлена поддержка `CAT_API_KEY` из окружения, заголовок `x-api-key` передаётся при наличии ключа (`utils/cat.py`) - [ ] **`fetch_cat()` не передаёт `x-api-key`** — TheCatAPI требует/рекомендует заголовок `x-api-key`, без него запросы могут быть ограничены (`utils/cat.py`, строка 15)
### Низкие ### Низкие
- [x] ~~**Глобальные экземпляры RateLimiter + factory-функции**~~ — дизайн подтверждён: глобальные синглтоны для production, factory-функции для тестов. Добавлен поясняющий комментарий (`utils/rate_limiter.py`) - [ ] **Глобальные экземпляры RateLimiter + factory-функции** — factory-функции `make_*_limiter()` существуют, но production-код импортирует глобальные переменные напрямую (`utils/rate_limiter.py`, строки 94-97)
- [x] ~~**`@pytest.mark.asyncio` избыточен**~~ — удалены 5 декораторов из `tests/test_integration.py` (`pytest.ini` имеет `asyncio_mode = auto`) - [ ] **`@pytest.mark.asyncio` избыточен** — `pytest.ini` уже содержит `asyncio_mode = auto` (`tests/test_integration.py`)
- [x] ~~**`Dockerfile` не копирует `tests/`**~~ — отклонено: Docker — production-окружение, тесты туда не нужны - [ ] **`Dockerfile` не копирует `tests/`** — нельзя запустить `pytest` внутри контейнера, затрудняет отладку в Docker
- [x] ~~**Приватные атрибуты на объекте бота**~~`START_TIME` вынесен на уровень модуля `bot.py`, `self.bot._scheduler` удалён (не использовался). Обновлены `commands/status.py` и тесты - [ ] **Приватные атрибуты на объекте бота**`self.bot._start_time` и `self.bot._scheduler` нарушают инкапсуляцию (`bot.py`, строки 116, 148)
- [x] ~~**Избыточная проверка `ctx` в `on_command_error`**~~ — удалены проверки `ctx and`, так как `ctx` гарантированно передан discord.py (`bot.py`) - [ ] **Избыточная проверка `ctx` в `on_command_error`**`ctx` всегда передан и не может быть `None` (`bot.py`, строки 138-143)

12
bot.py
View File

@ -23,9 +23,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Время запуска бота (на уровне модуля, чтобы не хранить на объекте discord.py Bot)
START_TIME: float = time.time()
class TextHelpCommand(commands.HelpCommand): class TextHelpCommand(commands.HelpCommand):
"""Выводит справку по командам простым текстом вместо embed.""" """Выводит справку по командам простым текстом вместо embed."""
@ -107,7 +104,7 @@ class BotRunner:
intents=intents, intents=intents,
help_command=TextHelpCommand(), help_command=TextHelpCommand(),
) )
self.bot._start_time = time.time()
self.stop_event = threading.Event() self.stop_event = threading.Event()
self.bot_ready = threading.Event() self.bot_ready = threading.Event()
self.scheduler: SchedulerType | None = None self.scheduler: SchedulerType | None = None
@ -145,7 +142,7 @@ class BotRunner:
morning_time = os.getenv("MORNING_TIME", "07:00") morning_time = os.getenv("MORNING_TIME", "07:00")
self.scheduler = Scheduler(self.bot, morning_time) self.scheduler = Scheduler(self.bot, morning_time)
await self.scheduler.start() self.bot._scheduler = self.scheduler
logger.info( logger.info(
"Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name "Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name
) )
@ -156,7 +153,7 @@ class BotRunner:
return return
# Терминал — детали для разработчика # Терминал — детали для разработчика
cmd_name = ctx.command.name if ctx.command else "?" cmd_name = ctx.command.name if ctx and ctx.command else "?"
logger.error( logger.error(
"Ошибка команды %s: %s", "Ошибка команды %s: %s",
cmd_name, cmd_name,
@ -168,7 +165,8 @@ class BotRunner:
# ctx.interaction есть только у slash-команд (AutoshardedInteractionContext) # ctx.interaction есть только у slash-команд (AutoshardedInteractionContext)
# Для текстовых команд (!prefix) атрибута нет — используем hasattr # Для текстовых команд (!prefix) атрибута нет — используем hasattr
if ( if (
hasattr(ctx, "interaction") ctx
and hasattr(ctx, "interaction")
and ctx.interaction and ctx.interaction
and ctx.interaction.response.is_done() and ctx.interaction.response.is_done()
): ):

View File

@ -8,10 +8,13 @@ logger = logging.getLogger(__name__)
class Pg(commands.Cog): class Pg(commands.Cog):
"""Команда !pg — прогноз погоды для Магнитогорска""" """Команда !pg — прогноз погоды для Магнитогорска"""
def __init__(self):
self.api_url = API_URL_WEATHER
@commands.command(name="pg") @commands.command(name="pg")
async def pg(self, ctx: commands.Context) -> None: async def pg(self, ctx: commands.Context) -> None:
"""Прогноз погоды в Магнитогорске""" """Прогноз погоды в Магнитогорске"""
data = await fetch_weather(API_URL_WEATHER) data = await fetch_weather(self.api_url)
if data is None: if data is None:
logger.warning( logger.warning(
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author "%s: !pg — не удалось получить погоду (API вернул None)", ctx.author

View File

@ -13,11 +13,9 @@ class Status(commands.Cog):
@commands.command(name="status") @commands.command(name="status")
async def status(self, ctx: commands.Context) -> None: async def status(self, ctx: commands.Context) -> None:
"""Статус бота: пинг к Discord gateway и время работы""" """Статус бота: пинг к Discord gateway и время работы"""
# Lazy import чтобы избежать циклического импорта (bot -> commands -> bot)
from bot import START_TIME # noqa: F401
latency_ms = round(ctx.bot.latency * 1000, 1) latency_ms = round(ctx.bot.latency * 1000, 1)
uptime_seconds = time.time() - START_TIME start_time = getattr(ctx.bot, "_start_time", time.time())
uptime_seconds = time.time() - start_time
uptime_str = self._format_uptime(uptime_seconds) uptime_str = self._format_uptime(uptime_seconds)
embed = discord.Embed( embed = discord.Embed(

View File

@ -7,14 +7,10 @@ from commands.pg import Pg
class TestPgInit: class TestPgInit:
"""Тесты инициализации Cog Pg.""" """Тесты инициализации Cog Pg."""
def test_uses_api_url_constant(self) -> None: def test_init_sets_api_url(self) -> None:
"""Pg использует API_URL_WEATHER напрямую (без инстанс-переменной).""" """__init__ должен устанавливать api_url."""
from utils.pogoda import API_URL_WEATHER
cog = Pg() cog = Pg()
# Pg не хранит api_url как инстанс-переменную — использует константу напрямую assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
assert not hasattr(cog, "api_url")
assert API_URL_WEATHER == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
class TestPgCommand: class TestPgCommand:

View File

@ -2,7 +2,7 @@
import time import time
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock
from commands.status import Status from commands.status import Status
@ -14,11 +14,11 @@ class TestStatusCommand:
"""Команда status отправляет embed-сообщение.""" """Команда status отправляет embed-сообщение."""
mock_ctx = MagicMock() mock_ctx = MagicMock()
mock_ctx.bot.latency = 0.042 mock_ctx.bot.latency = 0.042
mock_ctx.bot._start_time = time.time()
mock_ctx.send = AsyncMock(return_value=None) mock_ctx.send = AsyncMock(return_value=None)
with patch("bot.START_TIME", time.time()): cog = Status()
cog = Status() await cog.status(cog, mock_ctx)
await cog.status(cog, mock_ctx)
mock_ctx.send.assert_awaited_once() mock_ctx.send.assert_awaited_once()
call_args = mock_ctx.send.call_args call_args = mock_ctx.send.call_args
@ -30,11 +30,11 @@ class TestStatusCommand:
"""Uptime форматируется корректно.""" """Uptime форматируется корректно."""
mock_ctx = MagicMock() mock_ctx = MagicMock()
mock_ctx.bot.latency = 0.050 mock_ctx.bot.latency = 0.050
mock_ctx.bot._start_time = time.time() - 90061 # 1д 1ч 1м 1с
mock_ctx.send = AsyncMock(return_value=None) mock_ctx.send = AsyncMock(return_value=None)
with patch("bot.START_TIME", time.time() - 90061): # 1д 1ч 1м 1с cog = Status()
cog = Status() await cog.status(cog, mock_ctx)
await cog.status(cog, mock_ctx)
call_args = mock_ctx.send.call_args call_args = mock_ctx.send.call_args
embed = call_args[1]["embed"] if call_args[1] else call_args[0][0] embed = call_args[1]["embed"] if call_args[1] else call_args[0][0]

View File

@ -97,28 +97,3 @@ class TestFetchCat:
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_cat() result = await fetch_cat()
assert result == "https://example.com/cat?w=100&h=200" assert result == "https://example.com/cat?w=100&h=200"
@patch("utils.cat._session.get")
@patch("utils.cat._cat_api_key", "test-api-key-123")
async def test_fetch_cat_sends_api_key_header(self, mock_get) -> None:
"""При заданном CAT_API_KEY должен передаваться заголовок x-api-key."""
mock_response = MagicMock()
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
await fetch_cat()
# Проверяем, что headers переданы с x-api-key
call_kwargs = mock_get.call_args
assert call_kwargs[1].get("headers") == {"x-api-key": "test-api-key-123"}
@patch("utils.cat._session.get")
@patch("utils.cat._cat_api_key", None)
async def test_fetch_cat_no_api_key_header(self, mock_get) -> None:
"""При отсутствии CAT_API_KEY заголовок x-api-key не передаётся."""
mock_response = MagicMock()
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
await fetch_cat()
call_kwargs = mock_get.call_args
assert call_kwargs[1].get("headers") is None

View File

@ -28,12 +28,14 @@ async def loaded_bot():
class TestCogLoading: class TestCogLoading:
"""Проверка загрузки ког-модулей.""" """Проверка загрузки ког-модулей."""
@pytest.mark.asyncio
async def test_all_cogs_load(self, loaded_bot) -> None: async def test_all_cogs_load(self, loaded_bot) -> None:
"""Все ког-модули должны загружаться без ошибок.""" """Все ког-модули должны загружаться без ошибок."""
from commands import ALL_COMMANDS from commands import ALL_COMMANDS
assert len(loaded_bot.cogs) == len(ALL_COMMANDS) assert len(loaded_bot.cogs) == len(ALL_COMMANDS)
@pytest.mark.asyncio
async def test_commands_registered(self, loaded_bot) -> None: async def test_commands_registered(self, loaded_bot) -> None:
"""Все команды должны быть зарегистрированы.""" """Все команды должны быть зарегистрированы."""
command_names = {cmd.name for cmd in loaded_bot.commands if cmd.cog is not None} command_names = {cmd.name for cmd in loaded_bot.commands if cmd.cog is not None}
@ -44,6 +46,7 @@ class TestCogLoading:
class TestCommandFlow: class TestCommandFlow:
"""Проверка полного потока команд (без сетевых вызовов).""" """Проверка полного потока команд (без сетевых вызовов)."""
@pytest.mark.asyncio
async def test_cat_command_success(self, loaded_bot) -> None: async def test_cat_command_success(self, loaded_bot) -> None:
"""Команда !cat должна отправить embed с котиком при успешном ответе API.""" """Команда !cat должна отправить embed с котиком при успешном ответе API."""
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch: with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch:
@ -62,6 +65,7 @@ class TestCommandFlow:
assert embed.title == "Котик для тебя!" assert embed.title == "Котик для тебя!"
assert embed.image.url == "https://example.com/cat.jpg" assert embed.image.url == "https://example.com/cat.jpg"
@pytest.mark.asyncio
async def test_cat_command_failure(self, loaded_bot) -> None: async def test_cat_command_failure(self, loaded_bot) -> None:
"""Команда !cat при ошибке API должна отправить fallback сообщение.""" """Команда !cat при ошибке API должна отправить fallback сообщение."""
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch: with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch:
@ -78,6 +82,7 @@ class TestCommandFlow:
content = mock_ctx.send.call_args[0][0] content = mock_ctx.send.call_args[0][0]
assert "Не удалось получить котика" in content assert "Не удалось получить котика" in content
@pytest.mark.asyncio
async def test_stats_command_flow(self) -> None: async def test_stats_command_flow(self) -> None:
"""Команда !stats должна показать реальную статистику бота.""" """Команда !stats должна показать реальную статистику бота."""
import discord import discord

View File

@ -25,12 +25,12 @@ class TestSchedulerInit:
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
assert scheduler.morning_time == "07:00" assert scheduler.morning_time == "07:00"
def test_init_does_not_start_scheduler(self) -> None: def test_init_creates_task(self) -> None:
"""Инициализация не должна запускать планировщик (start() вызывается отдельно).""" """Инициализация должна вызывать _start_scheduler."""
bot = AsyncMock() bot = AsyncMock()
with patch.object(Scheduler, "_start_scheduler") as mock_start: with patch.object(Scheduler, "_start_scheduler") as mock_start:
Scheduler(bot) Scheduler(bot)
mock_start.assert_not_called() mock_start.assert_called_once()
class TestSchedulerCalculateNextRun: class TestSchedulerCalculateNextRun:
@ -67,14 +67,14 @@ class TestSchedulerCalculateNextRun:
class TestSchedulerStartStop: class TestSchedulerStartStop:
"""Тесты запуска/остановки планировщика.""" """Тесты запуска/остановки планировщика."""
@pytest.mark.asyncio def test_start_starts_task(self) -> None:
async def test_start_starts_task(self) -> None: """start() должен вызывать _start_scheduler (1 в __init__ + 1 в start, но реальный task один)."""
"""start() должен вызывать _start_scheduler один раз."""
bot = AsyncMock() bot = AsyncMock()
with patch.object(Scheduler, "_start_scheduler") as mock_start: with patch.object(Scheduler, "_start_scheduler") as mock_start:
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
await scheduler.start() scheduler.start()
mock_start.assert_called_once() # __init__ вызывает _start_scheduler, start() тоже вызывает
assert mock_start.call_count == 2
def test_stop_stops_task(self) -> None: def test_stop_stops_task(self) -> None:
"""stop() должен остановить task.""" """stop() должен остановить task."""
@ -88,15 +88,12 @@ class TestSchedulerStartStop:
scheduler.stop() scheduler.stop()
assert scheduler._running is False assert scheduler._running is False
@pytest.mark.asyncio def test_double_start_no_duplicate(self) -> None:
async def test_double_start_no_duplicate(self) -> None: """Повторный start должен вызывать _start_scheduler дважды (реальный task не дублируется благодаря флагам)."""
"""Повторный start() не должен дублировать task (благодаря флагам)."""
bot = AsyncMock() bot = AsyncMock()
with patch.object(Scheduler, "_start_scheduler") as mock_start: with patch.object(Scheduler, "_start_scheduler") as mock_start:
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
await scheduler.start() scheduler.start() # второй вызов
await scheduler.start() # повторный вызов
# _start_scheduler вызывается 2 раза, но реальный task один (флаг _running защищает)
assert mock_start.call_count == 2 assert mock_start.call_count == 2

View File

@ -1,6 +1,5 @@
import asyncio import asyncio
import logging import logging
import os
import requests import requests
@ -9,7 +8,6 @@ from utils.rate_limiter import cat_limiter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CAT_API_URL = "https://api.thecatapi.com/v1/images/search" CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
_cat_api_key: str | None = os.getenv("CAT_API_KEY")
_session = requests.Session() _session = requests.Session()
@ -17,13 +15,8 @@ _session = requests.Session()
async def fetch_cat() -> str | None: async def fetch_cat() -> str | None:
"""Получить URL случайного котика. Вернуть None при ошибке.""" """Получить URL случайного котика. Вернуть None при ошибке."""
await cat_limiter.acquire() await cat_limiter.acquire()
headers: dict[str, str] | None = None
if _cat_api_key:
headers = {"x-api-key": _cat_api_key}
try: try:
response = await asyncio.to_thread( response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10)
_session.get, CAT_API_URL, timeout=10, headers=headers
)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data[0]["url"] return data[0]["url"]

View File

@ -151,6 +151,7 @@ class Scheduler:
) )
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._running = False self._running = False
self._start_scheduler()
def _start_scheduler(self): def _start_scheduler(self):
if self._running: if self._running:
@ -275,7 +276,7 @@ class Scheduler:
if not sent: if not sent:
logger.error("Не удалось найти канал для отправки morning-дайджеста") logger.error("Не удалось найти канал для отправки morning-дайджеста")
async def start(self) -> None: def start(self) -> None:
self._start_scheduler() self._start_scheduler()
def stop(self) -> None: def stop(self) -> None:

View File

@ -4,7 +4,6 @@ import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from defusedxml.ElementTree import fromstring
import requests import requests
from utils.rate_limiter import habr_rss_limiter from utils.rate_limiter import habr_rss_limiter
@ -24,6 +23,7 @@ _session = requests.Session()
async def fetch_rss(url: str) -> Optional[list[dict]]: async def fetch_rss(url: str) -> Optional[list[dict]]:
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom).""" """Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
await habr_rss_limiter.acquire() await habr_rss_limiter.acquire()
from defusedxml.ElementTree import fromstring
try: try:
response = await asyncio.to_thread(_session.get, url, timeout=10) response = await asyncio.to_thread(_session.get, url, timeout=10)

View File

@ -99,10 +99,7 @@ def make_habr_rss_limiter() -> RateLimiter:
return RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST) return RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST)
# Глобальные синглтоны для production. # Экземпляры лимитеров (глобальные, для production)
# Каждый API-модуль импортирует свой лимитер напрямую (один экземпляр на процесс).
# Factory-функции (make_*_limiter) используются для создания
# изолированных экземпляров в тестах с контролируемым временем.
cat_limiter: RateLimiter = make_cat_limiter() cat_limiter: RateLimiter = make_cat_limiter()
weather_limiter: RateLimiter = make_weather_limiter() weather_limiter: RateLimiter = make_weather_limiter()
open_meteo_limiter: RateLimiter = make_open_meteo_limiter() open_meteo_limiter: RateLimiter = make_open_meteo_limiter()