diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index e700e252a..b59c5e95b 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.13.20" +version = "2.13.21" 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/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py index 6c020dca7..d822bdd8c 100644 --- a/packages/uipath/src/uipath/_cli/cli_server.py +++ b/packages/uipath/src/uipath/_cli/cli_server.py @@ -22,18 +22,20 @@ from ._utils._console import ConsoleLogger from .cli_server_ipc import ( IPythonRuntimeServer, - PythonRunRequest, - PythonRunResult, PythonRuntimeService, + RunJobRequest, + RunJobResult, + StopJobRequest, start_ipc_server, ) __all__ = [ "server", "IPythonRuntimeServer", - "PythonRunRequest", - "PythonRunResult", "PythonRuntimeService", + "RunJobRequest", + "RunJobResult", + "StopJobRequest", "start_ipc_server", ] diff --git a/packages/uipath/src/uipath/_cli/cli_server_ipc.py b/packages/uipath/src/uipath/_cli/cli_server_ipc.py index 24ebd100c..11c461ae6 100644 --- a/packages/uipath/src/uipath/_cli/cli_server_ipc.py +++ b/packages/uipath/src/uipath/_cli/cli_server_ipc.py @@ -1,15 +1,3 @@ -"""uipath-ipc runtime transport — the IPC contract, DTOs, and service. - -The PascalCase method and field names are dictated by the .NET/CoreIpc peer -(the serializer maps them verbatim), so Sonar's S100/S116 naming rules are -suppressed for this file only (see ``sonar-project.properties``). - -``uipath-ipc`` is an optional dependency (the ``ipc`` extra): it is imported -lazily inside ``start_ipc_server`` so this module — and HTTP-only serving — -works without it. The DTOs and the contract below are pure stdlib and never -reference it. -""" - from abc import ABC, abstractmethod from dataclasses import dataclass, field @@ -19,13 +7,18 @@ console = ConsoleLogger() +def _run_id(job_key: str, resume_version: int | None) -> str: + return job_key if resume_version is None else f"{job_key}-{resume_version}" + + @dataclass -class PythonRunRequest: - """Mirrors the .NET PythonRunRequest DTO. PascalCase fields match the wire keys.""" +class RunJobRequest: + """PascalCase fields match the wire keys.""" JobKey: str = "" + ResumeVersion: int | None = None Command: str = "" - # The .NET peer sends a single string; HTTP callers and tests may pass a + # The peer sends a single string; HTTP callers and tests may pass a # pre-split list. parse_args accepts both. Args: str | list[str] | None = None WorkingDirectory: str | None = None @@ -33,51 +26,59 @@ class PythonRunRequest: @dataclass -class PythonRunResult: - """Mirrors the .NET PythonRunResult DTO.""" +class StopJobRequest: + JobKey: str = "" + ResumeVersion: int | None = None + ForceStop: bool = False + +@dataclass +class RunJobResult: ExitCode: int = 0 Error: str | None = None class IPythonRuntimeServer(ABC): - """Contract the .NET job executor calls over uipath-ipc.""" + """Contract the job executor calls over uipath-ipc.""" @abstractmethod - async def StartJob(self, request: PythonRunRequest) -> PythonRunResult: - """Run a job → PythonRunResult(ExitCode, Error).""" + async def RunJob(self, request: RunJobRequest) -> RunJobResult: + """Run a job → RunJobResult(ExitCode, Error).""" @abstractmethod - async def StopJob(self, job_key: str) -> bool: + async def StopJob(self, request: StopJobRequest) -> bool: """Cancel a running job by key (bool return avoids fire-and-forget).""" class PythonRuntimeService(IPythonRuntimeServer): """``IPythonRuntimeServer`` implementation backed by run/debug/eval.""" - async def StartJob(self, request: PythonRunRequest) -> PythonRunResult: + async def RunJob(self, request: RunJobRequest) -> RunJobResult: command_name = request.Command if not isinstance(command_name, str) or not command_name: - return PythonRunResult( - ExitCode=1, Error="Missing or invalid field: 'Command'" - ) + return RunJobResult(ExitCode=1, Error="Missing or invalid field: 'Command'") cmd = COMMANDS.get(command_name) if cmd is None: - return PythonRunResult(ExitCode=1, Error=f"Unknown command: {command_name}") + return RunJobResult(ExitCode=1, Error=f"Unknown command: {command_name}") args = parse_args(request.Args) - console.info(f"Starting job {request.JobKey}: {command_name} {args}") + console.info( + f"Running job {_run_id(request.JobKey, request.ResumeVersion)}: {command_name} {args}" + ) result = await _run_command_isolated( cmd, args, request.EnvironmentVariables, request.WorkingDirectory ) - # IPC contract (PythonRunResult) carries only ExitCode + Error. - return PythonRunResult(ExitCode=result["ExitCode"], Error=result["Error"]) + # IPC contract (RunJobResult) carries only ExitCode + Error. + return RunJobResult(ExitCode=result["ExitCode"], Error=result["Error"]) - async def StopJob(self, job_key: str) -> bool: - console.info(f"StopJob requested for {job_key} (no-op)") + async def StopJob(self, request: StopJobRequest) -> bool: + console.info( + f"StopJob requested for {_run_id(request.JobKey, request.ResumeVersion)} " + f"(force={request.ForceStop}) (no-op)" + ) return True diff --git a/packages/uipath/tests/cli/test_server_ipc.py b/packages/uipath/tests/cli/test_server_ipc.py index efcd76a19..43fe313c9 100644 --- a/packages/uipath/tests/cli/test_server_ipc.py +++ b/packages/uipath/tests/cli/test_server_ipc.py @@ -1,12 +1,12 @@ """Tests for the uipath-ipc runtime server channel. -The server hosts ``IPythonRuntimeServer`` (StartJob / StopJob) on a named pipe +The server hosts ``IPythonRuntimeServer`` (RunJob / StopJob) on a named pipe alongside the HTTP channel when ``--ipc-pipe`` names one (see ``test_server_transport.py`` for the channel composition). Mirrors ``test_server.py`` (the HTTP path) but drives the pipe with a Python ``uipath-ipc`` client. -Requires ``uipath-ipc`` to be installed. ``StartJob`` success runs the real +Requires ``uipath-ipc`` to be installed. ``RunJob`` success runs the real runtime (like ``test_server.test_start_job_success``); the rest exercise the IPC wiring and env isolation without it. """ @@ -21,11 +21,17 @@ import click import pytest -from uipath_ipc import IpcClient, NamedPipeClientTransport +from uipath_ipc import ( + IpcClient, + IpcServer, + NamedPipeClientTransport, + NamedPipeServerTransport, +) from uipath._cli import _server_core from uipath._cli.cli_server import ( IPythonRuntimeServer, + RunJobResult, start_ipc_server, ) @@ -81,7 +87,14 @@ def _wait_until_ready(pipe_name: str, timeout: float = 10.0) -> None: last_err: Exception | None = None while time.monotonic() < deadline: try: - asyncio.run(_with_proxy(pipe_name, lambda p: p.StopJob("readiness-probe"))) + asyncio.run( + _with_proxy( + pipe_name, + lambda p: p.StopJob( + {"JobKey": "00000000-0000-0000-0000-000000000000"} + ), + ) + ) return except Exception as e: # server not accepting connections yet last_err = e @@ -127,7 +140,7 @@ def pipe(self): # the process exits (mirrors test_server.py's background HTTP server). yield pipe_name - def test_start_job_success(self, pipe, temp_dir): + def test_run_job_success(self, pipe, temp_dir): """A real 'run' job executes and writes output.json (needs the runtime).""" script_file = "entrypoint.py" with open(os.path.join(temp_dir, script_file), "w") as f: @@ -147,7 +160,7 @@ def test_start_job_success(self, pipe, temp_dir): "WorkingDirectory": temp_dir, "EnvironmentVariables": {}, } - result = asyncio.run(_with_proxy(pipe, lambda p: p.StartJob(request))) + result = asyncio.run(_with_proxy(pipe, lambda p: p.RunJob(request))) assert result.ExitCode == 0 assert result.Error is None @@ -155,23 +168,45 @@ def test_start_job_success(self, pipe, temp_dir): with open(output_file, "r") as f: assert "Hello" in f.read() - def test_start_job_unknown_command(self, pipe): + def test_run_job_unknown_command(self, pipe): request = {"JobKey": "job-1", "Command": "does_not_exist"} - result = asyncio.run(_with_proxy(pipe, lambda p: p.StartJob(request))) + result = asyncio.run(_with_proxy(pipe, lambda p: p.RunJob(request))) assert result.ExitCode != 0 assert "Unknown command" in (result.Error or "") - def test_start_job_missing_command(self, pipe): + def test_run_job_missing_command(self, pipe): """Absent/empty Command is rejected before the job core is touched.""" - result = asyncio.run( - _with_proxy(pipe, lambda p: p.StartJob({"JobKey": "job-1"})) - ) + result = asyncio.run(_with_proxy(pipe, lambda p: p.RunJob({"JobKey": "job-1"}))) assert result.ExitCode != 0 assert "Command" in (result.Error or "") + def test_run_job_accepts_resume_version(self, pipe): + request = { + "JobKey": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "ResumeVersion": 4, + "Command": "does_not_exist", + } + result = asyncio.run(_with_proxy(pipe, lambda p: p.RunJob(request))) + + assert "Unknown command" in (result.Error or "") + + def test_stop_job_accepts_resume_version_and_force_stop(self, pipe): + request = { + "JobKey": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "ResumeVersion": 2, + "ForceStop": True, + } + result = asyncio.run(_with_proxy(pipe, lambda p: p.StopJob(request))) + + assert result is True + def test_stop_job_returns_true(self, pipe): """StopJob is a no-op stub today, but must ack (bool) so the call is awaitable.""" - result = asyncio.run(_with_proxy(pipe, lambda p: p.StopJob("job-1"))) + result = asyncio.run( + _with_proxy( + pipe, lambda p: p.StopJob({"JobKey": "job-1", "ForceStop": True}) + ) + ) assert result is True @@ -201,14 +236,14 @@ def test_env_vars_do_not_leak_between_jobs(self, pipe_with_spy): pipe_name, env_snapshots = pipe_with_spy async def run_two(proxy: Any) -> None: - await proxy.StartJob( + await proxy.RunJob( { "JobKey": "job-1", "Command": "spy", "EnvironmentVariables": {"TEST_VAR_A": "a"}, } ) - await proxy.StartJob( + await proxy.RunJob( { "JobKey": "job-2", "Command": "spy", @@ -224,3 +259,77 @@ async def run_two(proxy: Any) -> None: assert "TEST_VAR_B" not in run1 assert run2["TEST_VAR_B"] == "b" assert "TEST_VAR_A" not in run2 + + +class TestIpcContractFieldTransit: + @staticmethod + def _serve_spy(pipe_name: str, service: IPythonRuntimeServer) -> None: + def run_server() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def main() -> None: + server = IpcServer( + transport=NamedPipeServerTransport(pipe_name), + services={IPythonRuntimeServer: service}, + request_timeout=None, + ) + async with server: + await server.serve_forever() + + try: + loop.run_until_complete(main()) + except asyncio.CancelledError: + pass + finally: + loop.close() + + threading.Thread(target=run_server, daemon=True).start() + _wait_until_ready(pipe_name) + + def test_all_wire_fields_arrive_intact(self): + received: list[Any] = [] + + class SpyService(IPythonRuntimeServer): + async def RunJob(self, request: Any) -> RunJobResult: + received.append(request) + return RunJobResult(ExitCode=0) + + async def StopJob(self, request: Any) -> bool: + received.append(request) + return True + + pipe = _unique_pipe() + self._serve_spy(pipe, SpyService()) + received.clear() # drop the readiness probe's StopJob + + job_key = "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + + async def drive(proxy: Any) -> None: + await proxy.RunJob( + { + "JobKey": job_key, + "ResumeVersion": 5, + "Command": "run", + "Args": "main --input-file in.json", + "WorkingDirectory": "/tmp/wd", + "EnvironmentVariables": {"A": "1"}, + } + ) + await proxy.StopJob( + {"JobKey": job_key, "ResumeVersion": 5, "ForceStop": True} + ) + + asyncio.run(_with_proxy(pipe, drive)) + + run_request, stop_request = received + assert run_request.JobKey == job_key + assert run_request.ResumeVersion == 5 + assert run_request.Command == "run" + assert run_request.Args == "main --input-file in.json" + assert run_request.WorkingDirectory == "/tmp/wd" + assert run_request.EnvironmentVariables == {"A": "1"} + + assert stop_request.JobKey == job_key + assert stop_request.ResumeVersion == 5 + assert stop_request.ForceStop is True diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 55a1152c2..0356dc28d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.20" +version = "2.13.21" source = { editable = "." } dependencies = [ { name = "applicationinsights" },