Compare commits
No commits in common. "0d98d31e6173f2f8d65bade10dbe11c92d80dcc0" and "d4b457f94e0d0ae8b9510bbb6b0c5338382b34fb" have entirely different histories.
0d98d31e61
...
d4b457f94e
@ -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
|
||||||
|
|||||||
18
ISSUES.md
18
ISSUES.md
@ -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
12
bot.py
@ -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()
|
||||||
):
|
):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user