diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 5acc57d48..141da3925 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.14.27" +version = "2.14.28" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index 0094bc012..98d8e28b3 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from rich.console import Console +from rich.markup import escape from rich.tree import Tree from uipath.core.serialization import serialize_object @@ -166,7 +167,7 @@ async def emit_execution_completed( output_data = runtime_result.output.model_dump() else: output_data = runtime_result.output - self._print_json(output_data, label="output") + self._print_json(output_data, label="output", truncate=False) async def emit_execution_suspended( self, @@ -179,11 +180,13 @@ async def emit_execution_suspended( if runtime_result.trigger.trigger_type == UiPathResumeTriggerType.API: if runtime_result.output is None: - self._print_json({}, label="output") + self._print_json({}, label="output", truncate=False) elif isinstance(runtime_result.output, BaseModel): - self._print_json(runtime_result.output.model_dump(), label="output") + self._print_json( + runtime_result.output.model_dump(), label="output", truncate=False + ) else: - self._print_json(runtime_result.output, label="output") + self._print_json(runtime_result.output, label="output", truncate=False) self.console.print("[dim]Please provide your input:[/dim]") self._waiting_for_resume_data = True @@ -381,8 +384,26 @@ def _print_help(self) -> None: self.console.print(" [yellow]q, quit[/yellow] Exit debugger") self.console.print() - def _print_json(self, data: dict[str, Any] | str, label: str = "data") -> None: - """Print JSON data with enhanced hierarchy.""" + def _print_json( + self, + data: dict[str, Any] | str, + label: str = "data", + truncate: bool = True, + ) -> None: + """Print JSON data with enhanced hierarchy. + + Args: + data: The payload to print. + label: The root label of the tree. + truncate: Shorten long values and deep nesting. Pass False for a + run's output, which is the result itself and must print whole. + """ + + def shorten(val_str: str) -> str: + if truncate and len(val_str) > 250: + return val_str[:250] + "..." + return val_str + try: # Create a tree for nested structure tree = Tree(f"[bold cyan]{label}[/bold cyan]") @@ -405,13 +426,11 @@ def process_value( ) add_to_tree(branch, value, depth + 1) else: - val_str = str(value) - if len(val_str) > 250: - val_str = val_str[:250] + "..." + val_str = escape(shorten(str(value))) node.add(f"{key_label}: [green]{val_str}[/green]") def add_to_tree(node: Tree, payload: Any, depth: int = 0): - if depth > 10: + if truncate and depth > 10: node.add("[dim]...[/dim]") return @@ -424,16 +443,16 @@ def add_to_tree(node: Tree, payload: Any, depth: int = 0): elif isinstance(payload, dict): for key, value in payload.items(): - process_value(node, value, f"[yellow]{key}[/yellow]", depth) + process_value( + node, value, f"[yellow]{escape(str(key))}[/yellow]", depth + ) elif isinstance(payload, list): for i, item in enumerate(payload): process_value(node, item, f"[cyan]#{i}[/cyan]", depth) else: - val_str = str(payload) - if len(val_str) > 250: - val_str = val_str[:250] + "..." + val_str = escape(shorten(str(payload))) node.add(f"[green]{val_str}[/green]") add_to_tree(tree, data) @@ -445,11 +464,18 @@ def add_to_tree(node: Tree, payload: Any, depth: int = 0): except Exception: try: json_str = json.dumps(data, indent=2, default=str) - if len(json_str) > 10000: + if truncate and len(json_str) > 10000: json_str = json_str[:10000] + "\n..." from rich.syntax import Syntax - syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False) + # Syntax crops lines wider than the console unless it wraps. + syntax = Syntax( + json_str, + "json", + theme="monokai", + line_numbers=False, + word_wrap=not truncate, + ) self.console.print(f"\n[dim]{label}:") self.console.print(syntax) self.console.print() diff --git a/packages/uipath/tests/cli/test_console_debug_bridge.py b/packages/uipath/tests/cli/test_console_debug_bridge.py new file mode 100644 index 000000000..fc132d01a --- /dev/null +++ b/packages/uipath/tests/cli/test_console_debug_bridge.py @@ -0,0 +1,169 @@ +"""Tests for how `ConsoleDebugBridge` renders payloads. + +A run's output is the result the user asked for, so it must print whole. +Intermediate state is a progress stream and may still be shortened. +""" + +from typing import Any + +import pytest +from pydantic import BaseModel +from rich.console import Console + +from uipath._cli._debug._bridge import ConsoleDebugBridge +from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType +from uipath.runtime import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase + +# 256 characters, the length from the original report. The tail is distinct +# so a cut at 250 cannot pass by accident. +LONG = "x" * 240 + "0123456789ABCDEF" + + +def _bridge() -> ConsoleDebugBridge: + bridge = ConsoleDebugBridge() + # Wide enough that rich never wraps a value across lines. + bridge.console = Console(record=True, width=2000, color_system=None) + return bridge + + +async def _render_output(output: Any) -> str: + bridge = _bridge() + await bridge.emit_execution_completed( + UiPathRuntimeResult(output=output, status=UiPathRuntimeStatus.SUCCESSFUL) + ) + return bridge.console.export_text() + + +async def test_completed_output_prints_long_value_whole() -> None: + text = await _render_output({"dials": LONG}) + + assert LONG in text + assert "..." not in text + + +async def test_completed_output_prints_long_values_nested_and_in_lists() -> None: + text = await _render_output( + {"outer": {"inner": LONG}, "items": [LONG, {"deep": LONG}]} + ) + + assert text.count(LONG) == 3 + + +async def test_completed_output_prints_long_string_output_whole() -> None: + text = await _render_output(LONG) + + assert LONG in text + + +async def test_completed_output_prints_pydantic_model_whole() -> None: + class Output(BaseModel): + dials: str + + text = await _render_output(Output(dials=LONG)) + + assert LONG in text + + +async def test_completed_output_keeps_nesting_deeper_than_ten() -> None: + payload: dict[str, Any] = {"leaf": "bottom-value"} + for i in range(15): + payload = {f"level{i}": payload} + + text = await _render_output(payload) + + assert "bottom-value" in text + + +async def test_completed_output_prints_markup_like_text_literally() -> None: + text = await _render_output( + {"[b]key[/b]": "see [bold]docs[/bold]", "note": "close [/x] tag"} + ) + + assert "see [bold]docs[/bold]" in text + assert "close [/x] tag" in text + assert "[b]key[/b]" in text + # A render error would switch to the JSON fallback, which prints the label + # as "output:" instead of a tree root. + assert "output:" not in text + + +async def _render_suspended(output: Any) -> str: + bridge = _bridge() + await bridge.emit_execution_suspended( + UiPathRuntimeResult( + output=output, + status=UiPathRuntimeStatus.SUSPENDED, + trigger=UiPathResumeTrigger(trigger_type=UiPathResumeTriggerType.API), + ) + ) + return bridge.console.export_text() + + +async def test_suspended_api_output_prints_long_value_whole() -> None: + assert LONG in await _render_suspended({"question": LONG}) + + +async def test_suspended_api_output_prints_pydantic_model_whole() -> None: + class Question(BaseModel): + question: str + + assert LONG in await _render_suspended(Question(question=LONG)) + + +async def test_suspended_api_output_without_payload_prints_empty_output() -> None: + text = await _render_suspended(None) + + assert "output" in text + assert "Please provide your input" in text + + +class _BrokenTree: + """Stands in for rich's Tree so the JSON fallback path runs.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + raise RuntimeError("tree rendering failed") + + +async def test_json_fallback_prints_completed_output_whole( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("uipath._cli._debug._bridge.Tree", _BrokenTree) + huge = "y" * 20000 + + text = await _render_output({"blob": huge}) + + # The fallback wraps long lines, so compare with the line breaks removed. + assert huge in "".join(text.split()) + + +async def test_json_fallback_still_shortens_state_updates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("uipath._cli._debug._bridge.Tree", _BrokenTree) + huge = "y" * 20000 + bridge = _bridge() + await bridge.emit_state_update( + UiPathRuntimeStateEvent( + node_name="node", + phase=UiPathRuntimeStatePhase.UPDATED, + payload={"blob": huge}, + ) + ) + + assert huge not in "".join(bridge.console.export_text().split()) + + +async def test_state_update_still_shortens_long_values() -> None: + bridge = _bridge() + await bridge.emit_state_update( + UiPathRuntimeStateEvent( + node_name="node", + phase=UiPathRuntimeStatePhase.UPDATED, + payload={"state": LONG}, + ) + ) + text = bridge.console.export_text() + + assert LONG not in text + assert LONG[:250] + "..." in text diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 00cd8a8f2..fe23bda33 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.27" +version = "2.14.28" source = { editable = "." } dependencies = [ { name = "applicationinsights" },