From d3e6080b2a1db1366d921f9a11d1ef4fc43e02fe Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Thu, 23 Apr 2026 14:44:21 +0300 Subject: [PATCH] feat(debug): add --attach flag for non-interactive debug runs --- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/_cli/_debug/_bridge.py | 23 +++++-- packages/uipath/src/uipath/_cli/cli_debug.py | 22 ++++++- .../tests/cli/test_debug_bridge_selection.py | 64 +++++++++++++++++++ packages/uipath/uv.lock | 10 +-- 5 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 packages/uipath/tests/cli/test_debug_bridge_selection.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ae68beed7..a8d04271e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.10.53" +version = "2.10.54" 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" dependencies = [ "uipath-core>=0.5.8, <0.6.0", - "uipath-runtime>=0.10.0, <0.11.0", + "uipath-runtime>=0.10.1, <0.11.0", "uipath-platform>=0.1.13, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py index 9607398a0..2ad4e0418 100644 --- a/packages/uipath/src/uipath/_cli/_debug/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_debug/_bridge.py @@ -19,9 +19,15 @@ UiPathRuntimeResult, UiPathRuntimeStatus, ) -from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugQuitError +from uipath.runtime.debug import ( + DetachedDebugBridge, + UiPathDebugProtocol, + UiPathDebugQuitError, +) from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase +DebugAttachMode = Literal["signalr", "console", "none"] + logger = logging.getLogger(__name__) @@ -871,18 +877,27 @@ def get_remote_debug_bridge(context: UiPathRuntimeContext) -> UiPathDebugProtoco def get_debug_bridge( - context: UiPathRuntimeContext, verbose: bool = True + context: UiPathRuntimeContext, + verbose: bool = True, + attach: DebugAttachMode | None = None, ) -> UiPathDebugProtocol: """Factory to get appropriate debug bridge based on context. Args: context: The runtime context containing debug configuration. verbose: If True, console bridge shows all state updates. If False, only breakpoints. + attach: Explicit attach mode. When None, falls back to + ``context.job_id``-based selection. Returns: An instance of UiPathDebugBridge suitable for the context. """ - if context.job_id: + if attach == "none": + return DetachedDebugBridge() + if attach == "signalr": return get_remote_debug_bridge(context) - else: + if attach == "console": return ConsoleDebugBridge(verbose=verbose) + if context.job_id: + return get_remote_debug_bridge(context) + return ConsoleDebugBridge(verbose=verbose) diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index d2e08353b..cd9042b78 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -1,10 +1,11 @@ import asyncio import logging +from typing import cast, get_args import click from uipath._cli._chat._bridge import get_chat_bridge -from uipath._cli._debug._bridge import get_debug_bridge +from uipath._cli._debug._bridge import DebugAttachMode, get_debug_bridge from uipath._cli._utils._debug import setup_debugging from uipath._cli._utils._studio_project import StudioClient from uipath.core.tracing import UiPathTraceManager @@ -64,6 +65,15 @@ default=5678, help="Port for the debug server (default: 5678)", ) +@click.option( + "--attach", + type=click.Choice(list(get_args(DebugAttachMode)), case_sensitive=False), + default=None, + help=( + "Debugger attach mode. Defaults to 'signalr' for cloud runs, " + "'console' for local runs." + ), +) @track_command("debug") def debug( entrypoint: str | None, @@ -74,6 +84,7 @@ def debug( output_file: str | None, debug: bool, debug_port: int, + attach: str | None, ) -> None: """Debug the project.""" input_file = file or input_file @@ -81,6 +92,10 @@ def debug( if not setup_debugging(debug, debug_port): console.error(f"Failed to start debug server on port {debug_port}") + attach_mode: DebugAttachMode | None = ( + cast(DebugAttachMode, attach.lower()) if attach else None + ) + result = Middlewares.next( "debug", entrypoint, @@ -90,6 +105,7 @@ def debug( output_file=output_file, debug=debug, debug_port=debug_port, + attach=attach_mode, ) if result.error_message: @@ -141,7 +157,9 @@ async def execute_debug_runtime(): async def execute_debug_runtime(): chat_runtime: UiPathRuntimeProtocol | None = None - debug_bridge: UiPathDebugProtocol = get_debug_bridge(ctx) + debug_bridge: UiPathDebugProtocol = get_debug_bridge( + ctx, attach=attach_mode + ) runtime = await factory.new_runtime( entrypoint, diff --git a/packages/uipath/tests/cli/test_debug_bridge_selection.py b/packages/uipath/tests/cli/test_debug_bridge_selection.py new file mode 100644 index 000000000..732461c77 --- /dev/null +++ b/packages/uipath/tests/cli/test_debug_bridge_selection.py @@ -0,0 +1,64 @@ +"""Tests for `get_debug_bridge()` selection matrix. + +Locks in the non-breaking-change contract: absence of `attach` preserves the +legacy `job_id`-based selection. Explicit `attach` overrides that selection. +""" + +from __future__ import annotations + +import pytest + +from uipath._cli._debug._bridge import ( + ConsoleDebugBridge, + SignalRDebugBridge, + get_debug_bridge, +) +from uipath.runtime import UiPathRuntimeContext +from uipath.runtime.debug import DetachedDebugBridge + + +def _ctx(**overrides) -> UiPathRuntimeContext: + return UiPathRuntimeContext(**overrides) + + +def test_attach_none_returns_detached_bridge_without_job_id(): + bridge = get_debug_bridge(_ctx(), attach="none") + assert isinstance(bridge, DetachedDebugBridge) + + +def test_attach_none_returns_detached_bridge_even_when_job_id_set(monkeypatch): + """'none' wins over job_id — this is the whole point of the flag.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="none") + assert isinstance(bridge, DetachedDebugBridge) + + +def test_attach_signalr_forces_signalr_bridge(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="signalr") + assert isinstance(bridge, SignalRDebugBridge) + + +def test_attach_console_forces_console_bridge_even_when_job_id_set(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123"), attach="console") + assert isinstance(bridge, ConsoleDebugBridge) + + +def test_legacy_selection_signalr_when_job_id_set_and_no_attach(monkeypatch): + """Non-breaking change assertion: absence of `attach` preserves today's behavior.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + bridge = get_debug_bridge(_ctx(job_id="job-123")) + assert isinstance(bridge, SignalRDebugBridge) + + +def test_legacy_selection_console_when_no_job_id_and_no_attach(): + """Non-breaking change assertion: absence of `attach` preserves today's behavior.""" + bridge = get_debug_bridge(_ctx()) + assert isinstance(bridge, ConsoleDebugBridge) + + +def test_attach_signalr_without_job_id_raises(): + """Explicit signalr without job_id is a user error — surface it loudly.""" + with pytest.raises(ValueError, match="UIPATH_URL and UIPATH_JOB_KEY"): + get_debug_bridge(_ctx(), attach="signalr") diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index d922431c1..22bd59925 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.53" +version = "2.10.54" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2616,7 +2616,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.10.0,<0.11.0" }, + { name = "uipath-runtime", specifier = ">=0.10.1,<0.11.0" }, ] [package.metadata.requires-dev] @@ -2720,14 +2720,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.10.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/64/69462ee01a5607ce36b1fa152c52ac72fb28abe0aa049394406fc0b31525/uipath_runtime-0.10.0.tar.gz", hash = "sha256:d27d58e2252f506c8c0e00f814b37c3863150e8ffcde8e4c6ab14bd98febd3df", size = 139626, upload-time = "2026-03-24T19:42:43.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/87/2e625219b3364a7153549e6056bce41d2050725ed0844f2711c414a872c0/uipath_runtime-0.10.1.tar.gz", hash = "sha256:9ed1bdb6737ad64cc5bb7ef0c8466dbae8ca010858ecd856818396ea264eb3d5", size = 141189, upload-time = "2026-04-23T11:34:53.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/9c0e97a078b96e4d3742ea3515cb30886b08579cd08077cd42a159adf70d/uipath_runtime-0.10.0-py3-none-any.whl", hash = "sha256:4f52df0b56f54e70fcf34fbf74e223d02b97b5a6fd6d8f64bc06782bb5484b07", size = 42097, upload-time = "2026-03-24T19:42:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/ad/41/bc3465ee89dd01f8a9045d7d22d0f0927c0d437242eeded8d3d5b33f50ed/uipath_runtime-0.10.1-py3-none-any.whl", hash = "sha256:f04483db92ee7683513762a79bf48c229c7133d5adc7fef10ea5eaa4c7ce9b29", size = 43057, upload-time = "2026-04-23T11:34:51.781Z" }, ] [[package]]