Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions launch/launch/logging/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
from typing import (Any, Dict, List, Literal, Optional, Protocol, Set, Tuple,
Union)

import colorlog

from typing_extensions import TypeAlias

from . import handlers
Expand Down Expand Up @@ -126,6 +128,27 @@ def reset(self) -> None:
self.set_screen_format('default')
self.set_log_format('default')

def _logging_colors_enabled(self) -> bool:
"""
Determine if colored output should be used for console logging.

Colors are enabled when:
- RCUTILS_COLORIZED_OUTPUT=1 is explicitly set, OR
- RCUTILS_COLORIZED_OUTPUT is unset AND stdout is a TTY

Colors are disabled when:
- RCUTILS_COLORIZED_OUTPUT=0 is explicitly set

:return: True if colors should be used, False otherwise
"""
colorized_output = os.environ.get('RCUTILS_COLORIZED_OUTPUT')
if colorized_output == '0':
return False
elif colorized_output == '1':
return True
else:
return sys.stdout.isatty()

@property
def level(self) -> int:
return logging.root.getEffectiveLevel()
Expand Down Expand Up @@ -262,9 +285,13 @@ def set_screen_format(self, screen_format: str, *,
)
if screen_style is None:
screen_style = '{'
self.screen_formatter = logging.Formatter(
screen_format, style=screen_style
)
if self._logging_colors_enabled():
self.screen_formatter = colorlog.ColoredFormatter(
'{log_color}' + screen_format,
style=screen_style
)
else:
self.screen_formatter = logging.Formatter(screen_format, style=screen_style)
if self.screen_handler is not None:
self.screen_handler.setFormatter(self.screen_formatter)
else:
Expand Down
1 change: 1 addition & 0 deletions launch/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<exec_depend>python3-osrf-pycommon</exec_depend>
<exec_depend>python3-yaml</exec_depend>
<exec_depend>python3-typing-extensions</exec_depend>
<exec_depend>python3-colorlog</exec_depend>

<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
Expand Down
123 changes: 123 additions & 0 deletions launch/test/launch/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@
import pytest


def contains_ansicode(str_to_check: str) -> bool:
"""
Check if given string contains an ANSI code sequence.

:param str_to_check: Given string to check for ANSI codes.
:return: True if the given string contains any ANSI code sequence,
False otherwise.
"""
ansi_regex = re.compile(r"""
\x1b # literal ESC
\[ # literal [
[;\d]* # zero or more digits or semicolons
[A-Za-z] # a letter
""", re.VERBOSE)

return bool(ansi_regex.search(str_to_check))


@pytest.fixture
def log_dir(tmpdir_factory):
"""Test fixture that generates a temporary directory for log files."""
Expand Down Expand Up @@ -97,6 +115,111 @@ def test_output_loggers_bad_configuration(log_dir):
]


def test_colorized_output_with_tty(capsys, monkeypatch, mock_clean_env):
"""
Test color output with TTY.

Colors should be enabled when stdout is set to a TTY and
RCUTILS_COLORIZED_OUTPUT is unset.
"""
monkeypatch.setattr('sys.stdout.isatty', lambda: True)
monkeypatch.delenv('RCUTILS_COLORIZED_OUTPUT', raising=False)

launch.logging.reset()
logger = launch.logging.get_logger()
logger.setLevel(logging.INFO)
logger.info('Test message with TTY')

capture = capsys.readouterr()
assert contains_ansicode(capture.out), \
'Expected ANSI color codes in output with TTY'


def test_colorized_output_disabled_by_env(capsys, monkeypatch, mock_clean_env):
"""
Test color output disabled by env var.

Colors should be disabled when stdout is a TTY but
RCUTILS_COLORIZED_OUTPUT=0.
"""
monkeypatch.setattr('sys.stdout.isatty', lambda: True)
monkeypatch.setenv('RCUTILS_COLORIZED_OUTPUT', '0')

launch.logging.reset()
logger = launch.logging.get_logger()
logger.setLevel(logging.INFO)
logger.info('Test message with colors disabled')

capture = capsys.readouterr()
assert not contains_ansicode(capture.out), \
'Expected no ANSI color codes when disabled by env var'


def test_colorized_output_forced_by_env(capsys, monkeypatch, mock_clean_env):
"""
Test color output forced by env var in non TTY.

Colors should be forced on when RCUTILS_COLORIZED_OUTPUT=1
even without TTY.
"""
monkeypatch.setattr('sys.stdout.isatty', lambda: False)
monkeypatch.setenv('RCUTILS_COLORIZED_OUTPUT', '1')

launch.logging.reset()
logger = launch.logging.get_logger()
logger.setLevel(logging.INFO)
logger.info('Test message with forced colors')

capture = capsys.readouterr()
assert contains_ansicode(capture.out), \
'Expected ANSI color codes when forced by env var'


def test_file_logs_not_colorized(log_dir, monkeypatch, mock_clean_env):
"""
Test that file logs are not colorized.

File logs should never contain ANSI color codes even if
RCUTILS_COLORIZED_OUTPUT=1.
"""
monkeypatch.setenv('RCUTILS_COLORIZED_OUTPUT', '1')

launch.logging.reset()
launch.logging.launch_config.log_dir = log_dir
logger = launch.logging.get_logger()
logger.setLevel(logging.INFO)
logger.info('Test message for file logging')

log_file = pathlib.Path(log_dir) / 'launch.log'
assert log_file.exists(), 'Log file should exist'
file_contents = log_file.read_text()

assert not contains_ansicode(file_contents), \
'File logs should never contain ANSI color codes'
assert 'Test message for file logging' in file_contents, \
'Message should be in log file'


def test_colorized_output_no_tty(capsys, monkeypatch, mock_clean_env):
"""
Test color output without a TTY and no env var.

Colors should be disabled when stdout is not a TTY and
RCUTILS_COLORIZED_OUTPUT is unset.
"""
monkeypatch.setattr('sys.stdout.isatty', lambda: False)
monkeypatch.delenv('RCUTILS_COLORIZED_OUTPUT', raising=False)

launch.logging.reset()
logger = launch.logging.get_logger()
logger.setLevel(logging.INFO)
logger.info('Test message without TTY')

capture = capsys.readouterr()
assert not contains_ansicode(capture.out), \
'Expected no ANSI color codes without TTY'


@pytest.mark.parametrize('config,checks,main_log_file_name', params)
def test_output_loggers_configuration(
capsys, log_dir, config, checks, main_log_file_name, mock_clean_env
Expand Down