Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
58 changes: 42 additions & 16 deletions packages/uipath/src/uipath/_cli/_debug/_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -166,7 +167,7 @@
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,
Expand All @@ -179,11 +180,13 @@

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
Expand Down Expand Up @@ -381,8 +384,26 @@
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(

Check failure on line 387 in packages/uipath/src/uipath/_cli/_debug/_bridge.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 31 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AaDzKTWjm6UrJZdAGlhC&open=AaDzKTWjm6UrJZdAGlhC&pullRequest=1922
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]")
Expand All @@ -405,13 +426,11 @@
)
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

Expand All @@ -424,16 +443,16 @@

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)
Expand All @@ -445,11 +464,18 @@
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()
Expand Down
169 changes: 169 additions & 0 deletions packages/uipath/tests/cli/test_console_debug_bridge.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading