Skip to content

Commit 6de5bed

Browse files
feat(cli): serve the pre-warmed runtime over uipath-ipc alongside HTTP [ROBO-5779] (#1809)
1 parent 49b881d commit 6de5bed

10 files changed

Lines changed: 939 additions & 143 deletions

File tree

packages/uipath/pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.13.17"
3+
version = "2.13.18"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
@@ -46,6 +46,9 @@ Documentation = "https://uipath.github.io/uipath-python/"
4646
[project.scripts]
4747
uipath = "uipath._cli:cli"
4848

49+
[project.optional-dependencies]
50+
ipc = ["uipath-ipc>=2.5.1,<2.6.0"]
51+
4952
[build-system]
5053
requires = ["hatchling"]
5154
build-backend = "hatchling.build"
@@ -78,6 +81,7 @@ dev = [
7881
"types-toml>=0.10.8",
7982
"types-PyYAML>=6.0",
8083
"pytest-timeout>=2.4.0",
84+
"uipath-ipc>=2.5.1,<2.6.0",
8185
]
8286

8387
[tool.hatch.build.targets.wheel]
@@ -171,6 +175,7 @@ exclude-newer = "2 days"
171175
uipath-core = false
172176
uipath-runtime = false
173177
uipath-platform = false
178+
uipath-ipc = false
174179

175180
[tool.uv.sources]
176181
uipath-core = { path = "../uipath-core", editable = true }
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Transport-agnostic job core shared by the HTTP and uipath-ipc channels."""
2+
3+
import asyncio
4+
import os
5+
import shlex
6+
from typing import Any
7+
8+
from .cli_debug import debug
9+
from .cli_eval import eval
10+
from .cli_run import run
11+
12+
COMMANDS = {
13+
"run": run,
14+
"debug": debug,
15+
"eval": eval,
16+
}
17+
18+
19+
class _ServerState:
20+
"""Mutable server state, initialized lazily at server startup."""
21+
22+
def __init__(self) -> None:
23+
self.lock: asyncio.Lock | None = None
24+
self.baseline_env: dict[str, str] | None = None
25+
26+
def init(self) -> None:
27+
"""Must be called inside a running event loop at server startup."""
28+
if self.lock is not None:
29+
return
30+
self.lock = asyncio.Lock()
31+
self.baseline_env = os.environ.copy()
32+
33+
34+
_state = _ServerState()
35+
36+
37+
def parse_args(args: str | list[str] | None) -> list[str]:
38+
"""Parse args into a list of strings."""
39+
if args is None:
40+
return []
41+
if isinstance(args, list):
42+
return args
43+
if isinstance(args, str):
44+
return shlex.split(args)
45+
return []
46+
47+
48+
async def _run_command_isolated(
49+
cmd: Any,
50+
args: list[str],
51+
env_vars: dict[str, str],
52+
working_dir: str | None,
53+
) -> dict[str, Any]:
54+
"""Run one command with per-job env/cwd isolation (the shared job core)."""
55+
if _state.lock is None or _state.baseline_env is None:
56+
raise RuntimeError("Server state not initialized")
57+
58+
async with _state.lock:
59+
original_cwd = os.getcwd()
60+
try:
61+
# Start from server baseline + request env vars only, so nothing from
62+
# a previous job leaks through.
63+
os.environ.clear()
64+
os.environ.update(_state.baseline_env)
65+
if isinstance(env_vars, dict):
66+
os.environ.update(env_vars)
67+
68+
if working_dir and isinstance(working_dir, str):
69+
try:
70+
os.chdir(working_dir)
71+
except (FileNotFoundError, NotADirectoryError, PermissionError) as e:
72+
# Request-shaped error: the caller gave a bad working dir.
73+
# HTTP surfaces this as 400; IPC just returns ExitCode/Error.
74+
return {
75+
"ExitCode": 1,
76+
"Error": f"Cannot change to working directory: {e}",
77+
"Result": None,
78+
"Unexpected": False,
79+
"ClientError": True,
80+
}
81+
82+
result_value = await asyncio.to_thread(
83+
cmd.main, args, standalone_mode=False
84+
)
85+
return {
86+
"ExitCode": 0,
87+
"Error": None,
88+
"Result": result_value,
89+
"Unexpected": False,
90+
}
91+
except SystemExit as e:
92+
exit_code = e.code if isinstance(e.code, int) else 1
93+
return {
94+
"ExitCode": exit_code,
95+
"Error": None if exit_code == 0 else f"Exit code: {exit_code}",
96+
"Result": None,
97+
"Unexpected": False,
98+
}
99+
except Exception as e: # report any job failure as a result, not a fault
100+
return {"ExitCode": 1, "Error": str(e), "Result": None, "Unexpected": True}
101+
finally:
102+
# Restore to server baseline.
103+
try:
104+
os.chdir(original_cwd)
105+
except OSError:
106+
pass
107+
os.environ.clear()
108+
os.environ.update(_state.baseline_env)

0 commit comments

Comments
 (0)