Skip to content

Commit 57df1bf

Browse files
feat: add models-list command
wip wip
1 parent 27646a4 commit 57df1bf

6 files changed

Lines changed: 249 additions & 2 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.53"
3+
version = "2.10.54"
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/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"server": "cli_server",
4646
"register": "cli_register",
4747
"debug": "cli_debug",
48+
"list": "cli_list",
4849
"assets": "services.cli_assets",
4950
"buckets": "services.cli_buckets",
5051
"context-grounding": "services.cli_context_grounding",

packages/uipath/src/uipath/_cli/_utils/_resources.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import enum
22

3+
import click
4+
35
from ._console import ConsoleLogger
46

57
console = ConsoleLogger().get_instance()
@@ -20,3 +22,19 @@ def from_string(cls, resource: str) -> "Resources":
2022
f"Invalid resource type: '{resource}'. Valid types are: {valid_resources}"
2123
)
2224
raise
25+
26+
27+
class Listable(str, enum.Enum):
28+
"""Available resources that can be listed via `uipath list <resource>`."""
29+
30+
MODELS = "models"
31+
32+
@classmethod
33+
def from_string(cls, resource: str) -> "Listable":
34+
try:
35+
return Listable(resource)
36+
except ValueError as e:
37+
valid = ", ".join(r.value for r in Listable)
38+
raise click.ClickException(
39+
f"Invalid resource type: '{resource}'. Valid types are: {valid}"
40+
) from e
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import click
2+
3+
from ._utils._resources import Listable
4+
from ._utils._service_base import ServiceCommandBase, service_command
5+
6+
7+
@click.command(name="list")
8+
@click.argument("resource", required=True)
9+
@click.option(
10+
"--format",
11+
type=click.Choice(["json", "table", "csv"]),
12+
help="Output format (overrides global)",
13+
)
14+
@click.option("--output", "-o", type=click.Path(), help="Output file")
15+
@service_command
16+
async def list(ctx, resource, format, output):
17+
"""List available UiPath resources.
18+
19+
\b
20+
Examples:
21+
uipath list models
22+
""" # noqa: D301
23+
match Listable.from_string(resource):
24+
case Listable.MODELS:
25+
client = ServiceCommandBase.get_client(ctx)
26+
return await client.agenthub.get_available_llm_models_async()
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Integration tests for the generic `uipath list <resource>` CLI command.
2+
3+
These tests verify dispatch to each supported resource handler, output
4+
formatting, and error handling.
5+
"""
6+
7+
import json
8+
from unittest.mock import AsyncMock, MagicMock, patch
9+
10+
import pytest
11+
from click.testing import CliRunner
12+
13+
from uipath._cli import cli
14+
from uipath.platform.agenthub import LlmModel
15+
16+
17+
@pytest.fixture
18+
def runner():
19+
"""Provide a Click CLI test runner."""
20+
return CliRunner()
21+
22+
23+
@pytest.fixture
24+
def mock_client():
25+
"""Provide a mocked UiPath client with an async agenthub service."""
26+
with patch("uipath.platform._uipath.UiPath") as mock:
27+
client_instance = MagicMock()
28+
mock.return_value = client_instance
29+
30+
client_instance.agenthub = MagicMock()
31+
client_instance.agenthub.get_available_llm_models_async = AsyncMock()
32+
33+
yield client_instance
34+
35+
36+
def _make_models() -> list[LlmModel]:
37+
"""Build a small list of LlmModel instances."""
38+
return [
39+
LlmModel(modelName="gpt-4o-mini", vendor="openai"),
40+
LlmModel(modelName="claude-sonnet-4-5", vendor="anthropic"),
41+
]
42+
43+
44+
class TestListModels:
45+
def test_basic(self, runner, mock_client, mock_env_vars):
46+
"""Default table output lists each model."""
47+
mock_client.agenthub.get_available_llm_models_async.return_value = (
48+
_make_models()
49+
)
50+
51+
result = runner.invoke(cli, ["list", "models"])
52+
53+
assert result.exit_code == 0
54+
assert "gpt-4o-mini" in result.output
55+
assert "claude-sonnet-4-5" in result.output
56+
assert "openai" in result.output
57+
assert "anthropic" in result.output
58+
mock_client.agenthub.get_available_llm_models_async.assert_awaited_once()
59+
60+
def test_empty(self, runner, mock_client, mock_env_vars):
61+
"""Empty result prints the formatter's no-results placeholder."""
62+
mock_client.agenthub.get_available_llm_models_async.return_value = []
63+
64+
result = runner.invoke(cli, ["list", "models"])
65+
66+
assert result.exit_code == 0
67+
assert "No results" in result.output
68+
69+
def test_json_format(self, runner, mock_client, mock_env_vars):
70+
"""--format json returns parseable JSON with model fields."""
71+
mock_client.agenthub.get_available_llm_models_async.return_value = (
72+
_make_models()
73+
)
74+
75+
result = runner.invoke(cli, ["list", "models", "--format", "json"])
76+
77+
assert result.exit_code == 0
78+
payload = json.loads(result.output)
79+
assert isinstance(payload, list)
80+
assert {m["model_name"] for m in payload} == {
81+
"gpt-4o-mini",
82+
"claude-sonnet-4-5",
83+
}
84+
assert {m["vendor"] for m in payload} == {"openai", "anthropic"}
85+
86+
def test_csv_format(self, runner, mock_client, mock_env_vars):
87+
"""--format csv emits a header row and one row per model."""
88+
mock_client.agenthub.get_available_llm_models_async.return_value = (
89+
_make_models()
90+
)
91+
92+
result = runner.invoke(cli, ["list", "models", "--format", "csv"])
93+
94+
assert result.exit_code == 0
95+
lines = [line for line in result.output.splitlines() if line.strip()]
96+
# Header + 2 data rows
97+
assert len(lines) == 3
98+
assert "model_name" in lines[0]
99+
assert "vendor" in lines[0]
100+
assert any("gpt-4o-mini" in line for line in lines[1:])
101+
assert any("claude-sonnet-4-5" in line for line in lines[1:])
102+
103+
def test_global_format_flag(self, runner, mock_client, mock_env_vars):
104+
"""Global --format flag on the cli group is honored."""
105+
mock_client.agenthub.get_available_llm_models_async.return_value = (
106+
_make_models()
107+
)
108+
109+
result = runner.invoke(cli, ["--format", "json", "list", "models"])
110+
111+
assert result.exit_code == 0
112+
payload = json.loads(result.output)
113+
assert isinstance(payload, list)
114+
assert len(payload) == 2
115+
116+
def test_output_to_file(self, runner, mock_client, mock_env_vars, tmp_path):
117+
"""--output writes the formatted result to the given file."""
118+
mock_client.agenthub.get_available_llm_models_async.return_value = (
119+
_make_models()
120+
)
121+
122+
out_file = tmp_path / "models.json"
123+
result = runner.invoke(
124+
cli,
125+
["list", "models", "--format", "json", "--output", str(out_file)],
126+
)
127+
128+
assert result.exit_code == 0
129+
assert out_file.exists()
130+
payload = json.loads(out_file.read_text(encoding="utf-8"))
131+
assert {m["model_name"] for m in payload} == {
132+
"gpt-4o-mini",
133+
"claude-sonnet-4-5",
134+
}
135+
assert f"Output written to {out_file}" in result.output
136+
137+
def test_service_error(self, runner, mock_client, mock_env_vars):
138+
"""Exceptions from the service are turned into click errors."""
139+
mock_client.agenthub.get_available_llm_models_async.side_effect = RuntimeError(
140+
"boom"
141+
)
142+
143+
result = runner.invoke(cli, ["list", "models"])
144+
145+
assert result.exit_code != 0
146+
assert "boom" in result.output
147+
148+
149+
class TestListAuth:
150+
def test_missing_url(self, runner, monkeypatch):
151+
"""Missing UIPATH_URL surfaces an auth-configuration error."""
152+
monkeypatch.delenv("UIPATH_URL", raising=False)
153+
monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "mock_token")
154+
155+
result = runner.invoke(cli, ["list", "models"])
156+
157+
assert result.exit_code != 0
158+
assert "UIPATH_URL not configured" in result.output
159+
160+
def test_missing_token(self, runner, monkeypatch):
161+
"""Missing UIPATH_ACCESS_TOKEN surfaces an auth-configuration error."""
162+
monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant")
163+
monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False)
164+
165+
result = runner.invoke(cli, ["list", "models"])
166+
167+
assert result.exit_code != 0
168+
assert "Authentication required" in result.output
169+
170+
171+
class TestListDispatch:
172+
def test_unknown_resource(self, runner, mock_env_vars):
173+
"""Unknown resource type produces a click error listing valid types."""
174+
result = runner.invoke(cli, ["list", "bogus"])
175+
176+
assert result.exit_code != 0
177+
assert "Invalid resource type: 'bogus'" in result.output
178+
assert "models" in result.output
179+
180+
def test_missing_resource_argument(self, runner):
181+
"""Omitting the resource argument is a click usage error."""
182+
result = runner.invoke(cli, ["list"])
183+
184+
assert result.exit_code != 0
185+
assert "Missing argument" in result.output or "Usage" in result.output
186+
187+
def test_help_text(self, runner):
188+
"""--help surfaces the command description and options."""
189+
result = runner.invoke(cli, ["list", "--help"])
190+
191+
assert result.exit_code == 0
192+
assert "List available UiPath resources" in result.output
193+
assert "--format" in result.output
194+
assert "--output" in result.output
195+
assert "RESOURCE" in result.output.upper()
196+
197+
def test_registered_in_cli(self, runner):
198+
"""The generic list command is wired into the top-level CLI group."""
199+
result = runner.invoke(cli, ["--help"])
200+
201+
assert result.exit_code == 0
202+
assert "list" in result.output

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)