Skip to content
Closed
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.10"
version = "2.14.11"
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
13 changes: 11 additions & 2 deletions packages/uipath/src/uipath/_cli/_server_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,19 @@ def parse_args(args: str | list[str] | None) -> list[str]:
return []


def _surfaced_exit_code(result_value: Any, surface_exit_code: bool) -> int:
"""The command's own int return code when surfacing is enabled; else 0."""
if surface_exit_code and isinstance(result_value, int):
return result_value
return 0


async def _run_command_isolated(
cmd: Any,
args: list[str],
env_vars: dict[str, str],
working_dir: str | None,
surface_exit_code: bool = False,
) -> dict[str, Any]:
"""Run one command with per-job env/cwd isolation (the shared job core)."""
if _state.lock is None or _state.baseline_env is None:
Expand Down Expand Up @@ -82,9 +90,10 @@ async def _run_command_isolated(
result_value = await asyncio.to_thread(
cmd.main, args, standalone_mode=False
)
exit_code = _surfaced_exit_code(result_value, surface_exit_code)
return {
"ExitCode": 0,
"Error": None,
"ExitCode": exit_code,
"Error": None if exit_code == 0 else f"Exit code: {exit_code}",
Comment on lines 90 to +96
"Result": result_value,
"Unexpected": False,
}
Expand Down
25 changes: 24 additions & 1 deletion packages/uipath/src/uipath/_cli/cli_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,45 @@
default=None,
help="Simulation config as a JSON object (same schema as simulation.json)",
)
@click.option(
"--server-mode",
is_flag=True,
help="Serve jobs over uipath-ipc and stay alive (requires --ipc-pipe), "
"instead of running once and exiting.",
)
@click.option(
"--ipc-pipe",
type=str,
default=None,
help="Named pipe for the uipath-ipc channel (used with --server-mode).",
)
@track_command("run")
def run(
entrypoint: str | None,
input: str | None,
resume: bool,
file: str | None,
input_file: str | None,
output_file: str | None,
trace_file: str | None,
state_file: str | None,
debug: bool,
debug_port: int,
keep_state_file: bool,
simulation: str | None,
server_mode: bool,
ipc_pipe: str | None,

Check warning on line 145 in packages/uipath/src/uipath/_cli/cli_run.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Function "run" has 14 parameters, which is greater than the 13 authorized.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AaBHg3Jy6e8D0U_YCQHM&open=AaBHg3Jy6e8D0U_YCQHM&pullRequest=1875
) -> None:
"""Execute the project."""
"""Execute the project, or serve jobs over uipath-ipc with --server-mode."""
if server_mode:
if not ipc_pipe:
console.error("--server-mode requires --ipc-pipe.")
# cli_server -> _server_core -> cli_run cycles at import time; defer it.
from .cli_server import run_ipc_server

run_ipc_server(ipc_pipe, surface_exit_code=True)
return

input_file = file or input_file

# Setup debugging if requested
Expand Down
16 changes: 16 additions & 0 deletions packages/uipath/src/uipath/_cli/cli_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

__all__ = [
"server",
"run_ipc_server",
"IPythonRuntimeServer",
"PythonRuntimeService",
"PythonServerRunJobResult",
Expand Down Expand Up @@ -399,3 +400,18 @@ def _run_server(
asyncio.run(coro)
except KeyboardInterrupt:
console.info("Shutting down")


def run_ipc_server(ipc_pipe: str, surface_exit_code: bool = False) -> None:
"""Serve only the uipath-ipc channel, staying alive until the pipe closes.

Used by ``uipath run --server-mode``: no HTTP channel, no ready-ACK socket.
"""
try:
if sys.platform == "win32": # pragma: no cover
with asyncio.Runner(loop_factory=asyncio.ProactorEventLoop) as runner:
runner.run(start_ipc_server(ipc_pipe, surface_exit_code))
else: # pragma: no cover
asyncio.run(start_ipc_server(ipc_pipe, surface_exit_code))
except KeyboardInterrupt: # pragma: no cover
console.info("Shutting down")
13 changes: 10 additions & 3 deletions packages/uipath/src/uipath/_cli/cli_server_ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ async def StopJob(self, request: PythonServerStopJobRequest) -> bool:
class PythonRuntimeService(IPythonRuntimeServer):
"""``IPythonRuntimeServer`` implementation backed by run/debug/eval."""

def __init__(self, surface_exit_code: bool = False) -> None:
self._surface_exit_code = surface_exit_code

async def Register(self) -> bool:
console.info("Runtime client registered.")
return True
Expand All @@ -81,7 +84,11 @@ async def RunJob(self, request: PythonServerRunRequest) -> PythonServerRunJobRes
)

result = await _run_command_isolated(
cmd, args, request.EnvironmentVariables, request.WorkingDirectory
cmd,
args,
request.EnvironmentVariables,
request.WorkingDirectory,
surface_exit_code=self._surface_exit_code,
)
# IPC contract (PythonServerRunJobResult) carries only ExitCode + Error.
return PythonServerRunJobResult(
Expand All @@ -96,7 +103,7 @@ async def StopJob(self, request: PythonServerStopJobRequest) -> bool:
return True


async def start_ipc_server(pipe_name: str) -> None:
async def start_ipc_server(pipe_name: str, surface_exit_code: bool = False) -> None:
"""Serve the Python runtime over a uipath-ipc named pipe until it is closed."""
try:
from uipath_ipc import IpcServer, NamedPipeServerTransport
Expand All @@ -110,7 +117,7 @@ async def start_ipc_server(pipe_name: str) -> None:
_state.init()
server = IpcServer(
transport=NamedPipeServerTransport(pipe_name),
services={IPythonRuntimeServer: PythonRuntimeService()},
services={IPythonRuntimeServer: PythonRuntimeService(surface_exit_code)},
request_timeout=None, # jobs are long-running; no server-side timeout
)
console.success(f"IPC server listening on pipe '{pipe_name}'")
Expand Down
90 changes: 88 additions & 2 deletions packages/uipath/tests/cli/test_server_ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def _unique_pipe() -> str:
return f"uipath-ipc-test-{os.getpid()}-{_pipe_counter}"


def _serve_in_background(pipe_name: str) -> None:
def _serve_in_background(pipe_name: str, surface_exit_code: bool = False) -> None:
"""Run the IPC server on its own event loop in a daemon thread.

``asyncio.new_event_loop()`` yields the per-OS default loop — Proactor on
Expand All @@ -55,7 +55,7 @@ def run_server() -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(start_ipc_server(pipe_name))
loop.run_until_complete(start_ipc_server(pipe_name, surface_exit_code))
except asyncio.CancelledError:
pass
finally:
Expand Down Expand Up @@ -329,3 +329,89 @@ async def drive(proxy: Any) -> None:
assert stop_request.JobKey == job_key
assert stop_request.ResumeVersion == 5
assert stop_request.ForceStop is True


class TestIpcServerExitCode:
"""surface_exit_code makes the IPC server report a command's non-zero exit
code; the default reports 0."""

@pytest.fixture
def fail_command(self):
@click.command()
def fail_cmd() -> None:
click.get_current_context().exit(7)

original = _server_core.COMMANDS.copy()
_server_core.COMMANDS["fail"] = fail_cmd
try:
yield
finally:
_server_core.COMMANDS.clear()
_server_core.COMMANDS.update(original)

def test_surface_exit_code_reports_nonzero(self, fail_command):
pipe_name = _unique_pipe()
_serve_in_background(pipe_name, surface_exit_code=True)
result = asyncio.run(
_with_proxy(
pipe_name, lambda p: p.RunJob({"JobKey": "j", "Command": "fail"})
)
)
assert result.ExitCode == 7

def test_default_reports_zero(self, fail_command):
pipe_name = _unique_pipe()
_serve_in_background(pipe_name)
result = asyncio.run(
_with_proxy(
pipe_name, lambda p: p.RunJob({"JobKey": "j", "Command": "fail"})
)
)
assert result.ExitCode == 0


class TestRunServerModeGuard:
"""`uipath run --server-mode` requires --ipc-pipe."""

def test_server_mode_requires_ipc_pipe(self):
from click.testing import CliRunner

from uipath._cli.cli_run import run

result = CliRunner().invoke(run, ["--server-mode"])
assert result.exit_code == 1


class TestServerModeWiring:
"""`uipath run --server-mode` defers to run_ipc_server, which drives start_ipc_server."""

def test_run_ipc_server_drives_start_ipc_server(self, monkeypatch):
from uipath._cli import cli_server

captured: dict[str, Any] = {}

async def fake_start(pipe, surface_exit_code=False):
captured["pipe"] = pipe
captured["surface"] = surface_exit_code

monkeypatch.setattr(cli_server, "start_ipc_server", fake_start)
cli_server.run_ipc_server("pipe-1", surface_exit_code=True)
assert captured == {"pipe": "pipe-1", "surface": True}

def test_run_server_mode_defers_to_ipc_server(self, monkeypatch):
from click.testing import CliRunner

from uipath._cli import cli_server
from uipath._cli.cli_run import run

captured: dict[str, Any] = {}
monkeypatch.setattr(
cli_server,
"run_ipc_server",
lambda pipe, surface_exit_code=False: captured.update(
pipe=pipe, surface=surface_exit_code
),
)
result = CliRunner().invoke(run, ["--server-mode", "--ipc-pipe", "mypipe"])
assert result.exit_code == 0
assert captured == {"pipe": "mypipe", "surface": True}
32 changes: 32 additions & 0 deletions packages/uipath/tests/cli/test_server_job_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,38 @@ async def test_reports_unexpected_exception(restore_state: Any) -> None:
assert "boom" in result["Error"]


async def test_surface_exit_code_reports_nonzero_return(restore_state: Any) -> None:
_init(restore_state)
cmd = Mock()
cmd.main.return_value = 3
result = await _server_core._run_command_isolated(
cmd, [], {}, None, surface_exit_code=True
)
assert result["ExitCode"] == 3
assert result["Error"] == "Exit code: 3"
assert result["Unexpected"] is False


async def test_surface_exit_code_zero_return_is_success(restore_state: Any) -> None:
_init(restore_state)
cmd = Mock()
cmd.main.return_value = None
result = await _server_core._run_command_isolated(
cmd, [], {}, None, surface_exit_code=True
)
assert result["ExitCode"] == 0
assert result["Error"] is None


async def test_default_does_not_surface_nonzero_return(restore_state: Any) -> None:
_init(restore_state)
cmd = Mock()
cmd.main.return_value = 3
result = await _server_core._run_command_isolated(cmd, [], {}, None)
assert result["ExitCode"] == 0
assert result["Error"] is None


# parse_args accepts what every caller sends: the .NET peer sends a single
# string (shlex-split), HTTP dicts / tests may send a pre-split list, or None.

Expand Down
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