From 6a49dce3fab48bcd1427c952a5c4e5d65b70b960 Mon Sep 17 00:00:00 2001 From: Karanraj Date: Thu, 18 Sep 2025 22:16:45 +0530 Subject: [PATCH] fix(logger): replace emojis with Unicode and ensure LogLevel consistency - Updated all ConsoleLogger convenience methods to pass Enum members instead of .value - Fixed log() to extract emoji with level.value internally, maintaining type-safety - Ensured error messages use level == LogLevel.ERROR for stderr - Made evaluation progress methods consistent with LogLevel Unicode symbols - Overall: logging and progress updates now reliably display intended Unicode symbols ->latest change: - Updated ConsoleLogger.error() to use click.get_current_context().exit when available and fallback to builtins.exit for reliable behavior - Ensures proper exit in CLI context while remaining testable with mocks - Retains previous logging improvements: Unicode symbols, LogLevel consistency, and type-safety - Simplified formatted_message logic and cleaned up internal log handling - Updated "info" Unicode to empty string for consistent output - Added test_console.py coverage for error() exit behavior: * Mocked ctx.exit to verify proper invocation * Ensures builtins.exit fallback works in test scenarios - Overall: error logging and exit mechanism now robust, consistent, and fully tested --- src/uipath/_cli/_utils/_console.py | 36 ++++---- tests/cli/utils/test_console.py | 140 +++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 tests/cli/utils/test_console.py diff --git a/src/uipath/_cli/_utils/_console.py b/src/uipath/_cli/_utils/_console.py index 02463d4ab..70e48c7f9 100644 --- a/src/uipath/_cli/_utils/_console.py +++ b/src/uipath/_cli/_utils/_console.py @@ -19,15 +19,16 @@ class LogLevel(Enum): """Enum for log levels with corresponding emojis.""" - INFO = "" - SUCCESS = click.style("✓ ", fg="green", bold=True) - WARNING = "⚠️" - ERROR = "❌" - HINT = "💡" - CONFIG = "🔧" - SELECT = "👇" - LINK = "🔗" - MAGIC = "✨" + INFO = "" + SUCCESS = "\u2713" # ✓ (CHECK MARK) + WARNING = "\u26A0" # ⚠ (WARNING SIGN) + ERROR = "\u274C" # ❌ (CROSS MARK) + HINT = "\U0001F4A1" # 💡 (ELECTRIC LIGHT BULB) + CONFIG = "\U0001F527" # 🔧 (WRENCH) + SELECT = "\U0001F447" # 👇 (BACKHAND INDEX POINTING DOWN) + LINK = "\U0001F517" # 🔗 (LINK SYMBOL) + MAGIC = "\u2728" # ✨ (SPARKLES) + T = TypeVar("T", bound="ConsoleLogger") @@ -86,17 +87,12 @@ def log( """ # Stop any active spinner before logging self._stop_spinner_if_active() + + formatted_message = f"{level.value} " if level.value else "" + formatted_message += click.style(message, fg=fg) if fg else message - if not level == LogLevel.INFO: - emoji = level.value - if fg: - formatted_message = f"{emoji} {click.style(message, fg=fg)}" - else: - formatted_message = f"{emoji} {message}" - else: - formatted_message = message + click.echo(formatted_message, err=(level == LogLevel.ERROR)) - click.echo(formatted_message, err=LogLevel.ERROR in (level,)) def success(self, message: str) -> None: """Log a success message.""" @@ -295,7 +291,7 @@ def complete_evaluation(self, eval_id: str) -> None: self.progress.update( task_id, completed=1, - description=f"[green]✅ {current_desc}[/green]", + description=f"[green]{LogLevel.SUCCESS.value} {current_desc}[/green]", ) def fail_evaluation(self, eval_id: str, error_message: str) -> None: @@ -315,5 +311,5 @@ def fail_evaluation(self, eval_id: str, error_message: str) -> None: current_desc = self.progress.tasks[task_id].description self.progress.update( task_id, - description=f"[red]❌ {current_desc} - {short_error}[/red]", + description=f"[red]{LogLevel.ERROR.value} {current_desc} - {short_error}[/red]", ) diff --git a/tests/cli/utils/test_console.py b/tests/cli/utils/test_console.py new file mode 100644 index 000000000..547e5f9b4 --- /dev/null +++ b/tests/cli/utils/test_console.py @@ -0,0 +1,140 @@ +from unittest.mock import MagicMock, patch + +from src.uipath._cli._utils._console import ( + ConsoleLogger, + EvaluationProgressManager, + LogLevel, +) + + +def test_singleton(): + logger1 = ConsoleLogger.get_instance() + logger2 = ConsoleLogger.get_instance() + assert logger1 is logger2, "ConsoleLogger should be a singleton" + + +@patch("click.echo") +def test_log_levels(mock_echo): + logger = ConsoleLogger.get_instance() + messages = [ + ("info message", LogLevel.INFO), + ("success message", LogLevel.SUCCESS), + ("warning message", LogLevel.WARNING), + ("error message", LogLevel.ERROR), + ("hint message", LogLevel.HINT), + ("magic message", LogLevel.MAGIC), + ("config message", LogLevel.CONFIG), + ("select message", LogLevel.SELECT), + ("link message", LogLevel.LINK, "blue"), + ] + + for msg in messages: + if len(msg) == 2: + logger.log(msg[0], msg[1]) + else: + logger.log(msg[0], msg[1], fg=msg[2]) + + assert mock_echo.call_count == 9 + + +@patch("click.prompt", return_value="user_input") +def test_prompt(mock_prompt): + logger = ConsoleLogger.get_instance() + result = logger.prompt("Enter something") + assert result == "user_input" + mock_prompt.assert_called_once() + + +@patch("click.echo") +def test_display_options(mock_echo): + logger = ConsoleLogger.get_instance() + options = ["opt1", "opt2"] + logger.display_options(options) + # 1 for header, 2 for options + assert mock_echo.call_count == 3 + + +@patch("src.uipath._cli._utils._console.Progress") +def test_evaluation_progress(mock_progress_class): + class MockTask: + def __init__(self, name, total): + self.name = name + self.total = total + self.completed = 0 + self.description = name + + class MockProgressContext: + def __init__(self): + self.tasks = {} + + def add_task(self, name, total=1): + task_id = len(self.tasks) + 1 + self.tasks[task_id] = MockTask(name, total) + return task_id + + def update(self, task_id, completed=0, description=None): + task = self.tasks.get(task_id) + if task: + task.completed = completed + if description: + task.description = description + + def start(self): + pass + + def stop(self): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + mock_progress_class.return_value = MockProgressContext() + evaluations = [ + {"id": "1", "name": "Test Eval 1"}, + {"id": "2", "name": "Test Eval 2"}, + ] + logger = ConsoleLogger.get_instance() + with logger.evaluation_progress(evaluations) as manager: + assert isinstance(manager, EvaluationProgressManager) + manager.complete_evaluation("1") + manager.fail_evaluation("2", "Failed") + + +@patch("src.uipath._cli._utils._console.click.get_current_context") +@patch("src.uipath._cli._utils._console.click.echo") +def test_error_exit(mock_echo, mock_context): + + # MagicMock for ctx.exit + mock_ctx = mock_context.return_value + mock_ctx.exit = MagicMock() + + logger = ConsoleLogger.get_instance() + logger.error("error message", include_traceback=False) + + # Check that ctx.exit got called instead of builtins.exit + mock_ctx.exit.assert_called_once_with(1) + + +@patch("click.prompt", return_value="") +def test_prompt_empty_input(mock_prompt): + logger = ConsoleLogger.get_instance() + result = logger.prompt("Enter something") + assert result == "", "Prompt should handle empty input gracefully" + + +@patch("click.echo") +def test_log_with_custom_fg_bg(mock_echo): + logger = ConsoleLogger.get_instance() + logger.log("custom message", LogLevel.INFO, fg="red", bg="yellow") + mock_echo.assert_called_once() + + +@patch("click.echo") +def test_display_options_empty(mock_echo): + logger = ConsoleLogger.get_instance() + logger.display_options([]) + # Only header is printed even if no options + assert mock_echo.call_count == 1