From 67ed7a1e0085df232c11b3705bc0c2acac9d5845 Mon Sep 17 00:00:00 2001 From: Andreas Loeffler <73336148+andreas-loeffler@users.noreply.github.com> Date: Sun, 15 Feb 2026 06:32:23 +0800 Subject: [PATCH] add functionality to enable color logging for ros2 launch files Signed-off-by: Andreas Loeffler <73336148+andreas-loeffler@users.noreply.github.com> --- launch/launch/logging/__init__.py | 33 +++++++- launch/package.xml | 1 + launch/test/launch/test_logging.py | 123 +++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 3 deletions(-) diff --git a/launch/launch/logging/__init__.py b/launch/launch/logging/__init__.py index bc058a161..1505e15f7 100644 --- a/launch/launch/logging/__init__.py +++ b/launch/launch/logging/__init__.py @@ -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 @@ -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() @@ -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: diff --git a/launch/package.xml b/launch/package.xml index 0c8ee9211..0dc8fcf72 100644 --- a/launch/package.xml +++ b/launch/package.xml @@ -25,6 +25,7 @@ python3-osrf-pycommon python3-yaml python3-typing-extensions + python3-colorlog ament_copyright ament_flake8 diff --git a/launch/test/launch/test_logging.py b/launch/test/launch/test_logging.py index 62ecebae9..75c8b1a1e 100644 --- a/launch/test/launch/test_logging.py +++ b/launch/test/launch/test_logging.py @@ -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.""" @@ -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