feat: настроить логирование (stdout + файл, LOG_LEVEL)
This commit is contained in:
parent
ec38dfc725
commit
a52bb94224
@ -3,6 +3,7 @@ __pycache__
|
||||
.pytest_cache
|
||||
.git
|
||||
.env
|
||||
logs/
|
||||
data/
|
||||
*.egg-info
|
||||
.venv/
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
DISCORD_TOKEN=your_bot_token_here
|
||||
MORNING_TIME=07:00
|
||||
MORNING_CHANNEL_ID=channel_id
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@ -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 && \
|
||||
|
||||
4
bot.py
4
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()
|
||||
|
||||
132
tests/test_logger.py
Normal file
132
tests/test_logger.py
Normal 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
51
utils/logger.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user