mirror of
https://github.com/mistralai/mistral-vibe
synced 2026-04-25 17:14:55 +02:00
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Peter Evers <pevers90@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
346 lines
10 KiB
Python
346 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from logging.handlers import RotatingFileHandler
|
|
from pathlib import Path
|
|
from textwrap import dedent
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from vibe.core.logger import (
|
|
StructuredLogFormatter,
|
|
apply_logging_config,
|
|
decode_log_message,
|
|
encode_log_message,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_log_dir(tmp_path: Path):
|
|
"""Mock LOG_DIR and LOG_FILE to use tmp_path for testing."""
|
|
mock_dir = MagicMock()
|
|
mock_dir.path = tmp_path
|
|
mock_file = MagicMock()
|
|
mock_file.path = tmp_path / "vibe.log"
|
|
with (
|
|
patch("vibe.core.logger.LOG_DIR", mock_dir),
|
|
patch("vibe.core.logger.LOG_FILE", mock_file),
|
|
):
|
|
yield tmp_path
|
|
|
|
|
|
class TestStructuredFormatter:
|
|
def test_format_contains_required_fields(self) -> None:
|
|
formatter = StructuredLogFormatter()
|
|
record = logging.LogRecord(
|
|
name="test_logger",
|
|
level=logging.INFO,
|
|
pathname="test.py",
|
|
lineno=1,
|
|
msg="Test message",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
|
|
parts = output.split(" ", 4)
|
|
assert len(parts) == 5
|
|
assert "T" in parts[0]
|
|
assert parts[1].isdigit()
|
|
assert parts[2].isdigit()
|
|
assert parts[3] == "INFO"
|
|
assert parts[4] == "Test message"
|
|
|
|
def test_format_includes_exception(self) -> None:
|
|
formatter = StructuredLogFormatter()
|
|
try:
|
|
raise ValueError("test error")
|
|
except ValueError:
|
|
import sys
|
|
|
|
exc_info = sys.exc_info()
|
|
|
|
record = logging.LogRecord(
|
|
name="test_logger",
|
|
level=logging.ERROR,
|
|
pathname="test.py",
|
|
lineno=1,
|
|
msg="Error occurred",
|
|
args=(),
|
|
exc_info=exc_info,
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
|
|
assert "Error occurred" in output
|
|
assert "ValueError" in output
|
|
assert "test error" in output
|
|
|
|
def test_format_escapes_newlines_in_message(self) -> None:
|
|
formatter = StructuredLogFormatter()
|
|
multiline_msg = dedent("""
|
|
Line one
|
|
Line two
|
|
Line three""")
|
|
record = logging.LogRecord(
|
|
name="test_logger",
|
|
level=logging.INFO,
|
|
pathname="test.py",
|
|
lineno=1,
|
|
msg=multiline_msg,
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
|
|
assert "\n" not in output
|
|
assert "Line one\\nLine two\\nLine three" in output
|
|
|
|
def test_format_escapes_newlines_in_exception(self) -> None:
|
|
formatter = StructuredLogFormatter()
|
|
try:
|
|
raise ValueError("test error")
|
|
except ValueError:
|
|
import sys
|
|
|
|
exc_info = sys.exc_info()
|
|
|
|
record = logging.LogRecord(
|
|
name="test_logger",
|
|
level=logging.ERROR,
|
|
pathname="test.py",
|
|
lineno=1,
|
|
msg="Error occurred",
|
|
args=(),
|
|
exc_info=exc_info,
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
|
|
assert "\n" not in output
|
|
assert "ValueError" in output
|
|
assert "test error" in output
|
|
assert "\\n" in output
|
|
|
|
def test_format_output_is_single_line(self) -> None:
|
|
formatter = StructuredLogFormatter()
|
|
try:
|
|
error_msg = dedent("""
|
|
multi
|
|
line
|
|
error""")
|
|
raise RuntimeError(error_msg)
|
|
except RuntimeError:
|
|
import sys
|
|
|
|
exc_info = sys.exc_info()
|
|
|
|
multiline_msg = dedent("""
|
|
Something
|
|
went
|
|
wrong""")
|
|
record = logging.LogRecord(
|
|
name="test_logger",
|
|
level=logging.ERROR,
|
|
pathname="test.py",
|
|
lineno=1,
|
|
msg=multiline_msg,
|
|
args=(),
|
|
exc_info=exc_info,
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
lines = output.split("\n")
|
|
|
|
assert len(lines) == 1
|
|
|
|
|
|
class TestApplyLoggingConfig:
|
|
def test_adds_handler_to_logger(
|
|
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
|
test_logger = logging.getLogger("test_apply_logging")
|
|
initial_handler_count = len(test_logger.handlers)
|
|
|
|
apply_logging_config(test_logger)
|
|
|
|
assert len(test_logger.handlers) == initial_handler_count + 1
|
|
|
|
def test_creates_log_file(
|
|
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
|
test_logger = logging.getLogger("test_log_file")
|
|
test_logger.setLevel(logging.DEBUG)
|
|
|
|
apply_logging_config(test_logger)
|
|
test_logger.info("Test log entry")
|
|
|
|
log_file = mock_log_dir / "vibe.log"
|
|
assert log_file.exists()
|
|
|
|
def test_log_entry_format(
|
|
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
|
test_logger = logging.getLogger("test_format")
|
|
test_logger.setLevel(logging.DEBUG)
|
|
|
|
apply_logging_config(test_logger)
|
|
test_logger.warning("Test warning message")
|
|
|
|
log_file = mock_log_dir / "vibe.log"
|
|
content = log_file.read_text()
|
|
|
|
assert "WARNING" in content
|
|
assert "Test warning message" in content
|
|
|
|
def test_respects_log_level(
|
|
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("LOG_LEVEL", "WARNING")
|
|
test_logger = logging.getLogger("test_level_filter")
|
|
test_logger.setLevel(logging.DEBUG)
|
|
|
|
apply_logging_config(test_logger)
|
|
test_logger.debug("Debug message")
|
|
test_logger.info("Info message")
|
|
test_logger.warning("Warning message")
|
|
|
|
log_file = mock_log_dir / "vibe.log"
|
|
content = log_file.read_text()
|
|
|
|
assert "Debug message" not in content
|
|
assert "Info message" not in content
|
|
assert "Warning message" in content
|
|
|
|
def test_creates_log_directory_if_missing(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
log_dir = tmp_path / "nested" / "logs"
|
|
mock_dir = MagicMock()
|
|
mock_dir.path = log_dir
|
|
mock_file = MagicMock()
|
|
mock_file.path = log_dir / "vibe.log"
|
|
with (
|
|
patch("vibe.core.logger.LOG_DIR", mock_dir),
|
|
patch("vibe.core.logger.LOG_FILE", mock_file),
|
|
):
|
|
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
|
test_logger = logging.getLogger("test_mkdir")
|
|
|
|
apply_logging_config(test_logger)
|
|
|
|
assert log_dir.exists()
|
|
|
|
def test_debug_mode_overrides_log_level(
|
|
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("LOG_LEVEL", "WARNING")
|
|
monkeypatch.setenv("DEBUG_MODE", "true")
|
|
test_logger = logging.getLogger("test_debug_mode")
|
|
test_logger.setLevel(logging.DEBUG)
|
|
|
|
apply_logging_config(test_logger)
|
|
test_logger.debug("Debug message")
|
|
|
|
log_file = mock_log_dir / "vibe.log"
|
|
content = log_file.read_text()
|
|
|
|
assert "Debug message" in content
|
|
|
|
def test_invalid_log_level_defaults_to_warning(
|
|
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("LOG_LEVEL", "INVALID")
|
|
test_logger = logging.getLogger("test_invalid_level")
|
|
test_logger.setLevel(logging.DEBUG)
|
|
|
|
apply_logging_config(test_logger)
|
|
test_logger.info("Info message")
|
|
test_logger.warning("Warning message")
|
|
|
|
log_file = mock_log_dir / "vibe.log"
|
|
content = log_file.read_text()
|
|
|
|
assert "Info message" not in content
|
|
assert "Warning message" in content
|
|
|
|
def test_log_max_bytes_from_env(
|
|
self, mock_log_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("LOG_MAX_BYTES", "5242880") # 5 MB
|
|
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
|
test_logger = logging.getLogger("test_max_bytes")
|
|
|
|
apply_logging_config(test_logger)
|
|
|
|
# Verify handler was added with correct maxBytes
|
|
handler = test_logger.handlers[-1]
|
|
assert isinstance(handler, RotatingFileHandler)
|
|
assert handler.maxBytes == 5242880
|
|
|
|
|
|
class TestDecodeLogMessage:
|
|
def test_plain_message_unchanged(self) -> None:
|
|
assert decode_log_message("Hello world") == "Hello world"
|
|
|
|
def test_decodes_escaped_newline(self) -> None:
|
|
assert decode_log_message("hello\\nworld") == "hello\nworld"
|
|
|
|
def test_decodes_escaped_backslash(self) -> None:
|
|
assert decode_log_message("C:\\\\path") == "C:\\path"
|
|
|
|
def test_decodes_escaped_backslash_before_n(self) -> None:
|
|
# This is the bug case: C:\new encoded as C:\\new must decode back to C:\new
|
|
assert decode_log_message("C:\\\\new") == "C:\\new"
|
|
|
|
def test_roundtrip_with_newlines(self) -> None:
|
|
original = "line one\nline two\nline three"
|
|
encoded = encode_log_message(original)
|
|
assert decode_log_message(encoded) == original
|
|
|
|
def test_roundtrip_with_backslashes(self) -> None:
|
|
original = "C:\\Users\\test\\file.txt"
|
|
encoded = encode_log_message(original)
|
|
assert decode_log_message(encoded) == original
|
|
|
|
def test_roundtrip_with_backslash_n(self) -> None:
|
|
original = "C:\\new folder\\notes.txt"
|
|
encoded = encode_log_message(original)
|
|
assert decode_log_message(encoded) == original
|
|
|
|
def test_roundtrip_mixed(self) -> None:
|
|
original = "path: C:\\new\nand a newline"
|
|
encoded = encode_log_message(original)
|
|
assert decode_log_message(encoded) == original
|
|
|
|
def test_exception_encoding_escapes_backslashes(self) -> None:
|
|
formatter = StructuredLogFormatter()
|
|
try:
|
|
raise ValueError("error in C:\\new\\path")
|
|
except ValueError:
|
|
import sys
|
|
|
|
exc_info = sys.exc_info()
|
|
|
|
record = logging.LogRecord(
|
|
name="test",
|
|
level=logging.ERROR,
|
|
pathname="test.py",
|
|
lineno=1,
|
|
msg="fail",
|
|
args=(),
|
|
exc_info=exc_info,
|
|
)
|
|
|
|
output = formatter.format(record)
|
|
|
|
assert "\n" not in output
|
|
# The backslashes in the exception should be escaped
|
|
assert "C:\\\\new\\\\path" in output
|