Compare commits

...

13 Commits

Author SHA1 Message Date
deadzilla
0d98d31e61 ISSUES.md: задача Dockerfile+tests отмечена как отклонена (Docker — production) 2026-07-14 22:54:47 +05:00
deadzilla
60e7faca5f Откат: тесты и pytest удалены из Dockerfile (Docker — production-окружение) 2026-07-14 22:53:57 +05:00
deadzilla
f45beef888 ISSUES.md: отмечены все 5 задач низкого приоритета как выполненные 2026-07-14 22:51:10 +05:00
deadzilla
cd014b171f rate_limiter: добавлен комментарий, поясняющий дизайн глобальных синглтонов и factory-функций 2026-07-14 22:50:45 +05:00
deadzilla
d85f28f042 Удалены избыточные проверки ctx в on_command_error (ctx всегда передан discord.py) 2026-07-14 22:48:30 +05:00
deadzilla
bb9e50fb05 Убраны приватные атрибуты с объекта discord.py Bot: START_TIME вынесен на уровень модуля, _scheduler удалён (не использовался) 2026-07-14 22:46:57 +05:00
deadzilla
5a65be97d9 Dockerfile: добавлено копирование tests/ и установка pytest для отладки в контейнере 2026-07-14 22:43:19 +05:00
deadzilla
29313530f3 Удалены избыточные @pytest.mark.asyncio (asyncio_mode = auto в pytest.ini) 2026-07-14 22:41:10 +05:00
deadzilla
003bd0552f Отмечены все 4 задачи среднего уровня как выполненные в ISSUES.md 2026-07-14 22:27:59 +05:00
deadzilla
3888c3cd01 fetch_cat() передаёт x-api-key при наличии CAT_API_KEY в окружении 2026-07-14 22:27:30 +05:00
deadzilla
0d5d8dbe7b Удалён лишний __init__ в Pg; API_URL_WEATHER используется напрямую 2026-07-14 22:23:38 +05:00
deadzilla
bb9f291623 Scheduler.__init__ больше не создаёт task синхронно; start() стал async-методом 2026-07-14 22:20:45 +05:00
deadzilla
66b16e95f4 Вынесен импорт fromstring из fetch_rss на уровень модуля 2026-07-14 22:15:14 +05:00
14 changed files with 89 additions and 51 deletions

View File

@ -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

View File

@ -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
View File

@ -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()
):

View File

@ -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

View File

@ -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(

View File

@ -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:

View File

@ -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,11 +14,11 @@ 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)
cog = Status()
await cog.status(cog, mock_ctx)
with patch("bot.START_TIME", time.time()):
cog = Status()
await cog.status(cog, mock_ctx)
mock_ctx.send.assert_awaited_once()
call_args = mock_ctx.send.call_args
@ -30,11 +30,11 @@ 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)
cog = Status()
await cog.status(cog, mock_ctx)
with patch("bot.START_TIME", time.time() - 90061): # 1д 1ч 1м 1с
cog = Status()
await cog.status(cog, mock_ctx)
call_args = mock_ctx.send.call_args
embed = call_args[1]["embed"] if call_args[1] else call_args[0][0]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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:

View File

@ -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)

View File

@ -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()