From 2ef88a52a0a01352243104dce86edcb064a16697 Mon Sep 17 00:00:00 2001 From: nk-batman21 Date: Tue, 20 Oct 2026 13:38:09 +0700 Subject: [PATCH] Fix traceback colors shared between logging handlers --- colorlog/formatter.py | 11 +++++++ colorlog/tests/test_colorlog.py | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/colorlog/formatter.py b/colorlog/formatter.py index f67d44e..23e1ff9 100644 --- a/colorlog/formatter.py +++ b/colorlog/formatter.py @@ -1,5 +1,6 @@ """The ColoredFormatter class.""" +import copy import logging import os import sys @@ -179,6 +180,16 @@ def _append_reset(self, message: str, escapes: EscapeCodes) -> str: if sys.version_info >= (3, 13): + def format(self, record: logging.LogRecord) -> str: + """Keep exception colors local to this formatter.""" + if record.exc_info: + if not record.exc_text: + # Keep plain exception text available to record forwarders. + record.exc_text = super().formatException(record.exc_info) + record = copy.copy(record) + record.exc_text = None + return super().format(record) + def formatException(self, ei) -> str: """ Format and return the specified exception information as a string. diff --git a/colorlog/tests/test_colorlog.py b/colorlog/tests/test_colorlog.py index 8ac11e1..71074f4 100644 --- a/colorlog/tests/test_colorlog.py +++ b/colorlog/tests/test_colorlog.py @@ -1,6 +1,8 @@ """Test the colorlog.colorlog module.""" +import io import logging +import pickle import sys import unittest.mock @@ -158,3 +160,58 @@ def test_formatException(tty: bool, monkeypatch): text = formatter.formatException(ei) assert ("\x1b[" in text) == tty + + @pytest.fixture + def exception_record() -> logging.LogRecord: + try: + raise ValueError("example failure") + except ValueError: + return logging.LogRecord( + "example", logging.ERROR, "example.py", 1, "failed", (), sys.exc_info() + ) + + @pytest.mark.parametrize( + "plain_formatter", + [ + logging.Formatter("%(message)s"), + colorlog.ColoredFormatter("%(message)s", no_color=True), + colorlog.ColoredFormatter("%(message)s", stream=io.StringIO()), + ], + ids=["stdlib", "no_color", "non_tty"], + ) + @pytest.mark.parametrize("color_first", [True, False]) + def test_exception_colors_are_per_formatter( + exception_record, plain_formatter, color_first + ): + colored_formatter = colorlog.ColoredFormatter("%(message)s", force_color=True) + if color_first: + colored = colored_formatter.format(exception_record) + plain = plain_formatter.format(exception_record) + else: + plain = plain_formatter.format(exception_record) + colored = colored_formatter.format(exception_record) + + assert "\x1b[" not in plain + assert "\x1b[" in colored.split("\n", 1)[1] + + def test_exception_text_survives_record_forwarding(exception_record): + colorlog.ColoredFormatter("%(message)s", force_color=True).format( + exception_record + ) + exception_record.exc_info = None + forwarded = pickle.loads(pickle.dumps(exception_record)) + output = logging.Formatter("%(message)s").format(forwarded) + + assert "ValueError: example failure" in output + assert "\x1b[" not in output + + @pytest.mark.parametrize("force_color", [True, False]) + def test_cached_exception_text_without_exc_info(exception_record, force_color): + exception_record.exc_info = None + exception_record.exc_text = "ValueError: remotely cached failure" + formatter = colorlog.ColoredFormatter( + "%(message)s", force_color=force_color, no_color=not force_color + ) + + assert formatter.format(exception_record).endswith(exception_record.exc_text) + assert exception_record.exc_text == "ValueError: remotely cached failure"