Skip to content

Commit e5f5079

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

4 files changed

Lines changed: 204 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: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,37 @@
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+
)
58+
59+
lines.append(
60+
"\nUsage: uipath run <entrypoint> <input_arguments> [-f <input_json_file_path>]"
61+
)
62+
if entrypoints:
63+
lines.append(f"Example: uipath run {entrypoints[0]}")
64+
65+
click.echo("\n".join(lines))
66+
67+
3768
@click.command()
3869
@click.argument("entrypoint", required=False)
3970
@click.argument("input", required=False, default=None)
@@ -125,11 +156,6 @@ def run(
125156
return
126157

127158
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-
133159
try:
134160

135161
async def execute_runtime(
@@ -187,14 +213,23 @@ async def execute() -> None:
187213
factory: UiPathRuntimeFactoryProtocol | None = None
188214
try:
189215
factory = UiPathRuntimeFactoryRegistry.get(context=ctx)
216+
217+
resolved_entrypoint = entrypoint
218+
if not resolved_entrypoint:
219+
available = factory.discover_entrypoints()
220+
if len(available) == 1:
221+
resolved_entrypoint = available[0]
222+
else:
223+
raise _RunDiscoveryError(available)
224+
190225
factory_settings = await factory.get_settings()
191226
trace_settings = (
192227
factory_settings.trace_settings
193228
if factory_settings
194229
else None
195230
)
196231
runtime = await factory.new_runtime(
197-
entrypoint,
232+
resolved_entrypoint,
198233
ctx.conversation_id or ctx.job_id or "default",
199234
)
200235

@@ -230,6 +265,8 @@ async def execute() -> None:
230265

231266
asyncio.run(execute())
232267

268+
except _RunDiscoveryError as e:
269+
_show_run_usage_help(e.entrypoints)
233270
except UiPathRuntimeError as e:
234271
console.error(f"{e.error_info.title} - {e.error_info.detail}")
235272
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+
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)