Skip to content
Merged
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.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"
Expand Down
10 changes: 6 additions & 4 deletions packages/uipath/src/uipath/_cli/cli_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
63 changes: 32 additions & 31 deletions packages/uipath/src/uipath/_cli/cli_server_ipc.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -19,65 +7,78 @@
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
EnvironmentVariables: dict[str, str] = field(default_factory=dict)


@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


Expand Down
139 changes: 124 additions & 15 deletions packages/uipath/tests/cli/test_server_ipc.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand All @@ -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,
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -147,31 +160,53 @@ 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
assert os.path.exists(output_file)
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


Expand Down Expand Up @@ -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",
Expand All @@ -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
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