Skip to content
Draft
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.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"
Expand Down
23 changes: 1 addition & 22 deletions packages/uipath/src/uipath/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/uipath/src/uipath/_cli/cli_debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/uipath/src/uipath/_cli/cli_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from uipath.runtime import UiPathRuntimeContext, UiPathRuntimeFactoryRegistry

from ._telemetry import track_command
from .runtimes import requires_runtime

console = ConsoleLogger()

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/uipath/src/uipath/_cli/cli_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/uipath/src/uipath/_cli/cli_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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."""
Expand Down
2 changes: 2 additions & 0 deletions packages/uipath/src/uipath/_cli/cli_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/uipath/src/uipath/_cli/cli_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
PythonServerStopJobRequest,
start_ipc_server,
)
from .runtimes import requires_runtime

__all__ = [
"server",
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath/src/uipath/_cli/cli_server_ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 59 additions & 1 deletion packages/uipath/src/uipath/_cli/runtimes.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,69 @@
"""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:
register_func = ep.load()
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
77 changes: 77 additions & 0 deletions packages/uipath/testcases/common/startup_assert.py
Original file line number Diff line number Diff line change
@@ -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")
8 changes: 8 additions & 0 deletions packages/uipath/testcases/langchain-cross/src/assert.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Loading
Loading