Skip to content

Commit f5058fb

Browse files
authored
feat: add entrypoint autodiscover (#1431)
1 parent 4afbc8c commit f5058fb

6 files changed

Lines changed: 199 additions & 51 deletions

File tree

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.40"
3+
version = "2.10.41"
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"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
class EntrypointDiscoveryException(Exception):
2+
"""Raised when entrypoint auto-discovery fails."""
3+
4+
def __init__(self, entrypoints: list[str]):
5+
self.entrypoints = entrypoints
6+
7+
def get_usage_help(self) -> list[str]:
8+
if self.entrypoints:
9+
lines = ["Available entrypoints:"]
10+
for name in self.entrypoints:
11+
lines.append(f" - {name}")
12+
return lines
13+
return [
14+
"No entrypoints found.",
15+
"",
16+
"To configure entrypoints, use one of the following:",
17+
" 1. Functions project (uipath.json)",
18+
" 2. Framework-specific project (e.g. langgraph.json, llamaindex.json, openai_agents.json)",
19+
" 3. MCP project (mcp.json)",
20+
]

packages/uipath/src/uipath/_cli/cli_eval.py

Lines changed: 32 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import click
1010

11+
from uipath._cli._errors import EntrypointDiscoveryException
1112
from uipath._cli._evals._console_progress_reporter import ConsoleProgressReporter
1213
from uipath._cli._evals._progress_reporter import StudioWebProgressReporter
1314
from uipath._cli._evals._telemetry import EvalTelemetrySubscriber
@@ -135,13 +136,35 @@ def _resolve_model_settings_override(
135136
return override if override else None
136137

137138

138-
class _EvalDiscoveryError(Exception):
139+
class _EvalDiscoveryError(EntrypointDiscoveryException):
139140
"""Raised when auto-discovery of entrypoint or eval set fails."""
140141

141142
def __init__(self, entrypoints: list[str], eval_sets: list[Path]):
142-
self.entrypoints = entrypoints
143+
super().__init__(entrypoints)
143144
self.eval_sets = eval_sets
144145

146+
def get_usage_help(self) -> list[str]:
147+
lines = super().get_usage_help()
148+
149+
if self.eval_sets:
150+
lines.append("")
151+
lines.append("Available eval sets:")
152+
for f in self.eval_sets:
153+
lines.append(f" - {f}")
154+
else:
155+
lines.append("")
156+
lines.append(
157+
f"No eval sets found in '{EVAL_SETS_DIRECTORY_NAME}/' directory."
158+
)
159+
160+
lines.append("")
161+
lines.append("Usage: uipath eval <entrypoint> <eval_set>")
162+
if self.entrypoints and self.eval_sets:
163+
lines.append(
164+
f"Example: uipath eval {self.entrypoints[0]} {self.eval_sets[0]}"
165+
)
166+
return lines
167+
145168

146169
def _discover_eval_sets() -> list[Path]:
147170
"""Discover available eval set files."""
@@ -151,39 +174,6 @@ def _discover_eval_sets() -> list[Path]:
151174
return []
152175

153176

154-
def _show_eval_usage_help(entrypoints: list[str], eval_set_files: list[Path]) -> None:
155-
"""Show available entrypoints and eval sets with usage examples."""
156-
lines: list[str] = []
157-
158-
if entrypoints:
159-
lines.append("Available entrypoints:")
160-
for name in entrypoints:
161-
lines.append(f" - {name}")
162-
else:
163-
lines.append(
164-
"No entrypoints found. "
165-
"Add a 'functions' or 'agents' section to your config file "
166-
"(e.g. uipath.json, langgraph.json)."
167-
)
168-
169-
if eval_set_files:
170-
lines.append("\nAvailable eval sets:")
171-
for f in eval_set_files:
172-
lines.append(f" - {f}")
173-
else:
174-
lines.append(
175-
f"\nNo eval sets found in '{EVAL_SETS_DIRECTORY_NAME}/' directory."
176-
)
177-
178-
lines.append("\nUsage: uipath eval <entrypoint> <eval_set>")
179-
if entrypoints and eval_set_files:
180-
ep_name = entrypoints[0]
181-
es_path = eval_set_files[0]
182-
lines.append(f"Example: uipath eval {ep_name} {es_path}")
183-
184-
click.echo("\n".join(lines))
185-
186-
187177
@click.command()
188178
@click.argument("entrypoint", required=False)
189179
@click.argument("eval_set", required=False)
@@ -475,7 +465,13 @@ async def execute_eval():
475465
asyncio.run(execute_eval())
476466

477467
except _EvalDiscoveryError as e:
478-
_show_eval_usage_help(e.entrypoints, e.eval_sets)
468+
click.echo("\n".join(e.get_usage_help()))
469+
if not e.entrypoints:
470+
click.echo()
471+
console.link(
472+
"uipath.json spec:",
473+
"https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md",
474+
)
479475
except ValueError as e:
480476
console.error(str(e))
481477
except Exception as e:

packages/uipath/src/uipath/_cli/cli_run.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,29 @@
2727
LlmOpsHttpExporter,
2828
)
2929

30+
from ._errors import EntrypointDiscoveryException
3031
from ._telemetry import track_command
3132
from ._utils._console import ConsoleLogger
3233
from .middlewares import Middlewares
3334

3435
console = ConsoleLogger()
3536

3637

38+
class _RunDiscoveryError(EntrypointDiscoveryException):
39+
"""Raised when entrypoint auto-discovery fails."""
40+
41+
def get_usage_help(self) -> list[str]:
42+
lines = super().get_usage_help()
43+
lines.append("")
44+
lines.append(
45+
"Usage: uipath run <entrypoint> <input_arguments>"
46+
" [-f <input_json_file_path>]"
47+
)
48+
if self.entrypoints:
49+
lines.append(f"Example: uipath run {self.entrypoints[0]}")
50+
return lines
51+
52+
3753
@click.command()
3854
@click.argument("entrypoint", required=False)
3955
@click.argument("input", required=False, default=None)
@@ -125,11 +141,6 @@ def run(
125141
return
126142

127143
if result.should_continue:
128-
if not entrypoint:
129-
console.error("""No entrypoint specified. Please provide the path to the Python function.
130-
Usage: `uipath run <entrypoint> <input_arguments> [-f <input_json_file_path>]`""")
131-
return
132-
133144
try:
134145

135146
async def execute_runtime(
@@ -187,14 +198,23 @@ async def execute() -> None:
187198
factory: UiPathRuntimeFactoryProtocol | None = None
188199
try:
189200
factory = UiPathRuntimeFactoryRegistry.get(context=ctx)
201+
202+
resolved_entrypoint = entrypoint
203+
if not resolved_entrypoint:
204+
available = factory.discover_entrypoints()
205+
if len(available) == 1:
206+
resolved_entrypoint = available[0]
207+
else:
208+
raise _RunDiscoveryError(available)
209+
190210
factory_settings = await factory.get_settings()
191211
trace_settings = (
192212
factory_settings.trace_settings
193213
if factory_settings
194214
else None
195215
)
196216
runtime = await factory.new_runtime(
197-
entrypoint,
217+
resolved_entrypoint,
198218
ctx.conversation_id or ctx.job_id or "default",
199219
)
200220

@@ -230,6 +250,15 @@ async def execute() -> None:
230250

231251
asyncio.run(execute())
232252

253+
except _RunDiscoveryError as e:
254+
click.echo("\n".join(e.get_usage_help()))
255+
if not e.entrypoints:
256+
click.echo()
257+
console.link(
258+
"uipath.json spec:",
259+
"https://github.com/UiPath/uipath-python/blob/main/packages/uipath/specs/uipath.spec.md",
260+
)
261+
return
233262
except UiPathRuntimeError as e:
234263
console.error(f"{e.error_info.title} - {e.error_info.detail}")
235264
except Exception as e:

packages/uipath/tests/cli/test_run.py

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# type: ignore
22
import os
3-
from unittest.mock import patch
3+
from contextlib import asynccontextmanager
4+
from unittest.mock import AsyncMock, Mock, patch
45

56
import pytest
67
from click.testing import CliRunner
@@ -9,6 +10,41 @@
910
from uipath._cli.middlewares import MiddlewareResult
1011

1112

13+
def _middleware_continue():
14+
return MiddlewareResult(
15+
should_continue=True,
16+
error_message=None,
17+
should_include_stacktrace=False,
18+
)
19+
20+
21+
async def _empty_async_gen(*args, **kwargs):
22+
"""An async generator that yields nothing (simulates empty runtime.stream)."""
23+
if False: # pragma: no cover
24+
yield
25+
26+
27+
def _make_mock_factory(entrypoints: list[str]):
28+
"""Create a mock runtime factory with given entrypoints."""
29+
mock_factory = Mock()
30+
mock_factory.discover_entrypoints.return_value = entrypoints
31+
mock_factory.get_settings = AsyncMock(return_value=None)
32+
mock_factory.dispose = AsyncMock()
33+
34+
mock_runtime = Mock()
35+
mock_runtime.execute = AsyncMock(return_value=Mock(status="SUCCESSFUL"))
36+
mock_runtime.stream = Mock(side_effect=_empty_async_gen)
37+
mock_runtime.dispose = AsyncMock()
38+
mock_factory.new_runtime = AsyncMock(return_value=mock_runtime)
39+
40+
return mock_factory
41+
42+
43+
@asynccontextmanager
44+
async def _mock_resource_overwrites_context(*args, **kwargs):
45+
yield
46+
47+
1248
@pytest.fixture
1349
def entrypoint():
1450
return "main"
@@ -142,14 +178,81 @@ def test_run_input_file_success(
142178
assert "Successful execution." in result.output
143179

144180
class TestMiddleware:
145-
def test_no_entrypoint(self, runner: CliRunner, temp_dir: str):
181+
def test_autodiscover_entrypoint(self, runner: CliRunner, temp_dir: str):
182+
"""When exactly one entrypoint exists, it is auto-resolved."""
146183
with runner.isolated_filesystem(temp_dir=temp_dir):
147-
result = runner.invoke(cli, ["run"])
148-
assert result.exit_code == 1
149-
assert (
150-
"No entrypoint specified" in result.output
151-
or "Missing argument" in result.output
184+
mock_factory = _make_mock_factory(["my_agent"])
185+
186+
with (
187+
patch(
188+
"uipath._cli.cli_run.Middlewares.next",
189+
return_value=_middleware_continue(),
190+
),
191+
patch(
192+
"uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get",
193+
return_value=mock_factory,
194+
),
195+
patch(
196+
"uipath._cli.cli_run.ResourceOverwritesContext",
197+
side_effect=_mock_resource_overwrites_context,
198+
),
199+
):
200+
result = runner.invoke(cli, ["run"])
201+
202+
assert result.exit_code == 0, (
203+
f"output: {result.output!r}, exception: {result.exception}"
152204
)
205+
assert "Successful execution." in result.output
206+
mock_factory.new_runtime.assert_awaited_once()
207+
assert mock_factory.new_runtime.call_args[0][0] == "my_agent"
208+
209+
def test_no_entrypoint_multiple_available(
210+
self, runner: CliRunner, temp_dir: str
211+
):
212+
"""When multiple entrypoints exist and none specified, show usage help."""
213+
with runner.isolated_filesystem(temp_dir=temp_dir):
214+
mock_factory = _make_mock_factory(["agent_a", "agent_b"])
215+
216+
with (
217+
patch(
218+
"uipath._cli.cli_run.Middlewares.next",
219+
return_value=_middleware_continue(),
220+
),
221+
patch(
222+
"uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get",
223+
return_value=mock_factory,
224+
),
225+
):
226+
result = runner.invoke(cli, ["run"])
227+
228+
assert result.exit_code == 0
229+
assert "Available entrypoints:" in result.output
230+
assert "agent_a" in result.output
231+
assert "agent_b" in result.output
232+
assert "Usage: uipath run" in result.output
233+
mock_factory.new_runtime.assert_not_awaited()
234+
235+
def test_no_entrypoint_none_available(self, runner: CliRunner, temp_dir: str):
236+
"""When no entrypoints exist and none specified, show usage help."""
237+
with runner.isolated_filesystem(temp_dir=temp_dir):
238+
mock_factory = _make_mock_factory([])
239+
240+
with (
241+
patch(
242+
"uipath._cli.cli_run.Middlewares.next",
243+
return_value=_middleware_continue(),
244+
),
245+
patch(
246+
"uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get",
247+
return_value=mock_factory,
248+
),
249+
):
250+
result = runner.invoke(cli, ["run"])
251+
252+
assert result.exit_code == 0
253+
assert "No entrypoints found" in result.output
254+
assert "Usage: uipath run" in result.output
255+
mock_factory.new_runtime.assert_not_awaited()
153256

154257
def test_script_not_found(
155258
self, runner: CliRunner, temp_dir: str, entrypoint: str

packages/uipath/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)