From 6db317dccee4918d0f3c7330875aa0b8df08f614 Mon Sep 17 00:00:00 2001 From: Radu Mihai Gheorghe Date: Tue, 10 Mar 2026 19:35:39 +0200 Subject: [PATCH] feat: add entrypoint autodiscover --- packages/uipath/pyproject.toml | 2 +- packages/uipath/src/uipath/_cli/cli_run.py | 53 ++++++++-- packages/uipath/tests/cli/test_run.py | 117 +++++++++++++++++++-- packages/uipath/uv.lock | 62 ++++++++--- 4 files changed, 208 insertions(+), 26 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index a1e30f8e6..62d710fd2 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.11" +version = "2.10.12" 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/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 6b5152ed4..03dfa97fd 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -34,6 +34,38 @@ console = ConsoleLogger() +class _RunDiscoveryError(Exception): + """Raised when entrypoint auto-discovery fails.""" + + def __init__(self, entrypoints: list[str]): + self.entrypoints = entrypoints + + +def _show_run_usage_help(entrypoints: list[str]) -> None: + """Show available entrypoints with usage examples.""" + lines: list[str] = [] + + if entrypoints: + lines.append("Available entrypoints:") + for name in entrypoints: + lines.append(f" - {name}") + else: + lines.append( + "No entrypoints found. " + "Add a 'functions' or 'agents' section to your config file " + "(e.g. uipath.json, langgraph.json) " + "or MCP slugs to mcp.json." + ) + + lines.append( + "\nUsage: uipath run [-f ]" + ) + if entrypoints: + lines.append(f"Example: uipath run {entrypoints[0]}") + + click.echo("\n".join(lines)) + + @click.command() @click.argument("entrypoint", required=False) @click.argument("input", required=False, default=None) @@ -125,11 +157,6 @@ def run( return if result.should_continue: - if not entrypoint: - console.error("""No entrypoint specified. Please provide the path to the Python function. - Usage: `uipath run [-f ]`""") - return - try: async def execute_runtime( @@ -187,6 +214,15 @@ async def execute() -> None: factory: UiPathRuntimeFactoryProtocol | None = None try: factory = UiPathRuntimeFactoryRegistry.get(context=ctx) + + resolved_entrypoint = entrypoint + if not resolved_entrypoint: + available = factory.discover_entrypoints() + if len(available) == 1: + resolved_entrypoint = available[0] + else: + raise _RunDiscoveryError(available) + factory_settings = await factory.get_settings() trace_settings = ( factory_settings.trace_settings @@ -194,7 +230,7 @@ async def execute() -> None: else None ) runtime = await factory.new_runtime( - entrypoint, + resolved_entrypoint, ctx.conversation_id or ctx.job_id or "default", ) @@ -230,12 +266,17 @@ async def execute() -> None: asyncio.run(execute()) + except _RunDiscoveryError as e: + _show_run_usage_help(e.entrypoints) + return except UiPathRuntimeError as e: console.error(f"{e.error_info.title} - {e.error_info.detail}") + return except Exception as e: console.error( f"Error: Unexpected error occurred - {str(e)}", include_traceback=True ) + return console.success("Successful execution.") diff --git a/packages/uipath/tests/cli/test_run.py b/packages/uipath/tests/cli/test_run.py index 9069c5426..f7d8f3b98 100644 --- a/packages/uipath/tests/cli/test_run.py +++ b/packages/uipath/tests/cli/test_run.py @@ -1,6 +1,7 @@ # type: ignore import os -from unittest.mock import patch +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, Mock, patch import pytest from click.testing import CliRunner @@ -9,6 +10,41 @@ from uipath._cli.middlewares import MiddlewareResult +def _middleware_continue(): + return MiddlewareResult( + should_continue=True, + error_message=None, + should_include_stacktrace=False, + ) + + +async def _empty_async_gen(*args, **kwargs): + """An async generator that yields nothing (simulates empty runtime.stream).""" + return + yield # noqa: unreachable - makes this an async generator + + +def _make_mock_factory(entrypoints: list[str]): + """Create a mock runtime factory with given entrypoints.""" + mock_factory = Mock() + mock_factory.discover_entrypoints.return_value = entrypoints + mock_factory.get_settings = AsyncMock(return_value=None) + mock_factory.dispose = AsyncMock() + + mock_runtime = Mock() + mock_runtime.execute = AsyncMock(return_value=Mock(status="SUCCESSFUL")) + mock_runtime.stream = Mock(side_effect=_empty_async_gen) + mock_runtime.dispose = AsyncMock() + mock_factory.new_runtime = AsyncMock(return_value=mock_runtime) + + return mock_factory + + +@asynccontextmanager +async def _mock_resource_overwrites_context(*args, **kwargs): + yield + + @pytest.fixture def entrypoint(): return "main" @@ -142,14 +178,81 @@ def test_run_input_file_success( assert "Successful execution." in result.output class TestMiddleware: - def test_no_entrypoint(self, runner: CliRunner, temp_dir: str): + def test_autodiscover_entrypoint(self, runner: CliRunner, temp_dir: str): + """When exactly one entrypoint exists, it is auto-resolved.""" with runner.isolated_filesystem(temp_dir=temp_dir): - result = runner.invoke(cli, ["run"]) - assert result.exit_code == 1 - assert ( - "No entrypoint specified" in result.output - or "Missing argument" in result.output + mock_factory = _make_mock_factory(["my_agent"]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" ) + assert "Successful execution." in result.output + mock_factory.new_runtime.assert_awaited_once() + assert mock_factory.new_runtime.call_args[0][0] == "my_agent" + + def test_no_entrypoint_multiple_available( + self, runner: CliRunner, temp_dir: str + ): + """When multiple entrypoints exist and none specified, show usage help.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + mock_factory = _make_mock_factory(["agent_a", "agent_b"]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0 + assert "Available entrypoints:" in result.output + assert "agent_a" in result.output + assert "agent_b" in result.output + assert "Usage: uipath run" in result.output + mock_factory.new_runtime.assert_not_awaited() + + def test_no_entrypoint_none_available(self, runner: CliRunner, temp_dir: str): + """When no entrypoints exist and none specified, show usage help.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + mock_factory = _make_mock_factory([]) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=mock_factory, + ), + ): + result = runner.invoke(cli, ["run"]) + + assert result.exit_code == 0 + assert "No entrypoints found" in result.output + assert "Usage: uipath run" in result.output + mock_factory.new_runtime.assert_not_awaited() def test_script_not_found( self, runner: CliRunner, temp_dir: str, entrypoint: str diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f7e3594d4..681d269fd 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2531,7 +2531,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.11" +version = "2.10.12" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2602,8 +2602,8 @@ requires-dist = [ { name = "rich", specifier = ">=14.2.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, - { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.0.4,<0.1.0" }, + { name = "uipath-core", editable = "../uipath-core" }, + { name = "uipath-platform", editable = "../uipath-platform" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] @@ -2639,21 +2639,39 @@ dev = [ [[package]] name = "uipath-core" version = "0.5.6" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/8a/d129d33a81865f99d9134391a52f8691f557d95a18a38df4d88917b3e235/uipath_core-0.5.6.tar.gz", hash = "sha256:bebaf2e62111e844739e4f4e4dc47c48bac93b7e6fce6754502a9f4979c41888", size = 112659, upload-time = "2026-03-04T18:04:42.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/8f/77ab712518aa2a8485a558a0de245ac425e07fd8b74cfa8951550f0aea63/uipath_core-0.5.6-py3-none-any.whl", hash = "sha256:4a741fc760605165b0541b3abb6ade728bfa386e000ace00054bc43995720e5b", size = 42047, upload-time = "2026-03-04T18:04:41.606Z" }, + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-instrumentation", specifier = ">=0.60b0,<1.0.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2.0.0" }, + { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, ] [[package]] name = "uipath-platform" -version = "0.0.17" -source = { registry = "https://pypi.org/simple" } +version = "0.0.18" +source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, { name = "pydantic-function-models" }, @@ -2661,9 +2679,29 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/c9/e7568133f3a657af16b67444c4e090259941078acc62acb1e2c072903da4/uipath_platform-0.0.17.tar.gz", hash = "sha256:a2c228462d7e2642dcfc249547d9b8e94ba1c72b68f16ba673ee3e58204e9365", size = 264143, upload-time = "2026-03-06T20:34:22.23Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d1/a1c357dbea16a8b5d5b8ae5311ff2353cc03a8b5dd15ff83b0b693687930/uipath_platform-0.0.17-py3-none-any.whl", hash = "sha256:7b88f2b4eb189877fb2f99d704fc0cbc6e4244f01dac59cf812fa8a03db95e36", size = 159073, upload-time = "2026-03-06T20:34:20.993Z" }, + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic-function-models", specifier = ">=0.1.11" }, + { name = "tenacity", specifier = ">=9.0.0" }, + { name = "truststore", specifier = ">=0.10.1" }, + { name = "uipath-core", editable = "../uipath-core" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.19.0" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, ] [[package]]