Compare commits
13 Commits
d4b457f94e
...
0d98d31e61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d98d31e61 | ||
|
|
60e7faca5f | ||
|
|
f45beef888 | ||
|
|
cd014b171f | ||
|
|
d85f28f042 | ||
|
|
bb9e50fb05 | ||
|
|
5a65be97d9 | ||
|
|
29313530f3 | ||
|
|
003bd0552f | ||
|
|
3888c3cd01 | ||
|
|
0d5d8dbe7b | ||
|
|
bb9f291623 | ||
|
|
66b16e95f4 |
@ -2,6 +2,7 @@ DISCORD_TOKEN=your_bot_token_here
|
||||
MORNING_TIME=07:00
|
||||
MORNING_CHANNEL_ID=channel_id
|
||||
LOG_LEVEL=INFO
|
||||
CAT_API_KEY=your_cat_api_key_here
|
||||
CAT_API_RATE=1
|
||||
CAT_API_BURST=3
|
||||
WEATHER_API_RATE=1
|
||||
|
||||
18
ISSUES.md
18
ISSUES.md
@ -56,15 +56,15 @@
|
||||
|
||||
### Средние
|
||||
|
||||
- [ ] **`fromstring` импортирован внутри функции `fetch_rss`** — `from defusedxml.ElementTree import fromstring` на строке 24, скрывает зависимость от линтеров и добавляет оверхед (`utils/news.py`)
|
||||
- [ ] **`Scheduler._start_scheduler()` создаёт task синхронно** — `asyncio.create_task()` в `__init__` зависит от активного event loop неявно (`utils/morning_runner.py`, строка 119)
|
||||
- [ ] **`Pg.__init__` хранит `self.api_url` как инстанс-переменную** — копия константы `API_URL_WEATHER` без необходимости (`commands/pg.py`, строки 13-14)
|
||||
- [ ] **`fetch_cat()` не передаёт `x-api-key`** — TheCatAPI требует/рекомендует заголовок `x-api-key`, без него запросы могут быть ограничены (`utils/cat.py`, строка 15)
|
||||
- [x] ~~**`fromstring` импортирован внутри функции `fetch_rss`**~~ — вынесен на уровень модуля (`utils/news.py`)
|
||||
- [x] ~~**`Scheduler._start_scheduler()` создаёт task синхронно**~~ — `__init__` больше не создаёт task; `start()` стал async-методом (`utils/morning_runner.py`, `bot.py`)
|
||||
- [x] ~~**`Pg.__init__` хранит `self.api_url` как инстанс-переменную**~~ — удалён `__init__`, используется `API_URL_WEATHER` напрямую (`commands/pg.py`)
|
||||
- [x] ~~**`fetch_cat()` не передаёт `x-api-key`**~~ — добавлена поддержка `CAT_API_KEY` из окружения, заголовок `x-api-key` передаётся при наличии ключа (`utils/cat.py`)
|
||||
|
||||
### Низкие
|
||||
|
||||
- [ ] **Глобальные экземпляры RateLimiter + factory-функции** — factory-функции `make_*_limiter()` существуют, но production-код импортирует глобальные переменные напрямую (`utils/rate_limiter.py`, строки 94-97)
|
||||
- [ ] **`@pytest.mark.asyncio` избыточен** — `pytest.ini` уже содержит `asyncio_mode = auto` (`tests/test_integration.py`)
|
||||
- [ ] **`Dockerfile` не копирует `tests/`** — нельзя запустить `pytest` внутри контейнера, затрудняет отладку в Docker
|
||||
- [ ] **Приватные атрибуты на объекте бота** — `self.bot._start_time` и `self.bot._scheduler` нарушают инкапсуляцию (`bot.py`, строки 116, 148)
|
||||
- [ ] **Избыточная проверка `ctx` в `on_command_error`** — `ctx` всегда передан и не может быть `None` (`bot.py`, строки 138-143)
|
||||
- [x] ~~**Глобальные экземпляры RateLimiter + factory-функции**~~ — дизайн подтверждён: глобальные синглтоны для production, factory-функции для тестов. Добавлен поясняющий комментарий (`utils/rate_limiter.py`)
|
||||
- [x] ~~**`@pytest.mark.asyncio` избыточен**~~ — удалены 5 декораторов из `tests/test_integration.py` (`pytest.ini` имеет `asyncio_mode = auto`)
|
||||
- [x] ~~**`Dockerfile` не копирует `tests/`**~~ — отклонено: Docker — production-окружение, тесты туда не нужны
|
||||
- [x] ~~**Приватные атрибуты на объекте бота**~~ — `START_TIME` вынесен на уровень модуля `bot.py`, `self.bot._scheduler` удалён (не использовался). Обновлены `commands/status.py` и тесты
|
||||
- [x] ~~**Избыточная проверка `ctx` в `on_command_error`**~~ — удалены проверки `ctx and`, так как `ctx` гарантированно передан discord.py (`bot.py`)
|
||||
|
||||
12
bot.py
12
bot.py
@ -23,6 +23,9 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Время запуска бота (на уровне модуля, чтобы не хранить на объекте discord.py Bot)
|
||||
START_TIME: float = time.time()
|
||||
|
||||
|
||||
class TextHelpCommand(commands.HelpCommand):
|
||||
"""Выводит справку по командам простым текстом вместо embed."""
|
||||
@ -104,7 +107,7 @@ class BotRunner:
|
||||
intents=intents,
|
||||
help_command=TextHelpCommand(),
|
||||
)
|
||||
self.bot._start_time = time.time()
|
||||
|
||||
self.stop_event = threading.Event()
|
||||
self.bot_ready = threading.Event()
|
||||
self.scheduler: SchedulerType | None = None
|
||||
@ -142,7 +145,7 @@ class BotRunner:
|
||||
|
||||
morning_time = os.getenv("MORNING_TIME", "07:00")
|
||||
self.scheduler = Scheduler(self.bot, morning_time)
|
||||
self.bot._scheduler = self.scheduler
|
||||
await self.scheduler.start()
|
||||
logger.info(
|
||||
"Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name
|
||||
)
|
||||
@ -153,7 +156,7 @@ class BotRunner:
|
||||
return
|
||||
|
||||
# Терминал — детали для разработчика
|
||||
cmd_name = ctx.command.name if ctx and ctx.command else "?"
|
||||
cmd_name = ctx.command.name if ctx.command else "?"
|
||||
logger.error(
|
||||
"Ошибка команды %s: %s",
|
||||
cmd_name,
|
||||
@ -165,8 +168,7 @@ class BotRunner:
|
||||
# ctx.interaction есть только у slash-команд (AutoshardedInteractionContext)
|
||||
# Для текстовых команд (!prefix) атрибута нет — используем hasattr
|
||||
if (
|
||||
ctx
|
||||
and hasattr(ctx, "interaction")
|
||||
hasattr(ctx, "interaction")
|
||||
and ctx.interaction
|
||||
and ctx.interaction.response.is_done()
|
||||
):
|
||||
|
||||
@ -8,13 +8,10 @@ logger = logging.getLogger(__name__)
|
||||
class Pg(commands.Cog):
|
||||
"""Команда !pg — прогноз погоды для Магнитогорска"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = API_URL_WEATHER
|
||||
|
||||
@commands.command(name="pg")
|
||||
async def pg(self, ctx: commands.Context) -> None:
|
||||
"""Прогноз погоды в Магнитогорске"""
|
||||
data = await fetch_weather(self.api_url)
|
||||
data = await fetch_weather(API_URL_WEATHER)
|
||||
if data is None:
|
||||
logger.warning(
|
||||
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
|
||||
|
||||
@ -13,9 +13,11 @@ class Status(commands.Cog):
|
||||
@commands.command(name="status")
|
||||
async def status(self, ctx: commands.Context) -> None:
|
||||
"""Статус бота: пинг к Discord gateway и время работы"""
|
||||
# Lazy import чтобы избежать циклического импорта (bot -> commands -> bot)
|
||||
from bot import START_TIME # noqa: F401
|
||||
|
||||
latency_ms = round(ctx.bot.latency * 1000, 1)
|
||||
start_time = getattr(ctx.bot, "_start_time", time.time())
|
||||
uptime_seconds = time.time() - start_time
|
||||
uptime_seconds = time.time() - START_TIME
|
||||
uptime_str = self._format_uptime(uptime_seconds)
|
||||
|
||||
embed = discord.Embed(
|
||||
|
||||
@ -7,10 +7,14 @@ from commands.pg import Pg
|
||||
class TestPgInit:
|
||||
"""Тесты инициализации Cog Pg."""
|
||||
|
||||
def test_init_sets_api_url(self) -> None:
|
||||
"""__init__ должен устанавливать api_url."""
|
||||
def test_uses_api_url_constant(self) -> None:
|
||||
"""Pg использует API_URL_WEATHER напрямую (без инстанс-переменной)."""
|
||||
from utils.pogoda import API_URL_WEATHER
|
||||
|
||||
cog = Pg()
|
||||
assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
||||
# Pg не хранит api_url как инстанс-переменную — использует константу напрямую
|
||||
assert not hasattr(cog, "api_url")
|
||||
assert API_URL_WEATHER == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
||||
|
||||
|
||||
class TestPgCommand:
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import time
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from commands.status import Status
|
||||
|
||||
@ -14,9 +14,9 @@ class TestStatusCommand:
|
||||
"""Команда status отправляет embed-сообщение."""
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.bot.latency = 0.042
|
||||
mock_ctx.bot._start_time = time.time()
|
||||
mock_ctx.send = AsyncMock(return_value=None)
|
||||
|
||||
with patch("bot.START_TIME", time.time()):
|
||||
cog = Status()
|
||||
await cog.status(cog, mock_ctx)
|
||||
|
||||
@ -30,9 +30,9 @@ class TestStatusCommand:
|
||||
"""Uptime форматируется корректно."""
|
||||
mock_ctx = MagicMock()
|
||||
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)
|
||||
|
||||
with patch("bot.START_TIME", time.time() - 90061): # 1д 1ч 1м 1с
|
||||
cog = Status()
|
||||
await cog.status(cog, mock_ctx)
|
||||
|
||||
|
||||
@ -97,3 +97,28 @@ class TestFetchCat:
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_cat()
|
||||
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
|
||||
|
||||
@ -28,14 +28,12 @@ async def loaded_bot():
|
||||
class TestCogLoading:
|
||||
"""Проверка загрузки ког-модулей."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
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}
|
||||
@ -46,7 +44,6 @@ class TestCogLoading:
|
||||
class TestCommandFlow:
|
||||
"""Проверка полного потока команд (без сетевых вызовов)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_command_success(self, loaded_bot) -> None:
|
||||
"""Команда !cat должна отправить embed с котиком при успешном ответе API."""
|
||||
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch:
|
||||
@ -65,7 +62,6 @@ class TestCommandFlow:
|
||||
assert embed.title == "Котик для тебя!"
|
||||
assert embed.image.url == "https://example.com/cat.jpg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_command_failure(self, loaded_bot) -> None:
|
||||
"""Команда !cat при ошибке API должна отправить fallback сообщение."""
|
||||
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch:
|
||||
@ -82,7 +78,6 @@ class TestCommandFlow:
|
||||
content = mock_ctx.send.call_args[0][0]
|
||||
assert "Не удалось получить котика" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats_command_flow(self) -> None:
|
||||
"""Команда !stats должна показать реальную статистику бота."""
|
||||
import discord
|
||||
|
||||
@ -25,12 +25,12 @@ class TestSchedulerInit:
|
||||
scheduler = Scheduler(bot)
|
||||
assert scheduler.morning_time == "07:00"
|
||||
|
||||
def test_init_creates_task(self) -> None:
|
||||
"""Инициализация должна вызывать _start_scheduler."""
|
||||
def test_init_does_not_start_scheduler(self) -> None:
|
||||
"""Инициализация не должна запускать планировщик (start() вызывается отдельно)."""
|
||||
bot = AsyncMock()
|
||||
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||
Scheduler(bot)
|
||||
mock_start.assert_called_once()
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
class TestSchedulerCalculateNextRun:
|
||||
@ -67,14 +67,14 @@ class TestSchedulerCalculateNextRun:
|
||||
class TestSchedulerStartStop:
|
||||
"""Тесты запуска/остановки планировщика."""
|
||||
|
||||
def test_start_starts_task(self) -> None:
|
||||
"""start() должен вызывать _start_scheduler (1 в __init__ + 1 в start, но реальный task один)."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_starts_task(self) -> None:
|
||||
"""start() должен вызывать _start_scheduler один раз."""
|
||||
bot = AsyncMock()
|
||||
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||
scheduler = Scheduler(bot)
|
||||
scheduler.start()
|
||||
# __init__ вызывает _start_scheduler, start() тоже вызывает
|
||||
assert mock_start.call_count == 2
|
||||
await scheduler.start()
|
||||
mock_start.assert_called_once()
|
||||
|
||||
def test_stop_stops_task(self) -> None:
|
||||
"""stop() должен остановить task."""
|
||||
@ -88,12 +88,15 @@ class TestSchedulerStartStop:
|
||||
scheduler.stop()
|
||||
assert scheduler._running is False
|
||||
|
||||
def test_double_start_no_duplicate(self) -> None:
|
||||
"""Повторный start должен вызывать _start_scheduler дважды (реальный task не дублируется благодаря флагам)."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_start_no_duplicate(self) -> None:
|
||||
"""Повторный start() не должен дублировать task (благодаря флагам)."""
|
||||
bot = AsyncMock()
|
||||
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||
scheduler = Scheduler(bot)
|
||||
scheduler.start() # второй вызов
|
||||
await scheduler.start()
|
||||
await scheduler.start() # повторный вызов
|
||||
# _start_scheduler вызывается 2 раза, но реальный task один (флаг _running защищает)
|
||||
assert mock_start.call_count == 2
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
@ -8,6 +9,7 @@ from utils.rate_limiter import cat_limiter
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
|
||||
_cat_api_key: str | None = os.getenv("CAT_API_KEY")
|
||||
|
||||
_session = requests.Session()
|
||||
|
||||
@ -15,8 +17,13 @@ _session = requests.Session()
|
||||
async def fetch_cat() -> str | None:
|
||||
"""Получить URL случайного котика. Вернуть None при ошибке."""
|
||||
await cat_limiter.acquire()
|
||||
headers: dict[str, str] | None = None
|
||||
if _cat_api_key:
|
||||
headers = {"x-api-key": _cat_api_key}
|
||||
try:
|
||||
response = await asyncio.to_thread(_session.get, CAT_API_URL, timeout=10)
|
||||
response = await asyncio.to_thread(
|
||||
_session.get, CAT_API_URL, timeout=10, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data[0]["url"]
|
||||
|
||||
@ -151,7 +151,6 @@ class Scheduler:
|
||||
)
|
||||
self._task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
self._start_scheduler()
|
||||
|
||||
def _start_scheduler(self):
|
||||
if self._running:
|
||||
@ -276,7 +275,7 @@ class Scheduler:
|
||||
if not sent:
|
||||
logger.error("Не удалось найти канал для отправки morning-дайджеста")
|
||||
|
||||
def start(self) -> None:
|
||||
async def start(self) -> None:
|
||||
self._start_scheduler()
|
||||
|
||||
def stop(self) -> None:
|
||||
|
||||
@ -4,6 +4,7 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from defusedxml.ElementTree import fromstring
|
||||
import requests
|
||||
|
||||
from utils.rate_limiter import habr_rss_limiter
|
||||
@ -23,7 +24,6 @@ _session = requests.Session()
|
||||
async def fetch_rss(url: str) -> Optional[list[dict]]:
|
||||
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
|
||||
await habr_rss_limiter.acquire()
|
||||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
try:
|
||||
response = await asyncio.to_thread(_session.get, url, timeout=10)
|
||||
|
||||
@ -99,7 +99,10 @@ def make_habr_rss_limiter() -> RateLimiter:
|
||||
return RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST)
|
||||
|
||||
|
||||
# Экземпляры лимитеров (глобальные, для production)
|
||||
# Глобальные синглтоны для production.
|
||||
# Каждый API-модуль импортирует свой лимитер напрямую (один экземпляр на процесс).
|
||||
# Factory-функции (make_*_limiter) используются для создания
|
||||
# изолированных экземпляров в тестах с контролируемым временем.
|
||||
cat_limiter: RateLimiter = make_cat_limiter()
|
||||
weather_limiter: RateLimiter = make_weather_limiter()
|
||||
open_meteo_limiter: RateLimiter = make_open_meteo_limiter()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user