Skip to content

Commit 86d62dc

Browse files
authored
Feat: Support for colored log output (#30)
* Support for colored log output * make setup_logging() idempotent with force=True parameter
1 parent 27e74d1 commit 86d62dc

3 files changed

Lines changed: 309 additions & 5 deletions

File tree

requirements/base.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ datasets==4.1.1
2626
sentencepiece==0.2.1
2727
protobuf==6.33.0
2828

29+
# Color support for cross-platform terminals
30+
colorama==0.4.6
31+
2932
# Tokens will encode to torch.Tensor objects, but we only tokenize, not encode.
3033
# We don't necessarily need to install torch, but if needed, uncomment this:
3134
# --extra-index-url https://download.pytorch.org/whl/cpu

src/inference_endpoint/utils/logging.py

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,74 @@
2020
"""
2121

2222
import logging
23+
import os
2324
import sys
2425

26+
from colorama import Fore, Style
27+
from colorama import init as _colorama_init
28+
29+
# Initialize colorama
30+
_colorama_init(autoreset=True)
31+
32+
# Map levelname -> color
33+
_LEVEL_COLORS = {
34+
"INFO": Fore.GREEN,
35+
"WARNING": Fore.YELLOW,
36+
"ERROR": Fore.RED,
37+
"CRITICAL": Fore.RED,
38+
}
39+
40+
41+
class ColoredFormatter(logging.Formatter):
42+
"""Formatter that applies colors to log level names.
43+
44+
Applies colorama colors (green for INFO, yellow for WARNING, red for ERROR/CRITICAL)
45+
to the levelname field only, leaving the rest of the log message unmodified.
46+
"""
47+
48+
def __init__(
49+
self,
50+
fmt: str | None = None,
51+
datefmt: str | None = None,
52+
style: str = "%",
53+
use_color: bool = False,
54+
):
55+
"""Initialize the formatter.
56+
57+
Args:
58+
fmt: Log format string.
59+
datefmt: Date format string.
60+
style: Format style (% or {).
61+
use_color: Whether to apply colors to levelname. Defaults to False.
62+
Enable by setting FORCE_COLOR_LOGGING environment variable.
63+
"""
64+
super().__init__(fmt=fmt, datefmt=datefmt, style=style)
65+
self.use_color = use_color
66+
67+
def format(self, record: logging.LogRecord) -> str:
68+
"""Format the log record with optional colors.
69+
70+
Args:
71+
record: The log record to format.
72+
73+
Returns:
74+
Formatted log message with colors applied if enabled.
75+
"""
76+
# If coloring disabled, delegate
77+
if not self.use_color:
78+
return super().format(record)
79+
80+
orig = record.levelname
81+
color = _LEVEL_COLORS.get(orig)
82+
if color:
83+
try:
84+
record.levelname = f"{color}{orig}{Style.RESET_ALL}"
85+
return super().format(record)
86+
finally:
87+
record.levelname = orig
88+
89+
return super().format(record)
90+
2591

2692
def setup_logging(level: str | None = None, format_string: str | None = None) -> None:
2793
"""
@@ -38,20 +104,25 @@ def setup_logging(level: str | None = None, format_string: str | None = None) ->
38104
# Default format
39105
if format_string is None:
40106
format_string = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
107+
# Disable colors by default to avoid potential formatting overhead during benchmarking.
108+
# Colors can be explicitly enabled via FORCE_COLOR_LOGGING environment variable.
109+
use_color = os.getenv("FORCE_COLOR_LOGGING") is not None
110+
111+
handler = logging.StreamHandler(sys.stdout)
112+
handler.setFormatter(ColoredFormatter(fmt=format_string, use_color=use_color))
41113

42-
# Configure root logger
43114
logging.basicConfig(
44-
level=getattr(logging, level.upper()),
45-
format=format_string,
46-
handlers=[logging.StreamHandler(sys.stdout)],
115+
level=getattr(logging, level.upper()), handlers=[handler], force=True
47116
)
48117

49118
# Set specific logger levels
50119
logging.getLogger("asyncio").setLevel(logging.WARNING)
51120
logging.getLogger("urllib3").setLevel(logging.WARNING)
52121

53122
logger = logging.getLogger(__name__)
54-
logger.debug(f"Logging configured with level: {level}")
123+
logger.debug(
124+
f"Logging configured with level: {level}, colors={'on' if use_color else 'off'}"
125+
)
55126

56127

57128
def get_logger(name: str) -> logging.Logger:

tests/unit/test_logging.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Unit tests for logging module."""
17+
18+
import logging
19+
20+
import pytest
21+
from colorama import Fore, Style
22+
from inference_endpoint.utils.logging import ColoredFormatter, setup_logging
23+
24+
25+
class TestColoredFormatter:
26+
"""Tests for the ColoredFormatter class."""
27+
28+
def test_colored_formatter_with_color_enabled(self):
29+
"""Test that ColoredFormatter applies colors when use_color=True."""
30+
formatter = ColoredFormatter(fmt="%(levelname)s - %(message)s", use_color=True)
31+
record = logging.LogRecord(
32+
name="test",
33+
level=logging.INFO,
34+
pathname="test.py",
35+
lineno=1,
36+
msg="test message",
37+
args=(),
38+
exc_info=None,
39+
)
40+
41+
output = formatter.format(record)
42+
43+
# Check that color code is present in output
44+
assert Fore.GREEN in output
45+
assert Style.RESET_ALL in output
46+
assert "test message" in output
47+
48+
def test_colored_formatter_with_color_disabled(self):
49+
"""Test that ColoredFormatter does not apply colors when use_color=False."""
50+
formatter = ColoredFormatter(fmt="%(levelname)s - %(message)s", use_color=False)
51+
record = logging.LogRecord(
52+
name="test",
53+
level=logging.INFO,
54+
pathname="test.py",
55+
lineno=1,
56+
msg="test message",
57+
args=(),
58+
exc_info=None,
59+
)
60+
61+
output = formatter.format(record)
62+
63+
# Check that no color codes are present
64+
assert Fore.GREEN not in output
65+
assert Style.RESET_ALL not in output
66+
assert "INFO" in output
67+
assert "test message" in output
68+
69+
def test_colored_formatter_levelname_restoration(self):
70+
"""Test that ColoredFormatter restores original levelname after formatting."""
71+
formatter = ColoredFormatter(fmt="%(levelname)s", use_color=True)
72+
record = logging.LogRecord(
73+
name="test",
74+
level=logging.WARNING,
75+
pathname="test.py",
76+
lineno=1,
77+
msg="test",
78+
args=(),
79+
exc_info=None,
80+
)
81+
82+
original_levelname = record.levelname
83+
formatter.format(record)
84+
85+
# Verify levelname is restored
86+
assert record.levelname == original_levelname
87+
88+
@pytest.mark.parametrize(
89+
"level, expected_color",
90+
[
91+
(logging.INFO, Fore.GREEN),
92+
(logging.WARNING, Fore.YELLOW),
93+
(logging.ERROR, Fore.RED),
94+
(logging.CRITICAL, Fore.RED),
95+
],
96+
)
97+
def test_colored_formatter_level_colors(self, level, expected_color):
98+
"""Test that correct colors are applied for each log level."""
99+
formatter = ColoredFormatter(fmt="%(levelname)s", use_color=True)
100+
record = logging.LogRecord(
101+
name="test",
102+
level=level,
103+
pathname="test.py",
104+
lineno=1,
105+
msg="test",
106+
args=(),
107+
exc_info=None,
108+
)
109+
110+
output = formatter.format(record)
111+
112+
assert expected_color in output
113+
assert Style.RESET_ALL in output
114+
115+
def test_colored_formatter_debug_level_no_color(self):
116+
"""Test that DEBUG level (unmapped) does not crash or apply colors."""
117+
formatter = ColoredFormatter(fmt="%(levelname)s - %(message)s", use_color=True)
118+
record = logging.LogRecord(
119+
name="test",
120+
level=logging.DEBUG,
121+
pathname="test.py",
122+
lineno=1,
123+
msg="debug message",
124+
args=(),
125+
exc_info=None,
126+
)
127+
128+
output = formatter.format(record)
129+
130+
# DEBUG is not in the color map, so no color should be applied
131+
assert "DEBUG" in output
132+
assert "debug message" in output
133+
134+
# Verify NO colors were applied (since DEBUG is unmapped)
135+
assert Fore.GREEN not in output
136+
assert Fore.YELLOW not in output
137+
assert Fore.RED not in output
138+
assert Style.RESET_ALL not in output
139+
140+
141+
class TestSetupLogging:
142+
"""Tests for the setup_logging function."""
143+
144+
def test_setup_logging_configures_root_logger(self, monkeypatch):
145+
"""Test that setup_logging configures the root logger with a handler."""
146+
# Ensure FORCE_COLOR_LOGGING is not set to ensure colors are disabled by default
147+
monkeypatch.delenv("FORCE_COLOR_LOGGING", raising=False)
148+
149+
# Clear existing handlers
150+
root_logger = logging.getLogger()
151+
root_logger.handlers.clear()
152+
153+
setup_logging()
154+
155+
# Verify root logger has at least one handler
156+
assert len(root_logger.handlers) > 0
157+
158+
def test_setup_logging_colors_enabled_with_force_color_env(self, monkeypatch):
159+
"""Test that FORCE_COLOR_LOGGING env var enables colors by testing output directly."""
160+
monkeypatch.setenv("FORCE_COLOR_LOGGING", "1")
161+
162+
# Clear existing handlers
163+
root_logger = logging.getLogger()
164+
root_logger.handlers.clear()
165+
166+
setup_logging()
167+
168+
# Create a log record and format it
169+
record = logging.LogRecord(
170+
name="test",
171+
level=logging.INFO,
172+
pathname="test.py",
173+
lineno=1,
174+
msg="test",
175+
args=(),
176+
exc_info=None,
177+
)
178+
179+
# Get the first handler's formatter
180+
handler = root_logger.handlers[0]
181+
formatted = handler.formatter.format(record)
182+
183+
# Verify color codes are present in output when FORCE_COLOR_LOGGING is set
184+
assert Fore.GREEN in formatted
185+
186+
def test_setup_logging_colors_disabled_by_default(self, monkeypatch):
187+
"""Test that colors are disabled by default when FORCE_COLOR_LOGGING is not set."""
188+
monkeypatch.delenv("FORCE_COLOR_LOGGING", raising=False)
189+
190+
# Clear existing handlers
191+
root_logger = logging.getLogger()
192+
root_logger.handlers.clear()
193+
194+
setup_logging()
195+
196+
# Create a log record and format it
197+
record = logging.LogRecord(
198+
name="test",
199+
level=logging.INFO,
200+
pathname="test.py",
201+
lineno=1,
202+
msg="test",
203+
args=(),
204+
exc_info=None,
205+
)
206+
207+
# Get the first handler's formatter
208+
handler = root_logger.handlers[0]
209+
formatted = handler.formatter.format(record)
210+
211+
# Verify no color codes in output when FORCE_COLOR_LOGGING is not set
212+
assert Fore.GREEN not in formatted
213+
214+
def test_setup_logging_asyncio_logger_level(self, monkeypatch):
215+
"""Test that asyncio logger is set to WARNING level after calling setup_logging()."""
216+
monkeypatch.delenv("FORCE_COLOR_LOGGING", raising=False)
217+
218+
setup_logging()
219+
220+
asyncio_logger = logging.getLogger("asyncio")
221+
assert asyncio_logger.level == logging.WARNING
222+
223+
def test_setup_logging_urllib3_logger_level(self, monkeypatch):
224+
"""Test that urllib3 logger is set to WARNING level after calling setup_logging()."""
225+
monkeypatch.delenv("FORCE_COLOR_LOGGING", raising=False)
226+
227+
setup_logging()
228+
229+
urllib3_logger = logging.getLogger("urllib3")
230+
assert urllib3_logger.level == logging.WARNING

0 commit comments

Comments
 (0)