diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index c113a5a6b..737a182be 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.13" +version = "0.1.14" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py index 8d93993b7..601de3964 100644 --- a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -361,6 +361,62 @@ async def retrieve_async( except StopIteration as e: raise Exception("ContextGroundingIndex not found") from e + @traced(name="contextgrounding_list", run_type="uipath") + def list( + self, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> List[ContextGroundingIndex]: + """List all context grounding indexes in a folder. + + Args: + folder_key (Optional[str]): The key of the folder to list indexes from. + folder_path (Optional[str]): The path of the folder to list indexes from. + + Returns: + List[ContextGroundingIndex]: All indexes in the folder. + """ + folder_key = self._resolve_folder_key(folder_key, folder_path) + response = self.request( + "GET", + Endpoint("/ecs_/v2/indexes"), + params={"$expand": "dataSource"}, + headers={**header_folder(folder_key, None)}, + ).json() + return [ + ContextGroundingIndex.model_validate(item) + for item in response.get("value", []) + ] + + @traced(name="contextgrounding_list", run_type="uipath") + async def list_async( + self, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> List[ContextGroundingIndex]: + """Asynchronously list all context grounding indexes in a folder. + + Args: + folder_key (Optional[str]): The key of the folder to list indexes from. + folder_path (Optional[str]): The path of the folder to list indexes from. + + Returns: + List[ContextGroundingIndex]: All indexes in the folder. + """ + folder_key = self._resolve_folder_key(folder_key, folder_path) + response = ( + await self.request_async( + "GET", + Endpoint("/ecs_/v2/indexes"), + params={"$expand": "dataSource"}, + headers={**header_folder(folder_key, None)}, + ) + ).json() + return [ + ContextGroundingIndex.model_validate(item) + for item in response.get("value", []) + ] + @traced(name="contextgrounding_retrieve_by_id", run_type="uipath") def retrieve_by_id( self, @@ -542,7 +598,7 @@ async def create_index_async( @resource_override(resource_type="index") @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") def create_ephemeral_index( - self, usage: EphemeralIndexUsage, attachments: list[str] + self, usage: EphemeralIndexUsage, attachments: List[str] ) -> ContextGroundingIndex: """Create a new ephemeral context grounding index. @@ -570,7 +626,7 @@ def create_ephemeral_index( @resource_override(resource_type="index") @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") async def create_ephemeral_index_async( - self, usage: EphemeralIndexUsage, attachments: list[str] + self, usage: EphemeralIndexUsage, attachments: List[str] ) -> ContextGroundingIndex: """Create a new ephemeral context grounding index. @@ -661,7 +717,7 @@ def start_batch_transform( self, name: str, prompt: Annotated[str, Field(max_length=250000)], - output_columns: list[BatchTransformOutputColumn], + output_columns: List[BatchTransformOutputColumn], storage_bucket_folder_path_prefix: Annotated[ str | None, Field(max_length=512) ] = None, @@ -737,7 +793,7 @@ async def start_batch_transform_async( self, name: str, prompt: Annotated[str, Field(max_length=250000)], - output_columns: list[BatchTransformOutputColumn], + output_columns: List[BatchTransformOutputColumn], storage_bucket_folder_path_prefix: Annotated[ str | None, Field(max_length=512) ] = None, @@ -813,7 +869,7 @@ async def start_batch_transform_ephemeral( self, name: str, prompt: Annotated[str, Field(max_length=250000)], - output_columns: list[BatchTransformOutputColumn], + output_columns: List[BatchTransformOutputColumn], storage_bucket_folder_path_prefix: Annotated[ str | None, Field(max_length=512) ] = None, @@ -859,7 +915,7 @@ async def start_batch_transform_ephemeral_async( self, name: str, prompt: Annotated[str, Field(max_length=250000)], - output_columns: list[BatchTransformOutputColumn], + output_columns: List[BatchTransformOutputColumn], storage_bucket_folder_path_prefix: Annotated[ str | None, Field(max_length=512) ] = None, @@ -1741,7 +1797,7 @@ def _create_spec( def _create_ephemeral_spec( self, usage: str, - attachments: list[str], + attachments: List[str], ) -> RequestSpec: """Create request spec for ephemeral index creation. @@ -1834,7 +1890,7 @@ def _build_data_source(self, source: SourceConfig) -> Dict[str, Any]: return data_source.model_dump(by_alias=True, exclude_none=True) - def _build_ephemeral_data_source(self, attachments: list[str]) -> Dict[str, Any]: + def _build_ephemeral_data_source(self, attachments: List[str]) -> Dict[str, Any]: """Build data source configuration from typed source config. Args: @@ -2002,7 +2058,7 @@ def _batch_transform_creation_spec( index_id: str, name: str, enable_web_search_grounding: bool, - output_columns: list[BatchTransformOutputColumn], + output_columns: List[BatchTransformOutputColumn], storage_bucket_folder_path_prefix: str | None, target_file_name: str | None, prompt: str, @@ -2052,7 +2108,7 @@ def _batch_transform_ephemeral_creation_spec( index_id: str | None, name: str, enable_web_search_grounding: bool, - output_columns: list[BatchTransformOutputColumn], + output_columns: List[BatchTransformOutputColumn], storage_bucket_folder_path_prefix: str | None, prompt: str, ) -> RequestSpec: diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py index 168828b64..135ac281b 100644 --- a/packages/uipath-platform/tests/services/test_context_grounding_service.py +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -376,6 +376,153 @@ async def test_retrieve_async( == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_async/{version}" ) + def test_list( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "index-id-1", + "name": "index-one", + "lastIngestionStatus": "Completed", + }, + { + "id": "index-id-2", + "name": "index-two", + "lastIngestionStatus": "Queued", + }, + ] + }, + ) + + indexes = service.list() + + assert isinstance(indexes, list) + assert len(indexes) == 2 + assert all(isinstance(i, ContextGroundingIndex) for i in indexes) + assert indexes[0].id == "index-id-1" + assert indexes[0].name == "index-one" + assert indexes[1].id == "index-id-2" + assert indexes[1].name == "index-two" + + sent_requests = httpx_mock.get_requests() + assert sent_requests[1].method == "GET" + assert ( + sent_requests[1].url + == f"{base_url}{org}{tenant}/ecs_/v2/indexes?%24expand=dataSource" + ) + assert HEADER_USER_AGENT in sent_requests[1].headers + assert ( + sent_requests[1].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.list/{version}" + ) + + @pytest.mark.anyio + async def test_list_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "index-id-1", + "name": "index-one", + "lastIngestionStatus": "Completed", + }, + ] + }, + ) + + indexes = await service.list_async() + + assert isinstance(indexes, list) + assert len(indexes) == 1 + assert isinstance(indexes[0], ContextGroundingIndex) + assert indexes[0].id == "index-id-1" + + sent_requests = httpx_mock.get_requests() + assert sent_requests[1].method == "GET" + assert ( + sent_requests[1].url + == f"{base_url}{org}{tenant}/ecs_/v2/indexes?%24expand=dataSource" + ) + assert HEADER_USER_AGENT in sent_requests[1].headers + assert ( + sent_requests[1].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.list_async/{version}" + ) + + def test_list_empty( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$expand=dataSource", + status_code=200, + json={"value": []}, + ) + + indexes = service.list() + + assert indexes == [] + def test_retrieve_across_folders( self, httpx_mock: HTTPXMock, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1542a2fb2..c405963e9 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" [[package]] @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.13" +version = "0.1.14" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 4112c26ca..4571d036e 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.35" +version = "2.10.36" 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/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py index f271fcf29..aa6e177e8 100644 --- a/packages/uipath/src/uipath/_cli/__init__.py +++ b/packages/uipath/src/uipath/_cli/__init__.py @@ -47,6 +47,7 @@ "debug": "cli_debug", "assets": "services.cli_assets", "buckets": "services.cli_buckets", + "context-grounding": "services.cli_context_grounding", } _RUNTIME_COMMANDS = {"init", "dev", "run", "eval", "debug", "server"} @@ -78,7 +79,10 @@ def _load_command(name: str): module_name = _LAZY_COMMANDS[name] mod = __import__(f"uipath._cli.{module_name}", fromlist=[name]) - return getattr(mod, name) + # CLI names may use hyphens (e.g. "context-grounding") but Python + # attribute names use underscores; convert before getattr. + attr_name = name.replace("-", "_") + return getattr(mod, attr_name) def __getattr__(name: str): diff --git a/packages/uipath/src/uipath/_cli/services/__init__.py b/packages/uipath/src/uipath/_cli/services/__init__.py index 996a781a8..71fc2114f 100644 --- a/packages/uipath/src/uipath/_cli/services/__init__.py +++ b/packages/uipath/src/uipath/_cli/services/__init__.py @@ -9,8 +9,9 @@ from .cli_assets import assets from .cli_buckets import buckets +from .cli_context_grounding import context_grounding -__all__ = ["assets", "buckets", "register_service_commands"] +__all__ = ["assets", "buckets", "context_grounding", "register_service_commands"] def register_service_commands(cli_group): @@ -31,7 +32,7 @@ def register_service_commands(cli_group): Industry Precedent: AWS CLI, Azure CLI, and gcloud all use explicit registration. """ - services = [assets, buckets] + services = [assets, buckets, context_grounding] for service in services: cli_group.add_command(service) diff --git a/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py b/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py new file mode 100644 index 000000000..b086120fc --- /dev/null +++ b/packages/uipath/src/uipath/_cli/services/cli_context_grounding.py @@ -0,0 +1,369 @@ +"""Context Grounding service commands for UiPath CLI. + +Context Grounding provides semantic search over indexed document collections, +enabling RAG (Retrieval-Augmented Generation) for automation processes. + +Commands: + list - List all indexes in a folder + retrieve - Get details of a specific index by name + search - Perform semantic search against an index + ingest - Trigger re-ingestion of an index + delete - Delete an index by name + +Note: + create_index is intentionally not exposed here — its source configuration + (bucket, Google Drive, OneDrive, Dropbox, Confluence) is too complex to + express cleanly as CLI flags. Use the Python SDK directly for index creation. +""" +# ruff: noqa: D301 - Using regular """ strings (not r""") for Click \b formatting + +from typing import Any, NoReturn, Optional + +import click +from httpx import HTTPStatusError + +from .._utils._service_base import ( + ServiceCommandBase, + common_service_options, + handle_not_found_error, + service_command, +) + +# The SDK raises a bare Exception with this message when an index name doesn't exist. +_INDEX_NOT_FOUND_MSG = "ContextGroundingIndex not found" + + +def _handle_retrieve_error(index_name: str, e: Exception) -> NoReturn: + """Convert a retrieve() exception to a clean ClickException. + + Mirrors the pattern used in cli_buckets.py: + - bare Exception with the SDK's not-found message → structured not-found error + - HTTPStatusError 404 → structured not-found error + - anything else → re-raise so service_command's handler deals with it + """ + if isinstance(e, HTTPStatusError): + if e.response.status_code == 404: + handle_not_found_error("Index", index_name, e) + raise e + if _INDEX_NOT_FOUND_MSG.lower() in str(e).lower(): + handle_not_found_error("Index", index_name) + raise click.ClickException(str(e)) from e + + +@click.group(name="context-grounding") +def context_grounding() -> None: + """Manage UiPath Context Grounding indexes and perform semantic search. + + Context Grounding indexes documents from storage buckets or cloud drives + and makes them searchable via natural language queries (RAG). + + \b + Commands: + list - List all indexes in a folder + retrieve - Get details of a specific index + search - Query an index with natural language + ingest - Trigger re-ingestion of an index + delete - Delete an index + + \b + Examples: + uipath context-grounding list --folder-path "Shared" + uipath context-grounding retrieve --index "my-index" --folder-path "Shared" + uipath context-grounding search "how to process invoices" --index "my-index" --folder-path "Shared" + uipath context-grounding search "payment terms" --index "my-index" --folder-path "Shared" --limit 5 + uipath context-grounding ingest --index "my-index" --folder-path "Shared" + uipath context-grounding delete --index "my-index" --folder-path "Shared" --confirm + + \b + Folder context: + Set UIPATH_FOLDER_PATH to avoid passing --folder-path on every command: + export UIPATH_FOLDER_PATH="Shared" + """ + pass + + +@context_grounding.command("list") +@common_service_options +@service_command +def list_indexes( + ctx: click.Context, + folder_path: Optional[str], + folder_key: Optional[str], + format: Optional[str], + output: Optional[str], +) -> Any: + """List all context grounding indexes in a folder. + + \b + Examples: + uipath context-grounding list --folder-path "Shared" + uipath context-grounding list --folder-path "Shared" --format json + """ + client = ServiceCommandBase.get_client(ctx) + results = client.context_grounding.list( + folder_path=folder_path, + folder_key=folder_key, + ) + + # Table format: project to key fields for readability. + # JSON/CSV: return full objects so nothing is lost for scripting. + fmt = format or "table" + if fmt == "table": + return [ + { + "name": ix.name, + "last_ingestion_status": ix.last_ingestion_status, + "last_ingested": ix.last_ingested, + "description": ix.description, + } + for ix in results + ] + + return results + + +@context_grounding.command("retrieve") +@click.option( + "--index", "index_name", required=True, help="Name of the index to retrieve." +) +@common_service_options +@service_command +def retrieve( + ctx: click.Context, + index_name: str, + folder_path: Optional[str], + folder_key: Optional[str], + format: Optional[str], + output: Optional[str], +) -> Any: + """Retrieve details of a context grounding index by name. + + \b + Examples: + uipath context-grounding retrieve --index "my-index" --folder-path "Shared" + uipath context-grounding retrieve --index "my-index" --folder-path "Shared" --format json + """ + client = ServiceCommandBase.get_client(ctx) + + try: + return client.context_grounding.retrieve( + name=index_name, + folder_path=folder_path, + folder_key=folder_key, + ) + except Exception as e: + _handle_retrieve_error(index_name, e) + + +@context_grounding.command("search") +@click.argument("query") +@click.option( + "--index", "index_name", required=True, help="Name of the index to search." +) +@click.option( + "--limit", + "-n", + "number_of_results", + type=click.IntRange(min=1), + default=10, + show_default=True, + help="Maximum number of results to return.", +) +@click.option( + "--threshold", + type=float, + default=None, + help="Minimum relevance score threshold (0.0–1.0). Results below this score are excluded.", +) +@common_service_options +@service_command +def search( + ctx: click.Context, + query: str, + index_name: str, + number_of_results: int, + threshold: Optional[float], + folder_path: Optional[str], + folder_key: Optional[str], + format: Optional[str], + output: Optional[str], +) -> Any: + """Search an index with a natural language query. + + QUERY is the natural language search string. + + \b + Examples: + uipath context-grounding search "how to process invoices" --index "my-index" --folder-path "Shared" + uipath context-grounding search "payment terms" --index "my-index" --folder-path "Shared" --limit 5 + uipath context-grounding search "invoice" --index "my-index" --folder-path "Shared" --threshold 0.7 + uipath context-grounding search "approval workflow" --index "my-index" --folder-path "Shared" --format json + uipath context-grounding search "invoice policy" --index "my-index" --folder-path "Shared" -o results.json + """ + from uipath.platform.errors import IngestionInProgressException + + client = ServiceCommandBase.get_client(ctx) + + try: + results = client.context_grounding.search( + name=index_name, + query=query, + number_of_results=number_of_results, + threshold=threshold, + folder_path=folder_path, + folder_key=folder_key, + ) + except IngestionInProgressException as e: + raise click.ClickException( + f"Index '{index_name}' is currently being ingested. " + "Please wait for ingestion to complete and try again." + ) from e + except Exception as e: + _handle_retrieve_error(index_name, e) + + if not results: + click.echo("No results found.", err=True) + return None + + # Table format: show only human-readable columns, truncate content. + # JSON/CSV: return full objects so nothing is lost for scripting. + fmt = format or "table" + if fmt == "table": + rows = [] + for r in results: + content = r.content + if len(content) > 120: + content = content[:120] + "…" + rows.append( + { + "score": round(r.score, 3) if r.score is not None else None, + "source": r.source, + "page_number": r.page_number, + "content": content, + } + ) + return rows + + return results + + +@context_grounding.command("ingest") +@click.option( + "--index", "index_name", required=True, help="Name of the index to re-ingest." +) +@common_service_options +@service_command +def ingest( + ctx: click.Context, + index_name: str, + folder_path: Optional[str], + folder_key: Optional[str], + format: Optional[str], + output: Optional[str], +) -> None: + """Trigger re-ingestion of a context grounding index. + + \b + Examples: + uipath context-grounding ingest --index "my-index" --folder-path "Shared" + """ + from uipath.platform.errors import IngestionInProgressException + + client = ServiceCommandBase.get_client(ctx) + + try: + index = client.context_grounding.retrieve( + name=index_name, + folder_path=folder_path, + folder_key=folder_key, + ) + if not index.id: + raise click.ClickException( + f"Index '{index_name}' has no ID and cannot be ingested." + ) + if index.in_progress_ingestion(): + raise click.ClickException( + f"Index '{index_name}' is already being ingested." + ) + client.context_grounding.ingest_data( + index=index, + folder_path=folder_path, + folder_key=folder_key, + ) + except click.ClickException: + raise + except IngestionInProgressException as e: + # Catches the 409 race condition from ingest_data() itself. + raise click.ClickException( + f"Index '{index_name}' is already being ingested." + ) from e + except Exception as e: + _handle_retrieve_error(index_name, e) + + click.echo(f"Triggered ingestion for index '{index_name}'.", err=True) + + +@context_grounding.command("delete") +@click.option( + "--index", "index_name", required=True, help="Name of the index to delete." +) +@click.option("--confirm", is_flag=True, help="Skip confirmation prompt.") +@click.option( + "--dry-run", is_flag=True, help="Show what would be deleted without deleting." +) +@common_service_options +@service_command +def delete( + ctx: click.Context, + index_name: str, + confirm: bool, + dry_run: bool, + folder_path: Optional[str], + folder_key: Optional[str], + format: Optional[str], + output: Optional[str], +) -> None: + """Delete a context grounding index by name. + + \b + Examples: + uipath context-grounding delete --index "my-index" --folder-path "Shared" --confirm + uipath context-grounding delete --index "my-index" --folder-path "Shared" --dry-run + """ + client = ServiceCommandBase.get_client(ctx) + + # Resolve the index object first — surfaces not-found before prompting the user. + try: + index = client.context_grounding.retrieve( + name=index_name, + folder_path=folder_path, + folder_key=folder_key, + ) + except Exception as e: + _handle_retrieve_error(index_name, e) + + if not index.id: + raise click.ClickException( + f"Index '{index_name}' has no ID and cannot be deleted." + ) + + # dry-run and confirmation after index is confirmed to exist and have an ID. + if dry_run: + click.echo(f"Would delete index '{index_name}'.", err=True) + return + + if not confirm: + if not click.confirm(f"Delete index '{index_name}'?"): + click.echo("Deletion cancelled.") + return + + try: + client.context_grounding.delete_index( + index=index, + folder_path=folder_path, + folder_key=folder_key, + ) + except Exception as e: + raise click.ClickException(f"Failed to delete index '{index_name}': {e}") from e + + click.echo(f"Deleted index '{index_name}'.", err=True) diff --git a/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py index ff322abf0..9390f0b06 100644 --- a/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py +++ b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py @@ -7,7 +7,7 @@ """ import inspect -from typing import Any +from typing import Any, cast import click import pytest @@ -118,6 +118,18 @@ def assert_cli_sdk_alignment( # Parameter mappings: CLI param name → SDK param name # Used when CLI uses more user-friendly names than SDK PARAM_MAPPINGS = { + # context-grounding: --index maps to SDK 'name', --folder maps to SDK 'folder_path' + "context-grounding_list": {}, + "context-grounding_retrieve": { + "index_name": "name", + }, + "context-grounding_search": { + "index_name": "name", + }, + # ingest/delete: SDK takes an index *object*, not a name. CLI's --index is a + # lookup key used to call retrieve() internally — it has no direct SDK counterpart. + "context-grounding_ingest": {}, + "context-grounding_delete": {}, "buckets_files_download": { "bucket_name": "name", "remote_path": "blob_file_path", @@ -154,6 +166,13 @@ def assert_cli_sdk_alignment( # SDK parameters to exclude for specific commands # Used when SDK has optional params that CLI doesn't expose SDK_EXCLUSIONS = { + # context-grounding: ingest/delete take an index *object*; CLI uses --index name + # then retrieves internally — so the SDK 'index' object param is not a CLI option. + "context-grounding_list": set(), + "context-grounding_retrieve": set(), + "context-grounding_search": set(), + "context-grounding_ingest": {"index"}, + "context-grounding_delete": {"index"}, "buckets_list": { "name", "skip", @@ -188,6 +207,12 @@ def assert_cli_sdk_alignment( @pytest.mark.parametrize( "service,command,sdk_class,sdk_method", [ + # Context Grounding + ("context-grounding", "list", "ContextGroundingService", "list"), + ("context-grounding", "retrieve", "ContextGroundingService", "retrieve"), + ("context-grounding", "search", "ContextGroundingService", "search"), + ("context-grounding", "ingest", "ContextGroundingService", "ingest_data"), + ("context-grounding", "delete", "ContextGroundingService", "delete_index"), # Buckets - bucket operations ("buckets", "list", "BucketsService", "list"), ("buckets", "retrieve", "BucketsService", "retrieve"), @@ -212,29 +237,48 @@ def test_service_command_params_match_sdk(service, command, sdk_class, sdk_metho This test runs on every commit to catch SDK/CLI drift early. """ from uipath._cli import services as cli_services + from uipath.platform import context_grounding as cg_module from uipath.platform import orchestrator - # Get SDK class and method - sdk_cls = getattr(orchestrator, sdk_class) + # SDK class lookup: context-grounding lives in context_grounding, not orchestrator + if sdk_class == "ContextGroundingService": + sdk_cls = getattr(cg_module, sdk_class) + else: + sdk_cls = getattr(orchestrator, sdk_class) sdk_meth = getattr(sdk_cls, sdk_method) - # Get CLI service group and command - # Handle nested subgroups (e.g., "buckets_files") - if "_" in service: - # Nested subgroup (e.g., "buckets_files") - parts = service.split("_") - service_group = getattr(cli_services, parts[0]) # Get "buckets" - subgroup = service_group.commands[parts[1]] # Get "files" subgroup + # Services that are nested sub-groups (e.g. "buckets_files" means + # cli_services.buckets.commands["files"].commands[command]). + # All other service keys resolve directly to a cli_services attribute + # (hyphens are converted to underscores: "context-grounding" → context_grounding). + NESTED_SERVICES = {"buckets_files"} + + def _get_service_group(name: str) -> click.Group: + """Resolve a cli_services attribute, converting hyphens to underscores.""" + return getattr(cli_services, name.replace("-", "_")) + + if service in NESTED_SERVICES: + parent_name, sub_name = service.split("_", 1) + service_group = _get_service_group(parent_name) + subgroup = cast(click.Group, service_group.commands[sub_name]) cli_cmd = subgroup.commands[command.replace("_", "-")] else: - # Top-level service group - service_group = getattr(cli_services, service) + service_group = _get_service_group(service) cli_cmd = service_group.commands[command.replace("_", "-")] + # CLI-only options per command (beyond the global CLI_ONLY_OPTIONS set). + CLI_EXCLUSIONS: dict[str, set[str]] = { + # ingest/delete use --index to look up the index object via retrieve(); + # index_name is a CLI-only lookup key with no direct SDK parameter counterpart. + "context-grounding_ingest": {"index_name"}, + "context-grounding_delete": {"index_name"}, + } + # Get mappings and exclusions for this command mapping_key = f"{service}_{command}" param_mappings = PARAM_MAPPINGS.get(mapping_key, {}) sdk_exclusions = SDK_EXCLUSIONS.get(mapping_key, set()) + cli_exclusions = CLI_EXCLUSIONS.get(mapping_key, set()) # Run alignment check assert_cli_sdk_alignment( @@ -242,6 +286,7 @@ def test_service_command_params_match_sdk(service, command, sdk_class, sdk_metho sdk_meth, param_mappings=param_mappings, exclude_sdk=sdk_exclusions, + exclude_cli=cli_exclusions, ) diff --git a/packages/uipath/tests/cli/integration/test_context_grounding_commands.py b/packages/uipath/tests/cli/integration/test_context_grounding_commands.py new file mode 100644 index 000000000..75cf88670 --- /dev/null +++ b/packages/uipath/tests/cli/integration/test_context_grounding_commands.py @@ -0,0 +1,940 @@ +"""Integration tests for context-grounding CLI commands. + +These tests verify end-to-end functionality of the context-grounding service +commands, including proper context handling, error messages, and output formatting. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from uipath._cli import cli +from uipath.platform.context_grounding import ( + ContextGroundingIndex, + ContextGroundingQueryResponse, +) +from uipath.platform.errors import IngestionInProgressException + + +@pytest.fixture +def runner(): + """Provide a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_client(): + """Provide a mocked UiPath client.""" + with patch("uipath.platform._uipath.UiPath") as mock: + client_instance = MagicMock() + mock.return_value = client_instance + client_instance.context_grounding = MagicMock() + yield client_instance + + +def _make_index(name="my-index", status="Completed", description="Test index"): + """Helper: build a mock ContextGroundingIndex. + + Uses spec=ContextGroundingIndex so MagicMock is not mistaken for an + Iterator by format_output (MagicMock implements __iter__ by default). + in_progress_ingestion() returns False by default (ingestion complete). + """ + index = MagicMock(spec=ContextGroundingIndex) + index.id = "test-index-id" + index.name = name + index.last_ingestion_status = status + index.last_ingested = None + index.description = description + index.in_progress_ingestion.return_value = False + index.model_dump.return_value = { + "name": name, + "last_ingestion_status": status, + "description": description, + } + return index + + +def _make_result(source="doc.pdf", page="1", content="Some content", score=0.95): + """Helper: build a mock ContextGroundingQueryResponse. + + Uses spec=ContextGroundingQueryResponse for the same reason as _make_index. + """ + result = MagicMock(spec=ContextGroundingQueryResponse) + result.source = source + result.page_number = page + result.content = content + result.score = score + result.model_dump.return_value = { + "source": source, + "page_number": page, + "content": content, + "score": score, + } + return result + + +# --------------------------------------------------------------------------- +# retrieve +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +class TestListCommand: + def test_list_basic(self, runner, mock_client, mock_env_vars): + """list returns all indexes in a folder.""" + mock_client.context_grounding.list.return_value = [ + _make_index(name="index-one", status="Completed"), + _make_index(name="index-two", status="Queued"), + ] + + result = runner.invoke( + cli, + ["context-grounding", "list", "--folder-path", "Shared"], + ) + + assert result.exit_code == 0 + assert "index-one" in result.output + assert "index-two" in result.output + # table columns projected + assert "last_ingestion_status" in result.output + assert "last_ingested" in result.output + # raw fields not in table + assert "data_source" not in result.output + mock_client.context_grounding.list.assert_called_once_with( + folder_path="Shared", + folder_key=None, + ) + + def test_list_json_format(self, runner, mock_client, mock_env_vars): + """list with --format json emits JSON.""" + mock_client.context_grounding.list.return_value = [ + _make_index(name="index-one"), + ] + + result = runner.invoke( + cli, + [ + "context-grounding", + "list", + "--folder-path", + "Shared", + "--format", + "json", + ], + ) + + assert result.exit_code == 0 + assert "index-one" in result.output + + def test_list_empty(self, runner, mock_client, mock_env_vars): + """list with no indexes returns empty output gracefully.""" + mock_client.context_grounding.list.return_value = [] + + result = runner.invoke( + cli, + ["context-grounding", "list", "--folder-path", "Shared"], + ) + + assert result.exit_code == 0 + + def test_list_with_folder_key(self, runner, mock_client, mock_env_vars): + """list passes folder_key when --folder-key is provided.""" + mock_client.context_grounding.list.return_value = [] + folder_key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + result = runner.invoke( + cli, + ["context-grounding", "list", "--folder-key", folder_key], + ) + + assert result.exit_code == 0 + mock_client.context_grounding.list.assert_called_once_with( + folder_path=None, + folder_key=folder_key, + ) + + +# --------------------------------------------------------------------------- + + +class TestRetrieveCommand: + def test_retrieve_basic(self, runner, mock_client, mock_env_vars): + """retrieve returns index details.""" + mock_client.context_grounding.retrieve.return_value = _make_index() + + result = runner.invoke( + cli, + [ + "context-grounding", + "retrieve", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code == 0 + assert "my-index" in result.output + mock_client.context_grounding.retrieve.assert_called_once_with( + name="my-index", + folder_path="Shared", + folder_key=None, + ) + + def test_retrieve_json_format(self, runner, mock_client, mock_env_vars): + """retrieve with --format json emits JSON.""" + mock_client.context_grounding.retrieve.return_value = _make_index() + + result = runner.invoke( + cli, + [ + "context-grounding", + "retrieve", + "--index", + "my-index", + "--folder-path", + "Shared", + "--format", + "json", + ], + ) + + assert result.exit_code == 0 + assert "my-index" in result.output + + def test_retrieve_missing_index_flag_fails(self, runner, mock_env_vars): + """retrieve without --index shows usage error.""" + result = runner.invoke( + cli, ["context-grounding", "retrieve", "--folder-path", "Shared"] + ) + + assert result.exit_code != 0 + assert "index" in result.output.lower() or "missing" in result.output.lower() + + def test_retrieve_not_found(self, runner, mock_client, mock_env_vars): + """retrieve surfaces a clean not-found error (SDK bare Exception).""" + mock_client.context_grounding.retrieve.side_effect = Exception( + "ContextGroundingIndex not found" + ) + + result = runner.invoke( + cli, + [ + "context-grounding", + "retrieve", + "--index", + "no-such-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_retrieve_not_found_http_404(self, runner, mock_client, mock_env_vars): + """retrieve surfaces a clean not-found error (HTTPStatusError 404).""" + from unittest.mock import MagicMock + + from httpx import HTTPStatusError, Request, Response + + mock_response = MagicMock(spec=Response) + mock_response.status_code = 404 + mock_client.context_grounding.retrieve.side_effect = HTTPStatusError( + "404", request=MagicMock(spec=Request), response=mock_response + ) + + result = runner.invoke( + cli, + [ + "context-grounding", + "retrieve", + "--index", + "no-such-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_retrieve_with_folder_key(self, runner, mock_client, mock_env_vars): + """retrieve passes folder_key when --folder-key is provided.""" + mock_client.context_grounding.retrieve.return_value = _make_index() + folder_key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + result = runner.invoke( + cli, + [ + "context-grounding", + "retrieve", + "--index", + "my-index", + "--folder-key", + folder_key, + ], + ) + + assert result.exit_code == 0 + mock_client.context_grounding.retrieve.assert_called_once_with( + name="my-index", + folder_path=None, + folder_key=folder_key, + ) + + +# --------------------------------------------------------------------------- +# search +# --------------------------------------------------------------------------- + + +class TestSearchCommand: + def test_search_basic(self, runner, mock_client, mock_env_vars): + """search returns results table.""" + mock_client.context_grounding.search.return_value = [ + _make_result( + source="invoice.pdf", content="Pay within 30 days", score=0.92 + ), + _make_result(source="policy.pdf", content="Approval required", score=0.85), + ] + + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "process an invoice", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code == 0 + assert "invoice.pdf" in result.output + assert "policy.pdf" in result.output + mock_client.context_grounding.search.assert_called_once_with( + name="my-index", + query="process an invoice", + number_of_results=10, + threshold=None, + folder_path="Shared", + folder_key=None, + ) + + def test_search_with_limit_option(self, runner, mock_client, mock_env_vars): + """--limit N is forwarded to the SDK as number_of_results.""" + mock_client.context_grounding.search.return_value = [ + _make_result(content="Short answer"), + ] + + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "payment terms", + "--index", + "my-index", + "--folder-path", + "Shared", + "--limit", + "3", + ], + ) + + assert result.exit_code == 0 + mock_client.context_grounding.search.assert_called_once_with( + name="my-index", + query="payment terms", + number_of_results=3, + threshold=None, + folder_path="Shared", + folder_key=None, + ) + + def test_search_json_format_no_truncation(self, runner, mock_client, mock_env_vars): + """JSON output contains full content, not truncated.""" + long_content = "x" * 300 + mock_client.context_grounding.search.return_value = [ + _make_result(content=long_content), + ] + + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "query", + "--index", + "my-index", + "--folder-path", + "Shared", + "--format", + "json", + ], + ) + + assert result.exit_code == 0 + assert long_content in result.output + + def test_search_table_format_truncates_content( + self, runner, mock_client, mock_env_vars + ): + """Table output shows score/source/page/content only; content truncated at 120 chars.""" + long_content = "A" * 200 + mock_client.context_grounding.search.return_value = [ + _make_result(source="doc.pdf", page="3", content=long_content, score=0.92), + ] + + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "query", + "--index", + "my-index", + "--folder-path", + "Shared", + "--format", + "table", + ], + ) + + assert result.exit_code == 0 + # content is truncated + assert long_content not in result.output + assert "…" in result.output + # only human-readable columns rendered + assert "score" in result.output + assert "source" in result.output + assert "page_number" in result.output + assert "doc.pdf" in result.output + # raw fields not present in table + assert "metadata" not in result.output + assert "reference" not in result.output + + def test_search_empty_results(self, runner, mock_client, mock_env_vars): + """search with no results prints a helpful message and exits 0. + + Note: the message is emitted via click.echo(..., err=True) so it goes to + stderr. CliRunner mixes stderr into result.output by default, which is why + the assertion below works. If this runner were created with mix_stderr=False + the assertion would need to check result.stderr instead. + """ + mock_client.context_grounding.search.return_value = [] + + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "unknown query", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code == 0 + # message goes to stderr; CliRunner mixes stderr into output by default + assert "no results" in result.output.lower() + + def test_search_ingestion_in_progress_error( + self, runner, mock_client, mock_env_vars + ): + """search surfaces a clean error when index is being ingested.""" + mock_client.context_grounding.search.side_effect = IngestionInProgressException( + index_name="my-index" + ) + + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "query", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + assert ( + "ingested" in result.output.lower() or "ingestion" in result.output.lower() + ) + + def test_search_not_found(self, runner, mock_client, mock_env_vars): + """search surfaces a clean not-found error for missing index.""" + mock_client.context_grounding.search.side_effect = Exception( + "ContextGroundingIndex not found" + ) + + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "query", + "--index", + "no-such-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_search_missing_index_flag_fails(self, runner, mock_env_vars): + """search without --index shows usage error.""" + result = runner.invoke( + cli, ["context-grounding", "search", "query", "--folder-path", "Shared"] + ) + + assert result.exit_code != 0 + assert "index" in result.output.lower() or "missing" in result.output.lower() + + def test_search_missing_query_fails(self, runner, mock_env_vars): + """search without QUERY positional arg shows usage error.""" + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + + def test_search_with_folder_key(self, runner, mock_client, mock_env_vars): + """search passes folder_key when --folder-key is provided.""" + mock_client.context_grounding.search.return_value = [_make_result()] + folder_key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + result = runner.invoke( + cli, + [ + "context-grounding", + "search", + "query", + "--index", + "my-index", + "--folder-key", + folder_key, + ], + ) + + assert result.exit_code == 0 + mock_client.context_grounding.search.assert_called_once_with( + name="my-index", + query="query", + number_of_results=10, + threshold=None, + folder_path=None, + folder_key=folder_key, + ) + + +# --------------------------------------------------------------------------- +# ingest +# --------------------------------------------------------------------------- + + +class TestIngestCommand: + def test_ingest_basic(self, runner, mock_client, mock_env_vars): + """ingest triggers ingestion and prints confirmation.""" + index = _make_index() + mock_client.context_grounding.retrieve.return_value = index + mock_client.context_grounding.ingest_data.return_value = None + + result = runner.invoke( + cli, + [ + "context-grounding", + "ingest", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code == 0 + assert "my-index" in result.output + mock_client.context_grounding.ingest_data.assert_called_once_with( + index=index, + folder_path="Shared", + folder_key=None, + ) + + def test_ingest_already_in_progress_fast_fail( + self, runner, mock_client, mock_env_vars + ): + """ingest fails fast (no HTTP call) when retrieve shows ingestion in progress.""" + index = _make_index(status="In Progress") + index.in_progress_ingestion.return_value = True + mock_client.context_grounding.retrieve.return_value = index + + result = runner.invoke( + cli, + [ + "context-grounding", + "ingest", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + assert "already" in result.output.lower() or "ingested" in result.output.lower() + mock_client.context_grounding.ingest_data.assert_not_called() + + def test_ingest_already_in_progress(self, runner, mock_client, mock_env_vars): + """ingest surfaces a clean error when the API reports 409 (race condition).""" + index = _make_index() + mock_client.context_grounding.retrieve.return_value = index + mock_client.context_grounding.ingest_data.side_effect = ( + IngestionInProgressException(index_name="my-index", search_operation=False) + ) + + result = runner.invoke( + cli, + [ + "context-grounding", + "ingest", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + assert "already" in result.output.lower() or "ingested" in result.output.lower() + + def test_ingest_not_found(self, runner, mock_client, mock_env_vars): + """ingest surfaces a clean not-found error.""" + mock_client.context_grounding.retrieve.side_effect = Exception( + "ContextGroundingIndex not found" + ) + + result = runner.invoke( + cli, + [ + "context-grounding", + "ingest", + "--index", + "no-such-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_ingest_index_has_no_id(self, runner, mock_client, mock_env_vars): + """ingest raises a clean error if the retrieved index has no ID (avoids silent no-op).""" + index = _make_index() + index.id = None + mock_client.context_grounding.retrieve.return_value = index + + result = runner.invoke( + cli, + [ + "context-grounding", + "ingest", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + ) + + assert result.exit_code != 0 + assert "no id" in result.output.lower() or "cannot" in result.output.lower() + mock_client.context_grounding.ingest_data.assert_not_called() + + def test_ingest_with_folder_key(self, runner, mock_client, mock_env_vars): + """ingest passes folder_key when --folder-key is provided.""" + index = _make_index() + mock_client.context_grounding.retrieve.return_value = index + mock_client.context_grounding.ingest_data.return_value = None + folder_key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + result = runner.invoke( + cli, + [ + "context-grounding", + "ingest", + "--index", + "my-index", + "--folder-key", + folder_key, + ], + ) + + assert result.exit_code == 0 + mock_client.context_grounding.retrieve.assert_called_once_with( + name="my-index", + folder_path=None, + folder_key=folder_key, + ) + mock_client.context_grounding.ingest_data.assert_called_once_with( + index=index, + folder_path=None, + folder_key=folder_key, + ) + + def test_ingest_missing_index_flag_fails(self, runner, mock_env_vars): + """ingest without --index shows usage error.""" + result = runner.invoke( + cli, ["context-grounding", "ingest", "--folder-path", "Shared"] + ) + + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +class TestDeleteCommand: + def test_delete_with_confirm(self, runner, mock_client, mock_env_vars): + """delete --confirm removes the index without prompting.""" + index = _make_index() + mock_client.context_grounding.retrieve.return_value = index + mock_client.context_grounding.delete_index.return_value = None + + result = runner.invoke( + cli, + [ + "context-grounding", + "delete", + "--index", + "my-index", + "--folder-path", + "Shared", + "--confirm", + ], + ) + + assert result.exit_code == 0 + assert "my-index" in result.output + mock_client.context_grounding.delete_index.assert_called_once_with( + index=index, + folder_path="Shared", + folder_key=None, + ) + + def test_delete_dry_run(self, runner, mock_client, mock_env_vars): + """delete --dry-run prints what would be deleted without deleting.""" + index = _make_index() + mock_client.context_grounding.retrieve.return_value = index + + result = runner.invoke( + cli, + [ + "context-grounding", + "delete", + "--index", + "my-index", + "--folder-path", + "Shared", + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "would delete" in result.output.lower() + mock_client.context_grounding.delete_index.assert_not_called() + + def test_delete_prompts_without_confirm(self, runner, mock_client, mock_env_vars): + """delete without --confirm or --dry-run prompts the user; 'n' cancels.""" + index = _make_index() + mock_client.context_grounding.retrieve.return_value = index + + result = runner.invoke( + cli, + [ + "context-grounding", + "delete", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + input="n\n", + ) + + assert result.exit_code == 0 + assert "cancelled" in result.output.lower() + mock_client.context_grounding.delete_index.assert_not_called() + + def test_delete_prompts_yes_deletes(self, runner, mock_client, mock_env_vars): + """delete without --confirm but answering 'y' at the prompt deletes the index.""" + index = _make_index() + mock_client.context_grounding.retrieve.return_value = index + mock_client.context_grounding.delete_index.return_value = None + + result = runner.invoke( + cli, + [ + "context-grounding", + "delete", + "--index", + "my-index", + "--folder-path", + "Shared", + ], + input="y\n", + ) + + assert result.exit_code == 0 + mock_client.context_grounding.delete_index.assert_called_once() + + def test_delete_with_folder_key(self, runner, mock_client, mock_env_vars): + """delete passes folder_key when --folder-key is provided.""" + index = _make_index() + mock_client.context_grounding.retrieve.return_value = index + mock_client.context_grounding.delete_index.return_value = None + folder_key = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + result = runner.invoke( + cli, + [ + "context-grounding", + "delete", + "--index", + "my-index", + "--folder-key", + folder_key, + "--confirm", + ], + ) + + assert result.exit_code == 0 + mock_client.context_grounding.retrieve.assert_called_once_with( + name="my-index", + folder_path=None, + folder_key=folder_key, + ) + mock_client.context_grounding.delete_index.assert_called_once_with( + index=index, + folder_path=None, + folder_key=folder_key, + ) + + def test_delete_index_has_no_id(self, runner, mock_client, mock_env_vars): + """delete raises a clean error if the retrieved index has no ID (avoids silent no-op).""" + index = _make_index() + index.id = None + mock_client.context_grounding.retrieve.return_value = index + + result = runner.invoke( + cli, + [ + "context-grounding", + "delete", + "--index", + "my-index", + "--folder-path", + "Shared", + "--confirm", + ], + ) + + assert result.exit_code != 0 + assert "no id" in result.output.lower() or "cannot" in result.output.lower() + mock_client.context_grounding.delete_index.assert_not_called() + + def test_delete_not_found(self, runner, mock_client, mock_env_vars): + """delete surfaces a clean not-found error.""" + mock_client.context_grounding.retrieve.side_effect = Exception( + "ContextGroundingIndex not found" + ) + + result = runner.invoke( + cli, + [ + "context-grounding", + "delete", + "--index", + "no-such-index", + "--folder-path", + "Shared", + "--confirm", + ], + ) + + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + def test_delete_missing_index_flag_fails(self, runner, mock_env_vars): + """delete without --index shows usage error.""" + result = runner.invoke( + cli, ["context-grounding", "delete", "--folder-path", "Shared", "--confirm"] + ) + + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# help text +# --------------------------------------------------------------------------- + + +class TestHelpText: + def test_group_help(self, runner): + """context-grounding group has correct help text.""" + result = runner.invoke(cli, ["context-grounding", "--help"]) + + assert result.exit_code == 0 + assert "retrieve" in result.output + assert "search" in result.output + assert "ingest" in result.output + assert "delete" in result.output + + def test_search_help(self, runner): + """search command exposes all expected options.""" + result = runner.invoke(cli, ["context-grounding", "search", "--help"]) + + assert result.exit_code == 0 + assert "--index" in result.output + assert "--limit" in result.output + assert "--folder-path" in result.output + assert "--folder-key" in result.output + assert "--format" in result.output + + def test_retrieve_help(self, runner): + """retrieve command exposes --index and folder options.""" + result = runner.invoke(cli, ["context-grounding", "retrieve", "--help"]) + + assert result.exit_code == 0 + assert "--index" in result.output + assert "--folder-path" in result.output + + def test_delete_help(self, runner): + """delete command exposes --confirm and --dry-run.""" + result = runner.invoke(cli, ["context-grounding", "delete", "--help"]) + + assert result.exit_code == 0 + assert "--confirm" in result.output + assert "--dry-run" in result.output diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 6c70be260..252a1c998 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" [[package]] @@ -2543,7 +2543,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.35" +version = "2.10.36" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.13" +version = "0.1.14" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },