diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index bb12bfa6f..1ce573930 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -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" diff --git a/packages/uipath/src/uipath/_cli/_job_api.py b/packages/uipath/src/uipath/_cli/_job_api.py index 8777bc3a8..223b41894 100644 --- a/packages/uipath/src/uipath/_cli/_job_api.py +++ b/packages/uipath/src/uipath/_cli/_job_api.py @@ -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, } @@ -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, ) diff --git a/packages/uipath/src/uipath/_cli/_server_core.py b/packages/uipath/src/uipath/_cli/_server_core.py index 2ff91b6f6..f04ae1b5f 100644 --- a/packages/uipath/src/uipath/_cli/_server_core.py +++ b/packages/uipath/src/uipath/_cli/_server_core.py @@ -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], @@ -166,6 +175,11 @@ 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, @@ -173,13 +187,7 @@ async def _run_command_isolated( "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: diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 66bdfad10..2a446c95a 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -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: diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index d98b92653..774b3dba6 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -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: diff --git a/packages/uipath/src/uipath/_cli/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py index dc5e31bae..c6d6bf71a 100644 --- a/packages/uipath/src/uipath/_cli/cli_server.py +++ b/packages/uipath/src/uipath/_cli/cli_server.py @@ -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"], + } ) diff --git a/packages/uipath/src/uipath/_cli/cli_server_ipc.py b/packages/uipath/src/uipath/_cli/cli_server_ipc.py index 586680274..b329159e9 100644 --- a/packages/uipath/src/uipath/_cli/cli_server_ipc.py +++ b/packages/uipath/src/uipath/_cli/cli_server_ipc.py @@ -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: diff --git a/packages/uipath/tests/cli/eval/test_eval_discovery.py b/packages/uipath/tests/cli/eval/test_eval_discovery.py index bce44f86c..743fc848e 100644 --- a/packages/uipath/tests/cli/eval/test_eval_discovery.py +++ b/packages/uipath/tests/cli/eval/test_eval_discovery.py @@ -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 @@ -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 @@ -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 @@ -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 " in result.output @@ -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 diff --git a/packages/uipath/tests/cli/test_job_api.py b/packages/uipath/tests/cli/test_job_api.py index df2608890..913ae046e 100644 --- a/packages/uipath/tests/cli/test_job_api.py +++ b/packages/uipath/tests/cli/test_job_api.py @@ -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 diff --git a/packages/uipath/tests/cli/test_run.py b/packages/uipath/tests/cli/test_run.py index a47e5a4d7..4370cd42f 100644 --- a/packages/uipath/tests/cli/test_run.py +++ b/packages/uipath/tests/cli/test_run.py @@ -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 @@ -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() diff --git a/packages/uipath/tests/cli/test_server.py b/packages/uipath/tests/cli/test_server.py index 70d29cb38..0de506e2f 100644 --- a/packages/uipath/tests/cli/test_server.py +++ b/packages/uipath/tests/cli/test_server.py @@ -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 diff --git a/packages/uipath/tests/cli/test_server_ipc.py b/packages/uipath/tests/cli/test_server_ipc.py index 2d7d1b8ae..b3b4a9c69 100644 --- a/packages/uipath/tests/cli/test_server_ipc.py +++ b/packages/uipath/tests/cli/test_server_ipc.py @@ -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: diff --git a/packages/uipath/tests/cli/test_server_job_core.py b/packages/uipath/tests/cli/test_server_job_core.py index 7f918fe1d..652775455 100644 --- a/packages/uipath/tests/cli/test_server_job_core.py +++ b/packages/uipath/tests/cli/test_server_job_core.py @@ -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 @@ -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() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bc92233bb..ebf95dbc7 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.25" +version = "2.14.26" source = { editable = "." } dependencies = [ { name = "applicationinsights" },