Skip to content

Commit cc965d4

Browse files
committed
feat: add entrypoint autodiscover
1 parent 03b641c commit cc965d4

4 files changed

Lines changed: 215 additions & 26 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.11"
3+
version = "2.10.12"
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"

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

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,38 @@
3434
console = ConsoleLogger()
3535

3636

37+
class _RunDiscoveryError(Exception):
38+
"""Raised when entrypoint auto-discovery fails."""
39+
40+
def __init__(self, entrypoints: list[str]):
41+
self.entrypoints = entrypoints
42+
43+
44+
def _show_run_usage_help(entrypoints: list[str]) -> None:
45+
"""Show available entrypoints with usage examples."""
46+
lines: list[str] = []
47+
48+
if entrypoints:
49+
lines.append("Available entrypoints:")
50+
for name in entrypoints:
51+
lines.append(f" - {name}")
52+
else:
53+
lines.append(
54+
"No entrypoints found. "
55+
"Add a 'functions' or 'agents' section to your config file "
56+
"(e.g. uipath.json, langgraph.json) "
57+
"or MCP slugs to mcp.json."
58+
)
59+
60+
lines.append(
61+
"\nUsage: uipath run <entrypoint> <input_arguments> [-f <input_json_file_path>]"
62+
)
63+
if entrypoints:
64+
lines.append(f"Example: uipath run {entrypoints[0]}")
65+
66+
click.echo("\n".join(lines))
67+
68+
3769
@click.command()
3870
@click.argument("entrypoint", required=False)
3971
@click.argument("input", required=False, default=None)
@@ -85,6 +117,11 @@
85117
is_flag=True,
86118
help="Keep the temporary state file even when not resuming and no job id is provided",
87119
)
120+
@click.option(
121+
"--force",
122+
is_flag=True,
123+
help="Force using a single MCP server config with the provided entrypoint name as slug",
124+
)
88125
@track_command("run")
89126
def run(
90127
entrypoint: str | None,
@@ -98,6 +135,7 @@ def run(
98135
debug: bool,
99136
debug_port: int,
100137
keep_state_file: bool,
138+
force: bool,
101139
) -> None:
102140
"""Execute the project."""
103141
input_file = file or input_file
@@ -125,11 +163,6 @@ def run(
125163
return
126164

127165
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-
133166
try:
134167

135168
async def execute_runtime(
@@ -187,15 +220,25 @@ async def execute() -> None:
187220
factory: UiPathRuntimeFactoryProtocol | None = None
188221
try:
189222
factory = UiPathRuntimeFactoryRegistry.get(context=ctx)
223+
224+
resolved_entrypoint = entrypoint
225+
if not resolved_entrypoint:
226+
available = factory.discover_entrypoints()
227+
if len(available) == 1:
228+
resolved_entrypoint = available[0]
229+
else:
230+
raise _RunDiscoveryError(available)
231+
190232
factory_settings = await factory.get_settings()
191233
trace_settings = (
192234
factory_settings.trace_settings
193235
if factory_settings
194236
else None
195237
)
196238
runtime = await factory.new_runtime(
197-
entrypoint,
239+
resolved_entrypoint,
198240
ctx.conversation_id or ctx.job_id or "default",
241+
force=force,
199242
)
200243

201244
if ctx.job_id:
@@ -230,12 +273,17 @@ async def execute() -> None:
230273

231274
asyncio.run(execute())
232275

276+
except _RunDiscoveryError as e:
277+
_show_run_usage_help(e.entrypoints)
278+
return
233279
except UiPathRuntimeError as e:
234280
console.error(f"{e.error_info.title} - {e.error_info.detail}")
281+
return
235282
except Exception as e:
236283
console.error(
237284
f"Error: Unexpected error occurred - {str(e)}", include_traceback=True
238285
)
286+
return
239287

240288
console.success("Successful execution.")
241289

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+
return
24+
yield # noqa: unreachable - makes this an async generator
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: 50 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)