diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 2e5404478..a4847ee23 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "uipath-core>=0.5.30, <0.6.0", "uipath-runtime>=0.12.2, <0.13.0", "uipath-platform>=0.2.4, <0.3.0", + "uipath-ipc>=2.5.1", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", @@ -171,10 +172,23 @@ exclude-newer = "2 days" uipath-core = false uipath-runtime = false uipath-platform = false +uipath-ipc = false [tool.uv.sources] uipath-core = { path = "../uipath-core", editable = true } uipath-platform = { path = "../uipath-platform", editable = true } +# uipath-ipc is published to the (anonymously accessible) UiPath-Internal Azure +# Artifacts feed, not PyPI. Pin it to that index so uv fetches ONLY uipath-ipc +# there — this avoids a dependency-confusion exposure from a plain extra-index. +# NOTE: this is uv/dev-and-CI resolution config only (not baked into the wheel); +# `pip install uipath` from PyPI still needs uipath-ipc to be resolvable +# (public-PyPI release or an optional extra) — a separate decision. +uipath-ipc = { index = "uipath-internal" } + +[[tool.uv.index]] +name = "uipath-internal" +url = "https://pkgs.dev.azure.com/uipath/Public.Feeds/_packaging/UiPath-Internal/pypi/simple/" +explicit = true [[tool.uv.index]] name = "testpypi" diff --git a/packages/uipath/src/uipath/_cli/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py index 06450c401..b513c6b36 100644 --- a/packages/uipath/src/uipath/_cli/cli_server.py +++ b/packages/uipath/src/uipath/_cli/cli_server.py @@ -6,12 +6,15 @@ import sys import tempfile import time +from abc import ABC, abstractmethod +from dataclasses import dataclass, field from importlib.metadata import entry_points from importlib.util import find_spec from typing import Any import click from aiohttp import ClientSession, UnixConnector, web +from uipath_ipc import IpcServer, NamedPipeServerTransport from ._telemetry import track_command from ._utils._console import ConsoleLogger @@ -21,12 +24,12 @@ console = ConsoleLogger() +IS_WINDOWS = sys.platform == "win32" + SOCKET_ENV_VAR = "UIPATH_SERVER_SOCKET" DEFAULT_SOCKET_PATH = "/tmp/uipath-server.sock" DEFAULT_PORT = 8765 -IS_WINDOWS = sys.platform == "win32" - COMMANDS = { "run": run, "debug": debug, @@ -97,7 +100,7 @@ def preload_modules() -> None: def generate_socket_path() -> str: - """Generate a unique socket path for the server to listen on.""" + """Generate a unique socket path for the HTTP server to listen on.""" return os.path.join(tempfile.gettempdir(), f"uipath-server-{os.getpid()}.sock") @@ -120,6 +123,71 @@ def parse_args(args: str | list[str] | None) -> list[str]: return [] +async def _run_command_isolated( + cmd: Any, + args: list[str], + env_vars: dict[str, str], + working_dir: str | None, +) -> dict[str, Any]: + """Run one command with per-job env/cwd isolation (the shared job core).""" + if _state.lock is None or _state.baseline_env is None: + raise RuntimeError("Server state not initialized") + + async with _state.lock: + original_cwd = os.getcwd() + try: + # Start from server baseline + request env vars only, so nothing from + # a previous job leaks through. + os.environ.clear() + os.environ.update(_state.baseline_env) + if isinstance(env_vars, dict): + os.environ.update(env_vars) + + if working_dir and isinstance(working_dir, str): + try: + os.chdir(working_dir) + except (FileNotFoundError, NotADirectoryError, PermissionError) as e: + return { + "ExitCode": 1, + "Error": f"Cannot change to working directory: {e}", + "Result": None, + "Unexpected": False, + } + + result_value = await asyncio.to_thread( + cmd.main, args, standalone_mode=False + ) + 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, + } + 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: + # Restore to server baseline. + try: + os.chdir(original_cwd) + except OSError: + pass + os.environ.clear() + os.environ.update(_state.baseline_env) + + +# --------------------------------------------------------------------------- # +# HTTP transport (default) — aiohttp over a Unix socket / TCP, with ready-ACK # +# --------------------------------------------------------------------------- # + + async def send_ack(ack_socket_path: str, server_socket_path: str) -> None: """Send acknowledgment via HTTP POST to the ack socket.""" ack_message: dict[str, str] = { @@ -150,7 +218,7 @@ async def handle_health(request: web.Request) -> web.Response: async def handle_start(request: web.Request) -> web.Response: - """Handle POST /jobs/{job_key}/start endpoint.""" + """Handle POST /jobs/{job_key}/start — runs a job via the shared core.""" job_key = request.match_info.get("job_key") if not job_key: return web.json_response( @@ -173,13 +241,18 @@ async def handle_start(request: web.Request) -> web.Response: status=400, ) - args_raw = get_field(message, "args", "Args") - args = parse_args(args_raw) - + args = parse_args(get_field(message, "args", "Args")) env_vars = get_field(message, "environmentVariables", "EnvironmentVariables") or {} working_dir = get_field(message, "workingDirectory", "WorkingDirectory") - console.info(f"Starting job {job_key}: {command_name} {args}") + if env_vars and not isinstance(env_vars, dict): + return web.json_response( + { + "success": False, + "error": "Invalid field: 'environmentVariables' must be a dict", + }, + status=400, + ) cmd = COMMANDS.get(command_name) if cmd is None: @@ -188,78 +261,22 @@ async def handle_start(request: web.Request) -> web.Response: status=400, ) - console.info(f"Original cwd: {os.getcwd()}") - console.info(f"Requested working_dir: {working_dir}") + console.info(f"Starting job {job_key}: {command_name} {args}") - if _state.lock is None or _state.baseline_env is None: - raise RuntimeError("Server state not initialized") + result = await _run_command_isolated(cmd, args, env_vars, working_dir) - # Validate environmentVariables type early - if env_vars and not isinstance(env_vars, dict): + if result["Unexpected"]: return web.json_response( - { - "success": False, - "error": "Invalid field: 'environmentVariables' must be a dict", - }, - status=400, + {"success": False, "job_key": job_key, "error": result["Error"]}, + status=500, ) - - # Serialize command execution to prevent concurrent os.environ mutation - async with _state.lock: - original_cwd = os.getcwd() - - try: - # Start from server baseline + request env vars only. - # This ensures no env vars from previous requests leak through. - os.environ.clear() - os.environ.update(_state.baseline_env) - if isinstance(env_vars, dict): - os.environ.update(env_vars) - - if working_dir and isinstance(working_dir, str): - try: - os.chdir(working_dir) - except (FileNotFoundError, NotADirectoryError, PermissionError) as e: - return web.json_response( - { - "success": False, - "job_key": job_key, - "error": f"Cannot change to working directory: {e}", - }, - status=400, - ) - - result = await asyncio.to_thread(cmd.main, args, standalone_mode=False) - - return web.json_response( - { - "success": True, - "job_key": job_key, - "result": result, - } - ) - except SystemExit as e: - exit_code = e.code if isinstance(e.code, int) else 1 - return web.json_response( - { - "success": exit_code == 0, - "job_key": job_key, - "error": None if exit_code == 0 else f"Exit code: {exit_code}", - } - ) - except Exception as e: - return web.json_response( - {"success": False, "job_key": job_key, "error": str(e)}, - status=500, - ) - finally: - # Restore to server baseline - try: - os.chdir(original_cwd) - except OSError: - pass - os.environ.clear() - os.environ.update(_state.baseline_env) + if result["ExitCode"] == 0: + return web.json_response( + {"success": True, "job_key": job_key, "result": result["Result"]} + ) + return web.json_response( + {"success": False, "job_key": job_key, "error": result["Error"]} + ) ALLOWED_HOSTS = {"127.0.0.1", "localhost", "[::1]"} @@ -350,29 +367,115 @@ async def start_tcp_server(host: str, port: int) -> None: await runner.cleanup() +# --------------------------------------------------------------------------- # +# uipath-ipc transport, served alongside HTTP # +# Older servers didn't; the Handler copes # +# --------------------------------------------------------------------------- # + + +@dataclass +class PythonRunRequest: + """Mirrors the .NET PythonRunRequest DTO. PascalCase fields match the wire keys.""" + + JobKey: str = "" + Command: str = "" + Args: str | None = None + WorkingDirectory: str | None = None + EnvironmentVariables: dict[str, str] = field(default_factory=dict) + + +@dataclass +class PythonRunResult: + """Mirrors the .NET PythonRunResult DTO.""" + + ExitCode: int = 0 + Error: str | None = None + + +class IPythonRuntimeServer(ABC): + """Contract the .NET job executor calls over uipath-ipc.""" + + @abstractmethod + async def StartJob(self, request: PythonRunRequest) -> PythonRunResult: + """Run a job → PythonRunResult(ExitCode, Error).""" + + @abstractmethod + async def StopJob(self, job_key: str) -> 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: + command_name = request.Command + if not isinstance(command_name, str) or not command_name: + return PythonRunResult(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}") + + args = parse_args(request.Args) + + console.info(f"Starting job {request.JobKey}: {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"]) + + async def StopJob(self, job_key: str) -> bool: + # Cancellation is not wired into the job core yet — accept the request and + # no-op so the .NET side gets a clean response. Real cancellation lands here. + console.info(f"StopJob requested for {job_key} (no-op)") + return True + + +async def start_ipc_server(pipe_name: str) -> None: + """Serve the Python runtime over a uipath-ipc named pipe until it is closed.""" + _state.init() + server = IpcServer( + transport=NamedPipeServerTransport(pipe_name), + services={IPythonRuntimeServer: PythonRuntimeService()}, + request_timeout=None, # jobs are long-running; no server-side timeout + ) + console.success(f"IPC server listening on pipe '{pipe_name}'") + async with server: + await server.serve_forever() + + +# --------------------------------------------------------------------------- # +# CLI # +# --------------------------------------------------------------------------- # + + @click.command() @click.option( "--client-socket", type=str, default=None, - help=f"Unix socket path to send ready ack to (default: ${SOCKET_ENV_VAR} or {DEFAULT_SOCKET_PATH})", + help=f"Unix socket to send the ready ACK to (default: ${SOCKET_ENV_VAR} " + f"or {DEFAULT_SOCKET_PATH}).", ) @click.option( "--server-socket", type=str, default=None, - help="Unix socket path the server listens on (default: auto-generated in tmp dir)", + help="Unix socket the HTTP server listens on; its basename is the uipath-ipc " + "pipe name (default: auto-generated in tmp).", ) @click.option( "--port", type=int, default=None, - help=f"TCP port, used on Windows or when --tcp flag is set (default: {DEFAULT_PORT})", + help=f"TCP port, used on Windows or with --tcp (default: {DEFAULT_PORT}).", ) @click.option( "--tcp", is_flag=True, - help="Force TCP mode even on Unix systems", + help="Force TCP mode even on Unix systems.", ) @track_command("server") def server( @@ -381,27 +484,62 @@ def server( port: int | None, tcp: bool, ) -> None: - """Start an HTTP server that forwards commands to run/debug/eval. + """Serve run/debug/eval over HTTP, plus uipath-ipc when --server-socket is given.""" + preload_modules() + _run_server(client_socket, server_socket, port, tcp) + + +async def _serve( + ack_socket_path: str, + server_socket: str | None, + port: int, + use_tcp: bool, +) -> None: + """Run the HTTP channel and, when a server socket is given, the IPC channel too.""" + _state.init() - Creates its own socket to listen on and sends an ack to --client-socket with: - {"status": "ready", "socket": "/path/to/server.sock"} + tasks: list[Any] = [] + if use_tcp: + tasks.append(start_tcp_server("127.0.0.1", port)) + else: + tasks.append(start_unix_server(ack_socket_path, server_socket)) - Endpoint: POST /jobs/{job_key}/start - Body: {"command": "run", "args": "agent.json '{}'", "environmentVariables": {}, "workingDirectory": "/path"} + # The IPC pipe name is the HTTP UDS path's basename (directory stripped), + # identically to the .NET side (Path.GetFileName) — so the named-pipe transport + # resolves it to a socket distinct from the HTTP UDS and the two never collide. + if server_socket: + pipe_name = os.path.basename(server_socket) + tasks.append(start_ipc_server(pipe_name)) + else: + console.warning("--server-socket not provided; serving HTTP only (no IPC channel).") - Endpoint: GET /health - """ - use_tcp = IS_WINDOWS or tcp + await asyncio.gather(*tasks) - preload_modules() +def _run_server( + client_socket: str | None, + server_socket: str | None, + port: int | None, + tcp: bool, +) -> None: + """Drive ``_serve`` on the right event loop for the platform.""" + use_tcp = IS_WINDOWS or tcp + ack_socket_path = ( + client_socket or os.environ.get(SOCKET_ENV_VAR) or DEFAULT_SOCKET_PATH + ) + coro = _serve(ack_socket_path, server_socket, port or DEFAULT_PORT, use_tcp) try: - if use_tcp: - asyncio.run(start_tcp_server("127.0.0.1", port or DEFAULT_PORT)) + # Windows named pipes need the Proactor loop; build it explicitly since another + # lib (e.g. socketio) may have flipped the policy to Selector. Gate on the + # sys.platform literal so mypy narrows ProactorEventLoop (Windows-only) here. + if sys.platform == "win32": + loop = asyncio.ProactorEventLoop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(coro) + finally: + loop.close() else: - ack_socket_path = ( - client_socket or os.environ.get(SOCKET_ENV_VAR) or DEFAULT_SOCKET_PATH - ) - asyncio.run(start_unix_server(ack_socket_path, server_socket)) + asyncio.run(coro) except KeyboardInterrupt: console.info("Shutting down") diff --git a/packages/uipath/tests/cli/test_server_ipc.py b/packages/uipath/tests/cli/test_server_ipc.py new file mode 100644 index 000000000..0cbd89c15 --- /dev/null +++ b/packages/uipath/tests/cli/test_server_ipc.py @@ -0,0 +1,181 @@ +"""Tests for the uipath-ipc runtime server channel. + +The server always hosts ``IPythonRuntimeServer`` (StartJob / StopJob) on a named +pipe alongside the HTTP channel when a ``--server-socket`` is given (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 +runtime (like ``test_server.test_start_job_success``); the rest exercise the IPC +wiring and env isolation without it. +""" + +import asyncio +import json +import os +import threading +import time +from typing import Any, Awaitable, Callable + +import click +import pytest +from uipath_ipc import IpcClient, NamedPipeClientTransport + +from uipath._cli import cli_server +from uipath._cli.cli_server import ( + IPythonRuntimeServer, + start_ipc_server, +) + +_pipe_counter = 0 + + +def _unique_pipe() -> str: + global _pipe_counter + _pipe_counter += 1 + return f"uipath-ipc-test-{os.getpid()}-{_pipe_counter}" + + +def _serve_in_background(pipe_name: str) -> None: + """Run the IPC server on its own event loop in a daemon thread. + + ``asyncio.new_event_loop()`` yields the per-OS default loop — Proactor on + Windows (required for named pipes), Selector on Linux (CoreFxPipe UDS). + """ + + def run_server() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(start_ipc_server(pipe_name)) + except asyncio.CancelledError: + pass + finally: + loop.close() + + thread = threading.Thread(target=run_server, daemon=True) + thread.start() + time.sleep(0.5) + + +async def _with_proxy(pipe_name: str, fn: Callable[[Any], Awaitable[Any]]) -> Any: + """Connect a uipath-ipc client to the pipe, run ``fn(proxy)``, then close.""" + client = IpcClient(transport=NamedPipeClientTransport(pipe_name)) + try: + proxy = client.get_proxy(IPythonRuntimeServer) + return await fn(proxy) + finally: + await client.aclose() + + +def create_uipath_json(script_path: str, entrypoint_name: str = "main") -> dict: + return {"functions": {entrypoint_name: f"{script_path}:main"}} + + +SIMPLE_SCRIPT = """ +from dataclasses import dataclass + +@dataclass +class Input: + message: str + repeat: int = 1 + +def main(input: Input) -> str: + return (input.message + " ") * input.repeat +""" + + +class TestIpcServer: + @pytest.fixture + def pipe(self): + pipe_name = _unique_pipe() + _serve_in_background(pipe_name) + # Daemon thread; the server blocks in serve_forever and is torn down when + # the process exits (mirrors test_server.py's background HTTP server). + yield pipe_name + + def test_start_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: + f.write(SIMPLE_SCRIPT) + with open(os.path.join(temp_dir, "uipath.json"), "w") as f: + json.dump(create_uipath_json(script_file), f) + + input_file = os.path.join(temp_dir, "input.json") + with open(input_file, "w") as f: + json.dump({"message": "Hello", "repeat": 3}, f) + output_file = os.path.join(temp_dir, "output.json") + + request = { + "JobKey": "job-123", + "Command": "run", + "Args": ["main", "--input-file", input_file, "--output-file", output_file], + "WorkingDirectory": temp_dir, + "EnvironmentVariables": {}, + } + result = asyncio.run(_with_proxy(pipe, lambda p: p.StartJob(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): + request = {"JobKey": "job-1", "Command": "does_not_exist"} + result = asyncio.run(_with_proxy(pipe, lambda p: p.StartJob(request))) + assert result.ExitCode != 0 + assert "Unknown command" in (result.Error or "") + + +class TestIpcServerEnvIsolation: + """Env vars must not leak between sequential jobs (as on the HTTP path).""" + + @pytest.fixture + def pipe_with_spy(self): + env_snapshots: list[dict[str, str]] = [] + + @click.command() + def spy_cmd() -> None: + env_snapshots.append(dict(os.environ)) + + original = cli_server.COMMANDS.copy() + cli_server.COMMANDS["spy"] = spy_cmd + + pipe_name = _unique_pipe() + _serve_in_background(pipe_name) + try: + yield pipe_name, env_snapshots + finally: + cli_server.COMMANDS.clear() + cli_server.COMMANDS.update(original) + + 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( + { + "JobKey": "job-1", + "Command": "spy", + "EnvironmentVariables": {"TEST_VAR_A": "a"}, + } + ) + await proxy.StartJob( + { + "JobKey": "job-2", + "Command": "spy", + "EnvironmentVariables": {"TEST_VAR_B": "b"}, + } + ) + + asyncio.run(_with_proxy(pipe_name, run_two)) + + assert len(env_snapshots) == 2 + run1, run2 = env_snapshots + assert run1["TEST_VAR_A"] == "a" + assert "TEST_VAR_B" not in run1 + assert run2["TEST_VAR_B"] == "b" + assert "TEST_VAR_A" not in run2 diff --git a/packages/uipath/tests/cli/test_server_transport.py b/packages/uipath/tests/cli/test_server_transport.py new file mode 100644 index 000000000..1c925b375 --- /dev/null +++ b/packages/uipath/tests/cli/test_server_transport.py @@ -0,0 +1,181 @@ +"""`uipath server` serves BOTH transports concurrently — never either/or. + +The HTTP channel (aiohttp over a Unix socket, or TCP on Windows / ``--tcp``) is +ALWAYS started. The uipath-ipc named-pipe channel is started alongside it when a +``--server-socket`` is given, with the pipe name the socket's basename (directory +stripped, extension kept), matching the .NET side. The HTTP channel is never torn +down. + +These tests stub the three channel runners (so ``_serve``'s ``asyncio.gather`` +returns at once instead of serving forever) and assert which channels ``_serve`` +composes, how ``_run_server`` resolves its arguments, and that the CLI wires them +through. +""" + +import asyncio + +from click.testing import CliRunner + +import uipath._cli._telemetry as _telemetry +from uipath._cli import cli_server + + +def _stub_channels(monkeypatch) -> dict: + """Stub the three channel runners + state init; record what ``_serve`` starts. + + Each runner is replaced with an async recorder that returns immediately, so + ``_serve``'s ``asyncio.gather`` completes instead of blocking in serve-forever. + ``_state.init`` is stubbed too, so no event-loop-bound lock leaks between the + per-test loops ``asyncio.run`` creates. + """ + calls: dict = {} + + async def _rec_unix(ack_socket_path, server_socket_path=None): + calls["unix"] = (ack_socket_path, server_socket_path) + + async def _rec_tcp(host, port): + calls["tcp"] = (host, port) + + async def _rec_ipc(pipe_name): + calls["ipc"] = pipe_name + + monkeypatch.setattr(cli_server._state, "init", lambda: None) + monkeypatch.setattr(cli_server, "start_unix_server", _rec_unix) + monkeypatch.setattr(cli_server, "start_tcp_server", _rec_tcp) + monkeypatch.setattr(cli_server, "start_ipc_server", _rec_ipc) + return calls + + +# --------------------------------------------------------------------------- # +# _serve: channel composition # +# --------------------------------------------------------------------------- # + + +def test_serve_runs_http_and_ipc_together(monkeypatch): + calls = _stub_channels(monkeypatch) + asyncio.run(cli_server._serve("/tmp/ack.sock", "/tmp/run-1.sock", 8765, False)) + assert calls["unix"] == ("/tmp/ack.sock", "/tmp/run-1.sock") + assert calls["ipc"] == "run-1.sock" # pipe = socket basename (directory stripped) + assert "tcp" not in calls + + +def test_serve_derives_pipe_name_from_socket_basename(monkeypatch): + calls = _stub_channels(monkeypatch) + asyncio.run( + cli_server._serve("/tmp/ack.sock", "/var/tmp/uipath-server-42.sock", 8765, False) + ) + assert calls["ipc"] == "uipath-server-42.sock" # basename (directory stripped, extension kept) + + +def test_serve_rides_ipc_alongside_tcp(monkeypatch): + calls = _stub_channels(monkeypatch) + asyncio.run(cli_server._serve("/tmp/ack.sock", "/tmp/run-1.sock", 9000, True)) + assert calls["tcp"] == ("127.0.0.1", 9000) + assert "unix" not in calls + assert calls["ipc"] == "run-1.sock" # IPC is served next to TCP too, not only UDS + + +def test_serve_skips_ipc_without_server_socket(monkeypatch): + """No ``--server-socket`` ⇒ HTTP only (no pipe name to derive).""" + calls = _stub_channels(monkeypatch) + asyncio.run(cli_server._serve("/tmp/ack.sock", None, 8765, False)) + assert calls["unix"] == ("/tmp/ack.sock", None) + assert "ipc" not in calls + + +# --------------------------------------------------------------------------- # +# _run_server: argument resolution + per-OS loop # +# --------------------------------------------------------------------------- # + + +def _capture_serve(monkeypatch) -> dict: + """Replace ``_serve`` with an async recorder so ``_run_server`` runs to + completion on its real per-OS loop (Proactor on Windows, ``asyncio.run`` on + Linux) without actually serving anything.""" + seen: dict = {} + + async def _rec_serve(ack_socket_path, server_socket, port, use_tcp): + seen.update( + ack=ack_socket_path, + server_socket=server_socket, + port=port, + use_tcp=use_tcp, + ) + + monkeypatch.setattr(cli_server, "_serve", _rec_serve) + return seen + + +def test_run_server_defaults_ack_from_env(monkeypatch): + seen = _capture_serve(monkeypatch) + monkeypatch.setenv(cli_server.SOCKET_ENV_VAR, "/tmp/from-env.sock") + cli_server._run_server(None, "/tmp/s.sock", None, False) + assert seen["ack"] == "/tmp/from-env.sock" + assert seen["server_socket"] == "/tmp/s.sock" + assert seen["port"] == cli_server.DEFAULT_PORT + assert seen["use_tcp"] is cli_server.IS_WINDOWS # UDS on Linux, TCP on Windows + + +def test_run_server_prefers_explicit_client_socket(monkeypatch): + seen = _capture_serve(monkeypatch) + monkeypatch.setenv(cli_server.SOCKET_ENV_VAR, "/tmp/from-env.sock") + cli_server._run_server("/tmp/explicit.sock", "/tmp/s.sock", 1234, False) + assert seen["ack"] == "/tmp/explicit.sock" # explicit arg beats the env var + assert seen["port"] == 1234 + + +def test_run_server_falls_back_to_default_ack(monkeypatch): + seen = _capture_serve(monkeypatch) + monkeypatch.delenv(cli_server.SOCKET_ENV_VAR, raising=False) + cli_server._run_server(None, "/tmp/s.sock", None, False) + assert seen["ack"] == cli_server.DEFAULT_SOCKET_PATH + + +def test_run_server_tcp_flag_forces_tcp(monkeypatch): + seen = _capture_serve(monkeypatch) + cli_server._run_server("/tmp/a.sock", "/tmp/s.sock", None, True) + assert seen["use_tcp"] is True + + +# --------------------------------------------------------------------------- # +# CLI wiring (backward-compatible single --server-socket) # +# --------------------------------------------------------------------------- # + + +def _stub_cli(monkeypatch) -> dict: + """Disable telemetry + preload and record the args the CLI hands _run_server.""" + seen: dict = {} + monkeypatch.setattr(_telemetry, "is_telemetry_enabled", lambda: False) + monkeypatch.setattr(cli_server, "preload_modules", lambda: None) + monkeypatch.setattr( + cli_server, + "_run_server", + lambda client_socket, server_socket, port, tcp: seen.update( + client_socket=client_socket, + server_socket=server_socket, + port=port, + tcp=tcp, + ), + ) + return seen + + +def test_cli_passes_socket_args_through(monkeypatch): + seen = _stub_cli(monkeypatch) + result = CliRunner().invoke( + cli_server.server, + ["--client-socket", "/tmp/ack.sock", "--server-socket", "/tmp/run.sock"], + ) + assert result.exit_code == 0, result.output + assert seen["client_socket"] == "/tmp/ack.sock" + assert seen["server_socket"] == "/tmp/run.sock" + assert seen["tcp"] is False + + +def test_cli_server_socket_is_optional(monkeypatch): + """No ``--server-socket`` is no longer an error: HTTP auto-generates one and + the IPC channel is simply skipped.""" + seen = _stub_cli(monkeypatch) + result = CliRunner().invoke(cli_server.server, []) + assert result.exit_code == 0, result.output + assert seen["server_socket"] is None diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0f554676b..dd0629d46 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -7,6 +7,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P2D" [options.exclude-newer-package] +uipath-ipc = false uipath-runtime = false uipath-platform = false uipath-core = false @@ -2619,6 +2620,7 @@ dependencies = [ { name = "tenacity" }, { name = "truststore" }, { name = "uipath-core" }, + { name = "uipath-ipc" }, { name = "uipath-platform" }, { name = "uipath-runtime" }, ] @@ -2673,6 +2675,7 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, + { name = "uipath-ipc", specifier = ">=2.5.1", index = "https://pkgs.dev.azure.com/uipath/Public.Feeds/_packaging/UiPath-Internal/pypi/simple/" }, { name = "uipath-platform", editable = "../uipath-platform" }, { name = "uipath-runtime", specifier = ">=0.12.2,<0.13.0" }, ] @@ -2739,6 +2742,15 @@ dev = [ { name = "rust-just", specifier = ">=1.39.0" }, ] +[[package]] +name = "uipath-ipc" +version = "2.5.1+20260625.1" +source = { registry = "https://pkgs.dev.azure.com/uipath/Public.Feeds/_packaging/UiPath-Internal/pypi/simple/" } +sdist = { url = "https://pkgs.dev.azure.com/uipath/5b98d55c-1b14-4a03-893f-7a59746f1246/_packaging/788028a9-5a01-48ee-b925-3af51ae46294/pypi/download/uipath-ipc/2.5.1+20260625.1/uipath_ipc-2.5.1+20260625.1.tar.gz", hash = "sha256:bc202fef26d8ecae6b8bcfaccd6bcfb4675e0160faabf8d9281f1028643adee9" } +wheels = [ + { url = "https://pkgs.dev.azure.com/uipath/5b98d55c-1b14-4a03-893f-7a59746f1246/_packaging/788028a9-5a01-48ee-b925-3af51ae46294/pypi/download/uipath-ipc/2.5.1+20260625.1/uipath_ipc-2.5.1+20260625.1-py3-none-any.whl", hash = "sha256:0e90b0198b9afa8a1fa832e010aa1160914391d9b5610684a3bf582704f41773" }, +] + [[package]] name = "uipath-platform" version = "0.2.12"