From a52bb94224ee208a9c0ea1037f4cfc8c6dee3cd5 Mon Sep 17 00:00:00 2001 From: deadzilla Date: Thu, 11 Jun 2026 23:43:01 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20(stdout=20+=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB,=20LOG=5FLEVEL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 + .env.example | 1 + Dockerfile | 3 + bot.py | 4 ++ tests/test_logger.py | 132 +++++++++++++++++++++++++++++++++++++++++++ utils/logger.py | 51 +++++++++++++++++ 6 files changed, 192 insertions(+) create mode 100644 tests/test_logger.py create mode 100644 utils/logger.py diff --git a/.dockerignore b/.dockerignore index 8484e79..314e8c1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ __pycache__ .pytest_cache .git .env +logs/ data/ *.egg-info .venv/ diff --git a/.env.example b/.env.example index d6927de..5c588d0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ DISCORD_TOKEN=your_bot_token_here MORNING_TIME=07:00 MORNING_CHANNEL_ID=channel_id +LOG_LEVEL=INFO diff --git a/Dockerfile b/Dockerfile index 4e74f13..d49c553 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,9 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app +# Директория для логов +RUN mkdir -p logs + # Устанавливаем зависимости и утилиту ps для healthcheck COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt && \ diff --git a/bot.py b/bot.py index 58ecde7..eefb222 100644 --- a/bot.py +++ b/bot.py @@ -207,6 +207,10 @@ def _validate_config() -> None: if __name__ == "__main__": + from utils.logger import setup_logging + + setup_logging() + _validate_config() runner = BotRunner() diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..c1f3472 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,132 @@ +"""Тесты для utils/logger.py — проверка настройки логирования.""" + +import logging +import os +import tempfile +from pathlib import Path + +from utils.logger import setup_logging + + +def test_console_handler_added() -> None: + """setup_logging добавляет StreamHandler на stdout.""" + with _isolated_logger() as root: + handlers = [h for h in root.handlers if isinstance(h, logging.StreamHandler)] + assert len(handlers) >= 1 + + +def test_default_level_is_info() -> None: + """Уровень по умолчанию — INFO.""" + with _isolated_logger() as root: + assert root.level == logging.INFO + + +def test_custom_level_from_env() -> None: + """LOG_LEVEL из окружения влияет на уровень.""" + os.environ["LOG_LEVEL"] = "DEBUG" + try: + with _isolated_logger() as root: + assert root.level == logging.DEBUG + finally: + os.environ.pop("LOG_LEVEL", None) + + +def test_invalid_level_defaults_to_info() -> None: + """Неизвестный LOG_LEVEL fallback на INFO.""" + os.environ["LOG_LEVEL"] = "NOTAREALLEVEL" + try: + with _isolated_logger() as root: + assert root.level == logging.INFO + finally: + os.environ.pop("LOG_LEVEL", None) + + +def test_file_handler_when_logs_dir_exists() -> None: + """FileHandler добавляется если директория logs существует.""" + orig_cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmpdir: + logs_dir = Path(tmpdir) / "logs" + logs_dir.mkdir() + os.chdir(logs_dir.parent) + + try: + with _isolated_logger() as root: + file_handlers = [h for h in root.handlers if isinstance(h, logging.FileHandler)] + assert len(file_handlers) >= 1 + # Закрыть file handler чтобы освободить файл на Windows + for h in file_handlers: + h.close() + finally: + os.chdir(orig_cwd) + + +def test_no_file_handler_when_logs_dir_missing() -> None: + """FileHandler не добавляется если директории logs нет.""" + orig_cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmpdir: + os.chdir(tmpdir) + # Убедиться что logs/ нет + logs_dir = Path("logs") + if logs_dir.exists(): + import shutil + shutil.rmtree(logs_dir) + + try: + with _isolated_logger() as root: + file_handlers = [h for h in root.handlers if isinstance(h, logging.FileHandler)] + assert len(file_handlers) == 0 + finally: + os.chdir(orig_cwd) + + +def test_aiohttp_suppressed() -> None: + """Логгер aiohttp подавлен до WARNING.""" + with _isolated_logger(): + aiohttp_logger = logging.getLogger("aiohttp") + assert aiohttp_logger.level == logging.WARNING + + +def test_discord_level_is_info() -> None: + """Логгер discord установлен на INFO.""" + with _isolated_logger(): + discord_logger = logging.getLogger("discord") + assert discord_logger.level == logging.INFO + + +def test_log_message_format() -> None: + """Формат сообщения: время, уровень, имя модуля, текст.""" + import io + import sys + + with _isolated_logger() as root: + # Replace stdout with our buffer + buffer = io.StringIO() + for handler in root.handlers: + if isinstance(handler, logging.StreamHandler): + handler.stream = buffer + + test_logger = logging.getLogger("test_module") + test_logger.info("test message") + + output = buffer.getvalue() + assert "INFO" in output + assert "test_module" in output + assert "test message" in output + + +import contextlib + + +@contextlib.contextmanager +def _isolated_logger(): + """Создать изолированный root-логгер без handlers из других тестов.""" + old_handlers = logging.getLogger().handlers[:] + old_level = logging.getLogger().level + logging.getLogger().handlers.clear() + + yield setup_logging() + + # Restore + logging.getLogger().handlers.clear() + logging.getLogger().handlers.extend(old_handlers) + logging.getLogger().setLevel(old_level) diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..764c04a --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,51 @@ +""" +Настройка логирования для бота. + +Консольный вывод (stdout) + опциональный вывод в файл logs/bot.log. +Уровень настраивается через LOG_LEVEL в .env (по умолчанию INFO). +""" + +import logging +import os +import sys +from pathlib import Path + + +def setup_logging() -> logging.Logger: + """Настроить root-логгер: консоль + файл (если logs/ существует). + + Returns: + Настроенный root-логгер. + """ + level_name = os.getenv("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + + formatter = logging.Formatter( + "%(asctime)s [%(levelname)-8s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # Console handler — stdout + console = logging.StreamHandler(sys.stdout) + console.setLevel(level) + console.setFormatter(formatter) + + root = logging.getLogger() + root.setLevel(level) + root.addHandler(console) + + # File handler — logs/bot.log (если директория logs доступна) + log_dir = Path("logs") + if log_dir.exists(): + file_handler = logging.FileHandler( + log_dir / "bot.log", encoding="utf-8" + ) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + root.addHandler(file_handler) + + # Подавить шум от aiohttp и discord.internal + logging.getLogger("aiohttp").setLevel(logging.WARNING) + logging.getLogger("discord").setLevel(logging.INFO) + + return root