Skip to content
Draft
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.25"
version = "2.14.26"
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
4 changes: 3 additions & 1 deletion packages/uipath/src/uipath/_cli/_job_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def _to_log_level(levelno: int) -> int:
# Defensive only: the runtime delivers a result for SUCCESSFUL and FAULTED alone, and the peer
# resolves a suspended job from output.json (resume triggers never cross the wire).
"suspended": ExecutorJobStatus.SUSPENDED.value,
"stopped": ExecutorJobStatus.STOPPED.value,
}


Expand All @@ -144,7 +145,8 @@ def _to_result_dto(
return PythonJobResultDto(
jobKey=job_key,
resumeVersion=resume_version,
status=_EXECUTOR_STATUS.get(status_key, ExecutorJobStatus.SUCCESSFUL.value),
# A status this build does not know must not be reported as a success.
status=_EXECUTOR_STATUS.get(status_key, ExecutorJobStatus.FAULTED.value),
outputArgumentsFilePath=output_arguments_file_path,
error=error,
)
Expand Down
22 changes: 15 additions & 7 deletions packages/uipath/src/uipath/_cli/_server_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ def parse_args(args: str | list[str] | None) -> list[str]:
return []


def _exit_code_outcome(exit_code: int) -> dict[str, Any]:
return {
"ExitCode": exit_code,
"Error": None if exit_code == 0 else f"Exit code: {exit_code}",
"Result": None,
"Unexpected": False,
}


async def _run_command_isolated(
cmd: Any,
args: list[str],
Expand Down Expand Up @@ -166,20 +175,19 @@ async def _run_command_isolated(
finally:
if on_run_end is not None:
on_run_end()
# Under standalone_mode=False click returns ctx.exit(N)'s code instead of raising,
# and every ConsoleLogger.error path ends in ctx.exit(1). run/debug/eval never
# return an int of their own, so an int here is always an exit code.
if isinstance(result_value, int) and not isinstance(result_value, bool):
return _exit_code_outcome(result_value)
return {
"ExitCode": 0,
"Error": None,
"Result": result_value,
"Unexpected": False,
}
except SystemExit as e:
exit_code = e.code if isinstance(e.code, int) else 1
return {
"ExitCode": exit_code,
"Error": None if exit_code == 0 else f"Exit code: {exit_code}",
"Result": None,
"Unexpected": False,
}
return _exit_code_outcome(e.code if isinstance(e.code, int) else 1)
except Exception as e: # report any job failure as a result, not a fault
return {"ExitCode": 1, "Error": str(e), "Result": None, "Unexpected": True}
finally:
Expand Down
1 change: 1 addition & 0 deletions packages/uipath/src/uipath/_cli/cli_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ async def execute_eval():
"uipath.json spec:",
"https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md",
)
click.get_current_context().exit(1)
except ValueError as e:
console.error(str(e))
except Exception as e:
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath/src/uipath/_cli/cli_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ async def execute() -> None:
"uipath.json spec:",
"https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md",
)
return
click.get_current_context().exit(1)
except UiPathRuntimeError as e:
console.error(f"{e.error_info.title} - {e.error_info.detail}")
except Exception as e:
Expand Down
32 changes: 27 additions & 5 deletions packages/uipath/src/uipath/_cli/cli_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,23 +188,45 @@ async def handle_start(request: web.Request) -> web.Response:

result = await _run_command_isolated(cmd, args, env_vars, working_dir)

# The .NET peer decides success from ``exitCode`` alone and defaults a missing one to 0.
exit_code = result["ExitCode"]
if result["Unexpected"]:
return web.json_response(
{"success": False, "job_key": job_key, "error": result["Error"]},
{
"success": False,
"job_key": job_key,
"exitCode": exit_code,
"error": result["Error"],
},
status=500,
)
if result.get("ClientError"):
# Request-shaped failure (e.g. bad working directory) — 4xx, not 200.
return web.json_response(
{"success": False, "job_key": job_key, "error": result["Error"]},
{
"success": False,
"job_key": job_key,
"exitCode": exit_code,
"error": result["Error"],
},
status=400,
)
if result["ExitCode"] == 0:
if exit_code == 0:
return web.json_response(
{"success": True, "job_key": job_key, "result": result["Result"]}
{
"success": True,
"job_key": job_key,
"exitCode": exit_code,
"result": result["Result"],
}
)
return web.json_response(
{"success": False, "job_key": job_key, "error": result["Error"]}
{
"success": False,
"job_key": job_key,
"exitCode": exit_code,
"error": result["Error"],
}
)


Expand Down
6 changes: 3 additions & 3 deletions packages/uipath/src/uipath/_cli/cli_server_ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,11 @@ def _install() -> None:
)

async def StopJob(self, request: PythonServerStopJobRequest) -> bool:
console.info(
console.warning(
f"StopJob requested for {_run_id(request.jobKey, request.resumeVersion)} "
f"(force={request.forceStop}) (no-op)"
f"(force={request.forceStop}), but this server cannot stop a running job"
)
return True
return False


async def start_ipc_server(pipe_name: str) -> None:
Expand Down
10 changes: 5 additions & 5 deletions packages/uipath/tests/cli/eval/test_eval_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def test_multiple_entrypoints_shows_usage_help(
):
result = runner.invoke(cli, ["eval"])

assert result.exit_code == 0
assert result.exit_code == 1
assert "Available entrypoints:" in result.output
assert "agent_a" in result.output
assert "agent_b" in result.output
Expand Down Expand Up @@ -116,7 +116,7 @@ def test_multiple_entrypoints_no_eval_sets(self, runner: CliRunner, temp_dir: st
):
result = runner.invoke(cli, ["eval"])

assert result.exit_code == 0
assert result.exit_code == 1
assert "Available entrypoints:" in result.output
assert "a" in result.output
assert "b" in result.output
Expand Down Expand Up @@ -165,7 +165,7 @@ def test_multiple_eval_sets_shows_usage_help(
):
result = runner.invoke(cli, ["eval"])

assert result.exit_code == 0
assert result.exit_code == 1
assert "Available entrypoints:" in result.output
assert "my_agent" in result.output
assert "Available eval sets:" in result.output
Expand Down Expand Up @@ -302,7 +302,7 @@ def test_no_entrypoints_shows_helpful_message(
):
result = runner.invoke(cli, ["eval"])

assert result.exit_code == 0
assert result.exit_code == 1
assert "No entrypoints found" in result.output
assert "Usage: uipath eval <entrypoint> <eval_set>" in result.output

Expand Down Expand Up @@ -349,7 +349,7 @@ def test_explicit_entrypoint_skips_entrypoint_discovery(
result = runner.invoke(cli, ["eval", "agent_a"])

# Should still show usage help because multiple eval sets
assert result.exit_code == 0
assert result.exit_code == 1
assert "Available eval sets:" in result.output
assert "set-a.json" in result.output
assert "set-b.json" in result.output
18 changes: 18 additions & 0 deletions packages/uipath/tests/cli/test_job_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ class _Result:
assert dto.status == _job_api.ExecutorJobStatus.SUSPENDED.value


def test_to_result_dto_maps_stopped():
class _Result:
status = "stopped"
error = None

dto = _job_api._to_result_dto("j", None, _Result(), "p.args")
assert dto.status == _job_api.ExecutorJobStatus.STOPPED.value


def test_to_result_dto_reports_an_unknown_status_as_faulted():
class _Result:
status = "something-new"
error = None

dto = _job_api._to_result_dto("j", None, _Result(), "p.args")
assert dto.status == _job_api.ExecutorJobStatus.FAULTED.value


def test_to_log_level_maps_python_levels_to_wire_values():
assert _job_api._to_log_level(logging.CRITICAL) == _job_api.LogLevel.CRITICAL
assert _job_api._to_log_level(logging.ERROR) == _job_api.LogLevel.ERROR
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath/tests/cli/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ def test_no_entrypoint_multiple_available(
):
result = runner.invoke(cli, ["run"])

assert result.exit_code == 0
assert result.exit_code == 1
assert "Available entrypoints:" in result.output
assert "agent_a" in result.output
assert "agent_b" in result.output
Expand All @@ -383,7 +383,7 @@ def test_no_entrypoint_none_available(self, runner: CliRunner, temp_dir: str):
):
result = runner.invoke(cli, ["run"])

assert result.exit_code == 0
assert result.exit_code == 1
assert "No entrypoints found" in result.output
assert "Usage: uipath run" in result.output
mock_factory.new_runtime.assert_not_awaited()
Expand Down
22 changes: 22 additions & 0 deletions packages/uipath/tests/cli/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,34 @@ def test_start_job_success(self, server, temp_dir, simple_script):

assert response["success"] is True
assert response["job_key"] == job_key
assert response["exitCode"] == 0
assert os.path.exists(output_file)

with open(output_file, "r") as f:
output = f.read()
assert "Hello" in output

def test_failing_job_reports_its_exit_code(self, server, temp_dir):
"""A job that fails through ConsoleLogger.error must not be reported as a success."""
port = server

with pytest.MonkeyPatch().context() as mp:
mp.chdir(temp_dir)

script_file = "entrypoint.py"
with open(os.path.join(temp_dir, script_file), "w") as f:
f.write("def main(input: dict) -> str:\n raise ValueError('boom')\n")

with open(os.path.join(temp_dir, "uipath.json"), "w") as f:
json.dump(create_uipath_json(script_file), f)

response = asyncio.run(
start_job(port, "failing-job", "run", ["main", "{}"])
)

assert response["success"] is False
assert response["exitCode"] == 1

def test_start_job_unknown_command(self, server):
"""Test starting a job with unknown command."""
port = server
Expand Down
8 changes: 4 additions & 4 deletions packages/uipath/tests/cli/test_server_ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,16 +189,16 @@ def test_stop_job_accepts_resume_version_and_force_stop(self, pipe):
}
result = asyncio.run(_with_proxy(pipe, lambda p: p.StopJob(request)))

assert result is True
assert result is False

def test_stop_job_returns_true(self, pipe):
"""StopJob is a no-op stub today, but must ack (bool) so the call is awaitable."""
def test_stop_job_reports_that_nothing_was_stopped(self, pipe):
"""This server cannot stop a running job, so it must not claim it did."""
result = asyncio.run(
_with_proxy(
pipe, lambda p: p.StopJob({"jobKey": "job-1", "forceStop": True})
)
)
assert result is True
assert result is False


class TestIpcServerEnvIsolation:
Expand Down
33 changes: 33 additions & 0 deletions packages/uipath/tests/cli/test_server_job_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
from typing import Any
from unittest.mock import Mock

import click
import pytest

from uipath._cli import _server_core
from uipath._cli._utils._console import ConsoleLogger


@pytest.fixture
Expand Down Expand Up @@ -61,6 +63,37 @@ async def test_maps_system_exit_code(restore_state: Any) -> None:
assert result["Unexpected"] is False


async def test_maps_a_returned_click_exit_code(restore_state: Any) -> None:
_init(restore_state)

@click.command()
def failing() -> None:
ConsoleLogger().error("boom")

result = await _server_core._run_command_isolated(failing, [], {}, None)
assert result["ExitCode"] == 1
assert result["Error"] == "Exit code: 1"
assert result["Unexpected"] is False


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


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


async def test_reports_unexpected_exception(restore_state: Any) -> None:
_init(restore_state)
cmd = Mock()
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