feat: настроить логирование (stdout + файл, LOG_LEVEL)

This commit is contained in:
deadzilla 2026-06-11 23:43:01 +05:00
parent ec38dfc725
commit a52bb94224
6 changed files with 192 additions and 0 deletions

View File

@ -3,6 +3,7 @@ __pycache__
.pytest_cache .pytest_cache
.git .git
.env .env
logs/
data/ data/
*.egg-info *.egg-info
.venv/ .venv/

View File

@ -1,3 +1,4 @@
DISCORD_TOKEN=your_bot_token_here 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

View File

@ -5,6 +5,9 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
# Директория для логов
RUN mkdir -p logs
# Устанавливаем зависимости и утилиту ps для healthcheck # Устанавливаем зависимости и утилиту ps для healthcheck
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \ RUN pip install --no-cache-dir -r requirements.txt && \

4
bot.py
View File

@ -207,6 +207,10 @@ def _validate_config() -> None:
if __name__ == "__main__": if __name__ == "__main__":
from utils.logger import setup_logging
setup_logging()
_validate_config() _validate_config()
runner = BotRunner() runner = BotRunner()

132
tests/test_logger.py Normal file
View File

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

51
utils/logger.py Normal file
View File

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