From ba64d9e1a4b19cedb08ff7255f34a83950f19b1d Mon Sep 17 00:00:00 2001 From: Ion Mincu Date: Wed, 16 Sep 2026 11:45:09 +0300 Subject: [PATCH] fix(cli): stop `uipath --help` loading every installed agent runtime With uipath-langchain installed, `uipath --help` imported langgraph, langchain_core and openai just to print a help page. Click resolves every command to read its short help when rendering the help table, and resolving a command in `_RUNTIME_COMMANDS` called `_ensure_runtime_initialized()`. That loads the `uipath.runtime.factories` entry points, so every installed plugin's agent stack was imported before anything was printed. The six commands that execute an agent now carry `@requires_runtime`, which populates the factory registry when the callback runs. Click calls a callback only after it has parsed the arguments, so `--help` and shell completion resolve the command object and exit without ever loading a runtime. `run`, `eval`, `dev`, `debug`, `server` and `init` still initialize before they execute. Each command now declares its own need for a runtime, instead of the group holding a list of which names are special. `_cli/__init__.py` is back to a plain lazy importer, and `runtimes.py` owns both the discovery and the decorator that triggers it. The private `uipath._cli._ensure_runtime_initialized` moved to `uipath._cli.runtimes.ensure_runtime_initialized`; nothing in this repo, uipath-langchain or uipath-llamaindex imports the old name. Measured against uipath 2.14.22 with the same stock uipath-langchain 0.18.8, best of 3: before after uipath --help 3.85s 1.82s uipath run --help 3.09s 1.20s modules on --help 2995 1207 langgraph, langchain_core, openai and uipath_langchain are no longer imported by `--help`. `uipath --version` and `import uipath._cli` are unchanged by this commit. Help output is byte-identical, diffed against origin/main for `--help`, `run --help`, `init --help`, `eval --help`, `server --help`, `assets --help`, `context-grounding --help` and `--help --format json`. Guards, in tests/cli/test_lazy_commands.py and the langchain-cross testcase, assert which modules get imported rather than wall-clock time. `uipath --help` opens ~1200 module files, so its runtime is dominated by the runner's filesystem; the same build measured 2.7s and 13.1s on one machine. The langchain-cross guard registers a `uipath.runtime.factories` entry point, which is where the regression appears; it fails against the previous revision with ['langchain_core', 'langgraph', 'openai', 'uipath_langchain'] loaded and passes here. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/__init__.py | 23 +-- packages/uipath/src/uipath/_cli/cli_debug.py | 2 + packages/uipath/src/uipath/_cli/cli_dev.py | 2 + packages/uipath/src/uipath/_cli/cli_eval.py | 2 + packages/uipath/src/uipath/_cli/cli_init.py | 2 + packages/uipath/src/uipath/_cli/cli_run.py | 2 + packages/uipath/src/uipath/_cli/cli_server.py | 2 + .../uipath/src/uipath/_cli/cli_server_ipc.py | 4 +- packages/uipath/src/uipath/_cli/runtimes.py | 60 ++++++- .../uipath/testcases/common/startup_assert.py | 77 +++++++++ .../testcases/langchain-cross/src/assert.py | 8 + .../uipath/tests/cli/test_lazy_commands.py | 146 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 14 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 packages/uipath/testcases/common/startup_assert.py create mode 100644 packages/uipath/tests/cli/test_lazy_commands.py 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/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py index f12d46560..d0f99a8c4 100644 --- a/packages/uipath/src/uipath/_cli/__init__.py +++ b/packages/uipath/src/uipath/_cli/__init__.py @@ -51,33 +51,12 @@ "context-grounding": "services.cli_context_grounding", } -_RUNTIME_COMMANDS = {"init", "dev", "run", "eval", "debug", "server"} -_runtime_initialized = False - - -def _ensure_runtime_initialized(): - """Initialize runtime factories once, only when needed.""" - global _runtime_initialized - if _runtime_initialized: - return - _runtime_initialized = True - - from uipath._cli.runtimes import load_runtime_factories - from uipath.functions import register_default_runtime_factory - - register_default_runtime_factory() - load_runtime_factories() - - -def _load_command(name: str): +def _load_command(name: str) -> click.Command: """Load a CLI command by name.""" if name not in _LAZY_COMMANDS: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - if name in _RUNTIME_COMMANDS: - _ensure_runtime_initialized() - module_name = _LAZY_COMMANDS[name] mod = __import__(f"uipath._cli.{module_name}", fromlist=[name]) # CLI names may use hyphens (e.g. "context-grounding") but Python diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index fc2372f0d..9c3b47b2e 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -33,6 +33,7 @@ from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares +from .runtimes import requires_runtime console = ConsoleLogger() logger = logging.getLogger(__name__) @@ -87,6 +88,7 @@ default=None, help="Simulation config as a JSON object (same schema as simulation.json)", ) +@requires_runtime @track_command("debug") def debug( entrypoint: str | None, diff --git a/packages/uipath/src/uipath/_cli/cli_dev.py b/packages/uipath/src/uipath/_cli/cli_dev.py index 16b331dd3..f6067542f 100644 --- a/packages/uipath/src/uipath/_cli/cli_dev.py +++ b/packages/uipath/src/uipath/_cli/cli_dev.py @@ -12,6 +12,7 @@ from uipath.runtime import UiPathRuntimeContext, UiPathRuntimeFactoryRegistry from ._telemetry import track_command +from .runtimes import requires_runtime console = ConsoleLogger() @@ -54,6 +55,7 @@ def _check_dev_dependency(interface: str) -> None: default=5678, help="Port for the debug server (default: 5678)", ) +@requires_runtime @track_command("dev") def dev(interface: str, debug: bool, debug_port: int) -> None: """Launch UiPath Developer Console. diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 66bdfad10..03d8d5295 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -17,6 +17,7 @@ from uipath._cli._utils._studio_project import StudioClient from uipath._cli._utils._tracing import create_trace_manager from uipath._cli.middlewares import Middlewares +from uipath._cli.runtimes import requires_runtime from uipath.core.events import EventBus from uipath.eval.helpers import EVAL_SETS_DIRECTORY_NAME, EvalHelpers, get_agent_model from uipath.eval.models.evaluation_set import EvaluationSet @@ -301,6 +302,7 @@ def _discover_eval_sets() -> list[Path]: default=False, help="Include workload execution output (trace, result) in the output file", ) +@requires_runtime def eval( entrypoint: str | None, eval_set: str | None, diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index d6ff01a90..3f3d27a30 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -45,6 +45,7 @@ from .middlewares import Middlewares from .models.runtime_schema import Bindings, EntryPoint from .models.uipath_json_schema import UiPathJsonConfig +from .runtimes import requires_runtime console = ConsoleLogger() logger = logging.getLogger(__name__) @@ -419,6 +420,7 @@ def _display_entrypoint_graphs(entry_point_schemas: list[UiPathRuntimeSchema]) - default=False, help="Won't override existing .agent files and AGENTS.md file.", ) +@requires_runtime @track_command("initialize") def init(no_agents_md_override: bool) -> None: """Initialize the project.""" diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index d98b92653..531b984a3 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -40,6 +40,7 @@ from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares +from .runtimes import requires_runtime console = ConsoleLogger() @@ -123,6 +124,7 @@ def get_usage_help(self) -> list[str]: hidden=True, # set by the job executor, never by a person help="Named pipe to stream this job's logs and result over uipath-ipc instead of writing them to files.", ) +@requires_runtime @track_command("run") def run( entrypoint: str | None, diff --git a/packages/uipath/src/uipath/_cli/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py index dc5e31bae..a1dbdf29f 100644 --- a/packages/uipath/src/uipath/_cli/cli_server.py +++ b/packages/uipath/src/uipath/_cli/cli_server.py @@ -28,6 +28,7 @@ PythonServerStopJobRequest, start_ipc_server, ) +from .runtimes import requires_runtime __all__ = [ "server", @@ -338,6 +339,7 @@ async def start_tcp_server(host: str, port: int) -> None: is_flag=True, help="Force TCP mode even on Unix systems.", ) +@requires_runtime @track_command("server") def server( client_socket: str | None, diff --git a/packages/uipath/src/uipath/_cli/cli_server_ipc.py b/packages/uipath/src/uipath/_cli/cli_server_ipc.py index 586680274..988e513f5 100644 --- a/packages/uipath/src/uipath/_cli/cli_server_ipc.py +++ b/packages/uipath/src/uipath/_cli/cli_server_ipc.py @@ -173,9 +173,9 @@ async def start_ipc_server(pipe_name: str) -> None: _state.init() - from uipath._cli import _ensure_runtime_initialized + from uipath._cli.runtimes import ensure_runtime_initialized - _ensure_runtime_initialized() + ensure_runtime_initialized() from ._job_api import _MAX_MESSAGE_BYTES diff --git a/packages/uipath/src/uipath/_cli/runtimes.py b/packages/uipath/src/uipath/_cli/runtimes.py index 613e278a6..e38c3d800 100644 --- a/packages/uipath/src/uipath/_cli/runtimes.py +++ b/packages/uipath/src/uipath/_cli/runtimes.py @@ -1,7 +1,25 @@ +"""Runtime factory discovery for the CLI. + +Commands that execute an agent need ``UiPathRuntimeFactoryRegistry`` populated +with the built-in factory and every ``uipath.runtime.factories`` entry point. +Loading those entry points imports each installed agent stack (langgraph, +llama_index, ...), so it happens inside the command callback, after click has +parsed the arguments. Rendering ``--help`` and shell completion resolve the +command object but never call it, so they never pay for it. +""" + +import functools +from collections.abc import Callable from importlib.metadata import entry_points +from typing import ParamSpec, TypeVar + +P = ParamSpec("P") +R = TypeVar("R") + +_initialized = False -def load_runtime_factories(): +def load_runtime_factories() -> None: """Auto-discover and register all factory plugins.""" for ep in entry_points(group="uipath.runtime.factories"): try: @@ -9,3 +27,43 @@ def load_runtime_factories(): register_func() except Exception as e: print(f"Failed to load factory {ep.name}: {e}") + + +def ensure_runtime_initialized() -> None: + """Register the built-in runtime factory and every plugin factory, once.""" + global _initialized + if _initialized: + return + _initialized = True + + from uipath.functions import register_default_runtime_factory + + register_default_runtime_factory() + load_runtime_factories() + + +def requires_runtime(func: Callable[P, R]) -> Callable[P, R]: + """Initialize the runtime factories before a command callback runs. + + Stack it under ``@click.command()``, above ``@track_command`` so the + factory load stays out of the command's measured duration: + + @click.command() + @requires_runtime + @track_command("run") + def run(...): + ... + + Args: + func: The click command callback. + + Returns: + The callback, wrapped to populate the runtime factory registry first. + """ + + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + ensure_runtime_initialized() + return func(*args, **kwargs) + + return wrapper diff --git a/packages/uipath/testcases/common/startup_assert.py b/packages/uipath/testcases/common/startup_assert.py new file mode 100644 index 000000000..7bc3e946c --- /dev/null +++ b/packages/uipath/testcases/common/startup_assert.py @@ -0,0 +1,77 @@ +"""Assertions on what the CLI imports at startup. + +`uipath --help` once took 5-7s: click resolves every command to render the help +table, and resolving a runtime command used to load the `uipath.runtime.factories` +entry points, so printing a help page imported the whole agent stack of every +installed plugin. + +These assertions count modules rather than measure seconds. Wall-clock time for a +process that opens ~1200 module files is dominated by the runner's filesystem and +anti-virus -- measured swings of 2.7s to 13s for the same build on one machine -- +and the budget needed to catch the original regression sits inside that noise. The +set of imported modules is exactly what regressed, and it is deterministic. +""" + +import json +import subprocess +import sys +import textwrap + +RUNTIME_STACK = ( + "langgraph", + "langchain_core", + "openai", + "uipath_langchain", + "llama_index", + "uipath_llamaindex", +) + + +def _probe(body: str) -> dict: + """Run a probe in a fresh interpreter and return the JSON it prints.""" + completed = subprocess.run( + [sys.executable, "-c", textwrap.dedent(body)], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, ( + f"startup probe failed ({completed.returncode}): {completed.stderr}" + ) + return json.loads(completed.stdout) + + +def assert_help_does_not_load_runtime_stack() -> None: + """Rendering `--help` must not import any installed agent runtime. + + Only meaningful where a package registers a `uipath.runtime.factories` entry + point, so call it from a testcase that installs one. + """ + result = _probe( + f""" + import json, sys + from click.testing import CliRunner + from uipath._cli import cli + + outcome = CliRunner().invoke(cli, ["--help"]) + print(json.dumps({{ + "exit_code": outcome.exit_code, + # Checked here: the table sits past any excerpt worth sending back. + "has_table": "Commands" in outcome.output, + "excerpt": outcome.output[:200], + "loaded": sorted(set({RUNTIME_STACK!r}) & sys.modules.keys()), + }})) + """ + ) + + assert result["exit_code"] == 0, f"'uipath --help' failed: {result['excerpt']}" + assert result["has_table"], ( + f"'uipath --help' printed no command table: {result['excerpt']}" + ) + assert not result["loaded"], ( + f"'uipath --help' imported the agent runtime stack: {result['loaded']}. " + f"Printing help must not load runtime factories; see " + f"requires_runtime in uipath/_cli/runtimes.py." + ) + + print("'uipath --help' loaded no agent runtime stack") diff --git a/packages/uipath/testcases/langchain-cross/src/assert.py b/packages/uipath/testcases/langchain-cross/src/assert.py index 491a64c69..c2bbf8bf0 100644 --- a/packages/uipath/testcases/langchain-cross/src/assert.py +++ b/packages/uipath/testcases/langchain-cross/src/assert.py @@ -1,5 +1,7 @@ import json import os + +from startup_assert import assert_help_does_not_load_runtime_stack from trace_assert import assert_traces # Check NuGet package @@ -47,3 +49,9 @@ print("Required fields validation passed") print(f"Output structure validation passed - report: '{actual_report}'") + +# uipath-langchain registers a `uipath.runtime.factories` entry point, so this is +# the environment where `uipath --help` took 5-7s: resolving a command to read its +# short help used to load the factories, importing langgraph, langchain_core and +# openai to print a help page. +assert_help_does_not_load_runtime_stack() diff --git a/packages/uipath/tests/cli/test_lazy_commands.py b/packages/uipath/tests/cli/test_lazy_commands.py new file mode 100644 index 000000000..2676c0dcd --- /dev/null +++ b/packages/uipath/tests/cli/test_lazy_commands.py @@ -0,0 +1,146 @@ +"""Guards for CLI startup cost. + +`uipath --help` once took 5-7s. Resolving a command also initialized the runtime +factories, so rendering the help page loaded every installed agent stack. These +tests pin down when factories load, and what `--help` is allowed to import. +""" + +import json +import subprocess +import sys +import textwrap +from typing import Any + +import pytest +from click.testing import CliRunner + +from uipath._cli import _LAZY_COMMANDS, cli, runtimes + +# Commands that execute an agent and therefore need the runtime factories. +RUNTIME_COMMANDS = ("debug", "dev", "eval", "init", "run", "server") + +# Imported by the agent runtime, never needed to parse arguments or print help. +RUNTIME_STACK = frozenset({"openai", "langgraph", "langchain_core", "uipath_langchain"}) + + +def _run_in_fresh_interpreter(body: str) -> dict[str, Any]: + """Run a probe in a new interpreter and return the JSON it prints. + + sys.modules is process-global and the rest of the suite has already imported + the heavy modules, so these probes cannot run in the test process. + """ + completed = subprocess.run( + [sys.executable, "-c", textwrap.dedent(body)], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + return json.loads(completed.stdout) + + +def test_help_lists_every_command() -> None: + result = CliRunner().invoke(cli, ["--help"]) + + assert result.exit_code == 0 + for name in _LAZY_COMMANDS: + assert name in result.output + + +def test_help_json_lists_every_command(monkeypatch: pytest.MonkeyPatch) -> None: + """--format json needs full per-command metadata.""" + # LazyGroup.format_help picks the format out of sys.argv, which CliRunner + # leaves untouched. + monkeypatch.setattr(sys, "argv", ["uipath", "--help", "--format", "json"]) + result = CliRunner().invoke(cli, ["--help", "--format", "json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert {command["name"] for command in payload["commands"]} == set(_LAZY_COMMANDS) + # A group's subcommands only appear if the group was really resolved. + assets = next(c for c in payload["commands"] if c["name"] == "assets") + assert assets["subcommands"] + + +def test_help_does_not_import_runtime_stack() -> None: + """--help must not pull in the agent runtime stack. Regression guard.""" + probe = _run_in_fresh_interpreter( + f""" + import json, sys + from click.testing import CliRunner + from uipath._cli import cli, runtimes + + result = CliRunner().invoke(cli, ["--help"]) + print(json.dumps({{ + "exit_code": result.exit_code, + "runtime_stack": sorted({set(RUNTIME_STACK)!r} & sys.modules.keys()), + "runtime_initialized": runtimes._initialized, + }})) + """ + ) + + assert probe["exit_code"] == 0 + assert probe["runtime_stack"] == [] + assert probe["runtime_initialized"] is False + + +def test_subcommand_help_does_not_initialize_runtime() -> None: + """Printing a runtime command's help must not load its runtime factories.""" + probe = _run_in_fresh_interpreter( + f""" + import json + from click.testing import CliRunner + from uipath._cli import cli, runtimes + + results = {{}} + for name in {RUNTIME_COMMANDS!r}: + results[name] = CliRunner().invoke(cli, [name, "--help"]).exit_code + print(json.dumps({{ + "exit_codes": results, + "runtime_initialized": runtimes._initialized, + }})) + """ + ) + + assert set(probe["exit_codes"].values()) == {0}, probe["exit_codes"] + assert probe["runtime_initialized"] is False + + +@pytest.mark.parametrize("name", RUNTIME_COMMANDS) +def test_runtime_command_initializes_runtime_before_running( + name: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every agent-executing command is guarded by @requires_runtime. + + The sentinel raised from the initializer proves the guard runs, and runs + before the command body, without executing the command for real. + """ + + class Sentinel(Exception): + pass + + def initialize() -> None: + raise Sentinel + + monkeypatch.setattr(runtimes, "ensure_runtime_initialized", initialize) + result = CliRunner().invoke(cli, [name]) + + assert isinstance(result.exception, Sentinel), result.output + + +def test_running_a_runtime_command_initializes_runtime() -> None: + """Deferring initialization changed when factories load, not whether.""" + probe = _run_in_fresh_interpreter( + """ + import json + from click.testing import CliRunner + from uipath._cli import cli, runtimes + + # There is no project here, so the run fails; the exit code does not + # matter, only that invoking reaches ensure_runtime_initialized. + CliRunner().invoke(cli, ["run", "does-not-exist"]) + print(json.dumps({"runtime_initialized": runtimes._initialized})) + """ + ) + + assert probe["runtime_initialized"] is True 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" },