From 86a8f2dcaec4c68b10f073956df622027108a652 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Fri, 20 Feb 2026 14:29:14 +0200 Subject: [PATCH 1/5] wip: uipath-platform --- pyproject.toml | 12 +- src/uipath/_cli/_auth/_portal_service.py | 2 +- src/uipath/_cli/_auth/_utils.py | 3 +- src/uipath/_cli/_debug/_bridge.py | 3 +- src/uipath/_cli/_evals/mocks/input_mocker.py | 12 +- src/uipath/_cli/_evals/mocks/llm_mocker.py | 11 +- src/uipath/_cli/_utils/_common.py | 42 +- src/uipath/_cli/cli_debug.py | 2 +- .../legacy_context_precision_evaluator.py | 7 +- .../legacy_faithfulness_evaluator.py | 10 +- .../legacy_llm_as_judge_evaluator.py | 7 +- .../evaluators/legacy_trajectory_evaluator.py | 9 +- .../eval/evaluators/llm_as_judge_evaluator.py | 9 +- src/uipath/platform/__init__.py | 40 - src/uipath/platform/_uipath.py | 169 - src/uipath/platform/action_center/__init__.py | 14 - .../platform/action_center/_tasks_service.py | 689 ---- .../platform/action_center/task_schema.py | 30 - src/uipath/platform/action_center/tasks.py | 106 - src/uipath/platform/agenthub/__init__.py | 8 - .../platform/agenthub/_agenthub_service.py | 202 - src/uipath/platform/agenthub/agenthub.py | 18 - src/uipath/platform/attachments/__init__.py | 12 - .../platform/attachments/attachments.py | 44 - src/uipath/platform/chat/__init__.py | 63 - .../platform/chat/_conversations_service.py | 50 - .../platform/chat/_llm_gateway_service.py | 693 ---- src/uipath/platform/chat/llm_gateway.py | 128 - src/uipath/platform/chat/llm_throttle.py | 49 - src/uipath/platform/common/__init__.py | 63 - src/uipath/platform/common/_api_client.py | 59 - src/uipath/platform/common/_base_service.py | 192 - src/uipath/platform/common/_config.py | 136 - .../platform/common/_execution_context.py | 106 - .../common/_external_application_service.py | 140 - src/uipath/platform/common/_folder_context.py | 53 - src/uipath/platform/common/auth.py | 16 - .../platform/common/interrupt_models.py | 225 - src/uipath/platform/common/paging.py | 63 - src/uipath/platform/connections/__init__.py | 26 - .../connections/_connections_service.py | 811 ---- .../platform/connections/connections.py | 108 - .../platform/context_grounding/__init__.py | 71 - .../_context_grounding_service.py | 1883 --------- .../context_grounding/context_grounding.py | 209 - .../context_grounding_index.py | 76 - .../context_grounding_payloads.py | 234 -- src/uipath/platform/documents/__init__.py | 50 - .../platform/documents/_documents_service.py | 2551 ------------ src/uipath/platform/documents/documents.py | 298 -- src/uipath/platform/entities/__init__.py | 36 - .../platform/entities/_entities_service.py | 902 ---- src/uipath/platform/entities/entities.py | 325 -- src/uipath/platform/errors/__init__.py | 37 - .../errors/_base_url_missing_error.py | 13 - ..._batch_transform_not_complete_exception.py | 13 - .../platform/errors/_enriched_exception.py | 38 - .../errors/_folder_not_found_exception.py | 13 - .../_ingestion_in_progress_exception.py | 17 - .../errors/_operation_failed_exception.py | 19 - .../_operation_not_complete_exception.py | 14 - .../platform/errors/_secret_missing_error.py | 13 - .../_unsupported_data_source_exception.py | 17 - src/uipath/platform/guardrails/__init__.py | 36 - .../guardrails/_guardrails_service.py | 133 - src/uipath/platform/guardrails/guardrails.py | 62 - src/uipath/platform/orchestrator/__init__.py | 53 - .../platform/orchestrator/_assets_service.py | 555 --- .../orchestrator/_attachments_service.py | 1020 ----- .../platform/orchestrator/_buckets_service.py | 1799 -------- .../platform/orchestrator/_folder_service.py | 220 - .../platform/orchestrator/_jobs_service.py | 1485 ------- .../platform/orchestrator/_mcp_service.py | 225 - .../orchestrator/_processes_service.py | 329 -- .../platform/orchestrator/_queues_service.py | 352 -- src/uipath/platform/orchestrator/assets.py | 73 - .../platform/orchestrator/attachment.py | 36 - src/uipath/platform/orchestrator/buckets.py | 80 - src/uipath/platform/orchestrator/folder.py | 15 - src/uipath/platform/orchestrator/job.py | 83 - src/uipath/platform/orchestrator/mcp.py | 56 - src/uipath/platform/orchestrator/processes.py | 49 - src/uipath/platform/orchestrator/queues.py | 204 - .../platform/resource_catalog/__init__.py | 15 - .../_resource_catalog_service.py | 630 --- .../resource_catalog/resource_catalog.py | 124 - .../platform/resume_triggers/__init__.py | 17 - src/uipath/platform/resume_triggers/_enums.py | 55 - .../platform/resume_triggers/_protocol.py | 892 ---- src/uipath/tracing/__init__.py | 4 - src/uipath/tracing/_otel_exporters.py | 3 +- src/uipath/tracing/_utils.py | 401 -- src/uipath/utils/__init__.py | 5 - src/uipath/utils/_endpoints_manager.py | 198 - src/uipath/utils/dynamic_schema.py | 128 - tests/cli/test_hitl.py | 17 +- tests/cli/test_utils.py | 3 +- tests/cli/utils/test_dynamic_schema.py | 105 - tests/sdk/services/conftest.py | 56 - tests/sdk/services/test_actions_service.py | 179 - tests/sdk/services/test_api_client.py | 92 - tests/sdk/services/test_assets_service.py | 675 --- .../sdk/services/test_attachments_service.py | 1195 ------ tests/sdk/services/test_base_service.py | 102 - tests/sdk/services/test_buckets_service.py | 1939 --------- .../sdk/services/test_connections_service.py | 1775 -------- .../test_context_grounding_service.py | 2424 ----------- .../services/test_conversations_service.py | 166 - tests/sdk/services/test_documents_service.py | 3644 ----------------- tests/sdk/services/test_entities_service.py | 262 -- .../test_external_application_service.py | 114 - tests/sdk/services/test_folder_service.py | 501 --- tests/sdk/services/test_guardrails_service.py | 300 -- tests/sdk/services/test_jobs_service.py | 1392 ------- .../test_jobs_service_bulk_operations.py | 213 - .../services/test_jobs_service_pagination.py | 221 - tests/sdk/services/test_llm_integration.py | 120 - tests/sdk/services/test_llm_schema_cleanup.py | 229 -- tests/sdk/services/test_llm_service.py | 545 --- tests/sdk/services/test_llm_throttle.py | 429 -- tests/sdk/services/test_mcp_service.py | 541 --- tests/sdk/services/test_processes_service.py | 473 --- tests/sdk/services/test_queues_service.py | 808 ---- .../services/test_resource_catalog_service.py | 861 ---- .../services/test_uipath_llm_integration.py | 510 --- .../classification_response.json | 23 - ..._validation_action_response_completed.json | 1277 ------ ...validation_action_response_unassigned.json | 29 - .../ixp_extraction_response.json | 1078 ----- .../modern_extraction_response.json | 192 - tests/tracing/test_span_utils.py | 429 -- uv.lock | 33 +- 132 files changed, 97 insertions(+), 43463 deletions(-) delete mode 100644 src/uipath/platform/__init__.py delete mode 100644 src/uipath/platform/_uipath.py delete mode 100644 src/uipath/platform/action_center/__init__.py delete mode 100644 src/uipath/platform/action_center/_tasks_service.py delete mode 100644 src/uipath/platform/action_center/task_schema.py delete mode 100644 src/uipath/platform/action_center/tasks.py delete mode 100644 src/uipath/platform/agenthub/__init__.py delete mode 100644 src/uipath/platform/agenthub/_agenthub_service.py delete mode 100644 src/uipath/platform/agenthub/agenthub.py delete mode 100644 src/uipath/platform/attachments/__init__.py delete mode 100644 src/uipath/platform/attachments/attachments.py delete mode 100644 src/uipath/platform/chat/__init__.py delete mode 100644 src/uipath/platform/chat/_conversations_service.py delete mode 100644 src/uipath/platform/chat/_llm_gateway_service.py delete mode 100644 src/uipath/platform/chat/llm_gateway.py delete mode 100644 src/uipath/platform/chat/llm_throttle.py delete mode 100644 src/uipath/platform/common/__init__.py delete mode 100644 src/uipath/platform/common/_api_client.py delete mode 100644 src/uipath/platform/common/_base_service.py delete mode 100644 src/uipath/platform/common/_config.py delete mode 100644 src/uipath/platform/common/_execution_context.py delete mode 100644 src/uipath/platform/common/_external_application_service.py delete mode 100644 src/uipath/platform/common/_folder_context.py delete mode 100644 src/uipath/platform/common/auth.py delete mode 100644 src/uipath/platform/common/interrupt_models.py delete mode 100644 src/uipath/platform/common/paging.py delete mode 100644 src/uipath/platform/connections/__init__.py delete mode 100644 src/uipath/platform/connections/_connections_service.py delete mode 100644 src/uipath/platform/connections/connections.py delete mode 100644 src/uipath/platform/context_grounding/__init__.py delete mode 100644 src/uipath/platform/context_grounding/_context_grounding_service.py delete mode 100644 src/uipath/platform/context_grounding/context_grounding.py delete mode 100644 src/uipath/platform/context_grounding/context_grounding_index.py delete mode 100644 src/uipath/platform/context_grounding/context_grounding_payloads.py delete mode 100644 src/uipath/platform/documents/__init__.py delete mode 100644 src/uipath/platform/documents/_documents_service.py delete mode 100644 src/uipath/platform/documents/documents.py delete mode 100644 src/uipath/platform/entities/__init__.py delete mode 100644 src/uipath/platform/entities/_entities_service.py delete mode 100644 src/uipath/platform/entities/entities.py delete mode 100644 src/uipath/platform/errors/__init__.py delete mode 100644 src/uipath/platform/errors/_base_url_missing_error.py delete mode 100644 src/uipath/platform/errors/_batch_transform_not_complete_exception.py delete mode 100644 src/uipath/platform/errors/_enriched_exception.py delete mode 100644 src/uipath/platform/errors/_folder_not_found_exception.py delete mode 100644 src/uipath/platform/errors/_ingestion_in_progress_exception.py delete mode 100644 src/uipath/platform/errors/_operation_failed_exception.py delete mode 100644 src/uipath/platform/errors/_operation_not_complete_exception.py delete mode 100644 src/uipath/platform/errors/_secret_missing_error.py delete mode 100644 src/uipath/platform/errors/_unsupported_data_source_exception.py delete mode 100644 src/uipath/platform/guardrails/__init__.py delete mode 100644 src/uipath/platform/guardrails/_guardrails_service.py delete mode 100644 src/uipath/platform/guardrails/guardrails.py delete mode 100644 src/uipath/platform/orchestrator/__init__.py delete mode 100644 src/uipath/platform/orchestrator/_assets_service.py delete mode 100644 src/uipath/platform/orchestrator/_attachments_service.py delete mode 100644 src/uipath/platform/orchestrator/_buckets_service.py delete mode 100644 src/uipath/platform/orchestrator/_folder_service.py delete mode 100644 src/uipath/platform/orchestrator/_jobs_service.py delete mode 100644 src/uipath/platform/orchestrator/_mcp_service.py delete mode 100644 src/uipath/platform/orchestrator/_processes_service.py delete mode 100644 src/uipath/platform/orchestrator/_queues_service.py delete mode 100644 src/uipath/platform/orchestrator/assets.py delete mode 100644 src/uipath/platform/orchestrator/attachment.py delete mode 100644 src/uipath/platform/orchestrator/buckets.py delete mode 100644 src/uipath/platform/orchestrator/folder.py delete mode 100644 src/uipath/platform/orchestrator/job.py delete mode 100644 src/uipath/platform/orchestrator/mcp.py delete mode 100644 src/uipath/platform/orchestrator/processes.py delete mode 100644 src/uipath/platform/orchestrator/queues.py delete mode 100644 src/uipath/platform/resource_catalog/__init__.py delete mode 100644 src/uipath/platform/resource_catalog/_resource_catalog_service.py delete mode 100644 src/uipath/platform/resource_catalog/resource_catalog.py delete mode 100644 src/uipath/platform/resume_triggers/__init__.py delete mode 100644 src/uipath/platform/resume_triggers/_enums.py delete mode 100644 src/uipath/platform/resume_triggers/_protocol.py delete mode 100644 src/uipath/tracing/_utils.py delete mode 100644 src/uipath/utils/__init__.py delete mode 100644 src/uipath/utils/_endpoints_manager.py delete mode 100644 src/uipath/utils/dynamic_schema.py delete mode 100644 tests/cli/utils/test_dynamic_schema.py delete mode 100644 tests/sdk/services/conftest.py delete mode 100644 tests/sdk/services/test_actions_service.py delete mode 100644 tests/sdk/services/test_api_client.py delete mode 100644 tests/sdk/services/test_assets_service.py delete mode 100644 tests/sdk/services/test_attachments_service.py delete mode 100644 tests/sdk/services/test_base_service.py delete mode 100644 tests/sdk/services/test_buckets_service.py delete mode 100644 tests/sdk/services/test_connections_service.py delete mode 100644 tests/sdk/services/test_context_grounding_service.py delete mode 100644 tests/sdk/services/test_conversations_service.py delete mode 100644 tests/sdk/services/test_documents_service.py delete mode 100644 tests/sdk/services/test_entities_service.py delete mode 100644 tests/sdk/services/test_external_application_service.py delete mode 100644 tests/sdk/services/test_folder_service.py delete mode 100644 tests/sdk/services/test_guardrails_service.py delete mode 100644 tests/sdk/services/test_jobs_service.py delete mode 100644 tests/sdk/services/test_jobs_service_bulk_operations.py delete mode 100644 tests/sdk/services/test_jobs_service_pagination.py delete mode 100644 tests/sdk/services/test_llm_integration.py delete mode 100644 tests/sdk/services/test_llm_schema_cleanup.py delete mode 100644 tests/sdk/services/test_llm_service.py delete mode 100644 tests/sdk/services/test_llm_throttle.py delete mode 100644 tests/sdk/services/test_mcp_service.py delete mode 100644 tests/sdk/services/test_processes_service.py delete mode 100644 tests/sdk/services/test_queues_service.py delete mode 100644 tests/sdk/services/test_resource_catalog_service.py delete mode 100644 tests/sdk/services/test_uipath_llm_integration.py delete mode 100644 tests/sdk/services/tests_data/documents_service/classification_response.json delete mode 100644 tests/sdk/services/tests_data/documents_service/extraction_validation_action_response_completed.json delete mode 100644 tests/sdk/services/tests_data/documents_service/extraction_validation_action_response_unassigned.json delete mode 100644 tests/sdk/services/tests_data/documents_service/ixp_extraction_response.json delete mode 100644 tests/sdk/services/tests_data/documents_service/modern_extraction_response.json delete mode 100644 tests/tracing/test_span_utils.py diff --git a/pyproject.toml b/pyproject.toml index da1379f73..0d658fd8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.8.46" +version = "2.9.0" 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" @@ -23,6 +23,7 @@ dependencies = [ "mermaid-builder==0.0.3", "graphtty==0.1.8", "applicationinsights>=0.11.10", + "uipath-platform==0.0.1.dev1000020013", ] classifiers = [ "Intended Audience :: Developers", @@ -148,3 +149,12 @@ name = "testpypi" url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true + +[tool.uv.sources] +uipath-platform = { index = "testpypi" } +uipath-core = { index = "testpypi" } + +[tool.uv] +override-dependencies = [ + "uipath-core==0.5.1.dev1000450221" +] diff --git a/src/uipath/_cli/_auth/_portal_service.py b/src/uipath/_cli/_auth/_portal_service.py index d50b3a38d..d58a52da1 100644 --- a/src/uipath/_cli/_auth/_portal_service.py +++ b/src/uipath/_cli/_auth/_portal_service.py @@ -2,6 +2,7 @@ import click import httpx +from uipath.platform.common import TokenData from uipath.runtime.errors import ( UiPathErrorCategory, UiPathErrorCode, @@ -10,7 +11,6 @@ from ..._utils._auth import update_env_file from ..._utils._ssl_context import get_httpx_client_kwargs -from ...platform.common import TokenData from .._utils._console import ConsoleLogger from ._models import OrganizationInfo, TenantInfo, TenantsAndOrganizationInfoResponse from ._oidc_utils import OidcUtils diff --git a/src/uipath/_cli/_auth/_utils.py b/src/uipath/_cli/_auth/_utils.py index cd3628075..86f0cddc0 100644 --- a/src/uipath/_cli/_auth/_utils.py +++ b/src/uipath/_cli/_auth/_utils.py @@ -2,8 +2,9 @@ import os from pathlib import Path +from uipath.platform.common import TokenData + from ..._utils._auth import parse_access_token -from ...platform.common import TokenData from ._models import AccessTokenData diff --git a/src/uipath/_cli/_debug/_bridge.py b/src/uipath/_cli/_debug/_bridge.py index b08e552eb..05cc83f8a 100644 --- a/src/uipath/_cli/_debug/_bridge.py +++ b/src/uipath/_cli/_debug/_bridge.py @@ -10,6 +10,7 @@ from pydantic import BaseModel from rich.console import Console from rich.tree import Tree +from uipath.core.serialization import serialize_object from uipath.runtime import ( UiPathBreakpointResult, UiPathRuntimeContext, @@ -20,8 +21,6 @@ from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase from uipath.runtime.resumable import UiPathResumeTriggerType -from uipath._cli._utils._common import serialize_object - logger = logging.getLogger(__name__) diff --git a/src/uipath/_cli/_evals/mocks/input_mocker.py b/src/uipath/_cli/_evals/mocks/input_mocker.py index 964f1801f..9de60c3fe 100644 --- a/src/uipath/_cli/_evals/mocks/input_mocker.py +++ b/src/uipath/_cli/_evals/mocks/input_mocker.py @@ -4,10 +4,11 @@ from datetime import datetime from typing import Any +from uipath.platform import UiPath + from uipath._cli._evals.mocks.types import ( InputMockingStrategy, ) -from uipath.platform import UiPath from uipath.tracing import traced from .mocker import UiPathInputMockingError @@ -65,11 +66,16 @@ async def generate_llm_input( from .mocks import cache_manager_context try: - llm = UiPath( + from uipath.platform.chat import UiPathLlmChatService + + uipath = UiPath() + llm = UiPathLlmChatService( + uipath._config, + uipath._execution_context, requesting_product="agentsplayground", requesting_feature="agents-evaluations", agenthub_config="agentsevals", - ).llm + ) cache_manager = cache_manager_context.get() # Ensure additionalProperties is set for strict mode compatibility diff --git a/src/uipath/_cli/_evals/mocks/llm_mocker.py b/src/uipath/_cli/_evals/mocks/llm_mocker.py index aadfb45e9..92194211a 100644 --- a/src/uipath/_cli/_evals/mocks/llm_mocker.py +++ b/src/uipath/_cli/_evals/mocks/llm_mocker.py @@ -5,13 +5,12 @@ from typing import Any, Callable from pydantic import BaseModel, TypeAdapter +from uipath.core.tracing import _SpanUtils, traced from uipath._cli._evals.mocks.types import ( LLMMockingStrategy, MockingContext, ) -from uipath.tracing import traced -from uipath.tracing._utils import _SpanUtils from .._models._mocks import ExampleCall from .mocker import ( @@ -92,6 +91,7 @@ async def response( function_name = params.get("name") or func.__name__ if function_name in [x.name for x in self.context.strategy.tools_to_simulate]: from uipath.platform import UiPath + from uipath.platform.chat import UiPathLlmChatService from uipath.platform.chat._llm_gateway_service import _cleanup_schema from .mocks import ( @@ -101,11 +101,14 @@ async def response( span_collector_context, ) - llm = UiPath( + uipath = UiPath() + llm = UiPathLlmChatService( + uipath._config, + uipath._execution_context, requesting_product="agentsplayground", requesting_feature="agents-evaluations", agenthub_config="agentsevals", - ).llm + ) return_type: Any = func.__annotations__.get("return", None) if return_type is None: return_type = Any diff --git a/src/uipath/_cli/_utils/_common.py b/src/uipath/_cli/_utils/_common.py index ef1dcf793..8fbb660ad 100644 --- a/src/uipath/_cli/_utils/_common.py +++ b/src/uipath/_cli/_utils/_common.py @@ -1,14 +1,12 @@ import json import logging import os -import uuid -from datetime import date, datetime, time +from datetime import datetime from pathlib import Path from typing import Literal from urllib.parse import urlparse import click - from uipath.platform.common import UiPathConfig from ..._utils._bindings import ResourceOverwrite, ResourceOverwriteParser @@ -73,43 +71,6 @@ def get_env_vars(spinner: Spinner | None = None) -> list[str]: return [base_url, token] -def serialize_object(obj): - """Recursively serializes an object and all its nested components.""" - # Handle Pydantic models - if hasattr(obj, "model_dump"): - return serialize_object(obj.model_dump(by_alias=True)) - elif hasattr(obj, "dict"): - return serialize_object(obj.dict()) - elif hasattr(obj, "to_dict"): - return serialize_object(obj.to_dict()) - # Special handling for UiPathBaseRuntimeErrors - elif hasattr(obj, "as_dict"): - return serialize_object(obj.as_dict) - elif isinstance(obj, (datetime, date, time)): - return obj.isoformat() - # Handle dictionaries - elif isinstance(obj, dict): - return {k: serialize_object(v) for k, v in obj.items()} - # Handle lists - elif isinstance(obj, list): - return [serialize_object(item) for item in obj] - # Handle exceptions - elif isinstance(obj, Exception): - return str(obj) - # Handle other iterable objects (convert to dict first) - elif hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes)): - try: - return serialize_object(dict(obj)) - except (TypeError, ValueError): - return obj - # UUIDs must be serialized explicitly - elif isinstance(obj, uuid.UUID): - return str(obj) - # Return primitive types as is - else: - return obj - - def get_org_scoped_url(base_url: str) -> str: """Get organization scoped URL from base URL. @@ -154,7 +115,6 @@ async def ensure_coded_agent_project(studio_client: StudioClient): async def may_override_files( studio_client: StudioClient, scope: Literal["remote", "local"] ) -> bool: - from datetime import datetime from packaging import version diff --git a/src/uipath/_cli/cli_debug.py b/src/uipath/_cli/cli_debug.py index 87bad3233..21c04f799 100644 --- a/src/uipath/_cli/cli_debug.py +++ b/src/uipath/_cli/cli_debug.py @@ -6,6 +6,7 @@ import click from uipath.core.tracing import UiPathTraceManager +from uipath.platform.common import UiPathConfig from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeContext, @@ -32,7 +33,6 @@ from uipath._cli._utils._debug import setup_debugging from uipath._cli._utils._studio_project import StudioClient from uipath._utils._bindings import ResourceOverwritesContext -from uipath.platform.common import UiPathConfig from uipath.tracing import LiveTrackingSpanProcessor, LlmOpsHttpExporter from ._utils._console import ConsoleLogger diff --git a/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py b/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py index 848f74dc6..50059bf58 100644 --- a/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py +++ b/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py @@ -109,13 +109,16 @@ def model_post_init(self, __context: Any): def _initialize_llm(self): """Initialize the LLM used for evaluation.""" from uipath.platform import UiPath + from uipath.platform.chat import UiPathLlmChatService - uipath = UiPath( + uipath = UiPath() + self.llm = UiPathLlmChatService( + uipath._config, + uipath._execution_context, requesting_product="agentsplayground", requesting_feature="agents-evaluations", agenthub_config="agentsevals", ) - self.llm = uipath.llm @track_evaluation_metrics async def evaluate( diff --git a/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py b/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py index 1d2ea63aa..640390298 100644 --- a/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py +++ b/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py @@ -3,9 +3,10 @@ import json from typing import Any, Optional -from uipath.eval.models import NumericEvaluationResult from uipath.platform.chat import UiPathLlmChatService +from uipath.eval.models import NumericEvaluationResult + from ..models.models import AgentExecution, EvaluationResult from .base_legacy_evaluator import ( BaseLegacyEvaluator, @@ -47,13 +48,16 @@ def model_post_init(self, __context: Any): def _initialize_llm(self): """Initialize the LLM used for evaluation.""" from uipath.platform import UiPath + from uipath.platform.chat import UiPathLlmChatService - uipath = UiPath( + uipath = UiPath() + self.llm = UiPathLlmChatService( + uipath._config, + uipath._execution_context, requesting_product="agentsplayground", requesting_feature="agents-evaluations", agenthub_config="agentsevals", ) - self.llm = uipath.llm @track_evaluation_metrics async def evaluate( diff --git a/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py b/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py index 1833f2e31..eaec8e184 100644 --- a/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py +++ b/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py @@ -91,13 +91,16 @@ def model_post_init(self, __context: Any): def _initialize_llm(self): """Initialize the LLM used for evaluation.""" from uipath.platform import UiPath + from uipath.platform.chat import UiPathLlmChatService - uipath = UiPath( + uipath = UiPath() + self.llm = UiPathLlmChatService( + uipath._config, + uipath._execution_context, requesting_product="agentsplayground", requesting_feature="agents-evaluations", agenthub_config="agentsevals", ) - self.llm = uipath.llm async def evaluate( self, diff --git a/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py b/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py index fa07f89c9..98dfa308c 100644 --- a/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py +++ b/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py @@ -5,11 +5,11 @@ from opentelemetry.sdk.trace import ReadableSpan from pydantic import field_validator +from uipath.platform.chat import UiPathLlmChatService from uipath.eval.models import EvaluationResult from ..._utils.constants import COMMUNITY_agents_SUFFIX -from ...platform.chat import UiPathLlmChatService from ...platform.chat.llm_gateway import RequiredToolChoice from .._helpers.helpers import is_empty_value from ..models.models import ( @@ -62,13 +62,16 @@ def model_post_init(self, __context: Any): def _initialize_llm(self): """Initialize the LLM used for evaluation.""" from uipath.platform import UiPath + from uipath.platform.chat import UiPathLlmChatService - uipath = UiPath( + uipath = UiPath() + self.llm = UiPathLlmChatService( + uipath._config, + uipath._execution_context, requesting_product="agentsplayground", requesting_feature="agents-evaluations", agenthub_config="agentsevals", ) - self.llm = uipath.llm async def evaluate( self, diff --git a/src/uipath/eval/evaluators/llm_as_judge_evaluator.py b/src/uipath/eval/evaluators/llm_as_judge_evaluator.py index ab7abf603..a12d1bd39 100644 --- a/src/uipath/eval/evaluators/llm_as_judge_evaluator.py +++ b/src/uipath/eval/evaluators/llm_as_judge_evaluator.py @@ -118,15 +118,18 @@ def _get_llm_service(self): which includes multi-vendor models that agents use. """ from uipath.platform import UiPath + from uipath.platform.chat import UiPathLlmChatService try: - uipath = UiPath( + uipath = UiPath() + llm = UiPathLlmChatService( + uipath._config, + uipath._execution_context, requesting_product="agentsplayground", requesting_feature="agents-evaluations", agenthub_config="agentsevals", ) - # Use llm (normalized API) for multi-vendor model support - return uipath.llm.chat_completions + return llm.chat_completions except Exception as e: raise UiPathEvaluationError( code="FAILED_TO_GET_LLM_SERVICE", diff --git a/src/uipath/platform/__init__.py b/src/uipath/platform/__init__.py deleted file mode 100644 index 61abb103c..000000000 --- a/src/uipath/platform/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""UiPath SDK for Python. - -This package provides a Python interface to interact with UiPath's automation platform. - - -The main entry point is the UiPath class, which provides access to all SDK functionality. - -Example: -```python - # First set these environment variables: - # export UIPATH_URL="https://cloud.uipath.com/organization-name/default-tenant" - # export UIPATH_ACCESS_TOKEN="your_**_token" - # export UIPATH_FOLDER_PATH="your/folder/path" - - from uipath.platform import UiPath - sdk = UiPath() - # Invoke a process by name - sdk.processes.invoke("MyProcess") -``` - -## Error Handling - -Exception classes are available in the `errors` module and should be imported explicitly: - -```python - from uipath.platform.errors import ( - BaseUrlMissingError, - SecretMissingError, - EnrichedException, - IngestionInProgressException, - FolderNotFoundException, - UnsupportedDataSourceException, - ) -``` -""" - -from ._uipath import UiPath -from .common import UiPathApiConfig, UiPathExecutionContext - -__all__ = ["UiPathApiConfig", "UiPath", "UiPathExecutionContext"] diff --git a/src/uipath/platform/_uipath.py b/src/uipath/platform/_uipath.py deleted file mode 100644 index 044d6f497..000000000 --- a/src/uipath/platform/_uipath.py +++ /dev/null @@ -1,169 +0,0 @@ -from functools import cached_property -from typing import Optional - -from pydantic import ValidationError - -from .._utils._auth import resolve_config_from_env -from .action_center import TasksService -from .agenthub._agenthub_service import AgentHubService -from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService -from .common import ( - ApiClient, - ExternalApplicationService, - UiPathApiConfig, - UiPathExecutionContext, -) -from .connections import ConnectionsService -from .context_grounding import ContextGroundingService -from .documents import DocumentsService -from .entities import EntitiesService -from .errors import BaseUrlMissingError, SecretMissingError -from .guardrails import GuardrailsService -from .orchestrator import ( - AssetsService, - AttachmentsService, - BucketsService, - FolderService, - JobsService, - McpService, - ProcessesService, - QueuesService, -) -from .resource_catalog import ResourceCatalogService - - -def _has_valid_client_credentials( - client_id: Optional[str], client_secret: Optional[str] -) -> bool: - if bool(client_id) != bool(client_secret): - raise ValueError("Both client_id and client_secret must be provided together.") - return bool(client_id and client_secret) - - -class UiPath: - def __init__( - self, - *, - base_url: Optional[str] = None, - secret: Optional[str] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - scope: Optional[str] = None, - debug: bool = False, - requesting_product: Optional[str] = None, - requesting_feature: Optional[str] = None, - agenthub_config: Optional[str] = None, - action_id: Optional[str] = None, - ) -> None: - try: - if _has_valid_client_credentials(client_id, client_secret): - assert client_id and client_secret - service = ExternalApplicationService(base_url) - token_data = service.get_token_data(client_id, client_secret, scope) - base_url, secret = service._base_url, token_data.access_token - else: - base_url, secret = resolve_config_from_env(base_url, secret) - - self._config = UiPathApiConfig( - base_url=base_url, - secret=secret, - ) - except ValidationError as e: - for error in e.errors(): - if error["loc"][0] == "base_url": - raise BaseUrlMissingError() from e - elif error["loc"][0] == "secret": - raise SecretMissingError() from e - self._execution_context = UiPathExecutionContext( - requesting_product=requesting_product, - requesting_feature=requesting_feature, - agenthub_config=agenthub_config, - action_id=action_id, - ) - - @property - def api_client(self) -> ApiClient: - return ApiClient(self._config, self._execution_context) - - @property - def assets(self) -> AssetsService: - return AssetsService(self._config, self._execution_context) - - @cached_property - def attachments(self) -> AttachmentsService: - return AttachmentsService(self._config, self._execution_context) - - @property - def processes(self) -> ProcessesService: - return ProcessesService(self._config, self._execution_context, self.attachments) - - @property - def tasks(self) -> TasksService: - return TasksService(self._config, self._execution_context) - - @cached_property - def buckets(self) -> BucketsService: - return BucketsService(self._config, self._execution_context) - - @cached_property - def connections(self) -> ConnectionsService: - return ConnectionsService(self._config, self._execution_context, self.folders) - - @property - def context_grounding(self) -> ContextGroundingService: - return ContextGroundingService( - self._config, - self._execution_context, - self.folders, - self.buckets, - ) - - @property - def documents(self) -> DocumentsService: - return DocumentsService(self._config, self._execution_context) - - @property - def queues(self) -> QueuesService: - return QueuesService(self._config, self._execution_context) - - @property - def jobs(self) -> JobsService: - return JobsService(self._config, self._execution_context) - - @cached_property - def folders(self) -> FolderService: - return FolderService(self._config, self._execution_context) - - @property - def llm_openai(self) -> UiPathOpenAIService: - return UiPathOpenAIService(self._config, self._execution_context) - - @property - def llm(self) -> UiPathLlmChatService: - return UiPathLlmChatService(self._config, self._execution_context) - - @property - def entities(self) -> EntitiesService: - return EntitiesService(self._config, self._execution_context) - - @cached_property - def resource_catalog(self) -> ResourceCatalogService: - return ResourceCatalogService( - self._config, self._execution_context, self.folders - ) - - @property - def conversational(self) -> ConversationsService: - return ConversationsService(self._config, self._execution_context) - - @property - def mcp(self) -> McpService: - return McpService(self._config, self._execution_context, self.folders) - - @property - def guardrails(self) -> GuardrailsService: - return GuardrailsService(self._config, self._execution_context) - - @property - def agenthub(self) -> AgentHubService: - return AgentHubService(self._config, self._execution_context, self.folders) diff --git a/src/uipath/platform/action_center/__init__.py b/src/uipath/platform/action_center/__init__.py deleted file mode 100644 index 675f50eb6..000000000 --- a/src/uipath/platform/action_center/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""UiPath Action Center Models. - -This module contains models related to UiPath Action Center service. -""" - -from ._tasks_service import TasksService -from .task_schema import TaskSchema -from .tasks import Task - -__all__ = [ - "TasksService", - "Task", - "TaskSchema", -] diff --git a/src/uipath/platform/action_center/_tasks_service.py b/src/uipath/platform/action_center/_tasks_service.py deleted file mode 100644 index 776700a5b..000000000 --- a/src/uipath/platform/action_center/_tasks_service.py +++ /dev/null @@ -1,689 +0,0 @@ -import asyncio -import os -import uuid -from typing import Any, Dict, List, Optional, Tuple - -from ..._utils import Endpoint, RequestSpec, resource_override -from ..._utils.constants import ( - ENV_TENANT_ID, - HEADER_FOLDER_KEY, - HEADER_FOLDER_PATH, - HEADER_TENANT_ID, -) -from ...tracing import traced -from ..common import ( - BaseService, - FolderContext, - UiPathApiConfig, - UiPathConfig, - UiPathExecutionContext, -) -from .task_schema import TaskSchema -from .tasks import Task, TaskRecipient, TaskRecipientType - - -def _ensure_string_value(value: Any) -> str: - """Convert any value to a string for use in field Value.""" - if isinstance(value, str): - return value - return str(value) if value else "" - - -def _create_spec( - data: Optional[Dict[str, Any]], - action_schema: Optional[TaskSchema], - title: str, - app_key: Optional[str] = None, - app_folder_key: Optional[str] = None, - app_folder_path: Optional[str] = None, - priority: Optional[str] = None, - labels: Optional[List[str]] = None, - is_actionable_message_enabled: Optional[bool] = None, - actionable_message_metadata: Optional[Dict[str, Any]] = None, - source_name: str = "Agent", -) -> RequestSpec: - field_list = [] - outcome_list = [] - if action_schema: - if action_schema.inputs: - for input_field in action_schema.inputs: - field_name = input_field.name - field_value = data.get(field_name, "") if data is not None else "" - field_list.append( - { - "Id": input_field.key, - "Name": field_name, - "Title": field_name, - "Type": "Fact", - "Value": _ensure_string_value(field_value), - } - ) - if action_schema.outputs: - for output_field in action_schema.outputs: - field_name = output_field.name - field_list.append( - { - "Id": output_field.key, - "Name": field_name, - "Title": field_name, - "Type": "Fact", - "Value": "", - } - ) - if action_schema.in_outs: - for inout_field in action_schema.in_outs: - field_name = inout_field.name - field_value = data.get(field_name, "") if data is not None else "" - field_list.append( - { - "Id": inout_field.key, - "Name": field_name, - "Title": field_name, - "Type": "Fact", - "Value": _ensure_string_value(field_value), - } - ) - if action_schema.outcomes: - for outcome in action_schema.outcomes: - outcome_list.append( - { - "Id": action_schema.key, - "Name": outcome.name, - "Title": outcome.name, - "Type": "Action.Http", - "IsPrimary": True, - } - ) - - json_payload: Dict[str, Any] = { - "appId": app_key, - "title": title, - "data": data if data is not None else {}, - "actionableMessageMetaData": actionable_message_metadata - if actionable_message_metadata is not None - else ( - { - "fieldSet": { - "id": str(uuid.uuid4()), - "fields": field_list, - } - if len(field_list) != 0 - else {}, - "actionSet": { - "id": str(uuid.uuid4()), - "actions": outcome_list, - } - if len(outcome_list) != 0 - else {}, - } - if action_schema is not None - else {} - ), - } - - if priority and (normalized_priority := _normalize_priority(priority)): - json_payload["priority"] = normalized_priority - if labels is not None: - json_payload["tags"] = [ - { - "name": label, - "displayName": label, - "value": label, - "displayValue": label, - } - for label in labels - ] - if is_actionable_message_enabled is not None: - json_payload["isActionableMessageEnabled"] = is_actionable_message_enabled - - project_id = UiPathConfig.project_id - trace_id = UiPathConfig.trace_id - - if project_id and trace_id: - folder_key = UiPathConfig.folder_key - job_key = UiPathConfig.job_key - process_key = UiPathConfig.process_uuid - - task_source_metadata: Dict[str, Any] = { - "InstanceId": trace_id, - "FolderKey": folder_key, - "JobKey": job_key, - "ProcessKey": process_key, - } - - task_source = { - "sourceName": source_name, - "sourceId": project_id, - "taskSourceMetadata": task_source_metadata, - } - - json_payload["taskSource"] = task_source - - return RequestSpec( - method="POST", - endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), - json=json_payload, - headers=folder_headers(app_folder_key, app_folder_path), - ) - - -def _normalize_priority(priority: str | None) -> str | None: - """Normalize priority string to match API expectations. - - Converts case-insensitive priority strings to the proper capitalized format - expected by the Orchestrator API. - - Args: - priority: Priority string (case-insensitive: "low", "HIGH", "MeDiUm", etc.) - - Returns: - Normalized priority string ("Low", "Medium", "High", "Critical") or None - """ - if priority is None or not priority.strip(): - return None - - priority_map = { - "low": "Low", - "medium": "Medium", - "high": "High", - "critical": "Critical", - } - - normalized = priority_map.get(priority.lower()) - if normalized is None: - raise ValueError( - f"Invalid priority value: '{priority}'. " - f"Must be one of: Low, Medium, High, Critical (case-insensitive)" - ) - - return normalized - - -def _retrieve_action_spec( - action_key: str, app_folder_key: str, app_folder_path: str -) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/tasks/GenericTasks/GetTaskDataByKey"), - params={"taskKey": action_key}, - headers=folder_headers(app_folder_key, app_folder_path), - ) - - -async def _assign_task_spec( - self, task_key: str, assignee: str | None, task_recipient: TaskRecipient | None -) -> RequestSpec: - request_spec = RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks" - ), - ) - if task_recipient: - recipient_value = await _resolve_recipient(self, task_recipient) - if ( - task_recipient.type == TaskRecipientType.USER_ID - or task_recipient.type == TaskRecipientType.EMAIL - ): - request_spec.json = { - "taskAssignments": [ - { - "taskId": task_key, - "assignmentCriteria": "SingleUser", - "userNameOrEmail": recipient_value, - } - ] - } - else: - request_spec.json = { - "taskAssignments": [ - { - "taskId": task_key, - "assignmentCriteria": "AllUsers", - "assigneeNamesOrEmails": [recipient_value], - } - ] - } - elif assignee: - request_spec.json = { - "taskAssignments": [{"taskId": task_key, "UserNameOrEmail": assignee}] - } - return request_spec - - -async def _resolve_recipient(self, task_recipient: TaskRecipient) -> str: - recipient_value = task_recipient.value - - if task_recipient.type == TaskRecipientType.USER_ID: - spec = _resolve_user(task_recipient.value) - response = await self.request_async( - spec.method, - spec.endpoint, - json=spec.json, - content=spec.content, - headers=spec.headers, - scoped="org", - ) - recipient_value = response.json().get("email") - task_recipient.display_name = recipient_value - - if task_recipient.type == TaskRecipientType.GROUP_ID: - spec = _resolve_group(task_recipient.value) - response = await self.request_async( - spec.method, - spec.endpoint, - json=spec.json, - content=spec.content, - headers=spec.headers, - scoped="org", - ) - recipient_value = response.json().get("displayName") - task_recipient.display_name = recipient_value - - return recipient_value - - -def _resolve_user(entity_id: str) -> RequestSpec: - org_id = UiPathConfig.organization_id - return RequestSpec( - method="POST", - endpoint=Endpoint( - "/identity_/api/Directory/Resolve/{org_id}".format(org_id=org_id) - ), - json={"entityId": entity_id, "entityType": "User"}, - ) - - -def _resolve_group(entity_id: str) -> RequestSpec: - org_id = UiPathConfig.organization_id - return RequestSpec( - method="GET", - endpoint=Endpoint( - "/identity_/api/Group/{org_id}/{entity_id}".format( - org_id=org_id, entity_id=entity_id - ) - ), - ) - - -def _retrieve_app_key_spec(app_name: str) -> RequestSpec: - tenant_id = os.getenv(ENV_TENANT_ID, None) - if not tenant_id: - raise Exception(f"{ENV_TENANT_ID} env var is not set") - return RequestSpec( - method="GET", - endpoint=Endpoint("/apps_/default/api/v1/default/deployed-action-apps-schemas"), - params={"search": app_name, "filterByDeploymentTitle": "true"}, - headers={HEADER_TENANT_ID: tenant_id}, - ) - - -def folder_headers( - app_folder_key: Optional[str], app_folder_path: Optional[str] -) -> Dict[str, str]: - headers = {} - if app_folder_key: - headers[HEADER_FOLDER_KEY] = app_folder_key - elif app_folder_path: - headers[HEADER_FOLDER_PATH] = app_folder_path - return headers - - -class TasksService(FolderContext, BaseService): - """Service for managing UiPath Action Center tasks. - - Tasks are task-based automation components that can be integrated into - applications and processes. They represent discrete units of work that can - be triggered and monitored through the UiPath API. - - This service provides methods to create and retrieve tasks, supporting - both app-specific and generic tasks. It inherits folder context management - capabilities from FolderContext. - - Reference: https://docs.uipath.com/automation-cloud/docs/actions - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - @resource_override( - resource_type="app", - resource_identifier="app_name", - folder_identifier="app_folder_path", - ) - @traced(name="tasks_create", run_type="uipath") - async def create_async( - self, - title: str, - data: Optional[Dict[str, Any]] = None, - *, - app_name: Optional[str] = None, - app_key: Optional[str] = None, - app_folder_path: Optional[str] = None, - app_folder_key: Optional[str] = None, - assignee: Optional[str] = None, - recipient: Optional[TaskRecipient] = None, - priority: Optional[str] = None, - labels: Optional[List[str]] = None, - is_actionable_message_enabled: Optional[bool] = None, - actionable_message_metadata: Optional[Dict[str, Any]] = None, - source_name: str = "Agent", - ) -> Task: - """Creates a new action asynchronously. - - This method creates a new action task in UiPath Orchestrator. The action can be - either app-specific (using app_name or app_key) or a generic action. - - Args: - title: The title of the action - data: Optional dictionary containing input data for the action - app_name: The name of the application (if creating an app-specific action) - app_key: The key of the application (if creating an app-specific action) - app_folder_path: Optional folder path for the action - app_folder_key: Optional folder key for the action - assignee: Optional username or email to assign the task to - priority: Optional priority of the task - labels: Optional list of labels for the task - is_actionable_message_enabled: Optional boolean indicating whether actionable notifications are enabled for this task - actionable_message_metadata: Optional metadata for the action - source_name: The name of the source that created the task. Defaults to 'Agent'. - - Returns: - Action: The created action object - - Raises: - Exception: If neither app_name nor app_key is provided for app-specific actions - """ - app_folder_path = app_folder_path if app_folder_path else self._folder_path - - (key, action_schema) = ( - (app_key, None) - if app_key - else await self._get_app_key_and_schema_async(app_name, app_folder_path) - ) - spec = _create_spec( - title=title, - data=data, - app_key=key, - action_schema=action_schema, - app_folder_key=app_folder_key, - app_folder_path=app_folder_path, - priority=priority, - labels=labels, - is_actionable_message_enabled=is_actionable_message_enabled, - actionable_message_metadata=actionable_message_metadata, - source_name=source_name, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - json=spec.json, - content=spec.content, - headers=spec.headers, - ) - json_response = response.json() - if assignee or recipient: - spec = await _assign_task_spec( - self, json_response["id"], assignee, recipient - ) - await self.request_async( - spec.method, spec.endpoint, json=spec.json, content=spec.content - ) - return Task.model_validate(json_response) - - @resource_override( - resource_type="app", - resource_identifier="app_name", - folder_identifier="app_folder_path", - ) - @traced(name="tasks_create", run_type="uipath") - def create( - self, - title: str, - data: Optional[Dict[str, Any]] = None, - *, - app_name: Optional[str] = None, - app_key: Optional[str] = None, - app_folder_path: Optional[str] = None, - app_folder_key: Optional[str] = None, - assignee: Optional[str] = None, - recipient: Optional[TaskRecipient] = None, - priority: Optional[str] = None, - labels: Optional[List[str]] = None, - is_actionable_message_enabled: Optional[bool] = None, - actionable_message_metadata: Optional[Dict[str, Any]] = None, - source_name: str = "Agent", - ) -> Task: - """Creates a new task synchronously. - - This method creates a new action task in UiPath Orchestrator. The action can be - either app-specific (using app_name or app_key) or a generic action. - - Args: - title: The title of the action - data: Optional dictionary containing input data for the action - app_name: The name of the application (if creating an app-specific action) - app_key: The key of the application (if creating an app-specific action) - app_folder_path: Optional folder path for the action - app_folder_key: Optional folder key for the action - assignee: Optional username or email to assign the task to - priority: Optional priority of the task - labels: Optional list of labels for the task - is_actionable_message_enabled: Optional boolean indicating whether actionable notifications are enabled for this task - actionable_message_metadata: Optional metadata for the action - source_name: The name of the source that created the task. Defaults to 'Agent'. - - Returns: - Action: The created action object - - Raises: - Exception: If neither app_name nor app_key is provided for app-specific actions - """ - app_folder_path = app_folder_path if app_folder_path else self._folder_path - - (key, action_schema) = ( - (app_key, None) - if app_key - else self._get_app_key_and_schema(app_name, app_folder_path) - ) - spec = _create_spec( - title=title, - data=data, - app_key=key, - action_schema=action_schema, - app_folder_key=app_folder_key, - app_folder_path=app_folder_path, - priority=priority, - labels=labels, - is_actionable_message_enabled=is_actionable_message_enabled, - actionable_message_metadata=actionable_message_metadata, - source_name=source_name, - ) - - response = self.request( - spec.method, - spec.endpoint, - json=spec.json, - content=spec.content, - headers=spec.headers, - ) - json_response = response.json() - if assignee or recipient: - spec = asyncio.run( - _assign_task_spec(self, json_response["id"], assignee, recipient) - ) - self.request( - spec.method, spec.endpoint, json=spec.json, content=spec.content - ) - return Task.model_validate(json_response) - - @resource_override( - resource_type="app", - resource_identifier="app_name", - folder_identifier="app_folder_path", - ) - @traced(name="tasks_retrieve", run_type="uipath") - def retrieve( - self, - action_key: str, - app_folder_path: str = "", - app_folder_key: str = "", - app_name: str | None = None, - ) -> Task: - """Retrieves a task by its key synchronously. - - Args: - action_key: The unique identifier of the task to retrieve - app_folder_path: Optional folder path for the task - app_folder_key: Optional folder key for the task - app_name: app name hint for resource override - Returns: - Task: The retrieved task object - """ - spec = _retrieve_action_spec( - action_key=action_key, - app_folder_key=app_folder_key, - app_folder_path=app_folder_path, - ) - response = self.request( - spec.method, spec.endpoint, params=spec.params, headers=spec.headers - ) - - return Task.model_validate(response.json()) - - @resource_override( - resource_type="app", - resource_identifier="app_name", - folder_identifier="app_folder_path", - ) - @traced(name="tasks_retrieve", run_type="uipath") - async def retrieve_async( - self, - action_key: str, - app_folder_path: str = "", - app_folder_key: str = "", - app_name: str | None = None, - ) -> Task: - """Retrieves a task by its key asynchronously. - - Args: - action_key: The unique identifier of the task to retrieve - app_folder_path: Optional folder path for the task - app_folder_key: Optional folder key for the task - app_name: app name hint for resource override - Returns: - Task: The retrieved task object - """ - spec = _retrieve_action_spec( - action_key=action_key, - app_folder_key=app_folder_key, - app_folder_path=app_folder_path, - ) - response = await self.request_async( - spec.method, spec.endpoint, params=spec.params, headers=spec.headers - ) - - return Task.model_validate(response.json()) - - async def _get_app_key_and_schema_async( - self, app_name: Optional[str], app_folder_path: Optional[str] - ) -> Tuple[str, Optional[TaskSchema]]: - if not app_name: - raise Exception("appName or appKey is required") - spec = _retrieve_app_key_spec(app_name=app_name) - - response = await self.request_async( - spec.method, - spec.endpoint, - params=spec.params, - headers=spec.headers, - scoped="org", - ) - try: - deployed_app = self._extract_deployed_app( - response.json()["deployed"], app_folder_path - ) - action_schema = deployed_app["actionSchema"] - deployed_app_key = deployed_app["systemName"] - except (KeyError, IndexError): - raise Exception("Action app not found") from None - try: - return ( - deployed_app_key, - TaskSchema( - key=action_schema["key"], - in_outs=action_schema["inOuts"], - inputs=action_schema["inputs"], - outputs=action_schema["outputs"], - outcomes=action_schema["outcomes"], - ), - ) - except KeyError: - raise Exception("Failed to deserialize action schema") from KeyError - - def _get_app_key_and_schema( - self, app_name: Optional[str], app_folder_path: Optional[str] - ) -> Tuple[str, Optional[TaskSchema]]: - if not app_name: - raise Exception("appName or appKey is required") - - spec = _retrieve_app_key_spec(app_name=app_name) - - response = self.request( - spec.method, - spec.endpoint, - params=spec.params, - headers=spec.headers, - scoped="org", - ) - - try: - deployed_app = self._extract_deployed_app( - response.json()["deployed"], app_folder_path - ) - action_schema = deployed_app["actionSchema"] - deployed_app_key = deployed_app["systemName"] - except (KeyError, IndexError): - raise Exception("Action app not found") from None - try: - return ( - deployed_app_key, - TaskSchema( - key=action_schema["key"], - in_outs=action_schema["inOuts"], - inputs=action_schema["inputs"], - outputs=action_schema["outputs"], - outcomes=action_schema["outcomes"], - ), - ) - except KeyError: - raise Exception("Failed to deserialize action schema") from KeyError - - # should be removed after folder filtering support is added on apps API - def _extract_deployed_app( - self, deployed_apps: List[Dict[str, Any]], app_folder_path: Optional[str] - ) -> Dict[str, Any]: - if len(deployed_apps) > 1 and not app_folder_path: - raise Exception("Multiple app schemas found") - try: - if app_folder_path: - return next( - app - for app in deployed_apps - if app["deploymentFolder"]["fullyQualifiedName"] == app_folder_path - ) - else: - return next( - app - for app in deployed_apps - if app["deploymentFolder"]["key"] == self._folder_key - ) - except StopIteration: - raise KeyError from StopIteration - - @property - def custom_headers(self) -> Dict[str, str]: - return self.folder_headers diff --git a/src/uipath/platform/action_center/task_schema.py b/src/uipath/platform/action_center/task_schema.py deleted file mode 100644 index 2fb707549..000000000 --- a/src/uipath/platform/action_center/task_schema.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Module defining the ActionSchema model for UiPath platform actions.""" - -from typing import List, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class FieldDetails(BaseModel): - """Model representing details of a field in an action schema.""" - - name: str - key: str - - -class TaskSchema(BaseModel): - """Model representing the schema of an action in the UiPath platform.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - key: str - in_outs: Optional[List[FieldDetails]] = Field(default=None, alias="inOuts") - inputs: Optional[List[FieldDetails]] = None - outputs: Optional[List[FieldDetails]] = None - outcomes: Optional[List[FieldDetails]] = None diff --git a/src/uipath/platform/action_center/tasks.py b/src/uipath/platform/action_center/tasks.py deleted file mode 100644 index f882cf40f..000000000 --- a/src/uipath/platform/action_center/tasks.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Data model for an Action in the UiPath Platform.""" - -import enum -from datetime import datetime -from typing import Any, Dict, List, Literal, Optional, Union - -from pydantic import BaseModel, ConfigDict, Field, field_serializer - - -class TaskStatus(enum.IntEnum): - """Enum representing possible Task status.""" - - UNASSIGNED = 0 - PENDING = 1 - COMPLETED = 2 - - -class TaskRecipientType(str, enum.Enum): - """Task recipient type enumeration.""" - - USER_ID = "UserId" - GROUP_ID = "GroupId" - EMAIL = "UserEmail" - GROUP_NAME = "GroupName" - - -class TaskRecipient(BaseModel): - """Model representing a task recipient.""" - - type: Literal[ - TaskRecipientType.USER_ID, - TaskRecipientType.GROUP_ID, - TaskRecipientType.EMAIL, - TaskRecipientType.GROUP_NAME, - ] = Field(..., alias="type") - value: str = Field(..., alias="value") - display_name: Optional[str] = Field(default=None, alias="displayName") - - -class Task(BaseModel): - """Model representing a Task in the UiPath Platform.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - @field_serializer("*", when_used="json") - def serialize_datetime(self, value): - """Serialize datetime fields to ISO 8601 format for JSON output.""" - if isinstance(value, datetime): - return value.isoformat() if value else None - return value - - task_definition_properties_id: Optional[int] = Field( - default=None, alias="taskDefinitionPropertiesId" - ) - app_tasks_metadata: Optional[Any] = Field(default=None, alias="appTasksMetadata") - action_label: Optional[str] = Field(default=None, alias="actionLabel") - # 2.3.0 change to TaskStatus enum - status: Optional[Union[str, int]] = None - data: Optional[Dict[str, Any]] = None - action: Optional[str] = None - wait_job_state: Optional[str] = Field(default=None, alias="waitJobState") - organization_unit_fully_qualified_name: Optional[str] = Field( - default=None, alias="organizationUnitFullyQualifiedName" - ) - tags: Optional[List[Any]] = None - assigned_to_user: Optional[Any] = Field(default=None, alias="assignedToUser") - task_sla_details: Optional[List[Any]] = Field(default=None, alias="taskSlaDetails") - completed_by_user: Optional[Any] = Field(default=None, alias="completedByUser") - task_assignment_criteria: Optional[str] = Field( - default=None, alias="taskAssignmentCriteria" - ) - task_assignees: Optional[List[Any]] = Field(default=None, alias="taskAssignees") - title: Optional[str] = None - type: Optional[str] = None - priority: Optional[str] = None - assigned_to_user_id: Optional[int] = Field(default=None, alias="assignedToUserId") - organization_unit_id: Optional[int] = Field( - default=None, alias="organizationUnitId" - ) - external_tag: Optional[str] = Field(default=None, alias="externalTag") - creator_job_key: Optional[str] = Field(default=None, alias="creatorJobKey") - wait_job_key: Optional[str] = Field(default=None, alias="waitJobKey") - last_assigned_time: Optional[datetime] = Field( - default=None, alias="lastAssignedTime" - ) - completion_time: Optional[datetime] = Field(default=None, alias="completionTime") - parent_operation_id: Optional[str] = Field(default=None, alias="parentOperationId") - key: Optional[str] = None - is_deleted: bool = Field(default=False, alias="isDeleted") - deleter_user_id: Optional[int] = Field(default=None, alias="deleterUserId") - deletion_time: Optional[datetime] = Field(default=None, alias="deletionTime") - last_modification_time: Optional[datetime] = Field( - default=None, alias="lastModificationTime" - ) - last_modifier_user_id: Optional[int] = Field( - default=None, alias="lastModifierUserId" - ) - creation_time: Optional[datetime] = Field(default=None, alias="creationTime") - creator_user_id: Optional[int] = Field(default=None, alias="creatorUserId") - id: Optional[int] = None diff --git a/src/uipath/platform/agenthub/__init__.py b/src/uipath/platform/agenthub/__init__.py deleted file mode 100644 index 2012f3953..000000000 --- a/src/uipath/platform/agenthub/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""UiPath AgentHub Models. - -This module contains models related to UiPath AgentHub service. -""" - -from uipath.platform.agenthub.agenthub import LlmModel - -__all__ = ["LlmModel"] diff --git a/src/uipath/platform/agenthub/_agenthub_service.py b/src/uipath/platform/agenthub/_agenthub_service.py deleted file mode 100644 index 632639380..000000000 --- a/src/uipath/platform/agenthub/_agenthub_service.py +++ /dev/null @@ -1,202 +0,0 @@ -from typing import Any - -from ..._utils import Endpoint, RequestSpec, header_folder -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from ..orchestrator import FolderService -from .agenthub import LlmModel - - -class AgentHubService(FolderContext, BaseService): - """Service class for interacting with AgentHub platform service.""" - - def __init__( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folder_service: FolderService, - ) -> None: - self._folder_service = folder_service - super().__init__(config=config, execution_context=execution_context) - - def get_available_llm_models( - self, headers: dict[str, Any] | None = None - ) -> list[LlmModel]: - """Fetch available models from LLM Gateway discovery endpoint. - - Returns: - List of available models and their configurations. - """ - spec = self._available_models_spec(headers=headers) - - response = self.request( - spec.method, - spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - return [ - LlmModel.model_validate(available_model) - for available_model in response.json() - ] - - async def get_available_llm_models_async( - self, headers: dict[str, Any] | None = None - ) -> list[LlmModel]: - """Asynchronously fetch available models from LLM Gateway discovery endpoint. - - Returns: - List of available models and their configurations. - """ - spec = self._available_models_spec(headers=headers) - - response = await self.request_async( - spec.method, - spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - return [ - LlmModel.model_validate(available_model) - for available_model in response.json() - ] - - def invoke_system_agent( - self, - *, - agent_name: str, - entrypoint: str, - input_arguments: dict[str, Any] | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - headers: dict[str, Any] | None = None, - ) -> str: - """Start a system agent job. - - Args: - agent_name: The name of the system agent to invoke. - entrypoint: The entry point to execute. - input_arguments: Optional input arguments to pass to the agent. - folder_key: Optional folder key to override the default folder context. - folder_path: Optional folder path to override the default folder context. - - Returns: - str: The started job's key. - """ - folder_key = self._resolve_folder_key(folder_key, folder_path) - - spec = self._start_spec( - agent_name=agent_name, - entrypoint=entrypoint, - input_arguments=input_arguments, - folder_key=folder_key, - headers=headers, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - response_data = response.json() - - return response_data["key"] - - async def invoke_system_agent_async( - self, - *, - agent_name: str, - entrypoint: str, - input_arguments: dict[str, Any] | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - headers: dict[str, Any] | None = None, - ) -> str: - """Asynchronously start a system agent and return the job. - - Args: - agent_name: The name of the system agent to invoke. - entrypoint: The entry point to execute. - input_arguments: Optional input arguments to pass to the agent. - folder_key: Optional folder key to override the default folder context. - folder_path: Optional folder path to override the default folder context. - - Returns: - str: The started job's key. - - """ - folder_key = self._resolve_folder_key(folder_key, folder_path) - - spec = self._start_spec( - agent_name=agent_name, - entrypoint=entrypoint, - input_arguments=input_arguments, - folder_key=folder_key, - headers=headers, - ) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - response_data = response.json() - - return response_data["key"] - - def _start_spec( - self, - agent_name: str, - entrypoint: str, - input_arguments: dict[str, Any] | None, - folder_key: str, - headers: dict[str, Any] | None, - ) -> RequestSpec: - """Build the request specification for starting a system agent. - - Args: - agent_name: The name of the system agent. - entrypoint: The entry point to execute. - input_arguments: Input arguments for the agent. - folder_key: Folder key for scoping. - - Returns: - RequestSpec: The request specification with endpoint, method, headers, and body. - """ - return RequestSpec( - method="POST", - endpoint=Endpoint(f"agenthub_/api/systemagents/{agent_name}/start"), - headers=header_folder(folder_key, None) | (headers or {}), - json={ - "EntryPoint": entrypoint, - "InputArguments": input_arguments or {}, - }, - ) - - def _resolve_folder_key( - self, folder_key: str | None, folder_path: str | None - ) -> str: - if folder_key is None and folder_path is not None: - folder_key = self._folder_service.retrieve_key(folder_path=folder_path) - - if folder_key is None and folder_path is None: - folder_key = self._folder_key or ( - self._folder_service.retrieve_key(folder_path=self._folder_path) - if self._folder_path - else None - ) - - if folder_key is None: - raise ValueError("AgentHubClient: Failed to resolve folder key") - - return folder_key - - def _available_models_spec(self, headers: dict[str, Any] | None) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("/agenthub_/llm/api/discovery"), - headers=headers or {}, - ) diff --git a/src/uipath/platform/agenthub/agenthub.py b/src/uipath/platform/agenthub/agenthub.py deleted file mode 100644 index 6ccfea047..000000000 --- a/src/uipath/platform/agenthub/agenthub.py +++ /dev/null @@ -1,18 +0,0 @@ -"""AgentHub response payload models.""" - -from pydantic import BaseModel, ConfigDict, Field - - -class LlmModel(BaseModel): - """Model representing an available LLM model.""" - - model_name: str = Field(..., alias="modelName") - vendor: str | None = Field(default=None) - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) diff --git a/src/uipath/platform/attachments/__init__.py b/src/uipath/platform/attachments/__init__.py deleted file mode 100644 index 302de848b..000000000 --- a/src/uipath/platform/attachments/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""UiPath Attachment Models. - -This module contains models related to UiPath Attachments service. -""" - -from .attachments import Attachment, AttachmentMode, BlobFileAccessInfo - -__all__ = [ - "Attachment", - "AttachmentMode", - "BlobFileAccessInfo", -] diff --git a/src/uipath/platform/attachments/attachments.py b/src/uipath/platform/attachments/attachments.py deleted file mode 100644 index e91c7eff4..000000000 --- a/src/uipath/platform/attachments/attachments.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Module defining the attachment model for attachments.""" - -import uuid -from dataclasses import dataclass -from enum import Enum -from typing import Any, Optional - -from pydantic import BaseModel, Field - - -class AttachmentMode(str, Enum): - """Mode of attachment open.""" - - READ = "read" - WRITE = "write" - - -class Attachment(BaseModel): - """Model representing an attachment. Id 'None' is used for uploads.""" - - id: uuid.UUID = Field(..., alias="ID") - full_name: str = Field(..., alias="FullName") - mime_type: str = Field(..., alias="MimeType") - metadata: Optional[dict[str, Any]] = Field(None, alias="Metadata") - model_config = { - "title": "UiPathAttachment", - "validate_by_name": True, - "validate_by_alias": True, - } - - -@dataclass -class BlobFileAccessInfo: - """Information about blob file access for an attachment. - - Attributes: - id: The unique identifier (UUID) of the attachment. - uri: The blob storage URI for accessing the file. - name: The name of the attachment file. - """ - - id: uuid.UUID - uri: str - name: str diff --git a/src/uipath/platform/chat/__init__.py b/src/uipath/platform/chat/__init__.py deleted file mode 100644 index 8aa491606..000000000 --- a/src/uipath/platform/chat/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -"""UiPath Chat Services. - -This module provides services for chat-related functionality including: -- LLM Gateway services for chat completions and embeddings -- Conversations service for autopilot conversations -""" - -from ._conversations_service import ConversationsService -from ._llm_gateway_service import ( - ChatModels, - EmbeddingModels, - UiPathLlmChatService, - UiPathOpenAIService, -) -from .llm_gateway import ( - AutoToolChoice, - ChatCompletion, - ChatCompletionChoice, - ChatCompletionUsage, - ChatMessage, - EmbeddingItem, - EmbeddingUsage, - RequiredToolChoice, - SpecificToolChoice, - TextEmbedding, - ToolCall, - ToolChoice, - ToolDefinition, - ToolFunctionDefinition, - ToolParametersDefinition, - ToolPropertyDefinition, -) -from .llm_throttle import get_llm_semaphore, set_llm_concurrency - -__all__ = [ - # Conversations Service - "ConversationsService", - # LLM Gateway Services - "ChatModels", - "EmbeddingModels", - "UiPathLlmChatService", - "UiPathOpenAIService", - # LLM Throttling - "get_llm_semaphore", - "set_llm_concurrency", - # LLM Gateway Models - "ToolPropertyDefinition", - "ToolParametersDefinition", - "ToolFunctionDefinition", - "ToolDefinition", - "AutoToolChoice", - "RequiredToolChoice", - "SpecificToolChoice", - "ChatMessage", - "ChatCompletionChoice", - "ChatCompletionUsage", - "ChatCompletion", - "EmbeddingItem", - "EmbeddingUsage", - "TextEmbedding", - "ToolChoice", - "ToolCall", -] diff --git a/src/uipath/platform/chat/_conversations_service.py b/src/uipath/platform/chat/_conversations_service.py deleted file mode 100644 index d86ac5245..000000000 --- a/src/uipath/platform/chat/_conversations_service.py +++ /dev/null @@ -1,50 +0,0 @@ -from uipath.core.chat import UiPathConversationMessage - -from ..._utils import Endpoint, RequestSpec -from ...tracing import traced -from ..common import BaseService, UiPathApiConfig, UiPathExecutionContext - - -class ConversationsService(BaseService): - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - @traced(name="retrieve_message", run_type="uipath") - def retrieve_message( - self, conversation_id: str, exchange_id: str, message_id: str - ) -> UiPathConversationMessage: - retrieve_message_spec = self._retrieve_message_spec( - conversation_id, exchange_id, message_id - ) - - response = self.request( - retrieve_message_spec.method, retrieve_message_spec.endpoint - ) - - return UiPathConversationMessage.model_validate(response.json()) - - @traced(name="retrieve_message", run_type="uipath") - async def retrieve_message_async( - self, conversation_id: str, exchange_id: str, message_id: str - ) -> UiPathConversationMessage: - retrieve_message_spec = self._retrieve_message_spec( - conversation_id, exchange_id, message_id - ) - - response = await self.request_async( - retrieve_message_spec.method, retrieve_message_spec.endpoint - ) - - return UiPathConversationMessage.model_validate(response.json()) - - def _retrieve_message_spec( - self, conversation_id: str, exchange_id: str, message_id: str - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}" - ), - ) diff --git a/src/uipath/platform/chat/_llm_gateway_service.py b/src/uipath/platform/chat/_llm_gateway_service.py deleted file mode 100644 index da91558e3..000000000 --- a/src/uipath/platform/chat/_llm_gateway_service.py +++ /dev/null @@ -1,693 +0,0 @@ -"""UiPath LLM Gateway Services. - -This module provides services for interacting with UiPath's LLM (Large Language Model) Gateway, -offering both OpenAI-compatible and normalized API interfaces for chat completions and embeddings. - -The module includes: -- UiPathOpenAIService: OpenAI-compatible API for chat completions and embeddings -- UiPathLlmChatService: UiPath's normalized API with advanced features like tool calling -- ChatModels: Constants for available chat models -- EmbeddingModels: Constants for available embedding models - -Classes: - ChatModels: Container for supported chat model identifiers - EmbeddingModels: Container for supported embedding model identifiers - UiPathOpenAIService: Service using OpenAI-compatible API format - UiPathLlmChatService: Service using UiPath's normalized API format -""" - -from typing import Any - -from opentelemetry import trace -from pydantic import BaseModel - -from ..._utils import Endpoint -from ...tracing import traced -from ...utils import EndpointManager -from ..common import BaseService, UiPathApiConfig, UiPathExecutionContext -from .llm_gateway import ( - ChatCompletion, - SpecificToolChoice, - TextEmbedding, - ToolChoice, - ToolDefinition, -) -from .llm_throttle import get_llm_semaphore - -# Common constants -API_VERSION = "2024-10-21" # Standard API version for OpenAI-compatible endpoints -NORMALIZED_API_VERSION = ( - "2024-08-01-preview" # API version for UiPath's normalized endpoints -) - - -class ChatModels(object): - """Available chat models for LLM Gateway services. - - This class provides constants for the supported chat models that can be used - with both UiPathOpenAIService and UiPathLlmChatService. - """ - - gpt_4 = "gpt-4" - gpt_4_1106_Preview = "gpt-4-1106-Preview" - gpt_4_32k = "gpt-4-32k" - gpt_4_turbo_2024_04_09 = "gpt-4-turbo-2024-04-09" - gpt_4_vision_preview = "gpt-4-vision-preview" - gpt_4o_2024_05_13 = "gpt-4o-2024-05-13" - gpt_4o_2024_08_06 = "gpt-4o-2024-08-06" - gpt_4o_mini_2024_07_18 = "gpt-4o-mini-2024-07-18" - gpt_4_1_mini_2025_04_14 = "gpt-4.1-mini-2025-04-14" - o3_mini = "o3-mini-2025-01-31" - - -class EmbeddingModels(object): - """Available embedding models for LLM Gateway services. - - This class provides constants for the supported embedding models that can be used - with the embeddings functionality. - """ - - text_embedding_3_large = "text-embedding-3-large" - text_embedding_ada_002 = "text-embedding-ada-002" - - -def _cleanup_schema(schema: dict[str, Any]) -> dict[str, Any]: - """Clean up a JSON schema for use with LLM Gateway. - - This function converts a JSON schema to a format that's - compatible with the LLM Gateway's JSON schema requirements by removing - titles and other metadata that might cause validation issues. - - Args: - schema (dict[str, Any]): an input JSON schema. - - Returns: - dict: A cleaned JSON schema dictionary suitable for LLM Gateway response_format. - - Examples: - ```python - from pydantic import BaseModel - - class Country(BaseModel): - name: str - capital: str - languages: list[str] - - schema = _cleanup_schema(Country.model_json_schema()) - # Returns a clean schema without titles and unnecessary metadata - ``` - """ - - def clean_type(type_def): - """Clean property definitions by removing titles and cleaning nested items. Additionally, `additionalProperties` is ensured on all objects.""" - cleaned_type = {} - for key, value in type_def.items(): - if key == "title" or key == "properties": - continue - else: - cleaned_type[key] = value - if type_def.get("type") == "object" and "additionalProperties" not in type_def: - cleaned_type["additionalProperties"] = False - - if "properties" in type_def: - properties = type_def.get("properties", {}) - for key, value in properties.items(): - properties[key] = clean_type(value) - cleaned_type["properties"] = properties - - if type_def.get("type") == "object": - cleaned_type["required"] = list(cleaned_type.get("properties", {}).keys()) - - if "$defs" in type_def: - cleaned_defs = {} - for key, value in type_def["$defs"].items(): - cleaned_defs[key] = clean_type(value) - cleaned_type["$defs"] = cleaned_defs - return cleaned_type - - # Create clean schema - clean_schema = clean_type(schema) - return clean_schema - - -class UiPathOpenAIService(BaseService): - """Service for calling UiPath's LLM Gateway using OpenAI-compatible API. - - This service provides access to Large Language Model capabilities through UiPath's - LLM Gateway, including chat completions and text embeddings. It uses the OpenAI-compatible - API format and is suitable for applications that need direct OpenAI API compatibility. - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - @traced(name="llm_embeddings", run_type="uipath") - async def embeddings( - self, - input: str, - embedding_model: str = EmbeddingModels.text_embedding_ada_002, - openai_api_version: str = API_VERSION, - ): - """Generate text embeddings using UiPath's LLM Gateway service. - - This method converts input text into dense vector representations that can be used - for semantic search, similarity calculations, and other NLP tasks. - - Args: - input (str): The input text to embed. Can be a single sentence, paragraph, - or document that you want to convert to embeddings. - embedding_model (str, optional): The embedding model to use. - Defaults to EmbeddingModels.text_embedding_ada_002. - Available models are defined in the EmbeddingModels class. - openai_api_version (str, optional): The OpenAI API version to use. - Defaults to API_VERSION. - - Returns: - TextEmbedding: The embedding response containing the vector representation - of the input text along with metadata. - - Examples: - ```python - # Basic embedding - embedding = await service.embeddings("Hello, world!") - - # Using a specific model - embedding = await service.embeddings( - "This is a longer text to embed", - embedding_model=EmbeddingModels.text_embedding_3_large - ) - ``` - """ - endpoint = EndpointManager.get_embeddings_endpoint().format( - model=embedding_model, api_version=openai_api_version - ) - endpoint = Endpoint("/" + endpoint) - - # Prepare headers - headers = { - "X-UIPATH-STREAMING-ENABLED": "false", - "X-UiPath-LlmGateway-RequestingProduct": self._execution_context.requesting_product, - "X-UiPath-LlmGateway-RequestingFeature": self._execution_context.requesting_feature, - } - - # Add AgentHub config header if specified - if self._execution_context.agenthub_config: - headers["X-UiPath-AgentHub-Config"] = ( - self._execution_context.agenthub_config - ) - - # Add Action ID header if specified (groups related LLM calls) - if self._execution_context.action_id: - headers["X-UiPath-LlmGateway-ActionId"] = self._execution_context.action_id - - async with get_llm_semaphore(): - response = await self.request_async( - "POST", - endpoint, - json={"input": input}, - params={"api-version": API_VERSION}, - headers=headers, - ) - - return TextEmbedding.model_validate(response.json()) - - @traced(name="LLM call", run_type="uipath") - async def chat_completions( - self, - messages: list[dict[str, str]], - model: str = ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens: int = 4096, - temperature: float = 0, - response_format: dict[str, Any] | type[BaseModel] | None = None, - api_version: str = API_VERSION, - ): - """Generate chat completions using UiPath's LLM Gateway service. - - This method provides conversational AI capabilities by sending a series of messages - to a language model and receiving a generated response. It supports multi-turn - conversations and various OpenAI-compatible models. - - Args: - messages (List[Dict[str, str]]): List of message dictionaries with 'role' and 'content' keys. - The supported roles are 'system', 'user', and 'assistant'. System messages set - the behavior/context, user messages are from the human, and assistant messages - are from the AI. - model (str, optional): The model to use for chat completion. - Defaults to ChatModels.gpt_4_1_mini_2025_04_14. - Available models are defined in the ChatModels class. - max_tokens (int, optional): Maximum number of tokens to generate in the response. - Defaults to 4096. Higher values allow longer responses. - temperature (float, optional): Temperature for sampling, between 0 and 1. - Lower values (closer to 0) make output more deterministic and focused, - higher values make it more creative and random. Defaults to 0. - response_format (Optional[Union[Dict[str, Any], type[BaseModel]]], optional): - An object specifying the format that the model must output. Can be either: - - A dictionary with response format configuration (traditional format) - - A Pydantic BaseModel class (automatically converted to JSON schema) - Used to enable JSON mode or other structured outputs. Defaults to None. - api_version (str, optional): The API version to use. Defaults to API_VERSION. - - Returns: - ChatCompletion: The chat completion response containing the generated message, - usage statistics, and other metadata. - - Examples: - ```python - # Simple conversation - messages = [ - {"role": "system", "content": "You are a helpful Python programming assistant."}, - {"role": "user", "content": "How do I read a file in Python?"} - ] - response = await service.chat_completions(messages) - - # Multi-turn conversation with more tokens - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "What is machine learning?"}, - {"role": "assistant", "content": "Machine learning is a subset of AI..."}, - {"role": "user", "content": "Can you give me a practical example?"} - ] - response = await service.chat_completions( - messages, - max_tokens=200, - temperature=0.3 - ) - - # Using Pydantic model for structured response - from pydantic import BaseModel - - class Country(BaseModel): - name: str - capital: str - languages: list[str] - - response = await service.chat_completions( - messages=[ - {"role": "system", "content": "You are a helpful assistant. Respond with structured JSON."}, - {"role": "user", "content": "Tell me about Canada."} - ], - response_format=Country, # Pass BaseModel directly - max_tokens=1000 - ) - ``` - - Note: - The conversation history can be included to provide context to the model. - Each message should have both 'role' and 'content' keys. - When using a Pydantic BaseModel as response_format, it will be automatically - converted to the appropriate JSON schema format for the LLM Gateway. - """ - span = trace.get_current_span() - span.set_attribute("model", model) - span.set_attribute("uipath.custom_instrumentation", True) - - endpoint = EndpointManager.get_passthrough_endpoint().format( - model=model, api_version=api_version - ) - endpoint = Endpoint("/" + endpoint) - - request_body = { - "messages": messages, - "max_tokens": max_tokens, - "temperature": temperature, - } - - # Handle response_format - convert BaseModel to schema if needed - if response_format: - if isinstance(response_format, type) and issubclass( - response_format, BaseModel - ): - # Convert Pydantic model to JSON schema format - cleaned_schema = _cleanup_schema(response_format.model_json_schema()) - request_body["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__.lower(), - "strict": True, - "schema": cleaned_schema, - }, - } - else: - # Use provided dictionary format directly - request_body["response_format"] = response_format - - # Prepare headers - headers = { - "X-UIPATH-STREAMING-ENABLED": "false", - "X-UiPath-LlmGateway-RequestingProduct": self._execution_context.requesting_product, - "X-UiPath-LlmGateway-RequestingFeature": self._execution_context.requesting_feature, - } - - # Add AgentHub config header if specified - if self._execution_context.agenthub_config: - headers["X-UiPath-AgentHub-Config"] = ( - self._execution_context.agenthub_config - ) - - # Add Action ID header if specified (groups related LLM calls) - if self._execution_context.action_id: - headers["X-UiPath-LlmGateway-ActionId"] = self._execution_context.action_id - - async with get_llm_semaphore(): - response = await self.request_async( - "POST", - endpoint, - json=request_body, - params={"api-version": API_VERSION}, - headers=headers, - ) - - return ChatCompletion.model_validate(response.json()) - - -class UiPathLlmChatService(BaseService): - """Service for calling UiPath's normalized LLM Gateway API. - - This service provides access to Large Language Model capabilities through UiPath's - normalized LLM Gateway API. Unlike the OpenAI-compatible service, this service uses - UiPath's standardized API format and supports advanced features like tool calling, - function calling, and more sophisticated conversation control. - - The normalized API provides a consistent interface across different underlying model - providers and includes enhanced features for enterprise use cases. - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - @traced(name="LLM call", run_type="uipath") - async def chat_completions( - self, - messages: list[dict[str, str]] | list[tuple[str, str]], - model: str = ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens: int = 4096, - temperature: float = 0, - n: int = 1, - frequency_penalty: float = 0, - presence_penalty: float = 0, - top_p: float | None = 1, - top_k: int | None = None, - tools: list[ToolDefinition] | None = None, - tool_choice: ToolChoice | None = None, - response_format: dict[str, Any] | type[BaseModel] | None = None, - api_version: str = NORMALIZED_API_VERSION, - ): - """Generate chat completions using UiPath's normalized LLM Gateway API. - - This method provides advanced conversational AI capabilities with support for - tool calling, function calling, and sophisticated conversation control parameters. - It uses UiPath's normalized API format for consistent behavior across different - model providers. - - Args: - messages (List[Dict[str, str]]): List of message dictionaries with 'role' and 'content' keys. - The supported roles are 'system', 'user', and 'assistant'. System messages set - the behavior/context, user messages are from the human, and assistant messages - are from the AI. - model (str, optional): The model to use for chat completion. - Defaults to ChatModels.gpt_4_1_mini_2025_04_14. - Available models are defined in the ChatModels class. - max_tokens (int, optional): Maximum number of tokens to generate in the response. - Defaults to 4096. Higher values allow longer responses. - temperature (float, optional): Temperature for sampling, between 0 and 1. - Lower values (closer to 0) make output more deterministic and focused, - higher values make it more creative and random. Defaults to 0. - n (int, optional): Number of chat completion choices to generate for each input. - Defaults to 1. Higher values generate multiple alternative responses. - frequency_penalty (float, optional): Penalty for token frequency between -2.0 and 2.0. - Positive values reduce repetition of frequent tokens. Defaults to 0. - presence_penalty (float, optional): Penalty for token presence between -2.0 and 2.0. - Positive values encourage discussion of new topics. Defaults to 0. - top_p (float, optional): Nucleus sampling parameter between 0 and 1. - Controls diversity by considering only the top p probability mass. Defaults to 1. - top_k (int, optional): Nucleus sampling parameter. - Controls diversity by considering only the top k most probable tokens. Defaults to None. - tools (Optional[List[ToolDefinition]], optional): List of tool definitions that the - model can call. Tools enable the model to perform actions or retrieve information - beyond text generation. Defaults to None. - tool_choice (Optional[ToolChoice], optional): Controls which tools the model can call. - Can be "auto" (model decides), "none" (no tools), or a specific tool choice. - Defaults to None. - response_format (Optional[Union[Dict[str, Any], type[BaseModel]]], optional): - An object specifying the format that the model must output. Can be either: - - A dictionary with response format configuration (traditional format) - - A Pydantic BaseModel class (automatically converted to JSON schema) - Used to enable JSON mode or other structured outputs. Defaults to None. - api_version (str, optional): The normalized API version to use. - Defaults to NORMALIZED_API_VERSION. - - Returns: - ChatCompletion: The chat completion response containing the generated message(s), - tool calls (if any), usage statistics, and other metadata. - - Examples: - ```python - # Basic conversation - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "What is the weather like today?"} - ] - response = await service.chat_completions(messages) - - # Conversation with tool calling - tools = [ - ToolDefinition( - function=FunctionDefinition( - name="get_weather", - description="Get current weather for a location", - parameters=ParametersDefinition( - type="object", - properties={ - "location": PropertyDefinition( - type="string", - description="City name" - ) - }, - required=["location"] - ) - ) - ) - ] - response = await service.chat_completions( - messages, - tools=tools, - tool_choice="auto", - max_tokens=500 - ) - - # Advanced parameters for creative writing - response = await service.chat_completions( - messages, - temperature=0.8, - top_p=0.9, - frequency_penalty=0.3, - presence_penalty=0.2, - n=3 # Generate 3 alternative responses - ) - - # Using Pydantic model for structured response - from pydantic import BaseModel - - class Country(BaseModel): - name: str - capital: str - languages: list[str] - - response = await service.chat_completions( - messages=[ - {"role": "system", "content": "You are a helpful assistant. Respond with structured JSON."}, - {"role": "user", "content": "Tell me about Canada."} - ], - response_format=Country, # Pass BaseModel directly - max_tokens=1000 - ) - ) - ``` - - Note: - This service uses UiPath's normalized API format which provides consistent - behavior across different underlying model providers and enhanced enterprise features. - """ - span = trace.get_current_span() - span.set_attribute("model", model) - span.set_attribute("uipath.custom_instrumentation", True) - - converted_messages = [] - - for message in messages: - if isinstance(message, tuple) and len(message) == 2: - role, content = message - converted_messages.append({"role": role, "content": content}) - elif isinstance(message, dict): - converted_messages.append(message) - else: - raise ValueError( - f"Invalid message format: {message}. Expected tuple (role, content) or dict with 'role' and 'content' keys." - ) - - endpoint = EndpointManager.get_normalized_endpoint().format( - model=model, api_version=api_version - ) - endpoint = Endpoint("/" + endpoint) - - # Build request body - Claude models don't support some OpenAI-specific parameters - is_claude_model = "claude" in model.lower() - - request_body = { - "messages": converted_messages, - "max_tokens": max_tokens, - "temperature": temperature, - } - - # Only add OpenAI-specific parameters for non-Claude models - if not is_claude_model: - request_body["n"] = n - request_body["frequency_penalty"] = frequency_penalty - request_body["presence_penalty"] = presence_penalty - if top_p is not None: - request_body["top_p"] = top_p - - if top_k is not None: - request_body["top_k"] = top_k - - # Handle response_format - convert BaseModel to schema if needed - if response_format: - if isinstance(response_format, type) and issubclass( - response_format, BaseModel - ): - # Convert Pydantic model to JSON schema format - cleaned_schema = _cleanup_schema(response_format.model_json_schema()) - request_body["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__.lower(), - "strict": True, - "schema": cleaned_schema, - }, - } - else: - # Use provided dictionary format directly - request_body["response_format"] = response_format - - # Add tools if provided - convert to UiPath format - if tools: - request_body["tools"] = [ - self._convert_tool_to_uipath_format(tool) for tool in tools - ] - - # Handle tool_choice - if tool_choice: - if isinstance(tool_choice, str): - request_body["tool_choice"] = tool_choice - elif isinstance(tool_choice, SpecificToolChoice): - request_body["tool_choice"] = {"type": "tool", "name": tool_choice.name} - else: - request_body["tool_choice"] = tool_choice.model_dump() - - # Use default headers but update with normalized API specific headers - headers = { - "X-UIPATH-STREAMING-ENABLED": "false", - "X-UiPath-LlmGateway-RequestingProduct": self._execution_context.requesting_product, - "X-UiPath-LlmGateway-RequestingFeature": self._execution_context.requesting_feature, - "X-UiPath-LlmGateway-NormalizedApi-ModelName": model, - "X-UiPath-LLMGateway-AllowFull4xxResponse": "true", # Debug: show full error details - } - - # Add AgentHub config header if specified - if self._execution_context.agenthub_config: - headers["X-UiPath-AgentHub-Config"] = ( - self._execution_context.agenthub_config - ) - - # Add Action ID header if specified (groups related LLM calls) - if self._execution_context.action_id: - headers["X-UiPath-LlmGateway-ActionId"] = self._execution_context.action_id - - # Log the complete request for debugging - import json as json_module - import logging - - logger = logging.getLogger(__name__) - - logger.info("=" * 80) - logger.info("📤 LLM Gateway Normalized API Request") - logger.info("=" * 80) - logger.info(f"Model: {model}") - logger.info(f"Endpoint: {endpoint}") - logger.info(f"API Version: {NORMALIZED_API_VERSION}") - logger.info(f"Is Claude Model: {is_claude_model}") - logger.info("-" * 80) - logger.info("Headers:") - for key, value in headers.items(): - logger.info(f" {key}: {value}") - logger.info("-" * 80) - logger.info("Request Body:") - # Create a copy for logging with tools truncated for readability - log_body: dict[str, Any] = request_body.copy() - tools_list = log_body.get("tools") - if tools_list and isinstance(tools_list, list): - log_body["tools"] = f"[{len(tools_list)} tool(s)]" - messages_list = log_body.get("messages") - if messages_list and isinstance(messages_list, list): - log_body["messages"] = [ - { - **msg, - "content": msg["content"][:100] + "..." - if len(msg.get("content", "")) > 100 - else msg["content"], - } - for msg in messages_list - ] - logger.info(json_module.dumps(log_body, indent=2)) - logger.info("=" * 80) - - async with get_llm_semaphore(): - response = await self.request_async( - "POST", - endpoint, - json=request_body, - params={"api-version": NORMALIZED_API_VERSION}, - headers=headers, - ) - - logger.info(f"✅ Response received with status: {response.status_code}") - return ChatCompletion.model_validate(response.json()) - - def _convert_tool_to_uipath_format(self, tool: ToolDefinition) -> dict[str, Any]: - """Convert an OpenAI-style tool definition to UiPath API format. - - This internal method transforms tool definitions from the standard OpenAI format - to the format expected by UiPath's normalized LLM Gateway API. - - Args: - tool (ToolDefinition): The tool definition in OpenAI format containing - function name, description, and parameter schema. - - Returns: - Dict[str, Any]: The tool definition converted to UiPath API format - with the appropriate structure and field mappings. - """ - parameters = { - "type": tool.function.parameters.type, - "properties": { - name: { - "type": prop.type, - **({"description": prop.description} if prop.description else {}), - **({"enum": prop.enum} if prop.enum else {}), - } - for name, prop in tool.function.parameters.properties.items() - }, - } - - if tool.function.parameters.required: - parameters["required"] = tool.function.parameters.required - - return { - "name": tool.function.name, - "description": tool.function.description, - "parameters": parameters, - } diff --git a/src/uipath/platform/chat/llm_gateway.py b/src/uipath/platform/chat/llm_gateway.py deleted file mode 100644 index 0223bd4d3..000000000 --- a/src/uipath/platform/chat/llm_gateway.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Models for LLM Gateway interactions in the UiPath platform.""" - -from typing import Any, Dict, List, Literal, Optional, Union - -from pydantic import BaseModel - - -class EmbeddingItem(BaseModel): - """Model representing an individual embedding item.""" - - embedding: List[float] - index: int - object: str - - -class EmbeddingUsage(BaseModel): - """Model representing usage statistics for embeddings.""" - - prompt_tokens: int - total_tokens: int - - -class TextEmbedding(BaseModel): - """Model representing a text embedding response.""" - - data: List[EmbeddingItem] - model: str - object: str - usage: EmbeddingUsage - - -class ToolCall(BaseModel): - """Model representing a tool call.""" - - id: str - name: str - arguments: Dict[str, Any] - - -class ToolPropertyDefinition(BaseModel): - """Model representing a tool property definition.""" - - type: str - description: Optional[str] = None - enum: Optional[List[str]] = None - - -class ToolParametersDefinition(BaseModel): - """Model representing tool parameters definition.""" - - type: str = "object" - properties: Dict[str, ToolPropertyDefinition] - required: Optional[List[str]] = None - - -class ToolFunctionDefinition(BaseModel): - """Model representing a tool function definition.""" - - name: str - description: Optional[str] = None - parameters: ToolParametersDefinition - - -class ToolDefinition(BaseModel): - """Model representing a tool definition.""" - - type: Literal["function"] = "function" - function: ToolFunctionDefinition - - -class AutoToolChoice(BaseModel): - """Model representing an automatic tool choice.""" - - type: Literal["auto"] = "auto" - - -class RequiredToolChoice(BaseModel): - """Model representing a required tool choice.""" - - type: Literal["required"] = "required" - - -class SpecificToolChoice(BaseModel): - """Model representing a specific tool choice.""" - - type: Literal["tool"] = "tool" - name: str - - -ToolChoice = Union[ - AutoToolChoice, RequiredToolChoice, SpecificToolChoice, Literal["auto", "none"] -] - - -class ChatMessage(BaseModel): - """Model representing a chat message.""" - - role: str - content: Optional[str] = None - tool_calls: Optional[List[ToolCall]] = None - - -class ChatCompletionChoice(BaseModel): - """Model representing a chat completion choice.""" - - index: int - message: ChatMessage - finish_reason: str - - -class ChatCompletionUsage(BaseModel): - """Model representing usage statistics for chat completions.""" - - prompt_tokens: int - completion_tokens: int - total_tokens: int - cache_read_input_tokens: Optional[int] = None - - -class ChatCompletion(BaseModel): - """Model representing a chat completion response.""" - - id: str - object: str - created: int - model: str - choices: List[ChatCompletionChoice] - usage: ChatCompletionUsage diff --git a/src/uipath/platform/chat/llm_throttle.py b/src/uipath/platform/chat/llm_throttle.py deleted file mode 100644 index 338ce1b9c..000000000 --- a/src/uipath/platform/chat/llm_throttle.py +++ /dev/null @@ -1,49 +0,0 @@ -"""LLM request throttling utilities. - -This module provides concurrency control for LLM API requests to prevent -overwhelming the system with simultaneous calls. -""" - -import asyncio - -DEFAULT_LLM_CONCURRENCY = 20 -_llm_concurrency_limit: int = DEFAULT_LLM_CONCURRENCY -_llm_semaphore: asyncio.Semaphore | None = None -_llm_semaphore_loop: asyncio.AbstractEventLoop | None = None - - -def get_llm_semaphore() -> asyncio.Semaphore: - """Get the LLM semaphore, creating with configured limit if not set. - - The semaphore is recreated if called from a different event loop than - the one it was originally created in. This prevents "bound to a different - event loop" errors when using multiple asyncio.run() calls. - """ - global _llm_semaphore, _llm_semaphore_loop - - loop = asyncio.get_running_loop() - - # Recreate semaphore if it doesn't exist or if the event loop changed - if _llm_semaphore is None or _llm_semaphore_loop is not loop: - _llm_semaphore = asyncio.Semaphore(_llm_concurrency_limit) - _llm_semaphore_loop = loop - - return _llm_semaphore - - -def set_llm_concurrency(limit: int) -> None: - """Set the max concurrent LLM requests. Call before making any LLM calls. - - Args: - limit: Maximum number of concurrent LLM requests allowed (must be > 0). - - Raises: - ValueError: If limit is less than 1. - """ - if limit < 1: - raise ValueError("LLM concurrency limit must be at least 1") - - global _llm_concurrency_limit, _llm_semaphore, _llm_semaphore_loop - _llm_concurrency_limit = limit - _llm_semaphore = None - _llm_semaphore_loop = None diff --git a/src/uipath/platform/common/__init__.py b/src/uipath/platform/common/__init__.py deleted file mode 100644 index 1503007e8..000000000 --- a/src/uipath/platform/common/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -"""UiPath Common Models. - -This module contains common models used across multiple services. -""" - -from ._api_client import ApiClient -from ._base_service import BaseService -from ._config import UiPathApiConfig, UiPathConfig -from ._execution_context import UiPathExecutionContext -from ._external_application_service import ExternalApplicationService -from ._folder_context import FolderContext -from .auth import TokenData -from .interrupt_models import ( - CreateBatchTransform, - CreateDeepRag, - CreateEphemeralIndex, - CreateEscalation, - CreateTask, - DocumentExtraction, - DocumentExtractionValidation, - InvokeProcess, - InvokeSystemAgent, - WaitBatchTransform, - WaitDeepRag, - WaitDocumentExtraction, - WaitDocumentExtractionValidation, - WaitEphemeralIndex, - WaitEscalation, - WaitJob, - WaitSystemAgent, - WaitTask, -) -from .paging import PagedResult - -__all__ = [ - "ApiClient", - "BaseService", - "UiPathApiConfig", - "UiPathExecutionContext", - "ExternalApplicationService", - "FolderContext", - "TokenData", - "UiPathConfig", - "CreateTask", - "CreateEscalation", - "WaitEscalation", - "InvokeProcess", - "WaitTask", - "WaitJob", - "PagedResult", - "CreateDeepRag", - "WaitDeepRag", - "CreateBatchTransform", - "WaitBatchTransform", - "DocumentExtraction", - "WaitDocumentExtraction", - "InvokeSystemAgent", - "WaitSystemAgent", - "CreateEphemeralIndex", - "WaitEphemeralIndex", - "DocumentExtractionValidation", - "WaitDocumentExtractionValidation", -] diff --git a/src/uipath/platform/common/_api_client.py b/src/uipath/platform/common/_api_client.py deleted file mode 100644 index da9b91f4a..000000000 --- a/src/uipath/platform/common/_api_client.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Any, Literal, Union - -from httpx import URL, Response - -from ._base_service import BaseService -from ._config import UiPathApiConfig -from ._execution_context import UiPathExecutionContext -from ._folder_context import FolderContext - - -class ApiClient(FolderContext, BaseService): - """Low-level client for making direct HTTP requests to the UiPath API. - - This class provides a flexible way to interact with the UiPath API when the - higher-level service classes don't provide the needed functionality. It inherits - from both FolderContext and BaseService to provide folder-aware request capabilities - with automatic authentication and retry logic. - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - def request( - self, - method: str, - url: Union[URL, str], - scoped: Literal["org", "tenant"] = "tenant", - **kwargs: Any, - ) -> Response: - if kwargs.get("include_folder_headers", False): - kwargs["headers"] = { - **kwargs.get("headers", self._client.headers), - **self.folder_headers, - } - - if "include_folder_headers" in kwargs: - del kwargs["include_folder_headers"] - - return super().request(method, url, scoped=scoped, **kwargs) - - async def request_async( - self, - method: str, - url: Union[URL, str], - scoped: Literal["org", "tenant"] = "tenant", - **kwargs: Any, - ) -> Response: - if kwargs.get("include_folder_headers", False): - kwargs["headers"] = { - **kwargs.get("headers", self._client_async.headers), - **self.folder_headers, - } - - if "include_folder_headers" in kwargs: - del kwargs["include_folder_headers"] - - return await super().request_async(method, url, scoped=scoped, **kwargs) diff --git a/src/uipath/platform/common/_base_service.py b/src/uipath/platform/common/_base_service.py deleted file mode 100644 index 65977d506..000000000 --- a/src/uipath/platform/common/_base_service.py +++ /dev/null @@ -1,192 +0,0 @@ -import inspect -from logging import getLogger -from typing import Any, Literal, Union - -from httpx import ( - URL, - AsyncClient, - Client, - ConnectTimeout, - Headers, - HTTPStatusError, - Response, - TimeoutException, -) -from tenacity import ( - retry, - retry_if_exception, - retry_if_result, - wait_exponential, -) - -from ..._utils import UiPathUrl, user_agent_value -from ..._utils._ssl_context import get_httpx_client_kwargs -from ..._utils.constants import HEADER_USER_AGENT -from ..errors import EnrichedException -from ._config import UiPathApiConfig -from ._execution_context import UiPathExecutionContext - - -def is_retryable_exception(exception: BaseException) -> bool: - return isinstance(exception, (ConnectTimeout, TimeoutException)) - - -def is_retryable_status_code(response: Response) -> bool: - return response.status_code >= 500 and response.status_code < 600 - - -class BaseService: - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - self._logger = getLogger("uipath") - self._config = config - self._execution_context = execution_context - - self._url = UiPathUrl(self._config.base_url) - - default_client_kwargs = get_httpx_client_kwargs() - - client_kwargs = { - **default_client_kwargs, # SSL, proxy, timeout, redirects - "base_url": self._url.base_url, - "headers": Headers(self.default_headers), - } - - self._client = Client(**client_kwargs) - self._client_async = AsyncClient(**client_kwargs) - - self._logger.debug(f"HEADERS: {self.default_headers}") - - super().__init__() - - @retry( - retry=( - retry_if_exception(is_retryable_exception) - | retry_if_result(is_retryable_status_code) - ), - wait=wait_exponential(multiplier=1, min=1, max=10), - ) - def request( - self, - method: str, - url: Union[URL, str], - *, - scoped: Literal["org", "tenant"] = "tenant", - **kwargs: Any, - ) -> Response: - self._logger.debug(f"Request: {method} {url}") - self._logger.debug(f"HEADERS: {kwargs.get('headers', self._client.headers)}") - - try: - stack = inspect.stack() - - # use the third frame because of the retry decorator - caller_frame = stack[3].frame - function_name = caller_frame.f_code.co_name - - if "self" in caller_frame.f_locals: - module_name = type(caller_frame.f_locals["self"]).__name__ - elif "cls" in caller_frame.f_locals: - module_name = caller_frame.f_locals["cls"].__name__ - else: - module_name = "" - except Exception: - function_name = "" - module_name = "" - - specific_component = ( - f"{module_name}.{function_name}" if module_name and function_name else "" - ) - - kwargs.setdefault("headers", {}) - kwargs["headers"][HEADER_USER_AGENT] = user_agent_value(specific_component) - - scoped_url = self._url.scope_url(str(url), scoped) - - response = self._client.request(method, scoped_url, **kwargs) - - try: - response.raise_for_status() - except HTTPStatusError as e: - # include the http response in the error message - raise EnrichedException(e) from e - - return response - - @retry( - retry=( - retry_if_exception(is_retryable_exception) - | retry_if_result(is_retryable_status_code) - ), - wait=wait_exponential(multiplier=1, min=1, max=10), - ) - async def request_async( - self, - method: str, - url: Union[URL, str], - *, - scoped: Literal["org", "tenant"] = "tenant", - **kwargs: Any, - ) -> Response: - self._logger.debug(f"Request: {method} {url}") - self._logger.debug( - f"HEADERS: {kwargs.get('headers', self._client_async.headers)}" - ) - - kwargs.setdefault("headers", {}) - kwargs["headers"][HEADER_USER_AGENT] = user_agent_value( - self._specific_component - ) - - scoped_url = self._url.scope_url(str(url), scoped) - - response = await self._client_async.request(method, scoped_url, **kwargs) - - try: - response.raise_for_status() - except HTTPStatusError as e: - # include the http response in the error message - raise EnrichedException(e) from e - return response - - @property - def default_headers(self) -> dict[str, str]: - return { - "Accept": "application/json", - **self.auth_headers, - **self.custom_headers, - } - - @property - def auth_headers(self) -> dict[str, str]: - header = f"Bearer {self._config.secret}" - return {"Authorization": header} - - @property - def custom_headers(self) -> dict[str, str]: - return {} - - @property - def _specific_component(self) -> str: - try: - stack = inspect.stack() - - caller_frame = stack[4].frame - function_name = caller_frame.f_code.co_name - - if "self" in caller_frame.f_locals: - module_name = type(caller_frame.f_locals["self"]).__name__ - elif "cls" in caller_frame.f_locals: - module_name = caller_frame.f_locals["cls"].__name__ - else: - module_name = "" - except Exception: - function_name = "" - module_name = "" - - specific_component = ( - f"{module_name}.{function_name}" if module_name and function_name else "" - ) - - return specific_component diff --git a/src/uipath/platform/common/_config.py b/src/uipath/platform/common/_config.py deleted file mode 100644 index 5d790a340..000000000 --- a/src/uipath/platform/common/_config.py +++ /dev/null @@ -1,136 +0,0 @@ -import os -from pathlib import Path - -from pydantic import BaseModel - - -class UiPathApiConfig(BaseModel): - base_url: str - secret: str - - -class ConfigurationManager: - _instance = None - studio_solution_id: str | None = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - @property - def bindings_file_path(self) -> Path: - from uipath._utils.constants import UIPATH_BINDINGS_FILE - - return Path(UIPATH_BINDINGS_FILE) - - @property - def config_file_path(self) -> Path: - from uipath._utils.constants import UIPATH_CONFIG_FILE - - return Path(UIPATH_CONFIG_FILE) - - @property - def config_file_name(self) -> str: - from uipath._utils.constants import UIPATH_CONFIG_FILE - - return UIPATH_CONFIG_FILE - - @property - def project_id(self) -> str | None: - from uipath._utils.constants import ENV_UIPATH_PROJECT_ID - - return os.getenv(ENV_UIPATH_PROJECT_ID, None) - - @property - def project_key(self) -> str | None: - from uipath._utils.constants import ENV_PROJECT_KEY - - return os.getenv(ENV_PROJECT_KEY, None) - - @property - def tenant_name(self) -> str | None: - from uipath._utils.constants import ENV_TENANT_NAME - - return os.getenv(ENV_TENANT_NAME, None) - - @property - def organization_id(self) -> str | None: - from uipath._utils.constants import ENV_ORGANIZATION_ID - - return os.getenv(ENV_ORGANIZATION_ID, None) - - @property - def base_url(self) -> str | None: - from uipath._utils.constants import ENV_BASE_URL - - return os.getenv(ENV_BASE_URL, None) - - @property - def folder_key(self) -> str | None: - from uipath._utils.constants import ENV_FOLDER_KEY - - return os.getenv(ENV_FOLDER_KEY, None) - - @property - def process_uuid(self) -> str | None: - from uipath._utils.constants import ENV_UIPATH_PROCESS_UUID - - return os.getenv(ENV_UIPATH_PROCESS_UUID, None) - - @property - def trace_id(self) -> str | None: - from uipath._utils.constants import ENV_UIPATH_TRACE_ID - - return os.getenv(ENV_UIPATH_TRACE_ID, None) - - @property - def process_version(self) -> str | None: - from uipath._utils.constants import ENV_UIPATH_PROCESS_VERSION - - return os.getenv(ENV_UIPATH_PROCESS_VERSION, None) - - @property - def is_studio_project(self) -> bool: - return self.project_id is not None - - @property - def job_key(self) -> str | None: - from uipath._utils.constants import ENV_JOB_KEY - - return os.getenv(ENV_JOB_KEY, None) - - @property - def has_legacy_eval_folder(self) -> bool: - from uipath._utils.constants import LEGACY_EVAL_FOLDER - - eval_path = Path(os.getcwd()) / LEGACY_EVAL_FOLDER - return eval_path.exists() and eval_path.is_dir() - - @property - def has_eval_folder(self) -> bool: - from uipath._utils.constants import EVALS_FOLDER - - coded_eval_path = Path(os.getcwd()) / EVALS_FOLDER - return coded_eval_path.exists() and coded_eval_path.is_dir() - - @property - def entry_points_file_path(self) -> Path: - from uipath._utils.constants import ENTRY_POINTS_FILE - - return Path(ENTRY_POINTS_FILE) - - @property - def studio_metadata_file_path(self) -> Path: - from uipath._utils.constants import STUDIO_METADATA_FILE - - return Path(".uipath", STUDIO_METADATA_FILE) - - @property - def is_tracing_enabled(self) -> bool: - from uipath._utils.constants import ENV_TRACING_ENABLED - - return os.getenv(ENV_TRACING_ENABLED, "true").lower() == "true" - - -UiPathConfig = ConfigurationManager() diff --git a/src/uipath/platform/common/_execution_context.py b/src/uipath/platform/common/_execution_context.py deleted file mode 100644 index ee1ea33b7..000000000 --- a/src/uipath/platform/common/_execution_context.py +++ /dev/null @@ -1,106 +0,0 @@ -from os import environ as env - -from uipath._utils.constants import ENV_JOB_ID, ENV_JOB_KEY, ENV_ROBOT_KEY - - -def _get_action_id_from_context() -> str | None: - """Get action_id from evaluation context if available.""" - try: - # Import here to avoid circular dependency - from uipath._cli._evals.mocks.mocks import eval_set_run_id_context - - return eval_set_run_id_context.get() - except (ImportError, LookupError): - return None - - -class UiPathExecutionContext: - """Manages the execution context for UiPath automation processes. - - The UiPathExecutionContext class handles information about the current execution environment, - including the job instance ID and robot key. This information is essential for - tracking and managing automation jobs in UiPath Automation Cloud. - """ - - def __init__( - self, - requesting_product: str | None = None, - requesting_feature: str | None = None, - agenthub_config: str | None = None, - action_id: str | None = None, - ) -> None: - try: - self._instance_key: str | None = env[ENV_JOB_KEY] - except KeyError: - self._instance_key = None - - try: - self._instance_id: str | None = env[ENV_JOB_ID] - except KeyError: - self._instance_id = None - - try: - self._robot_key: str | None = env[ENV_ROBOT_KEY] - except KeyError: - self._robot_key = None - - # LLM Gateway headers for product/feature identification - self.requesting_product = requesting_product or "uipath-python-sdk" - self.requesting_feature = requesting_feature or "llm-call" - - # AgentHub configuration header - tells AgentHub how to route/configure the request - self.agenthub_config = agenthub_config - - # Action ID for grouping related LLM calls in observability/audit logs - # If not provided explicitly, try to get it from eval context - self.action_id = action_id or _get_action_id_from_context() - - super().__init__() - - @property - def instance_id(self) -> str | None: - """Get the current job instance ID. - - The instance ID uniquely identifies the current automation job execution - in UiPath Automation Cloud. - - Returns: - Optional[str]: The job instance ID. - - Raises: - ValueError: If the instance ID is not set in the environment. - """ - if self._instance_id is None: - raise ValueError(f"Instance ID is not set ({ENV_JOB_ID})") - - return self._instance_id - - @property - def instance_key(self) -> str | None: - """Get the current job instance key. - - The instance key uniquely identifies the current automation job execution - in UiPath Automation Cloud. - """ - if self._instance_key is None: - raise ValueError(f"Instance key is not set ({ENV_JOB_KEY})") - - return self._instance_key - - @property - def robot_key(self) -> str | None: - """Get the current robot key. - - The robot key identifies the UiPath Robot that is executing the current - automation job. - - Returns: - Optional[str]: The robot key. - - Raises: - ValueError: If the robot key is not set in the environment. - """ - if self._robot_key is None: - raise ValueError(f"Robot key is not set ({ENV_ROBOT_KEY})") - - return self._robot_key diff --git a/src/uipath/platform/common/_external_application_service.py b/src/uipath/platform/common/_external_application_service.py deleted file mode 100644 index ae4e543e4..000000000 --- a/src/uipath/platform/common/_external_application_service.py +++ /dev/null @@ -1,140 +0,0 @@ -from os import environ as env -from typing import Optional -from urllib.parse import urlparse - -import httpx -from httpx import HTTPStatusError, Request - -from ..._utils._ssl_context import get_httpx_client_kwargs -from ..._utils.constants import ENV_BASE_URL -from ..errors import EnrichedException -from .auth import TokenData - - -class ExternalApplicationService: - """Service for client credentials authentication flow.""" - - def __init__(self, base_url: Optional[str]): - if not (resolved_base_url := (base_url or env.get(ENV_BASE_URL))): - raise ValueError( - "Base URL must be set either via constructor or the BASE_URL environment variable." - ) - self._base_url = resolved_base_url - self._domain = self._extract_environment_from_base_url(self._base_url) - - def get_token_url(self) -> str: - """Get the token URL for the specified domain.""" - match self._domain: - case "alpha": - return "https://alpha.uipath.com/identity_/connect/token" - case "staging": - return "https://staging.uipath.com/identity_/connect/token" - case _: # cloud (default) - return "https://cloud.uipath.com/identity_/connect/token" - - def _is_valid_domain_or_subdomain(self, hostname: str, domain: str) -> bool: - """Check if hostname is either an exact match or a valid subdomain of the domain. - - Args: - hostname: The hostname to check - domain: The domain to validate against - - Returns: - True if hostname is valid domain or subdomain, False otherwise - """ - return hostname == domain or hostname.endswith(f".{domain}") - - def _extract_environment_from_base_url(self, base_url: str) -> str: - """Extract domain from base URL. - - Args: - base_url: The base URL to extract domain from - - Returns: - The domain (alpha, staging, or cloud) - """ - try: - parsed = urlparse(base_url) - hostname = parsed.hostname - - if hostname: - match hostname: - case h if self._is_valid_domain_or_subdomain(h, "alpha.uipath.com"): - return "alpha" - case h if self._is_valid_domain_or_subdomain( - h, "staging.uipath.com" - ): - return "staging" - case h if self._is_valid_domain_or_subdomain(h, "cloud.uipath.com"): - return "cloud" - - # Default to cloud if we can't determine - return "cloud" - except Exception: - # Default to cloud if parsing fails - return "cloud" - - def get_token_data( - self, client_id: str, client_secret: str, scope: Optional[str] = "OR.Execution" - ) -> TokenData: - """Authenticate using client credentials flow. - - Args: - client_id: The client ID for authentication - client_secret: The client secret for authentication - scope: The scope for the token (default: OR.Execution) - - Returns: - Token data if successful - """ - token_url = self.get_token_url() - - data = { - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": scope, - } - - try: - with httpx.Client(**get_httpx_client_kwargs()) as client: - response = client.post(token_url, data=data) - match response.status_code: - case 200: - return TokenData.model_validate(response.json()) - case 400: - raise EnrichedException( - HTTPStatusError( - message="Invalid client credentials or request parameters.", - request=Request( - data=data, url=token_url, method="post" - ), - response=response, - ) - ) - case 401: - raise EnrichedException( - HTTPStatusError( - message="Unauthorized: Invalid client credentials.", - request=Request( - data=data, url=token_url, method="post" - ), - response=response, - ) - ) - case _: - raise EnrichedException( - HTTPStatusError( - message=f"Authentication failed with unexpected status: {response.status_code}", - request=Request( - data=data, url=token_url, method="post" - ), - response=response, - ) - ) - except EnrichedException: - raise - except httpx.RequestError as e: - raise Exception(f"Network error during authentication: {e}") from e - except Exception as e: - raise Exception(f"Unexpected error during authentication: {e}") from e diff --git a/src/uipath/platform/common/_folder_context.py b/src/uipath/platform/common/_folder_context.py deleted file mode 100644 index 2b2a76dca..000000000 --- a/src/uipath/platform/common/_folder_context.py +++ /dev/null @@ -1,53 +0,0 @@ -from os import environ as env -from typing import Any - -from uipath._utils.constants import ( - ENV_FOLDER_KEY, - ENV_FOLDER_PATH, - HEADER_FOLDER_KEY, - HEADER_FOLDER_PATH, -) - - -class FolderContext: - """Manages the folder context for UiPath automation resources. - - The FolderContext class handles information about the current folder in which - automation resources (like processes, assets, etc.) are being accessed or modified. - This is essential for organizing and managing resources in the UiPath Automation Cloud - folder structure. - """ - - def __init__(self, **kwargs: Any) -> None: - try: - self._folder_key: str | None = env[ENV_FOLDER_KEY] - except KeyError: - self._folder_key = None - - try: - self._folder_path: str | None = env[ENV_FOLDER_PATH] - except KeyError: - self._folder_path = None - - super().__init__(**kwargs) - - @property - def folder_headers(self) -> dict[str, str]: - """Get the HTTP headers for folder-based API requests. - - Returns headers containing either the folder key or folder path, - which are used to specify the target folder for API operations. - The folder context is essential for operations that need to be - performed within a specific folder in UiPath Automation Cloud. - - Returns: - dict[str, str]: A dictionary containing the appropriate folder - header (either folder key or folder path). If no folder header is - set as environment variable, the function returns an empty dictionary. - """ - if self._folder_key is not None: - return {HEADER_FOLDER_KEY: self._folder_key} - elif self._folder_path is not None: - return {HEADER_FOLDER_PATH: self._folder_path} - else: - return {} diff --git a/src/uipath/platform/common/auth.py b/src/uipath/platform/common/auth.py deleted file mode 100644 index 03d891940..000000000 --- a/src/uipath/platform/common/auth.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Module defining the TokenData model for authentication tokens.""" - -from typing import Optional - -from pydantic import BaseModel - - -class TokenData(BaseModel): - """Pydantic model for token data structure.""" - - access_token: str - refresh_token: Optional[str] = None - expires_in: Optional[int] = None - token_type: Optional[str] = None - scope: Optional[str] = None - id_token: Optional[str] = None diff --git a/src/uipath/platform/common/interrupt_models.py b/src/uipath/platform/common/interrupt_models.py deleted file mode 100644 index f18043d66..000000000 --- a/src/uipath/platform/common/interrupt_models.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Models for interrupt operations in UiPath platform.""" - -from typing import Annotated, Any - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from uipath.platform.context_grounding.context_grounding_index import ( - ContextGroundingIndex, -) - -from ..action_center.tasks import Task, TaskRecipient -from ..attachments import Attachment -from ..context_grounding import ( - BatchTransformCreationResponse, - BatchTransformOutputColumn, - CitationMode, - DeepRagCreationResponse, - EphemeralIndexUsage, -) -from ..documents import ( - ActionPriority, - ExtractionResponseIXP, - FileContent, - StartExtractionResponse, -) -from ..documents.documents import StartExtractionValidationResponse -from ..orchestrator.job import Job - - -class InvokeProcess(BaseModel): - """Model representing a process invocation.""" - - name: str - process_folder_path: str | None = None - process_folder_key: str | None = None - input_arguments: dict[str, Any] | None - attachments: list[Attachment] | None = None - - -class WaitJob(BaseModel): - """Model representing a wait job operation.""" - - job: Job - process_folder_path: str | None = None - process_folder_key: str | None = None - - -class CreateTask(BaseModel): - """Model representing an action creation.""" - - title: str - data: dict[str, Any] | None = None - assignee: str | None = "" - recipient: TaskRecipient | None = None - app_name: str | None = None - app_folder_path: str | None = None - app_folder_key: str | None = None - app_key: str | None = None - priority: str | None = None - labels: list[str] | None = None - is_actionable_message_enabled: bool | None = None - actionable_message_metadata: dict[str, Any] | None = None - source_name: str = "Agent" - - -class CreateEscalation(CreateTask): - """Model representing an escalation creation.""" - - pass - - -class WaitTask(BaseModel): - """Model representing a wait action operation.""" - - action: Task - app_folder_path: str | None = None - app_folder_key: str | None = None - app_name: str | None = None - recipient: TaskRecipient | None = None - - -class WaitEscalation(WaitTask): - """Model representing a wait escalation operation.""" - - pass - - -class CreateDeepRag(BaseModel): - """Model representing a Deep RAG task creation.""" - - name: str - index_name: Annotated[str, Field(max_length=512)] | None = None - index_id: Annotated[str, Field(max_length=512)] | None = None - prompt: Annotated[str, Field(max_length=250000)] - glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**" - citation_mode: CitationMode = CitationMode.SKIP - index_folder_key: str | None = None - index_folder_path: str | None = None - is_ephemeral_index: bool | None = None - - @model_validator(mode="after") - def validate_ephemeral_index_requires_index_id(self) -> "CreateDeepRag": - """Validate that if it is an ephemeral index that it is using index id.""" - if self.is_ephemeral_index is True and self.index_id is None: - raise ValueError("Index id must be provided for an ephemeral index") - return self - - -class WaitDeepRag(BaseModel): - """Model representing a wait Deep RAG task.""" - - deep_rag: DeepRagCreationResponse - index_folder_path: str | None = None - index_folder_key: str | None = None - - -class CreateEphemeralIndex(BaseModel): - """Model representing a Ephemeral Index task creation.""" - - usage: EphemeralIndexUsage - attachments: list[str] - - -class WaitEphemeralIndex(BaseModel): - """Model representing a wait Ephemeral Index task.""" - - index: ContextGroundingIndex - - -class CreateBatchTransform(BaseModel): - """Model representing a Batch Transform task creation.""" - - name: str - index_name: str | None = None - index_id: Annotated[str, Field(max_length=512)] | None = None - prompt: Annotated[str, Field(max_length=250000)] - output_columns: list[BatchTransformOutputColumn] - storage_bucket_folder_path_prefix: Annotated[str | None, Field(max_length=512)] = ( - None - ) - enable_web_search_grounding: bool = False - destination_path: str - index_folder_key: str | None = None - index_folder_path: str | None = None - is_ephemeral_index: bool | None = None - - @model_validator(mode="after") - def validate_ephemeral_index_requires_index_id(self) -> "CreateBatchTransform": - """Validate that if it is an ephemeral index that it is using index id.""" - if self.is_ephemeral_index is True and self.index_id is None: - raise ValueError("Index id must be provided for an ephemeral index") - return self - - -class WaitBatchTransform(BaseModel): - """Model representing a wait Batch Transform task.""" - - batch_transform: BatchTransformCreationResponse - index_folder_path: str | None = None - index_folder_key: str | None = None - - -class InvokeSystemAgent(BaseModel): - """Model representing a system agent job invocation.""" - - agent_name: str - entrypoint: str - input_arguments: dict[str, Any] | None = None - folder_path: str | None = None - folder_key: str | None = None - - -class WaitSystemAgent(BaseModel): - """Model representing a wait system agent job invocation.""" - - job_key: str - process_folder_path: str | None = None - process_folder_key: str | None = None - - -class DocumentExtraction(BaseModel): - """Model representing a document extraction task creation.""" - - project_name: str - tag: str - file: FileContent | None = None - file_path: str | None = None - - model_config = ConfigDict( - arbitrary_types_allowed=True, - ) - - @model_validator(mode="after") - def validate_exactly_one_file_source(self) -> "DocumentExtraction": - """Validate that exactly one of file or file_path is provided.""" - if (self.file is None) == (self.file_path is None): - raise ValueError( - "Exactly one of 'file' or 'file_path' must be provided, not both or neither" - ) - return self - - -class WaitDocumentExtraction(BaseModel): - """Model representing a wait document extraction task creation.""" - - extraction: StartExtractionResponse - - -class DocumentExtractionValidation(BaseModel): - """Model representing a document extraction task creation.""" - - extraction_response: ExtractionResponseIXP - action_title: str - action_catalog: str | None = None - action_priority: ActionPriority | None = None - action_folder: str | None = None - storage_bucket_name: str | None = None - storage_bucket_directory_path: str | None = None - - -class WaitDocumentExtractionValidation(BaseModel): - """Model representing a wait document extraction task creation.""" - - extraction_validation: StartExtractionValidationResponse - task_url: str | None = None diff --git a/src/uipath/platform/common/paging.py b/src/uipath/platform/common/paging.py deleted file mode 100644 index 5fe41d0cc..000000000 --- a/src/uipath/platform/common/paging.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Pagination result types for UiPath SDK.""" - -from dataclasses import dataclass -from typing import Generic, Iterator, List, Optional, TypeVar - -__all__ = ["PagedResult"] - -T = TypeVar("T") - - -@dataclass(frozen=True) -class PagedResult(Generic[T]): - """Container for a single page of results from a paginated API. - - Attributes: - items: The list of items in this page - continuation_token: Token to fetch next page (REST APIs) - has_more: Whether more results likely exist (OData APIs) - skip: Number of items skipped (OData APIs) - top: Maximum items requested (OData APIs) - - Example: - # Offset-based pagination (OData) - skip = 0 - while True: - result = sdk.buckets.list(skip=skip, top=100) - for bucket in result.items: - process(bucket) - if not result.has_more: - break - skip += 100 - - # Cursor-based pagination (REST) - token = None - while True: - result = sdk.buckets.list_files( - name="my-storage", - continuation_token=token - ) - for file in result.items: - process(file) - if not result.continuation_token: - break - token = result.continuation_token - """ - - items: List[T] - continuation_token: Optional[str] = None - has_more: Optional[bool] = None - skip: Optional[int] = None - top: Optional[int] = None - - def __iter__(self) -> Iterator[T]: - """Allow iteration over items directly.""" - return iter(self.items) - - def __len__(self) -> int: - """Return the number of items in this page.""" - return len(self.items) - - def __bool__(self) -> bool: - """Return True if page contains items.""" - return bool(self.items) diff --git a/src/uipath/platform/connections/__init__.py b/src/uipath/platform/connections/__init__.py deleted file mode 100644 index 2a29b94ae..000000000 --- a/src/uipath/platform/connections/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""UiPath Connections Models. - -This module contains models related to UiPath Connections service. -""" - -from ._connections_service import ConnectionsService -from .connections import ( - ActivityMetadata, - ActivityParameterLocationInfo, - Connection, - ConnectionMetadata, - ConnectionToken, - ConnectionTokenType, - EventArguments, -) - -__all__ = [ - "ConnectionsService", - "ActivityMetadata", - "ActivityParameterLocationInfo", - "Connection", - "ConnectionMetadata", - "ConnectionToken", - "ConnectionTokenType", - "EventArguments", -] diff --git a/src/uipath/platform/connections/_connections_service.py b/src/uipath/platform/connections/_connections_service.py deleted file mode 100644 index cb78508a2..000000000 --- a/src/uipath/platform/connections/_connections_service.py +++ /dev/null @@ -1,811 +0,0 @@ -import json -import logging -from typing import Any, Dict, List, Optional -from urllib.parse import parse_qsl, quote, urlsplit - -from httpx import Response - -from ..._utils import Endpoint, RequestSpec, header_folder, resource_override -from ...tracing import traced -from ..common import BaseService, UiPathApiConfig, UiPathExecutionContext -from ..orchestrator._folder_service import FolderService -from .connections import ( - ActivityMetadata, - Connection, - ConnectionMetadata, - ConnectionToken, - ConnectionTokenType, - EventArguments, -) - -logger: logging.Logger = logging.getLogger("uipath") - - -class ConnectionsService(BaseService): - """Service for managing UiPath external service connections. - - This service provides methods to retrieve direct connection information retrieval - and secure token management. - """ - - def __init__( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folders_service: FolderService, - ) -> None: - super().__init__(config=config, execution_context=execution_context) - self._folders_service = folders_service - - @resource_override("connection", resource_identifier="key") - @traced( - name="connections_retrieve", - run_type="uipath", - hide_output=True, - ) - def retrieve(self, key: str) -> Connection: - """Retrieve connection details by its key. - - This method fetches the configuration and metadata for a connection, - which can be used to establish communication with an external service. - - Args: - key (str): The unique identifier of the connection to retrieve. - - Returns: - Connection: The connection details, including configuration parameters - and authentication information. - """ - spec = self._retrieve_spec(key) - response = self.request(spec.method, url=spec.endpoint) - return Connection.model_validate(response.json()) - - @traced( - name="connections_metadata", - run_type="uipath", - hide_output=True, - ) - def metadata( - self, - element_instance_id: int, - connector_key: str, - tool_path: str, - parameters: Optional[Dict[str, str]] = None, - schema_mode: bool = True, - max_jit_depth: int = 5, - ) -> ConnectionMetadata: - """Synchronously retrieve connection API metadata. - - This method fetches the metadata for a connection. When parameters are provided, - it automatically fetches JIT (Just-In-Time) metadata for cascading fields in a loop, - following action URLs up to a maximum depth. - - Args: - element_instance_id (int): The element instance ID of the connection. - connector_key (str): The connector key (e.g., 'uipath-atlassian-jira', 'uipath-slack'). - tool_path (str): The tool path to retrieve metadata for. - parameters (Optional[Dict[str, str]]): Parameter values. When provided, triggers - automatic JIT fetching for cascading fields. - schema_mode (bool): Whether or not to represent the output schema in the response fields. - max_jit_depth (int): The maximum depth of the JIT resolution loop. - - Returns: - ConnectionMetadata: The connection metadata. - - Examples: - >>> metadata = sdk.connections.metadata( - ... element_instance_id=123, - ... connector_key="uipath-atlassian-jira", - ... tool_path="Issue", - ... parameters={"projectId": "PROJ-123"} # Optional - ... ) - """ - spec = self._metadata_spec( - element_instance_id, connector_key, tool_path, schema_mode - ) - response = self.request( - spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers - ) - data = response.json() - metadata = ConnectionMetadata.model_validate(data) - - last_action_url = None - depth = 0 - - while ( - parameters - and (action_url := self._get_jit_action_url(metadata)) - and depth < max_jit_depth - ): - # Stop if we're about to call the same URL template again - if action_url == last_action_url: - break - - last_action_url = action_url - depth += 1 - - jit_spec = self._metadata_jit_spec( - element_instance_id, action_url, parameters, schema_mode - ) - jit_response = self.request( - jit_spec.method, - url=jit_spec.endpoint, - params=jit_spec.params, - headers=jit_spec.headers, - ) - data = jit_response.json() - metadata = ConnectionMetadata.model_validate(data) - - return metadata - - @traced(name="connections_list", run_type="uipath") - def list( - self, - *, - name: Optional[str] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - connector_key: Optional[str] = None, - skip: Optional[int] = None, - top: Optional[int] = None, - ) -> List[Connection]: - """Lists all connections with optional filtering. - - Args: - name: Optional connection name to filter (supports partial matching) - folder_path: Optional folder path for filtering connections - folder_key: Optional folder key (mutually exclusive with folder_path) - connector_key: Optional connector key to filter by specific connector type - skip: Number of records to skip (for pagination) - top: Maximum number of records to return - - Returns: - List[Connection]: List of connection instances - - Raises: - ValueError: If both folder_path and folder_key are provided together, or if - folder_path is provided but cannot be resolved to a folder_key - - Examples: - >>> # List all connections - >>> connections = sdk.connections.list() - - >>> # Find connections by name - >>> salesforce_conns = sdk.connections.list(name="Salesforce") - - >>> # List all Slack connections in Finance folder - >>> connections = sdk.connections.list( - ... folder_path="Finance", - ... connector_key="uipath-slack" - ... ) - """ - spec = self._list_spec( - name=name, - folder_key=self._folders_service.retrieve_folder_key(folder_path), - connector_key=connector_key, - skip=skip, - top=top, - ) - response = self.request( - spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers - ) - - return self._parse_and_validate_list_response(response) - - @traced(name="connections_list", run_type="uipath") - async def list_async( - self, - *, - name: Optional[str] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - connector_key: Optional[str] = None, - skip: Optional[int] = None, - top: Optional[int] = None, - ) -> List[Connection]: - """Asynchronously lists all connections with optional filtering. - - Args: - name: Optional connection name to filter (supports partial matching) - folder_path: Optional folder path for filtering connections - folder_key: Optional folder key (mutually exclusive with folder_path) - connector_key: Optional connector key to filter by specific connector type - skip: Number of records to skip (for pagination) - top: Maximum number of records to return - - Returns: - List[Connection]: List of connection instances - - Raises: - ValueError: If both folder_path and folder_key are provided together, or if - folder_path is provided but cannot be resolved to a folder_key - - Examples: - >>> # List all connections - >>> connections = await sdk.connections.list_async() - - >>> # Find connections by name - >>> salesforce_conns = await sdk.connections.list_async(name="Salesforce") - - >>> # List all Slack connections in Finance folder - >>> connections = await sdk.connections.list_async( - ... folder_path="Finance", - ... connector_key="uipath-slack" - ... ) - """ - spec = self._list_spec( - name=name, - folder_key=await self._folders_service.retrieve_folder_key_async( - folder_path - ), - connector_key=connector_key, - skip=skip, - top=top, - ) - response = await self.request_async( - spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers - ) - - return self._parse_and_validate_list_response(response) - - @resource_override("connection", resource_identifier="key") - @traced( - name="connections_retrieve", - run_type="uipath", - hide_output=True, - ) - async def retrieve_async(self, key: str) -> Connection: - """Asynchronously retrieve connection details by its key. - - This method fetches the configuration and metadata for a connection, - which can be used to establish communication with an external service. - - Args: - key (str): The unique identifier of the connection to retrieve. - - Returns: - Connection: The connection details, including configuration parameters - and authentication information. - """ - spec = self._retrieve_spec(key) - response = await self.request_async(spec.method, url=spec.endpoint) - return Connection.model_validate(response.json()) - - @traced( - name="connections_metadata", - run_type="uipath", - hide_output=True, - ) - async def metadata_async( - self, - element_instance_id: int, - connector_key: str, - tool_path: str, - parameters: Optional[Dict[str, str]] = None, - schema_mode: bool = True, - max_jit_depth: int = 5, - ) -> ConnectionMetadata: - """Asynchronously retrieve connection API metadata. - - This method fetches the metadata for a connection. When parameters are provided, - it automatically fetches JIT (Just-In-Time) metadata for cascading fields in a loop, - following action URLs up to a maximum depth. - - Args: - element_instance_id (int): The element instance ID of the connection. - connector_key (str): The connector key (e.g., 'uipath-atlassian-jira', 'uipath-slack'). - tool_path (str): The tool path to retrieve metadata for. - parameters (Optional[Dict[str, str]]): Parameter values. When provided, triggers - automatic JIT fetching for cascading fields. - schema_mode (bool): Whether or not to represent the output schema in the response fields. - max_jit_depth (int): The maximum depth of the JIT resolution loop. - - Returns: - ConnectionMetadata: The connection metadata. - - Examples: - >>> metadata = await sdk.connections.metadata_async( - ... element_instance_id=123, - ... connector_key="uipath-atlassian-jira", - ... tool_path="Issue", - ... parameters={"projectId": "PROJ-123"} # Optional - ... ) - """ - spec = self._metadata_spec( - element_instance_id, connector_key, tool_path, schema_mode - ) - response = await self.request_async( - spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers - ) - data = response.json() - metadata = ConnectionMetadata.model_validate(data) - - last_action_url = None - depth = 0 - - while ( - parameters - and (action_url := self._get_jit_action_url(metadata)) - and depth < max_jit_depth - ): - # Stop if we're about to call the same URL template again - if action_url == last_action_url: - break - - last_action_url = action_url - depth += 1 - - jit_spec = self._metadata_jit_spec( - element_instance_id, action_url, parameters, schema_mode - ) - jit_response = await self.request_async( - jit_spec.method, - url=jit_spec.endpoint, - params=jit_spec.params, - headers=jit_spec.headers, - ) - data = jit_response.json() - metadata = ConnectionMetadata.model_validate(data) - - return metadata - - @traced( - name="connections_retrieve_token", - run_type="uipath", - hide_output=True, - ) - def retrieve_token( - self, key: str, token_type: ConnectionTokenType = ConnectionTokenType.DIRECT - ) -> ConnectionToken: - """Retrieve an authentication token for a connection. - - This method obtains a fresh authentication token that can be used to - communicate with the external service. This is particularly useful for - services that use token-based authentication. - - Args: - key (str): The unique identifier of the connection. - token_type (ConnectionTokenType): The token type to use. - - Returns: - ConnectionToken: The authentication token details, including the token - value and any associated metadata. - """ - spec = self._retrieve_token_spec(key, token_type) - response = self.request(spec.method, url=spec.endpoint, params=spec.params) - return ConnectionToken.model_validate(response.json()) - - @traced( - name="connections_retrieve_token", - run_type="uipath", - hide_output=True, - ) - async def retrieve_token_async( - self, key: str, token_type: ConnectionTokenType = ConnectionTokenType.DIRECT - ) -> ConnectionToken: - """Asynchronously retrieve an authentication token for a connection. - - This method obtains a fresh authentication token that can be used to - communicate with the external service. This is particularly useful for - services that use token-based authentication. - - Args: - key (str): The unique identifier of the connection. - token_type (ConnectionTokenType): The token type to use. - - Returns: - ConnectionToken: The authentication token details, including the token - value and any associated metadata. - """ - spec = self._retrieve_token_spec(key, token_type) - response = await self.request_async( - spec.method, url=spec.endpoint, params=spec.params - ) - return ConnectionToken.model_validate(response.json()) - - @traced( - name="connections_retrieve_event_payload", - run_type="uipath", - ) - def retrieve_event_payload(self, event_args: EventArguments) -> Dict[str, Any]: - """Retrieve event payload from UiPath Integration Service. - - Args: - event_args (EventArguments): The event arguments. Should be passed along from the job's input. - - Returns: - Dict[str, Any]: The event payload data - """ - if not event_args.additional_event_data: - raise ValueError("additional_event_data is required") - - # Parse additional event data to get event id - event_data = json.loads(event_args.additional_event_data) - - event_id = None - if "processedEventId" in event_data: - event_id = event_data["processedEventId"] - elif "rawEventId" in event_data: - event_id = event_data["rawEventId"] - else: - raise ValueError("Event Id not found in additional event data") - - # Build request URL using connection token's API base URI - spec = self._retrieve_event_payload_spec("v1", event_id) - - response = self.request(spec.method, url=spec.endpoint) - - return response.json() - - @traced( - name="connections_retrieve_event_payload", - run_type="uipath", - ) - async def retrieve_event_payload_async( - self, event_args: EventArguments - ) -> Dict[str, Any]: - """Retrieve event payload from UiPath Integration Service. - - Args: - event_args (EventArguments): The event arguments. Should be passed along from the job's input. - - Returns: - Dict[str, Any]: The event payload data - """ - if not event_args.additional_event_data: - raise ValueError("additional_event_data is required") - - # Parse additional event data to get event id - event_data = json.loads(event_args.additional_event_data) - - event_id = None - if "processedEventId" in event_data: - event_id = event_data["processedEventId"] - elif "rawEventId" in event_data: - event_id = event_data["rawEventId"] - else: - raise ValueError("Event Id not found in additional event data") - - # Build request URL using connection token's API base URI - spec = self._retrieve_event_payload_spec("v1", event_id) - - response = await self.request_async(spec.method, url=spec.endpoint) - - return response.json() - - def _retrieve_event_payload_spec(self, version: str, event_id: str) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/elements_/{version}/events/{event_id}"), - ) - - def _retrieve_spec(self, key: str) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/connections_/api/v1/Connections/{key}"), - ) - - def _metadata_spec( - self, - element_instance_id: int, - connector_key: str, - tool_path: str, - schema_mode: bool, - ) -> RequestSpec: - metadata_endpoint_url = f"/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata" - return RequestSpec( - method="GET", - endpoint=Endpoint(metadata_endpoint_url), - headers={ - "accept": "application/schema+json" - if schema_mode - else "application/json" - }, - ) - - def _metadata_jit_spec( - self, - element_instance_id: int, - dynamic_path: str, - parameters: Dict[str, str], - schema_mode: bool, - ) -> RequestSpec: - """Build request spec for JIT metadata with dynamic path parameter substitution. - - For example, if the dynamic path is "elements/jira/projects/{projectId}/issues", and the parameters - are {"projectId": "PROJ-123"}, the resolved path will be "elements/jira/projects/PROJ-123/issues". - """ - for key, value in parameters.items(): - dynamic_path = dynamic_path.replace( - f"{{{key}}}", quote(str(value), safe="") - ) - split = urlsplit(dynamic_path.lstrip("/")) - query_params = dict(parse_qsl(split.query)) - - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"/elements_/v3/element/instances/{element_instance_id}/{split.path}" - ), - params=query_params, - headers={ - "accept": "application/schema+json" - if schema_mode - else "application/json" - }, - ) - - def _get_jit_action_url( - self, connection_metadata: ConnectionMetadata - ) -> Optional[str]: - """Return the URL of the JIT action that should be triggered dynamically.""" - if "method" not in connection_metadata.metadata: - return None - - methods = connection_metadata.metadata["method"] - actions = [ - action - for method_data in methods.values() - for action in method_data.get("design", {}).get("actions", []) - if action.get("actionType") == "api" - ] - return actions[0].get("apiConfiguration", {}).get("url") if actions else None - - def _retrieve_token_spec( - self, key: str, token_type: ConnectionTokenType = ConnectionTokenType.DIRECT - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/connections_/api/v1/Connections/{key}/token"), - params={"tokenType": token_type.value}, - ) - - def _parse_and_validate_list_response(self, response: Response) -> List[Connection]: - """Parse and validate the list response from the API. - - Handles both OData response format (with 'value' field) and raw list responses. - - Args: - response: The HTTP response from the API - - Returns: - List of validated Connection instances - """ - data = response.json() - - # Handle both OData responses (dict with 'value') and raw list responses - if isinstance(data, dict): - connections_data = data.get("value", []) - elif isinstance(data, list): - connections_data = data - else: - connections_data = [] - - return [Connection.model_validate(conn) for conn in connections_data] - - def _list_spec( - self, - name: Optional[str] = None, - folder_key: Optional[str] = None, - connector_key: Optional[str] = None, - skip: Optional[int] = None, - top: Optional[int] = None, - ) -> RequestSpec: - """Build the request specification for listing connections. - - Args: - name: Optional connection name to filter (supports partial matching) - folder_key: Optional folder key - connector_key: Optional connector key to filter by specific connector type - skip: Number of records to skip (for pagination) - top: Maximum number of records to return - - Returns: - RequestSpec with endpoint, params, and headers configured - - Raises: - ValueError: If both folder_path and folder_key are provided together, or if - folder_path is provided but cannot be resolved to a folder_key - """ - # Build OData filters - filters = [] - if name: - # Escape single quotes in name for OData - escaped_name = name.replace("'", "''") - filters.append(f"contains(Name, '{escaped_name}')") - if connector_key: - filters.append(f"connector/key eq '{connector_key}'") - - params = {} - if filters: - params["$filter"] = " and ".join(filters) - if skip is not None: - params["$skip"] = str(skip) - if top is not None: - params["$top"] = str(top) - - # Always expand connector and folder for complete information - params["$expand"] = "connector,folder" - - # Use header_folder which handles validation - headers = header_folder(folder_key, None) - - return RequestSpec( - method="GET", - endpoint=Endpoint("/connections_/api/v1/Connections"), - params=params, - headers=headers, - ) - - @traced( - name="activity_invoke", - run_type="uipath", - ) - def invoke_activity( - self, - activity_metadata: ActivityMetadata, - connection_id: str, - activity_input: Dict[str, Any], - ) -> Any: - """Invoke an activity synchronously. - - Args: - activity_metadata: Metadata describing the activity to invoke - connection_id: The ID of the connection - activity_input: Input parameters for the activity - - Returns: - The response from the activity - - Raises: - ValueError: If required parameters are missing or invalid - RuntimeError: If the HTTP request fails or returns an error status - """ - spec = self._build_activity_request_spec( - activity_metadata, connection_id, activity_input - ) - - response = self.request( - spec.method, - url=spec.endpoint, - headers=spec.headers, - params=spec.params, - json=spec.json, - files=spec.files, - ) - - return response.json() - - @traced( - name="activity_invoke", - run_type="uipath", - ) - async def invoke_activity_async( - self, - activity_metadata: ActivityMetadata, - connection_id: str, - activity_input: Dict[str, Any], - ) -> Any: - """Invoke an activity asynchronously. - - Args: - activity_metadata: Metadata describing the activity to invoke - connection_id: The ID of the connection - activity_input: Input parameters for the activity - - Returns: - The response from the activity - - Raises: - ValueError: If required parameters are missing or invalid - RuntimeError: If the HTTP request fails or returns an error status - """ - spec = self._build_activity_request_spec( - activity_metadata, connection_id, activity_input - ) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - headers=spec.headers, - params=spec.params, - json=spec.json, - files=spec.files, - ) - - return response.json() - - def _build_activity_request_spec( - self, - activity_metadata: ActivityMetadata, - connection_id: str, - activity_input: Dict[str, Any], - ) -> RequestSpec: - """Build the request specification for invoking an activity.""" - url = f"/elements_/v3/element/instances/{connection_id}{activity_metadata.object_path}" - - query_params: Dict[str, str] = {} - path_params: Dict[str, str] = {} - header_params: Dict[str, str] = {} - multipart_params: Dict[str, Any] = {} - body_fields: Dict[str, Any] = {} - - # iterating through input items instead of parameters because input will usually not contain all parameters - # and we don't want to add unused optional parameters to the request - for param_name, value in activity_input.items(): - if value is None: - continue - - value_str = str(value) if not isinstance(value, str) else value - - if param_name in activity_metadata.parameter_location_info.query_params: - query_params[param_name] = value_str - elif param_name in activity_metadata.parameter_location_info.path_params: - path_params[param_name] = value_str - elif param_name in activity_metadata.parameter_location_info.header_params: - header_params[param_name] = value_str - elif ( - param_name in activity_metadata.parameter_location_info.multipart_params - ): - multipart_params[param_name] = value - elif param_name in activity_metadata.parameter_location_info.body_fields: - body_fields[param_name] = value - else: - raise ValueError( - f"Parameter {param_name} does not exist in activity metadata." - ) - - # path parameter handling - for key, value in path_params.items(): - url = url.replace(f"{{{key}}}", value) - - # header parameter handling - headers = { - "x-uipath-originator": "uipath-python", - "x-uipath-source": "uipath-python", - **header_params, - } - - # body and files handling - json_data: Dict[str, Any] | None = None - files: Dict[str, Any] | None = None - - # multipart/form-data for file uploads - json_section = activity_metadata.json_body_section or "body" - if "multipart" in activity_metadata.content_type.lower(): - files = {} - - for key, val in multipart_params.items(): - # json body itself appears as a multipart param as well - # instead of making assumptions on whether or not it's present, we'll handle it defensively - if key == json_section: - continue - # files not supported yet supported so this will likely not work - files[key] = ( - key, - val, - None, - ) # probably needs to extract content type from val since IS metadata doesn't provide it - - files[json_section] = ( - "", - json.dumps(body_fields), - "application/json", - ) - - # application/json for regular requests - elif "json" in activity_metadata.content_type.lower(): - json_data = body_fields - else: - raise ValueError( - f"Unsupported content type: {activity_metadata.content_type}" - ) - - return RequestSpec( - method=activity_metadata.method_name, - endpoint=Endpoint(url), - headers=headers, - params=query_params, - json=json_data, - files=files, - ) diff --git a/src/uipath/platform/connections/connections.py b/src/uipath/platform/connections/connections.py deleted file mode 100644 index 394861708..000000000 --- a/src/uipath/platform/connections/connections.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Models for connections in the UiPath platform.""" - -from enum import Enum -from typing import Any, List, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class ConnectionMetadata(BaseModel): - """Metadata about a connection.""" - - fields: dict[str, Any] = Field(default_factory=dict, alias="fields") - metadata: dict[str, Any] = Field(default_factory=dict, alias="metadata") - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - -class Connection(BaseModel): - """Model representing a connection in the UiPath platform.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - id: Optional[str] = None - name: Optional[str] = None - owner: Optional[str] = None - create_time: Optional[str] = Field(default=None, alias="createTime") - update_time: Optional[str] = Field(default=None, alias="updateTime") - state: Optional[str] = None - api_base_uri: Optional[str] = Field(default=None, alias="apiBaseUri") - element_instance_id: int = Field(alias="elementInstanceId") - connector: Optional[Any] = None - is_default: Optional[bool] = Field(default=None, alias="isDefault") - last_used_time: Optional[str] = Field(default=None, alias="lastUsedTime") - connection_identity: Optional[str] = Field(default=None, alias="connectionIdentity") - polling_interval_in_minutes: Optional[int] = Field( - default=None, alias="pollingIntervalInMinutes" - ) - folder: Optional[Any] = None - element_version: Optional[str] = Field(default=None, alias="elementVersion") - - -class ConnectionTokenType(str, Enum): - """Enum representing types of connection tokens.""" - - DIRECT = "direct" - BEARER = "bearer" - - -class ConnectionToken(BaseModel): - """Model representing a connection token in the UiPath platform.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - access_token: str = Field(alias="accessToken") - token_type: Optional[str] = Field(default=None, alias="tokenType") - scope: Optional[str] = None - expires_in: Optional[int] = Field(default=None, alias="expiresIn") - api_base_uri: Optional[str] = Field(default=None, alias="apiBaseUri") - element_instance_id: Optional[int] = Field(default=None, alias="elementInstanceId") - - -class EventArguments(BaseModel): - """Model representing event arguments for a connection.""" - - event_connector: Optional[str] = Field(default=None, alias="UiPathEventConnector") - event: Optional[str] = Field(default=None, alias="UiPathEvent") - event_object_type: Optional[str] = Field( - default=None, alias="UiPathEventObjectType" - ) - event_object_id: Optional[str] = Field(default=None, alias="UiPathEventObjectId") - additional_event_data: Optional[str] = Field( - default=None, alias="UiPathAdditionalEventData" - ) - - model_config = ConfigDict( - populate_by_name=True, - extra="allow", - ) - - -class ActivityParameterLocationInfo(BaseModel): - """Information about parameter location in an activity.""" - - query_params: List[str] = [] - header_params: List[str] = [] - path_params: List[str] = [] - multipart_params: List[str] = [] - body_fields: List[str] = [] - - -class ActivityMetadata(BaseModel): - """Metadata for an activity.""" - - object_path: str - method_name: str - content_type: str - parameter_location_info: ActivityParameterLocationInfo - json_body_section: Optional[str] = None diff --git a/src/uipath/platform/context_grounding/__init__.py b/src/uipath/platform/context_grounding/__init__.py deleted file mode 100644 index d4129d633..000000000 --- a/src/uipath/platform/context_grounding/__init__.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Init file for context grounding module.""" - -from ._context_grounding_service import ContextGroundingService -from .context_grounding import ( - BatchTransformCreationResponse, - BatchTransformOutputColumn, - BatchTransformResponse, - BatchTransformStatus, - Citation, - CitationMode, - ContextGroundingQueryResponse, - DeepRagContent, - DeepRagCreationResponse, - DeepRagResponse, - DeepRagStatus, - EphemeralIndexUsage, - IndexStatus, -) -from .context_grounding_index import ContextGroundingIndex -from .context_grounding_payloads import ( - BaseSourceConfig, - BucketDataSource, - BucketSourceConfig, - ConfluenceDataSource, - ConfluenceSourceConfig, - ConnectionSourceConfig, - CreateIndexPayload, - DropboxDataSource, - DropboxSourceConfig, - GoogleDriveDataSource, - GoogleDriveSourceConfig, - Indexer, - OneDriveDataSource, - OneDriveSourceConfig, - PreProcessing, - SourceConfig, -) - -__all__ = [ - "BatchTransformCreationResponse", - "BatchTransformOutputColumn", - "BatchTransformResponse", - "BatchTransformStatus", - "BaseSourceConfig", - "BucketDataSource", - "BucketSourceConfig", - "CitationMode", - "ConfluenceDataSource", - "ConfluenceSourceConfig", - "ConnectionSourceConfig", - "ContextGroundingIndex", - "ContextGroundingQueryResponse", - "ContextGroundingService", - "CreateIndexPayload", - "DeepRagCreationResponse", - "DeepRagResponse", - "DeepRagContent", - "DeepRagStatus", - "IndexStatus", - "DropboxDataSource", - "DropboxSourceConfig", - "EphemeralIndexUsage", - "GoogleDriveDataSource", - "GoogleDriveSourceConfig", - "Indexer", - "OneDriveDataSource", - "OneDriveSourceConfig", - "PreProcessing", - "SourceConfig", - "Citation", -] diff --git a/src/uipath/platform/context_grounding/_context_grounding_service.py b/src/uipath/platform/context_grounding/_context_grounding_service.py deleted file mode 100644 index 11ad14f30..000000000 --- a/src/uipath/platform/context_grounding/_context_grounding_service.py +++ /dev/null @@ -1,1883 +0,0 @@ -from pathlib import Path -from typing import Annotated, Any, Dict, List, Optional, Tuple, Union - -import httpx -from pydantic import Field, TypeAdapter - -from ..._utils import Endpoint, RequestSpec, header_folder, resource_override -from ..._utils._ssl_context import get_httpx_client_kwargs -from ..._utils.constants import ( - LLMV4_REQUEST, - ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE, -) -from ...tracing import traced -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from ..errors import ( - BatchTransformNotCompleteException, - IngestionInProgressException, - UnsupportedDataSourceException, -) -from ..orchestrator._buckets_service import BucketsService -from ..orchestrator._folder_service import FolderService -from .context_grounding import ( - BatchTransformCreationResponse, - BatchTransformOutputColumn, - BatchTransformReadUriResponse, - BatchTransformResponse, - BatchTransformStatus, - CitationMode, - ContextGroundingQueryResponse, - DeepRagCreationResponse, - DeepRagResponse, - EphemeralIndexUsage, -) -from .context_grounding_index import ContextGroundingIndex -from .context_grounding_payloads import ( - AttachmentsDataSource, - BucketDataSource, - BucketSourceConfig, - ConfluenceDataSource, - ConfluenceSourceConfig, - CreateEphemeralIndexPayload, - CreateIndexPayload, - DropboxDataSource, - DropboxSourceConfig, - GoogleDriveDataSource, - GoogleDriveSourceConfig, - OneDriveDataSource, - OneDriveSourceConfig, - PreProcessing, - SourceConfig, -) - - -class ContextGroundingService(FolderContext, BaseService): - """Service for managing semantic automation contexts in UiPath. - - Context Grounding is a feature that helps in understanding and managing the - semantic context in which automation processes operate. It provides capabilities - for indexing, retrieving, and searching through contextual information that - can be used to enhance AI-enabled automation. - - This service requires a valid folder key to be set in the environment, as - context grounding operations are always performed within a specific folder - context. - """ - - def __init__( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folders_service: FolderService, - buckets_service: BucketsService, - ) -> None: - self._folders_service = folders_service - self._buckets_service = buckets_service - super().__init__(config=config, execution_context=execution_context) - - # 2.3.0 prefix trace name with contextgrounding - @resource_override(resource_type="index") - @traced(name="add_to_index", run_type="uipath") - def add_to_index( - self, - name: str, - blob_file_path: str, - content_type: Optional[str] = None, - content: Optional[Union[str, bytes]] = None, - source_path: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ingest_data: bool = True, - ) -> None: - """Add content to the index. - - Args: - name (str): The name of the index to add content to. - content_type (Optional[str]): The MIME type of the file. For file inputs this is computed dynamically. Default is "application/octet-stream". - blob_file_path (str): The path where the blob will be stored in the storage bucket. - content (Optional[Union[str, bytes]]): The content to be added, either as a string or bytes. - source_path (Optional[str]): The source path of the content if it is being uploaded from a file. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - ingest_data (bool): Whether to ingest data in the index after content is uploaded. Defaults to True. - - Raises: - ValueError: If neither content nor source_path is provided, or if both are provided. - """ - if not (content or source_path): - raise ValueError("Content or source_path is required") - if content and source_path: - raise ValueError("Content and source_path are mutually exclusive") - - index = self.retrieve(name=name, folder_key=folder_key, folder_path=folder_path) - bucket_name, bucket_folder_path = self._extract_bucket_info(index) - if source_path: - self._buckets_service.upload( - name=bucket_name, - blob_file_path=blob_file_path, - source_path=source_path, - folder_path=bucket_folder_path, - content_type=content_type, - ) - else: - self._buckets_service.upload( - name=bucket_name, - content=content, - blob_file_path=blob_file_path, - folder_path=bucket_folder_path, - content_type=content_type, - ) - - if ingest_data: - self.ingest_data(index, folder_key=folder_key, folder_path=folder_path) - - # 2.3.0 prefix trace name with contextgrounding - @resource_override(resource_type="index") - @traced(name="add_to_index", run_type="uipath") - async def add_to_index_async( - self, - name: str, - blob_file_path: str, - content_type: Optional[str] = None, - content: Optional[Union[str, bytes]] = None, - source_path: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ingest_data: bool = True, - ) -> None: - """Asynchronously add content to the index. - - Args: - name (str): The name of the index to add content to. - content_type (Optional[str]): The MIME type of the file. For file inputs this is computed dynamically. Default is "application/octet-stream". - blob_file_path (str): The path where the blob will be stored in the storage bucket. - content (Optional[Union[str, bytes]]): The content to be added, either as a string or bytes. - source_path (Optional[str]): The source path of the content if it is being uploaded from a file. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - ingest_data (bool): Whether to ingest data in the index after content is uploaded. Defaults to True. - - Raises: - ValueError: If neither content nor source_path is provided, or if both are provided. - """ - if not (content or source_path): - raise ValueError("Content or source_path is required") - if content and source_path: - raise ValueError("Content and source_path are mutually exclusive") - - index = await self.retrieve_async( - name=name, folder_key=folder_key, folder_path=folder_path - ) - bucket_name, bucket_folder_path = self._extract_bucket_info(index) - if source_path: - await self._buckets_service.upload_async( - name=bucket_name, - blob_file_path=blob_file_path, - source_path=source_path, - folder_path=bucket_folder_path, - content_type=content_type, - ) - else: - await self._buckets_service.upload_async( - name=bucket_name, - content=content, - blob_file_path=blob_file_path, - folder_path=bucket_folder_path, - content_type=content_type, - ) - - if ingest_data: - await self.ingest_data_async( - index, folder_key=folder_key, folder_path=folder_path - ) - - @resource_override(resource_type="index") - @traced(name="contextgrounding_retrieve", run_type="uipath") - def retrieve( - self, - name: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> ContextGroundingIndex: - """Retrieve context grounding index information by its name. - - Args: - name (str): The name of the context index to retrieve. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - - Returns: - ContextGroundingIndex: The index information, including its configuration and metadata if found. - - Raises: - Exception: If no index with the given name is found. - """ - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - try: - return next( - ContextGroundingIndex.model_validate(item) - for item in response["value"] - if item["name"] == name - ) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e - - @resource_override(resource_type="index") - @traced(name="contextgrounding_retrieve", run_type="uipath") - async def retrieve_async( - self, - name: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> ContextGroundingIndex: - """Asynchronously retrieve context grounding index information by its name. - - Args: - name (str): The name of the context index to retrieve. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - - Returns: - ContextGroundingIndex: The index information, including its configuration and metadata if found. - - Raises: - Exception: If no index with the given name is found. - """ - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = ( - await self.request_async( - spec.method, - spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - try: - return next( - ContextGroundingIndex.model_validate(item) - for item in response["value"] - if item["name"] == name - ) - except StopIteration as e: - raise Exception("ContextGroundingIndex not found") from e - - @traced(name="contextgrounding_retrieve_by_id", run_type="uipath") - def retrieve_by_id( - self, - id: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Any: - """Retrieve context grounding index information by its ID. - - This method provides direct access to a context index using its unique - identifier, which can be more efficient than searching by name. - - Args: - id (str): The unique identifier of the context index. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - - Returns: - Any: The index information, including its configuration and metadata. - """ - spec = self._retrieve_by_id_spec( - id, - folder_key=folder_key, - folder_path=folder_path, - ) - - return self.request( - spec.method, - spec.endpoint, - params=spec.params, - ).json() - - @traced(name="contextgrounding_retrieve_by_id", run_type="uipath") - async def retrieve_by_id_async( - self, - id: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Any: - """Retrieve asynchronously context grounding index information by its ID. - - This method provides direct access to a context index using its unique - identifier, which can be more efficient than searching by name. - - Args: - id (str): The unique identifier of the context index. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - - Returns: - Any: The index information, including its configuration and metadata. - """ - spec = self._retrieve_by_id_spec( - id, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - params=spec.params, - ) - - return response.json() - - @resource_override(resource_type="index") - @traced(name="contextgrounding_create_index", run_type="uipath") - def create_index( - self, - name: str, - source: SourceConfig, - description: Optional[str] = None, - advanced_ingestion: Optional[bool] = True, - preprocessing_request: Optional[str] = LLMV4_REQUEST, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> ContextGroundingIndex: - """Create a new context grounding index. - - Args: - name (str): The name of the index to create. - source (SourceConfig): Source configuration using one of: - - BucketSourceConfig: For storage buckets - - GoogleDriveSourceConfig: For Google Drive - - DropboxSourceConfig: For Dropbox - - OneDriveSourceConfig: For OneDrive - - ConfluenceSourceConfig: For Confluence - - The source can include an optional indexer field for scheduled indexing: - source.indexer = Indexer(cron_expression="0 0 18 ? * 2", time_zone_id="UTC") - description (Optional[str]): Description of the index. - advanced_ingestion (Optional[bool]): Enable advanced ingestion with preprocessing. Defaults to True. - preprocessing_request (Optional[str]): The OData type for preprocessing request. Defaults to LLMV4_REQUEST. - folder_key (Optional[str]): The key of the folder where the index will be created. - folder_path (Optional[str]): The path of the folder where the index will be created. - - Returns: - ContextGroundingIndex: The created index information. - """ - spec = self._create_spec( - name=name, - description=description, - source=source, - advanced_ingestion=advanced_ingestion - if advanced_ingestion is not None - else True, - preprocessing_request=preprocessing_request or LLMV4_REQUEST, - folder_path=folder_path, - folder_key=folder_key, - ) - - response = self.request( - spec.method, - spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - return ContextGroundingIndex.model_validate(response.json()) - - @resource_override(resource_type="index") - @traced(name="contextgrounding_create_index", run_type="uipath") - async def create_index_async( - self, - name: str, - source: SourceConfig, - description: Optional[str] = None, - advanced_ingestion: Optional[bool] = True, - preprocessing_request: Optional[str] = LLMV4_REQUEST, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> ContextGroundingIndex: - """Create a new context grounding index. - - Args: - name (str): The name of the index to create. - source (SourceConfig): Source configuration using one of: - - BucketSourceConfig: For storage buckets - - GoogleDriveSourceConfig: For Google Drive - - DropboxSourceConfig: For Dropbox - - OneDriveSourceConfig: For OneDrive - - ConfluenceSourceConfig: For Confluence - - The source can include an optional indexer field for scheduled indexing: - source.indexer = Indexer(cron_expression="0 0 18 ? * 2", time_zone_id="UTC") - description (Optional[str]): Description of the index. - advanced_ingestion (Optional[bool]): Enable advanced ingestion with preprocessing. Defaults to True. - preprocessing_request (Optional[str]): The OData type for preprocessing request. Defaults to LLMV4_REQUEST. - folder_key (Optional[str]): The key of the folder where the index will be created. - folder_path (Optional[str]): The path of the folder where the index will be created. - - Returns: - ContextGroundingIndex: The created index information. - """ - spec = self._create_spec( - name=name, - description=description, - source=source, - advanced_ingestion=advanced_ingestion - if advanced_ingestion is not None - else True, - preprocessing_request=preprocessing_request or LLMV4_REQUEST, - folder_path=folder_path, - folder_key=folder_key, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - return ContextGroundingIndex.model_validate(response.json()) - - @resource_override(resource_type="index") - @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") - def create_ephemeral_index( - self, usage: EphemeralIndexUsage, attachments: list[str] - ) -> ContextGroundingIndex: - """Create a new ephemeral context grounding index. - - Args: - usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) - attachments (list[str]): The list of attachments ids from which the ephemeral index will be created - - Returns: - ContextGroundingIndex: The created index information. - """ - spec = self._create_ephemeral_spec( - usage, - attachments, - ) - - response = self.request( - spec.method, - spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - return ContextGroundingIndex.model_validate(response.json()) - - @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] - ) -> ContextGroundingIndex: - """Create a new ephemeral context grounding index. - - Args: - usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) - attachments (list[str]): The list of attachments ids from which the ephemeral index will be created - - Returns: - ContextGroundingIndex: The created index information. - """ - spec = self._create_ephemeral_spec( - usage, - attachments, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - return ContextGroundingIndex.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_retrieve_deep_rag", run_type="uipath") - def retrieve_deep_rag( - self, - id: str, - *, - index_name: str | None = None, - ) -> DeepRagResponse: - """Retrieves a Deep RAG task. - - Args: - id (str): The id of the Deep RAG task. - index_name (Optional[str]): Index name hint for resource override. - - Returns: - DeepRagResponse: The Deep RAG task response. - """ - spec = self._deep_rag_retrieve_spec( - id=id, - ) - response = self.request( - spec.method, - spec.endpoint, - params=spec.params, - json=spec.json, - headers=spec.headers, - ) - return DeepRagResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_retrieve_deep_rag_async", run_type="uipath") - async def retrieve_deep_rag_async( - self, - id: str, - *, - index_name: str | None = None, - ) -> DeepRagResponse: - """Asynchronously retrieves a Deep RAG task. - - Args: - id (str): The id of the Deep RAG task. - index_name (Optional[str]): Index name hint for resource override. - - Returns: - DeepRagResponse: The Deep RAG task response. - """ - spec = self._deep_rag_retrieve_spec( - id=id, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - params=spec.params, - json=spec.json, - headers=spec.headers, - ) - - return DeepRagResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_start_batch_transform", run_type="uipath") - def start_batch_transform( - self, - name: str, - prompt: Annotated[str, Field(max_length=250000)], - output_columns: list[BatchTransformOutputColumn], - storage_bucket_folder_path_prefix: Annotated[ - str | None, Field(max_length=512) - ] = None, - target_file_name: Annotated[str | None, Field(max_length=512)] = None, - enable_web_search_grounding: bool = False, - index_name: str | None = None, - index_id: Annotated[str, Field(max_length=512)] | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> BatchTransformCreationResponse: - """Starts a Batch Transform, task on the targeted index. - - Batch Transform tasks are processing and transforming csv files from the index. - Only one file can be processed per batch transform job. - - Args: - name (str): The name of the Deep RAG task. - index_name (str): The name of the context index to search in. - prompt (str): Describe the task: what to research, what to synthesize. - output_columns (list[BatchTransformOutputColumn]): The output columns to add into the csv. - storage_bucket_folder_path_prefix (str): The prefix pattern for filtering files in the storage bucket. - Can be combined with target_file_name. Defaults to None. - target_file_name (str, optional): Specific file name to target. - If both target_file_name and storage_bucket_folder_path_prefix are provided, they will be combined (e.g., "data/file.csv"). - If only target_file_name is provided, it will be used directly. - Only one file can be processed per batch transform job. - enable_web_search_grounding (Optional[bool]): Whether to enable web search. Defaults to False. - index_id (str): The id of the context index to search in, used in place of name if present - folder_key (str, optional): The folder key where the index resides. Defaults to None. - folder_path (str, optional): The folder path where the index resides. Defaults to None. - - Returns: - BatchTransformCreationResponse: The batch transform task creation response. - """ - if not (index_name or index_id): - raise ValueError("Index name or id is required") - if index_name and index_id: - raise ValueError("Index name or id are mutually exclusive") - - if not index_id: - index = self.retrieve( - index_name, folder_key=folder_key, folder_path=folder_path - ) - if index and index.in_progress_ingestion(): - raise IngestionInProgressException(index_name=index_name) - index_id = index.id - - spec = self._batch_transform_creation_spec( - index_id=index_id, - name=name, - storage_bucket_folder_path_prefix=storage_bucket_folder_path_prefix, - target_file_name=target_file_name, - prompt=prompt, - output_columns=output_columns, - enable_web_search_grounding=enable_web_search_grounding, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - spec.endpoint, - json=spec.json, - params=spec.params, - headers=spec.headers, - ) - return BatchTransformCreationResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_start_batch_transform_async", run_type="uipath") - async def start_batch_transform_async( - self, - name: str, - prompt: Annotated[str, Field(max_length=250000)], - output_columns: list[BatchTransformOutputColumn], - storage_bucket_folder_path_prefix: Annotated[ - str | None, Field(max_length=512) - ] = None, - target_file_name: Annotated[str | None, Field(max_length=512)] = None, - enable_web_search_grounding: bool = False, - index_name: str | None = None, - index_id: Annotated[str, Field(max_length=512)] | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> BatchTransformCreationResponse: - """Asynchronously starts a Batch Transform, task on the targeted index. - - Batch Transform tasks are processing and transforming csv files from the index. - Only one file can be processed per batch transform job. - - Args: - name (str): The name of the Deep RAG task. - index_name (str): The name of the context index to search in. - prompt (str): Describe the task: what to research, what to synthesize. - output_columns (list[BatchTransformOutputColumn]): The output columns to add into the csv. - storage_bucket_folder_path_prefix (str): The prefix pattern for filtering files in the storage bucket. - Can be combined with target_file_name. Defaults to None. - target_file_name (str, optional): Specific file name to target. - If both target_file_name and storage_bucket_folder_path_prefix are provided, they will be combined (e.g., "data/file.csv"). - If only target_file_name is provided, it will be used directly. - Only one file can be processed per batch transform job. - enable_web_search_grounding (Optional[bool]): Whether to enable web search. Defaults to False. - index_id (str): The id of the context index to search in, used in place of name if present - folder_key (str, optional): The folder key where the index resides. Defaults to None. - folder_path (str, optional): The folder path where the index resides. Defaults to None. - - Returns: - BatchTransformCreationResponse: The batch transform task creation response. - """ - if not (index_name or index_id): - raise ValueError("Index name or id is required") - if index_name and index_id: - raise ValueError("Index name or id are mutually exclusive") - - if not index_id: - index = await self.retrieve_async( - index_name, folder_key=folder_key, folder_path=folder_path - ) - if index and index.in_progress_ingestion(): - raise IngestionInProgressException(index_name=index_name) - index_id = index.id - - spec = self._batch_transform_creation_spec( - index_id=index_id, - name=name, - storage_bucket_folder_path_prefix=storage_bucket_folder_path_prefix, - target_file_name=target_file_name, - prompt=prompt, - output_columns=output_columns, - enable_web_search_grounding=enable_web_search_grounding, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - json=spec.json, - params=spec.params, - headers=spec.headers, - ) - return BatchTransformCreationResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_start_batch_transform", run_type="uipath") - async def start_batch_transform_ephemeral( - self, - name: str, - prompt: Annotated[str, Field(max_length=250000)], - output_columns: list[BatchTransformOutputColumn], - storage_bucket_folder_path_prefix: Annotated[ - str | None, Field(max_length=512) - ] = None, - enable_web_search_grounding: bool = False, - index_id: Annotated[str, Field(max_length=512)] | None = None, - ) -> BatchTransformCreationResponse: - """Asynchronously starts a Batch Transform, task on the targeted index. - - Batch Transform tasks are processing and transforming csv files from the index. - - Args: - name (str): The name of the Deep RAG task. - prompt (str): Describe the task: what to research, what to synthesize. - output_columns (list[BatchTransformOutputColumn]): The output columns to add into the csv. - storage_bucket_folder_path_prefix (str): The prefix pattern for filtering files in the storage bucket. Use "*" to include all files. Defaults to "*". - enable_web_search_grounding (Optional[bool]): Whether to enable web search. Defaults to False. - index_id (str): The id of the context index to search in, used in place of name if present - - Returns: - BatchTransformCreationResponse: The batch transform task creation response. - """ - spec = self._batch_transform_ephemeral_creation_spec( - index_id=index_id, - name=name, - storage_bucket_folder_path_prefix=storage_bucket_folder_path_prefix, - prompt=prompt, - output_columns=output_columns, - enable_web_search_grounding=enable_web_search_grounding, - ) - - response = self.request( - spec.method, - spec.endpoint, - json=spec.json, - params=spec.params, - headers=spec.headers, - ) - return BatchTransformCreationResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_start_batch_transform_async", run_type="uipath") - async def start_batch_transform_ephemeral_async( - self, - name: str, - prompt: Annotated[str, Field(max_length=250000)], - output_columns: list[BatchTransformOutputColumn], - storage_bucket_folder_path_prefix: Annotated[ - str | None, Field(max_length=512) - ] = None, - enable_web_search_grounding: bool = False, - index_id: Annotated[str, Field(max_length=512)] | None = None, - ) -> BatchTransformCreationResponse: - """Asynchronously starts a Batch Transform, task on the targeted index. - - Batch Transform tasks are processing and transforming csv files from the index. - - Args: - name (str): The name of the Deep RAG task. - prompt (str): Describe the task: what to research, what to synthesize. - output_columns (list[BatchTransformOutputColumn]): The output columns to add into the csv. - storage_bucket_folder_path_prefix (str): The prefix pattern for filtering files in the storage bucket. Use "*" to include all files. Defaults to "*". - enable_web_search_grounding (Optional[bool]): Whether to enable web search. Defaults to False. - index_id (str): The id of the context index to search in, used in place of name if present - - Returns: - BatchTransformCreationResponse: The batch transform task creation response. - """ - spec = self._batch_transform_ephemeral_creation_spec( - index_id=index_id, - name=name, - storage_bucket_folder_path_prefix=storage_bucket_folder_path_prefix, - prompt=prompt, - output_columns=output_columns, - enable_web_search_grounding=enable_web_search_grounding, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - json=spec.json, - params=spec.params, - headers=spec.headers, - ) - return BatchTransformCreationResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_retrieve_batch_transform", run_type="uipath") - def retrieve_batch_transform( - self, - id: str, - *, - index_name: str | None = None, - ) -> BatchTransformResponse: - """Retrieves a Batch Transform task status. - - Args: - id (str): The id of the Batch Transform task. - index_name (Optional[str]): Index name hint for resource override. - - Returns: - BatchTransformResponse: The Batch Transform task response. - """ - spec = self._batch_transform_retrieve_spec(id=id) - response = self.request( - spec.method, - spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - return BatchTransformResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_retrieve_batch_transform_async", run_type="uipath") - async def retrieve_batch_transform_async( - self, - id: str, - *, - index_name: str | None = None, - ) -> BatchTransformResponse: - """Asynchronously retrieves a Batch Transform task status. - - Args: - id (str): The id of the Batch Transform task. - index_name (Optional[str]): Index name hint for resource override. - - Returns: - BatchTransformResponse: The Batch Transform task response. - """ - spec = self._batch_transform_retrieve_spec(id=id) - response = await self.request_async( - spec.method, - spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - return BatchTransformResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_download_batch_transform_result", run_type="uipath") - def download_batch_transform_result( - self, - id: str, - destination_path: str, - *, - validate_status: bool = True, - index_name: str | None = None, - ) -> None: - """Downloads the Batch Transform result file to the specified path. - - Args: - id (str): The id of the Batch Transform task. - destination_path (str): The local file path where the result file will be saved. - validate_status (bool): Whether to validate the batch transform status before downloading. Defaults to True. - index_name (Optional[str]): Index name hint for resource override. - - Raises: - BatchTransformNotCompleteException: If validate_status is True and the batch transform is not complete. - """ - if validate_status: - batch_transform = self.retrieve_batch_transform( - id=id, index_name=index_name - ) - if batch_transform.last_batch_rag_status != BatchTransformStatus.SUCCESSFUL: - raise BatchTransformNotCompleteException( - batch_transform_id=id, - status=batch_transform.last_batch_rag_status, - ) - - spec = self._batch_transform_get_read_uri_spec(id=id) - response = self.request( - spec.method, - spec.endpoint, - headers=spec.headers, - ) - uri_response = BatchTransformReadUriResponse.model_validate(response.json()) - - Path(destination_path).parent.mkdir(parents=True, exist_ok=True) - - # SAS uris can be downloaded without authentication - # encrypted artifacts require authenticated DownloadBlob endpoint - with open(destination_path, "wb") as file: - if uri_response.is_encrypted: - download_spec = self._batch_transform_download_blob_spec(id=id) - download_response = self.request( - download_spec.method, - download_spec.endpoint, - headers=download_spec.headers, - ) - file_content = download_response.content - else: - with httpx.Client(**get_httpx_client_kwargs()) as client: - file_content = client.get(uri_response.uri).content - file.write(file_content) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced( - name="contextgrounding_download_batch_transform_result_async", run_type="uipath" - ) - async def download_batch_transform_result_async( - self, - id: str, - destination_path: str, - *, - validate_status: bool = True, - index_name: str | None = None, - ) -> None: - """Asynchronously downloads the Batch Transform result file to the specified path. - - Args: - id (str): The id of the Batch Transform task. - destination_path (str): The local file path where the result file will be saved. - validate_status (bool): Whether to validate the batch transform status before downloading. Defaults to True. - index_name (Optional[str]): Index name hint for resource override. - - Raises: - BatchTransformNotCompleteException: If validate_status is True and the batch transform is not complete. - """ - if validate_status: - batch_transform = await self.retrieve_batch_transform_async( - id=id, index_name=index_name - ) - if batch_transform.last_batch_rag_status != BatchTransformStatus.SUCCESSFUL: - raise BatchTransformNotCompleteException( - batch_transform_id=id, - status=batch_transform.last_batch_rag_status, - ) - - spec = self._batch_transform_get_read_uri_spec(id=id) - response = await self.request_async( - spec.method, - spec.endpoint, - headers=spec.headers, - ) - uri_response = BatchTransformReadUriResponse.model_validate(response.json()) - - # SAS uris can be downloaded without authentication - # encrypted artifacts require authenticated DownloadBlob endpoint - if uri_response.is_encrypted: - download_spec = self._batch_transform_download_blob_spec(id=id) - download_response = await self.request_async( - download_spec.method, - download_spec.endpoint, - headers=download_spec.headers, - ) - file_content = download_response.content - else: - async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client: - download_response = await client.get(uri_response.uri) - file_content = download_response.content - - Path(destination_path).parent.mkdir(parents=True, exist_ok=True) - - with open(destination_path, "wb") as file: - file.write(file_content) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_start_deep_rag", run_type="uipath") - def start_deep_rag( - self, - name: str, - prompt: Annotated[str, Field(max_length=250000)], - glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**", - citation_mode: CitationMode = CitationMode.SKIP, - index_name: Annotated[str, Field(max_length=512)] | None = None, - index_id: Annotated[str, Field(max_length=512)] | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> DeepRagCreationResponse: - """Starts a Deep RAG task on the targeted index. - - Args: - name (str): The name of the Deep RAG task. - index_name (str): The name of the context index to search in. - prompt (str): Describe the task: what to research across documents, what to synthesize and how to cite sources. - glob_pattern (str): The glob pattern to search in the index. Defaults to "**". - citation_mode (CitationMode): The citation mode to use. Defaults to SKIP. - folder_key (str, optional): The folder key where the index resides. Defaults to None. - folder_path (str, optional): The folder path where the index resides. Defaults to None. - index_id (str): The id of the context index to search in, used in place of name if present - - Returns: - DeepRagCreationResponse: The Deep RAG task creation response. - """ - if not (index_name or index_id): - raise ValueError("Index name or id is required") - if index_name and index_id: - raise ValueError("Index name or id are mutually exclusive") - - if not index_id: - index = self.retrieve( - index_name, folder_key=folder_key, folder_path=folder_path - ) - if index and index.in_progress_ingestion(): - raise IngestionInProgressException(index_name=index_name) - index_id = index.id - - spec = self._deep_rag_creation_spec( - index_id=index_id, - name=name, - glob_pattern=glob_pattern, - prompt=prompt, - citation_mode=citation_mode, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - spec.endpoint, - json=spec.json, - params=spec.params, - headers=spec.headers, - ) - - return DeepRagCreationResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_start_deep_rag_async", run_type="uipath") - async def start_deep_rag_async( - self, - name: str, - prompt: Annotated[str, Field(max_length=250000)], - glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**", - citation_mode: CitationMode = CitationMode.SKIP, - index_name: Annotated[str, Field(max_length=512)] | None = None, - index_id: Annotated[str, Field(max_length=512)] | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> DeepRagCreationResponse: - """Asynchronously starts a Deep RAG task on the targeted index. - - Args: - name (str): The name of the Deep RAG task. - index_name (str): The name of the context index to search in. - name (str): The name of the Deep RAG task. - prompt (str): Describe the task: what to research across documents, what to synthesize and how to cite sources. - glob_pattern (str): The glob pattern to search in the index. Defaults to "**". - citation_mode (CitationMode): The citation mode to use. Defaults to SKIP. - folder_key (str, optional): The folder key where the index resides. Defaults to None. - folder_path (str, optional): The folder path where the index resides. Defaults to None. - index_id (str): The id of the context index to search in, used in place of name if present - - Returns: - DeepRagCreationResponse: The Deep RAG task creation response. - """ - if not (index_name or index_id): - raise ValueError("Index name or id is required") - if index_name and index_id: - raise ValueError("Index name or id are mutually exclusive") - - if not index_id: - index = await self.retrieve_async( - index_name, folder_key=folder_key, folder_path=folder_path - ) - if index and index.in_progress_ingestion(): - raise IngestionInProgressException(index_name=index_name) - index_id = index.id - - spec = self._deep_rag_creation_spec( - index_id=index_id, - name=name, - glob_pattern=glob_pattern, - prompt=prompt, - citation_mode=citation_mode, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - params=spec.params, - json=spec.json, - headers=spec.headers, - ) - - return DeepRagCreationResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_start_deep_rag", run_type="uipath") - async def start_deep_rag_ephemeral( - self, - name: str, - prompt: Annotated[str, Field(max_length=250000)], - glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**", - citation_mode: CitationMode = CitationMode.SKIP, - index_id: Annotated[str, Field(max_length=512)] | None = None, - ) -> DeepRagCreationResponse: - """Asynchronously starts a Deep RAG task on the targeted index. - - Args: - name (str): The name of the Deep RAG task. - name (str): The name of the Deep RAG task. - prompt (str): Describe the task: what to research across documents, what to synthesize and how to cite sources. - glob_pattern (str): The glob pattern to search in the index. Defaults to "**". - citation_mode (CitationMode): The citation mode to use. Defaults to SKIP. - index_id (str): The id of the context index to search in, used in place of name if present - - Returns: - DeepRagCreationResponse: The Deep RAG task creation response. - """ - spec = self._deep_rag_ephemeral_creation_spec( - index_id=index_id, - name=name, - glob_pattern=glob_pattern, - prompt=prompt, - citation_mode=citation_mode, - ) - - response = self.request( - spec.method, - spec.endpoint, - params=spec.params, - json=spec.json, - headers=spec.headers, - ) - - return DeepRagCreationResponse.model_validate(response.json()) - - @resource_override(resource_type="index", resource_identifier="index_name") - @traced(name="contextgrounding_start_deep_rag_async", run_type="uipath") - async def start_deep_rag_ephemeral_async( - self, - name: str, - prompt: Annotated[str, Field(max_length=250000)], - glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**", - citation_mode: CitationMode = CitationMode.SKIP, - index_id: Annotated[str, Field(max_length=512)] | None = None, - ) -> DeepRagCreationResponse: - """Asynchronously starts a Deep RAG task on the targeted index. - - Args: - name (str): The name of the Deep RAG task. - name (str): The name of the Deep RAG task. - prompt (str): Describe the task: what to research across documents, what to synthesize and how to cite sources. - glob_pattern (str): The glob pattern to search in the index. Defaults to "**". - citation_mode (CitationMode): The citation mode to use. Defaults to SKIP. - index_id (str): The id of the context index to search in, used in place of name if present - - Returns: - DeepRagCreationResponse: The Deep RAG task creation response. - """ - spec = self._deep_rag_ephemeral_creation_spec( - index_id=index_id, - name=name, - glob_pattern=glob_pattern, - prompt=prompt, - citation_mode=citation_mode, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - params=spec.params, - json=spec.json, - headers=spec.headers, - ) - - return DeepRagCreationResponse.model_validate(response.json()) - - @resource_override(resource_type="index") - @traced(name="contextgrounding_search", run_type="uipath") - def search( - self, - name: str, - query: str, - number_of_results: int = 10, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> List[ContextGroundingQueryResponse]: - """Search for contextual information within a specific index. - - This method performs a semantic search against the specified context index, - helping to find relevant information that can be used in automation processes. - The search is powered by AI and understands natural language queries. - - Args: - name (str): The name of the context index to search in. - query (str): The search query in natural language. - number_of_results (int, optional): Maximum number of results to return. - Defaults to 10. - - Returns: - List[ContextGroundingQueryResponse]: A list of search results, each containing - relevant contextual information and metadata. - """ - index = self.retrieve(name, folder_key=folder_key, folder_path=folder_path) - if index and index.in_progress_ingestion(): - raise IngestionInProgressException(index_name=name) - - spec = self._search_spec( - name, - query, - number_of_results, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - return TypeAdapter(List[ContextGroundingQueryResponse]).validate_python( - response.json() - ) - - @resource_override(resource_type="index") - @traced(name="contextgrounding_search", run_type="uipath") - async def search_async( - self, - name: str, - query: str, - number_of_results: int = 10, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> List[ContextGroundingQueryResponse]: - """Search asynchronously for contextual information within a specific index. - - This method performs a semantic search against the specified context index, - helping to find relevant information that can be used in automation processes. - The search is powered by AI and understands natural language queries. - - Args: - name (str): The name of the context index to search in. - query (str): The search query in natural language. - number_of_results (int, optional): Maximum number of results to return. - Defaults to 10. - - Returns: - List[ContextGroundingQueryResponse]: A list of search results, each containing - relevant contextual information and metadata. - """ - index = self.retrieve( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - if index and index.in_progress_ingestion(): - raise IngestionInProgressException(index_name=name) - spec = self._search_spec( - name, - query, - number_of_results, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = await self.request_async( - spec.method, - spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - return TypeAdapter(List[ContextGroundingQueryResponse]).validate_python( - response.json() - ) - - @traced(name="contextgrounding_ingest_data", run_type="uipath") - def ingest_data( - self, - index: ContextGroundingIndex, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Ingest data into the context grounding index. - - Args: - index (ContextGroundingIndex): The context grounding index to perform data ingestion. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - """ - if not index.id: - return - spec = self._ingest_spec( - index.id, - folder_key=folder_key, - folder_path=folder_path, - ) - try: - self.request( - spec.method, - spec.endpoint, - headers=spec.headers, - ) - except httpx.HTTPStatusError as e: - if e.response.status_code != 409: - raise e - raise IngestionInProgressException( - index_name=index.name, search_operation=False - ) from e - - @traced(name="contextgrounding_ingest_data", run_type="uipath") - async def ingest_data_async( - self, - index: ContextGroundingIndex, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Asynchronously ingest data into the context grounding index. - - Args: - index (ContextGroundingIndex): The context grounding index to perform data ingestion. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - """ - if not index.id: - return - spec = self._ingest_spec( - index.id, - folder_key=folder_key, - folder_path=folder_path, - ) - try: - await self.request_async( - spec.method, - spec.endpoint, - headers=spec.headers, - ) - except httpx.HTTPStatusError as e: - if e.response.status_code != 409: - raise e - raise IngestionInProgressException( - index_name=index.name, search_operation=False - ) from e - - @traced(name="contextgrounding_delete_index", run_type="uipath") - def delete_index( - self, - index: ContextGroundingIndex, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Delete a context grounding index. - - This method removes the specified context grounding index from Orchestrator. - - Args: - index (ContextGroundingIndex): The context grounding index to delete. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - """ - if not index.id: - return - spec = self._delete_by_id_spec( - index.id, - folder_key=folder_key, - folder_path=folder_path, - ) - self.request( - spec.method, - spec.endpoint, - headers=spec.headers, - ) - - @traced(name="contextgrounding_delete_index", run_type="uipath") - async def delete_index_async( - self, - index: ContextGroundingIndex, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Asynchronously delete a context grounding index. - - This method removes the specified context grounding index from Orchestrator. - - Args: - index (ContextGroundingIndex): The context grounding index to delete. - folder_key (Optional[str]): The key of the folder where the index resides. - folder_path (Optional[str]): The path of the folder where the index resides. - """ - if not index.id: - return - spec = self._delete_by_id_spec( - index.id, - folder_key=folder_key, - folder_path=folder_path, - ) - await self.request_async( - spec.method, - spec.endpoint, - headers=spec.headers, - ) - - def _ingest_spec( - self, - key: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - folder_key = self._resolve_folder_key(folder_key, folder_path) - - return RequestSpec( - method="POST", - endpoint=Endpoint(f"/ecs_/v2/indexes/{key}/ingest"), - headers={ - **header_folder(folder_key, None), - }, - ) - - def _retrieve_spec( - self, - name: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - folder_key = self._resolve_folder_key(folder_key, folder_path) - - return RequestSpec( - method="GET", - endpoint=Endpoint("/ecs_/v2/indexes"), - params={ - "$filter": f"Name eq '{name}'", - "$expand": "dataSource", - }, - headers={ - **header_folder(folder_key, None), - }, - ) - - def _create_spec( - self, - name: str, - description: Optional[str], - source: SourceConfig, - advanced_ingestion: bool, - preprocessing_request: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - """Create request spec for index creation. - - Args: - name: Index name - description: Index description - source: Source configuration (typed model) with optional indexer - advanced_ingestion: Whether to enable advanced ingestion with preprocessing - preprocessing_request: OData type for preprocessing request - folder_key: Optional folder key - folder_path: Optional folder path - - Returns: - RequestSpec for the create index request - """ - folder_key = self._resolve_folder_key(folder_key, folder_path) - - data_source_dict = self._build_data_source(source) - - # Add indexer from source config if present - if source.indexer: - data_source_dict["indexer"] = source.indexer.model_dump(by_alias=True) - - payload = CreateIndexPayload( - name=name, - description=description or "", - data_source=data_source_dict, - pre_processing=( - PreProcessing(**{"@odata.type": preprocessing_request}) - if advanced_ingestion and preprocessing_request - else None - ), - ) - - return RequestSpec( - method="POST", - endpoint=Endpoint("/ecs_/v2/indexes/create"), - json=payload.model_dump(by_alias=True, exclude_none=True), - headers={ - **header_folder(folder_key, None), - }, - ) - - def _create_ephemeral_spec( - self, - usage: str, - attachments: list[str], - ) -> RequestSpec: - """Create request spec for ephemeral index creation. - - Args: - usage (str): The task in which the ephemeral index will be used for - attachments (list[str]): The list of attachments ids from which the ephemeral index will be created - - Returns: - RequestSpec for the create index request - """ - data_source_dict = self._build_ephemeral_data_source(attachments) - - payload = CreateEphemeralIndexPayload( - usage=usage, - data_source=data_source_dict, - ) - - return RequestSpec( - method="POST", - endpoint=Endpoint("/ecs_/v2/indexes/createephemeral"), - json=payload.model_dump(by_alias=True, exclude_none=True), - headers={}, - ) - - def _build_data_source(self, source: SourceConfig) -> Dict[str, Any]: - """Build data source configuration from typed source config. - - Args: - source: Typed source configuration model - - Returns: - Dictionary with data source configuration for API - """ - file_name_glob = f"**/*.{source.file_type}" if source.file_type else "**/*" - - data_source: Union[ - BucketDataSource, - GoogleDriveDataSource, - DropboxDataSource, - OneDriveDataSource, - ConfluenceDataSource, - ] - - if isinstance(source, BucketSourceConfig): - data_source = BucketDataSource( - folder=source.folder_path, - bucketName=source.bucket_name, - fileNameGlob=file_name_glob, - directoryPath=source.directory_path, - ) - elif isinstance(source, GoogleDriveSourceConfig): - data_source = GoogleDriveDataSource( - folder=source.folder_path, - connectionId=source.connection_id, - connectionName=source.connection_name, - leafFolderId=source.leaf_folder_id, - directoryPath=source.directory_path, - fileNameGlob=file_name_glob, - ) - elif isinstance(source, DropboxSourceConfig): - data_source = DropboxDataSource( - folder=source.folder_path, - connectionId=source.connection_id, - connectionName=source.connection_name, - directoryPath=source.directory_path, - fileNameGlob=file_name_glob, - ) - elif isinstance(source, OneDriveSourceConfig): - data_source = OneDriveDataSource( - folder=source.folder_path, - connectionId=source.connection_id, - connectionName=source.connection_name, - leafFolderId=source.leaf_folder_id, - directoryPath=source.directory_path, - fileNameGlob=file_name_glob, - ) - elif isinstance(source, ConfluenceSourceConfig): - data_source = ConfluenceDataSource( - folder=source.folder_path, - connectionId=source.connection_id, - connectionName=source.connection_name, - directoryPath=source.directory_path, - fileNameGlob=file_name_glob, - spaceId=source.space_id, - ) - else: - raise ValueError( - f"Unsupported source configuration type: {type(source).__name__}" - ) - - return data_source.model_dump(by_alias=True, exclude_none=True) - - def _build_ephemeral_data_source(self, attachments: list[str]) -> Dict[str, Any]: - """Build data source configuration from typed source config. - - Args: - attachments (list[str]): The list of attachments ids from which the ephemeral index will be created - - Returns: - Dictionary with data source configuration for API - """ - data_source = AttachmentsDataSource(attachments=attachments) - return data_source.model_dump( - by_alias=True, - exclude_none=True, - mode="json", - ) - - def _retrieve_by_id_spec( - self, - id: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - folder_key = self._resolve_folder_key(folder_key, folder_path) - - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/ecs_/v2/indexes/{id}"), - headers={ - **header_folder(folder_key, None), - }, - ) - - def _delete_by_id_spec( - self, - id: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - folder_key = self._resolve_folder_key(folder_key, folder_path) - - return RequestSpec( - method="DELETE", - endpoint=Endpoint(f"/ecs_/v2/indexes/{id}"), - headers={ - **header_folder(folder_key, None), - }, - ) - - def _search_spec( - self, - name: str, - query: str, - number_of_results: int = 10, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - folder_key = self._resolve_folder_key(folder_key, folder_path) - - return RequestSpec( - method="POST", - endpoint=Endpoint("/ecs_/v1/search"), - json={ - "query": {"query": query, "numberOfResults": number_of_results}, - "schema": {"name": name}, - }, - headers={ - **header_folder(folder_key, None), - }, - ) - - def _deep_rag_creation_spec( - self, - index_id: str, - name: str, - glob_pattern: str, - prompt: str, - citation_mode: CitationMode, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> RequestSpec: - folder_key = self._resolve_folder_key(folder_key, folder_path) - - return RequestSpec( - method="POST", - endpoint=Endpoint(f"/ecs_/v2/indexes/{index_id}/createDeepRag"), - json={ - "name": name, - "prompt": prompt, - "globPattern": glob_pattern, - "citationMode": citation_mode.value, - }, - params={ - "$select": "id,lastDeepRagStatus,createdDate", - }, - headers={ - **header_folder(folder_key, None), - }, - ) - - def _deep_rag_ephemeral_creation_spec( - self, - index_id: str | None, - name: str, - glob_pattern: str, - prompt: str, - citation_mode: CitationMode, - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint(f"/ecs_/v2/indexes/{index_id}/createDeepRag"), - json={ - "name": name, - "prompt": prompt, - "globPattern": glob_pattern, - "citationMode": citation_mode.value, - }, - params={ - "$select": "id,lastDeepRagStatus,createdDate", - }, - headers={}, - ) - - def _batch_transform_creation_spec( - self, - index_id: str, - name: str, - enable_web_search_grounding: bool, - output_columns: list[BatchTransformOutputColumn], - storage_bucket_folder_path_prefix: str | None, - target_file_name: str | None, - prompt: str, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> RequestSpec: - folder_key = self._resolve_folder_key(folder_key, folder_path) - - # determine targetFileGlobPattern based on the provided parameters: - # 1. if both target_file_name and storage_bucket_folder_path_prefix are provided, combine them - # 2. if only target_file_name is provided, use it directly - # 3. if only storage_bucket_folder_path_prefix is provided, use it with wildcard - # 4. default to "**" if neither is provided - if target_file_name and storage_bucket_folder_path_prefix: - target_file_glob_pattern = ( - f"{storage_bucket_folder_path_prefix}/{target_file_name}" - ) - elif target_file_name: - target_file_glob_pattern = target_file_name - elif storage_bucket_folder_path_prefix: - target_file_glob_pattern = f"{storage_bucket_folder_path_prefix}/*" - else: - target_file_glob_pattern = "**" - - return RequestSpec( - method="POST", - endpoint=Endpoint(f"/ecs_/v2/indexes/{index_id}/createBatchRag"), - json={ - "name": name, - "prompt": prompt, - "targetFileGlobPattern": target_file_glob_pattern, - "useWebSearchGrounding": enable_web_search_grounding, - "outputColumns": [ - column.model_dump(by_alias=True) for column in output_columns - ], - }, - headers={ - **header_folder(folder_key, None), - }, - ) - - def _batch_transform_ephemeral_creation_spec( - self, - index_id: str | None, - name: str, - enable_web_search_grounding: bool, - output_columns: list[BatchTransformOutputColumn], - storage_bucket_folder_path_prefix: str | None, - prompt: str, - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint(f"/ecs_/v2/indexes/{index_id}/createBatchRag"), - json={ - "name": name, - "prompt": prompt, - "targetFileGlobPattern": f"{storage_bucket_folder_path_prefix}/*" - if storage_bucket_folder_path_prefix - else "**", - "useWebSearchGrounding": enable_web_search_grounding, - "outputColumns": [ - column.model_dump(by_alias=True) for column in output_columns - ], - }, - headers={}, - ) - - def _deep_rag_retrieve_spec( - self, - id: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/ecs_/v2/deeprag/{id}"), - params={ - "$expand": "content", - "$select": "content,name,createdDate,lastDeepRagStatus", - }, - ) - - def _batch_transform_retrieve_spec( - self, - id: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}"), - ) - - def _batch_transform_get_read_uri_spec( - self, - id: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}/GetReadUri"), - ) - - def _batch_transform_download_blob_spec( - self, - id: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}/DownloadBlob"), - ) - - def _resolve_folder_key(self, folder_key, folder_path): - if folder_key is None and folder_path is not None: - folder_key = self._folders_service.retrieve_key(folder_path=folder_path) - - if folder_key is None and folder_path is None: - folder_key = self._folder_key or ( - self._folders_service.retrieve_key(folder_path=self._folder_path) - if self._folder_path - else None - ) - - return folder_key - - def _extract_bucket_info(self, index: ContextGroundingIndex) -> Tuple[str, str]: - """Extract bucket information from the index, validating it's a storage bucket data source. - - Args: - index: The context grounding index - - Returns: - Tuple of (bucket_name, folder_path) - - Raises: - UnsupportedDataSourceException: If the data source is not an Orchestrator Storage Bucket - """ - if not index.data_source: - raise UnsupportedDataSourceException("add_to_index") - - # Check if the data source has the @odata.type field indicating it's a storage bucket - data_source_dict = ( - index.data_source.model_dump(by_alias=True) - if hasattr(index.data_source, "model_dump") - else index.data_source.__dict__ - ) - odata_type = data_source_dict.get("@odata.type") or data_source_dict.get( - "odata.type" - ) - - if odata_type and odata_type != ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE: - raise UnsupportedDataSourceException("add_to_index", odata_type) - - # Try to extract bucket information - bucket_name = getattr(index.data_source, "bucketName", None) - folder = getattr(index.data_source, "folder", None) - - if not bucket_name or not folder: - raise UnsupportedDataSourceException("add_to_index") - - return bucket_name, folder diff --git a/src/uipath/platform/context_grounding/context_grounding.py b/src/uipath/platform/context_grounding/context_grounding.py deleted file mode 100644 index 851e2a81a..000000000 --- a/src/uipath/platform/context_grounding/context_grounding.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Context Grounding response payload models.""" - -from enum import Enum -from typing import Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class BatchTransformOutputColumn(BaseModel): - """Model representing a batch transform output column.""" - - name: str = Field( - min_length=1, - max_length=500, - pattern=r"^[\w\s\.,!?-]+$", - ) - description: str = Field(..., min_length=1, max_length=20000) - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - -class CitationMode(str, Enum): - """Enum representing possible citation modes.""" - - SKIP = "Skip" - INLINE = "Inline" - - -class EphemeralIndexUsage(str, Enum): - """Enum representing possible ephemeral index usage types.""" - - DEEP_RAG = "DeepRAG" - BATCH_RAG = "BatchRAG" - - -class DeepRagStatus(str, Enum): - """Enum representing possible deep RAG tasks status.""" - - QUEUED = "Queued" - IN_PROGRESS = "InProgress" - SUCCESSFUL = "Successful" - FAILED = "Failed" - - -class IndexStatus(str, Enum): - """Enum representing possible index tasks status.""" - - QUEUED = "Queued" - IN_PROGRESS = "InProgress" - SUCCESSFUL = "Successful" - FAILED = "Failed" - - -class Citation(BaseModel): - """Model representing a deep RAG citation.""" - - ordinal: int - page_number: int = Field(alias="pageNumber") - source: str - reference: str - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - -class DeepRagContent(BaseModel): - """Model representing a deep RAG task content.""" - - text: str - citations: list[Citation] - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - -class DeepRagResponse(BaseModel): - """Model representing a deep RAG task response.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - name: str - created_date: str = Field(alias="createdDate") - last_deep_rag_status: DeepRagStatus = Field(alias="lastDeepRagStatus") - content: DeepRagContent | None = Field(alias="content") - - -class BatchTransformStatus(str, Enum): - """Enum representing possible batch transform status values.""" - - IN_PROGRESS = "InProgress" - SUCCESSFUL = "Successful" - QUEUED = "Queued" - FAILED = "Failed" - - -class BatchTransformCreationResponse(BaseModel): - """Model representing a batch transform task creation response.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - ) - id: str - last_batch_rag_status: DeepRagStatus = Field(alias="lastBatchRagStatus") - error_message: str | None = Field(alias="errorMessage", default=None) - - -class BatchTransformResponse(BaseModel): - """Model representing a batch transform task response.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - ) - id: str - name: str - last_batch_rag_status: BatchTransformStatus = Field(alias="lastBatchRagStatus") - prompt: str - target_file_glob_pattern: str = Field(alias="targetFileGlobPattern") - use_web_search_grounding: bool = Field(alias="useWebSearchGrounding") - output_columns: list[BatchTransformOutputColumn] = Field(alias="outputColumns") - created_date: str = Field(alias="createdDate") - - -class BatchTransformReadUriResponse(BaseModel): - """Model representing a batch transform result file download URI response.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - ) - uri: str - is_encrypted: bool = Field(alias="isEncrypted", default=False) - - -class DeepRagCreationResponse(BaseModel): - """Model representing a deep RAG task creation response.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - id: str - last_deep_rag_status: DeepRagStatus = Field(alias="lastDeepRagStatus") - created_date: str = Field(alias="createdDate") - - -class ContextGroundingMetadata(BaseModel): - """Model representing metadata for a Context Grounding query response.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - operation_id: str = Field(alias="operation_id") - strategy: str = Field(alias="strategy") - - -class ContextGroundingQueryResponse(BaseModel): - """Model representing a Context Grounding query response item.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - source: str = Field(alias="source") - page_number: str = Field(alias="page_number") - content: str = Field(alias="content") - metadata: ContextGroundingMetadata = Field(alias="metadata") - source_document_id: Optional[str] = Field(default=None, alias="source_document_id") - caption: Optional[str] = Field(default=None, alias="caption") - score: Optional[float] = Field(default=None, alias="score") - reference: Optional[str] = Field(default=None, alias="reference") diff --git a/src/uipath/platform/context_grounding/context_grounding_index.py b/src/uipath/platform/context_grounding/context_grounding_index.py deleted file mode 100644 index b4261f014..000000000 --- a/src/uipath/platform/context_grounding/context_grounding_index.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Models for Context Grounding Index in the UiPath platform.""" - -from datetime import datetime -from typing import Any, List, Optional - -from pydantic import BaseModel, ConfigDict, Field, field_serializer - - -class ContextGroundingField(BaseModel): - """Model representing a field in a Context Grounding Index.""" - - id: Optional[str] = Field(default=None, alias="id") - name: Optional[str] = Field(default=None, alias="name") - description: Optional[str] = Field(default=None, alias="description") - type: Optional[str] = Field(default=None, alias="type") - is_filterable: Optional[bool] = Field(default=None, alias="isFilterable") - searchable_type: Optional[str] = Field(default=None, alias="searchableType") - is_user_defined: Optional[bool] = Field(default=None, alias="isUserDefined") - - -class ContextGroundingDataSource(BaseModel): - """Model representing a data source in a Context Grounding Index.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - id: Optional[str] = Field(default=None, alias="id") - folder: Optional[str] = Field(default=None, alias="folder") - bucketName: Optional[str] = Field(default=None, alias="bucketName") - - -class ContextGroundingIndex(BaseModel): - """Model representing a Context Grounding Index in the UiPath platform.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - @field_serializer("last_ingested", "last_queried") - def serialize_datetime(self, value): - """Serialize datetime fields to ISO 8601 format.""" - if isinstance(value, datetime): - return value.isoformat() if value else None - return value - - id: Optional[str] = Field(default=None, alias="id") - name: Optional[str] = Field(default=None, alias="name") - description: Optional[str] = Field(default=None, alias="description") - memory_usage: Optional[int] = Field(default=None, alias="memoryUsage") - disk_usage: Optional[int] = Field(default=None, alias="diskUsage") - data_source: Optional[ContextGroundingDataSource] = Field( - default=None, alias="dataSource" - ) - pre_processing: Any = Field(default=None, alias="preProcessing") - fields: Optional[List[ContextGroundingField]] = Field(default=None, alias="fields") - last_ingestion_status: Optional[str] = Field( - default=None, alias="lastIngestionStatus" - ) - last_ingested: Optional[datetime] = Field(default=None, alias="lastIngested") - last_queried: Optional[datetime] = Field(default=None, alias="lastQueried") - folder_key: Optional[str] = Field(default=None, alias="folderKey") - - def in_progress_ingestion(self): - """Check if the last ingestion is in progress.""" - return ( - self.last_ingestion_status == "Queued" - or self.last_ingestion_status == "InProgress" - ) diff --git a/src/uipath/platform/context_grounding/context_grounding_payloads.py b/src/uipath/platform/context_grounding/context_grounding_payloads.py deleted file mode 100644 index 420665245..000000000 --- a/src/uipath/platform/context_grounding/context_grounding_payloads.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Payload models for context grounding index creation and configuration.""" - -import re -from typing import Any, Dict, Literal, Optional, Union - -from pydantic import BaseModel, ConfigDict, Field, model_validator -from pydantic.alias_generators import to_camel - -from uipath._utils.constants import ( - CONFLUENCE_DATA_SOURCE_REQUEST, - DROPBOX_DATA_SOURCE_REQUEST, - GOOGLE_DRIVE_DATA_SOURCE_REQUEST, - ONEDRIVE_DATA_SOURCE_REQUEST, - ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST, -) - - -class BaseDataSource(BaseModel): - """Base model for data source configurations.""" - - folder: str = Field(alias="folder", description="Folder path") - file_name_glob: str = Field( - alias="fileNameGlob", description="File name glob pattern" - ) - directory_path: str = Field(alias="directoryPath", description="Directory path") - - -class BucketDataSource(BaseDataSource): - """Data source configuration for storage buckets.""" - - odata_type: str = Field( - alias="@odata.type", - default=ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST, - ) - bucket_name: str = Field(alias="bucketName", description="Storage bucket name") - - -class GoogleDriveDataSource(BaseDataSource): - """Data source configuration for Google Drive.""" - - odata_type: str = Field( - alias="@odata.type", - default=GOOGLE_DRIVE_DATA_SOURCE_REQUEST, - ) - connection_id: str = Field(alias="connectionId", description="Connection ID") - connection_name: str = Field(alias="connectionName", description="Connection name") - leaf_folder_id: str = Field(alias="leafFolderId", description="Leaf folder ID") - - -class DropboxDataSource(BaseDataSource): - """Data source configuration for Dropbox.""" - - odata_type: str = Field( - alias="@odata.type", - default=DROPBOX_DATA_SOURCE_REQUEST, - ) - connection_id: str = Field(alias="connectionId", description="Connection ID") - connection_name: str = Field(alias="connectionName", description="Connection name") - - -class OneDriveDataSource(BaseDataSource): - """Data source configuration for OneDrive.""" - - odata_type: str = Field( - alias="@odata.type", - default=ONEDRIVE_DATA_SOURCE_REQUEST, - ) - connection_id: str = Field(alias="connectionId", description="Connection ID") - connection_name: str = Field(alias="connectionName", description="Connection name") - leaf_folder_id: str = Field(alias="leafFolderId", description="Leaf folder ID") - - -class ConfluenceDataSource(BaseDataSource): - """Data source configuration for Confluence.""" - - odata_type: str = Field( - alias="@odata.type", - default=CONFLUENCE_DATA_SOURCE_REQUEST, - ) - connection_id: str = Field(alias="connectionId", description="Connection ID") - connection_name: str = Field(alias="connectionName", description="Connection name") - space_id: str = Field(alias="spaceId", description="Space ID") - - -class AttachmentsDataSource(BaseModel): - """Data source configuration for Attachments.""" - - attachments: list[str] = Field(description="List of attachment ids") - - -class Indexer(BaseModel): - """Configuration for periodic indexing of data sources.""" - - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) - - cron_expression: str = Field(description="Cron expression for scheduling") - time_zone_id: str = Field(default="UTC", description="Time zone ID") - - @model_validator(mode="before") - @classmethod - def validate_cron(cls, values: Dict[str, Any]) -> Dict[str, Any]: - """Validate cron expression format.""" - cron_expr = values.get("cron_expression") or values.get("cronExpression") - if not cron_expr: - return values - - # Supports @aliases, @every syntax and standard cron expressions with 5-7 fields - cron_pattern = r"^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})$" - - if not re.match(cron_pattern, cron_expr.strip(), re.IGNORECASE): - raise ValueError(f"Invalid cron expression format: '{cron_expr}'") - - return values - - -class PreProcessing(BaseModel): - """Preprocessing configuration for context grounding index.""" - - odata_type: str = Field( - alias="@odata.type", description="OData type for preprocessing" - ) - - -class CreateIndexPayload(BaseModel): - """Payload for creating a context grounding index. - - Note: data_source is Dict[str, Any] because it may contain additional - fields like 'indexer' that are added dynamically based on configuration. - The data source is still validated through the _build_data_source method - which uses typed models internally. - """ - - name: str = Field(description="Index name") - description: str = Field(default="", description="Index description") - data_source: Dict[str, Any] = Field( - alias="dataSource", description="Data source configuration" - ) - pre_processing: Optional[PreProcessing] = Field( - default=None, alias="preProcessing", description="Preprocessing configuration" - ) - - model_config = ConfigDict(populate_by_name=True) - - -class CreateEphemeralIndexPayload(BaseModel): - """Payload for creating an ephemeral context grounding index. - - Note: data_source is Dict[str, Any] because it may contain additional - fields like 'indexer' that are added dynamically based on configuration. - The data source is still validated through the _build_data_source method - which uses typed models internally. - """ - - usage: str = Field(description="Index usage") - data_source: Dict[str, Any] = Field( - alias="dataSource", description="Data source configuration" - ) - - model_config = ConfigDict(populate_by_name=True) - - -# user-facing source configuration models -class BaseSourceConfig(BaseModel): - """Base configuration for all source types.""" - - folder_path: str = Field(description="Folder path in orchestrator") - directory_path: str = Field(description="Directory path") - file_type: Optional[str] = Field( - default=None, description="File type filter (e.g., 'pdf', 'txt')" - ) - indexer: Optional[Indexer] = Field( - default=None, description="Optional indexer configuration for periodic updates" - ) - - -class ConnectionSourceConfig(BaseSourceConfig): - """Base configuration for sources that use connections.""" - - connection_id: str = Field(description="Connection ID") - connection_name: str = Field(description="Connection name") - - -class BucketSourceConfig(BaseSourceConfig): - """Data source configuration for storage buckets.""" - - type: Literal["bucket"] = Field( - default="bucket", description="Source type identifier" - ) - bucket_name: str = Field(description="Storage bucket name") - directory_path: str = Field(default="/", description="Directory path in bucket") - - -class GoogleDriveSourceConfig(ConnectionSourceConfig): - """Data source configuration for Google Drive.""" - - type: Literal["google_drive"] = Field( - default="google_drive", description="Source type identifier" - ) - leaf_folder_id: str = Field(description="Leaf folder ID in Google Drive") - - -class DropboxSourceConfig(ConnectionSourceConfig): - """Data source configuration for Dropbox.""" - - type: Literal["dropbox"] = Field( - default="dropbox", description="Source type identifier" - ) - - -class OneDriveSourceConfig(ConnectionSourceConfig): - """Data source configuration for OneDrive.""" - - type: Literal["onedrive"] = Field( - default="onedrive", description="Source type identifier" - ) - leaf_folder_id: str = Field(description="Leaf folder ID in OneDrive") - - -class ConfluenceSourceConfig(ConnectionSourceConfig): - """Data source configuration for Confluence.""" - - type: Literal["confluence"] = Field( - default="confluence", description="Source type identifier" - ) - space_id: str = Field(description="Confluence space ID") - - -SourceConfig = Union[ - BucketSourceConfig, - GoogleDriveSourceConfig, - DropboxSourceConfig, - OneDriveSourceConfig, - ConfluenceSourceConfig, -] diff --git a/src/uipath/platform/documents/__init__.py b/src/uipath/platform/documents/__init__.py deleted file mode 100644 index 78f4b9156..000000000 --- a/src/uipath/platform/documents/__init__.py +++ /dev/null @@ -1,50 +0,0 @@ -"""UiPath Documents Models. - -This module contains models related to UiPath Document Understanding service. -""" - -from ._documents_service import DocumentsService # type: ignore[attr-defined] -from .documents import ( - ActionPriority, - ClassificationResponse, - ClassificationResult, - DocumentBounds, - ExtractionResponse, - ExtractionResponseIXP, - ExtractionResult, - FieldGroupValueProjection, - FieldType, - FieldValueProjection, - FileContent, - ProjectType, - Reference, - StartExtractionResponse, - StartExtractionValidationResponse, - StartOperationResponse, - ValidateClassificationAction, - ValidateExtractionAction, - ValidationAction, -) - -__all__ = [ - "DocumentsService", - "FieldType", - "ActionPriority", - "ProjectType", - "FieldValueProjection", - "FieldGroupValueProjection", - "ExtractionResult", - "ExtractionResponse", - "ExtractionResponseIXP", - "ValidationAction", - "ValidateClassificationAction", - "ValidateExtractionAction", - "Reference", - "DocumentBounds", - "ClassificationResult", - "ClassificationResponse", - "FileContent", - "StartExtractionResponse", - "StartOperationResponse", - "StartExtractionValidationResponse", -] diff --git a/src/uipath/platform/documents/_documents_service.py b/src/uipath/platform/documents/_documents_service.py deleted file mode 100644 index f1a88db1d..000000000 --- a/src/uipath/platform/documents/_documents_service.py +++ /dev/null @@ -1,2551 +0,0 @@ -# type: ignore # this is riddled with typing issues -- fix this later. -import asyncio -import time -from contextlib import nullcontext -from pathlib import Path -from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, Union -from uuid import UUID - -from ..._utils import Endpoint, resource_override -from ...tracing import traced -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from ..errors import OperationFailedException, OperationNotCompleteException -from .documents import ( - ActionPriority, - ClassificationResponse, - ClassificationResult, - ExtractionResponse, - ExtractionResponseIXP, - FileContent, - ProjectType, - StartExtractionResponse, - StartExtractionValidationResponse, - ValidateClassificationAction, - ValidateExtractionAction, -) - -POLLING_INTERVAL = 2 # seconds -POLLING_TIMEOUT = 300 # seconds - - -def _must_not_be_provided(**kwargs: Any) -> None: - for name, value in kwargs.items(): - if value is not None: - raise ValueError(f"`{name}` must not be provided") - - -def _must_be_provided(**kwargs: Any) -> None: - for name, value in kwargs.items(): - if value is None: - raise ValueError(f"`{name}` must be provided") - - -def _exactly_one_must_be_provided(**kwargs: Any) -> None: - provided = [name for name, value in kwargs.items() if value is not None] - if len(provided) != 1: - raise ValueError( - f"Exactly one of `{', '.join(kwargs.keys())}` must be provided" - ) - - -def _validate_classify_params( - project_type: ProjectType, - tag: Optional[str], - version: Optional[int], - project_name: Optional[str], - file: Optional[FileContent], - file_path: Optional[str], -) -> None: - _exactly_one_must_be_provided(file=file, file_path=file_path) - if project_type == ProjectType.PRETRAINED: - _must_not_be_provided( - project_name=project_name, - tag=tag, - version=version, - ) - else: - _must_be_provided( - project_name=project_name, - ) - _exactly_one_must_be_provided(tag=tag, version=version) - - -def _validate_extract_params_and_get_project_type( - tag: Optional[str], - version: Optional[int], - project_name: Optional[str], - file: Optional[FileContent], - file_path: Optional[str], - classification_result: Optional[ClassificationResult], - project_type: Optional[ProjectType], - document_type_name: Optional[str], -) -> ProjectType: - if file or file_path: - _exactly_one_must_be_provided(file=file, file_path=file_path) - _must_be_provided(project_type=project_type) - _must_not_be_provided( - classification_result=classification_result, - ) - - if project_type == ProjectType.PRETRAINED: - _must_not_be_provided(tag=tag, project_name=project_name, version=version) - _must_be_provided(document_type_name=document_type_name) - elif project_type == ProjectType.MODERN: - _must_be_provided( - project_name=project_name, - document_type_name=document_type_name, - ) - _exactly_one_must_be_provided(version=version, tag=tag) - else: - _must_be_provided(project_name=project_name) - _exactly_one_must_be_provided(version=version, tag=tag) - _must_not_be_provided(document_type_name=document_type_name) - else: - _must_be_provided(classification_result=classification_result) - _must_not_be_provided( - tag=tag, - version=version, - project_name=project_name, - project_type=project_type, - file=file, - file_path=file_path, - document_type_name=document_type_name, - ) - project_type = classification_result.project_type - - return project_type - - -class DocumentsService(FolderContext, BaseService): - """Service for managing UiPath DocumentUnderstanding Document Operations. - - This service provides methods to extract data from documents using UiPath's Document Understanding capabilities. - - !!! warning "Preview Feature" - This function is currently experimental. - Behavior and parameters are subject to change in future versions. - """ - - def __init__( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - polling_interval: float = POLLING_INTERVAL, - polling_timeout: float = POLLING_TIMEOUT, - ) -> None: - super().__init__(config=config, execution_context=execution_context) - self.polling_interval = polling_interval - self.polling_timeout = polling_timeout - - def _get_common_headers(self) -> Dict[str, str]: - return { - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - } - - def _get_classifier_id( - self, project_type: ProjectType, project_id: str, version: Optional[int] - ) -> Optional[str]: - if project_type == ProjectType.PRETRAINED: - return "ml-classification" - - if version is None: - return None - - response = self.request( - "GET", - url=Endpoint(f"/du_/api/framework/projects/{project_id}/classifiers"), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - - try: - return next( - classifier["id"] - for classifier in response.json().get("classifiers", []) - if classifier["projectVersion"] == version - ) - except StopIteration: - raise ValueError(f"Classifier for version '{version}' not found.") from None - - async def _get_classifier_id_async( - self, project_type: ProjectType, project_id: str, version: Optional[int] - ) -> Optional[str]: - if project_type == ProjectType.PRETRAINED: - return "ml-classification" - - if version is None: - return None - - response = await self.request_async( - "GET", - url=Endpoint(f"/du_/api/framework/projects/{project_id}/classifiers"), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - - try: - return next( - classifier["id"] - for classifier in response.json().get("classifiers", []) - if classifier["projectVersion"] == version - ) - except StopIteration: - raise ValueError(f"Classifier for version '{version}' not found.") from None - - def _get_extractor_id( - self, - project_id: str, - version: Optional[int], - document_type_id: str, - project_type: ProjectType, - ) -> str: - if project_type == ProjectType.PRETRAINED: - return document_type_id - - if version is None: - return None - - response = self.request( - "GET", - url=Endpoint(f"/du_/api/framework/projects/{project_id}/extractors"), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - - try: - return next( - extractor["id"] - for extractor in response.json().get("extractors", []) - if extractor["projectVersion"] == version - and extractor["documentTypeId"] == document_type_id - ) - except StopIteration: - raise ValueError( - f"Extractor for version '{version}' and document type id '{document_type_id}' not found." - ) from None - - async def _get_extractor_id_async( - self, - project_id: str, - version: Optional[int], - document_type_id: str, - project_type: ProjectType, - ) -> str: - if project_type == ProjectType.PRETRAINED: - return document_type_id - - if version is None: - return None - - response = await self.request_async( - "GET", - url=Endpoint(f"/du_/api/framework/projects/{project_id}/extractors"), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - - try: - return next( - extractor["id"] - for extractor in response.json().get("extractors", []) - if extractor["projectVersion"] == version - and extractor["documentTypeId"] == document_type_id - ) - except StopIteration: - raise ValueError( - f"Extractor for version '{version}' and document type id '{document_type_id}' not found." - ) from None - - def _get_project_id( - self, - project_type: ProjectType, - project_name: Optional[str], - classification_result: Optional[ClassificationResult], - ) -> str: - if project_type == ProjectType.PRETRAINED: - return str(UUID(int=0)) - - if classification_result is not None: - return classification_result.project_id - - response = self.request( - "GET", - url=Endpoint("/du_/api/framework/projects"), - params={"api-version": 1.1, "type": project_type.value}, - headers=self._get_common_headers(), - ) - - try: - return next( - project["id"] - for project in response.json()["projects"] - if project["name"] == project_name - ) - except StopIteration: - raise ValueError(f"Project '{project_name}' not found.") from None - - async def _get_project_id_async( - self, - project_type: ProjectType, - project_name: Optional[str], - classification_result: Optional[ClassificationResult], - ) -> str: - if project_type == ProjectType.PRETRAINED: - return str(UUID(int=0)) - - if classification_result is not None: - return classification_result.project_id - - response = await self.request_async( - "GET", - url=Endpoint("/du_/api/framework/projects"), - params={"api-version": 1.1, "type": project_type.value}, - headers=self._get_common_headers(), - ) - - try: - return next( - project["id"] - for project in response.json()["projects"] - if project["name"] == project_name - ) - except StopIteration: - raise ValueError(f"Project '{project_name}' not found.") from None - - def _get_project_tags(self, project_id: str) -> Set[str]: - response = self.request( - "GET", - url=Endpoint(f"/du_/api/framework/projects/{project_id}/tags"), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - return {tag["name"] for tag in response.json().get("tags", [])} - - async def _get_project_tags_async(self, project_id: str) -> Set[str]: - response = await self.request_async( - "GET", - url=Endpoint(f"/du_/api/framework/projects/{project_id}/tags"), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - return {tag["name"] for tag in response.json().get("tags", [])} - - def _get_document_id( - self, - project_id: Optional[str], - file: Optional[FileContent], - file_path: Optional[str], - classification_result: Optional[ClassificationResult], - ) -> str: - if classification_result is not None: - return classification_result.document_id - - document_id = self._start_digitization( - project_id=project_id, - file=file, - file_path=file_path, - ) - self._wait_for_digitization( - project_id=project_id, - document_id=document_id, - ) - - return document_id - - async def _get_document_id_async( - self, - project_id: Optional[str], - file: Optional[FileContent], - file_path: Optional[str], - classification_result: Optional[ClassificationResult], - ) -> str: - if classification_result is not None: - return classification_result.document_id - - document_id = await self._start_digitization_async( - project_id=project_id, - file=file, - file_path=file_path, - ) - await self._wait_for_digitization_async( - project_id=project_id, - document_id=document_id, - ) - - return document_id - - def _get_version( - self, - version: Optional[int], - project_type: ProjectType, - classification_result: Optional[ClassificationResult], - ) -> Optional[int]: - if project_type == ProjectType.PRETRAINED: - return None - - if version is not None: - return version - - if classification_result is None or classification_result.classifier_id is None: - return None - - return self.request( - "GET", - url=Endpoint( - f"/du_/api/framework/projects/{classification_result.project_id}/classifiers/{classification_result.classifier_id}" - ), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ).json()["projectVersion"] - - async def _get_version_async( - self, - version: Optional[int], - project_type: ProjectType, - classification_result: Optional[ClassificationResult], - ) -> Optional[int]: - if project_type == ProjectType.PRETRAINED: - return None - - if version is not None: - return version - - if classification_result is None or classification_result.classifier_id is None: - return None - - return ( - await self.request_async( - "GET", - url=Endpoint( - f"/du_/api/framework/projects/{classification_result.project_id}/classifiers/{classification_result.classifier_id}" - ), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - ).json()["projectVersion"] - - def _get_tag( - self, - project_type: ProjectType, - project_id: str, - tag: Optional[str], - version: Optional[int], - project_name: Optional[str], - classification_result: Optional[ClassificationResult], - ) -> Optional[str]: - if project_type == ProjectType.PRETRAINED: - return None - - if version is not None: - return None - - if classification_result is not None: - return classification_result.tag - - tags = self._get_project_tags(project_id) - if tag not in tags: - raise ValueError( - f"Tag '{tag}' not found in project '{project_name}'. Available tags: {tags}" - ) - - return tag - - async def _get_tag_async( - self, - project_type: ProjectType, - project_id: str, - tag: Optional[str], - version: Optional[int], - project_name: Optional[str], - classification_result: Optional[ClassificationResult], - ) -> Optional[str]: - if project_type == ProjectType.PRETRAINED: - return None - - if version is not None: - return None - - if classification_result is not None: - return classification_result.tag - - tags = await self._get_project_tags_async(project_id) - if tag not in tags: - raise ValueError( - f"Tag '{tag}' not found in project '{project_name}'. Available tags: {tags}" - ) - - return tag - - def _start_digitization( - self, - project_id: str, - file: Optional[FileContent] = None, - file_path: Optional[str] = None, - ) -> str: - with open(Path(file_path), "rb") if file_path else nullcontext(file) as handle: - return self.request( - "POST", - url=Endpoint( - f"/du_/api/framework/projects/{project_id}/digitization/start" - ), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - files={"File": handle}, - ).json()["documentId"] - - async def _start_digitization_async( - self, - project_id: str, - file: Optional[FileContent] = None, - file_path: Optional[str] = None, - ) -> str: - with open(Path(file_path), "rb") if file_path else nullcontext(file) as handle: - return ( - await self.request_async( - "POST", - url=Endpoint( - f"/du_/api/framework/projects/{project_id}/digitization/start" - ), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - files={"File": handle}, - ) - ).json()["documentId"] - - def _wait_for_digitization(self, project_id: str, document_id: str) -> None: - def result_getter() -> Tuple[str, Optional[str], Optional[str]]: - result = self.request( - method="GET", - url=Endpoint( - f"/du_/api/framework/projects/{project_id}/digitization/result/{document_id}" - ), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ).json() - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - self._wait_for_operation( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - async def _wait_for_digitization_async( - self, project_id: str, document_id: str - ) -> None: - async def result_getter() -> Tuple[str, Optional[str], Optional[str]]: - result = ( - await self.request_async( - method="GET", - url=Endpoint( - f"/du_/api/framework/projects/{project_id}/digitization/result/{document_id}" - ), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - ).json() - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - await self._wait_for_operation_async( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - def _get_document_type_id( - self, - project_id: str, - document_type_name: Optional[str], - project_type: ProjectType, - classification_result: Optional[ClassificationResult], - ) -> str: - if project_type == ProjectType.IXP: - return str(UUID(int=0)) - - if classification_result is not None: - return classification_result.document_type_id - - response = self.request( - "GET", - url=Endpoint(f"/du_/api/framework/projects/{project_id}/document-types"), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - - try: - return next( - extractor["id"] - for extractor in response.json().get("documentTypes", []) - if extractor["name"].lower() == document_type_name.lower() - ) - except StopIteration: - raise ValueError( - f"Document type '{document_type_name}' not found." - ) from None - - async def _get_document_type_id_async( - self, - project_id: str, - document_type_name: Optional[str], - project_type: ProjectType, - classification_result: Optional[ClassificationResult], - ) -> str: - if project_type == ProjectType.IXP: - return str(UUID(int=0)) - - if classification_result is not None: - return classification_result.document_type_id - - response = await self.request_async( - "GET", - url=Endpoint(f"/du_/api/framework/projects/{project_id}/document-types"), - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - - try: - return next( - extractor["id"] - for extractor in response.json().get("documentTypes", []) - if extractor["name"].lower() == document_type_name.lower() - ) - except StopIteration: - raise ValueError( - f"Document type '{document_type_name}' not found." - ) from None - - def _start_extraction( - self, - project_id: str, - extractor_id: str, - tag: Optional[str], - document_type_id: str, - document_id: str, - ) -> StartExtractionResponse: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/start" - ) - - operation_id = self.request( - "POST", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - json={"documentId": document_id}, - ).json()["operationId"] - - return StartExtractionResponse( - operation_id=operation_id, - document_id=document_id, - project_id=project_id, - tag=tag, - ) - - async def _start_extraction_async( - self, - project_id: str, - extractor_id: str, - tag: Optional[str], - document_type_id: str, - document_id: str, - ) -> StartExtractionResponse: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/start" - ) - - operation_id = ( - await self.request_async( - "POST", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - json={"documentId": document_id}, - ) - ).json()["operationId"] - - return StartExtractionResponse( - operation_id=operation_id, - document_id=document_id, - project_id=project_id, - tag=tag, - ) - - def _wait_for_operation( - self, - result_getter: Callable[[], Tuple[Any, Optional[Any], Optional[Any]]], - wait_statuses: List[str], - success_status: str, - ) -> Any: - start_time = time.monotonic() - status = wait_statuses[0] - result = None - - while ( - status in wait_statuses - and (time.monotonic() - start_time) < self.polling_timeout - ): - status, error, result = result_getter() - time.sleep(self.polling_interval) - - if status != success_status: - if time.monotonic() - start_time >= self.polling_timeout: - raise TimeoutError("Operation timed out.") - raise RuntimeError( - f"Operation failed with status: {status}, error: {error}" - ) - - return result - - async def _wait_for_operation_async( - self, - result_getter: Callable[ - [], Awaitable[Tuple[Any, Optional[Any], Optional[Any]]] - ], - wait_statuses: List[str], - success_status: str, - ) -> Any: - start_time = time.monotonic() - status = wait_statuses[0] - result = None - - while ( - status in wait_statuses - and (time.monotonic() - start_time) < self.polling_timeout - ): - status, error, result = await result_getter() - await asyncio.sleep(self.polling_interval) - - if status != success_status: - if time.monotonic() - start_time >= self.polling_timeout: - raise TimeoutError("Operation timed out.") - raise RuntimeError( - f"Operation failed with status: {status}, error: {error}" - ) - - return result - - def _wait_for_extraction( - self, - project_id: str, - extractor_id: Optional[str], - tag: Optional[str], - document_type_id: str, - operation_id: str, - project_type: ProjectType, - ) -> Union[ExtractionResponse, ExtractionResponseIXP]: - def result_getter() -> Tuple[str, str, Any]: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/result/{operation_id}" - ) - - result = self.request( - method="GET", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ).json() - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - extraction_response = self._wait_for_operation( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - extraction_response["projectId"] = project_id - extraction_response["extractorId"] = extractor_id - extraction_response["tag"] = tag - extraction_response["documentTypeId"] = document_type_id - extraction_response["projectType"] = project_type - - if project_type == ProjectType.IXP: - return ExtractionResponseIXP.model_validate(extraction_response) - - return ExtractionResponse.model_validate(extraction_response) - - async def _wait_for_extraction_async( - self, - project_id: str, - extractor_id: Optional[str], - tag: Optional[str], - document_type_id: str, - operation_id: str, - project_type: ProjectType, - ) -> Union[ExtractionResponse, ExtractionResponseIXP]: - async def result_getter() -> Tuple[str, str, Any]: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/result/{operation_id}" - ) - - result = ( - await self.request_async( - method="GET", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - ).json() - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - extraction_response = await self._wait_for_operation_async( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - extraction_response["projectId"] = project_id - extraction_response["extractorId"] = extractor_id - extraction_response["tag"] = tag - extraction_response["documentTypeId"] = document_type_id - extraction_response["projectType"] = project_type - - if project_type == ProjectType.IXP: - return ExtractionResponseIXP.model_validate(extraction_response) - - return ExtractionResponse.model_validate(extraction_response) - - def _start_classification( - self, - project_id: str, - tag: Optional[str], - classifier_id: Optional[str], - document_id: str, - ) -> str: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/start" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/classification/start" - ) - - return self.request( - "POST", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - json={"documentId": document_id}, - ).json()["operationId"] - - async def _start_classification_async( - self, - project_id: str, - tag: Optional[str], - classifier_id: Optional[str], - document_id: str, - ) -> str: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/start" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/classification/start" - ) - - return ( - await self.request_async( - "POST", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - json={"documentId": document_id}, - ) - ).json()["operationId"] - - def _wait_for_classification( - self, - project_id: str, - project_type: ProjectType, - classifier_id: Optional[str], - tag: Optional[str], - operation_id: str, - ) -> List[ClassificationResult]: - def result_getter() -> Tuple[str, Optional[str], Optional[str]]: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/result/{operation_id}" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/classification/result/{operation_id}" - ) - - result = self.request( - method="GET", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ).json() - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - classification_response = self._wait_for_operation( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - for classification_result in classification_response["classificationResults"]: - classification_result["ProjectId"] = project_id - classification_result["ProjectType"] = project_type - classification_result["ClassifierId"] = classifier_id - classification_result["Tag"] = tag - - return ClassificationResponse.model_validate( - classification_response - ).classification_results - - async def _wait_for_classification_async( - self, - project_id: str, - project_type: ProjectType, - classifier_id: Optional[str], - tag: Optional[str], - operation_id: str, - ) -> List[ClassificationResult]: - async def result_getter() -> Tuple[str, Optional[str], Optional[str]]: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/result/{operation_id}" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/classification/result/{operation_id}" - ) - - result = ( - await self.request_async( - method="GET", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - ).json() - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - classification_response = await self._wait_for_operation_async( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - for classification_result in classification_response["classificationResults"]: - classification_result["ProjectId"] = project_id - classification_result["ProjectType"] = project_type - classification_result["ClassifierId"] = classifier_id - classification_result["Tag"] = tag - - return ClassificationResponse.model_validate( - classification_response - ).classification_results - - @traced(name="documents_classify", run_type="uipath") - def classify( - self, - project_type: ProjectType, - tag: Optional[str] = None, - version: Optional[int] = None, - project_name: Optional[str] = None, - file: Optional[FileContent] = None, - file_path: Optional[str] = None, - ) -> List[ClassificationResult]: - """Classify a document using a DU Modern project. - - Args: - project_type (ProjectType): Type of the project. - project_name (str, optional): Name of the [DU Modern](https://docs.uipath.com/document-understanding/automation-cloud/latest/user-guide/about-document-understanding) project. Must be provided if `project_type` is not `ProjectType.PRETRAINED`. - tag (str, optional): Tag of the published project version. Must be provided if `project_type` is not `ProjectType.PRETRAINED`. - version (int, optional): Version of the published project. It can be used instead of `tag`. - file (FileContent, optional): The document file to be classified. - file_path (str, optional): Path to the document file to be classified. - - Note: - Either `file` or `file_path` must be provided, but not both. - - Returns: - List[ClassificationResult]: A list of classification results. - - Examples: - ```python - Modern DU project: - with open("path/to/document.pdf", "rb") as file: - classification_results = service.classify( - project_name="MyModernProjectName", - tag="Production", - file=file, - ) - - Pretrained project: - with open("path/to/document.pdf", "rb") as file: - classification_results = service.classify( - project_type=ProjectType.PRETRAINED, - file=file, - ) - ``` - """ - _validate_classify_params( - project_type=project_type, - tag=tag, - version=version, - project_name=project_name, - file=file, - file_path=file_path, - ) - - project_id = self._get_project_id( - project_name=project_name, - project_type=project_type, - classification_result=None, - ) - - document_id = self._get_document_id( - project_id=project_id, - file=file, - file_path=file_path, - classification_result=None, - ) - - classifier_id = self._get_classifier_id( - project_type=project_type, project_id=project_id, version=version - ) - - tag = self._get_tag( - project_type=project_type, - project_id=project_id, - tag=tag, - version=version, - project_name=project_name, - classification_result=None, - ) - - operation_id = self._start_classification( - project_id=project_id, - tag=tag, - classifier_id=classifier_id, - document_id=document_id, - ) - return self._wait_for_classification( - project_id=project_id, - project_type=project_type, - classifier_id=classifier_id, - tag=tag, - operation_id=operation_id, - ) - - @traced(name="documents_classify_async", run_type="uipath") - async def classify_async( - self, - project_type: ProjectType, - tag: Optional[str] = None, - version: Optional[int] = None, - project_name: Optional[str] = None, - file: Optional[FileContent] = None, - file_path: Optional[str] = None, - ) -> List[ClassificationResult]: - """Asynchronously version of the [`classify`][uipath.platform.documents._documents_service.DocumentsService.classify] method.""" - _validate_classify_params( - project_type=project_type, - tag=tag, - version=version, - project_name=project_name, - file=file, - file_path=file_path, - ) - - project_id = await self._get_project_id_async( - project_name=project_name, - project_type=project_type, - classification_result=None, - ) - - document_id = await self._get_document_id_async( - project_id=project_id, - file=file, - file_path=file_path, - classification_result=None, - ) - - classifier_id = await self._get_classifier_id_async( - project_type=project_type, project_id=project_id, version=version - ) - - tag = await self._get_tag_async( - project_type=project_type, - project_id=project_id, - tag=tag, - version=version, - project_name=project_name, - classification_result=None, - ) - - operation_id = await self._start_classification_async( - project_id=project_id, - tag=tag, - classifier_id=classifier_id, - document_id=document_id, - ) - return await self._wait_for_classification_async( - project_id=project_id, - project_type=project_type, - classifier_id=classifier_id, - tag=tag, - operation_id=operation_id, - ) - - @traced(name="documents_start_ixp_extraction", run_type="uipath") - def start_ixp_extraction( - self, - project_name: str, - tag: str, - file: Optional[FileContent] = None, - file_path: Optional[str] = None, - ) -> StartExtractionResponse: - """Start an IXP extraction process without waiting for results (non-blocking). - - This method uploads the file as an attachment and starts the extraction process, - returning immediately without waiting for the extraction to complete. - Use this for async workflows where you want to receive results via callback/webhook. - - Args: - project_name (str): Name of the IXP project. - tag (str): Tag of the published project version (e.g., "staging"). - file (FileContent, optional): The document file to be processed. - file_path (str, optional): Path to the document file to be processed. - - Note: - Either `file` or `file_path` must be provided, but not both. - - Returns: - ExtractionStartResponse: Contains the operation_id, document_id, project_id, and tag - - Examples: - ```python - start_response = uipath.documents.start_ixp_extraction( - project_name="MyIXPProjectName", - tag="staging", - file_path="path/to/document.pdf", - ) - # start_response.operation_id can be used to poll for results later - ``` - """ - _exactly_one_must_be_provided(file=file, file_path=file_path) - - project_id = self._get_project_id( - project_type=ProjectType.IXP, - project_name=project_name, - classification_result=None, - ) - - document_id = self._start_digitization( - project_id=project_id, - file=file, - file_path=file_path, - ) - - return self._start_extraction( - project_id=project_id, - extractor_id=None, - tag=tag, - document_type_id=str(UUID(int=0)), - document_id=document_id, - ) - - @traced(name="documents_start_ixp_extraction_async", run_type="uipath") - async def start_ixp_extraction_async( - self, - project_name: str, - tag: str, - file: Optional[FileContent] = None, - file_path: Optional[str] = None, - ) -> StartExtractionResponse: - """Asynchronous version of the [`start_ixp_extraction`][uipath.platform.documents._documents_service.DocumentsService.start_ixp_extraction] method.""" - _exactly_one_must_be_provided(file=file, file_path=file_path) - - project_id = await self._get_project_id_async( - project_type=ProjectType.IXP, - project_name=project_name, - classification_result=None, - ) - - document_id = await self._start_digitization_async( - project_id=project_id, - file=file, - file_path=file_path, - ) - - return await self._start_extraction_async( - project_id=project_id, - extractor_id=None, - tag=tag, - document_type_id=str(UUID(int=0)), - document_id=document_id, - ) - - def _retrieve_operation_result( - self, - url: Endpoint, - operation_id: str, - operation_name: str, - ) -> Dict: - response = self.request( - method="GET", - url=url, - params={"api-version": "1.1"}, - headers=self._get_common_headers(), - ).json() - - status = response.get("status") - if status in ["NotStarted", "Running"]: - raise OperationNotCompleteException( - operation_id=operation_id, - status=response.get("status"), - operation_name=operation_name, - ) - - if status != "Succeeded": - raise OperationFailedException( - operation_id=operation_id, - status=status, - error=response.get("error"), - operation_name=operation_name, - ) - return response.get("result") - - async def _retrieve_operation_result_async( - self, - url: Endpoint, - operation_id: str, - operation_name: str, - ) -> Dict: - response = ( - await self.request_async( - method="GET", - url=url, - params={"api-version": "1.1"}, - headers=self._get_common_headers(), - ) - ).json() - - status = response.get("status") - if status in ["NotStarted", "Running"]: - raise OperationNotCompleteException( - operation_id=operation_id, - status=response.get("status"), - operation_name=operation_name, - ) - - if status != "Succeeded": - raise OperationFailedException( - operation_id=operation_id, - status=status, - error=response.get("error"), - operation_name=operation_name, - ) - return response.get("result") - - @traced(name="documents_retrieve_ixp_extraction_result", run_type="uipath") - def retrieve_ixp_extraction_result( - self, - project_id: str, - tag: str, - operation_id: str, - ) -> ExtractionResponseIXP: - """Retrieve the result of an IXP extraction operation (single-shot, non-blocking). - - This method retrieves the result of an IXP extraction that was previously started - with `start_ixp_extraction`. It does not poll - it makes a single request and - returns the result if available, or raises an exception if not complete. - - Args: - project_id (str): The ID of the IXP project. - tag (str): The tag of the published project version. - operation_id (str): The operation ID returned from `start_ixp_extraction`. - - Returns: - ExtractionResponseIXP: The extraction response containing the extracted data. - - Raises: - OperationNotCompleteException: If the extraction is not yet complete. - OperationFailedException: If the extraction operation failed. - - Examples: - ```python - # After receiving a callback/webhook that extraction is complete: - result = service.retrieve_ixp_extraction_result( - project_id=start_response.project_id, - tag=start_response.tag, - operation_id=start_response.operation_id, - ) - ``` - """ - document_type_id = str(UUID(int=0)) - - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/result/{operation_id}" - ) - - extraction_response = self._retrieve_operation_result( - url=url, - operation_id=operation_id, - operation_name="IXP extraction", - ) - - extraction_response["projectId"] = project_id - extraction_response["extractorId"] = None - extraction_response["tag"] = tag - extraction_response["documentTypeId"] = document_type_id - extraction_response["projectType"] = ProjectType.IXP - - return ExtractionResponseIXP.model_validate(extraction_response) - - @traced(name="documents_retrieve_ixp_extraction_result_async", run_type="uipath") - async def retrieve_ixp_extraction_result_async( - self, - project_id: str, - tag: str, - operation_id: str, - ) -> ExtractionResponseIXP: - """Asynchronous version of the [`retrieve_ixp_extraction_result`][uipath.platform.documents._documents_service.DocumentsService.retrieve_ixp_extraction_result] method.""" - document_type_id = str(UUID(int=0)) - - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/result/{operation_id}" - ) - - extraction_response = await self._retrieve_operation_result_async( - url=url, - operation_id=operation_id, - operation_name="IXP extraction", - ) - - extraction_response["projectId"] = project_id - extraction_response["extractorId"] = None - extraction_response["tag"] = tag - extraction_response["documentTypeId"] = document_type_id - extraction_response["projectType"] = ProjectType.IXP - - return ExtractionResponseIXP.model_validate(extraction_response) - - @traced(name="documents_extract", run_type="uipath") - def extract( - self, - tag: Optional[str] = None, - version: Optional[int] = None, - project_name: Optional[str] = None, - file: Optional[FileContent] = None, - file_path: Optional[str] = None, - classification_result: Optional[ClassificationResult] = None, - project_type: Optional[ProjectType] = None, - document_type_name: Optional[str] = None, - ) -> Union[ExtractionResponse, ExtractionResponseIXP]: - """Extract predicted data from a document using an DU Modern/IXP project. - - Args: - project_name (str, optional): Name of the [IXP](https://docs.uipath.com/ixp/automation-cloud/latest/overview/managing-projects#creating-a-new-project)/[DU Modern](https://docs.uipath.com/document-understanding/automation-cloud/latest/user-guide/about-document-understanding) project. Must be provided if `classification_result` is not provided. - tag (str): Tag of the published project version. Must be provided if `classification_result` is not provided and `project_type` is not `ProjectType.PRETRAINED`. - version (int, optional): Version of the published project. It can be used instead of `tag`. - file (FileContent, optional): The document file to be processed. Must be provided if `classification_result` is not provided. - file_path (str, optional): Path to the document file to be processed. Must be provided if `classification_result` is not provided. - project_type (ProjectType, optional): Type of the project. Must be provided if `project_name` is provided. - document_type_name (str, optional): Document type name associated with the extractor to be used for extraction. Required if `project_type` is `ProjectType.MODERN` and `project_name` is provided. - classification_result (ClassificationResult, optional): The classification result obtained from a previous classification step. If provided, `project_name`, `project_type`, `file`, `file_path`, and `document_type_name` must not be provided. - - Note: - Either `file` or `file_path` must be provided, but not both. - - Returns: - Union[ExtractionResponse, ExtractionResponseIXP]: The extraction response containing the extracted data. - - Examples: - IXP projects: - ```python - with open("path/to/document.pdf", "rb") as file: - extraction_response = service.extract( - project_name="MyIXPProjectName", - tag="live", - file=file, - ) - ``` - - DU Modern projects (providing document type name): - ```python - with open("path/to/document.pdf", "rb") as file: - extraction_response = service.extract( - project_name="MyModernProjectName", - tag="Production", - file=file, - project_type=ProjectType.MODERN, - document_type_name="Receipts", - ) - ``` - - DU Modern projects (using existing classification result): - ```python - with open("path/to/document.pdf", "rb") as file: - classification_results = uipath.documents.classify( - tag="Production", - project_name="MyModernProjectName", - file=file, - ) - - extraction_result = uipath.documents.extract( - classification_result=max(classification_results, key=lambda result: result.confidence), - ) - ``` - """ - project_type = _validate_extract_params_and_get_project_type( - tag=tag, - version=version, - project_name=project_name, - file=file, - file_path=file_path, - classification_result=classification_result, - project_type=project_type, - document_type_name=document_type_name, - ) - - project_id = self._get_project_id( - project_name=project_name, - project_type=project_type, - classification_result=classification_result, - ) - - version = self._get_version( - version=version, - project_type=project_type, - classification_result=classification_result, - ) - - tag = self._get_tag( - project_type=project_type, - project_id=project_id, - tag=tag, - version=version, - project_name=project_name, - classification_result=classification_result, - ) - - document_id = self._get_document_id( - project_id=project_id, - file=file, - file_path=file_path, - classification_result=classification_result, - ) - - document_type_id = self._get_document_type_id( - project_id=project_id, - document_type_name=document_type_name, - project_type=project_type, - classification_result=classification_result, - ) - - extractor_id = self._get_extractor_id( - project_id=project_id, - version=version, - document_type_id=document_type_id, - project_type=project_type, - ) - - operation_id = self._start_extraction( - project_id=project_id, - extractor_id=extractor_id, - tag=tag, - document_type_id=document_type_id, - document_id=document_id, - ).operation_id - - return self._wait_for_extraction( - project_id=project_id, - extractor_id=extractor_id, - tag=tag, - document_type_id=document_type_id, - operation_id=operation_id, - project_type=project_type, - ) - - @traced(name="documents_extract_async", run_type="uipath") - async def extract_async( - self, - tag: Optional[str] = None, - version: Optional[int] = None, - project_name: Optional[str] = None, - file: Optional[FileContent] = None, - file_path: Optional[str] = None, - classification_result: Optional[ClassificationResult] = None, - project_type: Optional[ProjectType] = None, - document_type_name: Optional[str] = None, - ) -> Union[ExtractionResponse, ExtractionResponseIXP]: - """Asynchronously version of the [`extract`][uipath.platform.documents._documents_service.DocumentsService.extract] method.""" - project_type = _validate_extract_params_and_get_project_type( - tag=tag, - version=version, - project_name=project_name, - file=file, - file_path=file_path, - classification_result=classification_result, - project_type=project_type, - document_type_name=document_type_name, - ) - - project_id = await self._get_project_id_async( - project_name=project_name, - project_type=project_type, - classification_result=classification_result, - ) - - version = await self._get_version_async( - version=version, - project_type=project_type, - classification_result=classification_result, - ) - - tag = await self._get_tag_async( - project_type=project_type, - project_id=project_id, - tag=tag, - version=version, - project_name=project_name, - classification_result=classification_result, - ) - - document_id = await self._get_document_id_async( - project_id=project_id, - file=file, - file_path=file_path, - classification_result=classification_result, - ) - - document_type_id = await self._get_document_type_id_async( - project_id=project_id, - document_type_name=document_type_name, - project_type=project_type, - classification_result=classification_result, - ) - - extractor_id = await self._get_extractor_id_async( - project_id=project_id, - version=version, - document_type_id=document_type_id, - project_type=project_type, - ) - - operation_id = ( - await self._start_extraction_async( - project_id=project_id, - extractor_id=extractor_id, - tag=tag, - document_type_id=document_type_id, - document_id=document_id, - ) - ).operation_id - - return await self._wait_for_extraction_async( - project_id=project_id, - extractor_id=extractor_id, - tag=tag, - document_type_id=document_type_id, - operation_id=operation_id, - project_type=project_type, - ) - - def _start_classification_validation( - self, - project_id: str, - classifier_id: Optional[str], - tag: Optional[str], - classification_results: List[ClassificationResult], - action_title: str, - action_priority: Optional[ActionPriority] = None, - action_catalog: Optional[str] = None, - action_folder: Optional[str] = None, - storage_bucket_name: Optional[str] = None, - storage_bucket_directory_path: Optional[str] = None, - ) -> str: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/start" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/start" - ) - - return self.request( - "POST", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - json={ - "classificationResults": [ - cr.model_dump() for cr in classification_results - ], - "documentId": classification_results[0].document_id, - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "storageBucketDirectoryPath": storage_bucket_directory_path, - }, - ).json()["operationId"] - - async def _start_classification_validation_async( - self, - project_id: str, - classifier_id: Optional[str], - tag: Optional[str], - classification_results: List[ClassificationResult], - action_title: str, - action_priority: Optional[ActionPriority] = None, - action_catalog: Optional[str] = None, - action_folder: Optional[str] = None, - storage_bucket_name: Optional[str] = None, - storage_bucket_directory_path: Optional[str] = None, - ) -> str: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/start" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/start" - ) - - return ( - await self.request_async( - "POST", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - json={ - "classificationResults": [ - cr.model_dump() for cr in classification_results - ], - "documentId": classification_results[0].document_id, - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "storageBucketDirectoryPath": storage_bucket_directory_path, - }, - ) - ).json()["operationId"] - - def _start_extraction_validation( - self, - project_id: str, - extractor_id: Optional[str], - tag: Optional[str], - document_type_id: str, - action_title: str, - action_priority: Optional[ActionPriority], - action_catalog: Optional[str], - action_folder: Optional[str], - storage_bucket_name: Optional[str], - storage_bucket_directory_path: Optional[str], - extraction_response: ExtractionResponse, - ) -> StartExtractionValidationResponse: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/start" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/start" - ) - - operation_id = self.request( - "POST", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - json={ - "extractionResult": extraction_response.extraction_result.model_dump(), - "documentId": extraction_response.extraction_result.document_id, - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "allowChangeOfDocumentType": True, - "storageBucketDirectoryPath": storage_bucket_directory_path, - }, - ).json()["operationId"] - - return StartExtractionValidationResponse( - operation_id=operation_id, - document_id=extraction_response.extraction_result.document_id, - project_id=project_id, - tag=tag, - ) - - async def _start_extraction_validation_async( - self, - project_id: str, - extractor_id: Optional[str], - tag: Optional[str], - document_type_id: str, - action_title: str, - action_priority: Optional[ActionPriority], - action_catalog: Optional[str], - action_folder: Optional[str], - storage_bucket_name: Optional[str], - storage_bucket_directory_path: Optional[str], - extraction_response: ExtractionResponse, - ) -> StartExtractionValidationResponse: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/start" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/start" - ) - - operation_id = ( - await self.request_async( - "POST", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - json={ - "extractionResult": extraction_response.extraction_result.model_dump(), - "documentId": extraction_response.extraction_result.document_id, - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "allowChangeOfDocumentType": True, - "storageBucketDirectoryPath": storage_bucket_directory_path, - }, - ) - ).json()["operationId"] - - return StartExtractionValidationResponse( - operation_id=operation_id, - document_id=extraction_response.extraction_result.document_id, - project_id=project_id, - tag=tag, - ) - - @traced(name="documents_start_ixp_extraction_validation", run_type="uipath") - def start_ixp_extraction_validation( - self, - extraction_response: ExtractionResponseIXP, - action_title: str, - action_catalog: Optional[str] = None, - action_priority: Optional[ActionPriority] = None, - action_folder: Optional[str] = None, - storage_bucket_name: Optional[str] = None, - storage_bucket_directory_path: Optional[str] = None, - ) -> StartExtractionValidationResponse: - """Start an IXP extraction validation action without waiting for results (non-blocking). - - Args: - extraction_response (ExtractionResponseIXP): The extraction response from the IXP extraction process. - action_title (str): The title of the validation action. - action_catalog (str, optional): The catalog of the validation action. - action_priority (ActionPriority, optional): The priority of the validation action. - action_folder (str, optional): The folder of the validation action. - storage_bucket_name (str, optional): The name of the storage bucket where validation data will be stored. - storage_bucket_directory_path (str, optional): The directory path within the storage bucket. - - Returns: - StartExtractionValidationResponse: Contains the operation_id, document_id, project_id, and tag. - - Examples: - ```python - start_operation_response = service.start_ixp_extraction_validation( - action_title="Validate IXP Extraction", - action_priority=ActionPriority.HIGH, - action_catalog="DefaultCatalog", - action_folder="Validations", - storage_bucket_name="my-storage-bucket", - storage_bucket_directory_path="validations/ixp", - extraction_response=extraction_response, - ) - # start_operation_response can be used to poll for validation results later - ``` - """ - return self._start_extraction_validation( - project_id=extraction_response.project_id, - extractor_id=None, - tag=extraction_response.tag, - document_type_id=str(UUID(int=0)), - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=extraction_response, - ) - - @resource_override( - resource_type="bucket", - resource_identifier="storage_bucket_name", - folder_identifier="action_folder", - ) - @traced( - name="documents_start_ixp_extraction_validation_async", - run_type="uipath", - ) - async def start_ixp_extraction_validation_async( - self, - extraction_response: ExtractionResponseIXP, - action_title: str, - action_catalog: Optional[str] = None, - action_priority: Optional[ActionPriority] = None, - action_folder: Optional[str] = None, - storage_bucket_name: Optional[str] = None, - storage_bucket_directory_path: Optional[str] = None, - ) -> StartExtractionValidationResponse: - """Asynchronous version of the [`start_ixp_extraction_validation`][uipath.platform.documents._documents_service.DocumentsService.start_ixp_extraction_validation] method.""" - return await self._start_extraction_validation_async( - project_id=extraction_response.project_id, - extractor_id=None, - tag=extraction_response.tag, - document_type_id=str(UUID(int=0)), - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=extraction_response, - ) - - @resource_override( - resource_type="bucket", - resource_identifier="storage_bucket_name", - folder_identifier="action_folder", - ) - @traced( - name="documents_retrieve_ixp_extraction_validation_result", - run_type="uipath", - ) - def retrieve_ixp_extraction_validation_result( - self, - project_id: str, - tag: str, - operation_id: str, - ) -> ValidateExtractionAction: - """Retrieve the result of an IXP create validate extraction action operation (single-shot, non-blocking). - - This method retrieves the result of an IXP create validate extraction action that was previously started - with `start_ixp_extraction_validation`. It does not poll - it makes a single request and - returns the result if available, or raises an exception if not complete. - - Args: - operation_id (str): The operation ID returned from `start_ixp_extraction_validation`. - project_id (str): The ID of the IXP project. - tag (str): The tag of the published project version. - - Returns: - ValidateExtractionAction: The validation action - - Raises: - OperationNotCompleteException: If the validation action is not yet complete. - OperationFailedException: If the validation action has failed. - - Examples: - ```python - # After receiving a callback/webhook that validation is complete: - validation_result = service.retrieve_ixp_extraction_validation_result( - operation_id=start_operation_response.operation_id, - project_id=start_operation_response.project_id, - tag=start_operation_response.tag, - ) - ``` - """ - document_type_id = str(UUID(int=0)) - - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}" - ) - - result = self._retrieve_operation_result( - url=url, - operation_id=operation_id, - operation_name="IXP Create Validate Extraction Action", - ) - - result["projectId"] = project_id - result["projectType"] = ProjectType.IXP - result["extractorId"] = None - result["tag"] = tag - result["documentTypeId"] = str(UUID(int=0)) - result["operationId"] = operation_id - - return ValidateExtractionAction.model_validate(result) - - @traced( - name="documents_retrieve_ixp_extraction_validation_result_async", - run_type="uipath", - ) - async def retrieve_ixp_extraction_validation_result_async( - self, - project_id: str, - tag: str, - operation_id: str, - ) -> ValidateExtractionAction: - """Asynchronous version of the [`retrieve_ixp_extraction_validation_result`][uipath.platform.documents._documents_service.DocumentsService.retrieve_ixp_extraction_validation_result] method.""" - document_type_id = str(UUID(int=0)) - - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}" - ) - - result = await self._retrieve_operation_result_async( - url=url, - operation_id=operation_id, - operation_name="IXP Create Validate Extraction Action", - ) - - result["projectId"] = project_id - result["projectType"] = ProjectType.IXP - result["extractorId"] = None - result["tag"] = tag - result["documentTypeId"] = str(UUID(int=0)) - result["operationId"] = operation_id - - return ValidateExtractionAction.model_validate(result) - - def _get_classification_validation_result( - self, - project_id: str, - classifier_id: Optional[str], - tag: Optional[str], - operation_id: str, - ) -> Dict: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/result/{operation_id}" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/result/{operation_id}" - ) - - return self.request( - method="GET", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ).json() - - async def _get_classification_validation_result_async( - self, - project_id: str, - classifier_id: Optional[str], - tag: Optional[str], - operation_id: str, - ) -> Dict: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/result/{operation_id}" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/result/{operation_id}" - ) - - return ( - await self.request_async( - method="GET", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - ).json() - - def _get_extraction_validation_result( - self, - project_id: str, - extractor_id: Optional[str], - tag: Optional[str], - document_type_id: str, - operation_id: str, - ) -> Dict: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/result/{operation_id}" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}" - ) - - return self.request( - method="GET", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ).json() - - async def _get_extraction_validation_result_async( - self, - project_id: str, - extractor_id: Optional[str], - tag: Optional[str], - document_type_id: str, - operation_id: str, - ) -> Dict: - if tag is None: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/result/{operation_id}" - ) - else: - url = Endpoint( - f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}" - ) - - return ( - await self.request_async( - method="GET", - url=url, - params={"api-version": 1.1}, - headers=self._get_common_headers(), - ) - ).json() - - def _wait_for_create_validate_classification_action( - self, - project_id: str, - project_type: ProjectType, - classifier_id: Optional[str], - tag: Optional[str], - operation_id: str, - ) -> ValidateClassificationAction: - def result_getter() -> Tuple[Any, Optional[Any], Optional[Any]]: - result = self._get_classification_validation_result( - project_id=project_id, - classifier_id=classifier_id, - tag=tag, - operation_id=operation_id, - ) - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - response = self._wait_for_operation( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - response["projectId"] = project_id - response["projectType"] = project_type - response["classifierId"] = classifier_id - response["tag"] = tag - response["operationId"] = operation_id - return ValidateClassificationAction.model_validate(response) - - async def _wait_for_create_validate_classification_action_async( - self, - project_id: str, - project_type: ProjectType, - classifier_id: Optional[str], - tag: Optional[str], - operation_id: str, - ) -> ValidateClassificationAction: - async def result_getter() -> Tuple[Any, Optional[Any], Optional[Any]]: - result = await self._get_classification_validation_result_async( - project_id=project_id, - classifier_id=classifier_id, - tag=tag, - operation_id=operation_id, - ) - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - response = await self._wait_for_operation_async( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - response["projectId"] = project_id - response["projectType"] = project_type - response["classifierId"] = classifier_id - response["tag"] = tag - response["operationId"] = operation_id - return ValidateClassificationAction.model_validate(response) - - def _wait_for_create_validate_extraction_action( - self, - project_id: str, - project_type: ProjectType, - extractor_id: Optional[str], - tag: Optional[str], - document_type_id: str, - operation_id: str, - ) -> ValidateExtractionAction: - def result_getter() -> Tuple[Any, Optional[Any], Optional[Any]]: - result = self._get_extraction_validation_result( - project_id=project_id, - extractor_id=extractor_id, - tag=tag, - document_type_id=document_type_id, - operation_id=operation_id, - ) - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - response = self._wait_for_operation( - result_getter=result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - response["projectId"] = project_id - response["projectType"] = project_type - response["extractorId"] = extractor_id - response["tag"] = tag - response["documentTypeId"] = document_type_id - response["operationId"] = operation_id - return ValidateExtractionAction.model_validate(response) - - async def _wait_for_create_validate_extraction_action_async( - self, - project_id: str, - project_type: ProjectType, - extractor_id: Optional[str], - tag: Optional[str], - document_type_id: str, - operation_id: str, - ) -> ValidateExtractionAction: - async def result_getter_async() -> Tuple[Any, Optional[Any], Optional[Any]]: - result = await self._get_extraction_validation_result_async( - project_id=project_id, - extractor_id=extractor_id, - tag=tag, - document_type_id=document_type_id, - operation_id=operation_id, - ) - return ( - result["status"], - result.get("error", None), - result.get("result", None), - ) - - response = await self._wait_for_operation_async( - result_getter=result_getter_async, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - response["projectId"] = project_id - response["projectType"] = project_type - response["extractorId"] = extractor_id - response["tag"] = tag - response["documentTypeId"] = document_type_id - response["operationId"] = operation_id - return ValidateExtractionAction.model_validate(response) - - @traced(name="documents_create_validate_classification_action", run_type="uipath") - def create_validate_classification_action( - self, - classification_results: List[ClassificationResult], - action_title: str, - action_priority: Optional[ActionPriority] = None, - action_catalog: Optional[str] = None, - action_folder: Optional[str] = None, - storage_bucket_name: Optional[str] = None, - storage_bucket_directory_path: Optional[str] = None, - ) -> ValidateClassificationAction: - """Create a validate classification action for a document based on the classification results. More details about validation actions can be found in the [official documentation](https://docs.uipath.com/ixp/automation-cloud/latest/user-guide/validating-classifications). - - Args: - classification_results (List[ClassificationResult]): The classification results to be validated, typically obtained from the [`classify`][uipath.platform.documents._documents_service.DocumentsService.classify] method. - action_title (str): Title of the action. - action_priority (ActionPriority, optional): Priority of the action. - action_catalog (str, optional): Catalog of the action. - action_folder (str, optional): Folder of the action. - storage_bucket_name (str, optional): Name of the storage bucket. - storage_bucket_directory_path (str, optional): Directory path in the storage bucket. - - Returns: - ValidateClassificationAction: The created validate classification action. - - Examples: - ```python - validation_action = service.create_validate_classification_action( - action_title="Test Validation Action", - action_priority=ActionPriority.MEDIUM, - action_catalog="default_du_actions", - action_folder="Shared", - storage_bucket_name="du_storage_bucket", - storage_bucket_directory_path="TestDirectory", - classification_results=classification_results, - ) - ``` - """ - if not classification_results: - raise ValueError("`classification_results` must not be empty") - - operation_id = self._start_classification_validation( - project_id=classification_results[0].project_id, - classifier_id=classification_results[0].classifier_id, - tag=classification_results[0].tag, - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - classification_results=classification_results, - ) - - return self._wait_for_create_validate_classification_action( - project_id=classification_results[0].project_id, - project_type=classification_results[0].project_type, - classifier_id=classification_results[0].classifier_id, - tag=classification_results[0].tag, - operation_id=operation_id, - ) - - @traced(name="documents_create_validate_classification_action", run_type="uipath") - async def create_validate_classification_action_async( - self, - classification_results: List[ClassificationResult], - action_title: str, - action_priority: Optional[ActionPriority] = None, - action_catalog: Optional[str] = None, - action_folder: Optional[str] = None, - storage_bucket_name: Optional[str] = None, - storage_bucket_directory_path: Optional[str] = None, - ) -> ValidateClassificationAction: - """Asynchronous version of the [`create_validation_action`][uipath.platform.documents._documents_service.DocumentsService.create_validate_classification_action] method.""" - if not classification_results: - raise ValueError("`classification_results` must not be empty") - - operation_id = await self._start_classification_validation_async( - project_id=classification_results[0].project_id, - classifier_id=classification_results[0].classifier_id, - tag=classification_results[0].tag, - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - classification_results=classification_results, - ) - - return await self._wait_for_create_validate_classification_action_async( - project_id=classification_results[0].project_id, - project_type=classification_results[0].project_type, - classifier_id=classification_results[0].classifier_id, - tag=classification_results[0].tag, - operation_id=operation_id, - ) - - @traced(name="documents_create_validate_extraction_action", run_type="uipath") - def create_validate_extraction_action( - self, - extraction_response: ExtractionResponse, - action_title: str, - action_priority: Optional[ActionPriority] = None, - action_catalog: Optional[str] = None, - action_folder: Optional[str] = None, - storage_bucket_name: Optional[str] = None, - storage_bucket_directory_path: Optional[str] = None, - ) -> ValidateExtractionAction: - """Create a validate extraction action for a document based on the extraction response. More details about validation actions can be found in the [official documentation](https://docs.uipath.com/ixp/automation-cloud/latest/user-guide/validating-extractions). - - Args: - extraction_response (ExtractionResponse): The extraction result to be validated, typically obtained from the [`extract`][uipath.platform.documents._documents_service.DocumentsService.extract] method. - action_title (str): Title of the action. - action_priority (ActionPriority, optional): Priority of the action. - action_catalog (str, optional): Catalog of the action. - action_folder (str, optional): Folder of the action. - storage_bucket_name (str, optional): Name of the storage bucket. - storage_bucket_directory_path (str, optional): Directory path in the storage bucket. - - Returns: - ValidateClassificationAction: The created validation action. - - Examples: - ```python - validation_action = service.create_validate_extraction_action( - action_title="Test Validation Action", - action_priority=ActionPriority.MEDIUM, - action_catalog="default_du_actions", - action_folder="Shared", - storage_bucket_name="du_storage_bucket", - storage_bucket_directory_path="TestDirectory", - extraction_response=extraction_response, - ) - ``` - """ - operation_id = self._start_extraction_validation( - project_id=extraction_response.project_id, - extractor_id=extraction_response.extractor_id, - tag=extraction_response.tag, - document_type_id=extraction_response.document_type_id, - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=extraction_response, - ).operation_id - - return self._wait_for_create_validate_extraction_action( - project_id=extraction_response.project_id, - project_type=extraction_response.project_type, - extractor_id=extraction_response.extractor_id, - tag=extraction_response.tag, - document_type_id=extraction_response.document_type_id, - operation_id=operation_id, - ) - - @traced(name="documents_create_validate_extraction_action_async", run_type="uipath") - async def create_validate_extraction_action_async( - self, - extraction_response: ExtractionResponse, - action_title: str, - action_priority: Optional[ActionPriority] = None, - action_catalog: Optional[str] = None, - action_folder: Optional[str] = None, - storage_bucket_name: Optional[str] = None, - storage_bucket_directory_path: Optional[str] = None, - ) -> ValidateExtractionAction: - """Asynchronous version of the [`create_validation_action`][uipath.platform.documents._documents_service.DocumentsService.create_validate_extraction_action] method.""" - operation_id = ( - await self._start_extraction_validation_async( - project_id=extraction_response.project_id, - extractor_id=extraction_response.extractor_id, - tag=extraction_response.tag, - document_type_id=extraction_response.document_type_id, - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=extraction_response, - ) - ).operation_id - - return await self._wait_for_create_validate_extraction_action_async( - project_id=extraction_response.project_id, - project_type=extraction_response.project_type, - extractor_id=extraction_response.extractor_id, - tag=extraction_response.tag, - document_type_id=extraction_response.document_type_id, - operation_id=operation_id, - ) - - @traced(name="documents_get_validate_classification_result", run_type="uipath") - def get_validate_classification_result( - self, validation_action: ValidateClassificationAction - ) -> List[ClassificationResult]: - """Get the result of a validate classification action. - - Note: - This method will block until the validation action is completed, meaning the user has completed the validation in UiPath Action Center. - - Args: - validation_action (ValidateClassificationAction): The validation action to get the result for, typically obtained from the [`create_validate_classification_action`][uipath.platform.documents._documents_service.DocumentsService.create_validate_classification_action] method. - - Returns: - List[ClassificationResult]: The validated classification results. - - Examples: - ```python - validated_results = service.get_validate_classification_result(validate_classification_action) - ``` - """ - - def result_getter() -> Tuple[str, None, Any]: - result = self._get_classification_validation_result( - project_id=validation_action.project_id, - classifier_id=validation_action.classifier_id, - tag=validation_action.tag, - operation_id=validation_action.operation_id, - ) - return (result["result"]["actionStatus"], None, result["result"]) - - response = self._wait_for_operation( - result_getter=result_getter, - wait_statuses=["Unassigned", "Pending"], - success_status="Completed", - ) - classification_results = [] - for cr in response["validatedClassificationResults"]: - cr["ProjectId"] = validation_action.project_id - cr["ProjectType"] = validation_action.project_type - cr["ClassifierId"] = validation_action.classifier_id - cr["Tag"] = validation_action.tag - classification_results.append(ClassificationResult.model_validate(cr)) - - return classification_results - - @traced( - name="documents_get_validate_classification_result_async", run_type="uipath" - ) - async def get_validate_classification_result_async( - self, validation_action: ValidateClassificationAction - ) -> List[ClassificationResult]: - """Asynchronous version of the [`get_validation_result`][uipath.platform.documents._documents_service.DocumentsService.get_validate_classification_result] method.""" - - async def result_getter() -> Tuple[str, None, Any]: - result = await self._get_classification_validation_result_async( - project_id=validation_action.project_id, - classifier_id=validation_action.classifier_id, - tag=validation_action.tag, - operation_id=validation_action.operation_id, - ) - return (result["result"]["actionStatus"], None, result["result"]) - - response = await self._wait_for_operation_async( - result_getter=result_getter, - wait_statuses=["Unassigned", "Pending"], - success_status="Completed", - ) - classification_results = [] - for cr in response["validatedClassificationResults"]: - cr["ProjectId"] = validation_action.project_id - cr["ProjectType"] = validation_action.project_type - cr["ClassifierId"] = validation_action.classifier_id - cr["Tag"] = validation_action.tag - classification_results.append(ClassificationResult.model_validate(cr)) - - return classification_results - - @traced(name="documents_get_validate_extraction_result", run_type="uipath") - def get_validate_extraction_result( - self, validation_action: ValidateExtractionAction - ) -> Union[ExtractionResponse, ExtractionResponseIXP]: - """Get the result of a validate extraction action. - - Note: - This method will block until the validation action is completed, meaning the user has completed the validation in UiPath Action Center. - - Args: - validation_action (ValidateClassificationAction): The validation action to get the result for, typically obtained from the [`create_validate_extraction_action`][uipath.platform.documents._documents_service.DocumentsService.create_validate_extraction_action] method. - - Returns: - Union[ExtractionResponse, ExtractionResponseIXP]: The validated extraction response. - - Examples: - ```python - validated_result = service.get_validate_extraction_result(validate_extraction_action) - ``` - """ - - def result_getter() -> Tuple[str, None, Any]: - result = self._get_extraction_validation_result( - project_id=validation_action.project_id, - extractor_id=validation_action.extractor_id, - tag=validation_action.tag, - document_type_id=validation_action.document_type_id, - operation_id=validation_action.operation_id, - ) - return (result["result"]["actionStatus"], None, result["result"]) - - response = self._wait_for_operation( - result_getter=result_getter, - wait_statuses=["Unassigned", "Pending"], - success_status="Completed", - ) - response["extractionResult"] = response.pop("validatedExtractionResults") - response["projectId"] = validation_action.project_id - response["extractorId"] = validation_action.extractor_id - response["tag"] = validation_action.tag - response["documentTypeId"] = validation_action.document_type_id - response["projectType"] = validation_action.project_type - - if validation_action.project_type == ProjectType.IXP: - return ExtractionResponseIXP.model_validate(response) - - return ExtractionResponse.model_validate(response) - - @traced(name="documents_get_validate_extraction_result_async", run_type="uipath") - async def get_validate_extraction_result_async( - self, validation_action: ValidateExtractionAction - ) -> Union[ExtractionResponse, ExtractionResponseIXP]: - """Asynchronous version of the [`get_validation_result`][uipath.platform.documents._documents_service.DocumentsService.get_validate_extraction_result] method.""" - - async def result_getter() -> Tuple[str, None, Any]: - result = await self._get_extraction_validation_result_async( - project_id=validation_action.project_id, - extractor_id=validation_action.extractor_id, - tag=validation_action.tag, - document_type_id=validation_action.document_type_id, - operation_id=validation_action.operation_id, - ) - return (result["result"]["actionStatus"], None, result["result"]) - - response = await self._wait_for_operation_async( - result_getter=result_getter, - wait_statuses=["Unassigned", "Pending"], - success_status="Completed", - ) - response["extractionResult"] = response.pop("validatedExtractionResults") - response["projectId"] = validation_action.project_id - response["extractorId"] = validation_action.extractor_id - response["tag"] = validation_action.tag - response["documentTypeId"] = validation_action.document_type_id - response["projectType"] = validation_action.project_type - - if validation_action.project_type == ProjectType.IXP: - return ExtractionResponseIXP.model_validate(response) - - return ExtractionResponse.model_validate(response) diff --git a/src/uipath/platform/documents/documents.py b/src/uipath/platform/documents/documents.py deleted file mode 100644 index 7870635f9..000000000 --- a/src/uipath/platform/documents/documents.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Document service payload models.""" - -from __future__ import annotations - -from enum import Enum -from typing import IO, Any, List, Optional, Union - -from pydantic import BaseModel, ConfigDict, Field - -FileContent = Union[IO[bytes], bytes, str] - - -class FieldType(str, Enum): - """Field types supported by Document Understanding service.""" - - TEXT = "Text" - NUMBER = "Number" - DATE = "Date" - NAME = "Name" - ADDRESS = "Address" - KEYWORD = "Keyword" - SET = "Set" - BOOLEAN = "Boolean" - TABLE = "Table" - INTERNAL = "Internal" - - -class ActionPriority(str, Enum): - """Priority levels for validation actions. More details can be found in the [official documentation](https://docs.uipath.com/action-center/automation-cloud/latest/user-guide/create-document-validation-action#configuration).""" - - LOW = "Low" - """Low priority""" - MEDIUM = "Medium" - """Medium priority""" - HIGH = "High" - """High priority""" - CRITICAL = "Critical" - """Critical priority""" - - @classmethod - def from_str(cls, value: str | None) -> ActionPriority: - """Creates an ActionPriority from a string.""" - if not value: - return cls.MEDIUM - try: - return cls[value.upper()] - except (KeyError, AttributeError): - return cls.MEDIUM - - -class ProjectType(str, Enum): - """Project types available and supported by Documents Service.""" - - IXP = "IXP" - """Represents an [IXP](https://docs.uipath.com/ixp/automation-cloud/latest/overview/managing-projects#creating-a-new-project) project type.""" - MODERN = "Modern" - """Represents a [DU Modern](https://docs.uipath.com/document-understanding/automation-cloud/latest/user-guide/about-document-understanding) project type.""" - PRETRAINED = "Pretrained" - """Represents a [Pretrained](https://docs.uipath.com/document-understanding/automation-cloud/latest/user-guide/out-of-the-box-pre-trained-ml-packages) project type.""" - - -class FieldValueProjection(BaseModel): - """A model representing a projection of a field value in a document extraction result.""" - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - id: str - name: str - value: Optional[str] - unformatted_value: Optional[str] = Field(alias="unformattedValue") - confidence: Optional[float] - ocr_confidence: Optional[float] = Field(alias="ocrConfidence") - type: FieldType - - -class FieldGroupValueProjection(BaseModel): - """A model representing a projection of a field group value in a document extraction result.""" - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - field_group_name: str = Field(alias="fieldGroupName") - field_values: List[FieldValueProjection] = Field(alias="fieldValues") - - -class ExtractionResult(BaseModel): - """A model representing the result of a document extraction process.""" - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - document_id: str = Field(alias="DocumentId") - results_version: int = Field(alias="ResultsVersion") - results_document: dict[str, Any] = Field(alias="ResultsDocument") - extractor_payloads: Optional[List[dict[str, Any]]] = Field( - default=None, alias="ExtractorPayloads" - ) - business_rules_results: Optional[List[dict[str, Any]]] = Field( - default=None, alias="BusinessRulesResults" - ) - - -class ExtractionResponse(BaseModel): - """A model representing the response from a document extraction process. - - Attributes: - extraction_result (ExtractionResult): The result of the extraction process. - project_id (str): The ID of the project associated with the extraction. - tag (str): The tag associated with the published model version. - document_type_id (str): The ID of the document type associated with the extraction. - """ - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - extraction_result: ExtractionResult = Field(alias="extractionResult") - project_id: str = Field(alias="projectId") - project_type: ProjectType = Field(alias="projectType") - extractor_id: Optional[str] = Field(alias="extractorId", default=None) - tag: Optional[str] - document_type_id: str = Field(alias="documentTypeId") - - -class ExtractionResponseIXP(ExtractionResponse): - """A model representing the response from a document extraction process for IXP projects. - - Attributes: - data_projection (List[FieldGroupValueProjection]): A simplified projection of the extracted data. - """ - - data_projection: Optional[List[FieldGroupValueProjection]] = Field( - alias="dataProjection", - default=None, - ) - - -class ValidationAction(BaseModel): - """A model representing a validation action for a document. - - Attributes: - action_data (dict): The data associated with the validation action. - action_status (str): The status of the validation action. Possible values can be found in the [official documentation](https://docs.uipath.com/action-center/automation-cloud/latest/user-guide/about-actions#action-statuses). - project_id (str): The ID of the project associated with the validation action. - tag (str): The tag associated with the published model version. - operation_id (str): The operation ID associated with the validation action. - """ - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - action_data: dict[str, Any] = Field(alias="actionData") - action_status: str = Field(alias="actionStatus") - project_id: str = Field(alias="projectId") - project_type: ProjectType = Field(alias="projectType") - tag: Optional[str] - operation_id: str = Field(alias="operationId") - - -class ValidateClassificationAction(ValidationAction): - """A model representing a validation action for document classification.""" - - classifier_id: Optional[str] = Field(alias="classifierId") - - -class ValidateExtractionAction(ValidationAction): - """A model representing a validation action for document extraction.""" - - extractor_id: Optional[str] = Field(alias="extractorId") - document_type_id: str = Field(alias="documentTypeId") - validated_extraction_result: Optional[ExtractionResult] = Field( - alias="validatedExtractionResults", default=None - ) - data_projection: Optional[List[FieldGroupValueProjection]] = Field( - alias="dataProjection", default=None - ) - - -class Reference(BaseModel): - """A model representing a reference within a document.""" - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - text_start_index: int = Field(alias="TextStartIndex") - text_length: int = Field(alias="TextLength") - tokens: List[str] = Field(alias="Tokens") - - -class DocumentBounds(BaseModel): - """A model representing the bounds of a document in terms of pages and text.""" - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - start_page: int = Field(alias="StartPage") - page_count: int = Field(alias="PageCount") - text_start_index: int = Field(alias="TextStartIndex") - text_length: int = Field(alias="TextLength") - page_range: str = Field(alias="PageRange") - - -class ClassificationResult(BaseModel): - """A model representing the result of a document classification. - - Attributes: - document_id (str): The ID of the classified document. - document_type_id (str): The ID of the predicted document type. - confidence (float): The confidence score of the classification. - ocr_confidence (float): The OCR confidence score of the document. - reference (Reference): The reference information for the classified document. - document_bounds (DocumentBounds): The bounds of the document in terms of pages and text. - classifier_name (str): The name of the classifier used. - project_id (str): The ID of the project associated with the classification. - """ - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - document_id: str = Field(alias="DocumentId") - document_type_id: str = Field(alias="DocumentTypeId") - confidence: float = Field(alias="Confidence") - ocr_confidence: float = Field(alias="OcrConfidence") - reference: Reference = Field(alias="Reference") - document_bounds: DocumentBounds = Field(alias="DocumentBounds") - classifier_name: str = Field(alias="ClassifierName") - project_id: str = Field(alias="ProjectId") - project_type: ProjectType = Field(alias="ProjectType") - classifier_id: Optional[str] = Field(alias="ClassifierId") - tag: Optional[str] = Field(alias="Tag") - - -class ClassificationResponse(BaseModel): - """A model representing the response from a document classification process.""" - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - classification_results: List[ClassificationResult] = Field( - alias="classificationResults" - ) - - -class StartOperationResponse(BaseModel): - """A model representing the response from starting an operation. - - Attributes: - operation_id (str): The ID of the extraction operation, used to poll for results. - document_id (str): The ID of the digitized document. - project_id (str): The ID of the project. - tag (str): The tag of the published project version. - """ - - model_config = ConfigDict( - serialize_by_alias=True, - validate_by_alias=True, - validate_by_name=True, - ) - - operation_id: str = Field(alias="operationId") - document_id: str = Field(alias="documentId") - project_id: str = Field(alias="projectId") - tag: str | None = Field(default=None) - - -class StartExtractionResponse(StartOperationResponse): - """A model representing the response from starting an extraction operation.""" - - -class StartExtractionValidationResponse(StartOperationResponse): - """A model representing the response from starting an extraction validation operation.""" diff --git a/src/uipath/platform/entities/__init__.py b/src/uipath/platform/entities/__init__.py deleted file mode 100644 index 64caf396e..000000000 --- a/src/uipath/platform/entities/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -"""UiPath Entities Models. - -This module contains models related to UiPath Entities service. -""" - -from ._entities_service import EntitiesService -from .entities import ( - Entity, - EntityField, - EntityFieldMetadata, - EntityRecord, - EntityRecordsBatchResponse, - ExternalField, - ExternalObject, - ExternalSourceFields, - FieldDataType, - FieldMetadata, - ReferenceType, - SourceJoinCriteria, -) - -__all__ = [ - "EntitiesService", - "Entity", - "EntityField", - "EntityRecord", - "EntityFieldMetadata", - "FieldDataType", - "FieldMetadata", - "EntityRecordsBatchResponse", - "ExternalField", - "ExternalObject", - "ExternalSourceFields", - "ReferenceType", - "SourceJoinCriteria", -] diff --git a/src/uipath/platform/entities/_entities_service.py b/src/uipath/platform/entities/_entities_service.py deleted file mode 100644 index 2b31b830c..000000000 --- a/src/uipath/platform/entities/_entities_service.py +++ /dev/null @@ -1,902 +0,0 @@ -from typing import Any, List, Optional, Type - -from httpx import Response - -from ..._utils import Endpoint, RequestSpec -from ...tracing import traced -from ..common import BaseService, UiPathApiConfig, UiPathExecutionContext -from .entities import ( - Entity, - EntityRecord, - EntityRecordsBatchResponse, -) - - -class EntitiesService(BaseService): - """Service for managing UiPath Data Service entities. - - Entities are database tables in UiPath Data Service that can store - structured data for automation processes. - - See Also: - https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - @traced(name="entity_retrieve", run_type="uipath") - def retrieve(self, entity_key: str) -> Entity: - """Retrieve an entity by its key. - - Args: - entity_key (str): The unique key/identifier of the entity. - - Returns: - Entity: The entity with all its metadata and field definitions, including: - - name: Entity name - - display_name: Human-readable display name - - fields: List of field metadata (field names, types, constraints) - - record_count: Number of records in the entity - - storage_size_in_mb: Storage size used by the entity - - Examples: - Basic usage:: - - # Retrieve entity metadata - entity = entities_service.retrieve("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - print(f"Entity: {entity.display_name}") - print(f"Records: {entity.record_count}") - - Inspecting entity fields:: - - entity = entities_service.retrieve("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - - # List all fields and their types - for field in entity.fields: - print(f"{field.name} ({field.sql_type.name})") - print(f" Required: {field.is_required}") - print(f" Primary Key: {field.is_primary_key}") - """ - spec = self._retrieve_spec(entity_key) - response = self.request(spec.method, spec.endpoint) - - return Entity.model_validate(response.json()) - - @traced(name="entity_retrieve", run_type="uipath") - async def retrieve_async(self, entity_key: str) -> Entity: - """Asynchronously retrieve an entity by its key. - - Args: - entity_key (str): The unique key/identifier of the entity. - - Returns: - Entity: The entity with all its metadata and field definitions, including: - - name: Entity name - - display_name: Human-readable display name - - fields: List of field metadata (field names, types, constraints) - - record_count: Number of records in the entity - - storage_size_in_mb: Storage size used by the entity - - Examples: - Basic usage:: - - # Retrieve entity metadata - entity = await entities_service.retrieve_async("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - print(f"Entity: {entity.display_name}") - print(f"Records: {entity.record_count}") - - Inspecting entity fields:: - - entity = await entities_service.retrieve_async("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - - # List all fields and their types - for field in entity.fields: - print(f"{field.name} ({field.sql_type.name})") - print(f" Required: {field.is_required}") - print(f" Primary Key: {field.is_primary_key}") - """ - spec = self._retrieve_spec(entity_key) - - response = await self.request_async(spec.method, spec.endpoint) - - return Entity.model_validate(response.json()) - - @traced(name="list_entities", run_type="uipath") - def list_entities(self) -> List[Entity]: - """List all entities in Data Service. - - Returns: - List[Entity]: A list of all entities with their metadata and field definitions. - Each entity includes name, display name, fields, record count, and storage information. - - Examples: - List all entities:: - - # Get all entities in the Data Service - entities = entities_service.list_entities() - for entity in entities: - print(f"{entity.display_name} ({entity.name})") - - Find entities with RBAC enabled:: - - entities = entities_service.list_entities() - - # Filter to entities with row-based access control - rbac_entities = [ - e for e in entities - if e.is_rbac_enabled - ] - - Summary report:: - - entities = entities_service.list_entities() - - total_records = sum(e.record_count or 0 for e in entities) - total_storage = sum(e.storage_size_in_mb or 0 for e in entities) - - print(f"Total entities: {len(entities)}") - print(f"Total records: {total_records}") - print(f"Total storage: {total_storage:.2f} MB") - """ - spec = self._list_entities_spec() - response = self.request(spec.method, spec.endpoint) - - entities_data = response.json() - return [Entity.model_validate(entity) for entity in entities_data] - - @traced(name="list_entities", run_type="uipath") - async def list_entities_async(self) -> List[Entity]: - """Asynchronously list all entities in the Data Service. - - Returns: - List[Entity]: A list of all entities with their metadata and field definitions. - Each entity includes name, display name, fields, record count, and storage information. - - Examples: - List all entities:: - - # Get all entities in the Data Service - entities = await entities_service.list_entities_async() - for entity in entities: - print(f"{entity.display_name} ({entity.name})") - - Find entities with RBAC enabled:: - - entities = await entities_service.list_entities_async() - - # Filter to entities with row-based access control - rbac_entities = [ - e for e in entities - if e.is_rbac_enabled - ] - - Summary report:: - - entities = await entities_service.list_entities_async() - - total_records = sum(e.record_count or 0 for e in entities) - total_storage = sum(e.storage_size_in_mb or 0 for e in entities) - - print(f"Total entities: {len(entities)}") - print(f"Total records: {total_records}") - print(f"Total storage: {total_storage:.2f} MB") - """ - spec = self._list_entities_spec() - response = await self.request_async(spec.method, spec.endpoint) - - entities_data = response.json() - return [Entity.model_validate(entity) for entity in entities_data] - - @traced(name="entity_list_records", run_type="uipath") - def list_records( - self, - entity_key: str, - schema: Optional[Type[Any]] = None, # Optional schema - start: Optional[int] = None, - limit: Optional[int] = None, - ) -> List[EntityRecord]: - """List records from an entity with optional pagination and schema validation. - - The schema parameter enables type-safe access to entity records by validating the - data against a user-defined class with type annotations. When provided, each record - is validated against the schema's field definitions before being returned. - - Args: - entity_key (str): The unique key/identifier of the entity. - schema (Optional[Type[Any]]): Optional schema class for validation. This should be - a Python class with type-annotated fields that match the entity's structure. - - Field Validation Rules: - - Required fields: Use standard type annotations (e.g., `name: str`) - - Optional fields: Use `Optional` or union with None (e.g., `age: Optional[int]` or `age: int | None`) - - Field names must match the entity's field names (case-sensitive) - - The 'Id' field is automatically validated and does not need to be included - - Example schema class:: - - class CustomerRecord: - name: str # Required field - email: str # Required field - age: Optional[int] # Optional field - phone: str | None # Optional field (Python 3.10+ syntax) - - Benefits of using schema: - - Type safety: Ensures records match expected structure - - Early validation: Catches data issues before processing - - Documentation: Schema serves as clear contract for record structure - - IDE support: Enables better autocomplete and type checking - - When schema validation fails, a `ValueError` is raised with details about - the validation error (e.g., missing required fields, type mismatches). - - start (Optional[int]): Starting index for pagination (0-based). - limit (Optional[int]): Maximum number of records to return. - - Returns: - List[EntityRecord]: A list of entity records. Each record contains an 'id' field - and all other fields from the entity. Fields can be accessed as attributes - or dictionary keys on the EntityRecord object. - - Raises: - ValueError: If schema validation fails for any record, including cases where - required fields are missing or field types don't match the schema. - - Examples: - Basic usage without schema:: - - # Retrieve all records from an entity - records = entities_service.list_records("Customers") - for record in records: - print(record.id) - - With pagination:: - - # Get first 50 records - records = entities_service.list_records("Customers", start=0, limit=50) - - With schema validation:: - - class CustomerRecord: - name: str - email: str - age: Optional[int] - is_active: bool - - # Records are validated against CustomerRecord schema - records = entities_service.list_records( - "Customers", - schema=CustomerRecord - ) - - # Safe to access fields knowing they match the schema - for record in records: - print(f"{record.name}: {record.email}") - """ - # Example method to generate the API request specification (mocked here) - spec = self._list_records_spec(entity_key, start, limit) - - # Make the HTTP request (assumes self.request exists) - response = self.request(spec.method, spec.endpoint, params=spec.params) - - # Parse the response JSON and extract the "value" field - records_data = response.json().get("value", []) - - # Validate and wrap records - return [ - EntityRecord.from_data(data=record, model=schema) for record in records_data - ] - - @traced(name="entity_list_records", run_type="uipath") - async def list_records_async( - self, - entity_key: str, - schema: Optional[Type[Any]] = None, # Optional schema - start: Optional[int] = None, - limit: Optional[int] = None, - ) -> List[EntityRecord]: - """Asynchronously list records from an entity with optional pagination and schema validation. - - The schema parameter enables type-safe access to entity records by validating the - data against a user-defined class with type annotations. When provided, each record - is validated against the schema's field definitions before being returned. - - Args: - entity_key (str): The unique key/identifier of the entity. - schema (Optional[Type[Any]]): Optional schema class for validation. This should be - a Python class with type-annotated fields that match the entity's structure. - - Field Validation Rules: - - Required fields: Use standard type annotations (e.g., `name: str`) - - Optional fields: Use `Optional` or union with None (e.g., `age: Optional[int]` or `age: int | None`) - - Field names must match the entity's field names (case-sensitive) - - The 'Id' field is automatically validated and does not need to be included - - Example schema class:: - - class CustomerRecord: - name: str # Required field - email: str # Required field - age: Optional[int] # Optional field - phone: str | None # Optional field (Python 3.10+ syntax) - - Benefits of using schema: - - Type safety: Ensures records match expected structure - - Early validation: Catches data issues before processing - - Documentation: Schema serves as clear contract for record structure - - IDE support: Enables better autocomplete and type checking - - When schema validation fails, a `ValueError` is raised with details about - the validation error (e.g., missing required fields, type mismatches). - - start (Optional[int]): Starting index for pagination (0-based). - limit (Optional[int]): Maximum number of records to return. - - Returns: - List[EntityRecord]: A list of entity records. Each record contains an 'id' field - and all other fields from the entity. Fields can be accessed as attributes - or dictionary keys on the EntityRecord object. - - Raises: - ValueError: If schema validation fails for any record, including cases where - required fields are missing or field types don't match the schema. - - Examples: - Basic usage without schema:: - - # Retrieve all records from an entity - records = await entities_service.list_records_async("Customers") - for record in records: - print(record.id) - - With pagination:: - - # Get first 50 records - records = await entities_service.list_records_async("Customers", start=0, limit=50) - - With schema validation:: - - class CustomerRecord: - name: str - email: str - age: Optional[int] - is_active: bool - - # Records are validated against CustomerRecord schema - records = await entities_service.list_records_async( - "Customers", - schema=CustomerRecord - ) - - # Safe to access fields knowing they match the schema - for record in records: - print(f"{record.name}: {record.email}") - """ - spec = self._list_records_spec(entity_key, start, limit) - - # Make the HTTP request (assumes self.request exists) - response = await self.request_async( - spec.method, spec.endpoint, params=spec.params - ) - - # Parse the response JSON and extract the "value" field - records_data = response.json().get("value", []) - - # Validate and wrap records - return [ - EntityRecord.from_data(data=record, model=schema) for record in records_data - ] - - @traced(name="entity_record_insert_batch", run_type="uipath") - def insert_records( - self, - entity_key: str, - records: List[Any], - schema: Optional[Type[Any]] = None, - ) -> EntityRecordsBatchResponse: - """Insert multiple records into an entity in a single batch operation. - - Args: - entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to insert. Each record should be an object - with attributes matching the entity's field names. - schema (Optional[Type[Any]]): Optional schema class for validation. When provided, - validates that each record in the response matches the schema structure. - - Returns: - EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully inserted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to insert - - Examples: - Insert records without schema:: - - class Customer: - def __init__(self, name, email, age): - self.name = name - self.email = email - self.age = age - - customers = [ - Customer("John Doe", "john@example.com", 30), - Customer("Jane Smith", "jane@example.com", 25), - ] - - response = entities_service.insert_records( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - customers - ) - - print(f"Inserted: {len(response.success_records)}") - print(f"Failed: {len(response.failure_records)}") - - Insert with schema validation:: - - class CustomerSchema: - name: str - email: str - age: int - - class Customer: - def __init__(self, name, email, age): - self.name = name - self.email = email - self.age = age - - customers = [Customer("Alice Brown", "alice@example.com", 28)] - - response = entities_service.insert_records( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - customers, - schema=CustomerSchema - ) - - # Access inserted records with validated structure - for record in response.success_records: - print(f"Inserted: {record.name} (ID: {record.id})") - """ - spec = self._insert_batch_spec(entity_key, records) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) - - @traced(name="entity_record_insert_batch", run_type="uipath") - async def insert_records_async( - self, - entity_key: str, - records: List[Any], - schema: Optional[Type[Any]] = None, - ) -> EntityRecordsBatchResponse: - """Asynchronously insert multiple records into an entity in a single batch operation. - - Args: - entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to insert. Each record should be an object - with attributes matching the entity's field names. - schema (Optional[Type[Any]]): Optional schema class for validation. When provided, - validates that each record in the response matches the schema structure. - - Returns: - EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully inserted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to insert - - Examples: - Insert records without schema:: - - class Customer: - def __init__(self, name, email, age): - self.name = name - self.email = email - self.age = age - - customers = [ - Customer("John Doe", "john@example.com", 30), - Customer("Jane Smith", "jane@example.com", 25), - ] - - response = await entities_service.insert_records_async( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - customers - ) - - print(f"Inserted: {len(response.success_records)}") - print(f"Failed: {len(response.failure_records)}") - - Insert with schema validation:: - - class CustomerSchema: - name: str - email: str - age: int - - class Customer: - def __init__(self, name, email, age): - self.name = name - self.email = email - self.age = age - - customers = [Customer("Alice Brown", "alice@example.com", 28)] - - response = await entities_service.insert_records_async( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - customers, - schema=CustomerSchema - ) - - # Access inserted records with validated structure - for record in response.success_records: - print(f"Inserted: {record.name} (ID: {record.id})") - """ - spec = self._insert_batch_spec(entity_key, records) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) - - @traced(name="entity_record_update_batch", run_type="uipath") - def update_records( - self, - entity_key: str, - records: List[Any], - schema: Optional[Type[Any]] = None, - ) -> EntityRecordsBatchResponse: - """Update multiple records in an entity in a single batch operation. - - Args: - entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to update. Each record must have an 'Id' field - and should be a Pydantic model with `model_dump()` method or similar object. - schema (Optional[Type[Any]]): Optional schema class for validation. When provided, - validates that each record in the request and response matches the schema structure. - - Returns: - EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully updated EntityRecord objects - - failure_records: List of EntityRecord objects that failed to update - - Examples: - Update records:: - - # First, retrieve records to update - records = entities_service.list_records("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - - # Modify the records - for record in records: - if record.name == "John Doe": - record.age = 31 - - # Update the modified records - response = entities_service.update_records( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - records - ) - - print(f"Updated: {len(response.success_records)}") - print(f"Failed: {len(response.failure_records)}") - - Update with schema validation:: - - class CustomerSchema: - name: str - email: str - age: int - - # Retrieve and update - records = entities_service.list_records( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - schema=CustomerSchema - ) - - # Modify specific records - for record in records: - if record.age < 30: - record.is_active = True - - response = entities_service.update_records( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - records, - schema=CustomerSchema - ) - - for record in response.success_records: - print(f"Updated: {record.name}") - """ - valid_records = [ - EntityRecord.from_data(data=record.model_dump(by_alias=True), model=schema) - for record in records - ] - - spec = self._update_batch_spec(entity_key, valid_records) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) - - @traced(name="entity_record_update_batch", run_type="uipath") - async def update_records_async( - self, - entity_key: str, - records: List[Any], - schema: Optional[Type[Any]] = None, - ) -> EntityRecordsBatchResponse: - """Asynchronously update multiple records in an entity in a single batch operation. - - Args: - entity_key (str): The unique key/identifier of the entity. - records (List[Any]): List of records to update. Each record must have an 'Id' field - and should be a Pydantic model with `model_dump()` method or similar object. - schema (Optional[Type[Any]]): Optional schema class for validation. When provided, - validates that each record in the request and response matches the schema structure. - - Returns: - EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully updated EntityRecord objects - - failure_records: List of EntityRecord objects that failed to update - - Examples: - Update records:: - - # First, retrieve records to update - records = await entities_service.list_records_async("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - - # Modify the records - for record in records: - if record.name == "John Doe": - record.age = 31 - - # Update the modified records - response = await entities_service.update_records_async( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - records - ) - - print(f"Updated: {len(response.success_records)}") - print(f"Failed: {len(response.failure_records)}") - - Update with schema validation:: - - class CustomerSchema: - name: str - email: str - age: int - - # Retrieve and update - records = await entities_service.list_records_async( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - schema=CustomerSchema - ) - - # Modify specific records - for record in records: - if record.age < 30: - record.is_active = True - - response = await entities_service.update_records_async( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - records, - schema=CustomerSchema - ) - - for record in response.success_records: - print(f"Updated: {record.name}") - """ - valid_records = [ - EntityRecord.from_data(data=record.model_dump(by_alias=True), model=schema) - for record in records - ] - - spec = self._update_batch_spec(entity_key, valid_records) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - return self.validate_entity_batch(response, schema) - - @traced(name="entity_record_delete_batch", run_type="uipath") - def delete_records( - self, - entity_key: str, - record_ids: List[str], - ) -> EntityRecordsBatchResponse: - """Delete multiple records from an entity in a single batch operation. - - Args: - entity_key (str): The unique key/identifier of the entity. - record_ids (List[str]): List of record IDs (GUIDs) to delete. - - Returns: - EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully deleted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to delete - - Examples: - Delete specific records by ID:: - - # Delete records by their IDs - record_ids = [ - "12345678-1234-1234-1234-123456789012", - "87654321-4321-4321-4321-210987654321", - ] - - response = entities_service.delete_records( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - record_ids - ) - - print(f"Deleted: {len(response.success_records)}") - print(f"Failed: {len(response.failure_records)}") - - Delete records matching a condition:: - - # Get all records - records = entities_service.list_records("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - - # Filter records to delete (e.g., inactive customers) - ids_to_delete = [ - record.id for record in records - if not getattr(record, 'is_active', True) - ] - - if ids_to_delete: - response = entities_service.delete_records( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - ids_to_delete - ) - print(f"Deleted {len(response.success_records)} inactive records") - """ - spec = self._delete_batch_spec(entity_key, record_ids) - response = self.request(spec.method, spec.endpoint, json=spec.json) - - delete_records_response = EntityRecordsBatchResponse.model_validate( - response.json() - ) - - return delete_records_response - - @traced(name="entity_record_delete_batch", run_type="uipath") - async def delete_records_async( - self, - entity_key: str, - record_ids: List[str], - ) -> EntityRecordsBatchResponse: - """Asynchronously delete multiple records from an entity in a single batch operation. - - Args: - entity_key (str): The unique key/identifier of the entity. - record_ids (List[str]): List of record IDs (GUIDs) to delete. - - Returns: - EntityRecordsBatchResponse: Response containing successful and failed record operations. - - success_records: List of successfully deleted EntityRecord objects - - failure_records: List of EntityRecord objects that failed to delete - - Examples: - Delete specific records by ID:: - - # Delete records by their IDs - record_ids = [ - "12345678-1234-1234-1234-123456789012", - "87654321-4321-4321-4321-210987654321", - ] - - response = await entities_service.delete_records_async( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - record_ids - ) - - print(f"Deleted: {len(response.success_records)}") - print(f"Failed: {len(response.failure_records)}") - - Delete records matching a condition:: - - # Get all records - records = await entities_service.list_records_async("a1b2c3d4-e5f6-7890-abcd-ef1234567890") - - # Filter records to delete (e.g., inactive customers) - ids_to_delete = [ - record.id for record in records - if not getattr(record, 'is_active', True) - ] - - if ids_to_delete: - response = await entities_service.delete_records_async( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - ids_to_delete - ) - print(f"Deleted {len(response.success_records)} inactive records") - """ - spec = self._delete_batch_spec(entity_key, record_ids) - response = await self.request_async(spec.method, spec.endpoint, json=spec.json) - - delete_records_response = EntityRecordsBatchResponse.model_validate( - response.json() - ) - - return delete_records_response - - def validate_entity_batch( - self, - batch_response: Response, - schema: Optional[Type[Any]] = None, - ) -> EntityRecordsBatchResponse: - # Validate the response format - insert_records_response = EntityRecordsBatchResponse.model_validate( - batch_response.json() - ) - - # Validate individual records - validated_successful_records = [ - EntityRecord.from_data( - data=successful_record.model_dump(by_alias=True), model=schema - ) - for successful_record in insert_records_response.success_records - ] - - validated_failed_records = [ - EntityRecord.from_data( - data=failed_record.model_dump(by_alias=True), model=schema - ) - for failed_record in insert_records_response.failure_records - ] - - return EntityRecordsBatchResponse( - success_records=validated_successful_records, - failure_records=validated_failed_records, - ) - - def _retrieve_spec( - self, - entity_key: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"datafabric_/api/Entity/{entity_key}"), - ) - - def _list_entities_spec(self) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("datafabric_/api/Entity"), - ) - - def _list_records_spec( - self, - entity_key: str, - start: Optional[int] = None, - limit: Optional[int] = None, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/read" - ), - params=({"start": start, "limit": limit}), - ) - - def _insert_batch_spec(self, entity_key: str, records: List[Any]) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/insert-batch" - ), - json=[record.__dict__ for record in records], - ) - - def _update_batch_spec( - self, entity_key: str, records: List[EntityRecord] - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/update-batch" - ), - json=[record.model_dump(by_alias=True) for record in records], - ) - - def _delete_batch_spec(self, entity_key: str, record_ids: List[str]) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/delete-batch" - ), - json=record_ids, - ) diff --git a/src/uipath/platform/entities/entities.py b/src/uipath/platform/entities/entities.py deleted file mode 100644 index ce334bf86..000000000 --- a/src/uipath/platform/entities/entities.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Entities models for UiPath Platform API interactions.""" - -from enum import Enum -from types import EllipsisType -from typing import Any, Dict, List, Optional, Type, Union, get_args, get_origin - -from pydantic import BaseModel, ConfigDict, Field, create_model - - -class ReferenceType(Enum): - """Enum representing types of references between entities.""" - - ManyToOne = "ManyToOne" - - -class FieldDisplayType(Enum): - """Enum representing display types of fields in entities.""" - - Basic = "Basic" - Relationship = "Relationship" - File = "File" - ChoiceSetSingle = "ChoiceSetSingle" - ChoiceSetMultiple = "ChoiceSetMultiple" - AutoNumber = "AutoNumber" - - -class DataDirectionType(Enum): - """Enum representing data direction types for fields in entities.""" - - ReadOnly = "ReadOnly" - ReadAndWrite = "ReadAndWrite" - - -class JoinType(Enum): - """Enum representing types of joins between entities.""" - - LeftJoin = "LeftJoin" - - -class EntityType(Enum): - """Enum representing types of entities.""" - - Entity = "Entity" - ChoiceSet = "ChoiceSet" - InternalEntity = "InternalEntity" - SystemEntity = "SystemEntity" - - -class EntityFieldMetadata(BaseModel): - """Model representing metadata for an entity field.""" - - model_config = ConfigDict( - validate_by_name=True, - ) - type: str - required: bool - name: str - - -class ExternalConnection(BaseModel): - """Model representing an external connection.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - id: str - connection_id: str = Field(alias="connectionId") - element_instance_id: str = Field(alias="elementInstanceId") - folder_id: str = Field(alias="folderKey") # named folderKey in TS SDK - connector_id: str = Field(alias="connectorKey") # named connectorKey in TS SDK - connector_name: str = Field(alias="connectorName") - connection_name: str = Field(alias="connectionName") - - -class ExternalFieldMapping(BaseModel): - """Model representing an external field mapping.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - id: str - external_field_name: str = Field(alias="externalFieldName") - external_field_display_name: str = Field(alias="externalFieldDisplayName") - external_object_id: str = Field(alias="externalObjectId") - external_field_type: str = Field(alias="externalFieldType") - internal_field_id: str = Field(alias="internalFieldId") - direction_type: DataDirectionType = Field(alias="directionType") - - -class FieldDataType(BaseModel): - """Model representing data type information for a field.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - name: str - length_limit: Optional[int] = Field(default=None, alias="LengthLimit") - max_value: Optional[int] = Field(default=None, alias="MaxValue") - min_value: Optional[int] = Field(default=None, alias="MinValue") - decimal_precision: Optional[int] = Field(default=None, alias="DecimalPrecision") - - -class FieldMetadata(BaseModel): - """Model representing metadata for an entity field.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - id: Optional[str] = Field(default=None, alias="id") - name: str - is_primary_key: bool = Field(alias="isPrimaryKey") - is_foreign_key: bool = Field(alias="isForeignKey") - is_external_field: bool = Field(alias="isExternalField") - is_hidden_field: bool = Field(alias="isHiddenField") - is_unique: bool = Field(alias="isUnique") - reference_name: Optional[str] = Field(default=None, alias="referenceName") - reference_entity: Optional["Entity"] = Field(default=None, alias="referenceEntity") - reference_choiceset: Optional["Entity"] = Field( - default=None, alias="referenceChoiceset" - ) - reference_field: Optional["EntityField"] = Field( - default=None, alias="referenceField" - ) - reference_type: ReferenceType = Field(alias="referenceType") - sql_type: "FieldDataType" = Field(alias="sqlType") - is_required: bool = Field(alias="isRequired") - display_name: str = Field(alias="displayName") - description: Optional[str] = Field(default=None, alias="description") - is_system_field: bool = Field(alias="isSystemField") - field_display_type: Optional[str] = Field( - default=None, alias="fieldDisplayType" - ) # Should be FieldDisplayType enum - choiceset_id: Optional[str] = Field(default=None, alias="choicesetId") - default_value: Optional[str] = Field(default=None, alias="defaultValue") - is_attachment: bool = Field(alias="isAttachment") - is_rbac_enabled: bool = Field(alias="isRbacEnabled") - - -class ExternalField(BaseModel): - """Model representing an external field.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - field_metadata: FieldMetadata = Field(alias="fieldMetadata") - external_field_mapping_detail: ExternalFieldMapping = Field( - alias="externalFieldMappingDetail" - ) - - -class EntityField(BaseModel): - """Model representing a field within an entity.""" - - model_config = ConfigDict( - validate_by_name=True, - ) - id: Optional[str] = Field(default=None, alias="id") - definition: Optional[FieldMetadata] = Field(default=None, alias="definition") - - -class ExternalObject(BaseModel): - """Model representing an external object.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - id: str - external_object_name: str = Field(alias="externalObjectName") - external_object_display_name: str = Field(alias="externalObjectDisplayName") - primary_key: str = Field(alias="primaryKey") - external_connection_id: str = Field(alias="externalConnectionId") - entity_id: str = Field(alias="entityId") - is_primary_source: bool = Field(alias="isPrimarySource") - - -class ExternalSourceFields(BaseModel): - """Model representing external source fields.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - fields: List[ExternalField] - external_object_detail: ExternalObject = Field(alias="externalObject") - external_connection_detail: ExternalConnection = Field(alias="externalConnection") - - -class SourceJoinCriteria(BaseModel): - """Model representing source join criteria.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - id: str - entity_id: str = Field(alias="entityId") - join_field_name: str = Field(alias="joinFieldName") - join_type: str = Field(alias="joinType") - related_source_object_id: str = Field(alias="relatedSourceObjectId") - related_source_object_field_name: str = Field(alias="relatedSourceObjectFieldName") - related_source_field_name: str = Field(alias="relatedSourceFieldName") - - -class EntityRecord(BaseModel): - """Model representing a record within an entity.""" - - model_config = { - "validate_by_name": True, - "validate_by_alias": True, - "extra": "allow", - } - - id: str = Field(alias="Id") # Mandatory field validated by Pydantic - - @classmethod - def from_data( - cls, data: Dict[str, Any], model: Optional[Any] = None - ) -> "EntityRecord": - """Create an EntityRecord instance by validating raw data and optionally instantiating a custom model. - - :param data: Raw data dictionary for the entity. - :param model: Optional user-defined class for validation. - :return: EntityRecord instance - """ - # Validate the "Id" field is mandatory and must be a string - id_value = data.get("Id", None) - if id_value is None or not isinstance(id_value, str): - raise ValueError("Field 'Id' is mandatory and must be a string.") - - if model: - # Check if the model is a plain Python class or Pydantic model - cls._validate_against_user_model(data, model) - - return cls(**data) - - @staticmethod - def _validate_against_user_model( - data: Dict[str, Any], user_class: Type[Any] - ) -> None: - user_class_annotations = getattr(user_class, "__annotations__", None) - if user_class_annotations is None: - raise ValueError( - f"User-provided class '{user_class.__name__}' is missing type annotations." - ) - - # Dynamically define a Pydantic model based on the user's class annotations - # Fields must be valid type annotations directly - pydantic_fields: dict[str, tuple[Any, EllipsisType | None]] = {} - - for name, annotation in user_class_annotations.items(): - is_optional = False - - origin = get_origin(annotation) - args = get_args(annotation) - - # Handle Optional[...] or X | None - if origin is Union and type(None) in args: - is_optional = True - - # Check for optional fields - if is_optional: - pydantic_fields[name] = (annotation, None) # Not required - else: - pydantic_fields[name] = (annotation, ...) - - # Dynamically create the Pydantic model class - dynamic_model = create_model( - f"Dynamic_{user_class.__name__}", - **pydantic_fields, # type: ignore[call-overload] # __base__ causes an issue. type checker cannot know that the key does not contain "__base__" - ) - - # Validate input data - dynamic_model.model_validate(data) - - -class Entity(BaseModel): - """Model representing an entity in the UiPath platform.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - - name: str - display_name: str = Field(alias="displayName") - entity_type: str = Field(alias="entityType") - description: Optional[str] = Field(default=None, alias="description") - fields: Optional[List[FieldMetadata]] = Field(default=None, alias="fields") - external_fields: Optional[List[ExternalSourceFields]] = Field( - default=None, alias="externalFields" - ) - source_join_criteria: Optional[List[SourceJoinCriteria]] = Field( - default=None, alias="sourceJoinCriteria" - ) - record_count: Optional[int] = Field(default=None, alias="recordCount") - storage_size_in_mb: Optional[float] = Field(default=None, alias="storageSizeInMB") - used_storage_size_in_mb: Optional[float] = Field( - default=None, alias="usedStorageSizeInMB" - ) - attachment_size_in_byte: Optional[int] = Field( - default=None, alias="attachmentSizeInBytes" - ) - is_rbac_enabled: bool = Field(alias="isRbacEnabled") - id: str - - -class EntityRecordsBatchResponse(BaseModel): - """Model representing a batch response of entity records.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - - success_records: List[EntityRecord] = Field(alias="successRecords") - failure_records: List[EntityRecord] = Field(alias="failureRecords") - - -Entity.model_rebuild() diff --git a/src/uipath/platform/errors/__init__.py b/src/uipath/platform/errors/__init__.py deleted file mode 100644 index 582341129..000000000 --- a/src/uipath/platform/errors/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -"""UiPath Platform Errors. - -This module contains all exception classes used by the UiPath Platform SDK. - -Available exceptions: -- BaseUrlMissingError: Raised when base URL is not configured -- SecretMissingError: Raised when access token is not configured -- FolderNotFoundException: Raised when a folder cannot be found -- UnsupportedDataSourceException: Raised when an operation is attempted on an unsupported data source type -- IngestionInProgressException: Raised when a search is attempted on an index during ingestion -- BatchTransformNotCompleteException: Raised when attempting to get results from an incomplete batch transform -- OperationNotCompleteException: Raised when attempting to get results from an incomplete operation -- OperationFailedException: Raised when an operation has failed -- EnrichedException: Enriched HTTP error with detailed request/response information -""" - -from ._base_url_missing_error import BaseUrlMissingError -from ._batch_transform_not_complete_exception import BatchTransformNotCompleteException -from ._enriched_exception import EnrichedException -from ._folder_not_found_exception import FolderNotFoundException -from ._ingestion_in_progress_exception import IngestionInProgressException -from ._operation_failed_exception import OperationFailedException -from ._operation_not_complete_exception import OperationNotCompleteException -from ._secret_missing_error import SecretMissingError -from ._unsupported_data_source_exception import UnsupportedDataSourceException - -__all__ = [ - "BaseUrlMissingError", - "BatchTransformNotCompleteException", - "EnrichedException", - "FolderNotFoundException", - "IngestionInProgressException", - "SecretMissingError", - "OperationNotCompleteException", - "OperationFailedException", - "UnsupportedDataSourceException", -] diff --git a/src/uipath/platform/errors/_base_url_missing_error.py b/src/uipath/platform/errors/_base_url_missing_error.py deleted file mode 100644 index db4c8efbd..000000000 --- a/src/uipath/platform/errors/_base_url_missing_error.py +++ /dev/null @@ -1,13 +0,0 @@ -class BaseUrlMissingError(Exception): - """Raised when base URL is not configured. - - This exception is raised when attempting to use the SDK without setting - the base URL via the UIPATH_URL environment variable or through authentication. - """ - - def __init__( - self, - message="Authentication required. Please run \033[1muipath auth\033[22m or set the base URL via the UIPATH_URL environment variable.", - ): - self.message = message - super().__init__(self.message) diff --git a/src/uipath/platform/errors/_batch_transform_not_complete_exception.py b/src/uipath/platform/errors/_batch_transform_not_complete_exception.py deleted file mode 100644 index 8b8956923..000000000 --- a/src/uipath/platform/errors/_batch_transform_not_complete_exception.py +++ /dev/null @@ -1,13 +0,0 @@ -class BatchTransformNotCompleteException(Exception): - """Raised when attempting to get results from an incomplete batch transform. - - This exception is raised when attempting to download results from a batch - transform task that has not yet completed successfully. - """ - - def __init__(self, batch_transform_id: str, status: str): - self.message = ( - f"Batch transform '{batch_transform_id}' is not complete. " - f"Current status: {status}" - ) - super().__init__(self.message) diff --git a/src/uipath/platform/errors/_enriched_exception.py b/src/uipath/platform/errors/_enriched_exception.py deleted file mode 100644 index dbf64ebd3..000000000 --- a/src/uipath/platform/errors/_enriched_exception.py +++ /dev/null @@ -1,38 +0,0 @@ -from httpx import HTTPStatusError - - -class EnrichedException(Exception): - """Enriched HTTP error with detailed request/response information. - - This exception wraps HTTPStatusError and provides additional context about - the failed HTTP request, including URL, method, status code, and response content. - """ - - def __init__(self, error: HTTPStatusError) -> None: - # Extract the relevant details from the HTTPStatusError - self.status_code = error.response.status_code if error.response else "Unknown" - self.url = str(error.request.url) if error.request else "Unknown" - self.http_method = ( - error.request.method - if error.request and error.request.method - else "Unknown" - ) - max_content_length = 200 - if error.response and error.response.content: - content = error.response.content.decode("utf-8") - if len(content) > max_content_length: - self.response_content = content[:max_content_length] + "... (truncated)" - else: - self.response_content = content - else: - self.response_content = "No content" - - enriched_message = ( - f"\nRequest URL: {self.url}" - f"\nHTTP Method: {self.http_method}" - f"\nStatus Code: {self.status_code}" - f"\nResponse Content: {self.response_content}" - ) - - # Initialize the parent Exception class with the formatted message - super().__init__(enriched_message) diff --git a/src/uipath/platform/errors/_folder_not_found_exception.py b/src/uipath/platform/errors/_folder_not_found_exception.py deleted file mode 100644 index b5daa8005..000000000 --- a/src/uipath/platform/errors/_folder_not_found_exception.py +++ /dev/null @@ -1,13 +0,0 @@ -class FolderNotFoundException(Exception): - """Raised when a folder cannot be found. - - This exception is raised when attempting to access a folder that does not exist - in the UiPath Orchestrator. - """ - - def __init__( - self, - folder_name, - ): - self.message = f"Folder {folder_name} not found." - super().__init__(self.message) diff --git a/src/uipath/platform/errors/_ingestion_in_progress_exception.py b/src/uipath/platform/errors/_ingestion_in_progress_exception.py deleted file mode 100644 index d0a093719..000000000 --- a/src/uipath/platform/errors/_ingestion_in_progress_exception.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Optional - - -class IngestionInProgressException(Exception): - """Raised when a search is attempted on an index during ingestion. - - This exception is raised when attempting to search an index that is currently - undergoing ingestion and is not yet available for queries. - """ - - def __init__(self, index_name: Optional[str], search_operation: bool = True): - index_name = index_name or "Unknown index name" - if search_operation: - self.message = f"index '{index_name}' cannot be searched during ingestion" - else: - self.message = f"index '{index_name}' is currently queued for ingestion" - super().__init__(self.message) diff --git a/src/uipath/platform/errors/_operation_failed_exception.py b/src/uipath/platform/errors/_operation_failed_exception.py deleted file mode 100644 index 216ef7d24..000000000 --- a/src/uipath/platform/errors/_operation_failed_exception.py +++ /dev/null @@ -1,19 +0,0 @@ -class OperationFailedException(Exception): - """Raised when attempting to get results from a failed operation. - - This exception is raised when attempting to retrieve results from operation - that failed to complete successfully. - """ - - def __init__( - self, - operation_id: str, - status: str, - error: str, - operation_name: str = "Operation", - ): - self.operation_id = operation_id - self.status = status - self.error = error - self.message = f"{operation_name} '{operation_id}' failed with status: {status} error: {error}" - super().__init__(self.message) diff --git a/src/uipath/platform/errors/_operation_not_complete_exception.py b/src/uipath/platform/errors/_operation_not_complete_exception.py deleted file mode 100644 index 50941353c..000000000 --- a/src/uipath/platform/errors/_operation_not_complete_exception.py +++ /dev/null @@ -1,14 +0,0 @@ -class OperationNotCompleteException(Exception): - """Raised when attempting to get results from an incomplete operation. - - This exception is raised when attempting to retrieve results from operation - that has not yet completed successfully. - """ - - def __init__( - self, operation_id: str, status: str, operation_name: str = "Operation" - ): - self.operation_id = operation_id - self.status = status - self.message = f"{operation_name} '{operation_id}' is not complete. Current status: {status}" - super().__init__(self.message) diff --git a/src/uipath/platform/errors/_secret_missing_error.py b/src/uipath/platform/errors/_secret_missing_error.py deleted file mode 100644 index e03ed3c51..000000000 --- a/src/uipath/platform/errors/_secret_missing_error.py +++ /dev/null @@ -1,13 +0,0 @@ -class SecretMissingError(Exception): - """Raised when access token is not configured. - - This exception is raised when attempting to use the SDK without setting - the access token via the UIPATH_ACCESS_TOKEN environment variable or through authentication. - """ - - def __init__( - self, - message="Authentication required. Please run \033[1muipath auth\033[22m or set the UIPATH_ACCESS_TOKEN environment variable to a valid access token.", - ): - self.message = message - super().__init__(self.message) diff --git a/src/uipath/platform/errors/_unsupported_data_source_exception.py b/src/uipath/platform/errors/_unsupported_data_source_exception.py deleted file mode 100644 index 0f9c30fe9..000000000 --- a/src/uipath/platform/errors/_unsupported_data_source_exception.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Optional - - -class UnsupportedDataSourceException(Exception): - """Raised when an operation is attempted on an unsupported data source type. - - This exception is raised when attempting to use an operation that only supports - specific data source types (e.g., Orchestrator Storage Bucket) with an incompatible - data source. - """ - - def __init__(self, operation: str, data_source_type: Optional[str] = None): - if data_source_type: - message = f"Operation '{operation}' is not supported for data source type: {data_source_type}. Only Orchestrator Storage Bucket data sources are supported." - else: - message = f"Operation '{operation}' requires an Orchestrator Storage Bucket data source." - super().__init__(message) diff --git a/src/uipath/platform/guardrails/__init__.py b/src/uipath/platform/guardrails/__init__.py deleted file mode 100644 index ffab74581..000000000 --- a/src/uipath/platform/guardrails/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -"""UiPath Guardrails Models. - -This module contains models related to UiPath Guardrails service. -""" - -# 2.3.0 remove -from uipath.core.guardrails import ( - BaseGuardrail, - DeterministicGuardrail, - DeterministicGuardrailsService, - GuardrailScope, - GuardrailValidationResult, - GuardrailValidationResultType, -) - -from ._guardrails_service import GuardrailsService -from .guardrails import ( - BuiltInValidatorGuardrail, - EnumListParameterValue, - GuardrailType, - MapEnumParameterValue, -) - -__all__ = [ - "GuardrailsService", - "BuiltInValidatorGuardrail", - "GuardrailType", - "GuardrailValidationResultType", - "BaseGuardrail", - "GuardrailScope", - "DeterministicGuardrail", - "DeterministicGuardrailsService", - "GuardrailValidationResult", - "EnumListParameterValue", - "MapEnumParameterValue", -] diff --git a/src/uipath/platform/guardrails/_guardrails_service.py b/src/uipath/platform/guardrails/_guardrails_service.py deleted file mode 100644 index cd4a03659..000000000 --- a/src/uipath/platform/guardrails/_guardrails_service.py +++ /dev/null @@ -1,133 +0,0 @@ -from typing import Any - -from httpx import HTTPStatusError -from uipath.core.guardrails import ( - GuardrailValidationResult, - GuardrailValidationResultType, -) - -from ..._utils import Endpoint, RequestSpec -from ...tracing import traced -from ..common import BaseService, UiPathApiConfig, UiPathExecutionContext -from ..errors import EnrichedException -from .guardrails import BuiltInValidatorGuardrail - - -class GuardrailsService(BaseService): - """Service for validating text against UiPath Guardrails. - - This service provides an interface for evaluating built-in guardrails such as: - - - PII detection - - Prompt injection detection - - Deterministic and custom guardrails are not yet supported. - - !!! info "Version Availability" - This service is available starting from **uipath** version **2.2.12**. - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - @staticmethod - def _parse_result(result_str: str) -> GuardrailValidationResultType: - """Parse result string from API response to GuardrailValidationResultType. - - Args: - result_str: The result string from the API response (e.g., "VALIDATION_FAILED"). - - Returns: - GuardrailValidationResultType: The parsed validation result type. - """ - if not result_str: - return GuardrailValidationResultType.VALIDATION_FAILED - - # Convert uppercase enum name to enum value - # API: "VALIDATION_FAILED" -> enum: "validation_failed" - result_value = result_str.lower() - try: - return GuardrailValidationResultType(result_value) - except ValueError: - # If direct conversion fails, try by enum name - try: - return GuardrailValidationResultType[result_str] - except KeyError: - # Fallback to validation_failed if unknown - return GuardrailValidationResultType.VALIDATION_FAILED - - @traced("evaluate_guardrail", run_type="uipath") - def evaluate_guardrail( - self, - input_data: str | dict[str, Any], - guardrail: BuiltInValidatorGuardrail, - ) -> GuardrailValidationResult: - """Validate input text using the provided guardrail. - - Args: - input_data: The text or structured data to validate. Dictionaries will be converted to a string before validation. - guardrail: A guardrail instance used for validation. - - Returns: - GuardrailValidationResult: The outcome of the guardrail evaluation. - """ - parameters = [ - param.model_dump(by_alias=True) for param in guardrail.validator_parameters - ] - payload = { - "validator": guardrail.validator_type, - "input": input_data if isinstance(input_data, str) else str(input_data), - "parameters": parameters, - } - spec = RequestSpec( - method="POST", - endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), - json=payload, - ) - try: - response = self.request( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - response_data = response.json() - except EnrichedException as e: - # Handle 403 responses: API returns 403 with valid JSON body for - # ENTITLEMENTS_MISSING or FEATURE_DISABLED cases - if e.status_code == 403: - # Access the original HTTPStatusError to get the full response - original_error = e.__cause__ - if ( - isinstance(original_error, HTTPStatusError) - and original_error.response - ): - try: - response_data = original_error.response.json() - except Exception: - # If JSON parsing fails, re-raise the original exception - raise - else: - # Try to parse from response_content if available - try: - import json - - response_data = json.loads(e.response_content) - except Exception: - raise - else: - raise - - result = self._parse_result(response_data.get("result", "")) - - reason = response_data.get("details", "") - - # Prepare model data - model_data = { - "result": result.value, - "reason": reason, - } - - return GuardrailValidationResult.model_validate(model_data) diff --git a/src/uipath/platform/guardrails/guardrails.py b/src/uipath/platform/guardrails/guardrails.py deleted file mode 100644 index cfc1e295f..000000000 --- a/src/uipath/platform/guardrails/guardrails.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Guardrails models for UiPath Platform.""" - -from enum import Enum -from typing import Annotated, Literal - -from pydantic import BaseModel, ConfigDict, Field -from uipath.core.guardrails import BaseGuardrail - - -class EnumListParameterValue(BaseModel): - """Enum list parameter value.""" - - parameter_type: Literal["enum-list"] = Field(alias="$parameterType") - id: str - value: list[str] - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - -class MapEnumParameterValue(BaseModel): - """Map enum parameter value.""" - - parameter_type: Literal["map-enum"] = Field(alias="$parameterType") - id: str - value: dict[str, float] - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - -class NumberParameterValue(BaseModel): - """Number parameter value.""" - - parameter_type: Literal["number"] = Field(alias="$parameterType") - id: str - value: float - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - -ValidatorParameter = Annotated[ - EnumListParameterValue | MapEnumParameterValue | NumberParameterValue, - Field(discriminator="parameter_type"), -] - - -class BuiltInValidatorGuardrail(BaseGuardrail): - """Built-in validator guardrail model.""" - - guardrail_type: Literal["builtInValidator"] = Field(alias="$guardrailType") - validator_type: str = Field(alias="validatorType") - validator_parameters: list[ValidatorParameter] = Field( - default_factory=list, alias="validatorParameters" - ) - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - -class GuardrailType(str, Enum): - """Guardrail type enumeration.""" - - BUILT_IN_VALIDATOR = "builtInValidator" - CUSTOM = "custom" diff --git a/src/uipath/platform/orchestrator/__init__.py b/src/uipath/platform/orchestrator/__init__.py deleted file mode 100644 index a6e3c0bee..000000000 --- a/src/uipath/platform/orchestrator/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -"""UiPath Orchestrator Models. - -This module contains models related to UiPath Orchestrator services. -""" - -from ._assets_service import AssetsService -from ._attachments_service import AttachmentsService -from ._buckets_service import BucketsService -from ._folder_service import FolderService -from ._jobs_service import JobsService -from ._mcp_service import McpService -from ._processes_service import ProcessesService -from ._queues_service import QueuesService -from .assets import Asset, UserAsset -from .attachment import Attachment -from .buckets import Bucket, BucketFile -from .job import Job, JobErrorInfo -from .mcp import McpServer, McpServerStatus, McpServerType -from .processes import Process -from .queues import ( - CommitType, - QueueItem, - QueueItemPriority, - TransactionItem, - TransactionItemResult, -) - -__all__ = [ - "AssetsService", - "AttachmentsService", - "BucketsService", - "FolderService", - "JobsService", - "McpService", - "ProcessesService", - "QueuesService", - "Asset", - "UserAsset", - "Attachment", - "Bucket", - "BucketFile", - "Job", - "JobErrorInfo", - "Process", - "CommitType", - "QueueItem", - "QueueItemPriority", - "TransactionItem", - "TransactionItemResult", - "McpServer", - "McpServerStatus", - "McpServerType", -] diff --git a/src/uipath/platform/orchestrator/_assets_service.py b/src/uipath/platform/orchestrator/_assets_service.py deleted file mode 100644 index 49c81d42f..000000000 --- a/src/uipath/platform/orchestrator/_assets_service.py +++ /dev/null @@ -1,555 +0,0 @@ -from typing import Any, Dict, Optional - -from httpx import Response - -from ..._utils import Endpoint, RequestSpec, header_folder, resource_override -from ..._utils.validation import validate_pagination_params -from ...tracing import traced -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from ..common.paging import PagedResult -from .assets import Asset, UserAsset - - -class AssetsService(FolderContext, BaseService): - """Service for managing UiPath assets. - - Assets are key-value pairs that can be used to store configuration data, - credentials, and other settings used by automation processes. - """ - - # Pagination limits - MAX_PAGE_SIZE = 1000 # Maximum items per page - MAX_SKIP_OFFSET = 10000 # Maximum skip offset - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - self._base_url = "assets" - - @traced(name="assets_list", run_type="uipath") - def list( - self, - *, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - skip: int = 0, - top: int = 100, - ) -> PagedResult[Asset]: - """List assets using OData API with offset-based pagination. - - Returns a single page of results with pagination metadata. - - Args: - folder_path: Folder path to filter assets. - folder_key: Folder key (mutually exclusive with folder_path). - filter: OData $filter expression (e.g., "ValueType eq 'Text'"). - orderby: OData $orderby expression (e.g., "Name asc"). - skip: Number of items to skip (default 0, max 10000). - top: Maximum items per page (default 100, max 1000). - - Returns: - PagedResult[Asset]: Page of assets with pagination metadata. - - Raises: - ValueError: If skip or top parameters are invalid. - - Examples: - ```python - from uipath.platform import UiPath - - client = UiPath() - - # List all assets in the default folder - result = client.assets.list(top=100) - for asset in result.items: - print(asset.name, asset.value_type) - - # List with filter - result = client.assets.list(filter="ValueType eq 'Text'") - - # Paginate through all assets - skip = 0 - while True: - result = client.assets.list(skip=skip, top=100) - for asset in result.items: - print(asset.name) - if not result.has_more: - break - skip += 100 - ``` - """ - validate_pagination_params( - skip=skip, - top=top, - max_skip=self.MAX_SKIP_OFFSET, - max_top=self.MAX_PAGE_SIZE, - ) - - spec = self._list_spec( - folder_path=folder_path, - folder_key=folder_key, - filter=filter, - orderby=orderby, - skip=skip, - top=top, - ) - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("value", []) - assets = [Asset.model_validate(item) for item in items] - - return PagedResult( - items=assets, - has_more=len(items) == top, - skip=skip, - top=top, - ) - - @traced(name="assets_list", run_type="uipath") - async def list_async( - self, - *, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - skip: int = 0, - top: int = 100, - ) -> PagedResult[Asset]: - """Asynchronously list assets using OData API with offset-based pagination. - - Returns a single page of results with pagination metadata. - - Args: - folder_path: Folder path to filter assets. - folder_key: Folder key (mutually exclusive with folder_path). - filter: OData $filter expression (e.g., "ValueType eq 'Text'"). - orderby: OData $orderby expression (e.g., "Name asc"). - skip: Number of items to skip (default 0, max 10000). - top: Maximum items per page (default 100, max 1000). - - Returns: - PagedResult[Asset]: Page of assets with pagination metadata. - - Raises: - ValueError: If skip or top parameters are invalid. - """ - validate_pagination_params( - skip=skip, - top=top, - max_skip=self.MAX_SKIP_OFFSET, - max_top=self.MAX_PAGE_SIZE, - ) - - spec = self._list_spec( - folder_path=folder_path, - folder_key=folder_key, - filter=filter, - orderby=orderby, - skip=skip, - top=top, - ) - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("value", []) - assets = [Asset.model_validate(item) for item in items] - - return PagedResult( - items=assets, - has_more=len(items) == top, - skip=skip, - top=top, - ) - - @resource_override(resource_type="asset") - @traced( - name="assets_retrieve", run_type="uipath", hide_input=True, hide_output=True - ) - def retrieve( - self, - name: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> UserAsset | Asset: - """Retrieve an asset by its name. - - Related Activity: [Get Asset](https://docs.uipath.com/activities/other/latest/workflow/get-robot-asset) - - Args: - name (str): The name of the asset. - folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - - Returns: - UserAsset: The asset data. - - Examples: - ```python - from uipath.platform import UiPath - - client = UiPath() - - client.assets.retrieve(name="MyAsset") - ``` - """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False - - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - content=spec.content, - headers=spec.headers, - json=spec.json, - ) - - if is_user: - return UserAsset.model_validate(response.json()) - else: - return Asset.model_validate(response.json()["value"][0]) - - @resource_override(resource_type="asset") - @traced( - name="assets_retrieve", run_type="uipath", hide_input=True, hide_output=True - ) - async def retrieve_async( - self, - name: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> UserAsset | Asset: - """Asynchronously retrieve an asset by its name. - - Related Activity: [Get Asset](https://docs.uipath.com/activities/other/latest/workflow/get-robot-asset) - - Args: - name (str): The name of the asset. - folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - - Returns: - UserAsset: The asset data. - """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False - - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - response = await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - content=spec.content, - headers=spec.headers, - json=spec.json, - ) - - if is_user: - return UserAsset.model_validate(response.json()) - else: - return Asset.model_validate(response.json()["value"][0]) - - @resource_override(resource_type="asset") - @traced( - name="assets_credential", run_type="uipath", hide_input=True, hide_output=True - ) - def retrieve_credential( - self, - name: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Optional[str]: - """Gets a specified Orchestrator credential. - - The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable) - - Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) - - Args: - name (str): The name of the credential asset. - folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - - Returns: - Optional[str]: The decrypted credential password. - - Raises: - ValueError: If the method is called for a user asset. - """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False - - if not is_user: - raise ValueError("This method can only be used for robot assets.") - - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - json=spec.json, - content=spec.content, - headers=spec.headers, - ) - - user_asset = UserAsset.model_validate(response.json()) - - return user_asset.credential_password - - @resource_override(resource_type="asset") - @traced( - name="assets_credential", run_type="uipath", hide_input=True, hide_output=True - ) - async def retrieve_credential_async( - self, - name: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Optional[str]: - """Asynchronously gets a specified Orchestrator credential. - - The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable) - - Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) - - Args: - name (str): The name of the credential asset. - folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - - Returns: - Optional[str]: The decrypted credential password. - - Raises: - ValueError: If the method is called for a user asset. - """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False - - if not is_user: - raise ValueError("This method can only be used for robot assets.") - - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - json=spec.json, - content=spec.content, - headers=spec.headers, - ) - - user_asset = UserAsset.model_validate(response.json()) - - return user_asset.credential_password - - @traced(name="assets_update", run_type="uipath", hide_input=True, hide_output=True) - def update( - self, - robot_asset: UserAsset, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Response: - """Update an asset's value. - - Related Activity: [Set Asset](https://docs.uipath.com/activities/other/latest/workflow/set-asset) - - Args: - robot_asset (UserAsset): The asset object containing the updated values. - - Returns: - Response: The HTTP response confirming the update. - - Raises: - ValueError: If the method is called for a user asset. - """ - try: - is_user = self._execution_context.robot_key is not None - except ValueError: - is_user = False - - if not is_user: - raise ValueError("This method can only be used for robot assets.") - - spec = self._update_spec( - robot_asset, folder_key=folder_key, folder_path=folder_path - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - json=spec.json, - content=spec.content, - headers=spec.headers, - ) - - return response.json() - - @traced(name="assets_update", run_type="uipath", hide_input=True, hide_output=True) - async def update_async( - self, - robot_asset: UserAsset, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Response: - """Asynchronously update an asset's value. - - Related Activity: [Set Asset](https://docs.uipath.com/activities/other/latest/workflow/set-asset) - - Args: - robot_asset (UserAsset): The asset object containing the updated values. - - Returns: - Response: The HTTP response confirming the update. - """ - spec = self._update_spec( - robot_asset, folder_key=folder_key, folder_path=folder_path - ) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - json=spec.json, - content=spec.content, - headers=spec.headers, - ) - - return response.json() - - @property - def custom_headers(self) -> Dict[str, str]: - return self.folder_headers - - def _retrieve_spec( - self, - name: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - try: - robot_key = self._execution_context.robot_key - except ValueError: - robot_key = None - - if robot_key is None: - return RequestSpec( - method="GET", - endpoint=Endpoint( - "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered", - ), - params={"$filter": f"Name eq '{name}'", "$top": 1}, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - return RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" - ), - json={ - "assetName": name, - "robotKey": robot_key, - "supportsCredentialsProxyDisconnected": True, - }, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _update_spec( - self, - robot_asset: UserAsset, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey" - ), - json={ - "robotKey": self._execution_context.robot_key, - "robotAsset": robot_asset.model_dump(by_alias=True, exclude_none=True), - }, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _list_spec( - self, - folder_path: Optional[str], - folder_key: Optional[str], - filter: Optional[str], - orderby: Optional[str], - skip: int, - top: int, - ) -> RequestSpec: - params: Dict[str, Any] = {"$skip": skip, "$top": top} - if filter: - params["$filter"] = filter - if orderby: - params["$orderby"] = orderby - - return RequestSpec( - method="GET", - endpoint=Endpoint( - "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered" - ), - params=params, - headers={**header_folder(folder_key, folder_path)}, - ) diff --git a/src/uipath/platform/orchestrator/_attachments_service.py b/src/uipath/platform/orchestrator/_attachments_service.py deleted file mode 100644 index fac8a1658..000000000 --- a/src/uipath/platform/orchestrator/_attachments_service.py +++ /dev/null @@ -1,1020 +0,0 @@ -import copy -import os -import shutil -import tempfile -import uuid -from contextlib import asynccontextmanager, contextmanager -from pathlib import Path -from typing import Any, AsyncIterator, Iterator, Tuple, overload - -import httpx -from httpx import Response -from httpx._types import RequestContent - -from ..._utils import Endpoint, RequestSpec, header_folder -from ..._utils._ssl_context import get_httpx_client_kwargs -from ..._utils.constants import TEMP_ATTACHMENTS_FOLDER -from ...tracing import traced -from ..attachments import Attachment, AttachmentMode, BlobFileAccessInfo -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext - - -def _upload_attachment_input_processor(inputs: dict[str, Any]) -> dict[str, Any]: - """Process attachment upload inputs to avoid logging large content.""" - processed_inputs = inputs.copy() - if "source_path" in processed_inputs: - processed_inputs["source_path"] = f"" - if "content" in processed_inputs: - if isinstance(processed_inputs["content"], str): - processed_inputs["content"] = "" - else: - processed_inputs["content"] = "" - return processed_inputs - - -class AttachmentsService(FolderContext, BaseService): - """Service for managing UiPath attachments. - - Attachments allow you to upload and download files to be used within UiPath - processes, actions, and other UiPath services. - - Reference: https://docs.uipath.com/orchestrator/reference/api-attachments - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - self._temp_dir = os.path.join(tempfile.gettempdir(), TEMP_ATTACHMENTS_FOLDER) - - @traced(name="attachments_open", run_type="uipath") - @contextmanager - def open( - self, - *, - attachment: Attachment, - mode: AttachmentMode = AttachmentMode.READ, - content: RequestContent | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> Iterator[Tuple[Attachment, Response]]: - """Open an attachment. - - Args: - attachment (Attachment): The attachment to open. - mode (AttachmentMode): The mode to use. - content (RequestContent | None): An optional request content to upload. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Returns: - str: The name of the downloaded attachment. - - Raises: - Exception: If the download fails and no local file is found. - """ - try: - if mode == AttachmentMode.READ: - assert attachment.id, "Attachment ID is required to open an attachment." - spec = self._retrieve_download_uri_spec( - key=attachment.id, - folder_key=folder_key, - folder_path=folder_path, - ) - else: - spec = self._create_attachment_and_retrieve_upload_uri_spec( - name=attachment.full_name, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - json=spec.json, - ).json() - resource: Attachment = copy.deepcopy(attachment) - resource.id = uuid.UUID(result["Id"]) - - resource_uri = result["BlobFileAccess"]["Uri"] - headers = { - key: value - for key, value in zip( - result["BlobFileAccess"]["Headers"]["Keys"], - result["BlobFileAccess"]["Headers"]["Values"], - strict=False, - ) - } - - if result["BlobFileAccess"]["RequiresAuth"]: - raise Exception( - "Attachment access not supported via UiPath Coded Agents." - ) - else: - http_verb = "GET" if mode == AttachmentMode.READ else "PUT" - with httpx.Client(**get_httpx_client_kwargs()) as client: - with client.stream( - http_verb, - resource_uri, - headers=headers, - content=content, - ) as response: - yield resource, response - except Exception as e: - # Re-raise the original exception if we can't find it locally - raise Exception(f"Attachment access failed with error: {e}") from e - - @traced(name="attachments_open", run_type="uipath") - @asynccontextmanager - async def open_async( - self, - *, - attachment: Attachment, - mode: AttachmentMode = AttachmentMode.READ, - content: RequestContent | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> AsyncIterator[Tuple[Attachment, Response]]: - """Open an attachment asynchronously. - - Args: - attachment (Attachment): The attachment to open. - mode (AttachmentMode): The mode to use. - content (RequestContent): An optional request content to upload. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Returns: - str: The name of the downloaded attachment. - - Raises: - Exception: If the download fails and no local file is found. - """ - try: - if mode == AttachmentMode.READ: - assert attachment.id, "Attachment ID is required to open an attachment." - spec = self._retrieve_download_uri_spec( - key=attachment.id, - folder_key=folder_key, - folder_path=folder_path, - ) - else: - spec = self._create_attachment_and_retrieve_upload_uri_spec( - name=attachment.full_name, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - json=spec.json, - ) - ).json() - resource: Attachment = copy.deepcopy(attachment) - resource.id = uuid.UUID(result["Id"]) - - resource_uri = result["BlobFileAccess"]["Uri"] - headers = { - key: value - for key, value in zip( - result["BlobFileAccess"]["Headers"]["Keys"], - result["BlobFileAccess"]["Headers"]["Values"], - strict=False, - ) - } - - if result["BlobFileAccess"]["RequiresAuth"]: - raise Exception( - "Attachment access not supported via UiPath Coded Agents." - ) - else: - http_verb = "GET" if mode == AttachmentMode.READ else "PUT" - async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client: - async with client.stream( - http_verb, - resource_uri, - headers=headers, - content=content, - ) as response: - yield resource, response - except Exception as e: - # Re-raise the original exception if we can't find it locally - raise Exception(f"Attachment access failed with error: {e}") from e - - @traced(name="attachments_download", run_type="uipath") - def download( - self, - *, - key: uuid.UUID, - destination_path: str, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> str: - """Download an attachment. - - This method downloads an attachment from UiPath to a local file. - If the attachment is not found in UiPath (404 error), it will check - for a local file in the temporary directory that matches the UUID. - - Note: - The local file fallback functionality is intended for local development - and debugging purposes only. - - Args: - key (uuid.UUID): The key of the attachment to download. - destination_path (str): The local path where the attachment will be saved. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Returns: - str: The name of the downloaded attachment. - - Raises: - Exception: If the download fails and no local file is found. - - Examples: - ```python - from uipath.platform import UiPath - - client = UiPath() - - attachment_name = client.attachments.download( - key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000"), - destination_path="path/to/save/document.pdf" - ) - print(f"Downloaded attachment: {attachment_name}") - ``` - """ - try: - spec = self._retrieve_download_uri_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - # Get the attachment name - attachment_name = result["Name"] - - download_uri = result["BlobFileAccess"]["Uri"] - headers = { - key: value - for key, value in zip( - result["BlobFileAccess"]["Headers"]["Keys"], - result["BlobFileAccess"]["Headers"]["Values"], - strict=False, - ) - } - - with open(destination_path, "wb") as file: - if result["BlobFileAccess"]["RequiresAuth"]: - response = self.request( - "GET", download_uri, headers=headers, stream=True - ) - for chunk in response.iter_bytes(chunk_size=8192): - file.write(chunk) - else: - with httpx.Client(**get_httpx_client_kwargs()) as client: - with client.stream( - "GET", download_uri, headers=headers - ) as response: - for chunk in response.iter_bytes(chunk_size=8192): - file.write(chunk) - - return attachment_name - except Exception as e: - # If not found in UiPath, check local storage - if "404" in str(e): - # Check if file exists in temp directory - if os.path.exists(self._temp_dir): - # Look for any file starting with our UUID - pattern = f"{key}_*" - matching_files = list(Path(self._temp_dir).glob(pattern)) - - if matching_files: - # Get the full filename - local_file = matching_files[0] - - # Extract the original name from the filename (part after UUID_) - file_name = os.path.basename(local_file) - original_name = file_name[len(f"{key}_") :] - - # Copy the file to the destination - shutil.copy2(local_file, destination_path) - - return original_name - - # Re-raise the original exception if we can't find it locally - raise Exception( - f"Attachment with key {key} not found in UiPath or local storage" - ) from e - - @traced(name="attachments_download", run_type="uipath") - async def download_async( - self, - *, - key: uuid.UUID, - destination_path: str, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> str: - """Download an attachment asynchronously. - - This method asynchronously downloads an attachment from UiPath to a local file. - If the attachment is not found in UiPath (404 error), it will check - for a local file in the temporary directory that matches the UUID. - - Note: - The local file fallback functionality is intended for local development - and debugging purposes only. - - Args: - key (uuid.UUID): The key of the attachment to download. - destination_path (str): The local path where the attachment will be saved. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Returns: - str: The name of the downloaded attachment. - - Raises: - Exception: If the download fails and no local file is found. - - Examples: - ```python - import asyncio - from uipath.platform import UiPath - - client = UiPath() - - async def main(): - attachment_name = await client.attachments.download_async( - key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000"), - destination_path="path/to/save/document.pdf" - ) - print(f"Downloaded attachment: {attachment_name}") - ``` - """ - try: - spec = self._retrieve_download_uri_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - # Get the attachment name - attachment_name = result["Name"] - - download_uri = result["BlobFileAccess"]["Uri"] - headers = { - key: value - for key, value in zip( - result["BlobFileAccess"]["Headers"]["Keys"], - result["BlobFileAccess"]["Headers"]["Values"], - strict=False, - ) - } - - with open(destination_path, "wb") as file: - if result["BlobFileAccess"]["RequiresAuth"]: - response = await self.request_async( - "GET", download_uri, headers=headers, stream=True - ) - async for chunk in response.aiter_bytes(chunk_size=8192): - file.write(chunk) - else: - async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client: - async with client.stream( - "GET", download_uri, headers=headers - ) as response: - async for chunk in response.aiter_bytes(chunk_size=8192): - file.write(chunk) - - return attachment_name - except Exception as e: - # If not found in UiPath, check local storage - if "404" in str(e): - # Check if file exists in temp directory - if os.path.exists(self._temp_dir): - # Look for any file starting with our UUID - pattern = f"{key}_*" - matching_files = list(Path(self._temp_dir).glob(pattern)) - - if matching_files: - # Get the full filename - local_file = matching_files[0] - - # Extract the original name from the filename (part after UUID_) - file_name = os.path.basename(local_file) - original_name = file_name[len(f"{key}_") :] - - # Copy the file to the destination - shutil.copy2(local_file, destination_path) - - return original_name - - # Re-raise the original exception if we can't find it locally - raise Exception( - f"Attachment with key {key} not found in UiPath or local storage" - ) from e - - @overload - def upload( - self, - *, - name: str, - content: str | bytes, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> uuid.UUID: ... - - @overload - def upload( - self, - *, - name: str, - source_path: str, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> uuid.UUID: ... - - @traced( - name="attachments_upload", - run_type="uipath", - input_processor=_upload_attachment_input_processor, - ) - def upload( - self, - *, - name: str, - content: str | bytes | None = None, - source_path: str | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> uuid.UUID: - """Upload a file or content to UiPath as an attachment. - - This method uploads content to UiPath and makes it available as an attachment. - You can either provide a file path or content in memory. - - Args: - name (str): The name of the attachment file. - content (str | bytes | None): The content to upload (string or bytes). - source_path (str | None): The local path of the file to upload. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Returns: - uuid.UUID: The UUID of the created attachment. - - Raises: - ValueError: If neither content nor source_path is provided, or if both are provided. - Exception: If the upload fails. - - Examples: - ```python - from uipath.platform import UiPath - - client = UiPath() - - # Upload a file from disk - attachment_key = client.attachments.upload( - name="my-document.pdf", - source_path="path/to/local/document.pdf", - ) - print(f"Uploaded attachment with key: {attachment_key}") - - # Upload content from memory - attachment_key = client.attachments.upload( - name="notes.txt", - content="This is a text file content", - ) - print(f"Uploaded attachment with key: {attachment_key}") - ``` - """ - # Validate input parameters - if not (content or source_path): - raise ValueError("Content or source_path is required") - if content and source_path: - raise ValueError("Content and source_path are mutually exclusive") - - spec = self._create_attachment_and_retrieve_upload_uri_spec( - name=name, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - json=spec.json, - ).json() - - # Get the ID from the response and convert to UUID - attachment_key = uuid.UUID(result["Id"]) - - upload_uri = result["BlobFileAccess"]["Uri"] - headers = { - key: value - for key, value in zip( - result["BlobFileAccess"]["Headers"]["Keys"], - result["BlobFileAccess"]["Headers"]["Values"], - strict=False, - ) - } - - if source_path: - # Upload from file - with open(source_path, "rb") as file: - file_content = file.read() - if result["BlobFileAccess"]["RequiresAuth"]: - self.request( - "PUT", upload_uri, headers=headers, content=file_content - ) - else: - with httpx.Client(**get_httpx_client_kwargs()) as client: - client.put(upload_uri, headers=headers, content=file_content) - else: - # Upload from memory - # Convert string to bytes if needed - if isinstance(content, str): - content = content.encode("utf-8") - - if result["BlobFileAccess"]["RequiresAuth"]: - self.request("PUT", upload_uri, headers=headers, content=content) - else: - with httpx.Client(**get_httpx_client_kwargs()) as client: - client.put(upload_uri, headers=headers, content=content) - - return attachment_key - - @overload - async def upload_async( - self, - *, - name: str, - content: str | bytes, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> uuid.UUID: ... - - @overload - async def upload_async( - self, - *, - name: str, - source_path: str, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> uuid.UUID: ... - - @traced( - name="attachments_upload", - run_type="uipath", - input_processor=_upload_attachment_input_processor, - ) - async def upload_async( - self, - *, - name: str, - content: str | bytes | None = None, - source_path: str | None = None, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> uuid.UUID: - """Upload a file or content to UiPath as an attachment asynchronously. - - This method asynchronously uploads content to UiPath and makes it available as an attachment. - You can either provide a file path or content in memory. - - Args: - name (str): The name of the attachment file. - content (str | bytes | None): The content to upload (string or bytes). - source_path (str | None): The local path of the file to upload. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Returns: - uuid.UUID: The UUID of the created attachment. - - Raises: - ValueError: If neither content nor source_path is provided, or if both are provided. - Exception: If the upload fails. - - Examples: - ```python - import asyncio - from uipath.platform import UiPath - - client = UiPath() - - async def main(): - # Upload a file from disk - attachment_key = await client.attachments.upload_async( - name="my-document.pdf", - source_path="path/to/local/document.pdf", - ) - print(f"Uploaded attachment with key: {attachment_key}") - - # Upload content from memory - attachment_key = await client.attachments.upload_async( - name="notes.txt", - content="This is a text file content", - ) - print(f"Uploaded attachment with key: {attachment_key}") - ``` - """ - # Validate input parameters - if not (content or source_path): - raise ValueError("Content or source_path is required") - if content and source_path: - raise ValueError("Content and source_path are mutually exclusive") - - spec = self._create_attachment_and_retrieve_upload_uri_spec( - name=name, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - json=spec.json, - ) - ).json() - - # Get the ID from the response and convert to UUID - attachment_key = uuid.UUID(result["Id"]) - - upload_uri = result["BlobFileAccess"]["Uri"] - headers = { - key: value - for key, value in zip( - result["BlobFileAccess"]["Headers"]["Keys"], - result["BlobFileAccess"]["Headers"]["Values"], - strict=False, - ) - } - - if source_path: - # Upload from file - with open(source_path, "rb") as file: - file_content = file.read() - if result["BlobFileAccess"]["RequiresAuth"]: - await self.request_async( - "PUT", upload_uri, headers=headers, content=file_content - ) - else: - with httpx.Client(**get_httpx_client_kwargs()) as client: - client.put(upload_uri, headers=headers, content=file_content) - else: - # Upload from memory - # Convert string to bytes if needed - if isinstance(content, str): - content = content.encode("utf-8") - - if result["BlobFileAccess"]["RequiresAuth"]: - await self.request_async( - "PUT", upload_uri, headers=headers, content=content - ) - else: - with httpx.Client(**get_httpx_client_kwargs()) as client: - client.put(upload_uri, headers=headers, content=content) - - return attachment_key - - @traced(name="attachments_get_blob_uri", run_type="uipath") - def get_blob_file_access_uri( - self, - *, - key: uuid.UUID, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> BlobFileAccessInfo: - """Get the BlobFileAccess information for an attachment. - - This method retrieves the blob storage URI and filename for downloading - an attachment without actually downloading the file. - - Args: - key (uuid.UUID): The key of the attachment. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Returns: - BlobFileAccessInfo: Object containing the blob storage URI and attachment name. - - Raises: - Exception: If the attachment is not found or the request fails. - - Examples: - ```python - from uipath.platform import UiPath - - client = UiPath() - - info = client.attachments.get_blob_file_access_uri( - key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - ) - print(f"Attachment ID: {info.id}") - print(f"Blob URI: {info.uri}") - print(f"File name: {info.name}") - ``` - """ - spec = self._retrieve_download_uri_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - return BlobFileAccessInfo( - id=key, - uri=result["BlobFileAccess"]["Uri"], - name=result["Name"], - ) - - @traced(name="attachments_get_blob_uri", run_type="uipath") - async def get_blob_file_access_uri_async( - self, - *, - key: uuid.UUID, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> BlobFileAccessInfo: - """Get the BlobFileAccess information for an attachment asynchronously. - - This method asynchronously retrieves the blob storage URI and filename - for downloading an attachment without actually downloading the file. - - Args: - key (uuid.UUID): The key of the attachment. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Returns: - BlobFileAccessInfo: Object containing the blob storage URI and attachment name. - - Raises: - Exception: If the attachment is not found or the request fails. - - Examples: - ```python - import asyncio - from uipath.platform import UiPath - - client = UiPath() - - async def main(): - info = await client.attachments.get_blob_file_access_uri_async( - key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - ) - print(f"Attachment ID: {info.id}") - print(f"Blob URI: {info.uri}") - print(f"File name: {info.name}") - ``` - """ - spec = self._retrieve_download_uri_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - return BlobFileAccessInfo( - id=key, - uri=result["BlobFileAccess"]["Uri"], - name=result["Name"], - ) - - @traced(name="attachments_delete", run_type="uipath") - def delete( - self, - *, - key: uuid.UUID, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> None: - """Delete an attachment. - - This method deletes an attachment from UiPath. - If the attachment is not found in UiPath (404 error), it will check - for a local file in the temporary directory that matches the UUID. - - Note: - The local file fallback functionality is intended for local development - and debugging purposes only. - - Args: - key (uuid.UUID): The key of the attachment to delete. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Raises: - Exception: If the deletion fails and no local file is found. - - Examples: - ```python - from uipath.platform import UiPath - - client = UiPath() - - client.attachments.delete( - key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - ) - print("Attachment deleted successfully") - ``` - """ - try: - spec = self._delete_attachment_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) - - self.request( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) - except Exception as e: - # If not found in UiPath, check local storage - if "404" in str(e): - # Check if file exists in temp directory - if os.path.exists(self._temp_dir): - # Look for any file starting with our UUID - pattern = f"{key}_*" - matching_files = list(Path(self._temp_dir).glob(pattern)) - - if matching_files: - # Delete all matching files - for file_path in matching_files: - os.remove(file_path) - return - - # Re-raise the original exception if we can't find it locally - raise Exception( - f"Attachment with key {key} not found in UiPath or local storage" - ) from e - - @traced(name="attachments_delete", run_type="uipath") - async def delete_async( - self, - *, - key: uuid.UUID, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> None: - """Delete an attachment asynchronously. - - This method asynchronously deletes an attachment from UiPath. - If the attachment is not found in UiPath (404 error), it will check - for a local file in the temporary directory that matches the UUID. - - Note: - The local file fallback functionality is intended for local development - and debugging purposes only. - - Args: - key (uuid.UUID): The key of the attachment to delete. - folder_key (str | None): The key of the folder. Override the default one set in the SDK config. - folder_path (str | None): The path of the folder. Override the default one set in the SDK config. - - Raises: - Exception: If the deletion fails and no local file is found. - - Examples: - ```python - import asyncio - from uipath.platform import UiPath - - client = UiPath() - - async def main(): - await client.attachments.delete_async( - key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - ) - print("Attachment deleted successfully") - ``` - """ - try: - spec = self._delete_attachment_spec( - key=key, - folder_key=folder_key, - folder_path=folder_path, - ) - - await self.request_async( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) - except Exception as e: - # If not found in UiPath, check local storage - if "404" in str(e): - # Check if file exists in temp directory - if os.path.exists(self._temp_dir): - # Look for any file starting with our UUID - pattern = f"{key}_*" - matching_files = list(Path(self._temp_dir).glob(pattern)) - - if matching_files: - # Delete all matching files - for file_path in matching_files: - os.remove(file_path) - return - - # Re-raise the original exception if we can't find it locally - raise Exception( - f"Attachment with key {key} not found in UiPath or local storage" - ) from e - - @property - def custom_headers(self) -> dict[str, str]: - """Return custom headers for API requests.""" - return self.folder_headers - - def _create_attachment_and_retrieve_upload_uri_spec( - self, - name: str, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint("/orchestrator_/odata/Attachments"), - json={ - "Name": name, - }, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _retrieve_download_uri_spec( - self, - key: uuid.UUID, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/orchestrator_/odata/Attachments({key})"), - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _delete_attachment_spec( - self, - key: uuid.UUID, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> RequestSpec: - return RequestSpec( - method="DELETE", - endpoint=Endpoint(f"/orchestrator_/odata/Attachments({key})"), - headers={ - **header_folder(folder_key, folder_path), - }, - ) diff --git a/src/uipath/platform/orchestrator/_buckets_service.py b/src/uipath/platform/orchestrator/_buckets_service.py deleted file mode 100644 index ebb6bcbe3..000000000 --- a/src/uipath/platform/orchestrator/_buckets_service.py +++ /dev/null @@ -1,1799 +0,0 @@ -import asyncio -import mimetypes -import uuid -from pathlib import Path -from typing import Any, Dict, Optional, Union - -import httpx - -from ..._utils import Endpoint, RequestSpec, header_folder, resource_override -from ..._utils._ssl_context import get_httpx_client_kwargs -from ..._utils.validation import validate_pagination_params -from ...tracing import traced -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from ..common.paging import PagedResult -from .buckets import Bucket, BucketFile - -# Pagination limits -MAX_PAGE_SIZE = 1000 # Maximum items per page (top parameter) -MAX_SKIP_OFFSET = 10000 # Maximum skip offset for offset-based pagination - - -class BucketsService(FolderContext, BaseService): - """Service for managing UiPath storage buckets. - - Buckets are cloud storage containers that can be used to store and manage files - used by automation processes. - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - self.custom_client = httpx.Client(**get_httpx_client_kwargs()) - self.custom_client_async = httpx.AsyncClient(**get_httpx_client_kwargs()) - - @traced(name="buckets_list", run_type="uipath") - def list( - self, - *, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - name: Optional[str] = None, - skip: int = 0, - top: int = 100, - ) -> PagedResult[Bucket]: - """List buckets using OData API with offset-based pagination. - - Returns a single page of results with pagination metadata. - - Args: - folder_path: Folder path to filter buckets - folder_key: Folder key (mutually exclusive with folder_path) - name: Filter by bucket name (contains match) - skip: Number of buckets to skip (default 0, max 10000) - top: Maximum number of buckets to return (default 100, max 1000) - - Returns: - PagedResult[Bucket]: Page containing buckets and pagination metadata - - Raises: - ValueError: If skip < 0, skip > 10000, top < 1, or top > 1000 - - Examples: - >>> # Get first page - >>> result = sdk.buckets.list(top=100) - >>> for bucket in result.items: - ... print(bucket.name) - >>> - >>> # Check pagination metadata - >>> if result.has_more: - ... print(f"More results available. Current: skip={result.skip}, top={result.top}") - >>> - >>> # Manual pagination to get all buckets - >>> skip = 0 - >>> top = 100 - >>> all_buckets = [] - >>> while True: - ... result = sdk.buckets.list(skip=skip, top=top, name="invoice") - ... all_buckets.extend(result.items) - ... if not result.has_more: - ... break - ... skip += top - >>> - >>> # Helper function for complete iteration - >>> def iter_all_buckets(sdk, top=100, **filters): - ... skip = 0 - ... while True: - ... result = sdk.buckets.list(skip=skip, top=top, **filters) - ... yield from result.items - ... if not result.has_more: - ... break - ... skip += top - >>> - >>> # Usage - >>> for bucket in iter_all_buckets(sdk, name="invoice"): - ... process_bucket(bucket) - """ - # Validate parameters using shared utility - validate_pagination_params( - skip=skip, - top=top, - max_skip=MAX_SKIP_OFFSET, - max_top=MAX_PAGE_SIZE, - ) - - spec = self._list_spec( - folder_path=folder_path, - folder_key=folder_key, - name=name, - skip=skip, - top=top, - ) - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("value", []) - buckets = [Bucket.model_validate(item) for item in items] - - return PagedResult( - items=buckets, - has_more=len(items) == top, - skip=skip, - top=top, - ) - - @traced(name="buckets_list", run_type="uipath") - async def list_async( - self, - *, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - name: Optional[str] = None, - skip: int = 0, - top: int = 100, - ) -> PagedResult[Bucket]: - """Async version of list() with offset-based pagination. - - Returns a single page of results with pagination metadata. - - Args: - folder_path: Folder path to filter buckets - folder_key: Folder key (mutually exclusive with folder_path) - name: Filter by bucket name (contains match) - skip: Number of buckets to skip (default 0, max 10000) - top: Maximum number of buckets to return (default 100, max 1000) - - Returns: - PagedResult[Bucket]: Page containing buckets and pagination metadata - - Raises: - ValueError: If skip < 0, skip > 10000, top < 1, or top > 1000 - - Examples: - >>> # Get first page - >>> result = await sdk.buckets.list_async(top=100) - >>> for bucket in result.items: - ... print(bucket.name) - >>> - >>> # Manual pagination - >>> skip = 0 - >>> top = 100 - >>> all_buckets = [] - >>> while True: - ... result = await sdk.buckets.list_async(skip=skip, top=top) - ... all_buckets.extend(result.items) - ... if not result.has_more: - ... break - ... skip += top - """ - # Validate parameters using shared utility - validate_pagination_params( - skip=skip, - top=top, - max_skip=MAX_SKIP_OFFSET, - max_top=MAX_PAGE_SIZE, - ) - - spec = self._list_spec( - folder_path=folder_path, - folder_key=folder_key, - name=name, - skip=skip, - top=top, - ) - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("value", []) - buckets = [Bucket.model_validate(item) for item in items] - - return PagedResult( - items=buckets, - has_more=len(items) == top, - skip=skip, - top=top, - ) - - @traced(name="buckets_exists", run_type="uipath") - def exists( - self, - name: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> bool: - """Check if bucket exists. - - Args: - name: Bucket name - folder_key: Folder key - folder_path: Folder path - - Returns: - bool: True if bucket exists - - Examples: - >>> if sdk.buckets.exists("my-storage"): - ... print("Bucket found") - """ - try: - self.retrieve(name=name, folder_key=folder_key, folder_path=folder_path) - return True - except LookupError: - return False - - @traced(name="buckets_exists", run_type="uipath") - async def exists_async( - self, - name: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> bool: - """Async version of exists().""" - try: - await self.retrieve_async( - name=name, folder_key=folder_key, folder_path=folder_path - ) - return True - except LookupError: - return False - - @traced(name="buckets_create", run_type="uipath") - def create( - self, - name: str, - *, - description: Optional[str] = None, - identifier: Optional[str] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - ) -> Bucket: - """Create a new bucket. - - Args: - name: Bucket name (must be unique within folder) - description: Optional description - identifier: UUID identifier (auto-generated if not provided) - folder_path: Folder to create bucket in - folder_key: Folder key - - Returns: - Bucket: Newly created bucket resource - - Raises: - Exception: If bucket creation fails - - Examples: - >>> bucket = sdk.buckets.create("my-storage") - >>> bucket = sdk.buckets.create( - ... "data-storage", - ... description="Production data" - ... ) - """ - spec = self._create_spec( - name=name, - description=description, - identifier=identifier or str(uuid.uuid4()), - folder_path=folder_path, - folder_key=folder_key, - ) - response = self.request( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ).json() - - bucket = Bucket.model_validate(response) - return bucket - - @traced(name="buckets_create", run_type="uipath") - async def create_async( - self, - name: str, - *, - description: Optional[str] = None, - identifier: Optional[str] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - ) -> Bucket: - """Async version of create().""" - spec = self._create_spec( - name=name, - description=description, - identifier=identifier or str(uuid.uuid4()), - folder_path=folder_path, - folder_key=folder_key, - ) - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - ).json() - - bucket = Bucket.model_validate(response) - return bucket - - @resource_override(resource_type="bucket") - @traced(name="buckets_delete", run_type="uipath") - def delete( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - ) -> None: - """Delete a bucket. - - Args: - name: Bucket name - key: Bucket identifier (UUID) - folder_path: Folder path - folder_key: Folder key - - Raises: - LookupError: If bucket is not found - - Examples: - >>> sdk.buckets.delete(name="old-storage") - >>> sdk.buckets.delete(key="abc-123-def") - """ - bucket = self.retrieve( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - self.request( - "DELETE", - url=f"/orchestrator_/odata/Buckets({bucket.id})", - headers={**self.folder_headers}, - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_delete", run_type="uipath") - async def delete_async( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - ) -> None: - """Async version of delete().""" - bucket = await self.retrieve_async( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - await self.request_async( - "DELETE", - url=f"/orchestrator_/odata/Buckets({bucket.id})", - headers={**self.folder_headers}, - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_download", run_type="uipath") - def download( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - blob_file_path: str, - destination_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Download a file from a bucket. - - Args: - key (Optional[str]): The key of the bucket. - name (Optional[str]): The name of the bucket. - blob_file_path (str): The path to the file in the bucket. - destination_path (str): The local path where the file will be saved. - folder_key (Optional[str]): The key of the folder where the bucket resides. - folder_path (Optional[str]): The path of the folder where the bucket resides. - - Raises: - ValueError: If neither key nor name is provided. - Exception: If the bucket with the specified key is not found. - """ - bucket = self.retrieve( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - spec = self._retrieve_readUri_spec( - bucket.id, blob_file_path, folder_key=folder_key, folder_path=folder_path - ) - result = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - read_uri = result["Uri"] - - headers = { - key: value - for key, value in zip( - result["Headers"]["Keys"], result["Headers"]["Values"], strict=False - ) - } - - with open(destination_path, "wb") as file: - if result["RequiresAuth"]: - file_content = self.request("GET", read_uri, headers=headers).content - else: - file_content = self.custom_client.get(read_uri, headers=headers).content - file.write(file_content) - - @resource_override(resource_type="bucket") - @traced(name="buckets_download", run_type="uipath") - async def download_async( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - blob_file_path: str, - destination_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Download a file from a bucket asynchronously. - - Args: - key (Optional[str]): The key of the bucket. - name (Optional[str]): The name of the bucket. - blob_file_path (str): The path to the file in the bucket. - destination_path (str): The local path where the file will be saved. - folder_key (Optional[str]): The key of the folder where the bucket resides. - folder_path (Optional[str]): The path of the folder where the bucket resides. - - Raises: - ValueError: If neither key nor name is provided. - Exception: If the bucket with the specified key is not found. - """ - bucket = await self.retrieve_async( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - spec = self._retrieve_readUri_spec( - bucket.id, blob_file_path, folder_key=folder_key, folder_path=folder_path - ) - result = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - read_uri = result["Uri"] - - headers = { - key: value - for key, value in zip( - result["Headers"]["Keys"], result["Headers"]["Values"], strict=False - ) - } - - if result["RequiresAuth"]: - file_content = ( - await self.request_async("GET", read_uri, headers=headers) - ).content - else: - file_content = ( - await self.custom_client_async.get(read_uri, headers=headers) - ).content - - await asyncio.to_thread(Path(destination_path).write_bytes, file_content) - - @resource_override(resource_type="bucket") - @traced(name="buckets_upload", run_type="uipath") - def upload( - self, - *, - key: Optional[str] = None, - name: Optional[str] = None, - blob_file_path: str, - content_type: Optional[str] = None, - source_path: Optional[str] = None, - content: Optional[Union[str, bytes]] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Upload a file to a bucket. - - Args: - key (Optional[str]): The key of the bucket. - name (Optional[str]): The name of the bucket. - blob_file_path (str): The path where the file will be stored in the bucket. - content_type (Optional[str]): The MIME type of the file. For file inputs this is computed dynamically. Default is "application/octet-stream". - source_path (Optional[str]): The local path of the file to upload. - content (Optional[Union[str, bytes]]): The content to upload (string or bytes). - folder_key (Optional[str]): The key of the folder where the bucket resides. - folder_path (Optional[str]): The path of the folder where the bucket resides. - - Raises: - ValueError: If neither key nor name is provided. - Exception: If the bucket with the specified key or name is not found. - """ - if content is not None and source_path is not None: - raise ValueError("Content and source_path are mutually exclusive") - if content is None and source_path is None: - raise ValueError("Either content or source_path must be provided") - - bucket = self.retrieve( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - if source_path: - _content_type, _ = mimetypes.guess_type(source_path) - else: - _content_type = content_type - _content_type = _content_type or "application/octet-stream" - - spec = self._retrieve_writeri_spec( - bucket.id, - _content_type, - blob_file_path, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - write_uri = result["Uri"] - - headers = { - key: value - for key, value in zip( - result["Headers"]["Keys"], result["Headers"]["Values"], strict=False - ) - } - - headers["Content-Type"] = _content_type - - if content is not None: - if isinstance(content, str): - content = content.encode("utf-8") - - if result["RequiresAuth"]: - self.request("PUT", write_uri, headers=headers, content=content) - else: - self.custom_client.put(write_uri, headers=headers, content=content) - - if source_path is not None: - with open(source_path, "rb") as file: - file_content = file.read() - if result["RequiresAuth"]: - self.request( - "PUT", write_uri, headers=headers, content=file_content - ) - else: - self.custom_client.put( - write_uri, headers=headers, content=file_content - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_upload", run_type="uipath") - async def upload_async( - self, - *, - key: Optional[str] = None, - name: Optional[str] = None, - blob_file_path: str, - content_type: Optional[str] = None, - source_path: Optional[str] = None, - content: Optional[Union[str, bytes]] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Upload a file to a bucket asynchronously. - - Args: - key (Optional[str]): The key of the bucket. - name (Optional[str]): The name of the bucket. - blob_file_path (str): The path where the file will be stored in the bucket. - content_type (Optional[str]): The MIME type of the file. For file inputs this is computed dynamically. Default is "application/octet-stream". - source_path (Optional[str]): The local path of the file to upload. - content (Optional[Union[str, bytes]]): The content to upload (string or bytes). - folder_key (Optional[str]): The key of the folder where the bucket resides. - folder_path (Optional[str]): The path of the folder where the bucket resides. - - Raises: - ValueError: If neither key nor name is provided. - Exception: If the bucket with the specified key or name is not found. - """ - if content is not None and source_path is not None: - raise ValueError("Content and source_path are mutually exclusive") - if content is None and source_path is None: - raise ValueError("Either content or source_path must be provided") - - bucket = await self.retrieve_async( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - if source_path: - _content_type, _ = mimetypes.guess_type(source_path) - else: - _content_type = content_type - _content_type = _content_type or "application/octet-stream" - - spec = self._retrieve_writeri_spec( - bucket.id, - _content_type, - blob_file_path, - folder_key=folder_key, - folder_path=folder_path, - ) - - result = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - write_uri = result["Uri"] - - headers = { - key: value - for key, value in zip( - result["Headers"]["Keys"], result["Headers"]["Values"], strict=False - ) - } - - headers["Content-Type"] = _content_type - - if content is not None: - if isinstance(content, str): - content = content.encode("utf-8") - - if result["RequiresAuth"]: - await self.request_async( - "PUT", write_uri, headers=headers, content=content - ) - else: - await self.custom_client_async.put( - write_uri, headers=headers, content=content - ) - - if source_path is not None: - file_content = await asyncio.to_thread(Path(source_path).read_bytes) - if result["RequiresAuth"]: - await self.request_async( - "PUT", write_uri, headers=headers, content=file_content - ) - else: - await self.custom_client_async.put( - write_uri, headers=headers, content=file_content - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_retrieve", run_type="uipath") - def retrieve( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Bucket: - """Retrieve bucket information by its name. - - Args: - name (Optional[str]): The name of the bucket to retrieve. - key (Optional[str]): The key of the bucket. - folder_key (Optional[str]): The key of the folder where the bucket resides. - folder_path (Optional[str]): The path of the folder where the bucket resides. - - Returns: - Bucket: The bucket resource instance. - - Raises: - ValueError: If neither bucket key nor bucket name is provided. - Exception: If the bucket with the specified name is not found. - - Examples: - >>> bucket = sdk.buckets.retrieve(name="my-storage") - >>> print(bucket.name, bucket.identifier) - """ - if key: - spec = self._retrieve_by_key_spec( - key, folder_key=folder_key, folder_path=folder_path - ) - try: - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - if "value" in response: - items = response.get("value", []) - if not items: - raise LookupError(f"Bucket with key '{key}' not found") - bucket_data = items[0] - else: - bucket_data = response - except (KeyError, IndexError) as e: - raise LookupError(f"Bucket with key '{key}' not found") from e - else: - if not name: - raise ValueError("Must specify a bucket name or bucket key") - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - try: - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - items = response.get("value", []) - if not items: - raise LookupError(f"Bucket with name '{name}' not found") - bucket_data = items[0] - except (KeyError, IndexError) as e: - raise LookupError(f"Bucket with name '{name}' not found") from e - - bucket = Bucket.model_validate(bucket_data) - return bucket - - @resource_override(resource_type="bucket") - @traced(name="buckets_retrieve", run_type="uipath") - async def retrieve_async( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Bucket: - """Asynchronously retrieve bucket information by its name. - - Args: - name (Optional[str]): The name of the bucket to retrieve. - key (Optional[str]): The key of the bucket. - folder_key (Optional[str]): The key of the folder where the bucket resides. - folder_path (Optional[str]): The path of the folder where the bucket resides. - - Returns: - Bucket: The bucket resource instance. - - Raises: - ValueError: If neither bucket key nor bucket name is provided. - Exception: If the bucket with the specified name is not found. - - Examples: - >>> bucket = await sdk.buckets.retrieve_async(name="my-storage") - >>> print(bucket.name, bucket.identifier) - """ - if key: - spec = self._retrieve_by_key_spec( - key, folder_key=folder_key, folder_path=folder_path - ) - try: - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - if "value" in response: - items = response.get("value", []) - if not items: - raise LookupError(f"Bucket with key '{key}' not found") - bucket_data = items[0] - else: - bucket_data = response - except (KeyError, IndexError) as e: - raise LookupError(f"Bucket with key '{key}' not found") from e - else: - if not name: - raise ValueError("Must specify a bucket name or bucket key") - spec = self._retrieve_spec( - name, - folder_key=folder_key, - folder_path=folder_path, - ) - try: - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - items = response.get("value", []) - if not items: - raise LookupError(f"Bucket with name '{name}' not found") - bucket_data = items[0] - except (KeyError, IndexError) as e: - raise LookupError(f"Bucket with name '{name}' not found") from e - - bucket = Bucket.model_validate(bucket_data) - return bucket - - @resource_override(resource_type="bucket") - @traced(name="buckets_list_files", run_type="uipath") - def list_files( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - prefix: str = "", - take_hint: int = 500, - continuation_token: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> PagedResult[BucketFile]: - """List files in a bucket using cursor-based pagination. - - Returns a single page of results with continuation token for manual pagination. - This method uses the REST API with continuation tokens for efficient pagination - of large file sets. Recommended for sequential iteration over millions of files. - - Args: - name: Bucket name - key: Bucket identifier - prefix: Filter files by prefix - take_hint: Minimum number of files to return (default 500, max 1000). - The API may return up to 2x this value in some cases. - continuation_token: Token from previous response. Pass None for first page. - folder_key: Folder key - folder_path: Folder path - - Returns: - PagedResult[BucketFile]: Page containing files and continuation token metadata - - Raises: - ValueError: If take_hint is not between 1 and 1000 - - Examples: - >>> # Get first page - >>> result = sdk.buckets.list_files(name="my-storage") - >>> print(f"Got {len(result.items)} files") - >>> - >>> # Manual pagination to get all files - >>> all_files = [] - >>> token = None - >>> while True: - ... result = sdk.buckets.list_files( - ... name="my-storage", - ... prefix="reports/2024/", - ... continuation_token=token - ... ) - ... all_files.extend(result.items) - ... if not result.continuation_token: - ... break - ... token = result.continuation_token - >>> - >>> # Helper function for iteration - >>> def iter_all_files(sdk, bucket_name, prefix=""): - ... token = None - ... while True: - ... result = sdk.buckets.list_files( - ... name=bucket_name, - ... prefix=prefix, - ... continuation_token=token - ... ) - ... yield from result.items - ... if not result.continuation_token: - ... break - ... token = result.continuation_token - >>> - >>> # Usage - >>> for file in iter_all_files(sdk, "my-storage", "reports/"): - ... print(file.path) - - Performance: - Cursor-based pagination scales efficiently to millions of files. - Each page requires one API call regardless of dataset size. - - For sequential processing, this is the most efficient method. - For filtered queries, consider get_files() with OData filters. - """ - # Validate parameters - if take_hint < 1 or take_hint > 1000: - raise ValueError("take_hint must be between 1 and 1000") - - bucket = self.retrieve( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - spec = self._list_files_spec( - bucket.id, - prefix, - continuation_token=continuation_token, - take_hint=take_hint, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("items", []) - files = [BucketFile.model_validate(item) for item in items] - next_token = response.get("continuationToken") - - return PagedResult( - items=files, - continuation_token=next_token, - has_more=next_token is not None, - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_list_files", run_type="uipath") - async def list_files_async( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - prefix: str = "", - take_hint: int = 500, - continuation_token: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> PagedResult[BucketFile]: - """Async version of list_files() with cursor-based pagination. - - Returns a single page of results with continuation token for manual pagination. - - Args: - name: Bucket name - key: Bucket identifier - prefix: Filter files by prefix - take_hint: Minimum number of files to return (default 500, max 1000). - The API may return up to 2x this value in some cases. - continuation_token: Token from previous response. Pass None for first page. - folder_key: Folder key - folder_path: Folder path - - Returns: - PagedResult[BucketFile]: Page containing files and continuation token metadata - - Raises: - ValueError: If take_hint is not between 1 and 1000 - - Examples: - >>> # Get first page - >>> result = await sdk.buckets.list_files_async(name="my-storage") - >>> print(f"Got {len(result.items)} files") - >>> - >>> # Manual pagination - >>> all_files = [] - >>> token = None - >>> while True: - ... result = await sdk.buckets.list_files_async( - ... name="my-storage", - ... continuation_token=token - ... ) - ... all_files.extend(result.items) - ... if not result.continuation_token: - ... break - ... token = result.continuation_token - """ - # Validate parameters - if take_hint < 1 or take_hint > 1000: - raise ValueError("take_hint must be between 1 and 1000") - - bucket = await self.retrieve_async( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - spec = self._list_files_spec( - bucket.id, - prefix, - continuation_token=continuation_token, - take_hint=take_hint, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("items", []) - files = [BucketFile.model_validate(item) for item in items] - next_token = response.get("continuationToken") - - return PagedResult( - items=files, - continuation_token=next_token, - has_more=next_token is not None, - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_exists_file", run_type="uipath") - def exists_file( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - blob_file_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> bool: - """Check if a file exists in a bucket. - - Args: - name: Bucket name - key: Bucket identifier - blob_file_path: Path to the file in the bucket (cannot be empty) - folder_key: Folder key - folder_path: Folder path - - Returns: - bool: True if file exists, False otherwise - - Note: - This method uses short-circuit iteration to stop at the first match, - making it memory-efficient even for large buckets. It will raise - LookupError if the bucket itself doesn't exist. - - Raises: - ValueError: If blob_file_path is empty or whitespace-only - LookupError: If bucket is not found - - Examples: - >>> if sdk.buckets.exists_file(name="my-storage", blob_file_path="data/file.csv"): - ... print("File exists") - >>> # Check in specific folder - >>> exists = sdk.buckets.exists_file( - ... name="my-storage", - ... blob_file_path="reports/2024/summary.pdf", - ... folder_path="Production" - ... ) - """ - if not blob_file_path or not blob_file_path.strip(): - raise ValueError("blob_file_path cannot be empty or whitespace-only") - - normalized_target = ( - blob_file_path if blob_file_path.startswith("/") else f"/{blob_file_path}" - ) - - bucket = self.retrieve( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - token = None - while True: - spec = self._list_files_spec( - bucket.id, - normalized_target, # Use normalized path for prefix - continuation_token=token, - take_hint=1, # Performance optimization: only need first match - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("items", []) - for item in items: - file = BucketFile.model_validate(item) - if file.path == normalized_target: - return True - - token = response.get("continuationToken") - if not token: - break - - return False - - @resource_override(resource_type="bucket") - @traced(name="buckets_exists_file", run_type="uipath") - async def exists_file_async( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - blob_file_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> bool: - """Async version of exists_file(). - - Args: - name: Bucket name - key: Bucket identifier - blob_file_path: Path to the file in the bucket (cannot be empty) - folder_key: Folder key - folder_path: Folder path - - Returns: - bool: True if file exists, False otherwise - - Raises: - ValueError: If blob_file_path is empty or whitespace-only - LookupError: If bucket is not found - - Examples: - >>> if await sdk.buckets.exists_file_async(name="my-storage", blob_file_path="data/file.csv"): - ... print("File exists") - """ - if not blob_file_path or not blob_file_path.strip(): - raise ValueError("blob_file_path cannot be empty or whitespace-only") - - normalized_target = ( - blob_file_path if blob_file_path.startswith("/") else f"/{blob_file_path}" - ) - - bucket = await self.retrieve_async( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - token = None - while True: - spec = self._list_files_spec( - bucket.id, - normalized_target, # Use normalized path for prefix - continuation_token=token, - take_hint=1, # Performance optimization: only need first match - folder_key=folder_key, - folder_path=folder_path, - ) - - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("items", []) - for item in items: - file = BucketFile.model_validate(item) - if file.path == normalized_target: - return True - - token = response.get("continuationToken") - if not token: - break - - return False - - @resource_override(resource_type="bucket") - @traced(name="buckets_delete_file", run_type="uipath") - def delete_file( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - blob_file_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Delete a file from a bucket. - - Args: - name: Bucket name - key: Bucket identifier - blob_file_path: Path to the file in the bucket - folder_key: Folder key - folder_path: Folder path - - Examples: - >>> sdk.buckets.delete_file(name="my-storage", blob_file_path="data/file.txt") - """ - bucket = self.retrieve( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - spec = self._delete_file_spec( - bucket.id, blob_file_path, folder_key=folder_key, folder_path=folder_path - ) - self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_delete_file", run_type="uipath") - async def delete_file_async( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - blob_file_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> None: - """Delete a file from a bucket asynchronously. - - Args: - name: Bucket name - key: Bucket identifier - blob_file_path: Path to the file in the bucket - folder_key: Folder key - folder_path: Folder path - - Examples: - >>> await sdk.buckets.delete_file_async(name="my-storage", blob_file_path="data/file.txt") - """ - bucket = await self.retrieve_async( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - spec = self._delete_file_spec( - bucket.id, blob_file_path, folder_key=folder_key, folder_path=folder_path - ) - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_get_files", run_type="uipath") - def get_files( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - prefix: str = "", - recursive: bool = False, - file_name_glob: Optional[str] = None, - skip: int = 0, - top: int = 500, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> PagedResult[BucketFile]: - """Get files using OData GetFiles API with offset-based pagination. - - This method uses the OData API with $skip/$top for pagination. - Supports recursive traversal, glob filtering, and OData features. - Automatically excludes directories from results. - - Note: Offset-based pagination can degrade performance with very - large skip values (e.g., skip > 10000). For sequential iteration - over large datasets, consider list_files() instead. - - Args: - name: Bucket name - key: Bucket identifier - prefix: Directory path to filter files (default: root) - recursive: Recurse subdirectories for flat view (default: False) - file_name_glob: File filter pattern (e.g., "*.pdf", "data_*.csv") - skip: Number of files to skip (default 0, max 10000). Used for pagination. - top: Maximum number of files to return (default 500, max 1000). - folder_key: Folder key - folder_path: Folder path - - Returns: - PagedResult[BucketFile]: Page containing files (directories excluded) and pagination metadata - - Raises: - ValueError: If skip < 0, skip > 10000, top < 1, top > 1000, neither name nor key is provided, or file_name_glob is empty - LookupError: If bucket not found - - Examples: - >>> # Get first page - >>> result = sdk.buckets.get_files(name="my-storage") - >>> for file in result.items: - ... print(file.name) - >>> - >>> # Filter with glob pattern - >>> result = sdk.buckets.get_files( - ... name="my-storage", - ... recursive=True, - ... file_name_glob="*.pdf" - ... ) - >>> - >>> # Manual offset-based pagination - >>> skip = 0 - >>> top = 500 - >>> all_files = [] - >>> while True: - ... result = sdk.buckets.get_files( - ... name="my-storage", - ... prefix="reports/", - ... skip=skip, - ... top=top - ... ) - ... all_files.extend(result.items) - ... if not result.has_more: - ... break - ... skip += top - >>> - >>> # Helper function - >>> def iter_all_files_odata(sdk, bucket_name, **filters): - ... skip = 0 - ... top = 500 - ... while True: - ... result = sdk.buckets.get_files( - ... name=bucket_name, - ... skip=skip, - ... top=top, - ... **filters - ... ) - ... yield from result.items - ... if not result.has_more: - ... break - ... skip += top - >>> - >>> # Usage with filters - >>> for file in iter_all_files_odata( - ... sdk, - ... "my-storage", - ... recursive=True, - ... file_name_glob="*.pdf" - ... ): - ... process_file(file) - - Performance: - Best for: Filtered queries, random access, sorted results. - Consider list_files() for: Sequential iteration over large datasets. - - Performance degrades with large skip values due to database offset costs. - """ - if skip < 0: - raise ValueError("skip must be >= 0") - if skip > MAX_SKIP_OFFSET: - raise ValueError( - f"skip must be <= {MAX_SKIP_OFFSET} (requested: {skip}). " - f"For large datasets, use list_files() with continuation tokens instead of offset-based pagination." - ) - if top < 1: - raise ValueError("top must be >= 1") - if top > MAX_PAGE_SIZE: - raise ValueError( - f"top must be <= {MAX_PAGE_SIZE} (requested: {top}). " - f"Use pagination with skip and top parameters to retrieve larger datasets." - ) - - if not (name or key): - raise ValueError("Must specify either bucket name or key") - - if file_name_glob is not None and not file_name_glob.strip(): - raise ValueError("file_name_glob cannot be empty") - - bucket = self.retrieve( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - spec = self._get_files_spec( - bucket.id, - prefix=prefix, - recursive=recursive, - file_name_glob=file_name_glob, - skip=skip, - top=top, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("value", []) - - files = [] - for item in items: - if not item.get("IsDirectory", False): - try: - files.append(BucketFile.model_validate(item)) - except Exception as e: - raise ValueError( - f"Failed to parse file entry: {e}. Item: {item}" - ) from e - - return PagedResult( - items=files, - has_more=len(items) == top, # Raw count, not len(files) - skip=skip, - top=top, - ) - - @resource_override(resource_type="bucket") - @traced(name="buckets_get_files", run_type="uipath") - async def get_files_async( - self, - *, - name: Optional[str] = None, - key: Optional[str] = None, - prefix: str = "", - recursive: bool = False, - file_name_glob: Optional[str] = None, - skip: int = 0, - top: int = 500, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> PagedResult[BucketFile]: - """Async version of get_files() with offset-based pagination. - - Returns a single page of results with pagination metadata. - Automatically excludes directories from results. - - Args: - name: Bucket name - key: Bucket identifier - prefix: Directory path to filter files - recursive: Recurse subdirectories for flat view - file_name_glob: File filter pattern (e.g., "*.pdf") - skip: Number of files to skip (default 0, max 10000) - top: Maximum number of files to return (default 500, max 1000) - folder_key: Folder key - folder_path: Folder path - - Returns: - PagedResult[BucketFile]: Page containing files (directories excluded) and pagination metadata - - Raises: - ValueError: If skip < 0, skip > 10000, top < 1, top > 1000, neither name nor key is provided, or file_name_glob is empty - LookupError: If bucket not found - - Examples: - >>> # Get first page - >>> result = await sdk.buckets.get_files_async( - ... name="my-storage", - ... recursive=True, - ... file_name_glob="*.pdf" - ... ) - >>> for file in result.items: - ... print(file.name) - >>> - >>> # Manual pagination - >>> skip = 0 - >>> top = 500 - >>> all_files = [] - >>> while True: - ... result = await sdk.buckets.get_files_async( - ... name="my-storage", - ... skip=skip, - ... top=top - ... ) - ... all_files.extend(result.items) - ... if not result.has_more: - ... break - ... skip += top - """ - if skip < 0: - raise ValueError("skip must be >= 0") - if skip > MAX_SKIP_OFFSET: - raise ValueError( - f"skip must be <= {MAX_SKIP_OFFSET} (requested: {skip}). " - f"For large datasets, use list_files() with continuation tokens instead of offset-based pagination." - ) - if top < 1: - raise ValueError("top must be >= 1") - if top > MAX_PAGE_SIZE: - raise ValueError( - f"top must be <= {MAX_PAGE_SIZE} (requested: {top}). " - f"Use pagination with skip and top parameters to retrieve larger datasets." - ) - - if not (name or key): - raise ValueError("Must specify either bucket name or key") - - if file_name_glob is not None and not file_name_glob.strip(): - raise ValueError("file_name_glob cannot be empty") - - bucket = await self.retrieve_async( - name=name, key=key, folder_key=folder_key, folder_path=folder_path - ) - - spec = self._get_files_spec( - bucket.id, - prefix=prefix, - recursive=recursive, - file_name_glob=file_name_glob, - skip=skip, - top=top, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("value", []) - - files = [] - for item in items: - if not item.get("IsDirectory", False): - try: - files.append(BucketFile.model_validate(item)) - except Exception as e: - raise ValueError( - f"Failed to parse file entry: {e}. Item: {item}" - ) from e - - return PagedResult( - items=files, - has_more=len(items) == top, # Raw count, not len(files) - skip=skip, - top=top, - ) - - @property - def custom_headers(self) -> Dict[str, str]: - return self.folder_headers - - def _list_spec( - self, - folder_path: Optional[str], - folder_key: Optional[str], - name: Optional[str], - skip: int, - top: int, - ) -> RequestSpec: - """Build OData request for listing buckets.""" - filters = [] - if name: - escaped_name = name.replace("'", "''") - filters.append(f"contains(tolower(Name), tolower('{escaped_name}'))") - - filter_str = " and ".join(filters) if filters else None - - params: Dict[str, Any] = {"$skip": skip, "$top": top} - if filter_str: - params["$filter"] = filter_str - - return RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/odata/Buckets"), - params=params, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _create_spec( - self, - name: str, - description: Optional[str], - identifier: str, - folder_path: Optional[str], - folder_key: Optional[str], - ) -> RequestSpec: - """Build request for creating bucket.""" - body = { - "Name": name, - "Identifier": identifier, - } - if description: - body["Description"] = description - - return RequestSpec( - method="POST", - endpoint=Endpoint("/orchestrator_/odata/Buckets"), - json=body, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _retrieve_spec( - self, - name: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - escaped_name = name.replace("'", "''") - return RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/odata/Buckets"), - params={"$filter": f"Name eq '{escaped_name}'", "$top": 1}, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _retrieve_readUri_spec( - self, - bucket_id: int, - blob_file_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"/orchestrator_/odata/Buckets({bucket_id})/UiPath.Server.Configuration.OData.GetReadUri" - ), - params={"path": blob_file_path}, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _retrieve_writeri_spec( - self, - bucket_id: int, - content_type: str, - blob_file_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"/orchestrator_/odata/Buckets({bucket_id})/UiPath.Server.Configuration.OData.GetWriteUri" - ), - params={"path": blob_file_path, "contentType": content_type}, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _retrieve_by_key_spec( - self, - key: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - escaped_key = key.replace("'", "''") - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{escaped_key}')" - ), - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _list_files_spec( - self, - bucket_id: int, - prefix: str, - continuation_token: Optional[str] = None, - take_hint: int = 500, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - """Build REST API request for listing files in a bucket. - - Uses the /api/Buckets/{id}/ListFiles endpoint which supports cursor-based pagination. - - Args: - bucket_id: The bucket ID - prefix: Path prefix for filtering - continuation_token: Token for pagination - take_hint: Minimum number of files to return (default 500, max 1000) - folder_key: Folder key - folder_path: Folder path - """ - params: Dict[str, Any] = {} - if prefix: - params["prefix"] = prefix - if continuation_token is not None: - params["continuationToken"] = continuation_token - params["takeHint"] = take_hint - - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/api/Buckets/{bucket_id}/ListFiles"), - params=params, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _delete_file_spec( - self, - bucket_id: int, - blob_file_path: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - """Build request for deleting a file from a bucket.""" - return RequestSpec( - method="DELETE", - endpoint=Endpoint( - f"/orchestrator_/odata/Buckets({bucket_id})/UiPath.Server.Configuration.OData.DeleteFile" - ), - params={"path": blob_file_path}, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _get_files_spec( - self, - bucket_id: int, - prefix: str = "", - recursive: bool = False, - file_name_glob: Optional[str] = None, - skip: int = 0, - top: int = 500, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - """Build OData request for GetFiles endpoint. - - Args: - bucket_id: Bucket ID - prefix: Directory path prefix - recursive: Recurse subdirectories - file_name_glob: File name filter pattern - skip: Number of items to skip (pagination) - top: Number of items to return (pagination) - folder_key: Folder key - folder_path: Folder path - - Returns: - RequestSpec: OData request specification - """ - params: Dict[str, Any] = {} - - params["directory"] = "/" if not prefix else prefix - - if recursive: - params["recursive"] = "true" - - if file_name_glob: - params["fileNameGlob"] = file_name_glob - - if skip > 0: - params["$skip"] = skip - params["$top"] = top - - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"/orchestrator_/odata/Buckets({bucket_id})/UiPath.Server.Configuration.OData.GetFiles" - ), - params=params, - headers={ - **header_folder(folder_key, folder_path), - }, - ) diff --git a/src/uipath/platform/orchestrator/_folder_service.py b/src/uipath/platform/orchestrator/_folder_service.py deleted file mode 100644 index 5fbb0447e..000000000 --- a/src/uipath/platform/orchestrator/_folder_service.py +++ /dev/null @@ -1,220 +0,0 @@ -from typing import Optional - -from typing_extensions import deprecated - -from ..._utils import Endpoint, RequestSpec -from ...tracing import traced -from ..common import BaseService, UiPathApiConfig, UiPathExecutionContext -from ..errors import FolderNotFoundException -from .folder import PersonalWorkspace - - -class FolderService(BaseService): - """Service for managing UiPath Folders. - - A folder represents a single area for data organization - and access control - it is created when you need to categorize, manage, and enforce authorization rules for a group - of UiPath resources (i.e. processes, assets, connections, storage buckets etc.) or other folders - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - def retrieve_folder_key(self, folder_path: str | None) -> str | None: - """Resolve a folder path to its corresponding folder key. - - Args: - folder_path: Folder path to resolve to a key - - Returns: - The resolved folder key - - Raises: - ValueError: If folder_path is None or if folder_path is not found - """ - if folder_path is None: - raise ValueError("Cannot obtain folder_key without providing folder_path") - - resolved_folder_key = self.retrieve_key(folder_path=folder_path) - if not resolved_folder_key: - raise FolderNotFoundException(folder_path) - return resolved_folder_key - - async def retrieve_folder_key_async(self, folder_path: str | None) -> str | None: - """Asynchronously resolve a folder path to its corresponding folder key. - - Args: - folder_path: Folder path to resolve to a key - - Returns: - The resolved folder key - - Raises: - ValueError: If folder_path is None or if folder_path is not found - """ - if folder_path is None: - raise ValueError("Cannot obtain folder_key without providing folder_path") - - resolved_folder_key = await self.retrieve_key_async(folder_path=folder_path) - if not resolved_folder_key: - raise FolderNotFoundException(folder_path) - return resolved_folder_key - - @traced(name="folder_retrieve_key_by_folder_path", run_type="uipath") - @deprecated("Use retrieve_key instead") - def retrieve_key_by_folder_path(self, folder_path: str) -> Optional[str]: - return self.retrieve_key(folder_path=folder_path) - - @traced(name="folder_retrieve_key", run_type="uipath") - def retrieve_key(self, *, folder_path: str) -> Optional[str]: - """Retrieve the folder key by folder path with pagination support. - - Args: - folder_path: The fully qualified folder path to search for. - - Returns: - The folder key if found, None otherwise. - """ - skip = 0 - take = 20 - - while True: - spec = self._retrieve_spec(folder_path, skip=skip, take=take) - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - ).json() - - # Search for the folder in current page - folder_key = next( - ( - item["Key"] - for item in response["PageItems"] - if item["FullyQualifiedName"] == folder_path - ), - None, - ) - - if folder_key is not None: - return folder_key - - page_items = response["PageItems"] - if len(page_items) < take: - break - - skip += take - - return None - - @traced(name="folder_retrieve_key", run_type="uipath") - async def retrieve_key_async(self, *, folder_path: str) -> Optional[str]: - """Retrieve the folder key by folder path with pagination support. - - Args: - folder_path: The fully qualified folder path to search for. - - Returns: - The folder key if found, None otherwise. - """ - skip = 0 - take = 20 - - while True: - spec = self._retrieve_spec(folder_path, skip=skip, take=take) - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - ) - ).json() - - # Search for the folder in current page - folder_key = next( - ( - item["Key"] - for item in response["PageItems"] - if item["FullyQualifiedName"] == folder_path - ), - None, - ) - - if folder_key is not None: - return folder_key - - page_items = response["PageItems"] - if len(page_items) < take: - break - - skip += take - - return None - - def _retrieve_spec( - self, folder_path: str, *, skip: int = 0, take: int = 20 - ) -> RequestSpec: - folder_name = folder_path.split("/")[-1] - return RequestSpec( - method="GET", - endpoint=Endpoint( - "orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser" - ), - params={ - "searchText": folder_name, - "skip": skip, - "take": take, - }, - ) - - @traced(name="folder_get_personal_workspace", run_type="uipath") - def get_personal_workspace(self) -> PersonalWorkspace: - """Retrieve the personal workspace folder for the current user. - - Returns: - PersonalWorkspace: The personal workspace information. - - Raises: - ValueError: If the user does not have a personal workspace. - """ - response = self.request( - "GET", - url=Endpoint( - "orchestrator_/odata/Users/UiPath.Server.Configuration.OData.GetCurrentUserExtended" - ), - params={"$select": "PersonalWorkspace", "$expand": "PersonalWorkspace"}, - ).json() - - personal_workspace = response.get("PersonalWorkspace") - if personal_workspace is None: - raise ValueError("Failed to fetch personal workspace") - - return PersonalWorkspace.model_validate(personal_workspace) - - @traced(name="folder_get_personal_workspace_async", run_type="uipath") - async def get_personal_workspace_async(self) -> PersonalWorkspace: - """Asynchronously retrieve the personal workspace folder for the current user. - - Returns: - PersonalWorkspace: The personal workspace information. - - Raises: - ValueError: If the personal workspace cannot be fetched. - """ - response = ( - await self.request_async( - "GET", - url=Endpoint( - "orchestrator_/odata/Users/UiPath.Server.Configuration.OData.GetCurrentUserExtended" - ), - params={"$select": "PersonalWorkspace", "$expand": "PersonalWorkspace"}, - ) - ).json() - - personal_workspace = response.get("PersonalWorkspace") - if personal_workspace is None: - raise ValueError("Failed to fetch personal workspace") - - return PersonalWorkspace.model_validate(personal_workspace) diff --git a/src/uipath/platform/orchestrator/_jobs_service.py b/src/uipath/platform/orchestrator/_jobs_service.py deleted file mode 100644 index a89758673..000000000 --- a/src/uipath/platform/orchestrator/_jobs_service.py +++ /dev/null @@ -1,1485 +0,0 @@ -import os -import shutil -import tempfile -import uuid -from pathlib import Path -from typing import Any, Dict, List, Optional, Union, cast, overload - -from ..._utils import Endpoint, RequestSpec, header_folder, resource_override -from ..._utils.constants import TEMP_ATTACHMENTS_FOLDER -from ..._utils.validation import validate_pagination_params -from ...tracing import traced -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from ..common.paging import PagedResult -from ..errors import EnrichedException -from ._attachments_service import AttachmentsService -from .job import Job - - -class JobsService(FolderContext, BaseService): - """Service for managing API payloads and job inbox interactions. - - A job represents a single execution of an automation - it is created when you start - a process and contains information about that specific run, including its status, - start time, and any input/output data. - """ - - # Pagination limits - MAX_PAGE_SIZE = 1000 # Maximum items per page - MAX_SKIP_OFFSET = 10000 # Maximum skip offset - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - self._attachments_service = AttachmentsService(config, execution_context) - # Define the temp directory path for local attachments - self._temp_dir = os.path.join(tempfile.gettempdir(), TEMP_ATTACHMENTS_FOLDER) - os.makedirs(self._temp_dir, exist_ok=True) - - @overload - def resume(self, *, inbox_id: str, payload: Any) -> None: ... - - @overload - def resume(self, *, job_id: str, payload: Any) -> None: ... - - @traced(name="jobs_resume", run_type="uipath") - def resume( - self, - *, - inbox_id: Optional[str] = None, - job_id: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - payload: Any, - ) -> None: - """Sends a payload to resume a paused job waiting for input, identified by its inbox ID. - - Args: - inbox_id (Optional[str]): The inbox ID of the job. - job_id (Optional[str]): The job ID of the job. - folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - payload (Any): The payload to deliver. - """ - if job_id is None and inbox_id is None: - raise ValueError("Either job_id or inbox_id must be provided") - - # for type checking - job_id = str(job_id) - inbox_id = ( - inbox_id - if inbox_id - else self._retrieve_inbox_id( - job_id=job_id, - folder_key=folder_key, - folder_path=folder_path, - ) - ) - spec = self._resume_spec( - inbox_id=inbox_id, - payload=payload, - folder_key=folder_key, - folder_path=folder_path, - ) - self.request( - spec.method, - url=spec.endpoint, - headers=spec.headers, - json=spec.json, - ) - - async def resume_async( - self, - *, - inbox_id: Optional[str] = None, - job_id: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - payload: Any, - ) -> None: - """Asynchronously sends a payload to resume a paused job waiting for input, identified by its inbox ID. - - Args: - inbox_id (Optional[str]): The inbox ID of the job. If not provided, the execution context will be used to retrieve the inbox ID. - job_id (Optional[str]): The job ID of the job. - folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - payload (Any): The payload to deliver. - - Examples: - ```python - import asyncio - - from uipath.platform import UiPath - - sdk = UiPath() - - - async def main(): # noqa: D103 - payload = await sdk.jobs.resume_async(job_id="38073051", payload="The response") - - asyncio.run(main()) - ``` - """ - if job_id is None and inbox_id is None: - raise ValueError("Either job_id or inbox_id must be provided") - - # for type checking - job_id = str(job_id) - inbox_id = ( - inbox_id - if inbox_id - else self._retrieve_inbox_id( - job_id=job_id, - folder_key=folder_key, - folder_path=folder_path, - ) - ) - - spec = self._resume_spec( - inbox_id=inbox_id, - payload=payload, - folder_key=folder_key, - folder_path=folder_path, - ) - await self.request_async( - spec.method, - url=spec.endpoint, - headers=spec.headers, - json=spec.json, - ) - - @property - def custom_headers(self) -> Dict[str, str]: - return self.folder_headers - - @traced(name="jobs_list", run_type="uipath") - def list( - self, - *, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - skip: int = 0, - top: int = 100, - ) -> PagedResult[Job]: - """List jobs using OData API with offset-based pagination. - - Returns a single page of results with pagination metadata. - - Args: - folder_path: Folder path to filter jobs - folder_key: Folder key (mutually exclusive with folder_path) - filter: OData $filter expression (e.g., "State eq 'Successful'") - orderby: OData $orderby expression (e.g., "CreationTime desc") - skip: Number of items to skip (default 0, max 10000) - top: Maximum items per page (default 100, max 1000) - - Returns: - PagedResult[Job]: Page of jobs with pagination metadata - - Raises: - ValueError: If skip or top parameters are invalid - - Examples: - >>> result = sdk.jobs.list(top=100) - >>> for job in result.items: - ... print(job.key, job.state) - >>> print(f"Has more: {result.has_more}") - """ - validate_pagination_params( - skip=skip, - top=top, - max_skip=self.MAX_SKIP_OFFSET, - max_top=self.MAX_PAGE_SIZE, - ) - - spec = self._list_spec( - folder_path=folder_path, - folder_key=folder_key, - filter=filter, - orderby=orderby, - skip=skip, - top=top, - ) - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("value", []) - jobs = [Job.model_validate(item) for item in items] - - return PagedResult( - items=jobs, - has_more=len(items) == top, - skip=skip, - top=top, - ) - - @traced(name="jobs_list", run_type="uipath") - async def list_async( - self, - *, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - skip: int = 0, - top: int = 100, - ) -> PagedResult[Job]: - """Async version of list() with offset-based pagination.""" - validate_pagination_params( - skip=skip, - top=top, - max_skip=self.MAX_SKIP_OFFSET, - max_top=self.MAX_PAGE_SIZE, - ) - - spec = self._list_spec( - folder_path=folder_path, - folder_key=folder_key, - filter=filter, - orderby=orderby, - skip=skip, - top=top, - ) - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("value", []) - jobs = [Job.model_validate(item) for item in items] - - return PagedResult( - items=jobs, - has_more=len(items) == top, - skip=skip, - top=top, - ) - - @traced(name="jobs_stop", run_type="uipath") - def stop( - self, - *, - job_keys: List[str], - strategy: str = "SoftStop", - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - ) -> None: - """Stop one or more jobs with specified strategy. - - This method uses bulk resolution to efficiently stop multiple jobs, - preventing N+1 query issues. Requests are automatically chunked for - large job lists to avoid URL length constraints. - - Args: - job_keys: List of job UUID keys to stop - strategy: Stop strategy - "SoftStop" (graceful) or "Kill" (force) - folder_path: Folder path - folder_key: Folder key - - Raises: - ValueError: If any job key is not a valid UUID format - LookupError: If any job keys are not found - - Examples: - >>> sdk.jobs.stop(job_keys=["ee9327fd-237d-419e-86ef-9946b34461e3"]) - >>> sdk.jobs.stop(job_keys=["key1", "key2"], strategy="Kill") - - Note: - Large batches are automatically chunked (50 keys per request) to - avoid URL length limits. The method supports stopping hundreds of - jobs efficiently. - """ - job_ids = self._resolve_job_identifiers( - job_keys=job_keys, - folder_key=folder_key, - folder_path=folder_path, - ) - - spec = self._stop_spec( - job_ids=job_ids, - strategy=strategy, - folder_key=folder_key, - folder_path=folder_path, - ) - - self.request( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - @traced(name="jobs_stop", run_type="uipath") - async def stop_async( - self, - *, - job_keys: List[str], - strategy: str = "SoftStop", - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - ) -> None: - """Async version of stop().""" - job_ids = await self._resolve_job_identifiers_async( - job_keys=job_keys, - folder_key=folder_key, - folder_path=folder_path, - ) - - spec = self._stop_spec( - job_ids=job_ids, - strategy=strategy, - folder_key=folder_key, - folder_path=folder_path, - ) - - await self.request_async( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - @traced(name="jobs_restart", run_type="uipath") - def restart( - self, - *, - job_key: str, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - ) -> Job: - """Restart a completed or failed job. - - Args: - job_key: Job UUID key to restart - folder_path: Folder path - folder_key: Folder key - - Returns: - Job: The restarted job - - Examples: - >>> restarted_job = sdk.jobs.restart(job_key="ee9327fd-237d-419e-86ef-9946b34461e3") - >>> print(restarted_job.state) - """ - job_ids = self._resolve_job_identifiers( - job_keys=[job_key], - folder_key=folder_key, - folder_path=folder_path, - ) - - spec = self._restart_spec( - job_id=job_ids[0], - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - return Job.model_validate(response.json()) - - @traced(name="jobs_restart", run_type="uipath") - async def restart_async( - self, - *, - job_key: str, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - ) -> Job: - """Async version of restart().""" - job_ids = await self._resolve_job_identifiers_async( - job_keys=[job_key], - folder_key=folder_key, - folder_path=folder_path, - ) - - spec = self._restart_spec( - job_id=job_ids[0], - folder_key=folder_key, - folder_path=folder_path, - ) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - json=spec.json, - headers=spec.headers, - ) - - return Job.model_validate(response.json()) - - @traced(name="jobs_exists", run_type="uipath") - def exists( - self, - job_key: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> bool: - """Check if job exists. - - Args: - job_key: Job UUID key - folder_key: Folder key - folder_path: Folder path - - Returns: - bool: True if job exists, False otherwise - - Examples: - >>> if sdk.jobs.exists(job_key="ee9327fd-237d-419e-86ef-9946b34461e3"): - ... print("Job found") - """ - try: - self.retrieve( - job_key=job_key, folder_key=folder_key, folder_path=folder_path - ) - return True - except LookupError: - return False - - @traced(name="jobs_exists", run_type="uipath") - async def exists_async( - self, - job_key: str, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> bool: - """Async version of exists().""" - try: - await self.retrieve_async( - job_key=job_key, folder_key=folder_key, folder_path=folder_path - ) - return True - except LookupError: - return False - - @resource_override(resource_type="process", resource_identifier="process_name") - @traced(name="jobs_retrieve", run_type="uipath") - def retrieve( - self, - job_key: str, - *, - folder_key: str | None = None, - folder_path: str | None = None, - process_name: str | None = None, - ) -> Job: - """Retrieve a job identified by its key. - - Args: - job_key (str): The job unique identifier. - folder_key (Optional[str]): The key of the folder in which the job was executed. - folder_path (Optional[str]): The path of the folder in which the job was executed. - process_name: process name hint for resource override - - Returns: - Job: The retrieved job. - - Raises: - LookupError: If the job with the specified key is not found. - - Examples: - ```python - from uipath.platform import UiPath - - sdk = UiPath() - job = sdk.jobs.retrieve(job_key="ee9327fd-237d-419e-86ef-9946b34461e3", folder_path="Shared") - ``` - """ - spec = self._retrieve_spec( - job_key=job_key, folder_key=folder_key, folder_path=folder_path - ) - try: - response = self.request( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) - return Job.model_validate(response.json()) - except EnrichedException as e: - if e.status_code == 404: - raise LookupError(f"Job with key '{job_key}' not found") from e - raise - - @resource_override(resource_type="process", resource_identifier="process_name") - @traced(name="jobs_retrieve_async", run_type="uipath") - async def retrieve_async( - self, - job_key: str, - *, - folder_key: str | None = None, - folder_path: str | None = None, - process_name: str | None = None, - ) -> Job: - """Asynchronously retrieve a job identified by its key. - - Args: - job_key (str): The job unique identifier. - folder_key (Optional[str]): The key of the folder in which the job was executed. - folder_path (Optional[str]): The path of the folder in which the job was executed. - process_name: process name hint for resource override - - Returns: - Job: The retrieved job. - - Raises: - LookupError: If the job with the specified key is not found. - - Examples: - ```python - import asyncio - - from uipath.platform import UiPath - - sdk = UiPath() - - - async def main(): # noqa: D103 - job = await sdk.jobs.retrieve_async(job_key="ee9327fd-237d-419e-86ef-9946b34461e3", folder_path="Shared") - - asyncio.run(main()) - ``` - """ - spec = self._retrieve_spec( - job_key=job_key, folder_key=folder_key, folder_path=folder_path - ) - try: - response = await self.request_async( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) - return Job.model_validate(response.json()) - except EnrichedException as e: - if e.status_code == 404: - raise LookupError(f"Job with key '{job_key}' not found") from e - raise - - def _retrieve_inbox_id( - self, - *, - job_id: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> str: - spec = self._retrieve_inbox_id_spec( - job_id=job_id, - folder_key=folder_key, - folder_path=folder_path, - ) - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - - response = response.json() - return self._extract_first_inbox_id(response) - - async def _retrieve_inbox_id_async( - self, - *, - job_id: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> str: - spec = self._retrieve_inbox_id_spec( - job_id=job_id, - folder_key=folder_key, - folder_path=folder_path, - ) - response = await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - - response = response.json() - return self._extract_first_inbox_id(response) - - def retrieve_api_payload(self, inbox_id: str) -> Any: - """Fetch payload data for API triggers. - - Args: - inbox_id: The Id of the inbox to fetch the payload for. - - Returns: - The value field from the API response payload. - """ - spec = self._retrieve_api_payload_spec(inbox_id=inbox_id) - - response = self.request( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) - - data = response.json() - return data.get("payload") - - async def retrieve_api_payload_async(self, inbox_id: str) -> Any: - """Asynchronously fetch payload data for API triggers. - - Args: - inbox_id: The Id of the inbox to fetch the payload for. - - Returns: - The value field from the API response payload. - """ - spec = self._retrieve_api_payload_spec(inbox_id=inbox_id) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - headers=spec.headers, - ) - - data = response.json() - return data.get("payload") - - def _retrieve_api_payload_spec( - self, - *, - inbox_id: str, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}"), - headers={ - **self.folder_headers, - }, - ) - - def _extract_first_inbox_id(self, response: Any) -> str: - if len(response["value"]) > 0: - return response["value"][0]["ItemKey"] - else: - raise LookupError("No inbox found") - - def _retrieve_inbox_id_spec( - self, - *, - job_id: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/odata/JobTriggers"), - params={ - "$filter": f"JobId eq {job_id}", - "$top": 1, - "$select": "ItemKey", - }, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def extract_output(self, job: Job) -> Optional[str]: - """Get the actual output data, downloading from attachment if necessary. - - Args: - job: The job instance to fetch output data from. - - Returns: - Parsed output arguments as dictionary, or None if no output - """ - if job.output_file: - # Large output stored as attachment - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) / f"output_{job.output_file}" - self._attachments_service.download( - key=uuid.UUID(job.output_file), destination_path=temp_path - ) - with open(temp_path, "r", encoding="utf-8") as f: - return f.read() - elif job.output_arguments: - # Small output stored inline - return job.output_arguments - else: - return None - - async def extract_output_async(self, job: Job) -> Optional[str]: - """Asynchronously fetch the actual output data, downloading from attachment if necessary. - - Args: - job: The job instance to fetch output data from. - - Returns: - Parsed output arguments as dictionary, or None if no output - """ - if job.output_file: - # Large output stored as attachment - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) / f"output_{job.output_file}" - await self._attachments_service.download_async( - key=uuid.UUID(job.output_file), destination_path=temp_path - ) - with open(temp_path, "r", encoding="utf-8") as f: - return f.read() - elif job.output_arguments: - # Small output stored inline - return job.output_arguments - else: - return None - - def _resume_spec( - self, - *, - inbox_id: str, - payload: Any = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" - ), - json={"payload": payload}, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _retrieve_spec( - self, - *, - job_key: str, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint( - f"/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})" - ), - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - @traced(name="jobs_list_attachments", run_type="uipath") - def list_attachments( - self, - *, - job_key: uuid.UUID, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> List[str]: - """List attachments associated with a specific job. - - This method retrieves all attachments linked to a job by its key. - - Args: - job_key (uuid.UUID): The key of the job to retrieve attachments for. - folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. - - Returns: - List[str]: A list of attachment IDs associated with the job. - - Raises: - Exception: If the retrieval fails. - """ - spec = self._list_job_attachments_spec( - job_key=job_key, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - return [item.get("attachmentId") for item in response] - - @traced(name="jobs_list_attachments", run_type="uipath") - async def list_attachments_async( - self, - *, - job_key: uuid.UUID, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> List[str]: - """List attachments associated with a specific job asynchronously. - - This method asynchronously retrieves all attachments linked to a job by its key. - - Args: - job_key (uuid.UUID): The key of the job to retrieve attachments for. - folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. - - Returns: - List[str]: A list of attachment IDs associated with the job. - - Raises: - Exception: If the retrieval fails. - - Examples: - ```python - import asyncio - from uipath.platform import UiPath - - client = UiPath() - - async def main(): - attachments = await client.jobs.list_attachments_async( - job_key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - ) - for attachment_id in attachments: - print(f"Attachment ID: {attachment_id}") - ``` - """ - spec = self._list_job_attachments_spec( - job_key=job_key, - folder_key=folder_key, - folder_path=folder_path, - ) - - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - return [item.get("attachmentId") for item in response] - - @traced(name="jobs_link_attachment", run_type="uipath") - def link_attachment( - self, - *, - attachment_key: uuid.UUID, - job_key: uuid.UUID, - category: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ): - """Link an attachment to a job. - - This method links an existing attachment to a specific job. - - Args: - attachment_key (uuid.UUID): The key of the attachment to link. - job_key (uuid.UUID): The key of the job to link the attachment to. - category (Optional[str]): Optional category for the attachment in the context of this job. - folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. - - Raises: - Exception: If the link operation fails. - """ - spec = self._link_job_attachment_spec( - attachment_key=attachment_key, - job_key=job_key, - category=category, - folder_key=folder_key, - folder_path=folder_path, - ) - self.request( - spec.method, - url=spec.endpoint, - headers=spec.headers, - json=spec.json, - ) - - @traced(name="jobs_link_attachment", run_type="uipath") - async def link_attachment_async( - self, - *, - attachment_key: uuid.UUID, - job_key: uuid.UUID, - category: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ): - """Link an attachment to a job asynchronously. - - This method asynchronously links an existing attachment to a specific job. - - Args: - attachment_key (uuid.UUID): The key of the attachment to link. - job_key (uuid.UUID): The key of the job to link the attachment to. - category (Optional[str]): Optional category for the attachment in the context of this job. - folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. - - Raises: - Exception: If the link operation fails. - """ - spec = self._link_job_attachment_spec( - attachment_key=attachment_key, - job_key=job_key, - category=category, - folder_key=folder_key, - folder_path=folder_path, - ) - await self.request_async( - spec.method, - url=spec.endpoint, - headers=spec.headers, - json=spec.json, - ) - - def _list_job_attachments_spec( - self, - job_key: uuid.UUID, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/api/JobAttachments/GetByJobKey"), - params={ - "jobKey": job_key, - }, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - def _link_job_attachment_spec( - self, - attachment_key: uuid.UUID, - job_key: uuid.UUID, - category: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint("/orchestrator_/api/JobAttachments/Post"), - json={ - "attachmentId": str(attachment_key), - "jobKey": str(job_key), - "category": category, - }, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - - @traced(name="jobs_create_attachment", run_type="uipath") - def create_attachment( - self, - *, - name: str, - content: Optional[Union[str, bytes]] = None, - source_path: Optional[Union[str, Path]] = None, - job_key: Optional[Union[str, uuid.UUID]] = None, - category: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> uuid.UUID: - """Create and upload an attachment, optionally linking it to a job. - - This method handles creating an attachment from a file or memory data. - If a job key is provided or available in the execution context, the attachment - will be created in UiPath and linked to the job. If no job is available, - the file will be saved to a temporary storage folder. - - Note: - The local storage functionality (when no job is available) is intended for - local development and debugging purposes only. - - Args: - name (str): The name of the attachment file. - content (Optional[Union[str, bytes]]): The content to upload (string or bytes). - source_path (Optional[Union[str, Path]]): The local path of the file to upload. - job_key (Optional[Union[str, uuid.UUID]]): The key of the job to link the attachment to. - category (Optional[str]): Optional category for the attachment in the context of the job. - folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. - - Returns: - uuid.UUID: The unique identifier for the created attachment, regardless of whether it was - uploaded to UiPath or stored locally. - - Raises: - ValueError: If neither content nor source_path is provided, or if both are provided. - Exception: If the upload fails. - - Examples: - ```python - from uipath.platform import UiPath - - client = UiPath() - - # Create attachment from file and link to job - attachment_id = client.jobs.create_attachment( - name="document.pdf", - source_path="path/to/local/document.pdf", - job_key="38073051" - ) - print(f"Created and linked attachment: {attachment_id}") - - # Create attachment from memory content (no job available - saves to temp storage) - attachment_id = client.jobs.create_attachment( - name="report.txt", - content="This is a text report" - ) - print(f"Created attachment: {attachment_id}") - ``` - """ - # Validate input parameters - if not (content or source_path): - raise ValueError("Content or source_path is required") - if content and source_path: - raise ValueError("Content and source_path are mutually exclusive") - - # Get job key from context if not explicitly provided - context_job_key = None - if job_key is None: - try: - context_job_key = self._execution_context.instance_key - except ValueError: - # Instance key is not set in environment - context_job_key = None - - # Check if a job is available - if job_key is not None or context_job_key is not None: - # Job is available - create attachment in UiPath and link to job - actual_job_key = job_key if job_key is not None else context_job_key - - # Create the attachment using the attachments service - if content is not None: - attachment_key = self._attachments_service.upload( - name=name, - content=content, - folder_key=folder_key, - folder_path=folder_path, - ) - else: - # source_path must be provided due to validation check above - attachment_key = self._attachments_service.upload( - name=name, - source_path=cast(str, source_path), - folder_key=folder_key, - folder_path=folder_path, - ) - - # Convert to UUID if string - if isinstance(actual_job_key, str): - actual_job_key = uuid.UUID(actual_job_key) - - # Link attachment to job - self.link_attachment( - attachment_key=attachment_key, - job_key=cast(uuid.UUID, actual_job_key), - category=category, - folder_key=folder_key, - folder_path=folder_path, - ) - - return attachment_key - else: - # No job available - save to temp folder - # Generate a UUID to use as identifier - attachment_id = uuid.uuid4() - - # Create destination file path - dest_path = os.path.join(self._temp_dir, f"{attachment_id}_{name}") - - # If we have source_path, copy the file - if source_path is not None: - source_path_str = ( - source_path if isinstance(source_path, str) else str(source_path) - ) - shutil.copy2(source_path_str, dest_path) - # If we have content, write it to a file - elif content is not None: - # Convert string to bytes if needed - if isinstance(content, str): - content = content.encode("utf-8") - - with open(dest_path, "wb") as f: - f.write(content) - - # Return only the UUID - return attachment_id - - @traced(name="jobs_create_attachment", run_type="uipath") - async def create_attachment_async( - self, - *, - name: str, - content: Optional[Union[str, bytes]] = None, - source_path: Optional[Union[str, Path]] = None, - job_key: Optional[Union[str, uuid.UUID]] = None, - category: Optional[str] = None, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> uuid.UUID: - """Create and upload an attachment asynchronously, optionally linking it to a job. - - This method asynchronously handles creating an attachment from a file or memory data. - If a job key is provided or available in the execution context, the attachment - will be created in UiPath and linked to the job. If no job is available, - the file will be saved to a temporary storage folder. - - Note: - The local storage functionality (when no job is available) is intended for - local development and debugging purposes only. - - Args: - name (str): The name of the attachment file. - content (Optional[Union[str, bytes]]): The content to upload (string or bytes). - source_path (Optional[Union[str, Path]]): The local path of the file to upload. - job_key (Optional[Union[str, uuid.UUID]]): The key of the job to link the attachment to. - category (Optional[str]): Optional category for the attachment in the context of the job. - folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. - - Returns: - uuid.UUID: The unique identifier for the created attachment, regardless of whether it was - uploaded to UiPath or stored locally. - - Raises: - ValueError: If neither content nor source_path is provided, or if both are provided. - Exception: If the upload fails. - - Examples: - ```python - import asyncio - from uipath.platform import UiPath - - client = UiPath() - - async def main(): - # Create attachment from file and link to job - attachment_id = await client.jobs.create_attachment_async( - name="document.pdf", - source_path="path/to/local/document.pdf", - job_key="38073051" - ) - print(f"Created and linked attachment: {attachment_id}") - - # Create attachment from memory content (no job available - saves to temp storage) - attachment_id = await client.jobs.create_attachment_async( - name="report.txt", - content="This is a text report" - ) - print(f"Created attachment: {attachment_id}") - ``` - """ - # Validate input parameters - if not (content or source_path): - raise ValueError("Content or source_path is required") - if content and source_path: - raise ValueError("Content and source_path are mutually exclusive") - - # Get job key from context if not explicitly provided - context_job_key = None - if job_key is None: - try: - context_job_key = self._execution_context.instance_key - except ValueError: - # Instance key is not set in environment - context_job_key = None - - # Check if a job is available - if job_key is not None or context_job_key is not None: - # Job is available - create attachment in UiPath and link to job - actual_job_key = job_key if job_key is not None else context_job_key - - # Create the attachment using the attachments service - if content is not None: - attachment_key = await self._attachments_service.upload_async( - name=name, - content=content, - folder_key=folder_key, - folder_path=folder_path, - ) - else: - # source_path must be provided due to validation check above - attachment_key = await self._attachments_service.upload_async( - name=name, - source_path=cast(str, source_path), - folder_key=folder_key, - folder_path=folder_path, - ) - - # Convert to UUID if string - if isinstance(actual_job_key, str): - actual_job_key = uuid.UUID(actual_job_key) - - # Link attachment to job - await self.link_attachment_async( - attachment_key=attachment_key, - job_key=cast(uuid.UUID, actual_job_key), - category=category, - folder_key=folder_key, - folder_path=folder_path, - ) - - return attachment_key - else: - # No job available - save to temp folder - # Generate a UUID to use as identifier - attachment_id = uuid.uuid4() - - # Create destination file path - dest_path = os.path.join(self._temp_dir, f"{attachment_id}_{name}") - - # If we have source_path, copy the file - if source_path is not None: - source_path_str = ( - source_path if isinstance(source_path, str) else str(source_path) - ) - shutil.copy2(source_path_str, dest_path) - # If we have content, write it to a file - elif content is not None: - # Convert string to bytes if needed - if isinstance(content, str): - content = content.encode("utf-8") - - with open(dest_path, "wb") as f: - f.write(content) - - # Return only the UUID - return attachment_id - - def _list_spec( - self, - folder_path: Optional[str], - folder_key: Optional[str], - filter: Optional[str], - orderby: Optional[str], - skip: int, - top: int, - ) -> RequestSpec: - """Build OData request for listing jobs.""" - params: Dict[str, Any] = {"$skip": skip, "$top": top} - if filter: - params["$filter"] = filter - if orderby: - params["$orderby"] = orderby - - return RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/odata/Jobs"), - params=params, - headers={**header_folder(folder_key, folder_path)}, - ) - - def _stop_spec( - self, - job_ids: List[int], - strategy: str, - folder_key: Optional[str], - folder_path: Optional[str], - ) -> RequestSpec: - """Build request for stopping jobs with strategy. - - Pure function - no HTTP calls, no side effects. - - Args: - job_ids: List of job integer IDs (already resolved) - strategy: Stop strategy ("SoftStop" or "Kill") - folder_key: Folder key - folder_path: Folder path - - Returns: - RequestSpec: Request specification for StopJobs endpoint - """ - return RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs" - ), - json={ - "jobIds": job_ids, - "strategy": strategy, - }, - headers={**header_folder(folder_key, folder_path)}, - ) - - def _restart_spec( - self, - job_id: int, - folder_key: Optional[str], - folder_path: Optional[str], - ) -> RequestSpec: - """Build request for restarting a job.""" - return RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.RestartJob" - ), - json={"jobId": job_id}, - headers={**header_folder(folder_key, folder_path)}, - ) - - def _resolve_job_identifiers( - self, - job_keys: List[str], - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> List[int]: - """Resolve job keys to job IDs in chunked bulk queries. - - This method prevents N+1 query issues by fetching all job IDs using - OData $filter with 'in' operator. Requests are chunked to avoid URL - length limits (max 50 keys per request). - - Args: - job_keys: List of job UUID keys to resolve - folder_key: Folder key - folder_path: Folder path - - Returns: - List[int]: List of job integer IDs in same order as job_keys - - Raises: - ValueError: If any job key is not a valid UUID - LookupError: If any job key is not found - - Note: - Duplicate keys in input are allowed and will return corresponding IDs. - """ - if not job_keys: - return [] - - for key in job_keys: - try: - uuid.UUID(key) - except ValueError as e: - raise ValueError(f"Invalid job key format: {key}") from e - - unique_keys = [] - seen = set() - for key in job_keys: - if key not in seen: - unique_keys.append(key) - seen.add(key) - - CHUNK_SIZE = 50 - all_key_to_id: Dict[str, int] = {} - - for i in range(0, len(unique_keys), CHUNK_SIZE): - chunk = unique_keys[i : i + CHUNK_SIZE] - keys_formatted = "','".join(chunk) - filter_expr = f"Key in ('{keys_formatted}')" - - spec = RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/odata/Jobs"), - params={ - "$filter": filter_expr, - "$select": "Id,Key", - "$top": len(chunk), - }, - headers={**header_folder(folder_key, folder_path)}, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("value", []) - - # Accumulate mappings from this chunk - for item in items: - all_key_to_id[item["Key"]] = item["Id"] - - # Verify all unique keys were found - if len(all_key_to_id) != len(unique_keys): - found_keys = set(all_key_to_id.keys()) - missing_keys = set(unique_keys) - found_keys - raise LookupError(f"Jobs not found for keys: {', '.join(missing_keys)}") - - # Build result preserving original order (including duplicates) - return [all_key_to_id[key] for key in job_keys] - - async def _resolve_job_identifiers_async( - self, - job_keys: List[str], - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> List[int]: - """Async version of _resolve_job_identifiers().""" - if not job_keys: - return [] - - for key in job_keys: - try: - uuid.UUID(key) - except ValueError as e: - raise ValueError(f"Invalid job key format: {key}") from e - - unique_keys = [] - seen = set() - for key in job_keys: - if key not in seen: - unique_keys.append(key) - seen.add(key) - - CHUNK_SIZE = 50 - all_key_to_id: Dict[str, int] = {} - - for i in range(0, len(unique_keys), CHUNK_SIZE): - chunk = unique_keys[i : i + CHUNK_SIZE] - keys_formatted = "','".join(chunk) - filter_expr = f"Key in ('{keys_formatted}')" - - spec = RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/odata/Jobs"), - params={ - "$filter": filter_expr, - "$select": "Id,Key", - "$top": len(chunk), - }, - headers={**header_folder(folder_key, folder_path)}, - ) - - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("value", []) - - for item in items: - all_key_to_id[item["Key"]] = item["Id"] - - if len(all_key_to_id) != len(unique_keys): - found_keys = set(all_key_to_id.keys()) - missing_keys = set(unique_keys) - found_keys - raise LookupError(f"Jobs not found for keys: {', '.join(missing_keys)}") - - return [all_key_to_id[key] for key in job_keys] diff --git a/src/uipath/platform/orchestrator/_mcp_service.py b/src/uipath/platform/orchestrator/_mcp_service.py deleted file mode 100644 index 47e5a6925..000000000 --- a/src/uipath/platform/orchestrator/_mcp_service.py +++ /dev/null @@ -1,225 +0,0 @@ -from typing import List - -from ..._utils import Endpoint, RequestSpec, header_folder, resource_override -from ...tracing import traced -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from ._folder_service import FolderService -from .mcp import McpServer - - -class McpService(FolderContext, BaseService): - """Service for managing MCP (Model Context Protocol) servers in UiPath. - - MCP servers provide contextual information and capabilities that can be used - by AI agents and automation processes. - """ - - def __init__( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folders_service: FolderService, - ) -> None: - super().__init__(config=config, execution_context=execution_context) - self._folders_service = folders_service - - @traced(name="mcp_list", run_type="uipath") - def list( - self, - *, - folder_path: str | None = None, - ) -> List[McpServer]: - """List all MCP servers. - - Args: - folder_path (Optional[str]): The path of the folder to list servers from. - - Returns: - List[McpServer]: A list of MCP servers with their configuration. - - Examples: - ```python - from uipath import UiPath - - client = UiPath() - - servers = client.mcp.list(folder_path="MyFolder") - for server in servers: - print(f"{server.name} - {server.slug}") - ``` - """ - spec = self._list_spec( - folder_path=folder_path, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - - return [McpServer.model_validate(server) for server in response.json()] - - @traced(name="mcp_list", run_type="uipath") - async def list_async( - self, - *, - folder_path: str | None = None, - ) -> List[McpServer]: - """Asynchronously list all MCP servers. - - Args: - folder_path (Optional[str]): The path of the folder to list servers from. - - Returns: - List[McpServer]: A list of MCP servers with their configuration. - - Examples: - ```python - import asyncio - - from uipath import UiPath - - sdk = UiPath() - - async def main(): - servers = await sdk.mcp.list_async(folder_path="MyFolder") - for server in servers: - print(f"{server.name} - {server.slug}") - - asyncio.run(main()) - ``` - """ - spec = self._list_spec( - folder_path=folder_path, - ) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - - return [McpServer.model_validate(server) for server in response.json()] - - @resource_override(resource_type="mcpServer", resource_identifier="slug") - @traced(name="mcp_retrieve", run_type="uipath") - def retrieve( - self, - slug: str, - *, - folder_path: str | None = None, - ) -> McpServer: - """Retrieve a specific MCP server by its slug. - - Args: - slug (str): The unique slug identifier for the server. - folder_path (Optional[str]): The path of the folder where the server is located. - - Returns: - McpServer: The MCP server configuration. - - Examples: - ```python - from uipath import UiPath - - client = UiPath() - - server = client.mcp.retrieve(slug="my-server-slug", folder_path="MyFolder") - print(f"Server: {server.name}, URL: {server.mcp_url}") - ``` - """ - spec = self._retrieve_spec( - slug=slug, - folder_path=folder_path, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - - return McpServer.model_validate(response.json()) - - @resource_override(resource_type="mcpServer", resource_identifier="slug") - @traced(name="mcp_retrieve", run_type="uipath") - async def retrieve_async( - self, - slug: str, - *, - folder_path: str | None = None, - ) -> McpServer: - """Asynchronously retrieve a specific MCP server by its slug. - - Args: - slug (str): The unique slug identifier for the server. - folder_path (Optional[str]): The path of the folder where the server is located. - - Returns: - McpServer: The MCP server configuration. - - Examples: - ```python - import asyncio - - from uipath import UiPath - - sdk = UiPath() - - async def main(): - server = await sdk.mcp.retrieve_async(slug="my-server-slug", folder_path="MyFolder") - print(f"Server: {server.name}, URL: {server.mcp_url}") - - asyncio.run(main()) - ``` - """ - spec = self._retrieve_spec( - slug=slug, - folder_path=folder_path, - ) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - - return McpServer.model_validate(response.json()) - - @property - def custom_headers(self) -> dict[str, str]: - return self.folder_headers - - def _list_spec( - self, - *, - folder_path: str | None, - ) -> RequestSpec: - folder_key = self._folders_service.retrieve_folder_key(folder_path) - return RequestSpec( - method="GET", - endpoint=Endpoint("/agenthub_/api/servers"), - headers={ - **header_folder(folder_key, None), - }, - ) - - def _retrieve_spec( - self, - slug: str, - *, - folder_path: str | None, - ) -> RequestSpec: - folder_key = self._folders_service.retrieve_folder_key(folder_path) - return RequestSpec( - method="GET", - endpoint=Endpoint(f"/agenthub_/api/servers/{slug}"), - headers={ - **header_folder(folder_key, None), - }, - ) diff --git a/src/uipath/platform/orchestrator/_processes_service.py b/src/uipath/platform/orchestrator/_processes_service.py deleted file mode 100644 index f5d86a53f..000000000 --- a/src/uipath/platform/orchestrator/_processes_service.py +++ /dev/null @@ -1,329 +0,0 @@ -import json -import os -import uuid -from typing import Any, Dict, Optional, Union - -from opentelemetry import trace -from opentelemetry.trace import format_span_id - -from ..._utils import Endpoint, RequestSpec, header_folder, resource_override -from ..._utils.constants import ENV_JOB_KEY, HEADER_JOB_KEY -from ...tracing import traced -from ...tracing._utils import _SpanUtils -from ..attachments import Attachment -from ..common import ( - BaseService, - FolderContext, - UiPathApiConfig, - UiPathConfig, - UiPathExecutionContext, -) -from ._attachments_service import AttachmentsService -from .job import Job - - -class ProcessesService(FolderContext, BaseService): - """Service for managing and executing UiPath automation processes. - - Processes (also known as automations or workflows) are the core units of - automation in UiPath, representing sequences of activities that perform - specific business tasks. - """ - - _INPUT_ARGUMENTS_SIZE_LIMIT = 10000 - - def __init__( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - attachment_service: AttachmentsService, - ) -> None: - self._attachments_service = attachment_service - super().__init__(config=config, execution_context=execution_context) - - @resource_override(resource_type="process") - @traced(name="processes_invoke", run_type="uipath") - def invoke( - self, - name: str, - input_arguments: Optional[Dict[str, Any]] = None, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - attachments: Optional[list[Attachment]] = None, - **kwargs: Any, - ) -> Job: - """Start execution of a process by its name. - - Related Activity: [Invoke Process](https://docs.uipath.com/activities/other/latest/workflow/invoke-process) - - Args: - name (str): The name of the process to execute. - input_arguments (Optional[Dict[str, Any]]): The input arguments to pass to the process. - attachments (Optional[list]): List of Attachment objects to pass to the process. - folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - - Returns: - Job: The job execution details. - - Examples: - ```python - from uipath.platform import UiPath - - client = UiPath() - - client.processes.invoke(name="MyProcess") - ``` - - ```python - # if you want to execute the process in a specific folder - # another one than the one set in the SDK config - from uipath.platform import UiPath - - client = UiPath() - - client.processes.invoke(name="MyProcess", folder_path="my-folder-key") - ``` - """ - input_data = self._handle_input_arguments( - input_arguments=input_arguments, - attachments=attachments, - folder_key=folder_key, - folder_path=folder_path, - ) - - spec = self._invoke_spec( - name, - input_data=input_data, - folder_key=folder_key, - folder_path=folder_path, - parent_span_id=kwargs.get("parent_span_id"), - ) - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - json=spec.json, - content=spec.content, - headers=spec.headers, - ) - - return Job.model_validate(response.json()["value"][0]) - - @resource_override(resource_type="process") - @traced(name="processes_invoke", run_type="uipath") - async def invoke_async( - self, - name: str, - input_arguments: Optional[Dict[str, Any]] = None, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - attachments: Optional[list[Attachment]] = None, - **kwargs: Any, - ) -> Job: - """Asynchronously start execution of a process by its name. - - Related Activity: [Invoke Process](https://docs.uipath.com/activities/other/latest/workflow/invoke-process) - - Args: - name (str): The name of the process to execute. - input_arguments (Optional[Dict[str, Any]]): The input arguments to pass to the process. - attachments (Optional[list]): List of Attachment objects to pass to the process. - folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. - folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. - - Returns: - Job: The job execution details. - - Examples: - ```python - import asyncio - - from uipath.platform import UiPath - - sdk = UiPath() - - async def main(): - job = await sdk.processes.invoke_async("testAppAction") - print(job) - - asyncio.run(main()) - ``` - """ - input_data = await self._handle_input_arguments_async( - input_arguments=input_arguments, - attachments=attachments, - folder_key=folder_key, - folder_path=folder_path, - ) - spec = self._invoke_spec( - name, - input_data=input_data, - folder_key=folder_key, - folder_path=folder_path, - parent_span_id=kwargs.get("parent_span_id"), - ) - - response = await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - json=spec.json, - content=spec.content, - headers=spec.headers, - ) - - return Job.model_validate(response.json()["value"][0]) - - @property - def custom_headers(self) -> Dict[str, str]: - return self.folder_headers - - @staticmethod - def _prepare_link_attachments( - attachments: Optional[list[Attachment]], - ) -> Optional[list[Dict[str, str]]]: - """Format attachments for process invocation payload.""" - if not attachments: - return None - - link_attachments = [ - {"attachmentId": str(att.id)} for att in attachments if att.id is not None - ] - return link_attachments if link_attachments else None - - def _handle_input_arguments( - self, - input_arguments: Optional[Dict[str, Any]] = None, - attachments: Optional[list[Attachment]] = None, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Dict[str, Any]: - """Handle input arguments and attachments, storing as attachment if they exceed size limit. - - Args: - input_arguments: The input arguments to process - attachments: List of Attachment objects to pass to the process - folder_key: The folder key for attachment storage - folder_path: The folder path for attachment storage - - Returns: - Dict containing either "InputArguments" or "InputFile" key, and optionally "Attachments" - """ - result: Dict[str, Any] = {} - - # handle input arguments - if not input_arguments: - result["InputArguments"] = json.dumps({}) - else: - # If payload exceeds limit, store as attachment - payload_json = json.dumps(input_arguments) - if len(payload_json) > self._INPUT_ARGUMENTS_SIZE_LIMIT: - attachment_id = self._attachments_service.upload( - name=f"{uuid.uuid4()}.json", - content=payload_json, - folder_key=folder_key, - folder_path=folder_path, - ) - result["InputFile"] = str(attachment_id) - else: - result["InputArguments"] = payload_json - - link_attachments = self._prepare_link_attachments(attachments) - if link_attachments: - result["Attachments"] = link_attachments - - return result - - async def _handle_input_arguments_async( - self, - input_arguments: Optional[Dict[str, Any]] = None, - attachments: Optional[list[Attachment]] = None, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - ) -> Dict[str, Any]: - """Handle input arguments and attachments, storing as attachment if they exceed size limit. - - Args: - input_arguments: The input arguments to process - attachments: List of Attachment objects to pass to the process - folder_key: The folder key for attachment storage - folder_path: The folder path for attachment storage - - Returns: - Dict containing either "InputArguments" or "InputFile" key, and optionally "Attachments" - """ - result: Dict[str, Any] = {} - - if not input_arguments: - result["InputArguments"] = json.dumps({}) - else: - payload_json = json.dumps(input_arguments) - if len(payload_json) > self._INPUT_ARGUMENTS_SIZE_LIMIT: - attachment_id = await self._attachments_service.upload_async( - name=f"{uuid.uuid4()}.json", - content=payload_json, - folder_key=folder_key, - folder_path=folder_path, - ) - result["InputFile"] = str(attachment_id) - else: - result["InputArguments"] = payload_json - - formatted_attachments = self._prepare_link_attachments(attachments) - if formatted_attachments: - result["Attachments"] = formatted_attachments - - return result - - @staticmethod - def _add_tracing( - payload: Dict[str, Any], - trace_id: Optional[str] = None, - parent_span_id: Optional[Union[str, int]] = None, - ) -> None: - """Enrich payload with trace context for cross-process correlation.""" - if not trace_id: - return - - payload["TraceId"] = _SpanUtils.normalize_trace_id(trace_id) - if not parent_span_id: - span_context = trace.get_current_span().get_span_context() - if span_context.span_id: - parent_span_id = format_span_id(span_context.span_id) - if parent_span_id: - if isinstance(parent_span_id, int): - parent_span_id = format_span_id(parent_span_id) - payload["ParentSpanId"] = _SpanUtils.normalize_span_id(parent_span_id) - - def _invoke_spec( - self, - name: str, - input_data: Optional[Dict[str, Any]] = None, - *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, - parent_span_id: Optional[str] = None, - ) -> RequestSpec: - payload: Dict[str, Any] = {"ReleaseName": name, **(input_data or {})} - self._add_tracing(payload, UiPathConfig.trace_id, parent_span_id) - - request_spec = RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" - ), - json={"startInfo": payload}, - headers={ - **header_folder(folder_key, folder_path), - }, - ) - job_key = os.environ.get(ENV_JOB_KEY, None) - if job_key: - request_spec.headers[HEADER_JOB_KEY] = job_key - - return request_spec diff --git a/src/uipath/platform/orchestrator/_queues_service.py b/src/uipath/platform/orchestrator/_queues_service.py deleted file mode 100644 index 9f1aebdb9..000000000 --- a/src/uipath/platform/orchestrator/_queues_service.py +++ /dev/null @@ -1,352 +0,0 @@ -from typing import Any, Dict, List, Union - -from httpx import Response - -from ..._utils import Endpoint, RequestSpec -from ...tracing import traced -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from .queues import ( - CommitType, - QueueItem, - TransactionItem, - TransactionItemResult, -) - - -class QueuesService(FolderContext, BaseService): - """Service for managing UiPath queues and queue items. - - Queues are a fundamental component of UiPath automation that enable distributed - and scalable processing of work items. - """ - - def __init__( - self, config: UiPathApiConfig, execution_context: UiPathExecutionContext - ) -> None: - super().__init__(config=config, execution_context=execution_context) - - @traced(name="queues_list_items", run_type="uipath") - def list_items(self) -> Response: - """Retrieves a list of queue items from the Orchestrator. - - Returns: - Response: HTTP response containing the list of queue items. - """ - spec = self._list_items_spec() - response = self.request(spec.method, url=spec.endpoint) - - return response.json() - - @traced(name="queues_list_items", run_type="uipath") - async def list_items_async(self) -> Response: - """Asynchronously retrieves a list of queue items from the Orchestrator. - - Returns: - Response: HTTP response containing the list of queue items. - """ - spec = self._list_items_spec() - response = await self.request_async(spec.method, url=spec.endpoint) - return response.json() - - @traced(name="queues_create_item", run_type="uipath") - def create_item(self, item: Union[Dict[str, Any], QueueItem]) -> Response: - """Creates a new queue item in the Orchestrator. - - Args: - item: Queue item data, either as a dictionary or QueueItem instance. - - Returns: - Response: HTTP response containing the created queue item details. - - Related Activity: [Add Queue Item](https://docs.uipath.com/ACTIVITIES/other/latest/workflow/add-queue-item) - """ - spec = self._create_item_spec(item) - response = self.request(spec.method, url=spec.endpoint, json=spec.json) - return response.json() - - @traced(name="queues_create_item", run_type="uipath") - async def create_item_async( - self, item: Union[Dict[str, Any], QueueItem] - ) -> Response: - """Asynchronously creates a new queue item in the Orchestrator. - - Args: - item: Queue item data, either as a dictionary or QueueItem instance. - - Returns: - Response: HTTP response containing the created queue item details. - - Related Activity: [Add Queue Item](https://docs.uipath.com/ACTIVITIES/other/latest/workflow/add-queue-item) - """ - spec = self._create_item_spec(item) - response = await self.request_async( - spec.method, url=spec.endpoint, json=spec.json - ) - return response.json() - - @traced(name="queues_create_items", run_type="uipath") - def create_items( - self, - items: List[Union[Dict[str, Any], QueueItem]], - queue_name: str, - commit_type: CommitType, - ) -> Response: - """Creates multiple queue items in bulk. - - Args: - items: List of queue items to create, each either a dictionary or QueueItem instance. - queue_name: Name of the target queue. - commit_type: Type of commit operation to use for the bulk operation. - - Returns: - Response: HTTP response containing the bulk operation result. - """ - spec = self._create_items_spec(items, queue_name, commit_type) - response = self.request(spec.method, url=spec.endpoint, json=spec.json) - return response.json() - - @traced(name="queues_create_items", run_type="uipath") - async def create_items_async( - self, - items: List[Union[Dict[str, Any], QueueItem]], - queue_name: str, - commit_type: CommitType, - ) -> Response: - """Asynchronously creates multiple queue items in bulk. - - Args: - items: List of queue items to create, each either a dictionary or QueueItem instance. - queue_name: Name of the target queue. - commit_type: Type of commit operation to use for the bulk operation. - - Returns: - Response: HTTP response containing the bulk operation result. - """ - spec = self._create_items_spec(items, queue_name, commit_type) - response = await self.request_async( - spec.method, url=spec.endpoint, json=spec.json - ) - return response.json() - - @traced(name="queues_create_transaction_item", run_type="uipath") - def create_transaction_item( - self, item: Union[Dict[str, Any], TransactionItem], no_robot: bool = False - ) -> Response: - """Creates a new transaction item in a queue. - - Args: - item: Transaction item data, either as a dictionary or TransactionItem instance. - no_robot: If True, the transaction will not be associated with a robot. Defaults to False. - - Returns: - Response: HTTP response containing the transaction item details. - """ - spec = self._create_transaction_item_spec(item, no_robot) - response = self.request(spec.method, url=spec.endpoint, json=spec.json) - return response.json() - - @traced(name="queues_create_transaction_item", run_type="uipath") - async def create_transaction_item_async( - self, item: Union[Dict[str, Any], TransactionItem], no_robot: bool = False - ) -> Response: - """Asynchronously creates a new transaction item in a queue. - - Args: - item: Transaction item data, either as a dictionary or TransactionItem instance. - no_robot: If True, the transaction will not be associated with a robot. Defaults to False. - - Returns: - Response: HTTP response containing the transaction item details. - """ - spec = self._create_transaction_item_spec(item, no_robot) - response = await self.request_async( - spec.method, url=spec.endpoint, json=spec.json - ) - return response.json() - - @traced(name="queues_update_progress_of_transaction_item", run_type="uipath") - def update_progress_of_transaction_item( - self, transaction_key: str, progress: str - ) -> Response: - """Updates the progress of a transaction item. - - Args: - transaction_key: Unique identifier of the transaction. - progress: Progress message to set. - - Returns: - Response: HTTP response confirming the progress update. - - Related Activity: [Set Transaction Progress](https://docs.uipath.com/activities/other/latest/workflow/set-transaction-progress) - """ - spec = self._update_progress_of_transaction_item_spec(transaction_key, progress) - response = self.request(spec.method, url=spec.endpoint, json=spec.json) - return response.json() - - @traced(name="queues_update_progress_of_transaction_item", run_type="uipath") - async def update_progress_of_transaction_item_async( - self, transaction_key: str, progress: str - ) -> Response: - """Asynchronously updates the progress of a transaction item. - - Args: - transaction_key: Unique identifier of the transaction. - progress: Progress message to set. - - Returns: - Response: HTTP response confirming the progress update. - - Related Activity: [Set Transaction Progress](https://docs.uipath.com/activities/other/latest/workflow/set-transaction-progress) - """ - spec = self._update_progress_of_transaction_item_spec(transaction_key, progress) - response = await self.request_async( - spec.method, url=spec.endpoint, json=spec.json - ) - return response.json() - - @traced(name="queues_complete_transaction_item", run_type="uipath") - def complete_transaction_item( - self, transaction_key: str, result: Union[Dict[str, Any], TransactionItemResult] - ) -> Response: - """Completes a transaction item with the specified result. - - Args: - transaction_key: Unique identifier of the transaction to complete. - result: Result data for the transaction, either as a dictionary or TransactionItemResult instance. - - Returns: - Response: HTTP response confirming the transaction completion. - - Related Activity: [Set Transaction Status](https://docs.uipath.com/activities/other/latest/workflow/set-transaction-status) - """ - spec = self._complete_transaction_item_spec(transaction_key, result) - response = self.request(spec.method, url=spec.endpoint, json=spec.json) - return response.json() - - @traced(name="queues_complete_transaction_item", run_type="uipath") - async def complete_transaction_item_async( - self, transaction_key: str, result: Union[Dict[str, Any], TransactionItemResult] - ) -> Response: - """Asynchronously completes a transaction item with the specified result. - - Args: - transaction_key: Unique identifier of the transaction to complete. - result: Result data for the transaction, either as a dictionary or TransactionItemResult instance. - - Returns: - Response: HTTP response confirming the transaction completion. - - Related Activity: [Set Transaction Status](https://docs.uipath.com/activities/other/latest/workflow/set-transaction-status) - """ - spec = self._complete_transaction_item_spec(transaction_key, result) - response = await self.request_async( - spec.method, url=spec.endpoint, json=spec.json - ) - return response.json() - - @property - def custom_headers(self) -> Dict[str, str]: - return self.folder_headers - - def _list_items_spec(self) -> RequestSpec: - return RequestSpec( - method="GET", - endpoint=Endpoint("/orchestrator_/odata/QueueItems"), - ) - - def _create_item_spec(self, item: Union[Dict[str, Any], QueueItem]) -> RequestSpec: - if isinstance(item, dict): - queue_item = QueueItem(**item) - elif isinstance(item, QueueItem): - queue_item = item - - json_payload = { - "itemData": queue_item.model_dump(exclude_unset=True, by_alias=True) - } - - return RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" - ), - json=json_payload, - ) - - def _create_items_spec( - self, - items: List[Union[Dict[str, Any], QueueItem]], - queue_name: str, - commit_type: CommitType, - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems" - ), - json={ - "queueName": queue_name, - "commitType": commit_type.value, - "queueItems": [ - item.model_dump(exclude_unset=True, by_alias=True) - if isinstance(item, QueueItem) - else QueueItem(**item).model_dump(exclude_unset=True, by_alias=True) - for item in items - ], - }, - ) - - def _create_transaction_item_spec( - self, item: Union[Dict[str, Any], TransactionItem], no_robot: bool = False - ) -> RequestSpec: - if isinstance(item, dict): - transaction_item = TransactionItem(**item) - elif isinstance(item, TransactionItem): - transaction_item = item - - return RequestSpec( - method="POST", - endpoint=Endpoint( - "/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction" - ), - json={ - "transactionData": { - **transaction_item.model_dump(exclude_unset=True, by_alias=True), - **( - {"RobotIdentifier": self._execution_context.robot_key} - if not no_robot - else {} - ), - } - }, - ) - - def _update_progress_of_transaction_item_spec( - self, transaction_key: str, progress: str - ) -> RequestSpec: - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress" - ), - json={"progress": progress}, - ) - - def _complete_transaction_item_spec( - self, transaction_key: str, result: Union[Dict[str, Any], TransactionItemResult] - ) -> RequestSpec: - if isinstance(result, dict): - transaction_result = TransactionItemResult(**result) - elif isinstance(result, TransactionItemResult): - transaction_result = result - - return RequestSpec( - method="POST", - endpoint=Endpoint( - f"/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult" - ), - json={ - "transactionResult": transaction_result.model_dump( - exclude_unset=True, by_alias=True - ) - }, - ) diff --git a/src/uipath/platform/orchestrator/assets.py b/src/uipath/platform/orchestrator/assets.py deleted file mode 100644 index 6ee89e806..000000000 --- a/src/uipath/platform/orchestrator/assets.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Models for UiPath Orchestrator Assets.""" - -from typing import Dict, List, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class CredentialsConnectionData(BaseModel): - """Model representing connection data for credentials.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - url: str = Field(alias="Url") - body: str = Field(alias="Body") - bearer_token: str = Field(alias="BearerToken") - - -class UserAsset(BaseModel): - """Model representing a user asset.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - name: Optional[str] = Field(default=None, alias="Name") - value: Optional[str] = Field(default=None, alias="Value") - value_type: Optional[str] = Field(default=None, alias="ValueType") - string_value: Optional[str] = Field(default=None, alias="StringValue") - bool_value: Optional[bool] = Field(default=None, alias="BoolValue") - int_value: Optional[int] = Field(default=None, alias="IntValue") - credential_username: Optional[str] = Field(default=None, alias="CredentialUsername") - credential_password: Optional[str] = Field(default=None, alias="CredentialPassword") - external_name: Optional[str] = Field(default=None, alias="ExternalName") - credential_store_id: Optional[int] = Field(default=None, alias="CredentialStoreId") - key_value_list: Optional[List[Dict[str, str]]] = Field( - default=None, alias="KeyValueList" - ) - connection_data: Optional[CredentialsConnectionData] = Field( - default=None, alias="ConnectionData" - ) - id: Optional[int] = Field(default=None, alias="Id") - - -class Asset(BaseModel): - """Model representing an orchestrator asset.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - key: Optional[str] = Field(default=None, alias="Key") - description: Optional[str] = Field(default=None, alias="Description") - name: Optional[str] = Field(default=None, alias="Name") - value: Optional[str] = Field(default=None, alias="Value") - value_type: Optional[str] = Field(default=None, alias="ValueType") - string_value: Optional[str] = Field(default=None, alias="StringValue") - bool_value: Optional[bool] = Field(default=None, alias="BoolValue") - int_value: Optional[int] = Field(default=None, alias="IntValue") - credential_username: Optional[str] = Field(default=None, alias="CredentialUsername") - credential_password: Optional[str] = Field(default=None, alias="CredentialPassword") - external_name: Optional[str] = Field(default=None, alias="ExternalName") - credential_store_id: Optional[int] = Field(default=None, alias="CredentialStoreId") diff --git a/src/uipath/platform/orchestrator/attachment.py b/src/uipath/platform/orchestrator/attachment.py deleted file mode 100644 index 3091b72c6..000000000 --- a/src/uipath/platform/orchestrator/attachment.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Module defining the Attachment model for UiPath Orchestrator.""" - -import uuid -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, ConfigDict, Field, field_serializer - - -class Attachment(BaseModel): - """Model representing an attachment in UiPath. - - Attachments can be associated with jobs in UiPath and contain binary files or documents. - """ - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - @field_serializer("creation_time", "last_modification_time", when_used="json") - def serialize_datetime(self, value): - """Serialize datetime fields to ISO 8601 format for JSON output.""" - if isinstance(value, datetime): - return value.isoformat() if value else None - return value - - name: str = Field(alias="Name") - creation_time: Optional[datetime] = Field(default=None, alias="CreationTime") - last_modification_time: Optional[datetime] = Field( - default=None, alias="LastModificationTime" - ) - key: uuid.UUID = Field(alias="Key") diff --git a/src/uipath/platform/orchestrator/buckets.py b/src/uipath/platform/orchestrator/buckets.py deleted file mode 100644 index d5b552386..000000000 --- a/src/uipath/platform/orchestrator/buckets.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Models for Orchestrator Buckets API responses.""" - -from typing import Any, List, Optional - -from pydantic import AliasChoices, BaseModel, ConfigDict, Field - - -class BucketFile(BaseModel): - """Represents a file within a bucket. - - Supports both ListFiles API (lowercase fields) and GetFiles API (PascalCase fields). - """ - - model_config = ConfigDict( - populate_by_name=True, - validate_by_alias=True, - extra="allow", - ) - - full_path: str = Field( - validation_alias=AliasChoices("fullPath", "FullPath"), - description="Full path within bucket", - ) - content_type: Optional[str] = Field( - default=None, - validation_alias=AliasChoices("contentType", "ContentType"), - description="MIME type", - ) - size: int = Field( - validation_alias=AliasChoices("size", "Size"), - description="File size in bytes", - ) - last_modified: Optional[str] = Field( - default=None, - validation_alias=AliasChoices("lastModified", "LastModified"), - description="Last modification timestamp (ISO format)", - ) - is_directory: bool = Field( - default=False, - validation_alias=AliasChoices("IsDirectory", "isDirectory"), - description="Whether this entry is a directory", - ) - - @property - def path(self) -> str: - """Alias for full_path for consistency.""" - return self.full_path - - @property - def name(self) -> str: - """Extract filename from full path.""" - return ( - self.full_path.split("/")[-1] if "/" in self.full_path else self.full_path - ) - - -class Bucket(BaseModel): - """Represents a bucket in Orchestrator.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - name: str = Field(alias="Name") - description: Optional[str] = Field(default=None, alias="Description") - identifier: str = Field(alias="Identifier") - storage_provider: Optional[str] = Field(default=None, alias="StorageProvider") - storage_parameters: Optional[str] = Field(default=None, alias="StorageParameters") - storage_container: Optional[str] = Field(default=None, alias="StorageContainer") - options: Optional[str] = Field(default=None, alias="Options") - credential_store_id: Optional[str] = Field(default=None, alias="CredentialStoreId") - external_name: Optional[str] = Field(default=None, alias="ExternalName") - password: Optional[str] = Field(default=None, alias="Password") - folders_count: Optional[int] = Field(default=None, alias="FoldersCount") - encrypted: Optional[bool] = Field(default=None, alias="Encrypted") - id: Optional[int] = Field(default=None, alias="Id") - tags: Optional[List[Any]] = Field(default=None, alias="Tags") diff --git a/src/uipath/platform/orchestrator/folder.py b/src/uipath/platform/orchestrator/folder.py deleted file mode 100644 index 8d9a91c9e..000000000 --- a/src/uipath/platform/orchestrator/folder.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Models for Orchestrator Folders API responses.""" - -from pydantic import BaseModel, ConfigDict, Field - - -class PersonalWorkspace(BaseModel): - """Represents a user's personal workspace folder.""" - - model_config = ConfigDict( - populate_by_name=True, - ) - - fully_qualified_name: str = Field(alias="FullyQualifiedName") - key: str = Field(alias="Key") - id: int = Field(alias="Id") diff --git a/src/uipath/platform/orchestrator/job.py b/src/uipath/platform/orchestrator/job.py deleted file mode 100644 index 7ade631e5..000000000 --- a/src/uipath/platform/orchestrator/job.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Models for Orchestrator Jobs.""" - -from enum import Enum -from typing import Any, Dict, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class JobState(str, Enum): - """Job state enum.""" - - SUCCESSFUL = "successful" - FAULTED = "faulted" - SUSPENDED = "suspended" - RUNNING = "running" - PENDING = "pending" - RESUMED = "resumed" - - -class JobErrorInfo(BaseModel): - """Model representing job error information.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - code: Optional[str] = Field(default=None, alias="Code") - title: Optional[str] = Field(default=None, alias="Title") - detail: Optional[str] = Field(default=None, alias="Detail") - category: Optional[str] = Field(default=None, alias="Category") - status: Optional[int] = Field(default=None, alias="Status") - - -class Job(BaseModel): - """Model representing an orchestrator job.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - key: Optional[str] = Field(default=None, alias="Key") - start_time: Optional[str] = Field(default=None, alias="StartTime") - end_time: Optional[str] = Field(default=None, alias="EndTime") - # 2.3.0 change to JobState enum - state: Optional[str] = Field(default=None, alias="State") - job_priority: Optional[str] = Field(default=None, alias="JobPriority") - specific_priority_value: Optional[int] = Field( - default=None, alias="SpecificPriorityValue" - ) - robot: Optional[Dict[str, Any]] = Field(default=None, alias="Robot") - release: Optional[Dict[str, Any]] = Field(default=None, alias="Release") - resource_overwrites: Optional[str] = Field(default=None, alias="ResourceOverwrites") - source: Optional[str] = Field(default=None, alias="Source") - source_type: Optional[str] = Field(default=None, alias="SourceType") - batch_execution_key: Optional[str] = Field(default=None, alias="BatchExecutionKey") - info: Optional[str] = Field(default=None, alias="Info") - creation_time: Optional[str] = Field(default=None, alias="CreationTime") - creator_user_id: Optional[int] = Field(default=None, alias="CreatorUserId") - last_modification_time: Optional[str] = Field( - default=None, alias="LastModificationTime" - ) - last_modifier_user_id: Optional[int] = Field( - default=None, alias="LastModifierUserId" - ) - deletion_time: Optional[str] = Field(default=None, alias="DeletionTime") - deleter_user_id: Optional[int] = Field(default=None, alias="DeleterUserId") - is_deleted: Optional[bool] = Field(default=None, alias="IsDeleted") - input_arguments: Optional[str] = Field(default=None, alias="InputArguments") - input_file: Optional[str] = Field(default=None, alias="InputFile") - output_arguments: Optional[str] = Field(default=None, alias="OutputArguments") - output_file: Optional[str] = Field(default=None, alias="OutputFile") - host_machine_name: Optional[str] = Field(default=None, alias="HostMachineName") - has_errors: Optional[bool] = Field(default=None, alias="HasErrors") - has_warnings: Optional[bool] = Field(default=None, alias="HasWarnings") - job_error: Optional[JobErrorInfo] = Field(default=None, alias="JobError") - folder_key: str = Field(alias="FolderKey") - id: int = Field(alias="Id") diff --git a/src/uipath/platform/orchestrator/mcp.py b/src/uipath/platform/orchestrator/mcp.py deleted file mode 100644 index 9a811d876..000000000 --- a/src/uipath/platform/orchestrator/mcp.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Models for MCP Servers in UiPath Orchestrator.""" - -from datetime import datetime -from enum import IntEnum -from typing import Optional - -from pydantic import BaseModel, ConfigDict -from pydantic.alias_generators import to_camel - - -class McpServerType(IntEnum): - """Enumeration of MCP server types.""" - - UiPath = 0 # Processes, Agents, Activities - Command = 1 # npx, uvx - Coded = 2 # PackageType.McpServer - SelfHosted = 3 # tunnel to (externally) self-hosted server - Remote = 4 # HTTP connection to remote MCP server - ProcessAssistant = 5 # Dynamic user process assistant - - -class McpServerStatus(IntEnum): - """Enumeration of MCP server statuses.""" - - Disconnected = 0 - Connected = 1 - - -class McpServer(BaseModel): - """Model representing an MCP server in UiPath Orchestrator.""" - - model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - id: Optional[str] = None - name: Optional[str] = None - slug: Optional[str] = None - description: Optional[str] = None - version: Optional[str] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - is_active: Optional[bool] = None - type: Optional[McpServerType] = None - status: Optional[McpServerStatus] = None - command: Optional[str] = None - arguments: Optional[str] = None - environment_variables: Optional[str] = None - process_key: Optional[str] = None - folder_key: Optional[str] = None - runtimes_count: Optional[int] = None - mcp_url: Optional[str] = None diff --git a/src/uipath/platform/orchestrator/processes.py b/src/uipath/platform/orchestrator/processes.py deleted file mode 100644 index 90c9d2799..000000000 --- a/src/uipath/platform/orchestrator/processes.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Models for Orchestrator Processes.""" - -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class Process(BaseModel): - """Model representing an orchestrator process.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - key: str = Field(alias="Key") - process_key: str = Field(alias="ProcessKey") - process_version: str = Field(alias="ProcessVersion") - is_latest_version: bool = Field(alias="IsLatestVersion") - is_process_deleted: bool = Field(alias="IsProcessDeleted") - description: str = Field(alias="Description") - name: str = Field(alias="Name") - environment_variables: Optional[str] = Field( - default=None, alias="EnvironmentVariables" - ) - process_type: str = Field(alias="ProcessType") - requires_user_interaction: bool = Field(alias="RequiresUserInteraction") - is_attended: bool = Field(alias="IsAttended") - is_compiled: bool = Field(alias="IsCompiled") - feed_id: str = Field(alias="FeedId") - job_priority: str = Field(alias="JobPriority") - specific_priority_value: int = Field(alias="SpecificPriorityValue") - target_framework: str = Field(alias="TargetFramework") - id: int = Field(alias="Id") - retention_action: str = Field(alias="RetentionAction") - retention_period: int = Field(alias="RetentionPeriod") - stale_retention_action: str = Field(alias="StaleRetentionAction") - stale_retention_period: int = Field(alias="StaleRetentionPeriod") - arguments: Optional[Dict[str, Optional[Any]]] = Field( - default=None, alias="Arguments" - ) - tags: List[str] = Field(alias="Tags") - environment: Optional[str] = Field(default=None, alias="Environment") - current_version: Optional[Dict[str, Any]] = Field( - default=None, alias="CurrentVersion" - ) - entry_point: Optional[Dict[str, Any]] = Field(default=None, alias="EntryPoint") diff --git a/src/uipath/platform/orchestrator/queues.py b/src/uipath/platform/orchestrator/queues.py deleted file mode 100644 index 3acd4a79b..000000000 --- a/src/uipath/platform/orchestrator/queues.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Models for Orchestrator Queues API.""" - -from datetime import datetime -from enum import Enum -from typing import Any, Dict, Optional - -from pydantic import BaseModel, ConfigDict, Field, field_serializer -from typing_extensions import Annotated - - -class QueueItemPriority(Enum): - """Enumeration for Queue Item Priority levels.""" - - LOW = "Low" - NORMAL = "Normal" - HIGH = "High" - - -class CommitType(Enum): - """Enumeration for Commit Types in batch processing.""" - - ALL_OR_NOTHING = "AllOrNothing" - STOP_ON_FIRST_FAILURE = "StopOnFirstFailure" - PROCESS_ALL_INDEPENDENTLY = "ProcessAllIndependently" - - -class QueueItem(BaseModel): - """Model representing an item in an Orchestrator queue.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - @field_serializer("defer_date", "due_date", "risk_sla_date", when_used="json") - def serialize_datetime(self, value): - """Serialize datetime fields to ISO 8601 format for JSON output.""" - if isinstance(value, datetime): - return value.isoformat() if value else None - return value - - name: str = Field( - description="The name of the queue into which the item will be added.", - alias="Name", - ) - priority: Optional[QueueItemPriority] = Field( - default=None, - description="Sets the processing importance for a given item.", - alias="Priority", - ) - specific_content: Optional[Dict[str, Any]] = Field( - default=None, - description="A collection of key value pairs containing custom data configured in the Add Queue Item activity, in UiPath Studio.", - alias="SpecificContent", - ) - defer_date: Optional[datetime] = Field( - default=None, - description="The earliest date and time at which the item is available for processing. If empty the item can be processed as soon as possible.", - alias="DeferDate", - ) - due_date: Optional[datetime] = Field( - default=None, - description="The latest date and time at which the item should be processed. If empty the item can be processed at any given time.", - alias="DueDate", - ) - risk_sla_date: Optional[datetime] = Field( - default=None, - description="The RiskSla date at time which is considered as risk zone for the item to be processed.", - alias="RiskSlaDate", - ) - progress: Optional[str] = Field( - default=None, - description="String field which is used to keep track of the business flow progress.", - alias="Progress", - ) - source: Optional[ - Annotated[str, Field(min_length=0, strict=True, max_length=20)] - ] = Field(default=None, description="The Source type of the item.", alias="Source") - parent_operation_id: Optional[ - Annotated[str, Field(min_length=0, strict=True, max_length=128)] - ] = Field( - default=None, - description="Operation id which started the job.", - alias="ParentOperationId", - ) - reference: Optional[ - Annotated[str, Field(min_length=0, strict=True, max_length=128)] - ] = Field( - default=None, - description="An optional, user-specified value for queue item identification.", - alias="Reference", - ) - - -class TransactionItem(BaseModel): - """Model representing a transaction item in an Orchestrator queue.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - @field_serializer("defer_date", "due_date", when_used="json") - def serialize_datetime(self, value): - """Serialize datetime fields to ISO 8601 format for JSON output.""" - if isinstance(value, datetime): - return value.isoformat() if value else None - return value - - name: str = Field( - description="The name of the queue in which to search for the next item or in which to insert the item before marking it as InProgress and sending it to the robot.", - alias="Name", - ) - robot_identifier: Optional[str] = Field( - default=None, - description="The unique key identifying the robot that sent the request.", - alias="RobotIdentifier", - ) - specific_content: Optional[Dict[str, Any]] = Field( - default=None, - description="If not null a new item will be added to the queue with this content before being moved to InProgress state and returned to the robot for processing. If null the next available item in the list will be moved to InProgress state and returned to the robot for processing.", - alias="SpecificContent", - ) - defer_date: Optional[datetime] = Field( - default=None, - description="The earliest date and time at which the item is available for processing. If empty the item can be processed as soon as possible.", - alias="DeferDate", - ) - due_date: Optional[datetime] = Field( - default=None, - description="The latest date and time at which the item should be processed. If empty the item can be processed at any given time.", - alias="DueDate", - ) - parent_operation_id: Optional[ - Annotated[str, Field(min_length=0, strict=True, max_length=128)] - ] = Field( - default=None, - description="Operation id which created the queue item.", - alias="ParentOperationId", - ) - - -class TransactionItemResult(BaseModel): - """Model representing the result of processing a transaction item in an Orchestrator queue.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - use_enum_values=True, - arbitrary_types_allowed=True, - extra="allow", - ) - - @field_serializer("defer_date", "due_date", when_used="json") - def serialize_datetime(self, value): - """Serialize datetime fields to ISO 8601 format for JSON output.""" - if isinstance(value, datetime): - return value.isoformat() if value else None - return value - - is_successful: Optional[bool] = Field( - default=None, - description="States if the processing was successful or not.", - alias="IsSuccessful", - ) - processing_exception: Optional[Any] = Field( - default=None, alias="ProcessingException" - ) - defer_date: Optional[datetime] = Field( - default=None, - description="The earliest date and time at which the item is available for processing. If empty the item can be processed as soon as possible.", - alias="DeferDate", - ) - due_date: Optional[datetime] = Field( - default=None, - description="The latest date and time at which the item should be processed. If empty the item can be processed at any given time.", - alias="DueDate", - ) - output: Optional[Dict[str, Any]] = Field( - default=None, - description="A collection of key value pairs containing custom data resulted after successful processing.", - alias="Output", - ) - analytics: Optional[Dict[str, Any]] = Field( - default=None, - description="A collection of key value pairs containing custom data for further analytics processing.", - alias="Analytics", - ) - progress: Optional[str] = Field( - default=None, - description="String field which is used to keep track of the business flow progress.", - alias="Progress", - ) - operation_id: Optional[Annotated[str, Field(strict=True, max_length=128)]] = Field( - default=None, - description="The operation id which finished the queue item. Will be saved only if queue item is in final state", - alias="OperationId", - ) diff --git a/src/uipath/platform/resource_catalog/__init__.py b/src/uipath/platform/resource_catalog/__init__.py deleted file mode 100644 index 227db5439..000000000 --- a/src/uipath/platform/resource_catalog/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""UiPath Resource Catalog Models. - -This module contains models related to UiPath Resource Catalog service. -""" - -from ._resource_catalog_service import ResourceCatalogService -from .resource_catalog import Folder, Resource, ResourceType, Tag - -__all__ = [ - "ResourceCatalogService", - "Folder", - "Resource", - "ResourceType", - "Tag", -] diff --git a/src/uipath/platform/resource_catalog/_resource_catalog_service.py b/src/uipath/platform/resource_catalog/_resource_catalog_service.py deleted file mode 100644 index d98695e3c..000000000 --- a/src/uipath/platform/resource_catalog/_resource_catalog_service.py +++ /dev/null @@ -1,630 +0,0 @@ -from typing import Any, AsyncIterator, Dict, Iterator, List, Optional - -from ..._utils import Endpoint, RequestSpec, header_folder -from ...tracing import traced -from ..common import BaseService, FolderContext, UiPathApiConfig, UiPathExecutionContext -from ..orchestrator._folder_service import FolderService -from .resource_catalog import Resource, ResourceType - - -class ResourceCatalogService(FolderContext, BaseService): - """Service for searching and discovering UiPath resources across folders. - - The Resource Catalog Service provides a centralized way to search and retrieve - UiPath resources (assets, queues, processes, storage buckets, etc.) across - tenant and folder scopes. It enables programmatic discovery of resources with - flexible filtering by resource type, name, and folder location. - - See Also: - https://docs.uipath.com/orchestrator/standalone/2024.10/user-guide/about-resource-catalog-service - - !!! info "Version Availability" - This service is available starting from **uipath** version **2.1.168**. - """ - - _DEFAULT_PAGE_SIZE = 20 - - def __init__( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folder_service: FolderService, - ) -> None: - self.folder_service = folder_service - super().__init__(config=config, execution_context=execution_context) - - @traced(name="resource_catalog_search", run_type="uipath") - def search( - self, - *, - name: Optional[str] = None, - resource_types: Optional[List[ResourceType]] = None, - resource_sub_types: Optional[List[str]] = None, - page_size: int = _DEFAULT_PAGE_SIZE, - ) -> Iterator[Resource]: - """Search for tenant scoped resources and folder scoped resources (accessible to the user). - - This method automatically handles pagination and yields resources one by one. - - Args: - name: Optional name filter for resources - resource_types: Optional list of resource types to filter by - resource_sub_types: Optional list of resource subtypes to filter by - page_size: Number of resources to fetch per API call (default: 20, max: 100) - - Yields: - Resource: Each resource matching the search criteria - - Examples: - >>> # Search for all resources with "invoice" in the name - >>> for resource in uipath.resource_catalog.search(name="invoice"): - ... print(f"{resource.name}: {resource.resource_type}") - - >>> # Search for specific resource types - >>> for resource in uipath.resource_catalog.search( - ... resource_types=[ResourceType.ASSET] - ... ): - ... print(resource.name) - """ - skip = 0 - take = min(page_size, 100) - - while True: - spec = self._search_spec( - name=name, - resource_types=resource_types, - resource_sub_types=resource_sub_types, - skip=skip, - take=take, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("value", []) - - if not items: - break - - for item in items: - yield Resource.model_validate(item) - - if len(items) < take: - break - - skip += take - - @traced(name="resource_catalog_search", run_type="uipath") - async def search_async( - self, - *, - name: Optional[str] = None, - resource_types: Optional[List[ResourceType]] = None, - resource_sub_types: Optional[List[str]] = None, - page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: - """Asynchronously search for tenant scoped resources and folder scoped resources (accessible to the user). - - This method automatically handles pagination and yields resources one by one. - - Args: - name: Optional name filter for resources - resource_types: Optional list of resource types to filter by - resource_sub_types: Optional list of resource subtypes to filter by - page_size: Number of resources to fetch per API call (default: 20, max: 100) - - Yields: - Resource: Each resource matching the search criteria - - Examples: - >>> # Search for all resources with "invoice" in the name - >>> async for resource in uipath.resource_catalog.search_async(name="invoice"): - ... print(f"{resource.name}: {resource.resource_type}") - - >>> # Search for specific resource types - >>> async for resource in uipath.resource_catalog.search_async( - ... resource_types=[ResourceType.ASSET] - ... ): - ... print(resource.name) - """ - skip = 0 - take = min(page_size, 100) - - while True: - spec = self._search_spec( - name=name, - resource_types=resource_types, - resource_sub_types=resource_sub_types, - skip=skip, - take=take, - ) - - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("value", []) - - if not items: - break - - for item in items: - yield Resource.model_validate(item) - - if len(items) < take: - break - - skip += take - - @traced(name="resource_catalog_list", run_type="uipath") - def list( - self, - *, - resource_types: Optional[List[ResourceType]] = None, - resource_sub_types: Optional[List[str]] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - page_size: int = _DEFAULT_PAGE_SIZE, - ) -> Iterator[Resource]: - """Get tenant scoped resources and folder scoped resources (accessible to the user). - - If no folder identifier is provided (path or key) only tenant resources will be retrieved. - This method automatically handles pagination and yields resources one by one. - - Args: - resource_types: Optional list of resource types to filter by - resource_sub_types: Optional list of resource subtypes to filter by - folder_path: Optional folder path to scope the results - folder_key: Optional folder key to scope the results - page_size: Number of resources to fetch per API call (default: 20, max: 100) - - Yields: - Resource: Each resource matching the criteria - - Examples: - >>> # Get all resources - >>> for resource in uipath.resource_catalog.list(): - ... print(f"{resource.name}: {resource.resource_type}") - - >>> # Get specific resource types - >>> assets = list(uipath.resource_catalog.list( - ... resource_types=[ResourceType.ASSET], - ... )) - - >>> # Get resources within a specific folder - >>> for resource in uipath.resource_catalog.list( - ... folder_path="/Shared/Finance", - ... resource_types=[ResourceType.ASSET], - ... resource_sub_types=["number"] - ... ): - ... print(resource.name) - """ - skip = 0 - take = min(page_size, 100) - - if take <= 0: - raise ValueError(f"page_size must be greater than 0. Got {page_size}") - - resolved_folder_key = self.folder_service.retrieve_folder_key(folder_path) - - while True: - spec = self._list_spec( - resource_types=resource_types, - resource_sub_types=resource_sub_types, - folder_key=resolved_folder_key, - skip=skip, - take=take, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("value", []) - - if not items: - break - - for item in items: - yield Resource.model_validate(item) - - if len(items) < take: - break - - skip += take - - @traced(name="resource_catalog_list", run_type="uipath") - async def list_async( - self, - *, - resource_types: Optional[List[ResourceType]] = None, - resource_sub_types: Optional[List[str]] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: - """Asynchronously get tenant scoped resources and folder scoped resources (accessible to the user). - - If no folder identifier is provided (path or key) only tenant resources will be retrieved. - This method automatically handles pagination and yields resources one by one. - - Args: - resource_types: Optional list of resource types to filter by - resource_sub_types: Optional list of resource subtypes to filter by - folder_path: Optional folder path to scope the results - folder_key: Optional folder key to scope the results - page_size: Number of resources to fetch per API call (default: 20, max: 100) - - Yields: - Resource: Each resource matching the criteria - - Examples: - >>> # Get all resources - >>> async for resource in uipath.resource_catalog.list_async(): - ... print(f"{resource.name}: {resource.resource_type}") - - >>> # Get specific resource types - >>> assets = [] - >>> async for resource in uipath.resource_catalog.list_async( - ... resource_types=[ResourceType.ASSET], - ... ): - ... assets.append(resource) - - >>> # Get resources within a specific folder - >>> async for resource in uipath.resource_catalog.list_async( - ... folder_path="/Shared/Finance", - ... resource_types=[ResourceType.ASSET], - ... resource_sub_types=["number"] - ... ): - ... print(resource.name) - """ - skip = 0 - take = min(page_size, 100) - - if take <= 0: - raise ValueError(f"page_size must be greater than 0. Got {page_size}") - - resolved_folder_key = await self.folder_service.retrieve_folder_key_async( - folder_path - ) - while True: - spec = self._list_spec( - resource_types=resource_types, - resource_sub_types=resource_sub_types, - folder_key=resolved_folder_key, - skip=skip, - take=take, - ) - - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("value", []) - - if not items: - break - - for item in items: - yield Resource.model_validate(item) - - if len(items) < take: - break - - skip += take - - @traced(name="list_by_type", run_type="uipath") - def list_by_type( - self, - *, - resource_type: ResourceType, - name: Optional[str] = None, - resource_sub_types: Optional[List[str]] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - page_size: int = _DEFAULT_PAGE_SIZE, - ) -> Iterator[Resource]: - """Get resources of a specific type (tenant scoped or folder scoped). - - If no folder identifier is provided (path or key) only tenant resources will be retrieved. - This method automatically handles pagination and yields resources one by one. - - Args: - resource_type: The specific resource type to filter by - name: Optional name filter for resources - resource_sub_types: Optional list of resource subtypes to filter by - folder_path: Optional folder path to scope the results - folder_key: Optional folder key to scope the results - page_size: Number of resources to fetch per API call (default: 20, max: 100) - - Yields: - Resource: Each resource matching the criteria - - Examples: - >>> # Get all assets - >>> for resource in uipath.resource_catalog.list_by_type(resource_type=ResourceType.ASSET): - ... print(f"{resource.name}: {resource.resource_sub_type}") - - >>> # Get assets with a specific name pattern - >>> assets = list(uipath.resource_catalog.list_by_type( - ... resource_type=ResourceType.ASSET, - ... name="config" - ... )) - - >>> # Get assets within a specific folder with subtype filter - >>> for resource in uipath.resource_catalog.list_by_type( - ... resource_type=ResourceType.ASSET, - ... folder_path="/Shared/Finance", - ... resource_sub_types=["number"] - ... ): - ... print(resource.name) - """ - skip = 0 - take = min(page_size, 100) - - if take <= 0: - raise ValueError(f"page_size must be greater than 0. Got {page_size}") - - resolved_folder_key = self.folder_service.retrieve_folder_key(folder_path) - - while True: - spec = self._list_by_type_spec( - resource_type=resource_type, - name=name, - resource_sub_types=resource_sub_types, - folder_key=resolved_folder_key, - skip=skip, - take=take, - ) - - response = self.request( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ).json() - - items = response.get("value", []) - - if not items: - break - - for item in items: - yield Resource.model_validate(item) - - if len(items) < take: - break - - skip += take - - @traced(name="list_by_type_async", run_type="uipath") - async def list_by_type_async( - self, - *, - resource_type: ResourceType, - name: Optional[str] = None, - resource_sub_types: Optional[List[str]] = None, - folder_path: Optional[str] = None, - folder_key: Optional[str] = None, - page_size: int = _DEFAULT_PAGE_SIZE, - ) -> AsyncIterator[Resource]: - """Asynchronously get resources of a specific type (tenant scoped or folder scoped). - - If no folder identifier is provided (path or key) only tenant resources will be retrieved. - This method automatically handles pagination and yields resources one by one. - - Args: - resource_type: The specific resource type to filter by - name: Optional name filter for resources - resource_sub_types: Optional list of resource subtypes to filter by - folder_path: Optional folder path to scope the results - folder_key: Optional folder key to scope the results - page_size: Number of resources to fetch per API call (default: 20, max: 100) - - Yields: - Resource: Each resource matching the criteria - - Examples: - >>> # Get all assets asynchronously - >>> async for resource in uipath.resource_catalog.list_by_type_async(resource_type=ResourceType.ASSET): - ... print(f"{resource.name}: {resource.resource_sub_type}") - - >>> # Get assets with a specific name pattern - >>> assets = [] - >>> async for resource in uipath.resource_catalog.list_by_type_async( - ... resource_type=ResourceType.ASSET, - ... name="config" - ... ): - ... assets.append(resource) - - >>> # Get assets within a specific folder with subtype filter - >>> async for resource in uipath.resource_catalog.list_by_type_async( - ... resource_type=ResourceType.ASSET, - ... folder_path="/Shared/Finance", - ... resource_sub_types=["number"] - ... ): - ... print(resource.name) - """ - skip = 0 - take = min(page_size, 100) - - if take <= 0: - raise ValueError(f"page_size must be greater than 0. Got {page_size}") - - resolved_folder_key = await self.folder_service.retrieve_folder_key_async( - folder_path - ) - - while True: - spec = self._list_by_type_spec( - resource_type=resource_type, - name=name, - resource_sub_types=resource_sub_types, - folder_key=resolved_folder_key, - skip=skip, - take=take, - ) - - response = ( - await self.request_async( - spec.method, - url=spec.endpoint, - params=spec.params, - headers=spec.headers, - ) - ).json() - - items = response.get("value", []) - - if not items: - break - - for item in items: - yield Resource.model_validate(item) - - if len(items) < take: - break - - skip += take - - def _search_spec( - self, - name: Optional[str], - resource_types: Optional[List[ResourceType]], - resource_sub_types: Optional[List[str]], - skip: int, - take: int, - ) -> RequestSpec: - """Build the request specification for searching resources. - - Args: - name: Optional name filter - resource_types: Optional resource types filter - resource_sub_types: Optional resource subtypes filter - skip: Number of resources to skip (for pagination) - take: Number of resources to take - - Returns: - RequestSpec: The request specification for the API call - """ - params: Dict[str, Any] = { - "skip": skip, - "take": take, - } - - if name: - params["name"] = name - - if resource_types: - params["entityTypes"] = [x.value for x in resource_types] - - if resource_sub_types: - params["entitySubType"] = resource_sub_types - - return RequestSpec( - method="GET", - endpoint=Endpoint("resourcecatalog_/Entities/Search"), - params=params, - ) - - def _list_spec( - self, - resource_types: Optional[List[ResourceType]], - resource_sub_types: Optional[List[str]], - folder_key: Optional[str], - skip: int, - take: int, - ) -> RequestSpec: - """Build the request specification for getting resources. - - Args: - resource_types: Optional resource types filter - resource_sub_types: Optional resource subtypes filter - folder_key: Optional folder key to scope the results - skip: Number of resources to skip (for pagination) - take: Number of resources to take - - Returns: - RequestSpec: The request specification for the API call - """ - params: Dict[str, Any] = { - "skip": skip, - "take": take, - } - - if resource_types: - params["entityTypes"] = [x.value for x in resource_types] - - if resource_sub_types: - params["entitySubType"] = resource_sub_types - - headers = { - **header_folder(folder_key, None), - } - - return RequestSpec( - method="GET", - endpoint=Endpoint("resourcecatalog_/Entities"), - params=params, - headers=headers, - ) - - def _list_by_type_spec( - self, - resource_type: ResourceType, - name: Optional[str], - resource_sub_types: Optional[List[str]], - folder_key: Optional[str], - skip: int, - take: int, - ) -> RequestSpec: - """Build the request specification for getting resources. - - Args: - resource_type: Resource type - resource_sub_types: Optional resource subtypes filter - folder_key: Optional folder key to scope the results - skip: Number of resources to skip (for pagination) - take: Number of resources to take - - Returns: - RequestSpec: The request specification for the API call - """ - params: Dict[str, Any] = { - "skip": skip, - "take": take, - } - - if name: - params["name"] = name - - if resource_sub_types: - params["entitySubType"] = resource_sub_types - - headers = { - **header_folder(folder_key, None), - } - - return RequestSpec( - method="GET", - endpoint=Endpoint(f"resourcecatalog_/Entities/{resource_type.value}"), - params=params, - headers=headers, - ) diff --git a/src/uipath/platform/resource_catalog/resource_catalog.py b/src/uipath/platform/resource_catalog/resource_catalog.py deleted file mode 100644 index bedf6525d..000000000 --- a/src/uipath/platform/resource_catalog/resource_catalog.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Models for Resource Catalog service.""" - -from enum import Enum -from typing import List, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class ResourceType(str, Enum): - """Resource type.""" - - ASSET = "asset" - BUCKET = "bucket" - MACHINE = "machine" - TRIGGER = "trigger" - PROCESS = "process" - PACKAGE = "package" - LIBRARY = "library" - INDEX = "index" - APP = "app" - CONNECTION = "connection" - CONNECTOR = "connector" - MCP_SERVER = "mcpserver" - QUEUE = "queue" - - @classmethod - def from_string(cls, value: str) -> "ResourceType": - """Create a ResourceType instance from a string value. - - Args: - value: String value to convert to ResourceType - - Returns: - ResourceType: The matching ResourceType enum member - - Raises: - ValueError: If the value doesn't match any ResourceType - """ - lower_value = value.lower() - for member in cls: - if member.value == lower_value: - return member - - available = ", ".join([f"'{member.value}'" for member in cls]) - raise ValueError( - f"'{value}' is not a valid ResourceType. Available options: {available}" - ) - - -class Tag(BaseModel): - """Tag model for resources.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - - key: str - display_name: str = Field(alias="displayName") - name: str - display_value: str = Field(alias="displayValue") - value: str - type: str - account_key: str = Field(alias="accountKey") - tenant_key: Optional[str] = Field(None, alias="tenantKey") - user_key: Optional[str] = Field(None, alias="userKey") - - -class Folder(BaseModel): - """Folder model for resources.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - - id: int - key: str - display_name: str = Field(alias="displayName") - code: str - fully_qualified_name: str = Field(alias="fullyQualifiedName") - timestamp: str - tenant_key: str = Field(alias="tenantKey") - account_key: str = Field(alias="accountKey") - user_key: Optional[str] = Field(None, alias="userKey") - type: str - path: str - permissions: Optional[List[str]] = Field(default_factory=list) - - -class Resource(BaseModel): - """Resource model from Resource Catalog.""" - - model_config = ConfigDict( - validate_by_name=True, - validate_by_alias=True, - ) - - resource_key: str = Field(alias="entityKey") - name: str - description: Optional[str] = None - resource_type: str = Field(alias="entityType") - tags: Optional[List[Tag]] = Field(default_factory=list) - folders: List[Folder] = Field(default_factory=list) - linked_folders_count: int = Field(0, alias="linkedFoldersCount") - source: Optional[str] = None - scope: str - search_state: str = Field(alias="searchState") - timestamp: str - folder_key: Optional[str] = Field(None, alias="folderKey") - folder_keys: List[str] = Field(default_factory=list, alias="folderKeys") - tenant_key: Optional[str] = Field(None, alias="tenantKey") - account_key: str = Field(alias="accountKey") - user_key: Optional[str] = Field(None, alias="userKey") - dependencies: Optional[list[str]] = Field(default_factory=list) - custom_data: Optional[str] = Field(None, alias="customData") - resource_sub_type: Optional[str] = Field(None, alias="entitySubType") - - -class ResourceSearchResponse(BaseModel): - """Response model for resource search API.""" - - count: int - value: List[Resource] diff --git a/src/uipath/platform/resume_triggers/__init__.py b/src/uipath/platform/resume_triggers/__init__.py deleted file mode 100644 index 47c35f5b7..000000000 --- a/src/uipath/platform/resume_triggers/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Init file for resume triggers module.""" - -from ._enums import PropertyName, TriggerMarker, is_no_content_marker -from ._protocol import ( - UiPathResumeTriggerCreator, - UiPathResumeTriggerHandler, - UiPathResumeTriggerReader, -) - -__all__ = [ - "UiPathResumeTriggerReader", - "UiPathResumeTriggerCreator", - "UiPathResumeTriggerHandler", - "PropertyName", - "TriggerMarker", - "is_no_content_marker", -] diff --git a/src/uipath/platform/resume_triggers/_enums.py b/src/uipath/platform/resume_triggers/_enums.py deleted file mode 100644 index f1a228497..000000000 --- a/src/uipath/platform/resume_triggers/_enums.py +++ /dev/null @@ -1,55 +0,0 @@ -"""UiPath resume trigger enums.""" - -from enum import Enum -from typing import Any - -from pydantic import BaseModel, Field - - -class PropertyName(str, Enum): - """UiPath trigger property names.""" - - INTERNAL = "__internal" - - -class TriggerMarker(str, Enum): - """UiPath trigger markers. - - These markers are used as properties of resume triggers objects for special handling at runtime. - """ - - NO_CONTENT = "NO_CONTENT" - - -def is_no_content_marker(value: Any) -> bool: - """Check if a value is a NO_CONTENT trigger marker (dict or string form).""" - if isinstance(value, dict): - return value.get(PropertyName.INTERNAL.value) == TriggerMarker.NO_CONTENT.value - if isinstance(value, str): - return ( - PropertyName.INTERNAL.value in value - and TriggerMarker.NO_CONTENT.value in value - ) - return False - - -class ExternalTriggerType(str, Enum): - """External trigger types.""" - - DEEP_RAG = "deepRag" - BATCH_RAG = "batchRag" - IXP_EXTRACTION = "ixpExtraction" - INDEX_INGESTION = "indexIngestion" - IXP_VS_ESCALATION = "IxpVsEscalation" - - -class ExternalTrigger(BaseModel): - """Model representing an external trigger entity.""" - - type: ExternalTriggerType - external_id: str = Field(alias="externalId") - - model_config = { - "validate_by_name": True, - "validate_by_alias": True, - } diff --git a/src/uipath/platform/resume_triggers/_protocol.py b/src/uipath/platform/resume_triggers/_protocol.py deleted file mode 100644 index c8b85ffa4..000000000 --- a/src/uipath/platform/resume_triggers/_protocol.py +++ /dev/null @@ -1,892 +0,0 @@ -"""Implementation of UiPath resume trigger protocols.""" - -import json -import os -import uuid -from typing import Any - -from uipath.core.errors import ( - ErrorCategory, - UiPathFaultedTriggerError, - UiPathPendingTriggerError, -) -from uipath.runtime import ( - UiPathApiTrigger, - UiPathResumeTrigger, - UiPathResumeTriggerName, - UiPathResumeTriggerType, -) - -from uipath._cli._utils._common import serialize_object -from uipath.platform import UiPath -from uipath.platform.action_center import Task -from uipath.platform.action_center.tasks import TaskStatus -from uipath.platform.common import ( - CreateBatchTransform, - CreateDeepRag, - CreateEscalation, - CreateTask, - DocumentExtraction, - DocumentExtractionValidation, - InvokeProcess, - UiPathConfig, - WaitBatchTransform, - WaitDeepRag, - WaitDocumentExtraction, - WaitDocumentExtractionValidation, - WaitEscalation, - WaitJob, - WaitTask, -) -from uipath.platform.common.interrupt_models import ( - CreateEphemeralIndex, - InvokeSystemAgent, - WaitEphemeralIndex, - WaitSystemAgent, -) -from uipath.platform.context_grounding import DeepRagStatus, IndexStatus -from uipath.platform.context_grounding.context_grounding_index import ( - ContextGroundingIndex, -) -from uipath.platform.errors import ( - BatchTransformNotCompleteException, - OperationNotCompleteException, -) -from uipath.platform.orchestrator.job import JobState -from uipath.platform.resume_triggers._enums import ( - ExternalTrigger, - ExternalTriggerType, - PropertyName, - TriggerMarker, -) - - -def _try_convert_to_json_format(value: str | None) -> Any: - """Attempts to parse a string as JSON and returns the parsed object or original string. - - Args: - value: The string value to attempt JSON parsing on. - - Returns: - The parsed JSON object if successful, otherwise the original string value. - """ - try: - if not value: - return None - return json.loads(value) - except json.decoder.JSONDecodeError: - return value - - -class UiPathResumeTriggerReader: - """Handles reading and retrieving Human-In-The-Loop (HITL) data from UiPath services. - - Implements UiPathResumeTriggerReaderProtocol. - """ - - def _extract_field(self, field_name: str, payload: Any) -> str | None: - """Extracts a field from the payload and returns it if it exists.""" - if not payload: - return payload - - if isinstance(payload, dict): - return payload.get(field_name) - - # 2.3.0 remove - try: - payload_dict = json.loads(payload) - return payload_dict.get(field_name) - except json.decoder.JSONDecodeError: - return None - - async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: - """Read a resume trigger and convert it to runtime-compatible input. - - This method retrieves data from UiPath services (Actions, Jobs, API) - based on the trigger type and returns it in a format that the - runtime can use to resume execution. - - Args: - trigger: The resume trigger to read - - Returns: - The data retrieved from UiPath services, ready to be used - as resume input. Format depends on trigger type: - - TASK: Task data (possibly with escalation processing) - - JOB: Job output data - - API: API payload - Returns None if no data is available. - - Raises: - UiPathRuntimeError: If reading fails, job failed, API connection failed, - trigger type is unknown, or HITL feedback retrieval failed. - """ - uipath = UiPath() - - match trigger.trigger_type: - case UiPathResumeTriggerType.TASK: - if trigger.item_key: - task: Task = await uipath.tasks.retrieve_async( - trigger.item_key, - app_folder_key=trigger.folder_key, - app_folder_path=trigger.folder_path, - app_name=self._extract_field("app_name", trigger.payload), - ) - pending_status = TaskStatus.PENDING.value - unassigned_status = TaskStatus.UNASSIGNED.value - - if task.status in (pending_status, unassigned_status): - # 2.3.0 remove (task.status will already be the enum) - current_status = ( - TaskStatus(task.status).name - if isinstance(task.status, int) - else "Unknown" - ) - raise UiPathPendingTriggerError( - ErrorCategory.SYSTEM, - f"Task is not completed yet. Current status: {current_status}", - ) - - if trigger.trigger_name == UiPathResumeTriggerName.ESCALATION: - return task - - trigger_response = task.data - if not bool(trigger_response): - # 2.3.0 change to task.status.name - assert isinstance(task.status, int) - trigger_response = { - "status": TaskStatus(task.status).name.lower(), - PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, - } - - return trigger_response - - case UiPathResumeTriggerType.JOB: - if trigger.item_key: - job = await uipath.jobs.retrieve_async( - trigger.item_key, - folder_key=trigger.folder_key, - folder_path=trigger.folder_path, - process_name=self._extract_field("name", trigger.payload), - ) - job_state = (job.state or "").lower() - successful_state = JobState.SUCCESSFUL.value - faulted_state = JobState.FAULTED.value - running_state = JobState.RUNNING.value - pending_state = JobState.PENDING.value - resumed_state = JobState.RESUMED.value - suspended_state = JobState.SUSPENDED.value - - if job_state in ( - pending_state, - running_state, - suspended_state, - resumed_state, - ): - raise UiPathPendingTriggerError( - ErrorCategory.SYSTEM, - f"Job is not finished yet. Current state: {job_state}", - ) - - if job_state != successful_state: - job_error = ( - _try_convert_to_json_format(str(job.job_error or job.info)) - or "Job error unavailable." - if job_state == faulted_state - else f"Job {job.key} is {job_state}." - ) - raise UiPathFaultedTriggerError( - ErrorCategory.USER, - f"Process did not finish successfully. Error: {job_error}", - ) - - output_data = await uipath.jobs.extract_output_async(job) - trigger_response = _try_convert_to_json_format(output_data) - - # if response is an empty dictionary, use job state as placeholder value - if isinstance(trigger_response, dict) and not bool( - trigger_response - ): - # 2.3.0 change to job_state.value - trigger_response = { - "state": job_state, - PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, - } - - return trigger_response - case UiPathResumeTriggerType.DEEP_RAG: - if trigger.item_key: - deep_rag = await uipath.context_grounding.retrieve_deep_rag_async( - trigger.item_key, - index_name=self._extract_field("index_name", trigger.payload), - ) - deep_rag_status = deep_rag.last_deep_rag_status - - if deep_rag_status in ( - DeepRagStatus.QUEUED, - DeepRagStatus.IN_PROGRESS, - ): - raise UiPathPendingTriggerError( - ErrorCategory.SYSTEM, - f"DeepRag is not finished yet. Current status: {deep_rag_status}", - ) - - if deep_rag_status != DeepRagStatus.SUCCESSFUL: - raise UiPathFaultedTriggerError( - ErrorCategory.USER, - f"DeepRag '{deep_rag.name}' did not finish successfully.", - ) - - trigger_response = deep_rag.content - - # if response is an empty dictionary, use Deep Rag state as placeholder value - if not trigger_response: - trigger_response = { - "status": deep_rag_status, - PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, - } - else: - trigger_response = trigger_response.model_dump() - trigger_response["deepRagId"] = trigger.item_key - - return trigger_response - - case UiPathResumeTriggerType.INDEX_INGESTION: - if trigger.item_key: - index = await uipath.context_grounding.retrieve_by_id_async( - trigger.item_key - ) - - ephemeral_index = ContextGroundingIndex(**index) - - ephemeral_index_status = ephemeral_index.last_ingestion_status - - if ephemeral_index_status in ( - IndexStatus.QUEUED, - IndexStatus.IN_PROGRESS, - ): - raise UiPathPendingTriggerError( - ErrorCategory.SYSTEM, - f"Index ingestion is not finished yet. Current status: {ephemeral_index_status}", - ) - - if ephemeral_index_status != IndexStatus.SUCCESSFUL: - raise UiPathFaultedTriggerError( - ErrorCategory.USER, - f"Index ingestion '{ephemeral_index.name}' did not finish successfully.", - ) - - trigger_response = ephemeral_index.model_dump() - - return trigger_response - - case UiPathResumeTriggerType.BATCH_RAG: - if trigger.item_key: - destination_path = self._extract_field( - "destination_path", trigger.payload - ) - assert destination_path is not None - try: - await uipath.context_grounding.download_batch_transform_result_async( - trigger.item_key, - destination_path, - validate_status=True, - index_name=self._extract_field( - "index_name", trigger.payload - ), - ) - except BatchTransformNotCompleteException as e: - raise UiPathPendingTriggerError( - ErrorCategory.SYSTEM, - f"{e.message}", - ) from e - - return f"Batch transform completed. Modified file available at {os.path.abspath(destination_path)}" - - case UiPathResumeTriggerType.IXP_EXTRACTION: - if trigger.item_key: - project_id = self._extract_field("project_id", trigger.payload) - tag = self._extract_field("tag", trigger.payload) - - assert project_id is not None - assert tag is not None - - try: - extraction_response = ( - await uipath.documents.retrieve_ixp_extraction_result_async( - project_id, tag, trigger.item_key - ) - ) - except OperationNotCompleteException as e: - raise UiPathPendingTriggerError( - ErrorCategory.SYSTEM, - f"{e.message}", - ) from e - - return extraction_response.model_dump() - - case UiPathResumeTriggerType.IXP_VS_ESCALATION: - if trigger.item_key: - project_id = self._extract_field("project_id", trigger.payload) - tag = self._extract_field("tag", trigger.payload) - - assert project_id is not None - assert tag is not None - try: - escalation_response = await uipath.documents.retrieve_ixp_extraction_validation_result_async( - project_id, tag, trigger.item_key - ) - except OperationNotCompleteException as e: - raise UiPathPendingTriggerError( - ErrorCategory.SYSTEM, - f"{e.message}", - ) from e - - pending_status_name = TaskStatus.PENDING.name.lower() - unassigned_status_name = TaskStatus.UNASSIGNED.name.lower() - - current_status = escalation_response.action_data["status"].lower() - if current_status in (pending_status_name, unassigned_status_name): - raise UiPathPendingTriggerError( - ErrorCategory.SYSTEM, - f"Document extraction escalation task is not completed yet. Current status: {current_status}", - ) - - return escalation_response.model_dump() - - case UiPathResumeTriggerType.API: - if trigger.api_resume and trigger.api_resume.inbox_id: - try: - return await uipath.jobs.retrieve_api_payload_async( - trigger.api_resume.inbox_id - ) - except Exception as e: - raise UiPathFaultedTriggerError( - ErrorCategory.SYSTEM, - f"Failed to get trigger payload" - f"Error fetching API trigger payload for inbox {trigger.api_resume.inbox_id}: {str(e)}", - ) from e - - case _: - raise UiPathFaultedTriggerError( - ErrorCategory.SYSTEM, - f"Unexpected trigger type received" - f"Trigger type :{type(trigger.trigger_type)} is invalid", - ) - - raise UiPathFaultedTriggerError( - ErrorCategory.SYSTEM, "Failed to receive payload from HITL action" - ) - - -class UiPathResumeTriggerCreator: - """Creates resume triggers from suspend values. - - Implements UiPathResumeTriggerCreatorProtocol. - """ - - async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: - """Create a resume trigger from a suspend value. - - This method processes the input value and creates an appropriate resume trigger - for HITL scenarios. It handles different input types: - - Tasks: Creates or references UiPath tasks with folder information - - Jobs: Invokes processes or references existing jobs with folder information - - API: Creates API triggers with generated inbox IDs - - Args: - suspend_value: The value that caused the suspension. - Can be UiPath models (CreateTask, InvokeProcess, etc.), - strings, or any other value that needs HITL processing. - - Returns: - UiPathResumeTrigger ready to be persisted - - Raises: - UiPathRuntimeError: If action/job creation fails, escalation fails, or an - unknown model type is encountered. - Exception: If any underlying UiPath service calls fail. - """ - try: - trigger_type = self._determine_trigger_type(suspend_value) - trigger_name = self._determine_trigger_name(suspend_value) - - resume_trigger = UiPathResumeTrigger( - trigger_type=trigger_type, - trigger_name=trigger_name, - payload=serialize_object(suspend_value), - ) - - match trigger_type: - case UiPathResumeTriggerType.TASK: - await self._handle_task_trigger(suspend_value, resume_trigger) - - case UiPathResumeTriggerType.JOB: - await self._handle_job_trigger(suspend_value, resume_trigger) - - case UiPathResumeTriggerType.API: - self._handle_api_trigger(suspend_value, resume_trigger) - - case UiPathResumeTriggerType.DEEP_RAG: - await self._handle_deep_rag_job_trigger( - suspend_value, resume_trigger - ) - case UiPathResumeTriggerType.INDEX_INGESTION: - await self._handle_ephemeral_index_job_trigger( - suspend_value, resume_trigger - ) - case UiPathResumeTriggerType.BATCH_RAG: - await self._handle_batch_rag_job_trigger( - suspend_value, resume_trigger - ) - case UiPathResumeTriggerType.IXP_EXTRACTION: - await self._handle_ixp_extraction_trigger( - suspend_value, resume_trigger - ) - case UiPathResumeTriggerType.IXP_VS_ESCALATION: - await self._handle_ixp_vs_escalation_trigger( - suspend_value, resume_trigger - ) - case _: - raise UiPathFaultedTriggerError( - ErrorCategory.SYSTEM, - f"Unexpected model received" - f"{type(suspend_value)} is not a valid Human-In-The-Loop model", - ) - except Exception as e: - raise UiPathFaultedTriggerError( - ErrorCategory.SYSTEM, - "Failed to create HITL action", - f"{str(e)}", - ) from e - return resume_trigger - - async def _create_external_trigger(self, external_trigger: ExternalTrigger): - """Creates an external trigger in orchestrator.""" - # only create external trigger entities for non-debug runs - if not UiPathConfig.job_key: - return - - uipath = UiPath() - await uipath.api_client.request_async( - method="POST", - url="orchestrator_/api/JobTriggers/SaveExternalTrigger", - json=external_trigger.model_dump(by_alias=True), - ) - - def _determine_trigger_type(self, value: Any) -> UiPathResumeTriggerType: - """Determines the resume trigger type based on the input value. - - Args: - value: The suspend value to analyze - - Returns: - The appropriate UiPathResumeTriggerType based on the input value type. - """ - if isinstance(value, (CreateTask, WaitTask, CreateEscalation, WaitEscalation)): - return UiPathResumeTriggerType.TASK - if isinstance( - value, (InvokeProcess, WaitJob, InvokeSystemAgent, WaitSystemAgent) - ): - return UiPathResumeTriggerType.JOB - if isinstance(value, (CreateDeepRag, WaitDeepRag)): - return UiPathResumeTriggerType.DEEP_RAG - if isinstance(value, (CreateEphemeralIndex, WaitEphemeralIndex)): - return UiPathResumeTriggerType.INDEX_INGESTION - if isinstance(value, (CreateBatchTransform, WaitBatchTransform)): - return UiPathResumeTriggerType.BATCH_RAG - if isinstance(value, (DocumentExtraction, WaitDocumentExtraction)): - return UiPathResumeTriggerType.IXP_EXTRACTION - if isinstance( - value, (DocumentExtractionValidation, WaitDocumentExtractionValidation) - ): - return UiPathResumeTriggerType.IXP_VS_ESCALATION - # default to API trigger - return UiPathResumeTriggerType.API - - def _determine_trigger_name(self, value: Any) -> UiPathResumeTriggerName: - """Determines the resume trigger name based on the input value. - - Args: - value: The suspend value to analyze - - Returns: - The appropriate UiPathResumeTriggerName based on the input value type. - """ - if isinstance(value, (CreateEscalation, WaitEscalation)): - return UiPathResumeTriggerName.ESCALATION - if isinstance(value, (CreateTask, WaitTask)): - return UiPathResumeTriggerName.TASK - if isinstance( - value, (InvokeProcess, WaitJob, InvokeSystemAgent, WaitSystemAgent) - ): - return UiPathResumeTriggerName.JOB - if isinstance(value, (CreateDeepRag, WaitDeepRag)): - return UiPathResumeTriggerName.DEEP_RAG - if isinstance(value, (CreateEphemeralIndex, WaitEphemeralIndex)): - return UiPathResumeTriggerName.INDEX_INGESTION - if isinstance(value, (CreateBatchTransform, WaitBatchTransform)): - return UiPathResumeTriggerName.BATCH_RAG - if isinstance(value, (DocumentExtraction, WaitDocumentExtraction)): - return UiPathResumeTriggerName.EXTRACTION - # default to API trigger - return UiPathResumeTriggerName.API - - async def _handle_task_trigger( - self, value: Any, resume_trigger: UiPathResumeTrigger - ) -> None: - """Handle task-type resume triggers. - - Args: - value: The suspend value (CreateTask or WaitTask) - resume_trigger: The resume trigger to populate - """ - resume_trigger.folder_path = value.app_folder_path - resume_trigger.folder_key = value.app_folder_key - - if isinstance(value, (WaitTask, WaitEscalation)): - resume_trigger.item_key = value.action.key - elif isinstance(value, (CreateTask, CreateEscalation)): - uipath = UiPath() - action = await uipath.tasks.create_async( - title=value.title, - app_name=value.app_name if value.app_name else "", - app_folder_path=value.app_folder_path if value.app_folder_path else "", - app_folder_key=value.app_folder_key if value.app_folder_key else "", - app_key=value.app_key if value.app_key else "", - assignee=value.assignee if value.assignee else "", - recipient=value.recipient if value.recipient else "", - data=value.data, - priority=value.priority, - labels=value.labels, - is_actionable_message_enabled=value.is_actionable_message_enabled, - actionable_message_metadata=value.actionable_message_metadata, - source_name=value.source_name, - ) - if not action: - raise Exception("Failed to create action") - resume_trigger.item_key = action.key - - async def _handle_deep_rag_job_trigger( - self, value: Any, resume_trigger: UiPathResumeTrigger - ) -> None: - """Handle Deep RAG resume triggers. - - Args: - value: The suspend value (CreateDeepRag or WaitDeepRag) - resume_trigger: The resume trigger to populate - """ - resume_trigger.folder_path = value.index_folder_path - resume_trigger.folder_key = value.index_folder_key - if isinstance(value, WaitDeepRag): - resume_trigger.item_key = value.deep_rag.id - elif isinstance(value, CreateDeepRag): - uipath = UiPath() - if value.is_ephemeral_index: - deep_rag = ( - await uipath.context_grounding.start_deep_rag_ephemeral_async( - name=value.name, - index_id=value.index_id, - prompt=value.prompt, - glob_pattern=value.glob_pattern, - citation_mode=value.citation_mode, - ) - ) - - else: - deep_rag = await uipath.context_grounding.start_deep_rag_async( - name=value.name, - index_name=value.index_name, - index_id=value.index_id, - prompt=value.prompt, - glob_pattern=value.glob_pattern, - citation_mode=value.citation_mode, - folder_path=value.index_folder_path, - folder_key=value.index_folder_key, - ) - if not deep_rag: - raise Exception("Failed to start deep rag") - - resume_trigger.item_key = deep_rag.id - - assert resume_trigger.item_key - await self._create_external_trigger( - ExternalTrigger( - type=ExternalTriggerType.DEEP_RAG, external_id=resume_trigger.item_key - ) - ) - - async def _handle_ephemeral_index_job_trigger( - self, value: Any, resume_trigger: UiPathResumeTrigger - ) -> None: - """Handle ephemeral index. - - Args: - value: The suspend value (CreateEphemeralIndex or WaitEphemeralIndex) - resume_trigger: The resume trigger to populate - - """ - if isinstance(value, WaitEphemeralIndex): - resume_trigger.item_key = value.index.id - elif isinstance(value, CreateEphemeralIndex): - uipath = UiPath() - ephemeral_index = ( - await uipath.context_grounding.create_ephemeral_index_async( - usage=value.usage, - attachments=value.attachments, - ) - ) - if not ephemeral_index: - raise Exception("Failed to create ephemeral index") - resume_trigger.item_key = ephemeral_index.id - - assert resume_trigger.item_key - await self._create_external_trigger( - ExternalTrigger( - type=ExternalTriggerType.INDEX_INGESTION, - external_id=resume_trigger.item_key, - ) - ) - - async def _handle_batch_rag_job_trigger( - self, value: Any, resume_trigger: UiPathResumeTrigger - ) -> None: - """Handle batch transform resume triggers. - - Args: - value: The suspend value (CreateBatchTransform or WaitBatchTransform) - resume_trigger: The resume trigger to populate - """ - resume_trigger.folder_path = value.index_folder_path - resume_trigger.folder_key = value.index_folder_key - if isinstance(value, WaitBatchTransform): - resume_trigger.item_key = value.batch_transform.id - elif isinstance(value, CreateBatchTransform): - uipath = UiPath() - if value.is_ephemeral_index: - batch_transform = await uipath.context_grounding.start_batch_transform_ephemeral_async( - name=value.name, - index_id=value.index_id, - prompt=value.prompt, - output_columns=value.output_columns, - storage_bucket_folder_path_prefix=value.storage_bucket_folder_path_prefix, - enable_web_search_grounding=value.enable_web_search_grounding, - ) - else: - batch_transform = await uipath.context_grounding.start_batch_transform_async( - name=value.name, - index_name=value.index_name, - index_id=value.index_id, - prompt=value.prompt, - output_columns=value.output_columns, - storage_bucket_folder_path_prefix=value.storage_bucket_folder_path_prefix, - enable_web_search_grounding=value.enable_web_search_grounding, - folder_path=value.index_folder_path, - folder_key=value.index_folder_key, - ) - if not batch_transform: - raise Exception("Failed to start batch transform") - - resume_trigger.item_key = batch_transform.id - - assert resume_trigger.item_key - await self._create_external_trigger( - ExternalTrigger( - type=ExternalTriggerType.BATCH_RAG, - external_id=resume_trigger.item_key, - ) - ) - - async def _handle_ixp_extraction_trigger( - self, value: Any, resume_trigger: UiPathResumeTrigger - ) -> None: - """Handle IXP Extraction resume triggers. - - Args: - value: The suspend value (DocumentExtraction or WaitDocumentExtraction) - resume_trigger: The resume trigger to populate - """ - resume_trigger.folder_path = resume_trigger.folder_key = None - if isinstance(value, WaitDocumentExtraction): - resume_trigger.item_key = value.extraction.operation_id - # add project_id and tag to the payload dict (needed when reading the trigger) - assert isinstance(resume_trigger.payload, dict) - resume_trigger.payload.setdefault("project_id", value.extraction.project_id) - resume_trigger.payload.setdefault("tag", value.extraction.tag) - elif isinstance(value, DocumentExtraction): - uipath = UiPath() - document_extraction = await uipath.documents.start_ixp_extraction_async( - project_name=value.project_name, - tag=value.tag, - file=value.file, - file_path=value.file_path, - ) - if not document_extraction: - raise Exception("Failed to start document extraction") - - resume_trigger.item_key = document_extraction.operation_id - - # add project_id and tag to the payload dict (needed when reading the trigger) - assert isinstance(resume_trigger.payload, dict) - resume_trigger.payload.setdefault( - "project_id", document_extraction.project_id - ) - resume_trigger.payload.setdefault("tag", document_extraction.tag) - - assert resume_trigger.item_key - await self._create_external_trigger( - ExternalTrigger( - type=ExternalTriggerType.IXP_EXTRACTION, - external_id=resume_trigger.item_key, - ) - ) - - async def _handle_ixp_vs_escalation_trigger( - self, value: Any, resume_trigger: UiPathResumeTrigger - ) -> None: - """Handle IXP VS Escalation resume triggers. - - Args: - value: The suspend value (DocumentExtractionValidation or WaitDocumentExtractionValidation) - resume_trigger: The resume trigger to populate - """ - resume_trigger.folder_path = resume_trigger.folder_key = None - - if isinstance(value, WaitDocumentExtractionValidation): - resume_trigger.item_key = value.extraction_validation.operation_id - - # add project_id and tag to the payload dict (needed when reading the trigger) - assert isinstance(resume_trigger.payload, dict) - resume_trigger.payload.setdefault( - "project_id", value.extraction_validation.project_id - ) - resume_trigger.payload.setdefault("tag", value.extraction_validation.tag) - elif isinstance(value, DocumentExtractionValidation): - uipath = UiPath() - extraction_validation = ( - await uipath.documents.start_ixp_extraction_validation_async( - extraction_response=value.extraction_response, - action_title=value.action_title, - action_priority=value.action_priority, - action_folder=value.action_folder, - storage_bucket_name=value.storage_bucket_name, - storage_bucket_directory_path=value.storage_bucket_directory_path, - ) - ) - if not extraction_validation: - raise Exception("Failed to start extraction validation") - - resume_trigger.item_key = extraction_validation.operation_id - - # add project_id and tag to the payload dict (needed when reading the trigger) - assert isinstance(resume_trigger.payload, dict) - resume_trigger.payload.setdefault( - "project_id", extraction_validation.project_id - ) - resume_trigger.payload.setdefault("tag", extraction_validation.tag) - - assert resume_trigger.item_key - await self._create_external_trigger( - ExternalTrigger( - type=ExternalTriggerType.IXP_VS_ESCALATION, - external_id=resume_trigger.item_key, - ) - ) - - async def _handle_job_trigger( - self, value: Any, resume_trigger: UiPathResumeTrigger - ) -> None: - """Handle job-type resume triggers. - - Args: - value: The suspend value (InvokeProcess, WaitJob, InvokeSystemAgent, WaitSystemAgent) - resume_trigger: The resume trigger to populate - """ - if isinstance(value, InvokeSystemAgent): - resume_trigger.folder_path = value.folder_path - resume_trigger.folder_key = value.folder_key - else: - resume_trigger.folder_path = value.process_folder_path - resume_trigger.folder_key = value.process_folder_key - - if isinstance(value, WaitJob): - resume_trigger.item_key = value.job.key - elif isinstance(value, WaitSystemAgent): - resume_trigger.item_key = value.job_key - elif isinstance(value, InvokeProcess): - uipath = UiPath() - job = await uipath.processes.invoke_async( - name=value.name, - input_arguments=value.input_arguments, - attachments=value.attachments, - folder_path=value.process_folder_path, - folder_key=value.process_folder_key, - ) - if not job: - raise Exception("Failed to invoke process") - resume_trigger.item_key = job.key - elif isinstance(value, InvokeSystemAgent): - uipath = UiPath() - job_key = await uipath.agenthub.invoke_system_agent_async( - agent_name=value.agent_name, - entrypoint=value.entrypoint, - input_arguments=value.input_arguments, - folder_path=value.folder_path, - folder_key=value.folder_key, - ) - if not job_key: - raise Exception("Failed to invoke system agent") - resume_trigger.item_key = job_key - - def _handle_api_trigger( - self, value: Any, resume_trigger: UiPathResumeTrigger - ) -> None: - """Handle API-type resume triggers. - - Args: - value: The suspend value - resume_trigger: The resume trigger to populate - """ - resume_trigger.api_resume = UiPathApiTrigger( - inbox_id=str(uuid.uuid4()), request=serialize_object(value) - ) - - -class UiPathResumeTriggerHandler: - """Combined handler for creating and reading resume triggers. - - Implements UiPathResumeTriggerProtocol by composing the creator and reader. - """ - - def __init__(self): - """Initialize the handler with creator and reader instances.""" - self._creator = UiPathResumeTriggerCreator() - self._reader = UiPathResumeTriggerReader() - - async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: - """Create a resume trigger from a suspend value. - - Args: - suspend_value: The value that caused the suspension. - - Returns: - UiPathResumeTrigger ready to be persisted - - Raises: - UiPathRuntimeError: If trigger creation fails - """ - return await self._creator.create_trigger(suspend_value) - - async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: - """Read a resume trigger and convert it to runtime-compatible input. - - Args: - trigger: The resume trigger to read - - Returns: - The data retrieved from UiPath services, or None if no data is available. - - Raises: - UiPathRuntimeError: If reading fails or job failed - """ - return await self._reader.read_trigger(trigger) diff --git a/src/uipath/tracing/__init__.py b/src/uipath/tracing/__init__.py index 46c71a23e..9c9da4b75 100644 --- a/src/uipath/tracing/__init__.py +++ b/src/uipath/tracing/__init__.py @@ -8,7 +8,6 @@ LlmOpsHttpExporter, SpanStatus, ) -from ._utils import AttachmentDirection, AttachmentProvider, SpanAttachment __all__ = [ "traced", @@ -16,7 +15,4 @@ "JsonLinesFileExporter", "LiveTrackingSpanProcessor", "SpanStatus", - "SpanAttachment", - "AttachmentProvider", - "AttachmentDirection", ] diff --git a/src/uipath/tracing/_otel_exporters.py b/src/uipath/tracing/_otel_exporters.py index 02fe8cdb0..15bcbad13 100644 --- a/src/uipath/tracing/_otel_exporters.py +++ b/src/uipath/tracing/_otel_exporters.py @@ -10,11 +10,10 @@ SpanExporter, SpanExportResult, ) +from uipath.core.tracing import _SpanUtils from uipath._utils._ssl_context import get_httpx_client_kwargs -from ._utils import _SpanUtils - logger = logging.getLogger(__name__) diff --git a/src/uipath/tracing/_utils.py b/src/uipath/tracing/_utils.py deleted file mode 100644 index 448648067..000000000 --- a/src/uipath/tracing/_utils.py +++ /dev/null @@ -1,401 +0,0 @@ -import inspect -import json -import logging -import os -from dataclasses import dataclass, field -from datetime import datetime -from enum import IntEnum -from os import environ as env -from typing import Any, Dict, List, Optional - -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.trace import StatusCode -from pydantic import BaseModel, ConfigDict, Field -from uipath.core.serialization import serialize_json - -logger = logging.getLogger(__name__) - -# SourceEnum.Robots = 4 (default for Python SDK / coded agents) -DEFAULT_SOURCE = 4 - - -class AttachmentProvider(IntEnum): - ORCHESTRATOR = 0 - - -class AttachmentDirection(IntEnum): - NONE = 0 - IN = 1 - OUT = 2 - - -class SpanAttachment(BaseModel): - """Represents an attachment in the UiPath tracing system.""" - - model_config = ConfigDict(populate_by_name=True, use_enum_values=True) - - id: str = Field(..., alias="id") - file_name: str = Field(..., alias="fileName") - mime_type: str = Field(..., alias="mimeType") - provider: AttachmentProvider = Field( - default=AttachmentProvider.ORCHESTRATOR, alias="provider" - ) - direction: AttachmentDirection = Field( - default=AttachmentDirection.NONE, alias="direction" - ) - - -@dataclass -class UiPathSpan: - """Represents a span in the UiPath tracing system. - - Note: attributes can be either a JSON string (backwards compatible) or a dict (optimized). - IDs are stored as OTEL hex strings (32 chars for trace_id, 16 chars for span_id/parent_id). - """ - - id: str # 16-char hex (OTEL span ID format) - trace_id: str # 32-char hex (OTEL trace ID format) - name: str - attributes: str | Dict[str, Any] # Support both str (legacy) and dict (optimized) - parent_id: Optional[str] = None # 16-char hex (OTEL span ID format) - start_time: str = field(default_factory=lambda: datetime.now().isoformat()) - end_time: str = field(default_factory=lambda: datetime.now().isoformat()) - status: int = 1 - created_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") - updated_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") - organization_id: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID", "") - ) - tenant_id: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_TENANT_ID", "") - ) - expiry_time_utc: Optional[str] = None - folder_key: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_FOLDER_KEY", "") - ) - source: int = DEFAULT_SOURCE - span_type: str = "Coded Agents" - process_key: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_PROCESS_UUID") - ) - reference_id: Optional[str] = field( - default_factory=lambda: env.get("TRACE_REFERENCE_ID") - ) - - job_key: Optional[str] = field(default_factory=lambda: env.get("UIPATH_JOB_KEY")) - - # Top-level fields for internal tracing schema - execution_type: Optional[int] = None - agent_version: Optional[str] = None - attachments: Optional[List[SpanAttachment]] = None - - def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: - """Convert the Span to a dictionary suitable for JSON serialization. - - Args: - serialize_attributes: If True and attributes is a dict, serialize to JSON string. - If False, keep attributes as-is (dict or str). - Default True for backwards compatibility. - """ - attributes_out = self.attributes - if serialize_attributes and isinstance(self.attributes, dict): - attributes_out = json.dumps(self.attributes) - - attachments_out = None - if self.attachments is not None: - attachments_out = [ - { - "Id": att.id, - "FileName": att.file_name, - "MimeType": att.mime_type, - "Provider": int(att.provider), - "Direction": int(att.direction), - } - for att in self.attachments - ] - - return { - "Id": self.id, - "TraceId": self.trace_id, - "ParentId": self.parent_id, - "Name": self.name, - "StartTime": self.start_time, - "EndTime": self.end_time, - "Attributes": attributes_out, - "Status": self.status, - "CreatedAt": self.created_at, - "UpdatedAt": self.updated_at, - "OrganizationId": self.organization_id, - "TenantId": self.tenant_id, - "ExpiryTimeUtc": self.expiry_time_utc, - "FolderKey": self.folder_key, - "Source": self.source, - "SpanType": self.span_type, - "ProcessKey": self.process_key, - "JobKey": self.job_key, - "ReferenceId": self.reference_id, - "ExecutionType": self.execution_type, - "AgentVersion": self.agent_version, - "Attachments": attachments_out, - } - - -class _SpanUtils: - @staticmethod - def normalize_trace_id(value: str) -> str: - """Normalize trace ID to 32-char OTEL hex format. - - Accepts both UUID format (with dashes) and OTEL hex format (32 chars). - Returns lowercase 32-char hex string. - """ - # Remove dashes if UUID format - normalized = value.replace("-", "").lower() - if len(normalized) != 32: - raise ValueError(f"Invalid trace ID format: {value}") - return normalized - - @staticmethod - def normalize_span_id(value: str) -> str: - """Normalize span ID to 16-char OTEL hex format. - - Accepts both UUID format (with dashes, uses last 16 hex chars) and OTEL hex format (16 chars). - Returns lowercase 16-char hex string. - """ - # Remove dashes if UUID format - normalized = value.replace("-", "").lower() - if len(normalized) == 32: - # UUID format - take last 16 chars (span ID portion) - return normalized[16:] - elif len(normalized) == 16: - return normalized - else: - raise ValueError(f"Invalid span ID format: {value}") - - @staticmethod - def otel_span_to_uipath_span( - otel_span: ReadableSpan, - custom_trace_id: Optional[str] = None, - serialize_attributes: bool = True, - ) -> UiPathSpan: - """Convert an OpenTelemetry span to a UiPathSpan. - - Args: - otel_span: The OpenTelemetry span to convert - custom_trace_id: Optional custom trace ID to use (UUID or OTEL hex format) - serialize_attributes: If True, serialize attributes to JSON string (backwards compatible). - If False, keep as dict for optimized processing. Default True. - """ - # Extract the context information from the OTel span - span_context = otel_span.get_span_context() - - # Convert to OTEL hex format (32 chars for trace_id, 16 chars for span_id) - trace_id = format(span_context.trace_id, "032x") - span_id = format(span_context.span_id, "016x") - - # Override trace_id if custom or env var provided (supports both UUID and hex format) - trace_id_override = custom_trace_id or os.environ.get("UIPATH_TRACE_ID") - if trace_id_override: - trace_id = _SpanUtils.normalize_trace_id(trace_id_override) - - # Get parent span ID if it exists - parent_id: Optional[str] = None - if otel_span.parent is not None: - parent_id = format(otel_span.parent.span_id, "016x") - else: - # Only set UIPATH_PARENT_SPAN_ID for root spans (spans without a parent) - parent_span_id_str = env.get("UIPATH_PARENT_SPAN_ID") - if parent_span_id_str: - parent_id = _SpanUtils.normalize_span_id(parent_span_id_str) - - # Build attributes dict efficiently - # Use the otel attributes as base - we only add new keys, don't modify existing - otel_attrs = otel_span.attributes if otel_span.attributes else {} - # Only copy if we need to modify - we'll build attributes_dict lazily - attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {} - - # Map status - status = 1 # Default to OK - if otel_span.status.status_code == StatusCode.ERROR: - status = 2 # Error - attributes_dict["error"] = otel_span.status.description - - # Process inputs - avoid redundant parsing if already parsed - original_inputs = otel_attrs.get("input", None) - if original_inputs: - if isinstance(original_inputs, str): - try: - attributes_dict["input.value"] = json.loads(original_inputs) - attributes_dict["input.mime_type"] = "application/json" - except Exception: - attributes_dict["input.value"] = original_inputs - else: - attributes_dict["input.value"] = original_inputs - - # Process outputs - avoid redundant parsing if already parsed - original_outputs = otel_attrs.get("output", None) - if original_outputs: - if isinstance(original_outputs, str): - try: - attributes_dict["output.value"] = json.loads(original_outputs) - attributes_dict["output.mime_type"] = "application/json" - except Exception: - attributes_dict["output.value"] = original_outputs - else: - attributes_dict["output.value"] = original_outputs - - # Add events as additional attributes if they exist - if otel_span.events: - events_list = [ - { - "name": event.name, - "timestamp": event.timestamp, - "attributes": dict(event.attributes) if event.attributes else {}, - } - for event in otel_span.events - ] - attributes_dict["events"] = events_list - - # Add links as additional attributes if they exist - if hasattr(otel_span, "links") and otel_span.links: - links_list = [ - { - "trace_id": link.context.trace_id, - "span_id": link.context.span_id, - "attributes": dict(link.attributes) if link.attributes else {}, - } - for link in otel_span.links - ] - attributes_dict["links"] = links_list - - span_type_value = attributes_dict.get("span_type", "OpenTelemetry") - span_type = str(span_type_value) - - # Top-level fields for internal tracing schema - execution_type = attributes_dict.get("executionType") - agent_version = attributes_dict.get("agentVersion") - reference_id = attributes_dict.get("referenceId") - - # Source: override via uipath.source attribute, else DEFAULT_SOURCE - uipath_source = attributes_dict.get("uipath.source") - source = uipath_source if isinstance(uipath_source, int) else DEFAULT_SOURCE - - attachments = None - attachments_data = attributes_dict.get("attachments") - if attachments_data: - try: - attachments_list = json.loads(attachments_data) - attachments = [ - SpanAttachment( - id=att.get("id"), - file_name=att.get("fileName", ""), - mime_type=att.get("mimeType", ""), - provider=att.get("provider", 0), - direction=att.get("direction", 0), - ) - for att in attachments_list - ] - except Exception as e: - logger.warning(f"Error processing attachments: {e}") - - # Create UiPathSpan from OpenTelemetry span - start_time = datetime.fromtimestamp( - (otel_span.start_time or 0) / 1e9 - ).isoformat() - - end_time_str = None - if otel_span.end_time is not None: - end_time_str = datetime.fromtimestamp( - (otel_span.end_time or 0) / 1e9 - ).isoformat() - else: - end_time_str = datetime.now().isoformat() - - return UiPathSpan( - id=span_id, - trace_id=trace_id, - parent_id=parent_id, - name=otel_span.name, - attributes=json.dumps(attributes_dict) - if serialize_attributes - else attributes_dict, - start_time=start_time, - end_time=end_time_str, - status=status, - span_type=span_type, - execution_type=execution_type, - agent_version=agent_version, - reference_id=reference_id, - source=source, - attachments=attachments, - ) - - @staticmethod - def format_object_for_trace_json( - input_object: Any, - ) -> str: - """Return a JSON string of inputs from the function signature.""" - return serialize_json(input_object) - - @staticmethod - def format_args_for_trace( - signature: inspect.Signature, *args: Any, **kwargs: Any - ) -> Dict[str, Any]: - try: - """Return a dictionary of inputs from the function signature.""" - # Create a parameter mapping by partially binding the arguments - - parameter_binding = signature.bind_partial(*args, **kwargs) - - # Fill in default values for any unspecified parameters - parameter_binding.apply_defaults() - - # Extract the input parameters, skipping special Python parameters - result = {} - for name, value in parameter_binding.arguments.items(): - # Skip class and instance references - if name in ("self", "cls"): - continue - - # Handle **kwargs parameters specially - param_info = signature.parameters.get(name) - if param_info and param_info.kind == inspect.Parameter.VAR_KEYWORD: - # Flatten nested kwargs directly into the result - if isinstance(value, dict): - result.update(value) - else: - # Regular parameter - result[name] = value - - return result - except Exception as e: - logger.warning( - f"Error formatting arguments for trace: {e}. Using args and kwargs directly." - ) - return {"args": args, "kwargs": kwargs} - - @staticmethod - def spans_to_llm_context(spans: list[ReadableSpan]) -> str: - """Convert spans to a formatted conversation history string suitable for LLM context. - - Includes function calls (including LLM calls) with their inputs and outputs. - """ - history = [] - for span in spans: - attributes = dict(span.attributes) if span.attributes else {} - - input_value = attributes.get("input.value") - output_value = attributes.get("output.value") - telemetry_filter = attributes.get("telemetry.filter") - - if not input_value or not output_value or telemetry_filter == "drop": - continue - - history.append(f"Function: {span.name}") - history.append(f"Input: {input_value}") - history.append(f"Output: {output_value}") - history.append("") - - if not history: - return "(empty)" - - return "\n".join(history) diff --git a/src/uipath/utils/__init__.py b/src/uipath/utils/__init__.py deleted file mode 100644 index 35b369b86..000000000 --- a/src/uipath/utils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from ._endpoints_manager import EndpointManager # noqa: D104 - -__all__ = [ - "EndpointManager", -] diff --git a/src/uipath/utils/_endpoints_manager.py b/src/uipath/utils/_endpoints_manager.py deleted file mode 100644 index 3b2e22ce0..000000000 --- a/src/uipath/utils/_endpoints_manager.py +++ /dev/null @@ -1,198 +0,0 @@ -import logging -import os -from enum import Enum - -import httpx - -from uipath._utils._ssl_context import get_httpx_client_kwargs - -loggger = logging.getLogger(__name__) - - -class UiPathEndpoints(Enum): - AH_NORMALIZED_COMPLETION_ENDPOINT = "agenthub_/llm/api/chat/completions" - AH_PASSTHROUGH_COMPLETION_ENDPOINT = "agenthub_/llm/openai/deployments/{model}/chat/completions?api-version={api_version}" - AH_EMBEDDING_ENDPOINT = ( - "agenthub_/llm/openai/deployments/{model}/embeddings?api-version={api_version}" - ) - AH_VENDOR_COMPLETION_ENDPOINT = ( - "agenthub_/llm/raw/vendor/{vendor}/model/{model}/completions" - ) - AH_CAPABILITIES_ENDPOINT = "agenthub_/llm/api/capabilities" - - OR_NORMALIZED_COMPLETION_ENDPOINT = "orchestrator_/llm/api/chat/completions" - OR_PASSTHROUGH_COMPLETION_ENDPOINT = "orchestrator_/llm/openai/deployments/{model}/chat/completions?api-version={api_version}" - OR_EMBEDDING_ENDPOINT = "orchestrator_/llm/openai/deployments/{model}/embeddings?api-version={api_version}" - OR_VENDOR_COMPLETION_ENDPOINT = ( - "orchestrator_/llm/raw/vendor/{vendor}/model/{model}/completions" - ) - OR_CAPABILITIES_ENDPOINT = "orchestrator_/llm/api/capabilities" - - -class EndpointManager: - """Manages and caches the UiPath endpoints. - This class provides functionality to determine which UiPath endpoints to use based on - the availability of AgentHub and Orchestrator. It checks for capabilities and caches - the results to avoid repeated network calls. - - The endpoint selection follows a fallback order: - 1. AgentHub (if available) - 2. Orchestrator (if available) - - Environment Variable Override: - The fallback behavior can be bypassed using the UIPATH_LLM_SERVICE environment variable: - - 'agenthub' or 'ah': Force use of AgentHub endpoints (skips capability checks) - - 'orchestrator' or 'or': Force use of Orchestrator endpoints (skips capability checks) - - Class Attributes: - _base_url (str): The base URL for UiPath services, retrieved from the UIPATH_URL - environment variable. - _agenthub_available (Optional[bool]): Cached result of AgentHub availability check. - _orchestrator_available (Optional[bool]): Cached result of Orchestrator availability check. - - Methods: - is_agenthub_available(): Checks if AgentHub is available, caching the result. - is_orchestrator_available(): Checks if Orchestrator is available, caching the result. - get_passthrough_endpoint(): Returns the appropriate passthrough completion endpoint. - get_normalized_endpoint(): Returns the appropriate normalized completion endpoint. - get_embeddings_endpoint(): Returns the appropriate embeddings endpoint. - get_vendor_endpoint(): Returns the appropriate vendor completion endpoint. - All endpoint methods automatically select the best available endpoint using the fallback order, - unless overridden by the UIPATH_LLM_SERVICE environment variable. - """ # noqa: D205 - - _base_url = os.getenv("UIPATH_URL", "") - _agenthub_available: bool | None = None - _orchestrator_available: bool | None = None - - @classmethod - def is_agenthub_available(cls) -> bool: - """Check if AgentHub is available and cache the result.""" - if cls._agenthub_available is None: - cls._agenthub_available = cls._check_agenthub() - return cls._agenthub_available - - @classmethod - def is_orchestrator_available(cls) -> bool: - """Check if Orchestrator is available and cache the result.""" - if cls._orchestrator_available is None: - cls._orchestrator_available = cls._check_orchestrator() - return cls._orchestrator_available - - @classmethod - def _check_capabilities(cls, endpoint: UiPathEndpoints, service_name: str) -> bool: - """Perform the actual check for service capabilities. - - Args: - endpoint: The capabilities endpoint to check - service_name: Human-readable service name for logging - - Returns: - bool: True if the service is available and has valid capabilities - """ - try: - with httpx.Client(**get_httpx_client_kwargs()) as http_client: - base_url = os.getenv("UIPATH_URL", "") - capabilities_url = f"{base_url.rstrip('/')}/{endpoint.value}" - loggger.debug( - f"Checking {service_name} capabilities at {capabilities_url}" - ) - response = http_client.get(capabilities_url) - - if response.status_code != 200: - return False - - capabilities = response.json() - - # Validate structure and required fields - if not isinstance(capabilities, dict) or "version" not in capabilities: - return False - - return True - - except Exception as e: - loggger.error( - f"Error checking {service_name} capabilities: {e}", exc_info=True - ) - return False - - @classmethod - def _check_agenthub(cls) -> bool: - """Perform the actual check for AgentHub capabilities.""" - return cls._check_capabilities( - UiPathEndpoints.AH_CAPABILITIES_ENDPOINT, "AgentHub" - ) - - @classmethod - def _check_orchestrator(cls) -> bool: - """Perform the actual check for Orchestrator capabilities.""" - return cls._check_capabilities( - UiPathEndpoints.OR_CAPABILITIES_ENDPOINT, "Orchestrator" - ) - - @classmethod - def _select_endpoint(cls, ah: UiPathEndpoints, orc: UiPathEndpoints) -> str: - """Select an endpoint based on UIPATH_LLM_SERVICE override or capability checks.""" - service_override = os.getenv("UIPATH_LLM_SERVICE", "").lower() - - if service_override in ("agenthub", "ah"): - return ah.value - if service_override in ("orchestrator", "or"): - return orc.value - - # Determine fallback order based on environment hints - hdens_env = os.getenv("HDENS_ENV", "").lower() - - # Default order: AgentHub -> Orchestrator - check_order = [ - ("ah", ah, cls.is_agenthub_available), - ("orc", orc, cls.is_orchestrator_available), - ] - - # Prioritize Orchestrator if HDENS_ENV is 'sf' - # Note: The default order already prioritizes AgentHub - if hdens_env == "sf": - check_order.reverse() - - # Execute fallback checks in the determined order - for _, endpoint, is_available in check_order: - if is_available(): - return endpoint.value - - url = os.getenv("UIPATH_URL", "") - if ".uipath.com" in url: - return ah.value - else: - return orc.value - - @classmethod - def get_passthrough_endpoint(cls) -> str: - """Get the passthrough completion endpoint.""" - return cls._select_endpoint( - UiPathEndpoints.AH_PASSTHROUGH_COMPLETION_ENDPOINT, - UiPathEndpoints.OR_PASSTHROUGH_COMPLETION_ENDPOINT, - ) - - @classmethod - def get_normalized_endpoint(cls) -> str: - """Get the normalized completion endpoint.""" - return cls._select_endpoint( - UiPathEndpoints.AH_NORMALIZED_COMPLETION_ENDPOINT, - UiPathEndpoints.OR_NORMALIZED_COMPLETION_ENDPOINT, - ) - - @classmethod - def get_embeddings_endpoint(cls) -> str: - """Get the embeddings endpoint.""" - return cls._select_endpoint( - UiPathEndpoints.AH_EMBEDDING_ENDPOINT, - UiPathEndpoints.OR_EMBEDDING_ENDPOINT, - ) - - @classmethod - def get_vendor_endpoint(cls) -> str: - """Get the vendor completion endpoint.""" - return cls._select_endpoint( - UiPathEndpoints.AH_VENDOR_COMPLETION_ENDPOINT, - UiPathEndpoints.OR_VENDOR_COMPLETION_ENDPOINT, - ) diff --git a/src/uipath/utils/dynamic_schema.py b/src/uipath/utils/dynamic_schema.py deleted file mode 100644 index c07f94198..000000000 --- a/src/uipath/utils/dynamic_schema.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Json schema to dynamic pydantic model.""" - -from enum import Enum -from typing import Any, Type, Union - -from pydantic import BaseModel, Field, create_model - - -def jsonschema_to_pydantic( - schema: dict[str, Any], -) -> Type[BaseModel]: - """Convert a schema dict to a pydantic model. - - Modified version of https://github.com/kreneskyp/jsonschema-pydantic to account for three unresolved issues. - 1. Support for title - 2. Better representation of optionals. - 3. Support for optional - - Args: - schema: JSON schema. - definitions: Definitions dict. Defaults to `$def`. - - Returns: Pydantic model. - """ - dynamic_type_counter = 0 - combined_model_counter = 0 - - def convert_type(prop: dict[str, Any]) -> Any: - nonlocal dynamic_type_counter, combined_model_counter - if "$ref" in prop: - # This is the full path. It will be updated in update_forward_refs. - return prop["$ref"].split("/")[-1].capitalize() - - if "type" in prop: - type_mapping = { - "string": str, - "number": float, - "integer": int, - "boolean": bool, - "array": list, - "object": dict, - "null": None, - } - - type_ = prop["type"] - - if "enum" in prop: - dynamic_members = { - f"KEY_{i}": value for i, value in enumerate(prop["enum"]) - } - - base_type: Any = type_mapping.get(type_, Any) - - class DynamicEnum(base_type, Enum): - pass - - type_ = DynamicEnum(prop.get("title", "DynamicEnum"), dynamic_members) # type: ignore[call-arg] # explicit ignore - return type_ - elif type_ == "array": - item_type: Any = convert_type(prop.get("items", {})) - return list[item_type] # noqa F821 - elif type_ == "object": - if "properties" in prop: - if "title" in prop and prop["title"]: - title = prop["title"] - else: - title = f"DynamicType_{dynamic_type_counter}" - dynamic_type_counter += 1 - - fields: dict[str, Any] = {} - required_fields = prop.get("required", []) - - for name, property in prop.get("properties", {}).items(): - pydantic_type = convert_type(property) - field_kwargs = {} - if "default" in property: - field_kwargs["default"] = property["default"] - if name not in required_fields: - # Note that we do not make this optional. This is due to a limitation in Pydantic/Python. - # If we convert the Optional type back to json schema, it is represented as type | None. - # pydantic_type = Optional[pydantic_type] - - if "default" not in field_kwargs: - field_kwargs["default"] = None - if "description" in property: - field_kwargs["description"] = property["description"] - if "title" in property: - field_kwargs["title"] = property["title"] - - fields[name] = (pydantic_type, Field(**field_kwargs)) - - object_model = create_model(title, **fields) - if "description" in prop: - object_model.__doc__ = prop["description"] - return object_model - else: - return dict[str, Any] - else: - return type_mapping.get(type_, Any) - - elif "allOf" in prop: - combined_fields = {} - for sub_schema in prop["allOf"]: - model = convert_type(sub_schema) - combined_fields.update(model.__annotations__) - combined_model = create_model( - f"CombinedModel_{combined_model_counter}", **combined_fields - ) - combined_model_counter += 1 - return combined_model - - elif "anyOf" in prop: - unioned_types = tuple( - convert_type(sub_schema) for sub_schema in prop["anyOf"] - ) - return Union[unioned_types] - elif prop == {} or "type" not in prop: - return Any - else: - raise ValueError(f"Unsupported schema: {prop}") - - namespace: dict[str, Any] = {} - for name, definition in schema.get("$defs", schema.get("definitions", {})).items(): - model = convert_type(definition) - namespace[name.capitalize()] = model - model = convert_type(schema) - model.model_rebuild(force=True, _types_namespace=namespace) - return model diff --git a/tests/cli/test_hitl.py b/tests/cli/test_hitl.py index 6606fa722..7803e69f3 100644 --- a/tests/cli/test_hitl.py +++ b/tests/cli/test_hitl.py @@ -5,34 +5,30 @@ import pytest from pytest_httpx import HTTPXMock from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError -from uipath.runtime import ( +from uipath.core.triggers import ( UiPathApiTrigger, UiPathResumeTrigger, UiPathResumeTriggerType, - UiPathRuntimeStatus, ) - from uipath.platform.action_center import Task from uipath.platform.action_center.tasks import TaskStatus from uipath.platform.common import ( CreateBatchTransform, CreateDeepRag, + CreateEphemeralIndex, CreateTask, DocumentExtraction, + DocumentExtractionValidation, InvokeProcess, InvokeSystemAgent, WaitBatchTransform, WaitDeepRag, + WaitDocumentExtractionValidation, + WaitEphemeralIndex, WaitJob, WaitSystemAgent, WaitTask, ) -from uipath.platform.common.interrupt_models import ( - CreateEphemeralIndex, - DocumentExtractionValidation, - WaitDocumentExtractionValidation, - WaitEphemeralIndex, -) from uipath.platform.context_grounding import ( BatchTransformCreationResponse, BatchTransformOutputColumn, @@ -63,6 +59,7 @@ UiPathResumeTriggerCreator, UiPathResumeTriggerReader, ) +from uipath.runtime import UiPathRuntimeStatus @pytest.fixture @@ -420,7 +417,6 @@ async def test_read_deep_rag_trigger_pending( ) -> None: """Test reading a pending deep rag trigger raises pending error.""" from uipath.core.errors import UiPathPendingTriggerError - from uipath.platform.context_grounding import DeepRagResponse task_id = "test-deep-rag-id" @@ -560,7 +556,6 @@ async def test_read_batch_rag_trigger_pending( ) -> None: """Test reading a pending batch rag trigger raises pending error.""" from uipath.core.errors import UiPathPendingTriggerError - from uipath.platform.errors import BatchTransformNotCompleteException task_id = "test-batch-rag-id" diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py index abafcc687..c97ed9465 100644 --- a/tests/cli/test_utils.py +++ b/tests/cli/test_utils.py @@ -2,14 +2,13 @@ from typing import Any from pydantic import BaseModel +from uipath.core.serialization import serialize_object from uipath.runtime.errors import ( UiPathErrorCategory, UiPathErrorCode, UiPathRuntimeError, ) -from uipath._cli._utils._common import serialize_object - class SamplePydanticModel(BaseModel): """Sample Pydantic model for serialization testing.""" diff --git a/tests/cli/utils/test_dynamic_schema.py b/tests/cli/utils/test_dynamic_schema.py deleted file mode 100644 index 13d86c146..000000000 --- a/tests/cli/utils/test_dynamic_schema.py +++ /dev/null @@ -1,105 +0,0 @@ -from enum import Enum - -from pydantic import BaseModel, Field - -from uipath.utils.dynamic_schema import jsonschema_to_pydantic - - -def atest_dynamic_schema(): - # Arrange - class InnerSchema(BaseModel): - """Inner schema description including a self-reference.""" - - self_reference: "InnerSchema" | None = None - - class CustomEnum(str, Enum): - KEY_1 = "VALUE_1" - KEY_2 = "VALUE_2" - - class Schema(BaseModel): - """Schema description.""" - - string: str = Field( - default="", title="String Title", description="String Description" - ) - optional_string: str | None = Field( - default=None, - title="Optional String Title", - description="Optional String Description", - ) - list_str: list[str] = Field( - default=[], title="List String", description="List String Description" - ) - - integer: int = Field( - default=0, title="Integer Title", description="Integer Description" - ) - optional_integer: int | None = Field( - default=None, - title="Option Integer Title", - description="Option Integer Description", - ) - list_integer: list[int] = Field( - default=[], - title="List Integer Title", - description="List Integer Description", - ) - - floating: float = Field( - default=0.0, title="Floating Title", description="Floating Description" - ) - optional_floating: float | None = Field( - default=None, - title="Option Floating Title", - description="Option Floating Description", - ) - list_floating: list[float] = Field( - default=[], - title="List Floating Title", - description="List Floating Description", - ) - - boolean: bool = Field( - default=False, title="Boolean Title", description="Boolean Description" - ) - optional_boolean: bool | None = Field( - default=None, - title="Option Boolean Title", - description="Option Boolean Description", - ) - list_boolean: list[bool] = Field( - default=[], - title="List Boolean Title", - description="List Boolean Description", - ) - - nested_object: InnerSchema = Field( - default=InnerSchema(self_reference=None), - title="Nested Object Title", - description="Nested Object Description", - ) - optional_nested_object: InnerSchema | None = Field( - default=None, - title="Optional Nested Object Title", - description="Optional Nested Object Description", - ) - list_nested_object: list[InnerSchema] = Field( - default=[], - title="List Nested Object Title", - description="List Nested Object Description", - ) - - enum: CustomEnum = Field( - default=CustomEnum.KEY_1, - title="Enum Title", - description="Enum Description", - ) - - schema_json = Schema.model_json_schema() - - # Act - dynamic_schema = jsonschema_to_pydantic(schema_json) - dynamic_schema_json = dynamic_schema.model_json_schema() - - # Assert - assert dynamic_schema_json == schema_json diff --git a/tests/sdk/services/conftest.py b/tests/sdk/services/conftest.py deleted file mode 100644 index 10b502bc3..000000000 --- a/tests/sdk/services/conftest.py +++ /dev/null @@ -1,56 +0,0 @@ -import importlib -from pathlib import Path - -import pytest - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext - - -@pytest.fixture -def base_url() -> str: - return "https://test.uipath.com" - - -@pytest.fixture -def org() -> str: - return "/org" - - -@pytest.fixture -def tenant() -> str: - return "/tenant" - - -@pytest.fixture -def secret() -> str: - return "secret" - - -@pytest.fixture -def config(base_url: str, org: str, tenant: str, secret: str) -> UiPathApiConfig: - return UiPathApiConfig(base_url=f"{base_url}{org}{tenant}", secret=secret) - - -@pytest.fixture -def version(monkeypatch: pytest.MonkeyPatch) -> str: - test_version = "1.0.0" - monkeypatch.setattr(importlib.metadata, "version", lambda _: test_version) - return test_version - - -@pytest.fixture -def execution_context(monkeypatch: pytest.MonkeyPatch) -> UiPathExecutionContext: - monkeypatch.setenv("UIPATH_ROBOT_KEY", "test-robot-key") - return UiPathExecutionContext() - - -@pytest.fixture -def tests_data_path() -> Path: - return Path(__file__).resolve().parent / "tests_data" - - -@pytest.fixture -def jobs_service(config, execution_context): - from uipath.platform.orchestrator import JobsService - - return JobsService(config, execution_context) diff --git a/tests/sdk/services/test_actions_service.py b/tests/sdk/services/test_actions_service.py deleted file mode 100644 index f1592be8e..000000000 --- a/tests/sdk/services/test_actions_service.py +++ /dev/null @@ -1,179 +0,0 @@ -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.action_center import Task -from uipath.platform.action_center._tasks_service import TasksService - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> TasksService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - - return TasksService(config=config, execution_context=execution_context) - - -class TestTasksService: - def test_retrieve( - self, - httpx_mock: HTTPXMock, - service: TasksService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/GetTaskDataByKey?taskKey=test-id", - status_code=200, - json={"id": 1, "title": "Test Action"}, - ) - - action = service.retrieve( - action_key="test-id", - app_folder_path="test-folder", - ) - - assert isinstance(action, Task) - assert action.id == 1 - assert action.title == "Test Action" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/GetTaskDataByKey?taskKey=test-id" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.TasksService.retrieve/{version}" - ) - - @pytest.mark.anyio - async def test_retrieve_async( - self, - httpx_mock: HTTPXMock, - service: TasksService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/GetTaskDataByKey?taskKey=test-id", - status_code=200, - json={"id": 1, "title": "Test Action"}, - ) - - action = await service.retrieve_async( - action_key="test-id", - app_folder_path="test-folder", - ) - - assert isinstance(action, Task) - assert action.id == 1 - assert action.title == "Test Action" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/GetTaskDataByKey?taskKey=test-id" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.TasksService.retrieve_async/{version}" - ) - - def test_create_with_app_key( - self, - httpx_mock: HTTPXMock, - service: TasksService, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", - status_code=200, - json={"id": 1, "title": "Test Action"}, - ) - - action = service.create( - title="Test Action", - app_key="test-app-key", - data={"test": "data"}, - ) - - assert isinstance(action, Task) - assert action.id == 1 - assert action.title == "Test Action" - - def test_create_with_assignee( - self, - httpx_mock: HTTPXMock, - service: TasksService, - base_url: str, - org: str, - tenant: str, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") - - httpx_mock.add_response( - url=f"{base_url}{org}/apps_/default/api/v1/default/deployed-action-apps-schemas?search=test-app&filterByDeploymentTitle=true", - status_code=200, - json={ - "deployed": [ - { - "systemName": "test-app", - "actionSchema": { - "key": "test-key", - "inputs": [], - "outputs": [], - "inOuts": [], - "outcomes": [], - }, - "deploymentFolder": {"fullyQualifiedName": "test-folder-path"}, - } - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", - status_code=200, - json={"id": 1, "title": "Test Action"}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks", - status_code=200, - json={}, - ) - - action = service.create( - title="Test Action", - app_name="test-app", - data={"test": "data"}, - assignee="test@example.com", - ) - - assert isinstance(action, Task) - assert action.id == 1 - assert action.title == "Test Action" diff --git a/tests/sdk/services/test_api_client.py b/tests/sdk/services/test_api_client.py deleted file mode 100644 index e07bfbb87..000000000 --- a/tests/sdk/services/test_api_client.py +++ /dev/null @@ -1,92 +0,0 @@ -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common._api_client import ApiClient - - -@pytest.fixture -def service( - config: UiPathApiConfig, execution_context: UiPathExecutionContext -) -> ApiClient: - return ApiClient(config=config, execution_context=execution_context) - - -class TestApiClient: - def test_request( - self, - httpx_mock: HTTPXMock, - service: ApiClient, - base_url: str, - org: str, - tenant: str, - version: str, - secret: str, - ): - endpoint = "/endpoint" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}{endpoint}", - status_code=200, - json={"test": "test"}, - ) - - response = service.request("GET", endpoint) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert sent_request.url == f"{base_url}{org}{tenant}{endpoint}" - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ApiClient.request/{version}" - ) - assert sent_request.headers["Authorization"] == f"Bearer {secret}" - - assert response is not None - assert response.status_code == 200 - assert response.json() == {"test": "test"} - - @pytest.mark.anyio - async def test_request_async( - self, - httpx_mock: HTTPXMock, - service: ApiClient, - base_url: str, - org: str, - tenant: str, - version: str, - secret: str, - ): - endpoint = "/endpoint" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}{endpoint}", - status_code=200, - json={"test": "test"}, - ) - - response = await service.request_async("GET", endpoint) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert sent_request.url == f"{base_url}{org}{tenant}{endpoint}" - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ApiClient.request_async/{version}" - ) - assert sent_request.headers["Authorization"] == f"Bearer {secret}" - - assert response is not None - assert response.status_code == 200 - assert response.json() == {"test": "test"} diff --git a/tests/sdk/services/test_assets_service.py b/tests/sdk/services/test_assets_service.py deleted file mode 100644 index 2a9a0c659..000000000 --- a/tests/sdk/services/test_assets_service.py +++ /dev/null @@ -1,675 +0,0 @@ -from unittest.mock import Mock, patch - -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common.paging import PagedResult -from uipath.platform.orchestrator import Asset, UserAsset -from uipath.platform.orchestrator._assets_service import AssetsService - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> AssetsService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return AssetsService(config=config, execution_context=execution_context) - - -class TestAssetsService: - class TestRetrieveAsset: - def test_retrieve_robot_asset( - self, - httpx_mock: HTTPXMock, - service: AssetsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - import json - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", - status_code=200, - json={"id": 1, "name": "Test Asset", "value": "test-value"}, - ) - - asset = service.retrieve(name="Test Asset") - - assert isinstance(asset, UserAsset) - assert asset.id == 1 - assert asset.name == "Test Asset" - assert asset.value == "test-value" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" - ) - - # Verify default behavior includes supportsCredentialsProxyDisconnected=True - request_body = json.loads(sent_request.content) - assert request_body["assetName"] == "Test Asset" - assert request_body["supportsCredentialsProxyDisconnected"] is True - assert "robotKey" in request_body - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve/{version}" - ) - - def test_retrieve_robot_asset_with_connection_data( - self, - httpx_mock: HTTPXMock, - service: AssetsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test retrieving a robot asset with external credential store connection data. - - This tests that the CredentialsConnectionData model correctly parses - API responses with uppercase field names (Url, Body, BearerToken). - """ - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", - status_code=200, - json={ - "Id": 1, - "Name": "Test Credential", - "ValueType": "Credential", - "ConnectionData": { - "Url": "https://credentialstore.example.com/api/credentials", - "Body": '{"credentialId": "12345"}', - "BearerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - }, - }, - ) - - asset = service.retrieve(name="Test Credential") - - assert isinstance(asset, UserAsset) - assert asset.id == 1 - assert asset.name == "Test Credential" - assert asset.value_type == "Credential" - - # Verify connection data is correctly parsed with uppercase aliases - assert asset.connection_data is not None - assert ( - asset.connection_data.url - == "https://credentialstore.example.com/api/credentials" - ) - assert asset.connection_data.body == '{"credentialId": "12345"}' - assert ( - asset.connection_data.bearer_token - == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert HEADER_USER_AGENT in sent_request.headers - - def test_retrieve_asset( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - version: str, - config: UiPathApiConfig, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service = AssetsService( - config=config, - execution_context=UiPathExecutionContext(), - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Asset'&$top=1", - status_code=200, - json={ - "value": [ - { - "key": "asset-key", - "name": "Test Asset", - "value": "test-value", - } - ] - }, - ) - - asset = service.retrieve(name="Test Asset") - - assert isinstance(asset, Asset) - assert asset.key == "asset-key" - assert asset.name == "Test Asset" - assert asset.value == "test-value" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?%24filter=Name+eq+%27Test+Asset%27&%24top=1" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve/{version}" - ) - - class TestListAssets: - def test_list_assets( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - version: str, - config: UiPathApiConfig, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service = AssetsService( - config=config, - execution_context=UiPathExecutionContext(), - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$skip=0&$top=100", - status_code=200, - json={ - "value": [ - { - "Key": "asset-key-1", - "Name": "Asset 1", - "Value": "value-1", - "ValueType": "Text", - }, - { - "Key": "asset-key-2", - "Name": "Asset 2", - "Value": "value-2", - "ValueType": "Text", - }, - ] - }, - ) - - result = service.list() - - assert isinstance(result, PagedResult) - assert len(result.items) == 2 - assert all(isinstance(asset, Asset) for asset in result.items) - assert result.items[0].key == "asset-key-1" - assert result.items[0].name == "Asset 1" - assert result.items[1].key == "asset-key-2" - assert result.items[1].name == "Asset 2" - assert result.skip == 0 - assert result.top == 100 - assert result.has_more is False # 2 items < 100 top - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.list/{version}" - ) - - def test_list_assets_with_filter_and_orderby( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - config: UiPathApiConfig, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service = AssetsService( - config=config, - execution_context=UiPathExecutionContext(), - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$skip=0&$top=100&$filter=ValueType eq 'Text'&$orderby=Name asc", - status_code=200, - json={ - "value": [ - { - "Key": "asset-key-1", - "Name": "Text Asset", - "ValueType": "Text", - }, - ] - }, - ) - - result = service.list(filter="ValueType eq 'Text'", orderby="Name asc") - - assert len(result.items) == 1 - assert result.items[0].name == "Text Asset" - - @pytest.mark.anyio - async def test_list_assets_async( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - version: str, - config: UiPathApiConfig, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service = AssetsService( - config=config, - execution_context=UiPathExecutionContext(), - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$skip=0&$top=100", - status_code=200, - json={ - "value": [ - { - "Key": "asset-key-1", - "Name": "Asset 1", - "Value": "value-1", - }, - ] - }, - ) - - result = await service.list_async() - - assert isinstance(result, PagedResult) - assert len(result.items) == 1 - assert result.items[0].key == "asset-key-1" - assert result.items[0].name == "Asset 1" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.list_async/{version}" - ) - - def test_retrieve_credential( - self, - httpx_mock: HTTPXMock, - service: AssetsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", - status_code=200, - json={ - "id": 1, - "name": "Test Credential", - "credential_username": "test-user", - "credential_password": "test-password", - }, - ) - - credential = service.retrieve_credential(name="Test Credential") - - assert credential == "test-password" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential/{version}" - ) - - def test_retrieve_credential_user_asset( - self, - service: AssetsService, - monkeypatch: pytest.MonkeyPatch, - config: UiPathApiConfig, - ) -> None: - with pytest.raises(ValueError): - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service = AssetsService( - config=config, - execution_context=UiPathExecutionContext(), - ) - service.retrieve_credential(name="Test Credential") - - async def test_retrieve_credential_async( - self, - httpx_mock: HTTPXMock, - service: AssetsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test asynchronously retrieving a credential asset.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", - status_code=200, - json={ - "id": 1, - "name": "Test Credential", - "credential_username": "test-user", - "credential_password": "test-password", - }, - ) - - credential = await service.retrieve_credential_async(name="Test Credential") - - assert credential == "test-password" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential_async/{version}" - ) - - def test_update( - self, - httpx_mock: HTTPXMock, - service: AssetsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey", - status_code=200, - json={"id": 1, "name": "Test Asset", "value": "updated-value"}, - ) - - asset = UserAsset(name="Test Asset", value="updated-value") - response = service.update(robot_asset=asset) - - assert response == {"id": 1, "name": "Test Asset", "value": "updated-value"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.update/{version}" - ) - - @pytest.mark.anyio - async def test_update_async( - self, - httpx_mock: HTTPXMock, - service: AssetsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey", - status_code=200, - json={"id": 1, "name": "Test Asset", "value": "updated-value"}, - ) - - asset = UserAsset(name="Test Asset", value="updated-value") - response = await service.update_async(robot_asset=asset) - - assert response == {"id": 1, "name": "Test Asset", "value": "updated-value"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.update_async/{version}" - ) - - class TestRequestKwargs: - """Test that all methods pass the correct kwargs to request/request_async.""" - - def test_retrieve_passes_all_kwargs( - self, service: AssetsService, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Test that retrieve passes all kwargs to request.""" - mock_response = Mock() - mock_response.json.return_value = { - "value": [{"key": "test-key", "name": "Test", "value": "test-value"}] - } - - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service._execution_context = UiPathExecutionContext() - - with patch.object( - service, "request", return_value=mock_response - ) as mock_request: - service.retrieve(name="Test") - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - # Verify all expected kwargs are present - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "json" in call_kwargs.kwargs - assert "content" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - # Verify positional arg (method) - assert call_kwargs.args[0] == "GET" - - @pytest.mark.anyio - async def test_retrieve_async_passes_all_kwargs( - self, service: AssetsService, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Test that retrieve_async passes all kwargs to request_async.""" - mock_response = Mock() - mock_response.json.return_value = { - "value": [{"key": "test-key", "name": "Test", "value": "test-value"}] - } - - monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) - service._execution_context = UiPathExecutionContext() - - with patch.object( - service, "request_async", return_value=mock_response - ) as mock_request: - await service.retrieve_async(name="Test") - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - # Verify all expected kwargs are present - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "json" in call_kwargs.kwargs - assert "content" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - # Verify positional arg (method) - assert call_kwargs.args[0] == "GET" - - def test_retrieve_credential_passes_all_kwargs( - self, service: AssetsService - ) -> None: - """Test that retrieve_credential passes all kwargs to request.""" - mock_response = Mock() - mock_response.json.return_value = { - "id": 1, - "name": "Test", - "credential_password": "secret", - } - - with patch.object( - service, "request", return_value=mock_response - ) as mock_request: - service.retrieve_credential(name="Test") - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - # Verify all expected kwargs are present - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "json" in call_kwargs.kwargs - assert "content" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - # Verify positional arg (method) - assert call_kwargs.args[0] == "POST" - - @pytest.mark.anyio - async def test_retrieve_credential_async_passes_all_kwargs( - self, service: AssetsService - ) -> None: - """Test that retrieve_credential_async passes all kwargs to request_async.""" - mock_response = Mock() - mock_response.json.return_value = { - "id": 1, - "name": "Test", - "credential_password": "secret", - } - - with patch.object( - service, "request_async", return_value=mock_response - ) as mock_request: - await service.retrieve_credential_async(name="Test") - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - # Verify all expected kwargs are present - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "json" in call_kwargs.kwargs - assert "content" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - # Verify positional arg (method) - assert call_kwargs.args[0] == "POST" - - def test_update_passes_all_kwargs(self, service: AssetsService) -> None: - """Test that update passes all kwargs to request.""" - mock_response = Mock() - mock_response.json.return_value = { - "id": 1, - "name": "Test", - "value": "updated", - } - - asset = UserAsset(name="Test", value="updated") - - with patch.object( - service, "request", return_value=mock_response - ) as mock_request: - service.update(robot_asset=asset) - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - # Verify all expected kwargs are present - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "json" in call_kwargs.kwargs - assert "content" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - # Verify positional arg (method) - assert call_kwargs.args[0] == "POST" - - @pytest.mark.anyio - async def test_update_async_passes_all_kwargs( - self, service: AssetsService - ) -> None: - """Test that update_async passes all kwargs to request_async.""" - mock_response = Mock() - mock_response.json.return_value = { - "id": 1, - "name": "Test", - "value": "updated", - } - - asset = UserAsset(name="Test", value="updated") - - with patch.object( - service, "request_async", return_value=mock_response - ) as mock_request: - await service.update_async(robot_asset=asset) - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - # Verify all expected kwargs are present - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "json" in call_kwargs.kwargs - assert "content" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - # Verify positional arg (method) - assert call_kwargs.args[0] == "POST" diff --git a/tests/sdk/services/test_attachments_service.py b/tests/sdk/services/test_attachments_service.py deleted file mode 100644 index 088f82b28..000000000 --- a/tests/sdk/services/test_attachments_service.py +++ /dev/null @@ -1,1195 +0,0 @@ -import json -import os -import shutil -import uuid -from typing import TYPE_CHECKING, Any, Generator, Tuple - -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.attachments import Attachment -from uipath.platform.attachments.attachments import AttachmentMode -from uipath.platform.orchestrator._attachments_service import AttachmentsService - -if TYPE_CHECKING: - from _pytest.monkeypatch import MonkeyPatch - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: "MonkeyPatch", -) -> AttachmentsService: - """Fixture that provides a configured AttachmentsService instance for testing. - - Args: - config: The Config fixture with test configuration settings. - execution_context: The UiPathExecutionContext fixture with test execution context. - monkeypatch: PyTest MonkeyPatch fixture for environment modification. - - Returns: - AttachmentsService: A configured instance of AttachmentsService. - """ - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return AttachmentsService(config=config, execution_context=execution_context) - - -@pytest.fixture -def temp_file(tmp_path: Any) -> Generator[Tuple[str, str, str], None, None]: - """Creates a temporary file for testing file uploads and downloads. - - Args: - tmp_path: PyTest fixture providing a temporary directory. - - Returns: - A tuple containing the file content, file name, and file path. - """ - content = "Test content" - name = f"test_file_{uuid.uuid4()}.txt" - path = os.path.join(tmp_path, name) - - with open(path, "w") as f: - f.write(content) - - yield content, name, path - - # Clean up the file after the test - if os.path.exists(path): - os.remove(path) - - -@pytest.fixture -def temp_attachments_dir(tmp_path: Any) -> Generator[str, None, None]: - """Create a temporary directory for attachments and clean it up after the test. - - Args: - tmp_path: Pytest's temporary directory fixture. - - Returns: - The path to the temporary directory. - """ - test_temp_dir = os.path.join(tmp_path, TEMP_ATTACHMENTS_FOLDER) - os.makedirs(test_temp_dir, exist_ok=True) - - yield test_temp_dir - - # Clean up the directory after the test - if os.path.exists(test_temp_dir): - shutil.rmtree(test_temp_dir) - - -@pytest.fixture -def local_attachment_file( - temp_attachments_dir: str, -) -> Generator[Tuple[uuid.UUID, str, str], None, None]: - """Creates a local attachment file in the temporary attachments directory. - - Args: - temp_attachments_dir: The temporary attachments directory. - - Returns: - A tuple containing the attachment ID, file name, and file content. - """ - attachment_id = uuid.uuid4() - file_name = "test_local_file.txt" - file_content = "Local test content" - - # Create the local file with the format {uuid}_{filename} - file_path = os.path.join(temp_attachments_dir, f"{attachment_id}_{file_name}") - with open(file_path, "w") as f: - f.write(file_content) - - yield attachment_id, file_name, file_content - - # Cleanup is handled by temp_attachments_dir fixture - - -@pytest.fixture -def blob_uri_response() -> dict[str, Any]: - """Provides a mock response for blob access requests. - - Returns: - Dict[str, Any]: A mock API response with blob storage access details. - """ - return { - "Id": "12345678-1234-1234-1234-123456789012", - "Name": "test_file.txt", - "BlobFileAccess": { - "Uri": "https://test-storage.com/test-container/test-blob", - "Headers": { - "Keys": ["x-ms-blob-type", "Content-Type"], - "Values": ["BlockBlob", "application/octet-stream"], - }, - "RequiresAuth": False, - }, - } - - -class TestAttachmentsService: - """Test suite for the AttachmentsService class.""" - - def test_upload_with_file_path( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - temp_file: Tuple[str, str, str], - blob_uri_response: dict[str, Any], - ) -> None: - """Test uploading an attachment from a file path. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - temp_file: Temporary file fixture tuple (content, name, path). - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - content, file_name, file_path = temp_file - - # Mock the create attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", - method="POST", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob upload - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="PUT", - status_code=201, - ) - - # Act - attachment_key = service.upload( - name=file_name, - source_path=file_path, - ) - - # Assert - assert attachment_key == uuid.UUID(blob_uri_response["Id"]) - - # Verify the requests - requests = httpx_mock.get_requests() - assert requests is not None - assert len(requests) == 2 - - # Check the first request to create the attachment - create_request = requests[0] - assert create_request is not None - assert create_request.method == "POST" - assert ( - create_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" - ) - assert json.loads(create_request.content) == {"Name": file_name} - assert HEADER_USER_AGENT in create_request.headers - assert create_request.headers[HEADER_USER_AGENT].startswith( - f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AttachmentsService.upload/{version}" - ) - - # Check the second request to upload the content - upload_request = requests[1] - assert upload_request is not None - assert upload_request.method == "PUT" - assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] - assert "x-ms-blob-type" in upload_request.headers - assert upload_request.headers["x-ms-blob-type"] == "BlockBlob" - - def test_upload_with_content( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - blob_uri_response: dict[str, Any], - ) -> None: - """Test uploading an attachment with in-memory content. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - content = "Test content in memory" - file_name = "text_content.txt" - - # Mock the create attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", - method="POST", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob upload - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="PUT", - status_code=201, - ) - - # Act - attachment_key = service.upload( - name=file_name, - content=content, - ) - - # Assert - assert attachment_key == uuid.UUID(blob_uri_response["Id"]) - - # Verify the requests - requests = httpx_mock.get_requests() - assert requests is not None - assert len(requests) == 2 - - # Check the first request to create the attachment - create_request = requests[0] - assert create_request is not None - assert create_request.method == "POST" - assert ( - create_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" - ) - assert json.loads(create_request.content) == {"Name": file_name} - assert HEADER_USER_AGENT in create_request.headers - - # Check the second request to upload the content - upload_request = requests[1] - assert upload_request is not None - assert upload_request.method == "PUT" - assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] - assert "x-ms-blob-type" in upload_request.headers - assert upload_request.headers["x-ms-blob-type"] == "BlockBlob" - assert upload_request.content == content.encode("utf-8") - - def test_upload_validation_errors( - self, - service: AttachmentsService, - ) -> None: - """Test validation errors when uploading attachments. - - Args: - service: AttachmentsService fixture. - """ - # Test missing both content and source_path - with pytest.raises(ValueError, match="Content or source_path is required"): - service.upload(name="test.txt") # type: ignore - - # Test providing both content and source_path - with pytest.raises( - ValueError, match="Content and source_path are mutually exclusive" - ): - service.upload( - name="test.txt", content="test content", source_path="/path/to/file.txt" - ) # type: ignore - - @pytest.mark.asyncio - async def test_upload_async_with_content( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - blob_uri_response: dict[str, Any], - ) -> None: - """Test asynchronously uploading an attachment with in-memory content. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - content = "Test content in memory" - file_name = "text_content.txt" - - # Mock the create attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", - method="POST", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob upload - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="PUT", - status_code=201, - ) - - # Act - attachment_key = await service.upload_async( - name=file_name, - content=content, - ) - - # Assert - assert attachment_key == uuid.UUID(blob_uri_response["Id"]) - - # Verify the requests - requests = httpx_mock.get_requests() - assert requests is not None - assert len(requests) == 2 - assert requests is not None - # Check the first request to create the attachment - create_request = requests[0] - assert create_request is not None - assert create_request.method == "POST" - assert create_request is not None - assert ( - create_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" - ) - assert HEADER_USER_AGENT in create_request.headers - - def test_download( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - tmp_path: Any, - blob_uri_response: dict[str, Any], - ) -> None: - """Test downloading an attachment. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - tmp_path: Temporary directory fixture. - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") - destination_path = os.path.join(tmp_path, "downloaded_file.txt") - file_content = b"Downloaded file content" - expected_name = blob_uri_response["Name"] - - # Mock the get attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", - method="GET", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob download - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="GET", - status_code=200, - content=file_content, - ) - - # Act - result = service.download( - key=attachment_key, - destination_path=destination_path, - ) - - # Assert - assert result == expected_name - assert os.path.exists(destination_path) - with open(destination_path, "rb") as f: - assert f.read() == file_content - - # Verify the requests - requests = httpx_mock.get_requests() - assert requests is not None - assert len(requests) == 2 - assert requests is not None - # Check the first request to get the attachment metadata - get_request = requests[0] - assert get_request is not None - assert get_request.method == "GET" - assert get_request is not None - assert ( - get_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" - ) - assert HEADER_USER_AGENT in get_request.headers - - # Check the second request to download the content - download_request = requests[1] - assert download_request is not None - assert download_request.method == "GET" - assert download_request is not None - - @pytest.mark.asyncio - async def test_download_async( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - tmp_path: Any, - blob_uri_response: dict[str, Any], - ) -> None: - """Test asynchronously downloading an attachment. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - tmp_path: Temporary directory fixture. - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") - destination_path = os.path.join(tmp_path, "downloaded_file_async.txt") - file_content = b"Downloaded file content async" - expected_name = blob_uri_response["Name"] - - # Mock the get attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", - method="GET", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob download - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="GET", - status_code=200, - content=file_content, - ) - - # Act - result = await service.download_async( - key=attachment_key, - destination_path=destination_path, - ) - - # Assert - assert result == expected_name - assert os.path.exists(destination_path) - with open(destination_path, "rb") as f: - assert f.read() == file_content - - def test_delete( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test deleting an attachment. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - """ - # Arrange - attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") - - # Mock the delete attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", - method="DELETE", - status_code=204, - ) - - # Act - service.delete(key=attachment_key) - - # Verify the request - request = httpx_mock.get_request() - assert request is not None - assert request.method == "DELETE" - assert request is not None - assert ( - request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" - ) - assert HEADER_USER_AGENT in request.headers - assert request.headers[HEADER_USER_AGENT].startswith( - f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AttachmentsService.delete/{version}" - ) - - @pytest.mark.asyncio - async def test_delete_async( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test asynchronously deleting an attachment. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - """ - # Arrange - attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") - - # Mock the delete attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", - method="DELETE", - status_code=204, - ) - - # Act - await service.delete_async(key=attachment_key) - - # Verify the request - request = httpx_mock.get_request() - assert request is not None - assert request.method == "DELETE" - assert request is not None - assert ( - request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" - ) - assert HEADER_USER_AGENT in request.headers - assert request.headers[HEADER_USER_AGENT].startswith( - f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AttachmentsService.delete_async/{version}" - ) - - def test_download_local_fallback( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - tmp_path: Any, - temp_attachments_dir: str, - local_attachment_file: Tuple[uuid.UUID, str, str], - ) -> None: - """Test downloading an attachment with local fallback. - - This test verifies the fallback mechanism when an attachment is not found in UiPath - but exists in the local temporary storage. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - tmp_path: Temporary directory fixture. - temp_attachments_dir: Fixture for temporary attachments directory. - local_attachment_file: Fixture providing an attachment file in the temporary directory. - """ - # Arrange - attachment_id, file_name, file_content = local_attachment_file - destination_path = os.path.join(tmp_path, "downloaded_file.txt") - - # Replace the temp_dir in the service to use our test directory - service._temp_dir = temp_attachments_dir - - # Mock the 404 response for UiPath attachment - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="GET", - status_code=404, - json={"error": "Attachment not found"}, - ) - - # Act - result = service.download( - key=attachment_id, - destination_path=destination_path, - ) - - # Assert - assert result == file_name - assert os.path.exists(destination_path) - - with open(destination_path, "r") as f: - assert f.read() == file_content - - @pytest.mark.asyncio - async def test_download_async_local_fallback( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - tmp_path: Any, - temp_attachments_dir: str, - local_attachment_file: Tuple[uuid.UUID, str, str], - ) -> None: - """Test asynchronously downloading an attachment with local fallback. - - This test verifies the fallback mechanism when an attachment is not found in UiPath - but exists in the local temporary storage, using the async method. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - tmp_path: Temporary directory fixture. - temp_attachments_dir: Fixture for temporary attachments directory. - local_attachment_file: Fixture providing an attachment file in the temporary directory. - """ - # Arrange - attachment_id, file_name, file_content = local_attachment_file - destination_path = os.path.join(tmp_path, "downloaded_file_async.txt") - - # Replace the temp_dir in the service to use our test directory - service._temp_dir = temp_attachments_dir - - # Mock the 404 response for UiPath attachment - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="GET", - status_code=404, - json={"error": "Attachment not found"}, - ) - - # Act - result = await service.download_async( - key=attachment_id, - destination_path=destination_path, - ) - - # Assert - assert result == file_name - assert os.path.exists(destination_path) - - with open(destination_path, "r") as f: - assert f.read() == file_content - - def test_delete_local_fallback( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - temp_attachments_dir: str, - local_attachment_file: Tuple[uuid.UUID, str, str], - ) -> None: - """Test deleting an attachment with local fallback. - - This test verifies the fallback mechanism when an attachment is not found in UiPath - but exists in the local temporary storage. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - temp_attachments_dir: Fixture for temporary attachments directory. - local_attachment_file: Fixture providing an attachment file in the temporary directory. - """ - # Arrange - attachment_id, file_name, _ = local_attachment_file - - # Replace the temp_dir in the service to use our test directory - service._temp_dir = temp_attachments_dir - - # Verify the file exists before deletion - expected_path = os.path.join( - temp_attachments_dir, f"{attachment_id}_{file_name}" - ) - assert os.path.exists(expected_path) - - # Mock the 404 response for UiPath attachment - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="DELETE", - status_code=404, - json={"error": "Attachment not found"}, - ) - - # Act - service.delete(key=attachment_id) - - # Assert - verify the file was deleted - assert not os.path.exists(expected_path) - - @pytest.mark.asyncio - async def test_delete_async_local_fallback( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - temp_attachments_dir: str, - local_attachment_file: Tuple[uuid.UUID, str, str], - ) -> None: - """Test asynchronously deleting an attachment with local fallback. - - This test verifies the fallback mechanism when an attachment is not found in UiPath - but exists in the local temporary storage, using the async method. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - temp_attachments_dir: Fixture for temporary attachments directory. - local_attachment_file: Fixture providing an attachment file in the temporary directory. - """ - # Arrange - attachment_id, file_name, _ = local_attachment_file - - # Replace the temp_dir in the service to use our test directory - service._temp_dir = temp_attachments_dir - - # Verify the file exists before deletion - expected_path = os.path.join( - temp_attachments_dir, f"{attachment_id}_{file_name}" - ) - assert os.path.exists(expected_path) - - # Mock the 404 response for UiPath attachment - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="DELETE", - status_code=404, - json={"error": "Attachment not found"}, - ) - - # Act - await service.delete_async(key=attachment_id) - - # Assert - verify the file was deleted - assert not os.path.exists(expected_path) - - def test_delete_not_found_throws_exception( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test that deleting a non-existent attachment throws an exception. - - This test verifies that when an attachment is not found in UiPath - and not found locally, an exception is raised. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - """ - # Arrange - attachment_id = uuid.uuid4() - - # Set a non-existent temp dir to ensure no local files are found - service._temp_dir = "non_existent_dir" - - # Mock the 404 response for UiPath attachment - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="DELETE", - status_code=404, - json={"error": "Attachment not found"}, - ) - - # Act & Assert - with pytest.raises( - Exception, - match=f"Attachment with key {attachment_id} not found in UiPath or local storage", - ): - service.delete(key=attachment_id) - - @pytest.mark.asyncio - async def test_delete_async_not_found_throws_exception( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test that asynchronously deleting a non-existent attachment throws an exception. - - This test verifies that when an attachment is not found in UiPath - and not found locally, an exception is raised when using the async method. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - """ - # Arrange - attachment_id = uuid.uuid4() - - # Set a non-existent temp dir to ensure no local files are found - service._temp_dir = "non_existent_dir" - - # Mock the 404 response for UiPath attachment - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="DELETE", - status_code=404, - json={"error": "Attachment not found"}, - ) - - # Act & Assert - with pytest.raises( - Exception, - match=f"Attachment with key {attachment_id} not found in UiPath or local storage", - ): - await service.delete_async(key=attachment_id) - - def test_open_read_mode( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - blob_uri_response: dict[str, Any], - ) -> None: - """Test opening an attachment in READ mode. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") - file_content = b"Test file content for reading" - attachment = Attachment( # type: ignore[call-arg] - ID=attachment_key, - FullName="test_file.txt", - MimeType="text/plain", - ) - - # Mock the get attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", - method="GET", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob download - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="GET", - status_code=200, - content=file_content, - ) - - # Act & Assert - with service.open(attachment=attachment, mode=AttachmentMode.READ) as ( - resource, - response, - ): - assert resource.id == uuid.UUID(blob_uri_response["Id"]) - assert response.status_code == 200 - content = response.read() - assert content == file_content - - # Verify the requests - requests = httpx_mock.get_requests() - assert requests is not None - assert len(requests) == 2 - - # Check the first request to get the attachment metadata - get_request = requests[0] - assert get_request is not None - assert get_request.method == "GET" - assert ( - get_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" - ) - assert HEADER_USER_AGENT in get_request.headers - - # Check the second request to stream the content - stream_request = requests[1] - assert stream_request is not None - assert stream_request.method == "GET" - assert stream_request.url == blob_uri_response["BlobFileAccess"]["Uri"] - - def test_open_write_mode( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - blob_uri_response: dict[str, Any], - ) -> None: - """Test opening an attachment in WRITE mode. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - file_name = "test_write_file.txt" - file_content = b"Content to write" - attachment = Attachment( # type: ignore[call-arg] - ID=uuid.uuid4(), - FullName=file_name, - MimeType="text/plain", - ) - - # Mock the create attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", - method="POST", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob upload - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="PUT", - status_code=201, - ) - - # Act & Assert - with service.open( - attachment=attachment, mode=AttachmentMode.WRITE, content=file_content - ) as (resource, response): - assert resource.id == uuid.UUID(blob_uri_response["Id"]) - assert response.status_code == 201 - - # Verify the requests - requests = httpx_mock.get_requests() - assert requests is not None - assert len(requests) == 2 - - # Check the first request to create the attachment - create_request = requests[0] - assert create_request is not None - assert create_request.method == "POST" - assert ( - create_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" - ) - assert json.loads(create_request.content) == {"Name": file_name} - assert HEADER_USER_AGENT in create_request.headers - - # Check the second request to upload the content - upload_request = requests[1] - assert upload_request is not None - assert upload_request.method == "PUT" - assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] - - @pytest.mark.asyncio - async def test_open_async_read_mode( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - blob_uri_response: dict[str, Any], - ) -> None: - """Test asynchronously opening an attachment in READ mode. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") - file_content = b"Test file content for async reading" - attachment = Attachment( # type: ignore[call-arg] - ID=attachment_key, - FullName="test_file_async.txt", - MimeType="text/plain", - ) - - # Mock the get attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", - method="GET", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob download - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="GET", - status_code=200, - content=file_content, - ) - - # Act & Assert - async with service.open_async( - attachment=attachment, mode=AttachmentMode.READ - ) as (resource, response): - assert resource.id == uuid.UUID(blob_uri_response["Id"]) - assert response.status_code == 200 - content = await response.aread() - assert content == file_content - - # Verify the requests - requests = httpx_mock.get_requests() - assert requests is not None - assert len(requests) == 2 - - # Check the first request to get the attachment metadata - get_request = requests[0] - assert get_request is not None - assert get_request.method == "GET" - assert ( - get_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" - ) - assert HEADER_USER_AGENT in get_request.headers - - # Check the second request to stream the content - stream_request = requests[1] - assert stream_request is not None - assert stream_request.method == "GET" - assert stream_request.url == blob_uri_response["BlobFileAccess"]["Uri"] - - @pytest.mark.asyncio - async def test_open_async_write_mode( - self, - httpx_mock: HTTPXMock, - service: AttachmentsService, - base_url: str, - org: str, - tenant: str, - version: str, - blob_uri_response: dict[str, Any], - ) -> None: - """Test asynchronously opening an attachment in WRITE mode. - - Args: - httpx_mock: HTTPXMock fixture for mocking HTTP requests. - service: AttachmentsService fixture. - base_url: Base URL fixture for the API endpoint. - org: Organization fixture for the API path. - tenant: Tenant fixture for the API path. - version: Version fixture for the user agent header. - blob_uri_response: Mock response fixture for blob operations. - """ - # Arrange - file_name = "test_write_file_async.txt" - file_content = b"Content to write async" - attachment = Attachment( # type: ignore[call-arg] - ID=uuid.uuid4(), - FullName=file_name, - MimeType="text/plain", - ) - - # Mock the create attachment endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", - method="POST", - status_code=200, - json=blob_uri_response, - ) - - # Mock the blob upload - httpx_mock.add_response( - url=blob_uri_response["BlobFileAccess"]["Uri"], - method="PUT", - status_code=201, - ) - - # Act & Assert - async with service.open_async( - attachment=attachment, mode=AttachmentMode.WRITE, content=file_content - ) as (resource, response): - assert resource.id == uuid.UUID(blob_uri_response["Id"]) - assert response.status_code == 201 - - # Verify the requests - requests = httpx_mock.get_requests() - assert requests is not None - assert len(requests) == 2 - - # Check the first request to create the attachment - create_request = requests[0] - assert create_request is not None - assert create_request.method == "POST" - assert ( - create_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" - ) - assert json.loads(create_request.content) == {"Name": file_name} - assert HEADER_USER_AGENT in create_request.headers - - # Check the second request to upload the content - upload_request = requests[1] - assert upload_request is not None - assert upload_request.method == "PUT" - assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] diff --git a/tests/sdk/services/test_base_service.py b/tests/sdk/services/test_base_service.py deleted file mode 100644 index 7a17e569a..000000000 --- a/tests/sdk/services/test_base_service.py +++ /dev/null @@ -1,102 +0,0 @@ -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.common._base_service import BaseService - - -@pytest.fixture -def service( - config: UiPathApiConfig, execution_context: UiPathExecutionContext -) -> BaseService: - return BaseService(config=config, execution_context=execution_context) - - -class TestBaseService: - def test_init_base_service(self, service: BaseService): - assert service is not None - - def test_base_service_default_headers(self, service: BaseService, secret: str): - assert service.default_headers == { - "Accept": "application/json", - "Authorization": f"Bearer {secret}", - } - - class TestRequest: - def test_simple_request( - self, - httpx_mock: HTTPXMock, - service: BaseService, - base_url: str, - org: str, - tenant: str, - version: str, - secret: str, - ): - endpoint = "/endpoint" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}{endpoint}", - status_code=200, - json={"test": "test"}, - ) - - response = service.request("GET", endpoint) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert sent_request.url == f"{base_url}{org}{tenant}{endpoint}" - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.TestRequest.test_simple_request/{version}" - ) - assert sent_request.headers["Authorization"] == f"Bearer {secret}" - - assert response is not None - assert response.status_code == 200 - assert response.json() == {"test": "test"} - - class TestRequestAsync: - @pytest.mark.anyio - async def test_simple_request_async( - self, - httpx_mock: HTTPXMock, - service: BaseService, - base_url: str, - org: str, - tenant: str, - version: str, - secret: str, - ): - endpoint = "/endpoint" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}{endpoint}", - status_code=200, - json={"test": "test"}, - ) - - response = await service.request_async("GET", endpoint) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert sent_request.url == f"{base_url}{org}{tenant}{endpoint}" - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.TestRequestAsync.test_simple_request_async/{version}" - ) - assert sent_request.headers["Authorization"] == f"Bearer {secret}" - - assert response is not None - assert response.status_code == 200 - assert response.json() == {"test": "test"} diff --git a/tests/sdk/services/test_buckets_service.py b/tests/sdk/services/test_buckets_service.py deleted file mode 100644 index 0fbb5f974..000000000 --- a/tests/sdk/services/test_buckets_service.py +++ /dev/null @@ -1,1939 +0,0 @@ -import os -from pathlib import Path - -import pytest -from pytest_httpx import HTTPXMock - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.orchestrator._buckets_service import BucketsService - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> BucketsService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return BucketsService(config=config, execution_context=execution_context) - - -@pytest.fixture -def temp_file(tmp_path): - """Create a temporary file for testing.""" - file_path = tmp_path / "test.txt" - file_path.write_text("test content") - return str(file_path) - - -class TestBucketsService: - class TestRetrieve: - def test_retrieve_by_key( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - bucket_key = "bucket-key" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", - status_code=200, - json={ - "value": [ - {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} - ] - }, - ) - - bucket = service.retrieve(key=bucket_key) - assert bucket.id == 123 - assert bucket.name == "test-bucket" - assert bucket.identifier == "bucket-key" - - def test_retrieve_by_name( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - bucket_name = "test-bucket" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq '{bucket_name}'&$top=1", - status_code=200, - json={ - "value": [ - {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} - ] - }, - ) - - bucket = service.retrieve(name=bucket_name) - assert bucket.id == 123 - assert bucket.name == "test-bucket" - assert bucket.identifier == "bucket-key" - - @pytest.mark.asyncio - async def test_retrieve_by_key_async( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - bucket_key = "bucket-key" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", - status_code=200, - json={ - "value": [ - {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} - ] - }, - ) - - bucket = await service.retrieve_async(key=bucket_key) - assert bucket.id == 123 - assert bucket.name == "test-bucket" - assert bucket.identifier == "bucket-key" - - @pytest.mark.asyncio - async def test_retrieve_by_name_async( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - bucket_name = "test-bucket" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq '{bucket_name}'&$top=1", - status_code=200, - json={ - "value": [ - {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} - ] - }, - ) - - bucket = await service.retrieve_async(name=bucket_name) - assert bucket.id == 123 - assert bucket.name == "test-bucket" - assert bucket.identifier == "bucket-key" - - class TestDownload: - def test_download( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - tmp_path: Path, - ): - bucket_key = "bucket-key" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", - status_code=200, - json={ - "value": [ - {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetReadUri?path=test-file.txt", - status_code=200, - json={ - "Uri": "https://test-storage.com/test-file.txt", - "Headers": {"Keys": [], "Values": []}, - "RequiresAuth": False, - }, - ) - - httpx_mock.add_response( - url="https://test-storage.com/test-file.txt", - status_code=200, - content=b"test content", - ) - - destination_path = str(tmp_path / "downloaded.txt") - service.download( - key=bucket_key, - blob_file_path="test-file.txt", - destination_path=destination_path, - ) - - assert os.path.exists(destination_path) - with open(destination_path, "rb") as f: - assert f.read() == b"test content" - - class TestUpload: - def test_upload_from_path( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - temp_file: str, - ): - bucket_key = "bucket-key" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", - status_code=200, - json={ - "value": [ - {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetWriteUri?path=test-file.txt&contentType=text/plain", - status_code=200, - json={ - "Uri": "https://test-storage.com/test-file.txt", - "Headers": {"Keys": [], "Values": []}, - "RequiresAuth": False, - }, - ) - - httpx_mock.add_response( - url="https://test-storage.com/test-file.txt", - status_code=200, - content=b"test content", - ) - - service.upload( - key=bucket_key, - blob_file_path="test-file.txt", - content_type="text/plain", - source_path=temp_file, - ) - - sent_requests = httpx_mock.get_requests() - assert len(sent_requests) == 3 - - assert sent_requests[2].method == "PUT" - assert sent_requests[2].url == "https://test-storage.com/test-file.txt" - - assert b"test content" in sent_requests[2].content - - def test_upload_from_memory( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - bucket_key = "bucket-key" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", - status_code=200, - json={ - "value": [ - {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetWriteUri?path=test-file.txt&contentType=text/plain", - status_code=200, - json={ - "Uri": "https://test-storage.com/test-file.txt", - "Headers": {"Keys": [], "Values": []}, - "RequiresAuth": False, - }, - ) - - httpx_mock.add_response( - url="https://test-storage.com/test-file.txt", - status_code=200, - content=b"test content", - ) - - service.upload( - key=bucket_key, - blob_file_path="test-file.txt", - content_type="text/plain", - content="test content", - ) - - sent_requests = httpx_mock.get_requests() - assert len(sent_requests) == 3 - - assert sent_requests[2].method == "PUT" - assert sent_requests[2].url == "https://test-storage.com/test-file.txt" - assert sent_requests[2].content == b"test content" - - -class TestList: - """Tests for list() method with auto-pagination.""" - - def test_list_all_buckets( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test listing buckets returns single page.""" - # Mock single page (100 items) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", - status_code=200, - json={ - "value": [ - {"Id": i, "Name": f"bucket-{i}", "Identifier": f"id-{i}"} - for i in range(100) - ] - }, - ) - - # Single page - no auto-pagination - buckets = list(service.list()) - assert len(buckets) == 100 - assert buckets[0].id == 0 - assert buckets[99].id == 99 - - def test_list_with_name_filter( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test filtering by bucket name.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100&$filter=contains%28tolower%28Name%29%2C+tolower%28%27test%27%29%29", - status_code=200, - json={ - "value": [ - {"Id": 1, "Name": "test-bucket", "Identifier": "id-1"}, - {"Id": 2, "Name": "another-test", "Identifier": "id-2"}, - ] - }, - ) - - buckets = list(service.list(name="test")) - assert len(buckets) == 2 - assert buckets[0].name == "test-bucket" - - def test_list_with_folder_path( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test listing with folder context.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", - status_code=200, - json={"value": [{"Id": 1, "Name": "bucket-1", "Identifier": "id-1"}]}, - match_headers={"x-uipath-folderpath": "Production"}, - ) - - buckets = list(service.list(folder_path="Production")) - assert len(buckets) == 1 - - def test_list_empty_results( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test list() with no buckets.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", - status_code=200, - json={"value": []}, - ) - - buckets = list(service.list()) - assert len(buckets) == 0 - - def test_list_pagination_stops_on_partial_page( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test pagination stops when fewer items than page size.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", - status_code=200, - json={ - "value": [ - {"Id": i, "Name": f"bucket-{i}", "Identifier": f"id-{i}"} - for i in range(30) - ] - }, - ) - - buckets = list(service.list()) - assert len(buckets) == 30 - # Verify only one request was made (no pagination) - assert len(httpx_mock.get_requests()) == 1 - - @pytest.mark.asyncio - async def test_list_async( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test async version of list().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", - status_code=200, - json={ - "value": [ - {"Id": i, "Name": f"bucket-{i}", "Identifier": f"id-{i}"} - for i in range(10) - ] - }, - ) - - buckets = [] - for bucket in (await service.list_async()).items: - buckets.append(bucket) - - assert len(buckets) == 10 - - -class TestExists: - """Tests for exists() method.""" - - def test_exists_bucket_found( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists() returns True when bucket found.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 1, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - assert service.exists("test-bucket") is True - - def test_exists_bucket_not_found( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists() returns False for LookupError.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'nonexistent'&$top=1", - status_code=200, - json={"value": []}, - ) - - assert service.exists("nonexistent") is False - - def test_exists_propagates_network_errors( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists() propagates non-LookupError exceptions.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'error-bucket'&$top=1", - status_code=500, - ) - - # Should raise exception (not return False) - from uipath.platform.errors import EnrichedException - - with pytest.raises(EnrichedException): - service.exists("error-bucket") - - @pytest.mark.asyncio - async def test_exists_async( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test async version of exists().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'async-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 1, "Name": "async-bucket", "Identifier": "id-1"}]}, - ) - - result = await service.exists_async("async-bucket") - assert result is True - - -class TestCreate: - """Tests for create() method.""" - - def test_create_with_auto_uuid( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test create() auto-generates UUID if not provided.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", - status_code=201, - json={"Id": 1, "Name": "new-bucket", "Identifier": "auto-uuid-123"}, - match_content=None, # We'll check the request separately - ) - - bucket = service.create("new-bucket") - assert bucket.id == 1 - assert bucket.name == "new-bucket" - - # Verify UUID was in request - requests = httpx_mock.get_requests() - assert len(requests) == 1 - import json - - body = json.loads(requests[0].content) - assert "Identifier" in body - assert len(body["Identifier"]) > 0 # UUID generated - - def test_create_with_explicit_uuid( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test create() uses provided UUID.""" - custom_uuid = "custom-uuid-456" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", - status_code=201, - json={ - "Id": 1, - "Name": "new-bucket", - "Identifier": custom_uuid, - }, - ) - - bucket = service.create("new-bucket", identifier=custom_uuid) - assert bucket.identifier == custom_uuid - - # Verify exact UUID in request - requests = httpx_mock.get_requests() - import json - - body = json.loads(requests[0].content) - assert body["Identifier"] == custom_uuid - - def test_create_with_description( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test create() includes description.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", - status_code=201, - json={ - "Id": 1, - "Name": "new-bucket", - "Identifier": "id-1", - "Description": "Test description", - }, - ) - - service.create("new-bucket", description="Test description") - - # Verify Description field in request body - requests = httpx_mock.get_requests() - import json - - body = json.loads(requests[0].content) - assert body["Description"] == "Test description" - - def test_create_with_folder_context( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test create() with folder_path.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", - status_code=201, - json={"Id": 1, "Name": "new-bucket", "Identifier": "id-1"}, - match_headers={"x-uipath-folderpath": "Production"}, - ) - - bucket = service.create("new-bucket", folder_path="Production") - assert bucket.id == 1 - - def test_create_name_escaping( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test bucket names with special chars don't break creation.""" - bucket_name = "Test's Bucket" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", - status_code=201, - json={"Id": 1, "Name": bucket_name, "Identifier": "id-1"}, - ) - - bucket = service.create(bucket_name) - assert bucket.name == bucket_name - - @pytest.mark.asyncio - async def test_create_async( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test async version of create().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", - status_code=201, - json={"Id": 1, "Name": "async-bucket", "Identifier": "id-1"}, - ) - - bucket = await service.create_async("async-bucket") - assert bucket.id == 1 - - -class TestEdgeCases: - """Tests for edge cases and error handling.""" - - def test_retrieve_with_quotes_in_name( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test bucket name with single quotes (OData escaping).""" - bucket_name = "Test's Bucket" - escaped_name = "Test''s Bucket" # OData escaping - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq '{escaped_name}'&$top=1", - status_code=200, - json={"value": [{"Id": 1, "Name": bucket_name, "Identifier": "id-1"}]}, - ) - - bucket = service.retrieve(name=bucket_name) - assert bucket.name == bucket_name - - def test_retrieve_key_not_found( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test retrieve by key raises LookupError.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='nonexistent')", - status_code=200, - json={"value": []}, - ) - - with pytest.raises(LookupError, match="key 'nonexistent' not found"): - service.retrieve(key="nonexistent") - - def test_retrieve_name_not_found( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test retrieve by name raises LookupError.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'nonexistent'&$top=1", - status_code=200, - json={"value": []}, - ) - - with pytest.raises(LookupError, match="name 'nonexistent' not found"): - service.retrieve(name="nonexistent") - - def test_list_handles_odata_collection_wrapper( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test list() handles OData 'value' array correctly.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", - status_code=200, - json={ - "value": [{"Id": 1, "Name": "bucket-1", "Identifier": "id-1"}], - "@odata.context": "https://example.com/$metadata#Buckets", - }, - ) - - buckets = list(service.list()) - assert len(buckets) == 1 - assert buckets[0].id == 1 - - -class TestListFiles: - """Tests for list_files() method (REST ListFiles API).""" - - def test_list_files_basic( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test basic file listing with list_files().""" - # Mock bucket retrieve - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - # Mock ListFiles response - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?takeHint=500", - status_code=200, - json={ - "items": [ - { - "fullPath": "/data/file1.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - }, - { - "fullPath": "/data/file2.txt", - "contentType": "text/plain", - "size": 200, - "lastModified": "2024-01-02T00:00:00Z", - }, - ], - "continuationToken": None, - }, - ) - - result = service.list_files(name="test-bucket") - - files = result.items - token = result.continuation_token - assert token is None # No more pages - assert len(files) == 2 - assert files[0].path == "/data/file1.txt" - assert files[0].size == 100 - assert files[1].path == "/data/file2.txt" - assert files[1].size == 200 - - def test_list_files_with_prefix( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test list_files() with prefix filter.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=data&takeHint=500", - status_code=200, - json={ - "items": [ - { - "fullPath": "/data/file1.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - ], - "continuationToken": None, - }, - ) - - result = service.list_files(name="test-bucket", prefix="data") - - files = result.items - token = result.continuation_token - assert token is None - assert len(files) == 1 - assert files[0].path == "/data/file1.txt" - - def test_list_files_pagination( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test list_files() handles pagination with continuationToken.""" - # Mock bucket retrieval (called twice - once for each page) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - # First page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?takeHint=500", - status_code=200, - json={ - "items": [ - { - "fullPath": f"/file{i}.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - for i in range(500) - ], - "continuationToken": "page2token", - }, - ) - - # Second page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?continuationToken=page2token&takeHint=500", - status_code=200, - json={ - "items": [ - { - "fullPath": f"/file{i}.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - for i in range(500, 550) - ], - "continuationToken": None, - }, - ) - - # Manual pagination - all_files = [] - token = None - - # First page - result = service.list_files(name="test-bucket", continuation_token=token) - - files = result.items - token = result.continuation_token - assert len(files) == 500 - assert token == "page2token" - all_files.extend(files) - - # Second page - result = service.list_files(name="test-bucket", continuation_token=token) - - files = result.items - token = result.continuation_token - assert len(files) == 50 - assert token is None # No more pages - all_files.extend(files) - - assert len(all_files) == 550 - - def test_list_files_empty( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test list_files() with empty bucket.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'empty-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 456, "Name": "empty-bucket", "Identifier": "id-2"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/456/ListFiles?takeHint=500", - status_code=200, - json={"items": [], "continuationToken": None}, - ) - - result = service.list_files(name="empty-bucket") - - files = result.items - token = result.continuation_token - assert token is None # No more pages - assert len(files) == 0 - - @pytest.mark.asyncio - async def test_list_files_async( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test async version of list_files().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?takeHint=500", - status_code=200, - json={ - "items": [ - { - "fullPath": "/async-file.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - ], - "continuationToken": None, - }, - ) - - result = await service.list_files_async(name="test-bucket") - files = result.items - token = result.continuation_token - assert token is None # No more pages - assert len(files) == 1 - assert files[0].path == "/async-file.txt" - - -class TestGetFiles: - """Tests for get_files() method (OData GetFiles API).""" - - def test_get_files_basic( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test basic file listing with get_files().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", - status_code=200, - json={ - "value": [ - { - "FullPath": "file1.txt", - "ContentType": "text/plain", - "Size": 100, - "IsDirectory": False, - }, - { - "FullPath": "file2.txt", - "ContentType": "text/plain", - "Size": 200, - "IsDirectory": False, - }, - ] - }, - ) - - files = list(service.get_files(name="test-bucket")) - assert len(files) == 2 - assert files[0].path == "file1.txt" - assert files[0].size == 100 - assert files[1].path == "file2.txt" - assert files[1].size == 200 - - def test_get_files_with_glob( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test get_files() with glob pattern.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&fileNameGlob=%2A.txt&%24top=500", - status_code=200, - json={ - "value": [ - { - "FullPath": "file1.txt", - "ContentType": "text/plain", - "Size": 100, - "IsDirectory": False, - } - ] - }, - ) - - files = list(service.get_files(name="test-bucket", file_name_glob="*.txt")) - assert len(files) == 1 - assert files[0].path == "file1.txt" - - def test_get_files_with_recursive( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test get_files() with recursive flag.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=docs&recursive=true&%24top=500", - status_code=200, - json={ - "value": [ - { - "FullPath": "docs/file1.txt", - "ContentType": "text/plain", - "Size": 100, - "IsDirectory": False, - }, - { - "FullPath": "docs/subdir/file2.txt", - "ContentType": "text/plain", - "Size": 200, - "IsDirectory": False, - }, - ] - }, - ) - - files = list( - service.get_files(name="test-bucket", prefix="docs", recursive=True) - ) - assert len(files) == 2 - assert files[1].path == "docs/subdir/file2.txt" - - def test_get_files_filters_directories( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test get_files() filters out directories.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", - status_code=200, - json={ - "value": [ - { - "FullPath": "file1.txt", - "ContentType": "text/plain", - "Size": 100, - "IsDirectory": False, - }, - { - "FullPath": "folder1", - "ContentType": None, - "Size": 0, - "IsDirectory": True, - }, - { - "FullPath": "file2.txt", - "ContentType": "text/plain", - "Size": 200, - "IsDirectory": False, - }, - ] - }, - ) - - files = list(service.get_files(name="test-bucket")) - # Should only get 2 files, directory should be filtered out - assert len(files) == 2 - assert all(not f.is_directory for f in files) - - def test_get_files_pagination( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test get_files() handles pagination with $skip and $top.""" - # Mock bucket retrieval (called twice - once for each page) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - # First page (full page of 500) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", - status_code=200, - json={ - "value": [ - { - "FullPath": f"file{i}.txt", - "ContentType": "text/plain", - "Size": 100, - "IsDirectory": False, - } - for i in range(500) - ] - }, - ) - - # Second page (partial page of 50) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24skip=500&%24top=500", - status_code=200, - json={ - "value": [ - { - "FullPath": f"file{i}.txt", - "ContentType": "text/plain", - "Size": 100, - "IsDirectory": False, - } - for i in range(500, 550) - ] - }, - ) - - # Manual pagination to get all files across both pages - all_files = [] - skip = 0 - while True: - result = service.get_files(name="test-bucket", skip=skip) - all_files.extend(result.items) - if not result.has_more: - break - skip += result.top - - assert len(all_files) == 550 - - def test_get_files_empty( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test get_files() with empty bucket.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'empty-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 456, "Name": "empty-bucket", "Identifier": "id-2"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(456)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", - status_code=200, - json={"value": []}, - ) - - files = list(service.get_files(name="empty-bucket")) - assert len(files) == 0 - - def test_get_files_without_last_modified( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test get_files() handles missing lastModified field.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", - status_code=200, - json={ - "value": [ - { - "FullPath": "file1.txt", - "ContentType": "text/plain", - "Size": 100, - "IsDirectory": False, - # Note: No LastModified field (GetFiles doesn't provide it) - } - ] - }, - ) - - files = list(service.get_files(name="test-bucket")) - assert len(files) == 1 - assert files[0].last_modified is None # Should be None, not error - - @pytest.mark.asyncio - async def test_get_files_async( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test async version of get_files().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", - status_code=200, - json={ - "value": [ - { - "FullPath": "async-file.txt", - "ContentType": "text/plain", - "Size": 100, - "IsDirectory": False, - } - ] - }, - ) - - result = await service.get_files_async(name="test-bucket") - files = result.items - assert len(files) == 1 - assert files[0].path == "async-file.txt" - - -class TestExistsFile: - """Tests for exists_file() method.""" - - def test_exists_file_found( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists_file() returns True when file is found.""" - # Mock bucket retrieve - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - # Mock ListFiles response with matching file (take_hint=1 for performance) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Fdata%2Ffile.txt&takeHint=1", - status_code=200, - json={ - "items": [ - { - "fullPath": "/data/file.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - ], - "continuationToken": None, - }, - ) - - result = service.exists_file(name="test-bucket", blob_file_path="data/file.txt") - assert result is True - - def test_exists_file_not_found( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists_file() returns False when file is not found.""" - # Mock bucket retrieve - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - # Mock ListFiles response with no matching files - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Fnonexistent.txt&takeHint=1", - status_code=200, - json={"items": [], "continuationToken": None}, - ) - - result = service.exists_file( - name="test-bucket", blob_file_path="nonexistent.txt" - ) - assert result is False - - def test_exists_file_bucket_not_found( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists_file() raises LookupError when bucket doesn't exist.""" - # Mock bucket retrieve returning empty (bucket not found) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'nonexistent-bucket'&$top=1", - status_code=200, - json={"value": []}, - ) - - # Should raise LookupError, not return False - with pytest.raises(LookupError, match="Bucket.*not found"): - service.exists_file( - name="nonexistent-bucket", blob_file_path="some-file.txt" - ) - - def test_exists_file_short_circuit_on_match( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists_file() stops iteration on first match (short-circuit).""" - # Mock bucket retrieve - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - # Mock first page with matching file - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Ftarget.txt&takeHint=1", - status_code=200, - json={ - "items": [ - { - "fullPath": "/target.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - ], - "continuationToken": "next-page-token", # Has more pages - }, - ) - - # Should not request second page since file was found on first page - result = service.exists_file(name="test-bucket", blob_file_path="target.txt") - assert result is True - - # Verify only 2 requests were made (retrieve + first page) - # NOT 3 requests (which would include second page) - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - def test_exists_file_searches_across_pages( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists_file() searches across multiple pages if needed.""" - # Mock bucket retrieve - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - # First page - no match - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Ftarget.txt&takeHint=1", - status_code=200, - json={ - "items": [ - { - "fullPath": "/other-file.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - ], - "continuationToken": "page2", - }, - ) - - # Second page - found - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Ftarget.txt&continuationToken=page2&takeHint=1", - status_code=200, - json={ - "items": [ - { - "fullPath": "/target.txt", - "contentType": "text/plain", - "size": 200, - "lastModified": "2024-01-02T00:00:00Z", - } - ], - "continuationToken": None, - }, - ) - - result = service.exists_file(name="test-bucket", blob_file_path="target.txt") - assert result is True - - # Should have made 3 requests (retrieve + page1 + page2) - requests = httpx_mock.get_requests() - assert len(requests) == 3 - - def test_exists_file_with_folder_context( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test exists_file() with folder_path parameter.""" - # Mock bucket retrieve with folder path - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Ffile.txt&takeHint=1", - status_code=200, - json={ - "items": [ - { - "fullPath": "/file.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - ], - "continuationToken": None, - }, - ) - - result = service.exists_file( - name="test-bucket", blob_file_path="file.txt", folder_path="Production" - ) - assert result is True - - @pytest.mark.asyncio - async def test_exists_file_async( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test async version of exists_file().""" - # Mock bucket retrieve - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - # Mock ListFiles response - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Fasync-file.txt&takeHint=1", - status_code=200, - json={ - "items": [ - { - "fullPath": "/async-file.txt", - "contentType": "text/plain", - "size": 100, - "lastModified": "2024-01-01T00:00:00Z", - } - ], - "continuationToken": None, - }, - ) - - result = await service.exists_file_async( - name="test-bucket", blob_file_path="async-file.txt" - ) - assert result is True - - @pytest.mark.asyncio - async def test_exists_file_async_not_found( - self, - httpx_mock: HTTPXMock, - service: BucketsService, - base_url: str, - org: str, - tenant: str, - ): - """Test async version returns False when file not found.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - status_code=200, - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Fmissing.txt&takeHint=1", - status_code=200, - json={"items": [], "continuationToken": None}, - ) - - result = await service.exists_file_async( - name="test-bucket", blob_file_path="missing.txt" - ) - assert result is False - - def test_exists_file_empty_path_raises_error(self, service: BucketsService): - """Test exists_file() raises ValueError for empty blob_file_path.""" - with pytest.raises(ValueError, match="blob_file_path cannot be empty"): - service.exists_file(name="test-bucket", blob_file_path="") - - def test_exists_file_whitespace_path_raises_error(self, service: BucketsService): - """Test exists_file() raises ValueError for whitespace-only blob_file_path.""" - with pytest.raises(ValueError, match="blob_file_path cannot be empty"): - service.exists_file(name="test-bucket", blob_file_path=" ") - - @pytest.mark.asyncio - async def test_exists_file_async_empty_path_raises_error( - self, service: BucketsService - ): - """Test async version raises ValueError for empty blob_file_path.""" - with pytest.raises(ValueError, match="blob_file_path cannot be empty"): - await service.exists_file_async(name="test-bucket", blob_file_path="") - - @pytest.mark.asyncio - async def test_exists_file_async_whitespace_path_raises_error( - self, service: BucketsService - ): - """Test async version raises ValueError for whitespace-only blob_file_path.""" - with pytest.raises(ValueError, match="blob_file_path cannot be empty"): - await service.exists_file_async(name="test-bucket", blob_file_path=" ") - - -class TestTopParameterValidation: - """Test top parameter validation for methods using 'top' parameter.""" - - # -------------------- list() tests -------------------- - - def test_list_top_exceeds_maximum(self, service: BucketsService): - """Test that top > 1000 raises ValueError for list().""" - with pytest.raises(ValueError, match=r"top must be <= 1000.*requested: 1001"): - service.list(top=1001) - - def test_list_top_at_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that top = 1000 is allowed for list().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=1000", - json={"value": [], "@odata.count": 0}, - ) - result = service.list(top=1000) - assert result is not None - assert len(result.items) == 0 - - def test_list_top_below_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that top = 999 is allowed for list().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=999", - json={"value": [], "@odata.count": 0}, - ) - result = service.list(top=999) - assert result is not None - - # -------------------- list_async() tests -------------------- - - @pytest.mark.asyncio - async def test_list_async_top_exceeds_maximum(self, service: BucketsService): - """Test that top > 1000 raises ValueError for list_async().""" - with pytest.raises(ValueError, match=r"top must be <= 1000.*requested: 2000"): - await service.list_async(top=2000) - - @pytest.mark.asyncio - async def test_list_async_top_at_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that top = 1000 is allowed for list_async().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=1000", - json={"value": [], "@odata.count": 0}, - ) - result = await service.list_async(top=1000) - assert result is not None - - @pytest.mark.asyncio - async def test_list_async_top_below_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that top = 999 is allowed for list_async().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=999", - json={"value": [], "@odata.count": 0}, - ) - result = await service.list_async(top=999) - assert result is not None - - # -------------------- get_files() tests -------------------- - - def test_get_files_top_exceeds_maximum(self, service: BucketsService): - """Test that top > 1000 raises ValueError for get_files().""" - with pytest.raises(ValueError, match=r"top must be <= 1000"): - service.get_files(name="test-bucket", top=1001) - - def test_get_files_top_at_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that top = 1000 is allowed for get_files().""" - # Mock bucket retrieval - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - # Mock file retrieval with GetFiles endpoint - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=1000", - json={"value": []}, - ) - result = service.get_files(name="test-bucket", top=1000) - assert result is not None - - def test_get_files_top_below_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that top = 999 is allowed for get_files().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=999", - json={"value": []}, - ) - result = service.get_files(name="test-bucket", top=999) - assert result is not None - - # -------------------- get_files_async() tests -------------------- - - @pytest.mark.asyncio - async def test_get_files_async_top_exceeds_maximum(self, service: BucketsService): - """Test that top > 1000 raises ValueError for get_files_async().""" - with pytest.raises(ValueError, match=r"top must be <= 1000"): - await service.get_files_async(name="test-bucket", top=1001) - - @pytest.mark.asyncio - async def test_get_files_async_top_at_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that top = 1000 is allowed for get_files_async().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=1000", - json={"value": []}, - ) - result = await service.get_files_async(name="test-bucket", top=1000) - assert result is not None - - @pytest.mark.asyncio - async def test_get_files_async_top_below_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that top = 999 is allowed for get_files_async().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=999", - json={"value": []}, - ) - result = await service.get_files_async(name="test-bucket", top=999) - assert result is not None - - # -------------------- skip parameter validation tests -------------------- - - def test_list_skip_exceeds_maximum(self, service: BucketsService): - """Test that skip > 10000 raises ValueError for list().""" - with pytest.raises( - ValueError, match=r"skip must be <= 10000.*requested: 10001" - ): - service.list(skip=10001) - - def test_list_skip_at_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that skip = 10000 is allowed for list().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=10000&$top=100", - json={"value": [], "@odata.count": 0}, - ) - result = service.list(skip=10000) - assert result is not None - - def test_list_skip_below_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that skip = 9999 is allowed for list().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=9999&$top=100", - json={"value": [], "@odata.count": 0}, - ) - result = service.list(skip=9999) - assert result is not None - - @pytest.mark.asyncio - async def test_list_async_skip_exceeds_maximum(self, service: BucketsService): - """Test that skip > 10000 raises ValueError for list_async().""" - with pytest.raises( - ValueError, match=r"skip must be <= 10000.*requested: 20000" - ): - await service.list_async(skip=20000) - - @pytest.mark.asyncio - async def test_list_async_skip_at_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that skip = 10000 is allowed for list_async().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=10000&$top=100", - json={"value": [], "@odata.count": 0}, - ) - result = await service.list_async(skip=10000) - assert result is not None - - def test_get_files_skip_exceeds_maximum(self, service: BucketsService): - """Test that skip > 10000 raises ValueError for get_files().""" - with pytest.raises(ValueError, match=r"skip must be <= 10000"): - service.get_files(name="test-bucket", skip=10001) - - def test_get_files_skip_at_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that skip = 10000 is allowed for get_files().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24skip=10000&%24top=500", - json={"value": []}, - ) - result = service.get_files(name="test-bucket", skip=10000) - assert result is not None - - @pytest.mark.asyncio - async def test_get_files_async_skip_exceeds_maximum(self, service: BucketsService): - """Test that skip > 10000 raises ValueError for get_files_async().""" - with pytest.raises(ValueError, match=r"skip must be <= 10000"): - await service.get_files_async(name="test-bucket", skip=10001) - - @pytest.mark.asyncio - async def test_get_files_async_skip_at_maximum( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that skip = 10000 is allowed for get_files_async().""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", - json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24skip=10000&%24top=500", - json={"value": []}, - ) - result = await service.get_files_async(name="test-bucket", skip=10000) - assert result is not None - - def test_combined_max_skip_and_top( - self, - service: BucketsService, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - ): - """Test that skip=10000 and top=1000 work together (combined boundary).""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=10000&$top=1000", - json={"value": [], "@odata.count": 0}, - ) - result = service.list(skip=10000, top=1000) - assert result is not None diff --git a/tests/sdk/services/test_connections_service.py b/tests/sdk/services/test_connections_service.py deleted file mode 100644 index d75f0643f..000000000 --- a/tests/sdk/services/test_connections_service.py +++ /dev/null @@ -1,1775 +0,0 @@ -import json -from unittest.mock import AsyncMock, MagicMock -from urllib.parse import unquote_plus - -import pytest -from pydantic import ValidationError -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.connections import ( - ActivityMetadata, - ActivityParameterLocationInfo, - Connection, - ConnectionMetadata, - ConnectionToken, - EventArguments, -) -from uipath.platform.connections._connections_service import ConnectionsService -from uipath.platform.orchestrator._folder_service import FolderService -from uipath.utils.dynamic_schema import jsonschema_to_pydantic - - -@pytest.fixture -def mock_folders_service() -> MagicMock: - """Mock FolderService for testing.""" - service = MagicMock(spec=FolderService) - service.retrieve_folder_key.return_value = "test-folder-key" - service.retrieve_folder_key_async = AsyncMock(return_value="test-folder-key") - return service - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - mock_folders_service: MagicMock, - monkeypatch: pytest.MonkeyPatch, -) -> ConnectionsService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return ConnectionsService( - config=config, - execution_context=execution_context, - folders_service=mock_folders_service, - ) - - -class TestConnectionsService: - def test_retrieve( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - connection_key = "test-connection" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}", - status_code=200, - json={ - "id": "test-id", - "name": "Test Connection", - "state": "active", - "elementInstanceId": 123, - }, - ) - - connection = service.retrieve(key=connection_key) - - assert isinstance(connection, Connection) - assert connection.id == "test-id" - assert connection.name == "Test Connection" - assert connection.state == "active" - assert connection.element_instance_id == 123 - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve/{version}" - ) - - def test_metadata( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - element_instance_id = 123 - connector_key = "test-connector" - tool_path = "test-tool" - valid_choice = { - "index": 0, - "finishReason": "done", - "message": {"content": "foo", "role": "user"}, - } - invalid_choice = { - "index": 0, - "finishReason": "done", - "message": {"content": 123, "role": "user"}, - } - valid_object = { - "choices": [valid_choice], - "usage": {"totalTokens": 100}, - "created": 1000, - } - invalid_object_1 = { - "choices": [valid_choice], - "usage": {"totalTokens": 100}, - "created": "string", - } - invalid_object_2 = { - "choices": [invalid_choice], - "usage": {"totalTokens": 100}, - "created": 1000, - } - json_schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "choices": { - "title": "Choices", - "type": "array", - "items": {"$ref": "#/definitions/choices"}, - }, - "usage": {"title": "Usage", "$ref": "#/definitions/usage"}, - "created": { - "title": "Creation timestamp", - "type": "integer", - "format": "int64", - }, - }, - "definitions": { - "message": { - "type": "object", - "title": "Message", - "properties": { - "content": { - "title": "Translated message content", - "type": "string", - }, - "role": { - "title": "Role of the message sender", - "type": "string", - }, - }, - }, - "choices": { - "type": "object", - "title": "Choices", - "properties": { - "index": { - "title": "Choice index", - "type": "integer", - "format": "int64", - }, - "finish_reason": { - "title": "Completion reason", - "type": "string", - }, - "message": { - "title": "Message", - "$ref": "#/definitions/message", - }, - }, - }, - "usage": { - "type": "object", - "title": "Usage", - "properties": { - "total_tokens": { - "title": "Total tokens used", - "type": "integer", - "format": "int64", - } - }, - }, - }, - } - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", - status_code=200, - json={ - "fields": json_schema, - }, - ) - - metadata = service.metadata(element_instance_id, connector_key, tool_path) - - assert isinstance(metadata, ConnectionMetadata) - dynamic_type = jsonschema_to_pydantic(metadata.fields) - - dynamic_type.model_validate(valid_object) - with pytest.raises(ValidationError): - assert dynamic_type.model_validate(invalid_object_1) - with pytest.raises(ValidationError): - assert dynamic_type.model_validate(invalid_object_2) - dynamic_type.model_json_schema() - - @pytest.mark.anyio - async def test_retrieve_async( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - connection_key = "test-connection" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}", - status_code=200, - json={ - "id": "test-id", - "name": "Test Connection", - "state": "active", - "elementInstanceId": 123, - }, - ) - - connection = await service.retrieve_async(key=connection_key) - - assert isinstance(connection, Connection) - assert connection.id == "test-id" - assert connection.name == "Test Connection" - assert connection.state == "active" - assert connection.element_instance_id == 123 - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_async/{version}" - ) - - async def test_metadata_async( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - element_instance_id = 123 - connector_key = "test-connector" - tool_path = "test-tool" - valid_choice = { - "index": 0, - "finishReason": "done", - "message": {"content": "foo", "role": "user"}, - } - invalid_choice = { - "index": 0, - "finishReason": "done", - "message": {"content": 123, "role": "user"}, - } - valid_object = { - "choices": [valid_choice], - "usage": {"totalTokens": 100}, - "created": 1000, - } - invalid_object_1 = { - "choices": [valid_choice], - "usage": {"totalTokens": 100}, - "created": "string", - } - invalid_object_2 = { - "choices": [invalid_choice], - "usage": {"totalTokens": 100}, - "created": 1000, - } - json_schema = { - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "choices": { - "title": "Choices", - "type": "array", - "items": {"$ref": "#/definitions/choices"}, - }, - "usage": {"title": "Usage", "$ref": "#/definitions/usage"}, - "created": { - "title": "Creation timestamp", - "type": "integer", - "format": "int64", - }, - }, - "definitions": { - "message": { - "type": "object", - "title": "Message", - "properties": { - "content": { - "title": "Translated message content", - "type": "string", - }, - "role": { - "title": "Role of the message sender", - "type": "string", - }, - }, - }, - "choices": { - "type": "object", - "title": "Choices", - "properties": { - "index": { - "title": "Choice index", - "type": "integer", - "format": "int64", - }, - "finish_reason": { - "title": "Completion reason", - "type": "string", - }, - "message": { - "title": "Message", - "$ref": "#/definitions/message", - }, - }, - }, - "usage": { - "type": "object", - "title": "Usage", - "properties": { - "total_tokens": { - "title": "Total tokens used", - "type": "integer", - "format": "int64", - } - }, - }, - }, - } - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", - status_code=200, - json={ - "fields": json_schema, - }, - ) - - metadata = await service.metadata_async( - element_instance_id, connector_key, tool_path - ) - - assert isinstance(metadata, ConnectionMetadata) - dynamic_type = jsonschema_to_pydantic(metadata.fields) - - dynamic_type.model_validate(valid_object) - with pytest.raises(ValidationError): - assert dynamic_type.model_validate(invalid_object_1) - with pytest.raises(ValidationError): - assert dynamic_type.model_validate(invalid_object_2) - dynamic_type.model_json_schema() - - def test_retrieve_token( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - connection_key = "test-connection" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}/token?tokenType=direct", - status_code=200, - json={ - "accessToken": "test-token", - "tokenType": "Bearer", - "expiresIn": 3600, - }, - ) - - token = service.retrieve_token(key=connection_key) - - assert isinstance(token, ConnectionToken) - assert token.access_token == "test-token" - assert token.token_type == "Bearer" - assert token.expires_in == 3600 - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}/token?tokenType=direct" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_token/{version}" - ) - - @pytest.mark.anyio - async def test_retrieve_token_async( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - connection_key = "test-connection" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}/token?tokenType=direct", - status_code=200, - json={ - "accessToken": "test-token", - "tokenType": "Bearer", - "expiresIn": 3600, - }, - ) - - token = await service.retrieve_token_async(key=connection_key) - - assert isinstance(token, ConnectionToken) - assert token.access_token == "test-token" - assert token.token_type == "Bearer" - assert token.expires_in == 3600 - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}/token?tokenType=direct" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_token_async/{version}" - ) - - def test_list_no_filters( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test list method without any filters.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", - status_code=200, - json={ - "value": [ - { - "id": "conn-1", - "name": "Slack Connection", - "state": "active", - "elementInstanceId": 101, - }, - { - "id": "conn-2", - "name": "Salesforce Connection", - "state": "active", - "elementInstanceId": 102, - }, - ] - }, - ) - - connections = service.list() - - assert isinstance(connections, list) - assert len(connections) == 2 - assert connections[0].id == "conn-1" - assert connections[0].name == "Slack Connection" - assert connections[1].id == "conn-2" - assert connections[1].name == "Salesforce Connection" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - # Check for URL-encoded version - assert "%24expand=connector%2Cfolder" in str(sent_request.url) - - def test_list_with_name_filter( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test list method with name filtering.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=contains%28Name%2C%20%27Salesforce%27%29&%24expand=connector%2Cfolder", - status_code=200, - json={ - "value": [ - { - "id": "conn-2", - "name": "Salesforce Connection", - "state": "active", - "elementInstanceId": 102, - } - ] - }, - ) - - connections = service.list(name="Salesforce") - - assert len(connections) == 1 - assert connections[0].name == "Salesforce Connection" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Decode URL-encoded characters (including + as space) - url_str = unquote_plus(str(sent_request.url)) - assert "contains(Name, 'Salesforce')" in url_str - - def test_list_with_folder_path_resolution( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - mock_folders_service: MagicMock, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test list method with folder path resolution.""" - mock_folders_service.retrieve_folder_key.return_value = "folder-123" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", - status_code=200, - json={"value": []}, - ) - - service.list(folder_path="Finance/Production") - - # Verify folder service was called - mock_folders_service.retrieve_folder_key.assert_called_once_with( - "Finance/Production" - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Verify the resolved key was used in headers - assert HEADER_FOLDER_KEY in sent_request.headers - assert sent_request.headers[HEADER_FOLDER_KEY] == "folder-123" - - def test_list_with_connector_filter( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test list method with connector key filtering.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=connector%2Fkey%20eq%20%27uipath-slack%27&%24expand=connector%2Cfolder", - status_code=200, - json={ - "value": [ - { - "id": "conn-1", - "name": "Slack Connection", - "state": "active", - "elementInstanceId": 101, - } - ] - }, - ) - - connections = service.list(connector_key="uipath-slack") - - assert len(connections) == 1 - assert connections[0].name == "Slack Connection" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Decode URL-encoded characters (including + as space) - url_str = unquote_plus(str(sent_request.url)) - assert "connector/key eq 'uipath-slack'" in url_str - - def test_list_with_pagination( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test list method with pagination parameters.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24skip=10&%24top=5&%24expand=connector%2Cfolder", - status_code=200, - json={"value": []}, - ) - - service.list(skip=10, top=5) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert "%24skip=10" in str(sent_request.url) - assert "%24top=5" in str(sent_request.url) - - def test_list_with_combined_filters( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - mock_folders_service: MagicMock, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test list method with multiple filters combined.""" - mock_folders_service.retrieve_folder_key.return_value = "folder-456" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=contains%28Name%2C%20%27Slack%27%29%20and%20connector%2Fkey%20eq%20%27uipath-slack%27&%24expand=connector%2Cfolder", - status_code=200, - json={"value": []}, - ) - - service.list(name="Slack", folder_path="Finance", connector_key="uipath-slack") - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Decode URL-encoded characters (including + as space) - url_str = unquote_plus(str(sent_request.url)) - assert "contains(Name, 'Slack')" in url_str - assert "connector/key eq 'uipath-slack'" in url_str - assert " and " in url_str - - @pytest.mark.anyio - async def test_list_async( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test async version of list method.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", - status_code=200, - json={ - "value": [ - { - "id": "conn-1", - "name": "Test Connection", - "state": "active", - "elementInstanceId": 101, - } - ] - }, - ) - - connections = await service.list_async() - - assert len(connections) == 1 - assert connections[0].name == "Test Connection" - - def test_retrieve_event_payload( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - event_id = "test-event-id" - additional_event_data = '{"processedEventId": "test-event-id"}' - - event_args = EventArguments(additional_event_data=additional_event_data) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}", - status_code=200, - json={ - "eventId": event_id, - "eventType": "test-event", - "data": {"key": "value"}, - "timestamp": "2025-08-12T10:00:00Z", - }, - ) - - payload = service.retrieve_event_payload(event_args=event_args) - - assert payload["eventId"] == event_id - assert payload["eventType"] == "test-event" - assert payload["data"]["key"] == "value" - assert payload["timestamp"] == "2025-08-12T10:00:00Z" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_event_payload/{version}" - ) - - def test_retrieve_event_payload_with_raw_event_id( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - event_id = "test-raw-event-id" - additional_event_data = '{"rawEventId": "test-raw-event-id"}' - - event_args = EventArguments(additional_event_data=additional_event_data) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}", - status_code=200, - json={ - "eventId": event_id, - "eventType": "test-raw-event", - "data": {"rawKey": "rawValue"}, - }, - ) - - payload = service.retrieve_event_payload(event_args=event_args) - - assert payload["eventId"] == event_id - assert payload["eventType"] == "test-raw-event" - assert payload["data"]["rawKey"] == "rawValue" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}" - ) - - def test_retrieve_event_payload_missing_additional_event_data( - self, - service: ConnectionsService, - ) -> None: - event_args = EventArguments(additional_event_data=None) - - with pytest.raises(ValueError, match="additional_event_data is required"): - service.retrieve_event_payload(event_args=event_args) - - def test_retrieve_event_payload_missing_event_id( - self, - service: ConnectionsService, - ) -> None: - additional_event_data = '{"someOtherField": "value"}' - event_args = EventArguments(additional_event_data=additional_event_data) - - with pytest.raises( - ValueError, match="Event Id not found in additional event data" - ): - service.retrieve_event_payload(event_args=event_args) - - @pytest.mark.anyio - async def test_retrieve_event_payload_async( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - event_id = "test-event-id-async" - additional_event_data = '{"processedEventId": "test-event-id-async"}' - - event_args = EventArguments(additional_event_data=additional_event_data) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}", - status_code=200, - json={ - "eventId": event_id, - "eventType": "test-async-event", - "data": {"asyncKey": "asyncValue"}, - "timestamp": "2025-08-12T11:00:00Z", - }, - ) - - payload = await service.retrieve_event_payload_async(event_args=event_args) - - assert payload["eventId"] == event_id - assert payload["eventType"] == "test-async-event" - assert payload["data"]["asyncKey"] == "asyncValue" - assert payload["timestamp"] == "2025-08-12T11:00:00Z" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_event_payload_async/{version}" - ) - - @pytest.mark.anyio - async def test_retrieve_event_payload_async_with_raw_event_id( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - event_id = "test-raw-event-id-async" - additional_event_data = '{"rawEventId": "test-raw-event-id-async"}' - - event_args = EventArguments(additional_event_data=additional_event_data) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}", - status_code=200, - json={ - "eventId": event_id, - "eventType": "test-async-raw-event", - "data": {"asyncRawKey": "asyncRawValue"}, - }, - ) - - payload = await service.retrieve_event_payload_async(event_args=event_args) - - assert payload["eventId"] == event_id - assert payload["eventType"] == "test-async-raw-event" - assert payload["data"]["asyncRawKey"] == "asyncRawValue" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}" - ) - - @pytest.mark.anyio - async def test_retrieve_event_payload_async_missing_additional_event_data( - self, - service: ConnectionsService, - ) -> None: - event_args = EventArguments(additional_event_data=None) - - with pytest.raises(ValueError, match="additional_event_data is required"): - await service.retrieve_event_payload_async(event_args=event_args) - - @pytest.mark.anyio - async def test_retrieve_event_payload_async_missing_event_id( - self, - service: ConnectionsService, - ) -> None: - additional_event_data = '{"someOtherField": "value"}' - event_args = EventArguments(additional_event_data=additional_event_data) - - with pytest.raises( - ValueError, match="Event Id not found in additional event data" - ): - await service.retrieve_event_payload_async(event_args=event_args) - - def test_list_with_name_containing_quote( - self, httpx_mock: HTTPXMock, service: ConnectionsService - ) -> None: - """Test that names with quotes are properly escaped.""" - httpx_mock.add_response(json={"value": []}) - - service.list(name="O'Malley") - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Verify the single quote was doubled (escaped) in the OData filter - # The URL should contain O''Malley (with doubled single quote) - url_str = str(sent_request.url) - # Check that the filter contains the escaped quote - assert "O%27%27Malley" in url_str or "O''Malley" in url_str.replace( - "%27%27", "''" - ) - - def test_list_with_raw_list_response( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test that list method handles raw list responses (not wrapped in 'value').""" - # Some API endpoints return a raw list instead of OData format - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", - status_code=200, - json=[ - { - "id": "conn-1", - "name": "Direct List Connection", - "state": "active", - "elementInstanceId": 101, - } - ], - ) - - connections = service.list() - - assert isinstance(connections, list) - assert len(connections) == 1 - assert connections[0].id == "conn-1" - assert connections[0].name == "Direct List Connection" - - def test_get_jit_action_url_with_api_action( - self, service: ConnectionsService - ) -> None: - """Test _get_jit_action_url extracts URL from first API action.""" - metadata = ConnectionMetadata( - fields={}, - metadata={ - "method": { - "POST": { - "design": { - "actions": [ - { - "actionType": "reset", - "name": "Reset Form", - }, - { - "actionType": "api", - "name": "Load Issue Types", - "apiConfiguration": { - "method": "GET", - "url": "elements/jira/projects/{project.id}/issuetypes", - }, - }, - ] - } - } - } - }, - ) - - url = service._get_jit_action_url(metadata) - - assert url == "elements/jira/projects/{project.id}/issuetypes" - - def test_metadata_with_jit_parameters( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test metadata() triggers JIT fetch when parameters are provided.""" - element_instance_id = 123 - connector_key = "uipath-jira" - tool_path = "Issue" - parameters = {"project.id": "PROJ-123"} - - # Mock initial metadata response - initial_response = { - "fields": { - "project.id": {"type": "string", "displayName": "Project ID"}, - "summary": {"type": "string", "displayName": "Summary"}, - }, - "metadata": { - "method": { - "POST": { - "design": { - "actions": [ - { - "actionType": "api", - "apiConfiguration": { - "url": "elements/jira/projects/{project.id}/issuetypes" - }, - } - ] - } - } - } - }, - } - - # Mock JIT metadata response - jit_response = { - "fields": { - "CustomIssueType": { - "type": "string", - "displayName": "Custom Issue Type", - }, - }, - } - - # Add mock responses - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", - json=initial_response, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/jira/projects/PROJ-123/issuetypes", - json=jit_response, - ) - - metadata = service.metadata( - element_instance_id, connector_key, tool_path, parameters - ) - - # Should return JIT metadata - assert isinstance(metadata, ConnectionMetadata) - assert "CustomIssueType" in metadata.fields - - # Verify both requests were made - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) - async def test_metadata_with_max_jit_depth( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test metadata() stops at max JIT depth to prevent infinite loops.""" - element_instance_id = 123 - connector_key = "uipath-jira" - tool_path = "Issue" - parameters = {"param": "value"} - max_jit_depth = 5 - - # Create a response that always has another action (infinite chain) - def create_response_with_action(level: int): - return { - "fields": { - f"field_level_{level}": { - "type": "string", - "displayName": f"Field Level {level}", - }, - }, - "metadata": { - "method": { - "POST": { - "design": { - "actions": [ - { - "actionType": "api", - "apiConfiguration": { - "url": f"elements/jira/level{level + 1}" - }, - } - ] - } - } - } - }, - } - - # Add initial response - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", - json=create_response_with_action(0), - ) - - # Add 10 more levels (more than max JIT depth) to test limit - for level in range(1, 11): - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/jira/level{level}", - json=create_response_with_action(level), - ) - - metadata = service.metadata( - element_instance_id, connector_key, tool_path, parameters, max_jit_depth - ) - - # Should return metadata from level 5 (stopped at max JIT depth) - assert isinstance(metadata, ConnectionMetadata) - assert "field_level_5" in metadata.fields - - # Verify exactly 6 requests were made (initial + 5 JIT levels) - requests = httpx_mock.get_requests() - assert len(requests) == 6 - - def test_metadata_stops_on_repeated_url( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test metadata() stops early when action URL repeats.""" - element_instance_id = 123 - connector_key = "uipath-jira" - tool_path = "Issue" - parameters = {"project.id": "PROJ-123"} - - # First response with action URL - level1_response = { - "fields": { - "field1": {"type": "string", "displayName": "Field 1"}, - }, - "metadata": { - "method": { - "POST": { - "design": { - "actions": [ - { - "actionType": "api", - "apiConfiguration": { - "url": "elements/jira/projects/{project.id}/metadata" - }, - } - ] - } - } - } - }, - } - - # Second response with the same action URL - level2_response = { - "fields": { - "field2": {"type": "string", "displayName": "Field 2"}, - }, - "metadata": { - "method": { - "POST": { - "design": { - "actions": [ - { - "actionType": "api", - "apiConfiguration": { - "url": "elements/jira/projects/{project.id}/metadata" - }, - } - ] - } - } - } - }, - } - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", - json=level1_response, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/jira/projects/PROJ-123/metadata", - json=level2_response, - ) - - metadata = service.metadata( - element_instance_id, connector_key, tool_path, parameters - ) - - # Should return metadata from level 2 (stopped because next URL is same) - assert isinstance(metadata, ConnectionMetadata) - assert "field2" in metadata.fields - - # Verify exactly 2 requests were made (initial + 1 JIT level, then stopped) - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - -@pytest.fixture -def simple_activity_metadata() -> ActivityMetadata: - """Simple activity metadata for non-path tests.""" - return ActivityMetadata( - object_path="/elements/test-connector/test-activity", - method_name="POST", - content_type="application/json", - parameter_location_info=ActivityParameterLocationInfo( - query_params=["query_param", "query_param2"], - header_params=["custom_header", "custom_header2"], - path_params=[], - multipart_params=[], - body_fields=["body_field1", "body_field2", "body_field3"], - ), - ) - - -@pytest.fixture -def path_activity_metadata() -> ActivityMetadata: - """Sample activity metadata for testing with all parameter types.""" - return ActivityMetadata( - object_path="/elements/test-connector/users/{userId}/posts/{postId}", - method_name="POST", - content_type="application/json", - parameter_location_info=ActivityParameterLocationInfo( - query_params=[], - header_params=[], - path_params=["userId", "postId"], - multipart_params=[], - body_fields=[], - ), - ) - - -@pytest.fixture -def multipart_activity_metadata() -> ActivityMetadata: - """Sample multipart activity metadata for testing.""" - return ActivityMetadata( - object_path="/elements/test-connector/upload", - method_name="POST", - content_type="multipart/form-data", - parameter_location_info=ActivityParameterLocationInfo( - query_params=[], - header_params=[], - path_params=[], - multipart_params=["file_param"], - body_fields=["description"], - ), - json_body_section="body", - ) - - -@pytest.fixture -def multipart_custom_section_metadata() -> ActivityMetadata: - """Sample multipart activity metadata with custom json_body_section.""" - return ActivityMetadata( - object_path="/elements/test-connector/rag", - method_name="POST", - content_type="multipart/form-data", - parameter_location_info=ActivityParameterLocationInfo( - query_params=[], - header_params=[], - path_params=[], - multipart_params=["file_param"], - body_fields=["prompt", "model"], - ), - json_body_section="RagRequest", - ) - - -class TestConnectorActivityInvocation: - def test_invoke_activity_with_query_params( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - simple_activity_metadata: ActivityMetadata, - ) -> None: - """Test invoking with query parameters only.""" - connection_id = "test-connection-123" - activity_input = { - "query_param": "test search query", - "query_param2": "additional query", - } - expected_response = {"results": [], "total": 0} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - _ = service.invoke_activity( - activity_metadata=simple_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Check query parameters - assert sent_request.url.params["query_param"] == "test search query" - assert sent_request.url.params["query_param2"] == "additional query" - - def test_invoke_activity_with_header_params( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - simple_activity_metadata: ActivityMetadata, - ) -> None: - """Test invoking with header parameters only.""" - connection_id = "test-connection-123" - activity_input = { - "custom_header": "secret-api-key", - "custom_header2": "client-123", - } - expected_response = {"authenticated": True} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - _ = service.invoke_activity( - activity_metadata=simple_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Check custom headers - assert sent_request.headers["custom_header"] == "secret-api-key" - assert sent_request.headers["custom_header2"] == "client-123" - - def test_invoke_activity_sets_standard_headers( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - simple_activity_metadata: ActivityMetadata, - ) -> None: - """Test invoking sets standard headers correctly.""" - connection_id = "test-connection-123" - activity_input = { - "body_field1": "Test Item", - } - expected_response = {"status": "success"} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - _ = service.invoke_activity( - activity_metadata=simple_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Check standard headers - assert sent_request.headers["x-uipath-originator"] == "uipath-python" - assert sent_request.headers["x-uipath-source"] == "uipath-python" - - def test_invoke_activity_with_body_fields( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - simple_activity_metadata: ActivityMetadata, - ) -> None: - """Test invoking with JSON body fields only.""" - connection_id = "test-connection-123" - activity_input = { - "body_field1": "Test Item", - "body_field2": "This is a test item", - "body_field3": "high", - } - expected_response = {"id": 456, "status": "created"} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - _ = service.invoke_activity( - activity_metadata=simple_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Check JSON body - request_json = json.loads(sent_request.content.decode()) - assert request_json == { - "body_field1": "Test Item", - "body_field2": "This is a test item", - "body_field3": "high", - } - - def test_invoke_activity_with_path_parameters( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - path_activity_metadata: ActivityMetadata, - ) -> None: - """Test invoking with path parameters only.""" - connection_id = "test-connection-123" - activity_input = { - "userId": "user456", - "postId": "post789", - } - expected_response = {"user": "user456", "post": "post789"} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - _ = service.invoke_activity( - activity_metadata=path_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # Verify URL path substitution worked correctly - assert sent_request.url.path.endswith( - "/elements_/v3/element/instances/test-connection-123/elements/test-connector/users/user456/posts/post789" - ) - - def test_invoke_activity_multipart_request( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - multipart_activity_metadata: ActivityMetadata, - ) -> None: - """Test invoking an Integration Service activity with multipart content.""" - connection_id = "test-connection-123" - activity_input = { - "file_param": b"test file content", - "description": "Test file upload", - } - expected_response = {"upload_id": "upload123", "status": "success"} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - _ = service.invoke_activity( - activity_metadata=multipart_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert "multipart/form-data" in sent_request.headers.get("content-type", "") - - @pytest.mark.asyncio - async def test_invoke_activity_async_json_request( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - simple_activity_metadata: ActivityMetadata, - ) -> None: - """Test async invocation of an Integration Service activity.""" - connection_id = "test-connection-123" - activity_input = { - "query_param": "test_query", - "body_field1": "async_value1", - "body_field2": "async_value2", - } - expected_response = {"result": "async_success", "data": {"id": 456}} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - response = await service.invoke_activity_async( - activity_metadata=simple_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - assert response == expected_response - - def test_invoke_activity_with_none_values_filtered( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - simple_activity_metadata: ActivityMetadata, - ) -> None: - """Test that None values are filtered out from the request.""" - connection_id = "test-connection-123" - activity_input = { - "query_param": "test_query", - "custom_header": None, # This should be filtered out - "body_field1": "value1", - "body_field2": None, # This should be filtered out - } - expected_response = {"result": "success"} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - _ = service.invoke_activity( - activity_metadata=simple_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - # custom_header should not be present since it was None - assert "custom_header" not in sent_request.headers - - # Only non-None body fields should be present - request_json = json.loads(sent_request.content.decode()) - assert request_json == {"body_field1": "value1"} - - def test_invoke_activity_unknown_parameter_raises_error( - self, - service: ConnectionsService, - simple_activity_metadata: ActivityMetadata, - ) -> None: - """Test that unknown parameters raise a ValueError.""" - connection_id = "test-connection-123" - activity_input = { - "unknown_param": "value", # This parameter doesn't exist in metadata - } - - with pytest.raises( - ValueError, - match="Parameter unknown_param does not exist in activity metadata", - ): - service.invoke_activity( - activity_metadata=simple_activity_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - def test_invoke_activity_unsupported_content_type_raises_error( - self, - service: ConnectionsService, - ) -> None: - """Test that unsupported content types raise a ValueError.""" - unsupported_metadata = ActivityMetadata( - object_path="/elements/test-connector/test-activity", - method_name="POST", - content_type="application/xml", # Unsupported content type - parameter_location_info=ActivityParameterLocationInfo( - query_params=[], - header_params=[], - path_params=[], - multipart_params=[], - body_fields=["xml_data"], - ), - ) - - connection_id = "test-connection-123" - activity_input = {"xml_data": "data"} - - with pytest.raises( - ValueError, match="Unsupported content type: application/xml" - ): - service.invoke_activity( - activity_metadata=unsupported_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - def test_invoke_activity_empty_input( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - ) -> None: - """Test invoking with empty input.""" - activity_metadata = ActivityMetadata( - object_path="/elements/test-connector/ping", - method_name="GET", - content_type="application/json", - parameter_location_info=ActivityParameterLocationInfo( - query_params=[], - header_params=[], - path_params=[], - multipart_params=[], - body_fields=[], - ), - ) - - connection_id = "test-connection-123" - expected_response = {"status": "pong"} - - httpx_mock.add_response( - method="GET", - status_code=200, - json=expected_response, - ) - - result = service.invoke_activity( - activity_metadata=activity_metadata, - connection_id=connection_id, - activity_input={}, - ) - - assert result == expected_response - - def test_invoke_activity_multipart_custom_json_body_section( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - multipart_custom_section_metadata: ActivityMetadata, - ) -> None: - """Test multipart request uses custom json_body_section name instead of 'body'.""" - connection_id = "test-connection-123" - activity_input = { - "file_param": b"test file content", - "prompt": "Summarize this document", - "model": "gpt-4", - } - expected_response = {"result": "summary text"} - - httpx_mock.add_response( - method="POST", - status_code=200, - json=expected_response, - ) - - _ = service.invoke_activity( - activity_metadata=multipart_custom_section_metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert "multipart/form-data" in sent_request.headers.get("content-type", "") - - # Parse the multipart body to verify part names - content_type = sent_request.headers["content-type"] - boundary = content_type.split("boundary=")[1] - body = sent_request.content.decode("utf-8", errors="replace") - parts = body.split(f"--{boundary}") - - # Find the part names in the multipart body - part_names = [] - for part in parts: - if 'name="' in part: - name = part.split('name="')[1].split('"')[0] - part_names.append(name) - - # The JSON body should be in "RagRequest" part, not "body" - assert "RagRequest" in part_names - assert "body" not in part_names - assert "file_param" in part_names - - def test_invoke_activity_multipart_default_json_body_section( - self, - httpx_mock: HTTPXMock, - service: ConnectionsService, - ) -> None: - """Test multipart request defaults to 'body' when json_body_section is None.""" - metadata = ActivityMetadata( - object_path="/elements/test-connector/upload", - method_name="POST", - content_type="multipart/form-data", - parameter_location_info=ActivityParameterLocationInfo( - query_params=[], - header_params=[], - path_params=[], - multipart_params=["file_param"], - body_fields=["description"], - ), - # json_body_section is None (default) - ) - connection_id = "test-connection-123" - activity_input = { - "file_param": b"file data", - "description": "A file", - } - - httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) - - _ = service.invoke_activity( - activity_metadata=metadata, - connection_id=connection_id, - activity_input=activity_input, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - content_type = sent_request.headers["content-type"] - boundary = content_type.split("boundary=")[1] - body = sent_request.content.decode("utf-8", errors="replace") - parts = body.split(f"--{boundary}") - - part_names = [] - for part in parts: - if 'name="' in part: - name = part.split('name="')[1].split('"')[0] - part_names.append(name) - - # Should default to "body" when json_body_section is None - assert "body" in part_names - assert "file_param" in part_names diff --git a/tests/sdk/services/test_context_grounding_service.py b/tests/sdk/services/test_context_grounding_service.py deleted file mode 100644 index 2e78c20ea..000000000 --- a/tests/sdk/services/test_context_grounding_service.py +++ /dev/null @@ -1,2424 +0,0 @@ -import json -from unittest.mock import MagicMock, patch - -import pytest -from pydantic import ValidationError -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT, LLMV3Mini_REQUEST -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.context_grounding import ( - BatchTransformCreationResponse, - BatchTransformOutputColumn, - BatchTransformResponse, - BatchTransformStatus, - BucketSourceConfig, - Citation, - CitationMode, - ConfluenceSourceConfig, - ContextGroundingIndex, - ContextGroundingQueryResponse, - DeepRagCreationResponse, - DeepRagResponse, - DropboxSourceConfig, - GoogleDriveSourceConfig, - Indexer, - OneDriveSourceConfig, -) -from uipath.platform.context_grounding._context_grounding_service import ( - ContextGroundingService, -) -from uipath.platform.orchestrator._buckets_service import BucketsService -from uipath.platform.orchestrator._folder_service import FolderService - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> ContextGroundingService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - folders_service = FolderService(config=config, execution_context=execution_context) - buckets_service = BucketsService(config=config, execution_context=execution_context) - return ContextGroundingService( - config=config, - execution_context=execution_context, - folders_service=folders_service, - buckets_service=buckets_service, - ) - - -class TestContextGroundingService: - def test_search( - 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_/v1/search", - status_code=200, - json=[ - { - "source": "test-source", - "page_number": "1", - "content": "Test content", - "metadata": { - "operation_id": "test-op", - "strategy": "test-strategy", - }, - "score": 0.95, - } - ], - ) - - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - response = service.search( - name="test-index", query="test query", number_of_results=1 - ) - - assert isinstance(response, list) - assert len(response) == 1 - assert isinstance(response[0], ContextGroundingQueryResponse) - assert response[0].source == "test-source" - assert response[0].page_number == "1" - assert response[0].content == "Test content" - assert response[0].metadata.operation_id == "test-op" - assert response[0].metadata.strategy == "test-strategy" - assert response[0].score == 0.95 - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert sent_requests[3].url == f"{base_url}{org}{tenant}/ecs_/v1/search" - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.search/{version}" - ) - - @pytest.mark.anyio - async def test_search_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_/v1/search", - status_code=200, - json=[ - { - "source": "test-source", - "page_number": "1", - "content": "Test content", - "metadata": { - "operation_id": "test-op", - "strategy": "test-strategy", - }, - "score": 0.95, - } - ], - ) - - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - response = await service.search_async( - name="test-index", query="test query", number_of_results=1 - ) - - assert isinstance(response, list) - assert len(response) == 1 - assert isinstance(response[0], ContextGroundingQueryResponse) - assert response[0].source == "test-source" - assert response[0].page_number == "1" - assert response[0].content == "Test content" - assert response[0].metadata.operation_id == "test-op" - assert response[0].metadata.strategy == "test-strategy" - assert response[0].score == 0.95 - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert sent_requests[3].url == f"{base_url}{org}{tenant}/ecs_/v1/search" - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.search_async/{version}" - ) - - def test_retrieve( - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - index = service.retrieve(name="test-index") - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "test-index-id" - assert index.name == "test-index" - assert index.last_ingestion_status == "Completed" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[1].method == "GET" - assert ( - sent_requests[1].url - == f"{base_url}{org}{tenant}/ecs_/v2/indexes?%24filter=Name+eq+%27test-index%27&%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.retrieve/{version}" - ) - - @pytest.mark.anyio - async def test_retrieve_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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - index = await service.retrieve_async(name="test-index") - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "test-index-id" - assert index.name == "test-index" - assert index.last_ingestion_status == "Completed" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[1].method == "GET" - assert ( - sent_requests[1].url - == f"{base_url}{org}{tenant}/ecs_/v2/indexes?%24filter=Name+eq+%27test-index%27&%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.retrieve_async/{version}" - ) - - def test_create_index_bucket( - 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/create", - status_code=200, - json={ - "id": "new-index-id", - "name": "test-bucket-index", - "description": "Test bucket index", - "lastIngestionStatus": "Queued", - "dataSource": {"bucketName": "test-bucket", "folder": "/test/folder"}, - }, - ) - - source = BucketSourceConfig( - bucket_name="test-bucket", - folder_path="/test/folder", - directory_path="/", - file_type="pdf", - ) - - index = service.create_index( - name="test-bucket-index", - description="Test bucket index", - source=source, - advanced_ingestion=True, - ) - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "new-index-id" - assert index.name == "test-bucket-index" - assert index.description == "Test bucket index" - assert index.last_ingestion_status == "Queued" - - sent_requests = httpx_mock.get_requests() - assert len(sent_requests) == 2 - - create_request = sent_requests[1] - assert create_request.method == "POST" - assert create_request.url == f"{base_url}{org}{tenant}/ecs_/v2/indexes/create" - assert HEADER_USER_AGENT in create_request.headers - assert ( - create_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_index/{version}" - ) - - request_data = json.loads(create_request.content) - assert request_data["name"] == "test-bucket-index" - assert request_data["description"] == "Test bucket index" - assert ( - request_data["dataSource"]["@odata.type"] - == "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSourceRequest" - ) - assert request_data["dataSource"]["bucketName"] == "test-bucket" - assert request_data["dataSource"]["folder"] == "/test/folder" - assert request_data["dataSource"]["directoryPath"] == "/" - assert request_data["dataSource"]["fileNameGlob"] == "**/*.pdf" - assert ( - request_data["preProcessing"]["@odata.type"] - == "#UiPath.Vdbs.Domain.Api.V20Models.LLMV4PreProcessingRequest" - ) - - def test_create_index_google_drive( - 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/create", - status_code=200, - json={ - "id": "google-index-id", - "name": "test-google-index", - "description": "Test Google Drive index", - "lastIngestionStatus": "Queued", - "dataSource": {"connectionId": "conn-123", "folder": "/test/folder"}, - }, - ) - - source = GoogleDriveSourceConfig( - connection_id="conn-123", - connection_name="Google Drive Connection", - leaf_folder_id="folder-456", - directory_path="/shared-docs", - folder_path="/test/folder", - file_type="docx", - indexer=Indexer( - cron_expression="0 18 * * 2", time_zone_id="Pacific Standard Time" - ), - ) - - index = service.create_index( - name="test-google-index", - description="Test Google Drive index", - source=source, - ) - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "google-index-id" - assert index.name == "test-google-index" - - sent_requests = httpx_mock.get_requests() - create_request = sent_requests[1] - - request_data = json.loads(create_request.content) - assert ( - request_data["dataSource"]["@odata.type"] - == "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSourceRequest" - ) - assert request_data["dataSource"]["connectionId"] == "conn-123" - assert request_data["dataSource"]["connectionName"] == "Google Drive Connection" - assert request_data["dataSource"]["leafFolderId"] == "folder-456" - assert request_data["dataSource"]["directoryPath"] == "/shared-docs" - assert request_data["dataSource"]["fileNameGlob"] == "**/*.docx" - assert request_data["dataSource"]["indexer"]["cronExpression"] == "0 18 * * 2" - assert ( - request_data["dataSource"]["indexer"]["timeZoneId"] - == "Pacific Standard Time" - ) - - def test_create_index_dropbox( - 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/create", - status_code=200, - json={ - "id": "dropbox-index-id", - "name": "test-dropbox-index", - "lastIngestionStatus": "Queued", - }, - ) - - source = DropboxSourceConfig( - connection_id="dropbox-conn-789", - connection_name="Dropbox Connection", - directory_path="/company-files", - folder_path="/test/folder", - ) - - index = service.create_index( - name="test-dropbox-index", source=source, advanced_ingestion=False - ) - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "dropbox-index-id" - - sent_requests = httpx_mock.get_requests() - create_request = sent_requests[1] - - request_data = json.loads(create_request.content) - assert ( - request_data["dataSource"]["@odata.type"] - == "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSourceRequest" - ) - assert request_data["dataSource"]["connectionId"] == "dropbox-conn-789" - assert request_data["dataSource"]["connectionName"] == "Dropbox Connection" - assert request_data["dataSource"]["directoryPath"] == "/company-files" - assert request_data["dataSource"]["fileNameGlob"] == "**/*" - assert "preProcessing" not in request_data - - def test_create_index_onedrive( - 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/create", - status_code=200, - json={ - "id": "onedrive-index-id", - "name": "test-onedrive-index", - "lastIngestionStatus": "Queued", - }, - ) - - source = OneDriveSourceConfig( - connection_id="onedrive-conn-101", - connection_name="OneDrive Connection", - leaf_folder_id="onedrive-folder-202", - directory_path="/reports", - folder_path="/test/folder", - file_type="xlsx", - ) - - index = service.create_index(name="test-onedrive-index", source=source) - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "onedrive-index-id" - - sent_requests = httpx_mock.get_requests() - create_request = sent_requests[1] - - request_data = json.loads(create_request.content) - assert ( - request_data["dataSource"]["@odata.type"] - == "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSourceRequest" - ) - assert request_data["dataSource"]["connectionId"] == "onedrive-conn-101" - assert request_data["dataSource"]["leafFolderId"] == "onedrive-folder-202" - assert request_data["dataSource"]["fileNameGlob"] == "**/*.xlsx" - - def test_create_index_confluence( - 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/create", - status_code=200, - json={ - "id": "confluence-index-id", - "name": "test-confluence-index", - "lastIngestionStatus": "Queued", - }, - ) - - source = ConfluenceSourceConfig( - connection_id="confluence-conn-303", - connection_name="Confluence Connection", - space_id="space-404", - directory_path="/wiki-docs", - folder_path="/test/folder", - ) - - index = service.create_index(name="test-confluence-index", source=source) - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "confluence-index-id" - - sent_requests = httpx_mock.get_requests() - create_request = sent_requests[1] - - request_data = json.loads(create_request.content) - assert ( - request_data["dataSource"]["@odata.type"] - == "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSourceRequest" - ) - assert request_data["dataSource"]["connectionId"] == "confluence-conn-303" - assert request_data["dataSource"]["connectionName"] == "Confluence Connection" - - @pytest.mark.anyio - async def test_create_index_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/create", - status_code=200, - json={ - "id": "async-index-id", - "name": "test-async-index", - "description": "Test async index", - "lastIngestionStatus": "Queued", - }, - ) - - source = BucketSourceConfig( - bucket_name="async-bucket", - folder_path="/async/folder", - ) - - index = await service.create_index_async( - name="test-async-index", description="Test async index", source=source - ) - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "async-index-id" - assert index.name == "test-async-index" - - sent_requests = httpx_mock.get_requests() - create_request = sent_requests[1] - assert create_request.method == "POST" - assert HEADER_USER_AGENT in create_request.headers - assert ( - create_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_index_async/{version}" - ) - - def test_create_index_missing_bucket_name( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - ) -> None: - # Pydantic will raise ValidationError for missing required fields - with pytest.raises(ValidationError, match="bucket_name"): - BucketSourceConfig(folder_path="/test/folder") # type: ignore[call-arg] - - def test_create_index_missing_google_drive_fields( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - ) -> None: - # Pydantic will raise ValidationError for missing required fields - with pytest.raises(ValidationError, match="connection_name"): - GoogleDriveSourceConfig( # type: ignore[call-arg] - connection_id="conn-123", - folder_path="/test/folder", - ) - - def test_create_index_custom_preprocessing( - 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/create", - status_code=200, - json={ - "id": "custom-prep-index-id", - "name": "test-custom-prep-index", - "lastIngestionStatus": "Queued", - }, - ) - - source = BucketSourceConfig( - bucket_name="test-bucket", - folder_path="/test/folder", - ) - - index = service.create_index( - name="test-custom-prep-index", - source=source, - preprocessing_request=LLMV3Mini_REQUEST, - ) - - assert isinstance(index, ContextGroundingIndex) - - sent_requests = httpx_mock.get_requests() - create_request = sent_requests[1] - - request_data = json.loads(create_request.content) - assert request_data["preProcessing"]["@odata.type"] == LLMV3Mini_REQUEST - - def test_all_requests_pass_spec_parameters( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Verify that all requests pass spec.method, spec.endpoint, spec.params, and spec.headers correctly.""" - # Mock folder service to always return the test folder key - with patch.object( - service._folders_service, "retrieve_key", return_value="test-folder-key" - ): - # Test retrieve method - with patch.object(service, "request") as mock_request: - mock_response = MagicMock() - mock_response.json.return_value = { - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - } - mock_request.return_value = mock_response - - service.retrieve(name="test-index") - - # Verify request was called with spec parameters - assert mock_request.called - call_args = mock_request.call_args - # Check positional args (method and endpoint) - assert call_args[0][0] == "GET" # method - assert str(call_args[0][1]) == "/ecs_/v2/indexes" # endpoint - # Check keyword args (params and headers) - assert "params" in call_args[1] - assert call_args[1]["params"]["$filter"] == "Name eq 'test-index'" - assert call_args[1]["params"]["$expand"] == "dataSource" - assert "headers" in call_args[1] - assert "x-uipath-folderkey" in call_args[1]["headers"] - assert ( - call_args[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" - ) - - # Test search method - with patch.object(service, "request") as mock_request: - # First call for retrieve - retrieve_response = MagicMock() - retrieve_response.json.return_value = { - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - } - # Second call for search - search_response = MagicMock() - search_response.json.return_value = [] - mock_request.side_effect = [retrieve_response, search_response] - - service.search( - name="test-index", query="test query", number_of_results=10 - ) - - # Check the search request (second call) - assert mock_request.call_count == 2 - search_call = mock_request.call_args_list[1] - assert search_call[0][0] == "POST" # method - assert str(search_call[0][1]) == "/ecs_/v1/search" # endpoint - assert "json" in search_call[1] - assert search_call[1]["json"]["query"]["query"] == "test query" - assert search_call[1]["json"]["query"]["numberOfResults"] == 10 - assert "headers" in search_call[1] - assert "x-uipath-folderkey" in search_call[1]["headers"] - assert ( - search_call[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" - ) - - # Test create_index method - with patch.object(service, "request") as mock_request: - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "new-index-id", - "name": "test-new-index", - "lastIngestionStatus": "Queued", - } - mock_request.return_value = mock_response - - source = BucketSourceConfig( - bucket_name="test-bucket", - folder_path="/test/folder", - directory_path="/", - ) - service.create_index(name="test-new-index", source=source) - - assert mock_request.called - call_args = mock_request.call_args - assert call_args[0][0] == "POST" # method - assert str(call_args[0][1]) == "/ecs_/v2/indexes/create" # endpoint - assert "json" in call_args[1] - assert "headers" in call_args[1] - assert "x-uipath-folderkey" in call_args[1]["headers"] - assert ( - call_args[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" - ) - - # Test ingest_data method - with patch.object(service, "request") as mock_request: - mock_request.return_value = MagicMock() - - test_index = ContextGroundingIndex( - id="test-index-id", - name="test-index", - last_ingestion_status="Completed", - ) - service.ingest_data(test_index) - - assert mock_request.called - call_args = mock_request.call_args - assert call_args[0][0] == "POST" # method - assert ( - str(call_args[0][1]) == "/ecs_/v2/indexes/test-index-id/ingest" - ) # endpoint - assert "headers" in call_args[1] - assert "x-uipath-folderkey" in call_args[1]["headers"] - assert ( - call_args[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" - ) - - # Test delete_index method - with patch.object(service, "request") as mock_request: - mock_request.return_value = MagicMock() - - test_index = ContextGroundingIndex( - id="test-index-id", - name="test-index", - last_ingestion_status="Completed", - ) - service.delete_index(test_index) - - assert mock_request.called - call_args = mock_request.call_args - assert call_args[0][0] == "DELETE" # method - assert ( - str(call_args[0][1]) == "/ecs_/v2/indexes/test-index-id" - ) # endpoint - assert "headers" in call_args[1] - assert "x-uipath-folderkey" in call_args[1]["headers"] - assert ( - call_args[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" - ) - - def test_retrieve_deep_rag( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - citation = Citation(ordinal=1, page_number=1, source="abc", reference="abc") - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/deeprag/test-task-id?$expand=content&$select=content,name,createdDate,lastDeepRagStatus", - status_code=200, - json={ - "name": "test-deep-rag-task", - "createdDate": "2024-01-15T10:30:00Z", - "lastDeepRagStatus": "Successful", - "content": { - "text": "This is the deep RAG response text.", - "citations": [citation.model_dump()], - }, - }, - ) - - response = service.retrieve_deep_rag(id="test-task-id") - - assert isinstance(response, DeepRagResponse) - assert response.name == "test-deep-rag-task" - assert response.created_date == "2024-01-15T10:30:00Z" - assert response.last_deep_rag_status == "Successful" - assert response.content is not None - assert response.content.text == "This is the deep RAG response text." - assert response.content.citations == [citation] - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "GET" - assert ( - sent_requests[0].url - == f"{base_url}{org}{tenant}/ecs_/v2/deeprag/test-task-id?%24expand=content&%24select=content%2Cname%2CcreatedDate%2ClastDeepRagStatus" - ) - - assert HEADER_USER_AGENT in sent_requests[0].headers - assert ( - sent_requests[0].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_deep_rag/{version}" - ) - - @pytest.mark.anyio - async def test_retrieve_deep_rag_async( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - citation = Citation(ordinal=1, page_number=1, source="abc", reference="abc") - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/deeprag/test-task-id?$expand=content&$select=content,name,createdDate,lastDeepRagStatus", - status_code=200, - json={ - "name": "test-deep-rag-task", - "createdDate": "2024-01-15T10:30:00Z", - "lastDeepRagStatus": "Successful", - "content": { - "text": "This is the deep RAG response text.", - "citations": [citation.model_dump()], - }, - }, - ) - - response = await service.retrieve_deep_rag_async(id="test-task-id") - - assert isinstance(response, DeepRagResponse) - assert response.name == "test-deep-rag-task" - assert response.created_date == "2024-01-15T10:30:00Z" - assert response.last_deep_rag_status == "Successful" - assert response.content is not None - assert response.content.text == "This is the deep RAG response text." - assert response.content.citations == [citation] - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "GET" - assert ( - sent_requests[0].url - == f"{base_url}{org}{tenant}/ecs_/v2/deeprag/test-task-id?%24expand=content&%24select=content%2Cname%2CcreatedDate%2ClastDeepRagStatus" - ) - - assert HEADER_USER_AGENT in sent_requests[0].headers - assert ( - sent_requests[0].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_deep_rag_async/{version}" - ) - - def test_start_deep_rag( - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - 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/test-index-id/createDeepRag?$select=id,lastDeepRagStatus,createdDate", - status_code=200, - json={ - "id": "new-deep-rag-task-id", - "lastDeepRagStatus": "Queued", - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - response = service.start_deep_rag( - index_name="test-index", - name="my-deep-rag-task", - prompt="Summarize all documents related to financial reports", - glob_pattern="*.pdf", - citation_mode=CitationMode.INLINE, - ) - - assert isinstance(response, DeepRagCreationResponse) - assert response.id == "new-deep-rag-task-id" - assert response.last_deep_rag_status == "Queued" - assert response.created_date == "2024-01-15T10:30:00Z" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert ( - f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createDeepRag" - in str(sent_requests[3].url) - ) - - request_data = json.loads(sent_requests[3].content) - assert request_data["name"] == "my-deep-rag-task" - assert ( - request_data["prompt"] - == "Summarize all documents related to financial reports" - ) - assert request_data["globPattern"] == "*.pdf" - assert request_data["citationMode"] == "Inline" - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_deep_rag/{version}" - ) - - @pytest.mark.anyio - async def test_start_deep_rag_task( - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - 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/test-index-id/createDeepRag?$select=id,lastDeepRagStatus,createdDate", - status_code=200, - json={ - "id": "new-deep-rag-task-id", - "lastDeepRagStatus": "Queued", - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - response = await service.start_deep_rag_async( - index_name="test-index", - name="my-deep-rag-task", - prompt="Summarize all documents related to financial reports", - glob_pattern="*.pdf", - citation_mode=CitationMode.INLINE, - ) - - assert isinstance(response, DeepRagCreationResponse) - assert response.id == "new-deep-rag-task-id" - assert response.last_deep_rag_status == "Queued" - assert response.created_date == "2024-01-15T10:30:00Z" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert ( - f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createDeepRag" - in str(sent_requests[3].url) - ) - - request_data = json.loads(sent_requests[3].content) - assert request_data["name"] == "my-deep-rag-task" - assert ( - request_data["prompt"] - == "Summarize all documents related to financial reports" - ) - assert request_data["globPattern"] == "*.pdf" - assert request_data["citationMode"] == "Inline" - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_deep_rag_async/{version}" - ) - - def test_start_batch_transform( - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - 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/test-index-id/createBatchRag", - status_code=200, - json={ - "id": "new-batch-transform-id", - "lastBatchRagStatus": "Queued", - "errorMessage": None, - }, - ) - - output_columns = [ - BatchTransformOutputColumn( - name="summary", - description="A summary of the document", - ) - ] - - response = service.start_batch_transform( - name="my-batch-transform", - index_name="test-index", - prompt="Summarize all documents", - output_columns=output_columns, - storage_bucket_folder_path_prefix="data", - enable_web_search_grounding=False, - ) - - assert isinstance(response, BatchTransformCreationResponse) - assert response.id == "new-batch-transform-id" - assert response.last_batch_rag_status == "Queued" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert ( - f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" - in str(sent_requests[3].url) - ) - - request_data = json.loads(sent_requests[3].content) - assert request_data["name"] == "my-batch-transform" - assert request_data["prompt"] == "Summarize all documents" - assert request_data["targetFileGlobPattern"] == "data/*" - assert request_data["useWebSearchGrounding"] is False - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform/{version}" - ) - - @pytest.mark.anyio - async def test_start_batch_transform_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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - 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/test-index-id/createBatchRag", - status_code=200, - json={ - "id": "new-batch-transform-id", - "lastBatchRagStatus": "Queued", - "errorMessage": None, - }, - ) - - output_columns = [ - BatchTransformOutputColumn( - name="summary", - description="A summary of the document", - ) - ] - - response = await service.start_batch_transform_async( - name="my-batch-transform", - index_name="test-index", - prompt="Summarize all documents", - output_columns=output_columns, - storage_bucket_folder_path_prefix="data", - enable_web_search_grounding=False, - ) - - assert isinstance(response, BatchTransformCreationResponse) - assert response.id == "new-batch-transform-id" - assert response.last_batch_rag_status == "Queued" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert ( - f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" - in str(sent_requests[3].url) - ) - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform_async/{version}" - ) - - def test_start_batch_transform_with_target_file_name( - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - 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/test-index-id/createBatchRag", - status_code=200, - json={ - "id": "new-batch-transform-id", - "lastBatchRagStatus": "Queued", - "errorMessage": None, - }, - ) - - output_columns = [ - BatchTransformOutputColumn( - name="Emoji", - description="Emoji", - ), - BatchTransformOutputColumn( - name="Language", - description="The output Language should be loaded from the row", - ), - ] - - response = service.start_batch_transform( - name="my-batch-transform", - index_name="test-index", - prompt="Extract emojis and language", - output_columns=output_columns, - target_file_name="size_1KB.csv", - enable_web_search_grounding=True, - ) - - assert isinstance(response, BatchTransformCreationResponse) - assert response.id == "new-batch-transform-id" - assert response.last_batch_rag_status == "Queued" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert ( - f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" - in str(sent_requests[3].url) - ) - - request_data = json.loads(sent_requests[3].content) - assert request_data["name"] == "my-batch-transform" - assert request_data["prompt"] == "Extract emojis and language" - assert request_data["targetFileGlobPattern"] == "size_1KB.csv" - assert request_data["useWebSearchGrounding"] is True - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform/{version}" - ) - - @pytest.mark.anyio - async def test_start_batch_transform_async_with_target_file_name( - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - 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/test-index-id/createBatchRag", - status_code=200, - json={ - "id": "new-batch-transform-id", - "lastBatchRagStatus": "Queued", - "errorMessage": None, - }, - ) - - output_columns = [ - BatchTransformOutputColumn( - name="Emoji", - description="Emoji", - ), - BatchTransformOutputColumn( - name="Language", - description="The output Language should be loaded from the row", - ), - ] - - response = await service.start_batch_transform_async( - name="my-batch-transform", - index_name="test-index", - prompt="Extract emojis and language", - output_columns=output_columns, - target_file_name="size_1KB.csv", - enable_web_search_grounding=True, - ) - - assert isinstance(response, BatchTransformCreationResponse) - assert response.id == "new-batch-transform-id" - assert response.last_batch_rag_status == "Queued" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert ( - f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" - in str(sent_requests[3].url) - ) - - request_data = json.loads(sent_requests[3].content) - assert request_data["name"] == "my-batch-transform" - assert request_data["prompt"] == "Extract emojis and language" - assert request_data["targetFileGlobPattern"] == "size_1KB.csv" - assert request_data["useWebSearchGrounding"] is True - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform_async/{version}" - ) - - def test_start_batch_transform_with_combined_prefix_and_filename( - 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?$filter=Name eq 'test-index'&$expand=dataSource", - status_code=200, - json={ - "value": [ - { - "id": "test-index-id", - "name": "test-index", - "lastIngestionStatus": "Completed", - } - ] - }, - ) - - 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/test-index-id/createBatchRag", - status_code=200, - json={ - "id": "new-batch-transform-id", - "lastBatchRagStatus": "Queued", - "errorMessage": None, - }, - ) - - output_columns = [ - BatchTransformOutputColumn( - name="summary", - description="A summary of the document", - ) - ] - - response = service.start_batch_transform( - name="my-batch-transform", - index_name="test-index", - prompt="Summarize the document", - output_columns=output_columns, - storage_bucket_folder_path_prefix="data", - target_file_name="size_1KB.csv", - enable_web_search_grounding=False, - ) - - assert isinstance(response, BatchTransformCreationResponse) - assert response.id == "new-batch-transform-id" - assert response.last_batch_rag_status == "Queued" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[3].method == "POST" - assert ( - f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" - in str(sent_requests[3].url) - ) - - request_data = json.loads(sent_requests[3].content) - assert request_data["name"] == "my-batch-transform" - assert request_data["prompt"] == "Summarize the document" - # Verify that both prefix and filename are combined - assert request_data["targetFileGlobPattern"] == "data/size_1KB.csv" - assert request_data["useWebSearchGrounding"] is False - - assert HEADER_USER_AGENT in sent_requests[3].headers - assert ( - sent_requests[3].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform/{version}" - ) - - def test_retrieve_batch_transform( - 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}/ecs_/v2/batchRag/test-batch-id", - status_code=200, - json={ - "id": "test-batch-id", - "name": "test-batch-transform", - "lastBatchRagStatus": "Successful", - "prompt": "Summarize documents", - "targetFileGlobPattern": "**", - "useWebSearchGrounding": False, - "outputColumns": [ - {"name": "summary", "description": "Document summary"} - ], - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - response = service.retrieve_batch_transform(id="test-batch-id") - - assert isinstance(response, BatchTransformResponse) - assert response.id == "test-batch-id" - assert response.name == "test-batch-transform" - assert response.last_batch_rag_status == BatchTransformStatus.SUCCESSFUL - assert response.prompt == "Summarize documents" - assert response.created_date == "2024-01-15T10:30:00Z" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "GET" - assert ( - sent_requests[0].url - == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id" - ) - - assert HEADER_USER_AGENT in sent_requests[0].headers - assert ( - sent_requests[0].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_batch_transform/{version}" - ) - - @pytest.mark.anyio - async def test_retrieve_batch_transform_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}/ecs_/v2/batchRag/test-batch-id", - status_code=200, - json={ - "id": "test-batch-id", - "name": "test-batch-transform", - "lastBatchRagStatus": "Successful", - "prompt": "Summarize documents", - "targetFileGlobPattern": "**", - "useWebSearchGrounding": False, - "outputColumns": [ - {"name": "summary", "description": "Document summary"} - ], - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - response = await service.retrieve_batch_transform_async(id="test-batch-id") - - assert isinstance(response, BatchTransformResponse) - assert response.id == "test-batch-id" - assert response.name == "test-batch-transform" - assert response.last_batch_rag_status == BatchTransformStatus.SUCCESSFUL - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "GET" - assert ( - sent_requests[0].url - == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id" - ) - - assert HEADER_USER_AGENT in sent_requests[0].headers - assert ( - sent_requests[0].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_batch_transform_async/{version}" - ) - - def test_download_batch_transform_result( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - version: str, - tmp_path, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", - status_code=200, - json={ - "id": "test-batch-id", - "name": "test-batch-transform", - "lastBatchRagStatus": "Successful", - "prompt": "Summarize documents", - "targetFileGlobPattern": "**", - "useWebSearchGrounding": False, - "outputColumns": [ - {"name": "summary", "description": "Document summary"} - ], - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", - status_code=200, - json={ - "uri": "https://storage.example.com/result.csv", - "isEncrypted": False, - }, - ) - - httpx_mock.add_response( - url="https://storage.example.com/result.csv", - status_code=200, - content=b"col1,col2\nval1,val2", - ) - - destination = tmp_path / "result.csv" - service.download_batch_transform_result( - id="test-batch-id", - destination_path=str(destination), - ) - - assert destination.exists() - assert destination.read_bytes() == b"col1,col2\nval1,val2" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "GET" - assert ( - sent_requests[0].url - == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id" - ) - - assert sent_requests[1].method == "GET" - assert ( - sent_requests[1].url - == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri" - ) - - 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.download_batch_transform_result/{version}" - ) - - @pytest.mark.anyio - async def test_download_batch_transform_result_async( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - version: str, - tmp_path, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", - status_code=200, - json={ - "id": "test-batch-id", - "name": "test-batch-transform", - "lastBatchRagStatus": "Successful", - "prompt": "Summarize documents", - "targetFileGlobPattern": "**", - "useWebSearchGrounding": False, - "outputColumns": [ - {"name": "summary", "description": "Document summary"} - ], - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", - status_code=200, - json={ - "uri": "https://storage.example.com/result.csv", - "isEncrypted": False, - }, - ) - - httpx_mock.add_response( - url="https://storage.example.com/result.csv", - status_code=200, - content=b"col1,col2\nval1,val2", - ) - - destination = tmp_path / "result.csv" - await service.download_batch_transform_result_async( - id="test-batch-id", - destination_path=str(destination), - ) - - assert destination.exists() - assert destination.read_bytes() == b"col1,col2\nval1,val2" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "GET" - assert ( - sent_requests[0].url - == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id" - ) - - assert sent_requests[1].method == "GET" - assert ( - sent_requests[1].url - == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri" - ) - - 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.download_batch_transform_result_async/{version}" - ) - - def test_download_batch_transform_result_creates_nested_directories( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - tmp_path, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", - status_code=200, - json={ - "id": "test-batch-id", - "name": "test-batch-transform", - "lastBatchRagStatus": "Successful", - "prompt": "Summarize documents", - "targetFileGlobPattern": "**", - "useWebSearchGrounding": False, - "outputColumns": [ - {"name": "summary", "description": "Document summary"} - ], - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", - status_code=200, - json={ - "uri": "https://storage.example.com/result.csv", - "isEncrypted": False, - }, - ) - - httpx_mock.add_response( - url="https://storage.example.com/result.csv", - status_code=200, - content=b"col1,col2\nval1,val2", - ) - - destination = tmp_path / "output" / "nested" / "result.csv" - service.download_batch_transform_result( - id="test-batch-id", - destination_path=str(destination), - ) - - assert destination.exists() - assert destination.read_bytes() == b"col1,col2\nval1,val2" - assert destination.parent.exists() - - def test_download_batch_transform_result_encrypted( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - version: str, - tmp_path, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", - status_code=200, - json={ - "id": "test-batch-id", - "name": "test-batch-transform", - "lastBatchRagStatus": "Successful", - "prompt": "Summarize documents", - "targetFileGlobPattern": "**", - "useWebSearchGrounding": False, - "outputColumns": [ - {"name": "summary", "description": "Document summary"} - ], - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", - status_code=200, - json={ - "uri": f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/DownloadBlob", - "isEncrypted": True, - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/DownloadBlob", - status_code=200, - content=b"encrypted,data\nval1,val2", - ) - - destination = tmp_path / "result_encrypted.csv" - service.download_batch_transform_result( - id="test-batch-id", - destination_path=str(destination), - ) - - assert destination.exists() - assert destination.read_bytes() == b"encrypted,data\nval1,val2" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - # Verify the DownloadBlob endpoint was called with Authorization header - download_request = sent_requests[2] - assert download_request.method == "GET" - assert "/DownloadBlob" in str(download_request.url) - assert "Authorization" in download_request.headers - assert download_request.headers["Authorization"].startswith("Bearer ") - - def test_create_ephemeral_index( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - import uuid - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", - status_code=200, - json={ - "id": "ephemeral-index-id", - "name": "ephemeral-index", - "lastIngestionStatus": "Queued", - }, - ) - - attachment_ids = [str(uuid.uuid4()), str(uuid.uuid4())] - index = service.create_ephemeral_index( - usage="DeepRAG", - attachments=attachment_ids, - ) - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "ephemeral-index-id" - assert index.name == "ephemeral-index" - assert index.last_ingestion_status == "Queued" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "POST" - assert ( - sent_requests[0].url - == f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral" - ) - - request_data = json.loads(sent_requests[0].content) - assert request_data["usage"] == "DeepRAG" - assert "dataSource" in request_data - assert request_data["dataSource"]["attachments"] == [ - str(att) for att in attachment_ids - ] - - assert HEADER_USER_AGENT in sent_requests[0].headers - assert ( - sent_requests[0].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_ephemeral_index/{version}" - ) - - @pytest.mark.anyio - async def test_create_ephemeral_index_async( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - import uuid - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", - status_code=200, - json={ - "id": "ephemeral-index-id", - "name": "ephemeral-index", - "lastIngestionStatus": "Queued", - }, - ) - - attachment_ids = [str(uuid.uuid4()), str(uuid.uuid4())] - index = await service.create_ephemeral_index_async( - usage="DeepRAG", - attachments=attachment_ids, - ) - - assert isinstance(index, ContextGroundingIndex) - assert index.id == "ephemeral-index-id" - assert index.name == "ephemeral-index" - assert index.last_ingestion_status == "Queued" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "POST" - assert ( - sent_requests[0].url - == f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral" - ) - - request_data = json.loads(sent_requests[0].content) - assert request_data["usage"] == "DeepRAG" - assert "dataSource" in request_data - assert request_data["dataSource"]["attachments"] == [ - str(att) for att in attachment_ids - ] - - assert HEADER_USER_AGENT in sent_requests[0].headers - assert ( - sent_requests[0].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_ephemeral_index_async/{version}" - ) - - @pytest.mark.anyio - async def test_download_batch_transform_result_async_creates_nested_directories( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - tmp_path, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", - status_code=200, - json={ - "id": "test-batch-id", - "name": "test-batch-transform", - "lastBatchRagStatus": "Successful", - "prompt": "Summarize documents", - "targetFileGlobPattern": "**", - "useWebSearchGrounding": False, - "outputColumns": [ - {"name": "summary", "description": "Document summary"} - ], - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", - status_code=200, - json={ - "uri": "https://storage.example.com/result.csv", - "isEncrypted": False, - }, - ) - - httpx_mock.add_response( - url="https://storage.example.com/result.csv", - status_code=200, - content=b"col1,col2\nval1,val2", - ) - - destination = tmp_path / "output" / "nested" / "result.csv" - await service.download_batch_transform_result_async( - id="test-batch-id", - destination_path=str(destination), - ) - - assert destination.exists() - assert destination.read_bytes() == b"col1,col2\nval1,val2" - assert destination.parent.exists() - - @pytest.mark.anyio - async def test_start_deep_rag_ephemeral_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}/ecs_/v2/indexes/ephemeral-index-id/createDeepRag?$select=id,lastDeepRagStatus,createdDate", - status_code=200, - json={ - "id": "new-deep-rag-task-id", - "lastDeepRagStatus": "Queued", - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - response = await service.start_deep_rag_ephemeral_async( - name="my-ephemeral-deep-rag-task", - prompt="Summarize all documents related to financial reports", - glob_pattern="*.pdf", - citation_mode=CitationMode.INLINE, - index_id="ephemeral-index-id", - ) - - assert isinstance(response, DeepRagCreationResponse) - assert response.id == "new-deep-rag-task-id" - assert response.last_deep_rag_status == "Queued" - assert response.created_date == "2024-01-15T10:30:00Z" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - assert sent_requests[0].method == "POST" - assert ( - f"{base_url}{org}{tenant}/ecs_/v2/indexes/ephemeral-index-id/createDeepRag" - in str(sent_requests[0].url) - ) - - request_data = json.loads(sent_requests[0].content) - assert request_data["name"] == "my-ephemeral-deep-rag-task" - assert ( - request_data["prompt"] - == "Summarize all documents related to financial reports" - ) - assert request_data["globPattern"] == "*.pdf" - assert request_data["citationMode"] == "Inline" - - assert HEADER_USER_AGENT in sent_requests[0].headers - assert ( - sent_requests[0].headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_deep_rag_ephemeral_async/{version}" - ) - - @pytest.mark.anyio - async def test_download_batch_transform_result_async_encrypted( - self, - httpx_mock: HTTPXMock, - service: ContextGroundingService, - base_url: str, - org: str, - tenant: str, - version: str, - tmp_path, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", - status_code=200, - json={ - "id": "test-batch-id", - "name": "test-batch-transform", - "lastBatchRagStatus": "Successful", - "prompt": "Summarize documents", - "targetFileGlobPattern": "**", - "useWebSearchGrounding": False, - "outputColumns": [ - {"name": "summary", "description": "Document summary"} - ], - "createdDate": "2024-01-15T10:30:00Z", - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", - status_code=200, - json={ - "uri": f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/DownloadBlob", - "isEncrypted": True, - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/DownloadBlob", - status_code=200, - content=b"encrypted,data\nval1,val2", - ) - - destination = tmp_path / "result_encrypted.csv" - await service.download_batch_transform_result_async( - id="test-batch-id", - destination_path=str(destination), - ) - - assert destination.exists() - assert destination.read_bytes() == b"encrypted,data\nval1,val2" - - sent_requests = httpx_mock.get_requests() - if sent_requests is None: - raise Exception("No request was sent") - - # Verify the DownloadBlob endpoint was called with Authorization header - download_request = sent_requests[2] - assert download_request.method == "GET" - assert "/DownloadBlob" in str(download_request.url) - assert "Authorization" in download_request.headers - assert download_request.headers["Authorization"].startswith("Bearer ") diff --git a/tests/sdk/services/test_conversations_service.py b/tests/sdk/services/test_conversations_service.py deleted file mode 100644 index 31aa4a653..000000000 --- a/tests/sdk/services/test_conversations_service.py +++ /dev/null @@ -1,166 +0,0 @@ -import pytest -from pytest_httpx import HTTPXMock -from uipath.core.chat import UiPathConversationMessage - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.chat import ConversationsService - - -@pytest.fixture -def service( - config: UiPathApiConfig, execution_context: UiPathExecutionContext -) -> ConversationsService: - return ConversationsService(config=config, execution_context=execution_context) - - -class TestConversationsService: - class TestRetrieveMessage: - @pytest.mark.anyio - async def test_retrieve_message( - self, - httpx_mock: HTTPXMock, - service: ConversationsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test retrieving a specific message from an exchange.""" - conversation_id = "123" - exchange_id = "202cf2d1-926e-422d-8cf2-4f5735fa91fa" - message_id = "08de239e-90da-4d17-b986-b7785268d8d7" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}", - status_code=200, - json={ - "messageId": message_id, - "role": "assistant", - "contentParts": [], - "toolCalls": [], - "interrupts": [], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - }, - ) - - result = await service.retrieve_message_async( - conversation_id=conversation_id, - exchange_id=exchange_id, - message_id=message_id, - ) - - assert isinstance(result, UiPathConversationMessage) - assert result.message_id == message_id - assert result.role == "assistant" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}" - ) - - @pytest.mark.anyio - async def test_retrieve_message_with_content_parts( - self, - httpx_mock: HTTPXMock, - service: ConversationsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test retrieving a message with content parts.""" - conversation_id = "123" - exchange_id = "202cf2d1-926e-422d-8cf2-4f5735fa91fa" - message_id = "08de239e-90da-4d17-b986-b7785268d8d7" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}", - status_code=200, - json={ - "messageId": message_id, - "role": "user", - "contentParts": [ - { - "contentPartId": "cp-1", - "mimeType": "text/plain", - "data": {"inline": "Hello, world!"}, - "citations": [], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - } - ], - "toolCalls": [], - "interrupts": [], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - }, - ) - - result = await service.retrieve_message_async( - conversation_id=conversation_id, - exchange_id=exchange_id, - message_id=message_id, - ) - - assert isinstance(result, UiPathConversationMessage) - assert result.message_id == message_id - assert result.role == "user" - assert result.content_parts is not None - assert len(result.content_parts) == 1 - assert result.content_parts[0].content_part_id == "cp-1" - assert result.content_parts[0].mime_type == "text/plain" - - @pytest.mark.anyio - async def test_retrieve_message_with_tool_calls( - self, - httpx_mock: HTTPXMock, - service: ConversationsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test retrieving a message with tool calls.""" - conversation_id = "123" - exchange_id = "202cf2d1-926e-422d-8cf2-4f5735fa91fa" - message_id = "08de239e-90da-4d17-b986-b7785268d8d7" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}", - status_code=200, - json={ - "messageId": message_id, - "role": "assistant", - "contentParts": [], - "toolCalls": [ - { - "toolCallId": "tc-1", - "name": "get_weather", - "input": {"city": "San Francisco"}, - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - } - ], - "interrupts": [], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z", - }, - ) - - result = await service.retrieve_message_async( - conversation_id=conversation_id, - exchange_id=exchange_id, - message_id=message_id, - ) - - assert isinstance(result, UiPathConversationMessage) - assert result.message_id == message_id - assert result.role == "assistant" - assert result.tool_calls is not None - assert len(result.tool_calls) == 1 - assert result.tool_calls[0].tool_call_id == "tc-1" - assert result.tool_calls[0].name == "get_weather" diff --git a/tests/sdk/services/test_documents_service.py b/tests/sdk/services/test_documents_service.py deleted file mode 100644 index 1a6543afb..000000000 --- a/tests/sdk/services/test_documents_service.py +++ /dev/null @@ -1,3644 +0,0 @@ -import json -from pathlib import Path -from typing import Any -from unittest.mock import Mock, patch -from uuid import UUID, uuid4 - -import pytest -from pytest_httpx import HTTPXMock - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.documents import ( - ActionPriority, - ClassificationResult, - DocumentsService, - ExtractionResponse, - ProjectType, - ValidateClassificationAction, - ValidateExtractionAction, -) -from uipath.platform.errors import ( - OperationFailedException, - OperationNotCompleteException, -) - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, -): - return DocumentsService( - config=config, - execution_context=execution_context, - polling_interval=0.001, # 1ms for fast tests - polling_timeout=10, # 10 seconds for tests - ) - - -@pytest.fixture -def documents_tests_data_path(tests_data_path: Path) -> Path: - return tests_data_path / "documents_service" - - -@pytest.fixture -def classification_response(documents_tests_data_path: Path) -> dict: # type: ignore - with open(documents_tests_data_path / "classification_response.json", "r") as f: - return json.load(f) - - -@pytest.fixture -def ixp_extraction_response(documents_tests_data_path: Path) -> dict: # type: ignore - with open(documents_tests_data_path / "ixp_extraction_response.json", "r") as f: - return json.load(f) - - -@pytest.fixture -def modern_extraction_response(documents_tests_data_path: Path) -> dict: # type: ignore - with open(documents_tests_data_path / "modern_extraction_response.json", "r") as f: - return json.load(f) - - -@pytest.fixture -def create_validation_action_response(documents_tests_data_path: Path) -> dict: # type: ignore - with open( - documents_tests_data_path - / "extraction_validation_action_response_unassigned.json", - "r", - ) as f: - return json.load(f) - - -@pytest.fixture -def extraction_validation_action_response_completed( - documents_tests_data_path: Path, -) -> dict: # type: ignore - with open( - documents_tests_data_path - / "extraction_validation_action_response_completed.json", - "r", - ) as f: - return json.load(f) - - -class TestDocumentsService: - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.parametrize( - "tag,version,project_name,project_type,file,file_path,error", - [ - ( - "Production", - None, - "TestProject", - ProjectType.MODERN, - None, - None, - "Exactly one of `file, file_path` must be provided", - ), - ( - "Production", - 4, - "TestProject", - ProjectType.MODERN, - b"something", - None, - "Exactly one of `tag, version` must be provided", - ), - ( - "Production", - None, - "TestProject", - ProjectType.MODERN, - b"something", - "something", - "Exactly one of `file, file_path` must be provided", - ), - ( - "Production", - None, - None, - ProjectType.PRETRAINED, - b"something", - None, - "`tag` must not be provided", - ), - ( - None, - None, - "TestProject", - ProjectType.PRETRAINED, - b"something", - None, - "`project_name` must not be provided", - ), - ( - None, - None, - None, - ProjectType.PRETRAINED, - b"something", - "pathto/file.pdf", - "Exactly one of `file, file_path` must be provided", - ), - ], - ) - @pytest.mark.asyncio - async def test_classify_with_invalid_parameters( - self, - service: DocumentsService, - mode: str, - tag, - version, - project_name, - project_type, - file, - file_path, - error, - ): - # ACT & ASSERT - with pytest.raises( - ValueError, - match=error, - ): - if mode == "async": - await service.classify_async( - tag=tag, - version=version, - project_name=project_name, - project_type=project_type, - file=file, - file_path=file_path, - ) - else: - service.classify( - tag=tag, - version=version, - project_name=project_name, - project_type=project_type, - file=file, - file_path=file_path, - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_with_classification_result_predefined( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - classification_response: dict, # type: ignore - modern_extraction_response: dict, # type: ignore - ): - # ARRANGE - project_id = str(UUID(int=0)) - document_id = str(uuid4()) - document_type_id = "receipts" - operation_id = str(uuid4()) - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.PRETRAINED.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = ( - "ml-classification" - ) - classification_response["classificationResults"][0]["Tag"] = None - classification_response["classificationResults"][0]["DocumentId"] = document_id - classification_response["classificationResults"][0]["DocumentTypeId"] = ( - document_type_id - ) - classification_result = ClassificationResult.model_validate( - classification_response["classificationResults"][0] - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/extraction/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": modern_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.extract_async( - classification_result=classification_result - ) - else: - response = service.extract(classification_result=classification_result) - - # ASSERT - modern_extraction_response["projectId"] = project_id - modern_extraction_response["projectType"] = ProjectType.PRETRAINED - modern_extraction_response["extractorId"] = document_type_id - modern_extraction_response["tag"] = None - modern_extraction_response["documentTypeId"] = document_type_id - assert response.model_dump() == modern_extraction_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_with_classification_result_modern_with_version( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - classification_response: dict, # type: ignore - modern_extraction_response: dict, # type: ignore - ): - # ARRANGE - project_id = str(uuid4()) - document_id = str(uuid4()) - document_type_id = str(uuid4()) - classifier_id = "classifier_2" - version = 2 - extractor_id = "extractor_2" - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.MODERN.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = ( - classifier_id - ) - classification_response["classificationResults"][0]["Tag"] = None - classification_response["classificationResults"][0]["DocumentId"] = document_id - classification_response["classificationResults"][0]["DocumentTypeId"] = ( - document_type_id - ) - classification_result = ClassificationResult.model_validate( - classification_response["classificationResults"][0] - ) - - operation_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "id": classifier_id, - "projectVersion": version, - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "extractors": [ - { - "id": str(uuid4()), - "projectVersion": 1, - "documentTypeId": str(uuid4()), - }, - { - "id": extractor_id, - "projectVersion": version, - "documentTypeId": document_type_id, - }, - { - "id": str(uuid4()), - "projectVersion": 3, - "documentTypeId": str(uuid4()), - }, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - - statuses = ["NotStarted", "Running", "Succeeded"] - for status in statuses: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": status, "result": modern_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.extract_async( - classification_result=classification_result - ) - else: - response = service.extract(classification_result=classification_result) - - # ASSERT - modern_extraction_response["projectId"] = project_id - modern_extraction_response["projectType"] = ProjectType.MODERN - modern_extraction_response["extractorId"] = extractor_id - modern_extraction_response["tag"] = None - modern_extraction_response["documentTypeId"] = document_type_id - assert response.model_dump() == modern_extraction_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_with_classification_result_modern_with_tag( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - classification_response: dict, # type: ignore - modern_extraction_response: dict, # type: ignore - ): - # ARRANGE - project_id = str(uuid4()) - document_id = str(uuid4()) - document_type_id = str(uuid4()) - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.MODERN.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = None - classification_response["classificationResults"][0]["Tag"] = "Production" - classification_response["classificationResults"][0]["DocumentId"] = document_id - classification_response["classificationResults"][0]["DocumentTypeId"] = ( - document_type_id - ) - classification_result = ClassificationResult.model_validate( - classification_response["classificationResults"][0] - ) - - operation_id = str(uuid4()) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/document-types/{document_type_id}/extraction/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/document-types/{document_type_id}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": modern_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.extract_async( - classification_result=classification_result - ) - else: - response = service.extract(classification_result=classification_result) - - # ASSERT - modern_extraction_response["projectId"] = project_id - modern_extraction_response["projectType"] = ProjectType.MODERN - modern_extraction_response["extractorId"] = None - modern_extraction_response["tag"] = "Production" - modern_extraction_response["documentTypeId"] = document_type_id - assert response.model_dump() == modern_extraction_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_classify_predefined( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - classification_response: dict, # type: ignore - ): - # ARRANGE - project_id = str(UUID(int=0)) - document_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - operation_id = str(uuid4()) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/classification/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/classification/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": classification_response}, - ) - - # ACT - if mode == "async": - response = await service.classify_async( - project_type=ProjectType.PRETRAINED, - file=b"test content", - ) - else: - response = service.classify( - project_type=ProjectType.PRETRAINED, - file=b"test content", - ) - - # ASSERT - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.PRETRAINED.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = ( - "ml-classification" - ) - classification_response["classificationResults"][0]["Tag"] = None - assert ( - response[0].model_dump() - == classification_response["classificationResults"][0] - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_classify_with_version_classifier_not_found( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - document_id = str(uuid4()) - version = 5 - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProject"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "classifiers": [ - {"id": "classifier_1", "projectVersion": 1}, - {"id": "classifier_2", "projectVersion": 2}, - {"id": "classifier_3", "projectVersion": 3}, - ] - }, - ) - - # ACT & ASSERT - with pytest.raises( - ValueError, - match=f"Classifier for version '{version}' not found.", - ): - if mode == "async": - await service.classify_async( - version=version, - project_name="TestProject", - project_type=ProjectType.MODERN, - file=b"test content", - ) - else: - service.classify( - version=version, - project_name="TestProject", - project_type=ProjectType.MODERN, - file=b"test content", - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_classify_modern_with_version( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - classification_response: dict, # type: ignore - ): - # ARRANGE - project_id = str(uuid4()) - document_id = str(uuid4()) - operation_id = str(uuid4()) - version = 2 - classifier_id = "classifier_2" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProject"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "classifiers": [ - {"id": "classifier_1", "projectVersion": 1}, - {"id": classifier_id, "projectVersion": version}, - {"id": "classifier_3", "projectVersion": 3}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": classification_response}, - ) - - # ACT - if mode == "async": - response = await service.classify_async( - version=version, - project_name="TestProject", - project_type=ProjectType.MODERN, - file=b"test content", - ) - else: - response = service.classify( - version=version, - project_name="TestProject", - project_type=ProjectType.MODERN, - file=b"test content", - ) - - # ASSERT - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.MODERN.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = ( - classifier_id - ) - classification_response["classificationResults"][0]["Tag"] = None - assert ( - response[0].model_dump() - == classification_response["classificationResults"][0] - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_classify_modern_with_tag( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - classification_response: dict, # type: ignore - ): - # ARRANGE - project_id = str(uuid4()) - document_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProject"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/tags?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "tags": [ - {"name": "Production"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - operation_id = str(uuid4()) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/classification/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/classification/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": classification_response}, - ) - - # ACT - if mode == "async": - response = await service.classify_async( - tag="Production", - project_name="TestProject", - project_type=ProjectType.MODERN, - file=b"test content", - ) - else: - response = service.classify( - tag="Production", - project_name="TestProject", - project_type=ProjectType.MODERN, - file=b"test content", - ) - - # ASSERT - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.MODERN.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = None - classification_response["classificationResults"][0]["Tag"] = "Production" - assert ( - response[0].model_dump() - == classification_response["classificationResults"][0] - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - @pytest.mark.parametrize( - "tag,version,project_name,file,file_path,classification_result,project_type,document_type_name, error", - [ - ( - None, - None, - None, - None, - None, - None, - None, - None, - "`classification_result` must be provided", - ), - ( - "live", - None, - "TestProject", - None, - None, - None, - None, - None, - "`classification_result` must be provided", - ), - ( - "live", - None, - "TestProject", - None, - None, - None, - ProjectType.IXP, - None, - "`classification_result` must be provided", - ), - ( - "live", - None, - "TestProject", - b"something", - None, - None, - ProjectType.MODERN, - None, - "`document_type_name` must be provided", - ), - ( - "live", - None, - "TestProject", - b"something", - None, - "dummy classification result", - ProjectType.MODERN, - "dummy doctype", - "`classification_result` must not be provided", - ), - ( - None, - None, - "TestProject", - b"something", - None, - None, - ProjectType.MODERN, - "dummy doctype", - "Exactly one of `version, tag` must be provided", - ), - ( - "live", - None, - "TestProject", - b"something", - "path/to/file.pdf", - None, - ProjectType.MODERN, - "dummy doctype", - "Exactly one of `file, file_path` must be provided", - ), - ( - "live", - 4, - "TestProject", - b"something", - None, - None, - ProjectType.MODERN, - "dummy doctype", - "Exactly one of `version, tag` must be provided", - ), - ( - "live", - None, - None, - b"something", - None, - None, - ProjectType.PRETRAINED, - "dummy doctype", - "`tag` must not be provided", - ), - ], - ) - async def test_extract_with_invalid_parameters( - self, - service: DocumentsService, - mode: str, - tag, - version, - project_name, - file, - file_path, - classification_result, - project_type, - document_type_name, - error, - ): - # ACT & ASSERT - with pytest.raises(ValueError, match=error): - if mode == "async": - await service.extract_async( - tag=tag, - version=version, - project_name=project_name, - project_type=project_type, - file=file, - file_path=file_path, - classification_result=classification_result, - document_type_name=document_type_name, - ) - else: - service.extract( - tag=tag, - version=version, - project_name=project_name, - project_type=project_type, - file=file, - file_path=file_path, - classification_result=classification_result, - document_type_name=document_type_name, - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_ixp_with_version( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - ixp_extraction_response: dict, # type: ignore - mode: str, - ): - project_id = str(uuid4()) - document_id = str(uuid4()) - operation_id = str(uuid4()) - extractor_id = "ixp_3" - version = 3 - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProjectIXP"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "extractors": [ - { - "id": extractor_id, - "projectVersion": version, - "documentTypeId": str(UUID(int=0)), - }, - { - "id": "ixp_2", - "projectVersion": 2, - "documentTypeId": str(UUID(int=0)), - }, - { - "id": "ixp_1", - "projectVersion": 1, - "documentTypeId": str(UUID(int=0)), - }, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": ixp_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.extract_async( - project_name="TestProjectIXP", - project_type=ProjectType.IXP, - version=version, - file=b"test content", - ) - else: - response = service.extract( - project_name="TestProjectIXP", - project_type=ProjectType.IXP, - version=version, - file=b"test content", - ) - - # ASSERT - expected_response = ixp_extraction_response - expected_response["projectId"] = project_id - expected_response["projectType"] = ProjectType.IXP.value - expected_response["extractorId"] = extractor_id - expected_response["tag"] = None - expected_response["documentTypeId"] = str(UUID(int=0)) - assert response.model_dump() == expected_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_ixp_with_tag( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - ixp_extraction_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - document_id = str(uuid4()) - operation_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProjectIXP"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/tags?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "tags": [ - {"name": "draft"}, - {"name": "live"}, - {"name": "production"}, - ] - }, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/live/document-types/{UUID(int=0)}/extraction/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/live/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "NotStarted", "result": ixp_extraction_response}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/live/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Running", "result": ixp_extraction_response}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/live/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": ixp_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.extract_async( - project_name="TestProjectIXP", - project_type=ProjectType.IXP, - tag="live", - file=b"test content", - ) - else: - response = service.extract( - project_name="TestProjectIXP", - project_type=ProjectType.IXP, - tag="live", - file=b"test content", - ) - - # ASSERT - expected_response = ixp_extraction_response - expected_response["projectId"] = project_id - expected_response["projectType"] = ProjectType.IXP.value - expected_response["extractorId"] = None - expected_response["tag"] = "live" - expected_response["documentTypeId"] = str(UUID(int=0)) - assert response.model_dump() == expected_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_predefined( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - modern_extraction_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(UUID(int=0)) - document_id = str(uuid4()) - document_type_id = "receipts" - operation_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/document-types?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "documentTypes": [ - {"id": str(uuid4()), "name": "Receipt"}, - {"id": document_type_id, "name": "Invoice"}, - {"id": str(uuid4()), "name": "Contract"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/extraction/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - - statuses = ["NotStarted", "Running", "Succeeded"] - for status in statuses: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": status, "result": modern_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.extract_async( - project_type=ProjectType.PRETRAINED, - file=b"test content", - document_type_name="Invoice", - ) - else: - response = service.extract( - project_type=ProjectType.PRETRAINED, - file=b"test content", - document_type_name="Invoice", - ) - - # ASSERT - expected_response = modern_extraction_response - expected_response["projectId"] = project_id - expected_response["projectType"] = ProjectType.PRETRAINED.value - expected_response["extractorId"] = document_type_id - expected_response["tag"] = None - expected_response["documentTypeId"] = document_type_id - assert response.model_dump() == expected_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_modern_with_version_extractor_not_found( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - document_id = str(uuid4()) - version = 5 - document_type_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProjectModern"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/document-types?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "documentTypes": [ - {"id": str(uuid4()), "name": "Receipt"}, - {"id": document_type_id, "name": "Invoice"}, - {"id": str(uuid4()), "name": "Contract"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "extractors": [ - { - "id": str(uuid4()), - "projectVersion": 1, - "documentTypeId": str(uuid4()), - }, - { - "id": str(uuid4()), - "projectVersion": 2, - "documentTypeId": str(uuid4()), - }, - { - "id": str(uuid4()), - "projectVersion": 3, - "documentTypeId": str(uuid4()), - }, - ] - }, - ) - - # ACT & ASSERT - with pytest.raises( - ValueError, - match=f"Extractor for version '{version}' and document type id '{document_type_id}' not found.", - ): - if mode == "async": - await service.extract_async( - project_name="TestProjectModern", - version=version, - file=b"test content", - project_type=ProjectType.MODERN, - document_type_name="Invoice", - ) - else: - service.extract( - project_name="TestProjectModern", - version=version, - file=b"test content", - project_type=ProjectType.MODERN, - document_type_name="Invoice", - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_modern_with_version( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - modern_extraction_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - document_type_id = str(uuid4()) - document_id = str(uuid4()) - operation_id = str(uuid4()) - extractor_id = str(uuid4()) - version = 2 - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProjectModern"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/document-types?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "documentTypes": [ - {"id": str(uuid4()), "name": "Receipt"}, - {"id": document_type_id, "name": "Invoice"}, - {"id": str(uuid4()), "name": "Contract"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "extractors": [ - { - "id": str(uuid4()), - "projectVersion": 1, - "documentTypeId": str(uuid4()), - }, - { - "id": extractor_id, - "projectVersion": version, - "documentTypeId": document_type_id, - }, - { - "id": str(uuid4()), - "projectVersion": 3, - "documentTypeId": str(uuid4()), - }, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - - statuses = ["NotStarted", "Running", "Succeeded"] - for status in statuses: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": status, "result": modern_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.extract_async( - project_name="TestProjectModern", - version=version, - file=b"test content", - project_type=ProjectType.MODERN, - document_type_name="Invoice", - ) - else: - response = service.extract( - project_name="TestProjectModern", - version=version, - file=b"test content", - project_type=ProjectType.MODERN, - document_type_name="Invoice", - ) - - # ASSERT - expected_response = modern_extraction_response - expected_response["projectId"] = project_id - expected_response["projectType"] = ProjectType.MODERN.value - expected_response["extractorId"] = extractor_id - expected_response["tag"] = None - expected_response["documentTypeId"] = document_type_id - assert response.model_dump() == expected_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_modern_with_tag( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - modern_extraction_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - document_type_id = str(uuid4()) - document_id = str(uuid4()) - operation_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProjectModern"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/tags?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "tags": [ - {"name": "Development"}, - {"name": "Staging"}, - {"name": "Production"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": "Succeeded", "result": {}}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/document-types?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={ - "documentTypes": [ - {"id": str(uuid4()), "name": "Receipt"}, - {"id": document_type_id, "name": "Invoice"}, - {"id": str(uuid4()), "name": "Contract"}, - ] - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/document-types/{document_type_id}/extraction/start?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - - statuses = ["NotStarted", "Running", "Succeeded"] - for status in statuses: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/document-types/{document_type_id}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={ - "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", - }, - json={"status": status, "result": modern_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.extract_async( - project_name="TestProjectModern", - tag="Production", - file=b"test content", - project_type=ProjectType.MODERN, - document_type_name="Invoice", - ) - else: - response = service.extract( - project_name="TestProjectModern", - tag="Production", - file=b"test content", - project_type=ProjectType.MODERN, - document_type_name="Invoice", - ) - - # ASSERT - expected_response = modern_extraction_response - expected_response["projectId"] = project_id - expected_response["projectType"] = ProjectType.MODERN.value - expected_response["extractorId"] = None - expected_response["tag"] = "Production" - expected_response["documentTypeId"] = document_type_id - assert response.model_dump() == expected_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_modern_without_document_type_name( - self, service: DocumentsService, mode: str - ): - # ACT & ASSERT - with pytest.raises( - ValueError, - match="`document_type_name` must be provided", - ): - if mode == "async": - await service.extract_async( - project_name="TestProjectModern", - tag="Production", - file=b"test content", - project_type=ProjectType.MODERN, - ) - else: - service.extract( - project_name="TestProjectModern", - tag="Production", - file=b"test content", - project_type=ProjectType.MODERN, - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_get_document_type_id_not_found( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/dummy_project_id/document-types?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={ - "documentTypes": [ - {"id": str(uuid4()), "name": "Receipt"}, - {"id": str(uuid4()), "name": "Invoice"}, - {"id": str(uuid4()), "name": "Contract"}, - ] - }, - ) - - # ACT & ASSERT - with pytest.raises( - ValueError, - match="Document type 'NonExistentType' not found.", - ): - if mode == "async": - await service._get_document_type_id_async( - project_id="dummy_project_id", - document_type_name="NonExistentType", - project_type=ProjectType.MODERN, - classification_result=None, - ) - else: - service._get_document_type_id( - project_id="dummy_project_id", - document_type_name="NonExistentType", - project_type=ProjectType.MODERN, - classification_result=None, - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_with_both_file_and_file_path_provided( - self, - service: DocumentsService, - mode: str, - ): - # ACT & ASSERT - with pytest.raises( - ValueError, - match="Exactly one of `file, file_path` must be provided", - ): - if mode == "async": - await service.extract_async( - project_name="TestProject", - project_type=ProjectType.IXP, - tag="live", - file=b"test content", - file_path="path/to/file.pdf", - ) - else: - service.extract( - project_name="TestProject", - project_type=ProjectType.IXP, - tag="live", - file=b"test content", - file_path="path/to/file.pdf", - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_with_wrong_project_name( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": str(uuid4()), "name": "YetAnotherProject"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - - # ACT & ASSERT - with pytest.raises(ValueError, match="Project 'TestProject' not found."): - if mode == "async": - await service.extract_async( - project_name="TestProject", - project_type=ProjectType.IXP, - tag="live", - file=b"test content", - ) - else: - service.extract( - project_name="TestProject", - project_type=ProjectType.IXP, - tag="live", - file=b"test content", - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_extract_with_wrong_tag( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={ - "projects": [ - {"id": str(uuid4()), "name": "OtherProject"}, - {"id": project_id, "name": "TestProject"}, - {"id": str(uuid4()), "name": "AnotherProject"}, - ] - }, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/tags?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"tags": [{"name": "staging"}]}, - ) - - # ACT & ASSERT - with pytest.raises(ValueError, match="Tag 'live' not found."): - if mode == "async": - await service.extract_async( - project_name="TestProject", - project_type=ProjectType.IXP, - tag="live", - file=b"test content", - ) - else: - service.extract( - project_name="TestProject", - project_type=ProjectType.IXP, - tag="live", - file=b"test content", - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_classification_action_pretrained( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - classification_response: dict, # type: ignore - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(UUID(int=0)) - operation_id = str(uuid4()) - tag = None - action_title = "TestAction" - action_priority = ActionPriority.LOW - action_catalog = "TestCatalog" - action_folder = "TestFolder" - storage_bucket_name = "TestBucket" - storage_bucket_directory_path = "Test/Directory/Path" - - classification_result = classification_response["classificationResults"][0] - classification_result["ProjectId"] = project_id - classification_result["ProjectType"] = ProjectType.PRETRAINED.value - classification_result["ClassifierId"] = "ml-classification" - classification_result["Tag"] = tag - classification_result = ClassificationResult.model_validate( - classification_result - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "storageBucketDirectoryPath": storage_bucket_directory_path, - "classificationResults": [ - classification_result.model_dump(), - ], - "documentId": classification_result.document_id, - }, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - # ACT - if mode == "async": - response = await service.create_validate_classification_action_async( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - classification_results=[classification_result], - ) - else: - response = service.create_validate_classification_action( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - classification_results=[classification_result], - ) - - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value - create_validation_action_response["classifierId"] = "ml-classification" - create_validation_action_response["tag"] = tag - create_validation_action_response["operationId"] = operation_id - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_classification_action_modern_with_version( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - classification_response: dict, # type: ignore - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - classifier_id = str(uuid4()) - action_title = "TestAction" - action_priority = ActionPriority.HIGH - action_catalog = "TestCatalog" - action_folder = "TestFolder" - storage_bucket_name = "TestBucket" - storage_bucket_directory_path = "Test/Directory/Path" - - classification_result = classification_response["classificationResults"][0] - classification_result["ProjectId"] = project_id - classification_result["ProjectType"] = ProjectType.MODERN.value - classification_result["ClassifierId"] = classifier_id - classification_result["Tag"] = None - classification_result = ClassificationResult.model_validate( - classification_result - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "storageBucketDirectoryPath": storage_bucket_directory_path, - "classificationResults": [ - classification_result.model_dump(), - ], - "documentId": classification_result.document_id, - }, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - # ACT - if mode == "async": - response = await service.create_validate_classification_action_async( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - classification_results=[classification_result], - ) - else: - response = service.create_validate_classification_action( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - classification_results=[classification_result], - ) - - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.MODERN.value - create_validation_action_response["classifierId"] = classifier_id - create_validation_action_response["tag"] = None - create_validation_action_response["operationId"] = operation_id - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_classification_action_modern_with_tag( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - classification_response: dict, # type: ignore - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - tag = "Production" - action_title = "TestAction" - action_priority = ActionPriority.MEDIUM - action_catalog = "TestCatalog" - action_folder = "TestFolder" - storage_bucket_name = "TestBucket" - storage_bucket_directory_path = "Test/Directory/Path" - - classification_result = classification_response["classificationResults"][0] - classification_result["ProjectId"] = project_id - classification_result["ProjectType"] = ProjectType.MODERN.value - classification_result["ClassifierId"] = None - classification_result["Tag"] = tag - classification_result = ClassificationResult.model_validate( - classification_result - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "storageBucketDirectoryPath": storage_bucket_directory_path, - "classificationResults": [ - classification_result.model_dump(), - ], - "documentId": classification_result.document_id, - }, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - # ACT - if mode == "async": - response = await service.create_validate_classification_action_async( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - classification_results=[classification_result], - ) - else: - response = service.create_validate_classification_action( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - classification_results=[classification_result], - ) - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.MODERN.value - create_validation_action_response["classifierId"] = None - create_validation_action_response["tag"] = tag - create_validation_action_response["operationId"] = operation_id - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_classification_action_with_empty_classification_results( - self, - service: DocumentsService, - mode: str, - ): - # ACT & ASSERT - with pytest.raises( - ValueError, - match="`classification_results` must not be empty", - ): - if mode == "async": - await service.create_validate_classification_action_async( - action_title="TestAction", - action_priority=ActionPriority.MEDIUM, - action_catalog="TestCatalog", - action_folder="TestFolder", - storage_bucket_name="TestBucket", - storage_bucket_directory_path="Test/Directory/Path", - classification_results=[], - ) - else: - service.create_validate_classification_action( - action_title="TestAction", - action_priority=ActionPriority.MEDIUM, - action_catalog="TestCatalog", - action_folder="TestFolder", - storage_bucket_name="TestBucket", - storage_bucket_directory_path="Test/Directory/Path", - classification_results=[], - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_classification_action_with_optional_params_omitted( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - classification_response: dict, # type: ignore - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(UUID(int=0)) - operation_id = str(uuid4()) - classifier_id = "ml-classification" - tag = None - action_title = "TestAction" - - classification_result = classification_response["classificationResults"][0] - classification_result["ProjectId"] = project_id - classification_result["ProjectType"] = ProjectType.PRETRAINED.value - classification_result["ClassifierId"] = classifier_id - classification_result["Tag"] = tag - classification_result = ClassificationResult.model_validate( - classification_result - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "actionTitle": action_title, - "actionPriority": None, - "actionCatalog": None, - "actionFolder": None, - "storageBucketName": None, - "storageBucketDirectoryPath": None, - "classificationResults": [ - classification_result.model_dump(), - ], - "documentId": classification_result.document_id, - }, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - # ACT - if mode == "async": - response = await service.create_validate_classification_action_async( - classification_results=[classification_result], - action_title=action_title, - ) - else: - response = service.create_validate_classification_action( - classification_results=[classification_result], - action_title=action_title, - ) - - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value - create_validation_action_response["classifierId"] = classifier_id - create_validation_action_response["tag"] = tag - create_validation_action_response["operationId"] = operation_id - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_extraction_action_pretrained( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - modern_extraction_response: dict, # type: ignore - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(UUID(int=0)) - operation_id = str(uuid4()) - document_type_id = "invoices" - tag = None - action_title = "TestAction" - action_priority = ActionPriority.MEDIUM - action_catalog = "TestCatalog" - action_folder = "TestFolder" - storage_bucket_name = "TestBucket" - storage_bucket_directory_path = "Test/Directory/Path" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "extractionResult": modern_extraction_response["extractionResult"], - "documentId": modern_extraction_response["extractionResult"][ - "DocumentId" - ], - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "allowChangeOfDocumentType": True, - "storageBucketDirectoryPath": storage_bucket_directory_path, - }, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - modern_extraction_response["projectId"] = project_id - modern_extraction_response["projectType"] = ProjectType.PRETRAINED.value - modern_extraction_response["extractorId"] = document_type_id - modern_extraction_response["tag"] = tag - modern_extraction_response["documentTypeId"] = document_type_id - - # ACT - if mode == "async": - response = await service.create_validate_extraction_action_async( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=ExtractionResponse.model_validate( - modern_extraction_response - ), - ) - else: - response = service.create_validate_extraction_action( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=ExtractionResponse.model_validate( - modern_extraction_response - ), - ) - - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value - create_validation_action_response["extractorId"] = document_type_id - create_validation_action_response["tag"] = tag - create_validation_action_response["documentTypeId"] = document_type_id - create_validation_action_response["operationId"] = operation_id - create_validation_action_response["validatedExtractionResults"] = None - create_validation_action_response["dataProjection"] = None - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_extraction_action_ixp_with_version( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - ixp_extraction_response: dict, # type: ignore - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - document_type_id = str(UUID(int=0)) - extractor_id = "ixp-4" - tag = None - action_title = "TestAction" - action_priority = ActionPriority.LOW - action_catalog = "TestCatalog" - action_folder = "TestFolder" - storage_bucket_name = "TestBucket" - storage_bucket_directory_path = "Test/Directory/Path" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "extractionResult": ixp_extraction_response["extractionResult"], - "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "allowChangeOfDocumentType": True, - "storageBucketDirectoryPath": storage_bucket_directory_path, - }, - json={"operationId": operation_id}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - ixp_extraction_response["projectId"] = project_id - ixp_extraction_response["projectType"] = ProjectType.IXP.value - ixp_extraction_response["extractorId"] = extractor_id - ixp_extraction_response["tag"] = tag - ixp_extraction_response["documentTypeId"] = document_type_id - - # ACT - if mode == "async": - response = await service.create_validate_extraction_action_async( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=ExtractionResponse.model_validate( - ixp_extraction_response - ), - ) - else: - response = service.create_validate_extraction_action( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=ExtractionResponse.model_validate( - ixp_extraction_response - ), - ) - - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.IXP.value - create_validation_action_response["extractorId"] = extractor_id - create_validation_action_response["tag"] = tag - create_validation_action_response["documentTypeId"] = document_type_id - create_validation_action_response["operationId"] = operation_id - create_validation_action_response["validatedExtractionResults"] = None - create_validation_action_response["dataProjection"] = None - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_extraction_action_ixp_with_tag( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - ixp_extraction_response: dict, # type: ignore - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - document_type_id = str(UUID(int=0)) - tag = "live" - action_title = "TestAction" - action_priority = ActionPriority.HIGH - action_catalog = "TestCatalog" - action_folder = "TestFolder" - storage_bucket_name = "TestBucket" - storage_bucket_directory_path = "Test/Directory/Path" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "extractionResult": ixp_extraction_response["extractionResult"], - "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "allowChangeOfDocumentType": True, - "storageBucketDirectoryPath": storage_bucket_directory_path, - }, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "NotStarted"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Running"}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - ixp_extraction_response["projectId"] = project_id - ixp_extraction_response["projectType"] = ProjectType.IXP.value - ixp_extraction_response["extractorId"] = None - ixp_extraction_response["tag"] = tag - ixp_extraction_response["documentTypeId"] = document_type_id - - # ACT - if mode == "async": - response = await service.create_validate_extraction_action_async( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=ExtractionResponse.model_validate( - ixp_extraction_response - ), - ) - else: - response = service.create_validate_extraction_action( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=ExtractionResponse.model_validate( - ixp_extraction_response - ), - ) - - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.IXP.value - create_validation_action_response["extractorId"] = None - create_validation_action_response["tag"] = tag - create_validation_action_response["documentTypeId"] = document_type_id - create_validation_action_response["operationId"] = operation_id - create_validation_action_response["validatedExtractionResults"] = None - create_validation_action_response["dataProjection"] = None - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_create_validate_extraction_action_with_optional_params_omitted( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - modern_extraction_response: dict, # type: ignore - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(UUID(int=0)) - operation_id = str(uuid4()) - document_type_id = "invoices" - tag = None - action_title = "TestAction" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "extractionResult": modern_extraction_response["extractionResult"], - "documentId": modern_extraction_response["extractionResult"][ - "DocumentId" - ], - "actionTitle": action_title, - "actionPriority": None, - "actionCatalog": None, - "actionFolder": None, - "storageBucketName": None, - "allowChangeOfDocumentType": True, - "storageBucketDirectoryPath": None, - }, - json={"operationId": operation_id}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - modern_extraction_response["projectId"] = project_id - modern_extraction_response["projectType"] = ProjectType.PRETRAINED.value - modern_extraction_response["extractorId"] = document_type_id - modern_extraction_response["tag"] = tag - modern_extraction_response["documentTypeId"] = document_type_id - - # ACT - if mode == "async": - response = await service.create_validate_extraction_action_async( - extraction_response=ExtractionResponse.model_validate( - modern_extraction_response - ), - action_title=action_title, - ) - else: - response = service.create_validate_extraction_action( - extraction_response=ExtractionResponse.model_validate( - modern_extraction_response - ), - action_title=action_title, - ) - - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value - create_validation_action_response["extractorId"] = document_type_id - create_validation_action_response["tag"] = tag - create_validation_action_response["documentTypeId"] = document_type_id - create_validation_action_response["operationId"] = operation_id - create_validation_action_response["validatedExtractionResults"] = None - create_validation_action_response["dataProjection"] = None - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_get_validate_classification_result_pretrained( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - service: DocumentsService, - create_validation_action_response: dict, # type: ignore - classification_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(UUID(int=0)) - operation_id = str(uuid4()) - - create_validation_action_response["actionStatus"] = "Completed" - create_validation_action_response["validatedClassificationResults"] = ( - classification_response["classificationResults"] - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value - create_validation_action_response["classifierId"] = "ml-classification" - create_validation_action_response["tag"] = None - create_validation_action_response["operationId"] = operation_id - - # ACT - if mode == "async": - results = await service.get_validate_classification_result_async( - validation_action=ValidateClassificationAction.model_validate( - create_validation_action_response - ) - ) - else: - results = service.get_validate_classification_result( - validation_action=ValidateClassificationAction.model_validate( - create_validation_action_response - ) - ) - - # ASSERT - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.PRETRAINED.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = ( - "ml-classification" - ) - classification_response["classificationResults"][0]["Tag"] = None - assert ( - results[0].model_dump() - == classification_response["classificationResults"][0] - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_get_validate_classification_result_modern_with_version( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - service: DocumentsService, - create_validation_action_response: dict, # type: ignore - classification_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - classifier_id = str(uuid4()) - - create_validation_action_response["actionStatus"] = "Completed" - create_validation_action_response["validatedClassificationResults"] = ( - classification_response["classificationResults"] - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.MODERN.value - create_validation_action_response["classifierId"] = classifier_id - create_validation_action_response["tag"] = None - create_validation_action_response["operationId"] = operation_id - - # ACT - if mode == "async": - results = await service.get_validate_classification_result_async( - validation_action=ValidateClassificationAction.model_validate( - create_validation_action_response - ) - ) - else: - results = service.get_validate_classification_result( - validation_action=ValidateClassificationAction.model_validate( - create_validation_action_response - ) - ) - - # ASSERT - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.MODERN.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = ( - classifier_id - ) - classification_response["classificationResults"][0]["Tag"] = None - assert ( - results[0].model_dump() - == classification_response["classificationResults"][0] - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_get_validate_classification_result_modern_with_tag( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - service: DocumentsService, - create_validation_action_response: dict, # type: ignore - classification_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - - create_validation_action_response["actionStatus"] = "Completed" - create_validation_action_response["validatedClassificationResults"] = ( - classification_response["classificationResults"] - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/classifiers/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.MODERN.value - create_validation_action_response["classifierId"] = None - create_validation_action_response["tag"] = "Production" - create_validation_action_response["operationId"] = operation_id - - # ACT - if mode == "async": - results = await service.get_validate_classification_result_async( - validation_action=ValidateClassificationAction.model_validate( - create_validation_action_response - ) - ) - else: - results = service.get_validate_classification_result( - validation_action=ValidateClassificationAction.model_validate( - create_validation_action_response - ) - ) - - # ASSERT - classification_response["classificationResults"][0]["ProjectId"] = project_id - classification_response["classificationResults"][0]["ProjectType"] = ( - ProjectType.MODERN.value - ) - classification_response["classificationResults"][0]["ClassifierId"] = None - classification_response["classificationResults"][0]["Tag"] = "Production" - assert ( - results[0].model_dump() - == classification_response["classificationResults"][0] - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_get_validate_extraction_result_pretrained( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - service: DocumentsService, - create_validation_action_response: dict, # type: ignore - modern_extraction_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(UUID(int=0)) - operation_id = str(uuid4()) - document_type_id = "invoices" - - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value - create_validation_action_response["extractorId"] = document_type_id - create_validation_action_response["tag"] = None - create_validation_action_response["documentTypeId"] = document_type_id - create_validation_action_response["operationId"] = operation_id - create_validation_action_response["actionStatus"] = "Completed" - create_validation_action_response["validatedExtractionResults"] = ( - modern_extraction_response["extractionResult"] - ) - create_validation_action_response["dataProjection"] = ( - modern_extraction_response.get("dataProjection", None) - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - # ACT - if mode == "async": - response = await service.get_validate_extraction_result_async( - validation_action=ValidateExtractionAction.model_validate( - create_validation_action_response - ) - ) - else: - response = service.get_validate_extraction_result( - validation_action=ValidateExtractionAction.model_validate( - create_validation_action_response - ) - ) - - # ASSERT - modern_extraction_response["projectId"] = project_id - modern_extraction_response["projectType"] = ProjectType.PRETRAINED.value - modern_extraction_response["extractorId"] = document_type_id - modern_extraction_response["tag"] = None - modern_extraction_response["documentTypeId"] = document_type_id - assert response.model_dump() == modern_extraction_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.parametrize( - "project_type,extraction_response_fixture", - [ - (ProjectType.MODERN, "modern_extraction_response"), - (ProjectType.IXP, "ixp_extraction_response"), - ], - ) - @pytest.mark.asyncio - async def test_get_validate_extraction_result_with_version( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - service: DocumentsService, - create_validation_action_response: dict, # type: ignore - modern_extraction_response: dict, # type: ignore - ixp_extraction_response: dict, # type: ignore - mode: str, - project_type: ProjectType, - extraction_response_fixture: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - document_type_id = str(UUID(int=0)) - extractor_id = str(uuid4()) - - # Select the appropriate extraction response based on the fixture name - extraction_response = ( - modern_extraction_response - if extraction_response_fixture == "modern_extraction_response" - else ixp_extraction_response - ) - - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = project_type.value - create_validation_action_response["extractorId"] = extractor_id - create_validation_action_response["tag"] = None - create_validation_action_response["documentTypeId"] = document_type_id - create_validation_action_response["operationId"] = operation_id - create_validation_action_response["actionStatus"] = "Completed" - create_validation_action_response["validatedExtractionResults"] = ( - extraction_response["extractionResult"] - ) - create_validation_action_response["dataProjection"] = extraction_response.get( - "dataProjection", None - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - # ACT - if mode == "async": - response = await service.get_validate_extraction_result_async( - validation_action=ValidateExtractionAction.model_validate( - create_validation_action_response - ) - ) - else: - response = service.get_validate_extraction_result( - validation_action=ValidateExtractionAction.model_validate( - create_validation_action_response - ) - ) - - # ASSERT - extraction_response["projectId"] = project_id - extraction_response["projectType"] = project_type - extraction_response["extractorId"] = extractor_id - extraction_response["tag"] = None - extraction_response["documentTypeId"] = document_type_id - assert response.model_dump() == extraction_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.parametrize( - "project_type,tag,extraction_response_fixture", - [ - (ProjectType.MODERN, "Production", "modern_extraction_response"), - (ProjectType.IXP, "live", "ixp_extraction_response"), - ], - ) - @pytest.mark.asyncio - async def test_get_validate_extraction_result_with_tag( - self, - httpx_mock: HTTPXMock, - base_url: str, - org: str, - tenant: str, - service: DocumentsService, - create_validation_action_response: dict, # type: ignore - modern_extraction_response: dict, # type: ignore - ixp_extraction_response: dict, # type: ignore - mode: str, - project_type: ProjectType, - tag: str, - extraction_response_fixture: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - document_type_id = str(UUID(int=0)) - - # Select the appropriate extraction response based on the fixture name - extraction_response = ( - modern_extraction_response - if extraction_response_fixture == "modern_extraction_response" - else ixp_extraction_response - ) - - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = project_type.value - create_validation_action_response["extractorId"] = None - create_validation_action_response["tag"] = tag - create_validation_action_response["documentTypeId"] = document_type_id - create_validation_action_response["operationId"] = operation_id - create_validation_action_response["actionStatus"] = "Completed" - create_validation_action_response["validatedExtractionResults"] = ( - extraction_response["extractionResult"] - ) - create_validation_action_response["dataProjection"] = extraction_response.get( - "dataProjection", None - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - # ACT - if mode == "async": - response = await service.get_validate_extraction_result_async( - validation_action=ValidateExtractionAction.model_validate( - create_validation_action_response - ) - ) - else: - response = service.get_validate_extraction_result( - validation_action=ValidateExtractionAction.model_validate( - create_validation_action_response - ) - ) - - # ASSERT - extraction_response["projectId"] = project_id - extraction_response["projectType"] = project_type - extraction_response["extractorId"] = None - extraction_response["tag"] = tag - extraction_response["documentTypeId"] = document_type_id - assert response.model_dump() == extraction_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - @patch("uipath.platform.documents._documents_service.time") - async def test_wait_for_operation_timeout( - self, - mock_time: Mock, - service: DocumentsService, - mode: str, - ): - # ARRANGE - mock_time.monotonic.side_effect = [0, 10, 30, 60, 200, 280, 310, 350] - - def mock_result_getter(): - return "Running", None, None - - async def mock_result_getter_async(): - return "Running", None, None - - # ACT & ASSERT - with pytest.raises(TimeoutError, match="Operation timed out."): - if mode == "async": - await service._wait_for_operation_async( - result_getter=mock_result_getter_async, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - else: - service._wait_for_operation( - result_getter=mock_result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_wait_for_operation_failed( - self, - service: DocumentsService, - mode: str, - ): - # ARRANGE - - def mock_result_getter(): - return "Failed", "Dummy error", None - - async def mock_result_getter_async(): - return "Failed", "Dummy error", None - - # ACT & ASSERT - with pytest.raises( - Exception, match="Operation failed with status: Failed, error: Dummy error" - ): - if mode == "async": - await service._wait_for_operation_async( - result_getter=mock_result_getter_async, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - else: - service._wait_for_operation( - result_getter=mock_result_getter, - wait_statuses=["NotStarted", "Running"], - success_status="Succeeded", - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_start_ixp_extraction( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - document_id = str(uuid4()) - operation_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={ - "projects": [ - {"id": project_id, "name": "TestProjectIXP"}, - ] - }, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_files={"File": b"test content"}, - json={"documentId": document_id}, - ) - - httpx_mock.add_response( - method="POST", - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/staging/document-types/{UUID(int=0)}/extraction/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={"documentId": document_id}, - json={"operationId": operation_id}, - ) - - # ACT - if mode == "async": - response = await service.start_ixp_extraction_async( - project_name="TestProjectIXP", - tag="staging", - file=b"test content", - ) - else: - response = service.start_ixp_extraction( - project_name="TestProjectIXP", - tag="staging", - file=b"test content", - ) - - # ASSERT - assert response.operation_id == operation_id - assert response.document_id == document_id - assert response.project_id == project_id - assert response.tag == "staging" - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_start_ixp_extraction_invalid_parameters( - self, - service: DocumentsService, - mode: str, - ): - # ACT & ASSERT - with pytest.raises( - ValueError, - match="Exactly one of `file, file_path` must be provided", - ): - if mode == "async": - await service.start_ixp_extraction_async( - project_name="TestProject", - tag="staging", - ) - else: - service.start_ixp_extraction( - project_name="TestProject", - tag="staging", - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_retrieve_ixp_extraction_result_success( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - ixp_extraction_response: dict[str, Any], - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/staging/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": ixp_extraction_response}, - ) - - # ACT - if mode == "async": - response = await service.retrieve_ixp_extraction_result_async( - project_id=project_id, - tag="staging", - operation_id=operation_id, - ) - else: - response = service.retrieve_ixp_extraction_result( - project_id=project_id, - tag="staging", - operation_id=operation_id, - ) - - # ASSERT - assert response.project_id == project_id - assert response.tag == "staging" - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_retrieve_ixp_extraction_result_not_complete( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/staging/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Running"}, - ) - - # ACT & ASSERT - with pytest.raises( - OperationNotCompleteException, - match=f"IXP extraction '{operation_id}' is not complete. Current status: Running", - ) as exc_info: - if mode == "async": - await service.retrieve_ixp_extraction_result_async( - project_id=project_id, - tag="staging", - operation_id=operation_id, - ) - else: - service.retrieve_ixp_extraction_result( - project_id=project_id, - tag="staging", - operation_id=operation_id, - ) - - assert exc_info.value.operation_id == operation_id - assert exc_info.value.status == "Running" - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_retrieve_ixp_extraction_result_failed( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/staging/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Failed", "error": "Dummy extraction error"}, - ) - - # ACT & ASSERT - with pytest.raises( - OperationFailedException, - match=f"IXP extraction '{operation_id}' failed with status: Failed error: Dummy extraction error", - ): - if mode == "async": - await service.retrieve_ixp_extraction_result_async( - project_id=project_id, - tag="staging", - operation_id=operation_id, - ) - else: - service.retrieve_ixp_extraction_result( - project_id=project_id, - tag="staging", - operation_id=operation_id, - ) - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_start_ixp_extraction_validation( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - ixp_extraction_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - tag = "live" - action_title = "TestAction" - action_priority = ActionPriority.HIGH - action_catalog = "TestCatalog" - action_folder = "TestFolder" - storage_bucket_name = "TestBucket" - storage_bucket_directory_path = "Test/Directory/Path" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "extractionResult": ixp_extraction_response["extractionResult"], - "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], - "actionTitle": action_title, - "actionPriority": action_priority, - "actionCatalog": action_catalog, - "actionFolder": action_folder, - "storageBucketName": storage_bucket_name, - "allowChangeOfDocumentType": True, - "storageBucketDirectoryPath": storage_bucket_directory_path, - }, - json={"operationId": operation_id}, - ) - - ixp_extraction_response["projectId"] = project_id - ixp_extraction_response["projectType"] = ProjectType.IXP.value - ixp_extraction_response["extractorId"] = None - ixp_extraction_response["tag"] = tag - ixp_extraction_response["documentTypeId"] = str(UUID(int=0)) - - # ACT - if mode == "async": - response = await service.start_ixp_extraction_validation_async( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=ExtractionResponse.model_validate( - ixp_extraction_response - ), - ) - else: - response = service.start_ixp_extraction_validation( - action_title=action_title, - action_priority=action_priority, - action_catalog=action_catalog, - action_folder=action_folder, - storage_bucket_name=storage_bucket_name, - storage_bucket_directory_path=storage_bucket_directory_path, - extraction_response=ExtractionResponse.model_validate( - ixp_extraction_response - ), - ) - - # ASSERT - assert response.model_dump() == { - "projectId": project_id, - "tag": tag, - "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], - "operationId": operation_id, - } - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_start_ixp_extraction_validation_with_optional_params_omitted( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - ixp_extraction_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - tag = "live" - action_title = "TestAction" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/start?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - match_json={ - "extractionResult": ixp_extraction_response["extractionResult"], - "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], - "actionTitle": action_title, - "actionPriority": None, - "actionCatalog": None, - "actionFolder": None, - "storageBucketName": None, - "allowChangeOfDocumentType": True, - "storageBucketDirectoryPath": None, - }, - json={"operationId": operation_id}, - ) - - ixp_extraction_response["projectId"] = project_id - ixp_extraction_response["projectType"] = ProjectType.IXP.value - ixp_extraction_response["extractorId"] = None - ixp_extraction_response["tag"] = tag - ixp_extraction_response["documentTypeId"] = str(UUID(int=0)) - - # ACT - if mode == "async": - response = await service.start_ixp_extraction_validation_async( - extraction_response=ExtractionResponse.model_validate( - ixp_extraction_response - ), - action_title=action_title, - ) - else: - response = service.start_ixp_extraction_validation( - extraction_response=ExtractionResponse.model_validate( - ixp_extraction_response - ), - action_title=action_title, - ) - - # ASSERT - assert response.model_dump() == { - "projectId": project_id, - "tag": tag, - "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], - "operationId": operation_id, - } - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_retrieve_ixp_extraction_validation_result_unassigned( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - create_validation_action_response: dict, # type: ignore - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - tag = "live" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Succeeded", "result": create_validation_action_response}, - ) - - # ACT - if mode == "async": - response = await service.retrieve_ixp_extraction_validation_result_async( - project_id=project_id, - tag=tag, - operation_id=operation_id, - ) - else: - response = service.retrieve_ixp_extraction_validation_result( - project_id=project_id, - tag=tag, - operation_id=operation_id, - ) - - # ASSERT - create_validation_action_response["projectId"] = project_id - create_validation_action_response["projectType"] = ProjectType.IXP.value - create_validation_action_response["extractorId"] = None - create_validation_action_response["tag"] = tag - create_validation_action_response["documentTypeId"] = str(UUID(int=0)) - create_validation_action_response["operationId"] = operation_id - create_validation_action_response["validatedExtractionResults"] = None - create_validation_action_response["dataProjection"] = None - assert response.model_dump() == create_validation_action_response - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_retrieve_ixp_extraction_validation_result_completed( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - extraction_validation_action_response_completed: dict, # type: ignore - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - tag = "live" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={ - "status": "Succeeded", - "result": extraction_validation_action_response_completed, - }, - ) - - # ACT - if mode == "async": - response = await service.retrieve_ixp_extraction_validation_result_async( - project_id=project_id, - tag=tag, - operation_id=operation_id, - ) - else: - response = service.retrieve_ixp_extraction_validation_result( - project_id=project_id, - tag=tag, - operation_id=operation_id, - ) - - # ASSERT - extraction_validation_action_response_completed["projectId"] = project_id - extraction_validation_action_response_completed["projectType"] = ( - ProjectType.IXP.value - ) - extraction_validation_action_response_completed["extractorId"] = None - extraction_validation_action_response_completed["tag"] = tag - extraction_validation_action_response_completed["documentTypeId"] = str( - UUID(int=0) - ) - extraction_validation_action_response_completed["operationId"] = operation_id - assert response.model_dump() == extraction_validation_action_response_completed - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_retrieve_ixp_extraction_validation_result_not_complete( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - tag = "live" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Running"}, - ) - - # ACT & ASSERT - with pytest.raises( - OperationNotCompleteException, - match=f"IXP Create Validate Extraction Action '{operation_id}' is not complete. Current status: Running", - ) as exc_info: - if mode == "async": - await service.retrieve_ixp_extraction_validation_result_async( - project_id=project_id, - tag=tag, - operation_id=operation_id, - ) - else: - service.retrieve_ixp_extraction_validation_result( - project_id=project_id, - tag=tag, - operation_id=operation_id, - ) - - assert exc_info.value.operation_id == operation_id - assert exc_info.value.status == "Running" - - @pytest.mark.parametrize("mode", ["sync", "async"]) - @pytest.mark.asyncio - async def test_retrieve_ixp_extraction_validation_result_failed( - self, - httpx_mock: HTTPXMock, - service: DocumentsService, - base_url: str, - org: str, - tenant: str, - mode: str, - ): - # ARRANGE - project_id = str(uuid4()) - operation_id = str(uuid4()) - tag = "live" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", - status_code=200, - match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, - json={"status": "Failed", "error": "Dummy error"}, - ) - - # ACT & ASSERT - with pytest.raises( - OperationFailedException, - match=f"IXP Create Validate Extraction Action '{operation_id}' failed with status: Failed error: Dummy error", - ): - if mode == "async": - await service.retrieve_ixp_extraction_validation_result_async( - project_id=project_id, - tag=tag, - operation_id=operation_id, - ) - else: - service.retrieve_ixp_extraction_validation_result( - project_id=project_id, - tag=tag, - operation_id=operation_id, - ) diff --git a/tests/sdk/services/test_entities_service.py b/tests/sdk/services/test_entities_service.py deleted file mode 100644 index 4c6c85882..000000000 --- a/tests/sdk/services/test_entities_service.py +++ /dev/null @@ -1,262 +0,0 @@ -import uuid -from dataclasses import make_dataclass -from typing import Optional - -import pytest -from pytest_httpx import HTTPXMock - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.entities import Entity -from uipath.platform.entities._entities_service import EntitiesService - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> EntitiesService: - return EntitiesService(config=config, execution_context=execution_context) - - -@pytest.fixture(params=[True, False], ids=["correct_schema", "incorrect_schema"]) -def record_schema(request): - is_correct = request.param - field_type = int if is_correct else str - schema_name = f"RecordSchema{'Correct' if is_correct else 'Incorrect'}" - - RecordSchema = make_dataclass( - schema_name, [("name", str), ("integer_field", field_type)] - ) - - return RecordSchema, is_correct - - -@pytest.fixture(params=[True, False], ids=["optional_field", "required_field"]) -def record_schema_optional(request): - is_optional = request.param - field_type = Optional[int] | None if is_optional else int - schema_name = f"RecordSchema{'Optional' if is_optional else 'Required'}" - - RecordSchemaOptional = make_dataclass( - schema_name, [("name", str), ("integer_field", field_type)] - ) - - return RecordSchemaOptional, is_optional - - -class TestEntitiesService: - def test_retrieve( - self, - httpx_mock: HTTPXMock, - service: EntitiesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - entity_key = uuid.uuid4() - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_key}", - status_code=200, - json={ - "name": "TestEntity", - "displayName": "TestEntity", - "entityType": "TestEntityType", - "description": "TestEntity Description", - "fields": [ - { - "id": "12345", - "name": "field_name", - "isPrimaryKey": True, - "isForeignKey": False, - "isExternalField": False, - "isHiddenField": True, - "isUnique": True, - "referenceType": "ManyToOne", - "sqlType": {"name": "VARCHAR", "LengthLimit": 100}, - "isRequired": True, - "displayName": "Field Display Name", - "description": "This is a brief description of the field.", - "isSystemField": False, - "isAttachment": False, - "isRbacEnabled": True, - } - ], - "isRbacEnabled": False, - "id": f"{entity_key}", - }, - ) - - entity = service.retrieve(entity_key=str(entity_key)) - - assert isinstance(entity, Entity) - assert entity.id == f"{entity_key}" - assert entity.name == "TestEntity" - assert entity.display_name == "TestEntity" - assert entity.entity_type == "TestEntityType" - assert entity.description == "TestEntity Description" - assert entity.fields is not None - assert entity.fields[0].id == "12345" - assert entity.fields[0].name == "field_name" - assert entity.fields[0].is_primary_key - assert not entity.fields[0].is_foreign_key - assert entity.fields[0].sql_type.name == "VARCHAR" - assert entity.fields[0].sql_type.length_limit == 100 - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_key}" - ) - - def test_retrieve_records_with_no_schema_succeeds( - self, - httpx_mock: HTTPXMock, - service: EntitiesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - entity_key = uuid.uuid4() - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{str(entity_key)}/read?start=0&limit=1", - status_code=200, - json={ - "totalCount": 1, - "value": [ - {"Id": "12345", "name": "record_name", "integer_field": 10}, - {"Id": "12346", "name": "record_name2", "integer_field": 11}, - ], - }, - ) - - records = service.list_records(entity_key=str(entity_key), start=0, limit=1) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert isinstance(records, list) - assert len(records) == 2 - assert records[0].id == "12345" - assert records[0].name == "record_name" - assert records[0].integer_field == 10 - assert records[1].id == "12346" - assert records[1].name == "record_name2" - assert records[1].integer_field == 11 - - def test_retrieve_records_with_schema_succeeds( - self, - httpx_mock: HTTPXMock, - service: EntitiesService, - base_url: str, - org: str, - tenant: str, - version: str, - record_schema, - ) -> None: - entity_key = uuid.uuid4() - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{str(entity_key)}/read?start=0&limit=1", - status_code=200, - json={ - "totalCount": 1, - "value": [ - {"Id": "12345", "name": "record_name", "integer_field": 10}, - {"Id": "12346", "name": "record_name2", "integer_field": 11}, - ], - }, - ) - - # Define the schema for the record. A wrong schema should make the validation fail - RecordSchema, is_schema_correct = record_schema - - if is_schema_correct: - records = service.list_records( - entity_key=str(entity_key), schema=RecordSchema, start=0, limit=1 - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert isinstance(records, list) - assert len(records) == 2 - assert records[0].id == "12345" - assert records[0].name == "record_name" - assert records[0].integer_field == 10 - assert records[1].id == "12346" - assert records[1].name == "record_name2" - assert records[1].integer_field == 11 - else: - # Validation should fail and raise an exception - with pytest.raises((ValueError, TypeError)): - service.list_records( - entity_key=str(entity_key), schema=RecordSchema, start=0, limit=1 - ) - - # Schema validation should take into account optional fields - def test_retrieve_records_with_optional_fields( - self, - httpx_mock: HTTPXMock, - service: EntitiesService, - base_url: str, - org: str, - tenant: str, - version: str, - record_schema_optional, - ) -> None: - entity_key = uuid.uuid4() - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{str(entity_key)}/read?start=0&limit=1", - status_code=200, - json={ - "totalCount": 1, - "value": [ - { - "Id": "12345", - "name": "record_name", - }, - { - "Id": "12346", - "name": "record_name2", - }, - ], - }, - ) - - RecordSchemaOptional, is_field_optional = record_schema_optional - - if is_field_optional: - records = service.list_records( - entity_key=str(entity_key), - schema=RecordSchemaOptional, - start=0, - limit=1, - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert isinstance(records, list) - assert len(records) == 2 - assert records[0].id == "12345" - assert records[0].name == "record_name" - assert records[1].id == "12346" - assert records[1].name == "record_name2" - else: - # Validation should fail and raise an exception for missing required field - with pytest.raises((ValueError, TypeError)): - service.list_records( - entity_key=str(entity_key), - schema=RecordSchemaOptional, - start=0, - limit=1, - ) diff --git a/tests/sdk/services/test_external_application_service.py b/tests/sdk/services/test_external_application_service.py deleted file mode 100644 index 849f566f3..000000000 --- a/tests/sdk/services/test_external_application_service.py +++ /dev/null @@ -1,114 +0,0 @@ -import httpx -import pytest -from pytest_httpx import HTTPXMock - -from uipath.platform.common._external_application_service import ( - ExternalApplicationService, -) -from uipath.platform.errors import EnrichedException - - -class TestExternalApplicationService: - @pytest.mark.parametrize( - "url,expected_domain", - [ - ("https://alpha.uipath.com", "alpha"), - ("https://sub.alpha.uipath.com", "alpha"), - ("https://staging.uipath.com", "staging"), - ("https://env.staging.uipath.com", "staging"), - ("https://cloud.uipath.com", "cloud"), - ("https://org.cloud.uipath.com", "cloud"), - ("https://something-else.com", "cloud"), - ("not-a-url", "cloud"), - ], - ) - def test_extract_domain_from_base_url(self, url: str, expected_domain: str): - service = ExternalApplicationService(url) - assert service._domain == expected_domain - - @pytest.mark.parametrize( - "domain,expected_url", - [ - ("alpha", "https://alpha.uipath.com/identity_/connect/token"), - ("staging", "https://staging.uipath.com/identity_/connect/token"), - ("cloud", "https://cloud.uipath.com/identity_/connect/token"), - ("unknown", "https://cloud.uipath.com/identity_/connect/token"), - ], - ) - def test_get_token_url(self, domain: str, expected_url: str): - service = ExternalApplicationService("https://cloud.uipath.com") - service._domain = domain - assert service.get_token_url() == expected_url - - def test_get_access_token_success(self, httpx_mock: HTTPXMock): - service = ExternalApplicationService("https://cloud.uipath.com") - - token_url = service.get_token_url() - httpx_mock.add_response( - url=token_url, - method="POST", - status_code=200, - json={"access_token": "fake-token"}, - ) - - token = service.get_token_data("client-id", "client-secret") - assert token.access_token == "fake-token" - - def test_get_access_token_invalid_client(self, httpx_mock: HTTPXMock): - service = ExternalApplicationService("https://cloud.uipath.com") - - token_url = service.get_token_url() - httpx_mock.add_response(url=token_url, method="POST", status_code=400, json={}) - - with pytest.raises(EnrichedException) as exc: - service.get_token_data("bad-id", "bad-secret") - - assert "400" in str(exc.value) - - def test_get_access_token_unauthorized(self, httpx_mock: HTTPXMock): - service = ExternalApplicationService("https://cloud.uipath.com") - - token_url = service.get_token_url() - httpx_mock.add_response(url=token_url, method="POST", status_code=401, json={}) - - with pytest.raises(EnrichedException) as exc: - service.get_token_data("bad-id", "bad-secret") - - assert "401" in str(exc.value) - - def test_get_access_token_unexpected_status(self, httpx_mock: HTTPXMock): - service = ExternalApplicationService("https://cloud.uipath.com") - - token_url = service.get_token_url() - httpx_mock.add_response(url=token_url, method="POST", status_code=500, json={}) - - with pytest.raises(EnrichedException) as exc: - service.get_token_data("client-id", "client-secret") - - assert "500" in str(exc.value).lower() - - def test_get_access_token_network_error(self, monkeypatch): - service = ExternalApplicationService("https://cloud.uipath.com") - - def fake_client_post(*args, **kwargs): - raise httpx.RequestError("network down") - - monkeypatch.setattr(httpx.Client, "post", fake_client_post) - - with pytest.raises(Exception) as exc: - service.get_token_data("client-id", "client-secret") - - assert "Network error during authentication" in str(exc.value) - - def test_get_access_token_unexpected_exception(self, monkeypatch): - service = ExternalApplicationService("https://cloud.uipath.com") - - def fake_client_post(*args, **kwargs): - raise ValueError("weird error") - - monkeypatch.setattr(httpx.Client, "post", fake_client_post) - - with pytest.raises(Exception) as exc: - service.get_token_data("client-id", "client-secret") - - assert "Unexpected error during authentication" in str(exc.value) diff --git a/tests/sdk/services/test_folder_service.py b/tests/sdk/services/test_folder_service.py deleted file mode 100644 index 5de441830..000000000 --- a/tests/sdk/services/test_folder_service.py +++ /dev/null @@ -1,501 +0,0 @@ -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.errors import FolderNotFoundException -from uipath.platform.orchestrator._folder_service import FolderService - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> FolderService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return FolderService(config=config, execution_context=execution_context) - - -class TestFolderService: - def test_retrieve_key_by_folder_path( - self, - httpx_mock: HTTPXMock, - service: FolderService, - 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", - } - ] - }, - ) - - with pytest.warns(DeprecationWarning, match="Use retrieve_key instead"): - folder_key = service.retrieve_key_by_folder_path("test-folder-path") - - assert folder_key == "test-folder-key" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.FolderService.retrieve_key/{version}" - ) - - def test_retrieve_key_by_folder_path_not_found( - self, - httpx_mock: HTTPXMock, - service: FolderService, - 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=non-existent-folder&skip=0&take=20", - status_code=200, - json={"PageItems": []}, - ) - - with pytest.warns(DeprecationWarning, match="Use retrieve_key instead"): - folder_key = service.retrieve_key_by_folder_path("non-existent-folder") - - assert folder_key is None - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=non-existent-folder&skip=0&take=20" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.FolderService.retrieve_key/{version}" - ) - - def test_retrieve_key( - self, - httpx_mock: HTTPXMock, - service: FolderService, - 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", - } - ] - }, - ) - - folder_key = service.retrieve_key(folder_path="test-folder-path") - - assert folder_key == "test-folder-key" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.FolderService.retrieve_key/{version}" - ) - - def test_retrieve_key_not_found( - self, - httpx_mock: HTTPXMock, - service: FolderService, - 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=non-existent-folder&skip=0&take=20", - status_code=200, - json={"PageItems": []}, - ) - - folder_key = service.retrieve_key(folder_path="non-existent-folder") - - assert folder_key is None - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=non-existent-folder&skip=0&take=20" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.FolderService.retrieve_key/{version}" - ) - - def test_retrieve_key_found_on_second_page( - self, - httpx_mock: HTTPXMock, - service: FolderService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test that retrieve_key can find a folder on subsequent pages through pagination.""" - # First page - folder not found - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=target-folder&skip=0&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": f"folder-key-{i}", - "FullyQualifiedName": f"other-folder-{i}", - } - for i in range(20) # Full page of 20 items, none matching - ] - }, - ) - - # Second page - folder found - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=target-folder&skip=20&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": "target-folder-key", - "FullyQualifiedName": "target-folder", - }, - { - "Key": "another-folder-key", - "FullyQualifiedName": "another-folder", - }, - ] - }, - ) - - folder_key = service.retrieve_key(folder_path="target-folder") - - assert folder_key == "target-folder-key" - - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - assert requests[0].method == "GET" - assert ( - requests[0].url - == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=target-folder&skip=0&take=20" - ) - - assert requests[1].method == "GET" - assert ( - requests[1].url - == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=target-folder&skip=20&take=20" - ) - - def test_retrieve_key_not_found_after_pagination( - self, - httpx_mock: HTTPXMock, - service: FolderService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test that retrieve_key returns None when folder is not found after paginating through all results.""" - # First page - full page, no match - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=missing-folder&skip=0&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": f"folder-key-{i}", - "FullyQualifiedName": f"other-folder-{i}", - } - for i in range(20) # Full page of 20 items - ] - }, - ) - - # Second page - no match - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=missing-folder&skip=20&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": "final-folder-key", - "FullyQualifiedName": "final-folder", - }, - ] - }, - ) - - folder_key = service.retrieve_key(folder_path="missing-folder") - - assert folder_key is None - - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - assert requests[0].method == "GET" - assert ( - requests[0].url - == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=missing-folder&skip=0&take=20" - ) - - assert requests[1].method == "GET" - assert ( - requests[1].url - == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=missing-folder&skip=20&take=20" - ) - - def test_retrieve_key_found_on_third_page( - self, - httpx_mock: HTTPXMock, - service: FolderService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test that retrieve_key can find a folder on the third page through multiple pagination requests.""" - # First page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=0&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": f"folder-key-{i}", - "FullyQualifiedName": f"page1-folder-{i}", - } - for i in range(20) - ] - }, - ) - - # Second page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=20&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": f"folder-key-{i}", - "FullyQualifiedName": f"page2-folder-{i}", - } - for i in range(20) - ] - }, - ) - - # Third page - folder found - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=40&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": "some-other-key", - "FullyQualifiedName": "some-other-folder", - }, - { - "Key": "deep-folder-key", - "FullyQualifiedName": "deep-folder", - }, - ] - }, - ) - - folder_key = service.retrieve_key(folder_path="deep-folder") - - assert folder_key == "deep-folder-key" - - requests = httpx_mock.get_requests() - assert len(requests) == 3 - - expected_urls = [ - f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=0&take=20", - f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=20&take=20", - f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=40&take=20", - ] - - for i, request in enumerate(requests): - assert request.method == "GET" - assert request.url == expected_urls[i] - - def test_retrieve_folder_key_with_folder_path( - self, - httpx_mock: HTTPXMock, - service: FolderService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test retrieve_folder_key resolves folder_path to folder_key.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=Production&skip=0&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": "retrieved-folder-key", - "FullyQualifiedName": "Finance/Production", - } - ] - }, - ) - - retrieved_key = service.retrieve_folder_key(folder_path="Finance/Production") - - assert retrieved_key == "retrieved-folder-key" - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - - def test_retrieve_folder_key_raises_error( - self, - service: FolderService, - ) -> None: - """Test retrieve_folder_key raises ValueError when folder_path is not provided.""" - with pytest.raises(ValueError) as exc_info: - service.retrieve_folder_key(folder_path=None) - - assert "Cannot obtain folder_key without providing folder_path" in str( - exc_info.value - ) - - def test_retrieve_folder_key_not_found_raises_error( - self, - httpx_mock: HTTPXMock, - service: FolderService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test retrieve_folder_key raises ValueError when folder_path is not found.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=Folder&skip=0&take=20", - status_code=200, - json={"PageItems": []}, - ) - - with pytest.raises(FolderNotFoundException) as exc_info: - service.retrieve_folder_key(folder_path="NonExistent/Folder") - - assert "Folder NonExistent/Folder not found" in str(exc_info.value) - - @pytest.mark.anyio - async def test_retrieve_folder_key_async_with_folder_path( - self, - httpx_mock: HTTPXMock, - service: FolderService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test retrieve_folder_key_async resolves folder_path to folder_key.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=Production&skip=0&take=20", - status_code=200, - json={ - "PageItems": [ - { - "Key": "async-retrieved-key", - "FullyQualifiedName": "Finance/Production", - } - ] - }, - ) - - retrieved_key = await service.retrieve_folder_key_async( - folder_path="Finance/Production" - ) - - assert retrieved_key == "async-retrieved-key" - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - - @pytest.mark.anyio - async def test_retrieve_folder_key_async_raises_error( - self, - service: FolderService, - ) -> None: - """Test retrieve_folder_key_async raises ValueError when folder_path is not provided.""" - with pytest.raises(ValueError) as exc_info: - await service.retrieve_folder_key_async(folder_path=None) - - assert "Cannot obtain folder_key without providing folder_path" in str( - exc_info.value - ) - - @pytest.mark.anyio - async def test_retrieve_folder_key_async_not_found_raises_error( - self, - httpx_mock: HTTPXMock, - service: FolderService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test retrieve_folder_key_async raises ValueError when folder_path is not found.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=Folder&skip=0&take=20", - status_code=200, - json={"PageItems": []}, - ) - - with pytest.raises(FolderNotFoundException) as exc_info: - await service.retrieve_folder_key_async(folder_path="NonExistent/Folder") - - assert "Folder NonExistent/Folder not found" in str(exc_info.value) diff --git a/tests/sdk/services/test_guardrails_service.py b/tests/sdk/services/test_guardrails_service.py deleted file mode 100644 index 9d8f5a900..000000000 --- a/tests/sdk/services/test_guardrails_service.py +++ /dev/null @@ -1,300 +0,0 @@ -import json - -import httpx -import pytest -from pytest_httpx import HTTPXMock -from uipath.core.guardrails import ( - GuardrailScope, - GuardrailSelector, - GuardrailValidationResultType, -) - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.guardrails import ( - BuiltInValidatorGuardrail, - EnumListParameterValue, - GuardrailsService, - MapEnumParameterValue, -) - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> GuardrailsService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return GuardrailsService(config=config, execution_context=execution_context) - - -class TestGuardrailsService: - """Test GuardrailsService functionality.""" - - class TestEvaluateGuardrail: - """Test evaluate_guardrail method.""" - - def test_evaluate_guardrail_validation( - self, - httpx_mock: HTTPXMock, - service: GuardrailsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - print(f"base_url: {base_url}, org: {org}, tenant: {tenant}") - # Mock the API response - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", - status_code=200, - json={ - "result": "PASSED", - "details": "Validation passed", - }, - ) - - # Create a PII detection guardrail - pii_guardrail = BuiltInValidatorGuardrail( - id="test-id", - name="PII detection guardrail", - description="Test PII detection", - enabled_for_evals=True, - selector=GuardrailSelector( - scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] - ), - guardrail_type="builtInValidator", - validator_type="pii_detection", - validator_parameters=[ - EnumListParameterValue( - parameter_type="enum-list", - id="entities", - value=["Email", "Address"], - ), - MapEnumParameterValue( - parameter_type="map-enum", - id="entityThresholds", - value={"Email": 1, "Address": 0.7}, - ), - ], - ) - - test_input = "There is no email or address here." - - result = service.evaluate_guardrail(test_input, pii_guardrail) - - assert result.result == GuardrailValidationResultType.PASSED - assert result.reason == "Validation passed" - - def test_evaluate_guardrail_validation_failed( - self, - httpx_mock: HTTPXMock, - service: GuardrailsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - # Mock API response for failed validation - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", - status_code=200, - json={ - "result": "VALIDATION_FAILED", - "details": "PII detected: Email found", - }, - ) - - pii_guardrail = BuiltInValidatorGuardrail( - id="test-id", - name="PII detection guardrail", - description="Test PII detection", - enabled_for_evals=True, - selector=GuardrailSelector( - scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] - ), - guardrail_type="builtInValidator", - validator_type="pii_detection", - validator_parameters=[], - ) - - test_input = "Contact me at john@example.com" - - result = service.evaluate_guardrail(test_input, pii_guardrail) - - assert result.result == GuardrailValidationResultType.VALIDATION_FAILED - assert result.reason == "PII detected: Email found" - - def test_evaluate_guardrail_feature_disabled_403( - self, - httpx_mock: HTTPXMock, - service: GuardrailsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - # Mock API response with 403 status for FEATURE_DISABLED - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", - status_code=403, - json={ - "result": "FEATURE_DISABLED", - "details": "Guardrail feature is disabled", - }, - ) - - pii_guardrail = BuiltInValidatorGuardrail( - id="test-id", - name="PII detection guardrail", - description="Test PII detection", - enabled_for_evals=True, - selector=GuardrailSelector( - scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] - ), - guardrail_type="builtInValidator", - validator_type="pii_detection", - validator_parameters=[], - ) - - test_input = "Contact me at john@example.com" - - result = service.evaluate_guardrail(test_input, pii_guardrail) - - assert result.result == GuardrailValidationResultType.FEATURE_DISABLED - assert result.reason == "Guardrail feature is disabled" - - def test_evaluate_guardrail_entitlements_missing_403( - self, - httpx_mock: HTTPXMock, - service: GuardrailsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - # Mock API response with 403 status for ENTITLEMENTS_MISSING - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", - status_code=403, - json={ - "result": "ENTITLEMENTS_MISSING", - "details": "Guardrail entitlement is missing", - }, - ) - - pii_guardrail = BuiltInValidatorGuardrail( - id="test-id", - name="PII detection guardrail", - description="Test PII detection", - enabled_for_evals=True, - selector=GuardrailSelector( - scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] - ), - guardrail_type="builtInValidator", - validator_type="pii_detection", - validator_parameters=[], - ) - - test_input = "Contact me at john@example.com" - - result = service.evaluate_guardrail(test_input, pii_guardrail) - - assert result.result == GuardrailValidationResultType.ENTITLEMENTS_MISSING - assert result.reason == "Guardrail entitlement is missing" - - def test_evaluate_guardrail_request_payload_structure( - self, - httpx_mock: HTTPXMock, - service: GuardrailsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test that the request payload has the correct structure after revert.""" - captured_request = None - - def capture_request(request): - nonlocal captured_request - captured_request = request - return httpx.Response( - status_code=200, - json={ - "result": "PASSED", - "details": "Validation passed", - }, - ) - - httpx_mock.add_callback( - method="POST", - url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", - callback=capture_request, - ) - - # Create a PII detection guardrail with parameters - pii_guardrail = BuiltInValidatorGuardrail( - id="test-id", - name="PII detection guardrail", - description="Test PII detection", - enabled_for_evals=True, - selector=GuardrailSelector( - scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] - ), - guardrail_type="builtInValidator", - validator_type="pii_detection", - validator_parameters=[ - EnumListParameterValue( - parameter_type="enum-list", - id="entities", - value=["Email", "Address"], - ), - MapEnumParameterValue( - parameter_type="map-enum", - id="entityThresholds", - value={"Email": 1, "Address": 0.7}, - ), - ], - ) - - test_input = "There is no email or address here." - - result = service.evaluate_guardrail(test_input, pii_guardrail) - - # Verify the request was captured - assert captured_request is not None - - # Parse the request payload - request_payload = json.loads(captured_request.content) - - # Verify the payload structure matches the reverted format: - # { - # "validator": guardrail.validator_type, - # "input": input_data, - # "parameters": parameters, - # } - assert "validator" in request_payload - assert "input" in request_payload - assert "parameters" in request_payload - - # Verify validator is a string (not an object) - assert isinstance(request_payload["validator"], str) - assert request_payload["validator"] == "pii_detection" - - # Verify input is a string - assert isinstance(request_payload["input"], str) - assert request_payload["input"] == "There is no email or address here." - - # Verify parameters is an array - assert isinstance(request_payload["parameters"], list) - assert len(request_payload["parameters"]) == 2 - - # Verify parameter structure - entities_param = request_payload["parameters"][0] - assert entities_param["$parameterType"] == "enum-list" - assert entities_param["id"] == "entities" - assert entities_param["value"] == ["Email", "Address"] - - thresholds_param = request_payload["parameters"][1] - assert thresholds_param["$parameterType"] == "map-enum" - assert thresholds_param["id"] == "entityThresholds" - assert thresholds_param["value"] == {"Email": 1, "Address": 0.7} - - # Verify result fields - assert result.result == GuardrailValidationResultType.PASSED - assert result.reason == "Validation passed" diff --git a/tests/sdk/services/test_jobs_service.py b/tests/sdk/services/test_jobs_service.py deleted file mode 100644 index c1254f9db..000000000 --- a/tests/sdk/services/test_jobs_service.py +++ /dev/null @@ -1,1392 +0,0 @@ -import json -import os -import shutil -import uuid -from typing import TYPE_CHECKING, Any, Generator, Tuple - -import pytest -from pytest_httpx import HTTPXMock -from pytest_mock import MockerFixture - -from uipath._utils.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.orchestrator import Job -from uipath.platform.orchestrator._jobs_service import JobsService - -if TYPE_CHECKING: - from _pytest.monkeypatch import MonkeyPatch - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> JobsService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - jobs_service = JobsService(config=config, execution_context=execution_context) - # We'll leave the real AttachmentsService for HTTP tests, - # and mock it in specific tests as needed - return jobs_service - - -@pytest.fixture -def temp_attachments_dir(tmp_path: Any) -> Generator[str, None, None]: - """Create a temporary directory for attachments and clean it up after the test. - - Args: - tmp_path: Pytest's temporary directory fixture. - - Returns: - The path to the temporary directory. - """ - test_temp_dir = os.path.join(tmp_path, TEMP_ATTACHMENTS_FOLDER) - os.makedirs(test_temp_dir, exist_ok=True) - - yield test_temp_dir - - # Clean up the directory after the test - if os.path.exists(test_temp_dir): - shutil.rmtree(test_temp_dir) - - -@pytest.fixture -def temp_file(tmp_path: Any) -> Generator[Tuple[str, str, str], None, None]: - """Create a temporary file and clean it up after the test. - - Args: - tmp_path: Pytest's temporary directory fixture. - - Returns: - A tuple containing the file content, file name, and file path. - """ - content = "Test source file content" - name = f"test_file_{uuid.uuid4()}.txt" - path = os.path.join(tmp_path, name) - - with open(path, "w") as f: - f.write(content) - - yield content, name, path - - # Clean up the file after the test - if os.path.exists(path): - os.remove(path) - - -@pytest.fixture -def local_attachment_file( - temp_attachments_dir: str, -) -> Generator[Tuple[uuid.UUID, str, str], None, None]: - """Creates a local attachment file in the temporary attachments directory. - - Args: - temp_attachments_dir: The temporary attachments directory. - - Returns: - A tuple containing the attachment ID, file name, and file content. - """ - attachment_id = uuid.uuid4() - file_name = "test_local_file.txt" - file_content = "Local test content" - - # Create the local file with the format {uuid}_{filename} - file_path = os.path.join(temp_attachments_dir, f"{attachment_id}_{file_name}") - with open(file_path, "w") as f: - f.write(file_content) - - yield attachment_id, file_name, file_content - - # Cleanup is handled by temp_attachments_dir fixture - - -class TestJobsService: - def test_retrieve( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - job_key = "test-job-key" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", - status_code=200, - json={ - "Key": job_key, - "State": "Running", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - }, - ) - - job = service.retrieve(job_key) - - assert isinstance(job, Job) - assert job.key == job_key - assert job.state == "Running" - assert job.start_time == "2024-01-01T00:00:00Z" - assert job.id == 123 - assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" - - sent_request = httpx_mock.get_request() - assert sent_request is not None - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.retrieve/{version}" - ) - - @pytest.mark.asyncio - async def test_retrieve_async( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - job_key = "test-job-key" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", - status_code=200, - json={ - "Key": job_key, - "State": "Running", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - }, - ) - - job = await service.retrieve_async(job_key) - - assert isinstance(job, Job) - assert job.key == job_key - assert job.state == "Running" - assert job.start_time == "2024-01-01T00:00:00Z" - assert job.id == 123 - assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" - - sent_request = httpx_mock.get_request() - assert sent_request is not None - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.retrieve_async/{version}" - ) - - def test_resume_with_inbox_id( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - inbox_id = "test-inbox-id" - payload = {"key": "value"} - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}", - status_code=200, - ) - - service.resume(inbox_id=inbox_id, payload=payload) - - sent_request = httpx_mock.get_request() - assert sent_request is not None - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" - ) - - assert json.loads(sent_request.content.decode()) == {"payload": payload} - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.resume/{version}" - ) - - def test_resume_with_job_id( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - job_id = "test-job-id" - inbox_id = "test-inbox-id" - payload = {"key": "value"} - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/JobTriggers?$filter=JobId eq {job_id}&$top=1&$select=ItemKey", - status_code=200, - json={"value": [{"ItemKey": inbox_id}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}", - status_code=200, - ) - - service.resume(job_id=job_id, payload=payload) - - sent_requests = httpx_mock.get_requests() - assert sent_requests is not None - assert sent_requests[1].method == "POST" - assert ( - sent_requests[1].url - == f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" - ) - - assert json.loads(sent_requests[1].content.decode()) == {"payload": payload} - - 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.JobsService.resume/{version}" - ) - - @pytest.mark.asyncio - async def test_resume_async_with_inbox_id( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - inbox_id = "test-inbox-id" - payload = {"key": "value"} - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}", - status_code=200, - ) - - await service.resume_async(inbox_id=inbox_id, payload=payload) - - sent_request = httpx_mock.get_request() - assert sent_request is not None - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" - ) - - assert json.loads(sent_request.content.decode()) == {"payload": payload} - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.resume_async/{version}" - ) - - @pytest.mark.asyncio - async def test_resume_async_with_job_id( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - job_id = "test-job-id" - inbox_id = "test-inbox-id" - payload = {"key": "value"} - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/JobTriggers?$filter=JobId eq {job_id}&$top=1&$select=ItemKey", - status_code=200, - json={"value": [{"ItemKey": inbox_id}]}, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}", - status_code=200, - ) - - await service.resume_async(job_id=job_id, payload=payload) - - sent_requests = httpx_mock.get_requests() - assert sent_requests is not None - assert sent_requests[1].method == "POST" - assert ( - sent_requests[1].url - == f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" - ) - - assert json.loads(sent_requests[1].content.decode()) == {"payload": payload} - - 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.JobsService.resume_async/{version}" - ) - - def test_list_attachments( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - job_key = uuid.uuid4() - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey?jobKey={job_key}", - method="GET", - status_code=200, - json=[ - { - "attachmentId": "12345678-1234-1234-1234-123456789012", - "creationTime": "2023-01-01T12:00:00Z", - "lastModificationTime": "2023-01-02T12:00:00Z", - }, - { - "attachmentId": "87654321-1234-1234-1234-123456789012", - "creationTime": "2023-01-03T12:00:00Z", - "lastModificationTime": "2023-01-04T12:00:00Z", - }, - ], - ) - - attachments = service.list_attachments(job_key=job_key) - - assert len(attachments) == 2 - assert isinstance(attachments[0], str) - assert attachments[0] == "12345678-1234-1234-1234-123456789012" - assert isinstance(attachments[1], str) - assert attachments[1] == "87654321-1234-1234-1234-123456789012" - - request = httpx_mock.get_request() - assert request is not None - assert request.method == "GET" - assert ( - request.url.path - == f"{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey" - ) - assert request.url.params.get("jobKey") == str(job_key) - assert HEADER_USER_AGENT in request.headers - - @pytest.mark.asyncio - async def test_list_attachments_async( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - job_key = uuid.uuid4() - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey?jobKey={job_key}", - method="GET", - status_code=200, - json=[ - { - "attachmentId": "12345678-1234-1234-1234-123456789012", - "creationTime": "2023-01-01T12:00:00Z", - "lastModificationTime": "2023-01-02T12:00:00Z", - }, - { - "attachmentId": "87654321-1234-1234-1234-123456789012", - "creationTime": "2023-01-03T12:00:00Z", - "lastModificationTime": "2023-01-04T12:00:00Z", - }, - ], - ) - - attachments = await service.list_attachments_async(job_key=job_key) - - assert len(attachments) == 2 - assert isinstance(attachments[0], str) - assert attachments[0] == "12345678-1234-1234-1234-123456789012" - assert isinstance(attachments[1], str) - assert attachments[1] == "87654321-1234-1234-1234-123456789012" - - request = httpx_mock.get_request() - assert request is not None - assert request.method == "GET" - assert ( - request.url.path - == f"{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey" - ) - assert request.url.params.get("jobKey") == str(job_key) - assert HEADER_USER_AGENT in request.headers - - def test_link_attachment( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - attachment_key = uuid.uuid4() - job_key = uuid.uuid4() - category = "Result" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/Post", - method="POST", - status_code=200, - ) - - service.link_attachment( - attachment_key=attachment_key, job_key=job_key, category=category - ) - - request = httpx_mock.get_request() - assert request is not None - assert request.method == "POST" - assert ( - request.url - == f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/Post" - ) - assert HEADER_USER_AGENT in request.headers - - body = json.loads(request.content) - assert body["attachmentId"] == str(attachment_key) - assert body["jobKey"] == str(job_key) - assert body["category"] == category - - @pytest.mark.asyncio - async def test_link_attachment_async( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - attachment_key = uuid.uuid4() - job_key = uuid.uuid4() - category = "Result" - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/Post", - method="POST", - status_code=200, - ) - - await service.link_attachment_async( - attachment_key=attachment_key, job_key=job_key, category=category - ) - - request = httpx_mock.get_request() - assert request is not None - assert request.method == "POST" - assert ( - request.url - == f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/Post" - ) - assert HEADER_USER_AGENT in request.headers - - body = json.loads(request.content) - assert body["attachmentId"] == str(attachment_key) - assert body["jobKey"] == str(job_key) - assert body["category"] == category - - def test_create_job_attachment_with_job( - self, - service: JobsService, - mocker: MockerFixture, - ) -> None: - """Test creating a job attachment when a job is available. - - This tests that the attachment is created in UiPath and linked to the job - when a job key is provided. - - Args: - service: JobsService fixture. - mocker: MockerFixture for mocking dependencies. - """ - # Arrange - job_key = str(uuid.uuid4()) - attachment_key = uuid.uuid4() - content = "Test attachment content" - name = "test_attachment.txt" - - # Mock the attachment service's upload method - mock_upload = mocker.patch.object( - service._attachments_service, "upload", return_value=attachment_key - ) - - # Mock the link_attachment method - mock_link = mocker.patch.object(service, "link_attachment") - - # Act - result = service.create_attachment(name=name, content=content, job_key=job_key) - - # Assert - assert result == attachment_key - mock_upload.assert_called_once_with( - name=name, - content=content, - folder_key=None, - folder_path=None, - ) - mock_link.assert_called_once_with( - attachment_key=attachment_key, - job_key=uuid.UUID(job_key), - category=None, - folder_key=None, - folder_path=None, - ) - - def test_create_job_attachment_with_job_context( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: "MonkeyPatch", - mocker: MockerFixture, - ) -> None: - """Test creating a job attachment when a job is available in the context. - - This tests that the attachment is created in UiPath and linked to the job - when a job key is available in the execution context. - - Args: - config: UiPathApiConfig fixture. - execution_context: UiPathExecutionContext fixture. - monkeypatch: MonkeyPatch fixture. - mocker: MockerFixture for mocking dependencies. - """ - # Arrange - job_key = uuid.uuid4() - attachment_key = uuid.uuid4() - content = "Test attachment content" - name = "test_attachment.txt" - - # Set job key in environment - must be string - monkeypatch.setenv("UIPATH_JOB_KEY", str(job_key)) - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - - # Create fresh execution context after setting environment variables - fresh_execution_context = UiPathExecutionContext() - service = JobsService(config=config, execution_context=fresh_execution_context) - - # Mock the attachment service's upload method - mock_upload = mocker.patch.object( - service._attachments_service, "upload", return_value=attachment_key - ) - - # Mock the link_attachment method - mock_link = mocker.patch.object(service, "link_attachment") - - # Act - result = service.create_attachment(name=name, content=content) - - # Assert - assert result == attachment_key - mock_upload.assert_called_once_with( - name=name, - content=content, - folder_key=None, - folder_path=None, - ) - mock_link.assert_called_once_with( - attachment_key=attachment_key, - job_key=job_key, - category=None, - folder_key=None, - folder_path=None, - ) - - def test_create_job_attachment_no_job( - self, - service: JobsService, - temp_attachments_dir: str, - ) -> None: - """Test creating a job attachment when no job is available. - - This tests that the attachment is stored locally when no job key is provided - or available in the context. - - Args: - service: JobsService fixture. - temp_attachments_dir: Temporary directory fixture that handles cleanup. - """ - # Arrange - content = "Test local attachment content" - name = "test_local_attachment.txt" - - # Use the temporary directory provided by the fixture - service._temp_dir = temp_attachments_dir - - # Act - result = service.create_attachment(name=name, content=content) - - # Assert - assert isinstance(result, uuid.UUID) - # Verify file was created - expected_path = os.path.join(temp_attachments_dir, f"{result}_{name}") - assert os.path.exists(expected_path) - - # Check content - with open(expected_path, "r") as f: - assert f.read() == content - - def test_create_job_attachment_from_file( - self, - service: JobsService, - temp_attachments_dir: str, - temp_file: Tuple[str, str, str], - ) -> None: - """Test creating a job attachment from a file when no job is available. - - Args: - service: JobsService fixture. - temp_attachments_dir: Temporary directory fixture that handles cleanup. - temp_file: Temporary file fixture that handles cleanup. - """ - # Arrange - source_content, source_name, source_path = temp_file - - # Use the temporary directory provided by the fixture - service._temp_dir = temp_attachments_dir - - # Act - result = service.create_attachment(name=source_name, source_path=source_path) - - # Assert - assert isinstance(result, uuid.UUID) - # Verify file was created - expected_path = os.path.join(temp_attachments_dir, f"{result}_{source_name}") - assert os.path.exists(expected_path) - - # Check content - with open(expected_path, "r") as f: - assert f.read() == source_content - - def test_extract_output_with_inline_arguments( - self, - service: JobsService, - ) -> None: - """Test extracting job output when output is stored inline (small output).""" - - job_data = { - "Key": "test-job-key", - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": '{"result": "small output data", "status": "completed"}', - "OutputFile": None, - } - job = Job.model_validate(job_data) - - result = service.extract_output(job) - - assert result == '{"result": "small output data", "status": "completed"}' - - def test_extract_output_with_attachment( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - temp_attachments_dir: str, - ) -> None: - """Test extracting job output when output is stored as attachment (large output).""" - - service._temp_dir = temp_attachments_dir - attachment_id = str(uuid.uuid4()) - large_output = '{"result": "' + "x" * 10001 + '", "status": "completed"}' - - job_data = { - "Key": "test-job-key", - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": None, - "OutputFile": attachment_id, - } - job = Job.model_validate(job_data) - - blob_uri = "https://test-storage.com/test-container/test-blob" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="GET", - status_code=200, - json={ - "Id": attachment_id, - "Name": "output.json", - "BlobFileAccess": { - "Uri": blob_uri, - "Headers": { - "Keys": ["Content-Type"], - "Values": ["application/json"], - }, - "RequiresAuth": False, - }, - }, - ) - - httpx_mock.add_response( - url=blob_uri, - method="GET", - status_code=200, - content=large_output.encode("utf-8"), - ) - - result = service.extract_output(job) - - assert result == large_output - - @pytest.mark.asyncio - async def test_extract_output_async_with_inline_arguments( - self, - service: JobsService, - ) -> None: - """Test extracting job output asynchronously when output is stored inline.""" - - job_data = { - "Key": "test-job-key", - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": '{"result": "small output data", "status": "completed"}', - "OutputFile": None, - } - job = Job.model_validate(job_data) - - result = await service.extract_output_async(job) - - assert result == '{"result": "small output data", "status": "completed"}' - - @pytest.mark.asyncio - async def test_extract_output_async_with_attachment( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - temp_attachments_dir: str, - ) -> None: - """Test extracting job output asynchronously when output is stored as attachment.""" - - service._temp_dir = temp_attachments_dir - attachment_id = str(uuid.uuid4()) - large_output = '{"result": "' + "y" * 10001 + '", "status": "completed"}' - - job_data = { - "Key": "test-job-key", - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": None, - "OutputFile": attachment_id, - } - job = Job.model_validate(job_data) - - blob_uri = "https://test-storage.com/test-container/test-blob" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="GET", - status_code=200, - json={ - "Id": attachment_id, - "Name": "output.json", - "BlobFileAccess": { - "Uri": blob_uri, - "Headers": { - "Keys": ["Content-Type"], - "Values": ["application/json"], - }, - "RequiresAuth": False, - }, - }, - ) - - httpx_mock.add_response( - url=blob_uri, - method="GET", - status_code=200, - content=large_output.encode("utf-8"), - ) - - result = await service.extract_output_async(job) - - assert result == large_output - - def test_extract_output_no_output( - self, - service: JobsService, - ) -> None: - """Test extracting job output when no output is available.""" - - job_data = { - "Key": "test-job-key", - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": None, - "OutputFile": None, - } - job = Job.model_validate(job_data) - - result = service.extract_output(job) - - assert result is None - - @pytest.mark.asyncio - async def test_extract_output_async_no_output( - self, - service: JobsService, - ) -> None: - """Test extracting job output asynchronously when no output is available.""" - - job_data = { - "Key": "test-job-key", - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": None, - "OutputFile": None, - } - job = Job.model_validate(job_data) - - result = await service.extract_output_async(job) - - assert result is None - - def test_retrieve_job_with_large_output_integration( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - temp_attachments_dir: str, - ) -> None: - """Retrieve job with large output stored as attachment and extract it. - - This test verifies the complete flow: - 1. Job retrieval returns a job with OutputFile (not OutputArguments) - 2. Extract output correctly downloads from the attachment - 3. The attachment ID matches between job and download - """ - # Arrange - service._temp_dir = temp_attachments_dir - job_key = "test-job-key-with-large-output" - attachment_id = str(uuid.uuid4()) - large_output_content = ( - '{"result": "' - + "z" * 10001 - + '", "status": "completed", "metadata": {"size": "large"}}' - ) - - # job has OutputFile instead of OutputArguments for large output - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", - method="GET", - status_code=200, - json={ - "Key": job_key, - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "EndTime": "2024-01-01T00:05:00Z", - "Id": 456, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": None, # large output is NOT stored inline - "OutputFile": attachment_id, # large output IS stored as attachment - "InputArguments": '{"input": "test"}', # small input stored inline - "InputFile": None, - }, - ) - - blob_uri = "https://test-storage.com/large-output-container/output-blob" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="GET", - status_code=200, - json={ - "Id": attachment_id, - "Name": "large_output.json", - "BlobFileAccess": { - "Uri": blob_uri, - "Headers": { - "Keys": ["Content-Type", "x-ms-blob-type"], - "Values": ["application/json", "BlockBlob"], - }, - "RequiresAuth": False, - }, - }, - ) - - httpx_mock.add_response( - url=blob_uri, - method="GET", - status_code=200, - content=large_output_content.encode("utf-8"), - ) - - job = service.retrieve(job_key) - - # job structure is correct for large output - assert job.key == job_key - assert job.state == "Successful" - assert job.output_arguments is None # large output not stored inline - assert job.output_file == attachment_id # large output stored as attachment - assert job.input_arguments == '{"input": "test"}' # small input stored inline - assert job.input_file is None - - extracted_output = service.extract_output(job) - - assert extracted_output == large_output_content - - requests = httpx_mock.get_requests() - assert len(requests) == 3 - - job_request = requests[0] - assert job_request.method == "GET" - assert job_key in str(job_request.url) - - attachment_request = requests[1] - assert attachment_request.method == "GET" - assert attachment_id in str(attachment_request.url) - assert "Attachments" in str(attachment_request.url) - - blob_request = requests[2] - assert blob_request.method == "GET" - assert blob_request.url == blob_uri - - @pytest.mark.asyncio - async def test_retrieve_job_with_large_output_integration_async( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - temp_attachments_dir: str, - ) -> None: - """Async integration test: Retrieve job with large output and extract it.""" - service._temp_dir = temp_attachments_dir - job_key = "test-job-key-async-large-output" - attachment_id = str(uuid.uuid4()) - large_output_content = ( - '{"result": "' - + "w" * 10001 - + '", "status": "completed", "metadata": {"async": true}}' - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", - method="GET", - status_code=200, - json={ - "Key": job_key, - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "EndTime": "2024-01-01T00:10:00Z", - "Id": 789, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": None, - "OutputFile": attachment_id, - "InputArguments": None, - "InputFile": None, - }, - ) - - blob_uri = "https://test-storage.com/async-output-container/output-blob" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", - method="GET", - status_code=200, - json={ - "Id": attachment_id, - "Name": "async_large_output.json", - "BlobFileAccess": { - "Uri": blob_uri, - "Headers": { - "Keys": ["Content-Type"], - "Values": ["application/json"], - }, - "RequiresAuth": False, - }, - }, - ) - - httpx_mock.add_response( - url=blob_uri, - method="GET", - status_code=200, - content=large_output_content.encode("utf-8"), - ) - - job = await service.retrieve_async(job_key) - - assert job.key == job_key - assert job.state == "Successful" - assert job.output_arguments is None - assert job.output_file == attachment_id - - extracted_output = await service.extract_output_async(job) - - assert extracted_output == large_output_content - - requests = httpx_mock.get_requests() - assert len(requests) == 3 - - job_request = requests[0] - attachment_request = requests[1] - blob_request = requests[2] - - assert job_key in str(job_request.url) - assert attachment_id in str(attachment_request.url) - assert blob_request.url == blob_uri - - def test_retrieve_job_with_small_output_vs_large_output( - self, - httpx_mock: HTTPXMock, - service: JobsService, - base_url: str, - org: str, - tenant: str, - ) -> None: - """Test that demonstrates the difference between small and large output handling.""" - - small_job_key = "job-with-small-output" - small_output = '{"result": "small", "status": "ok"}' - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={small_job_key})", - method="GET", - status_code=200, - json={ - "Key": small_job_key, - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 100, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": small_output, # small output stored inline - "OutputFile": None, # no attachment needed - "InputArguments": '{"input": "test"}', - "InputFile": None, - }, - ) - - large_job_key = "job-with-large-output" - large_attachment_id = str(uuid.uuid4()) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={large_job_key})", - method="GET", - status_code=200, - json={ - "Key": large_job_key, - "State": "Successful", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 200, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - "OutputArguments": None, # large output NOT stored inline - "OutputFile": large_attachment_id, # large output stored as attachment - "InputArguments": '{"input": "test"}', - "InputFile": None, - }, - ) - - small_job = service.retrieve(small_job_key) - large_job = service.retrieve(large_job_key) - - assert small_job.output_arguments == small_output - assert small_job.output_file is None - - assert large_job.output_arguments is None - assert large_job.output_file == large_attachment_id - - assert small_job.input_arguments == '{"input": "test"}' - assert small_job.input_file is None - assert large_job.input_arguments == '{"input": "test"}' - assert large_job.input_file is None - - small_extracted = service.extract_output(small_job) - assert small_extracted == small_output - - # only 2 requests made (job retrievals, no attachment downloads) - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - def test_create_job_attachment_validation_errors( - self, - service: JobsService, - ) -> None: - """Test validation errors in create_job_attachment. - - Args: - service: JobsService fixture. - """ - # Test missing both content and source_path - with pytest.raises(ValueError, match="Content or source_path is required"): - service.create_attachment(name="test.txt") - - # Test providing both content and source_path - with pytest.raises( - ValueError, match="Content and source_path are mutually exclusive" - ): - service.create_attachment( - name="test.txt", content="test content", source_path="/path/to/file.txt" - ) - - @pytest.mark.asyncio - async def test_create_job_attachment_async_with_job( - self, - service: JobsService, - mocker: MockerFixture, - ) -> None: - """Test creating a job attachment asynchronously when a job is available. - - Args: - service: JobsService fixture. - mocker: MockerFixture for mocking dependencies. - """ - # Arrange - job_key = str(uuid.uuid4()) - attachment_key = uuid.uuid4() - content = "Test attachment content" - name = "test_attachment.txt" - - # Mock the attachment service's upload_async method - # Create a mock that returns a coroutine returning a UUID - async_mock = mocker.AsyncMock(return_value=attachment_key) - mocker.patch.object( - service._attachments_service, "upload_async", side_effect=async_mock - ) - - # Mock the link_attachment_async method - mock_link = mocker.patch.object( - service, "link_attachment_async", side_effect=mocker.AsyncMock() - ) - - # Act - result = await service.create_attachment_async( - name=name, content=content, job_key=job_key - ) - - # Assert - assert result == attachment_key - async_mock.assert_called_once_with( - name=name, - content=content, - folder_key=None, - folder_path=None, - ) - mock_link.assert_called_once_with( - attachment_key=attachment_key, - job_key=uuid.UUID(job_key), - category=None, - folder_key=None, - folder_path=None, - ) - - @pytest.mark.asyncio - async def test_create_job_attachment_async_no_job( - self, - service: JobsService, - temp_attachments_dir: str, - ) -> None: - """Test creating a job attachment asynchronously when no job is available. - - Args: - service: JobsService fixture. - temp_attachments_dir: Temporary directory fixture that handles cleanup. - """ - # Arrange - content = "Test local attachment content async" - name = "test_local_attachment_async.txt" - - # Use the temporary directory provided by the fixture - service._temp_dir = temp_attachments_dir - - # Act - result = await service.create_attachment_async(name=name, content=content) - - # Assert - assert isinstance(result, uuid.UUID) - - # Verify file was created - expected_path = os.path.join(temp_attachments_dir, f"{result}_{name}") - assert os.path.exists(expected_path) - - # Check content - with open(expected_path, "r") as f: - assert f.read() == content - - def test_create_job_attachment_with_job_from_file( - self, - service: JobsService, - mocker: MockerFixture, - temp_file: Tuple[str, str, str], - ) -> None: - """Test creating a job attachment from a file when a job is available. - - This tests that the attachment is created in UiPath from a file and linked to the job - when a job key is provided. - - Args: - service: JobsService fixture. - mocker: MockerFixture for mocking dependencies. - temp_file: Temporary file fixture that handles cleanup. - """ - # Arrange - job_key = str(uuid.uuid4()) - attachment_key = uuid.uuid4() - - # Get file details from fixture - source_content, source_name, source_path = temp_file - - # Mock the attachment service's upload method - mock_upload = mocker.patch.object( - service._attachments_service, "upload", return_value=attachment_key - ) - - # Mock the link_attachment method - mock_link = mocker.patch.object(service, "link_attachment") - - # Act - result = service.create_attachment( - name=source_name, source_path=source_path, job_key=job_key - ) - - # Assert - assert result == attachment_key - mock_upload.assert_called_once_with( - name=source_name, - source_path=source_path, - folder_key=None, - folder_path=None, - ) - mock_link.assert_called_once_with( - attachment_key=attachment_key, - job_key=uuid.UUID(job_key), - category=None, - folder_key=None, - folder_path=None, - ) - - @pytest.mark.asyncio - async def test_create_job_attachment_async_with_job_from_file( - self, - service: JobsService, - mocker: MockerFixture, - temp_file: Tuple[str, str, str], - ) -> None: - """Test creating a job attachment asynchronously from a file when a job is available. - - Args: - service: JobsService fixture. - mocker: MockerFixture for mocking dependencies. - temp_file: Temporary file fixture that handles cleanup. - """ - # Arrange - job_key = str(uuid.uuid4()) - attachment_key = uuid.uuid4() - - # Get file details from fixture - source_content, source_name, source_path = temp_file - - # Mock the attachment service's upload_async method - async_mock = mocker.AsyncMock(return_value=attachment_key) - mocker.patch.object( - service._attachments_service, "upload_async", side_effect=async_mock - ) - - # Mock the link_attachment_async method - mock_link = mocker.patch.object( - service, "link_attachment_async", side_effect=mocker.AsyncMock() - ) - - # Act - result = await service.create_attachment_async( - name=source_name, source_path=source_path, job_key=job_key - ) - - # Assert - assert result == attachment_key - async_mock.assert_called_once_with( - name=source_name, - source_path=source_path, - folder_key=None, - folder_path=None, - ) - mock_link.assert_called_once_with( - attachment_key=attachment_key, - job_key=uuid.UUID(job_key), - category=None, - folder_key=None, - folder_path=None, - ) - - @pytest.mark.asyncio - async def test_create_job_attachment_async_from_file( - self, - service: JobsService, - temp_attachments_dir: str, - temp_file: Tuple[str, str, str], - ) -> None: - """Test creating a job attachment asynchronously from a file when no job is available. - - Args: - service: JobsService fixture. - temp_attachments_dir: Temporary directory fixture that handles cleanup. - temp_file: Temporary file fixture that handles cleanup. - """ - # Arrange - # Get file details from fixture - source_content, source_name, source_path = temp_file - - # Use the temporary directory provided by the fixture - service._temp_dir = temp_attachments_dir - - # Act - result = await service.create_attachment_async( - name=source_name, source_path=source_path - ) - - # Assert - assert isinstance(result, uuid.UUID) - - # Verify file was created - expected_path = os.path.join(temp_attachments_dir, f"{result}_{source_name}") - assert os.path.exists(expected_path) - - # Check content - with open(expected_path, "r") as f: - assert f.read() == source_content diff --git a/tests/sdk/services/test_jobs_service_bulk_operations.py b/tests/sdk/services/test_jobs_service_bulk_operations.py deleted file mode 100644 index ce2f48b28..000000000 --- a/tests/sdk/services/test_jobs_service_bulk_operations.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Tests for bulk job operations and N+1 fix.""" - -import pytest - -# Test UUIDs -KEY1 = "11111111-1111-1111-1111-111111111111" -KEY2 = "22222222-2222-2222-2222-222222222222" -KEY3 = "33333333-3333-3333-3333-333333333333" - - -class TestResolveJobIdentifiers: - """Test _resolve_job_identifiers() bulk query.""" - - def test_resolve_single_key(self, jobs_service, httpx_mock, base_url, org, tenant): - """Test resolving single job key to ID.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{KEY1}%27%29&%24select=Id%2CKey&%24top=1", - json={"value": [{"Key": KEY1, "Id": 100}]}, - ) - - ids = jobs_service._resolve_job_identifiers(job_keys=[KEY1]) - - assert ids == [100] - assert len(httpx_mock.get_requests()) == 1 - - def test_resolve_multiple_keys_single_query( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test resolving multiple job keys in single query (N+1 fix verification).""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{KEY1}%27%2C%27{KEY2}%27%2C%27{KEY3}%27%29&%24select=Id%2CKey&%24top=3", - json={ - "value": [ - {"Key": KEY1, "Id": 100}, - {"Key": KEY2, "Id": 101}, - {"Key": KEY3, "Id": 102}, - ] - }, - ) - - ids = jobs_service._resolve_job_identifiers(job_keys=[KEY1, KEY2, KEY3]) - - assert ids == [100, 101, 102] - assert len(httpx_mock.get_requests()) == 1 # Only 1 API call! - - def test_resolve_preserves_order( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test that returned IDs maintain input key order.""" - # API may return in different order - httpx_mock.add_response( - json={ - "value": [ - {"Key": KEY3, "Id": 102}, - {"Key": KEY1, "Id": 100}, - {"Key": KEY2, "Id": 101}, - ] - }, - ) - - ids = jobs_service._resolve_job_identifiers(job_keys=[KEY1, KEY2, KEY3]) - - assert ids == [100, 101, 102] # Correct order preserved - - def test_resolve_missing_key(self, jobs_service, httpx_mock, base_url, org, tenant): - """Test error when some keys not found.""" - httpx_mock.add_response( - json={ - "value": [ - {"Key": KEY1, "Id": 100}, - # KEY2 missing - {"Key": KEY3, "Id": 102}, - ] - }, - ) - - with pytest.raises(LookupError, match=f"Jobs not found for keys: {KEY2}"): - jobs_service._resolve_job_identifiers(job_keys=[KEY1, KEY2, KEY3]) - - def test_resolve_empty_list(self, jobs_service): - """Test handling of empty key list.""" - ids = jobs_service._resolve_job_identifiers(job_keys=[]) - assert ids == [] - - def test_resolve_duplicate_keys( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test that duplicate keys are handled correctly.""" - httpx_mock.add_response( - json={ - "value": [ - {"Key": KEY1, "Id": 100}, - {"Key": KEY2, "Id": 101}, - ] - }, - ) - - # Request with duplicates - ids = jobs_service._resolve_job_identifiers(job_keys=[KEY1, KEY2, KEY1]) - - # Should return corresponding IDs maintaining order (including duplicates) - assert ids == [100, 101, 100] - assert len(httpx_mock.get_requests()) == 1 # Only 1 query despite duplicates - - def test_resolve_invalid_uuid(self, jobs_service): - """Test that invalid UUID keys raise ValueError.""" - with pytest.raises(ValueError, match="Invalid job key format: not-a-uuid"): - jobs_service._resolve_job_identifiers(job_keys=["not-a-uuid"]) - - def test_resolve_large_batch_chunks( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test that large batches are chunked (50 keys per request).""" - # Create 100 test keys (should result in 2 chunks) - test_keys = [f"{i:08x}-0000-0000-0000-000000000000" for i in range(100)] - - # Mock first chunk (keys 0-49) - chunk1_keys = test_keys[:50] - chunk1_filter = "%27%2C%27".join(chunk1_keys) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{chunk1_filter}%27%29&%24select=Id%2CKey&%24top=50", - json={"value": [{"Key": k, "Id": i} for i, k in enumerate(chunk1_keys)]}, - ) - - # Mock second chunk (keys 50-99) - chunk2_keys = test_keys[50:] - chunk2_filter = "%27%2C%27".join(chunk2_keys) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{chunk2_filter}%27%29&%24select=Id%2CKey&%24top=50", - json={ - "value": [{"Key": k, "Id": i + 50} for i, k in enumerate(chunk2_keys)] - }, - ) - - ids = jobs_service._resolve_job_identifiers(job_keys=test_keys) - - assert len(ids) == 100 - assert len(httpx_mock.get_requests()) == 2 # 2 chunks = 2 API calls - - -class TestStopWithBulkResolution: - """Test stop() uses bulk resolution and pure spec pattern.""" - - def test_stop_multiple_jobs_only_two_calls( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test stopping multiple jobs makes only 2 API calls (resolve + stop).""" - # Mock bulk resolution - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{KEY1}%27%2C%27{KEY2}%27%29&%24select=Id%2CKey&%24top=2", - json={"value": [{"Key": KEY1, "Id": 100}, {"Key": KEY2, "Id": 101}]}, - ) - - # Mock stop request - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs", - method="POST", - json={}, - ) - - jobs_service.stop(job_keys=[KEY1, KEY2], strategy="SoftStop") - - requests = httpx_mock.get_requests() - assert len(requests) == 2 # Not N+1! - - # Verify stop request body matches Swagger schema - import json - - stop_request = requests[-1] - body = json.loads(stop_request.content) - assert body == {"jobIds": [100, 101], "strategy": "SoftStop"} - assert all(isinstance(id, int) for id in body["jobIds"]) # int64 validation - - def test_stop_single_job(self, jobs_service, httpx_mock, base_url, org, tenant): - """Test stopping single job.""" - # Mock bulk resolution - httpx_mock.add_response( - json={"value": [{"Key": KEY1, "Id": 100}]}, - ) - - # Mock stop request - httpx_mock.add_response( - method="POST", - json={}, - ) - - jobs_service.stop(job_keys=[KEY1], strategy="Kill") - - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - def test_stop_invalid_uuid_raises_error(self, jobs_service): - """Test that invalid UUID in stop() raises ValueError.""" - with pytest.raises(ValueError, match="Invalid job key format"): - jobs_service.stop(job_keys=["invalid-uuid"]) - - @pytest.mark.asyncio - async def test_stop_async_uses_async_resolution( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test stop_async uses async bulk resolution.""" - httpx_mock.add_response( - json={"value": [{"Key": KEY1, "Id": 100}]}, - ) - - httpx_mock.add_response( - method="POST", - json={}, - ) - - await jobs_service.stop_async(job_keys=[KEY1]) - - assert len(httpx_mock.get_requests()) == 2 diff --git a/tests/sdk/services/test_jobs_service_pagination.py b/tests/sdk/services/test_jobs_service_pagination.py deleted file mode 100644 index f135c3c15..000000000 --- a/tests/sdk/services/test_jobs_service_pagination.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Tests for JobsService PagedResult pagination.""" - -import pytest - -from uipath.platform.common.paging import PagedResult -from uipath.platform.orchestrator.job import Job - - -class TestJobsListPagination: - """Test list() pagination with PagedResult.""" - - def test_list_returns_paged_result( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test that list() returns PagedResult[Job].""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=100", - json={ - "value": [ - { - "Key": "job-1", - "Id": 1, - "State": "Successful", - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - result = jobs_service.list() - - assert isinstance(result, PagedResult) - assert len(result.items) == 1 - assert isinstance(result.items[0], Job) - assert result.skip == 0 - assert result.top == 100 - - def test_list_has_more_true_when_full_page( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test has_more=True when page is full.""" - jobs = [ - { - "Key": f"job-{i}", - "Id": i, - "State": "Successful", - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - for i in range(100) - ] - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=100", - json={"value": jobs}, - ) - - result = jobs_service.list(top=100) - - assert result.has_more is True - assert len(result.items) == 100 - - def test_list_has_more_false_when_partial_page( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test has_more=False when page is partial.""" - jobs = [ - { - "Key": f"job-{i}", - "Id": i, - "State": "Successful", - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - for i in range(50) - ] - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=100", - json={"value": jobs}, - ) - - result = jobs_service.list(top=100) - - assert result.has_more is False - assert len(result.items) == 50 - - def test_list_with_filter(self, jobs_service, httpx_mock, base_url, org, tenant): - """Test list with OData filter.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=State+eq+%27Successful%27&%24skip=0&%24top=100", - json={ - "value": [ - { - "Key": "job-1", - "Id": 1, - "State": "Successful", - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - result = jobs_service.list(filter="State eq 'Successful'") - - assert len(result.items) == 1 - assert result.items[0].state == "Successful" - - def test_list_manual_pagination( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test manual pagination across multiple pages.""" - # First page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=10", - json={ - "value": [ - { - "Key": f"job-{i}", - "Id": i, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - for i in range(10) - ] - }, - ) - # Second page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=10&%24top=10", - json={ - "value": [ - { - "Key": f"job-{i}", - "Id": i, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - for i in range(10, 15) - ] - }, - ) - - # Fetch first page - page1 = jobs_service.list(skip=0, top=10) - assert len(page1.items) == 10 - assert page1.has_more is True - - # Fetch second page - page2 = jobs_service.list(skip=10, top=10) - assert len(page2.items) == 5 - assert page2.has_more is False - - -class TestJobsListValidation: - """Test parameter validation for list().""" - - def test_list_skip_exceeds_maximum(self, jobs_service): - """Test error when skip > MAX_SKIP_OFFSET.""" - with pytest.raises( - ValueError, match=r"skip must be <= 10000.*requested: 10001" - ): - jobs_service.list(skip=10001) - - def test_list_top_exceeds_maximum(self, jobs_service): - """Test error when top > MAX_PAGE_SIZE.""" - with pytest.raises(ValueError, match=r"top must be <= 1000.*requested: 1001"): - jobs_service.list(top=1001) - - def test_list_uses_shared_validation(self, jobs_service): - """Test that list() uses shared validation utility.""" - with pytest.raises(ValueError, match="skip must be >= 0"): - jobs_service.list(skip=-1) - - with pytest.raises(ValueError, match="top must be >= 1"): - jobs_service.list(top=0) - - def test_list_skip_at_boundary( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test that skip=10000 is allowed.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=10000&%24top=100", - json={"value": []}, - ) - - result = jobs_service.list(skip=10000) - assert result is not None - - def test_list_top_at_boundary( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test that top=1000 is allowed.""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=1000", - json={"value": []}, - ) - - result = jobs_service.list(top=1000) - assert result is not None - - -class TestJobsListAsync: - """Test async version of list().""" - - @pytest.mark.asyncio - async def test_list_async_returns_paged_result( - self, jobs_service, httpx_mock, base_url, org, tenant - ): - """Test that list_async() returns PagedResult[Job].""" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=100", - json={ - "value": [ - { - "Key": "job-1", - "Id": 1, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - result = await jobs_service.list_async() - - assert isinstance(result, PagedResult) - assert len(result.items) == 1 diff --git a/tests/sdk/services/test_llm_integration.py b/tests/sdk/services/test_llm_integration.py deleted file mode 100644 index e1c3a549f..000000000 --- a/tests/sdk/services/test_llm_integration.py +++ /dev/null @@ -1,120 +0,0 @@ -import os - -import httpx -import pytest - -from uipath.platform import UiPathExecutionContext -from uipath.platform.chat import ( - ChatModels, - EmbeddingModels, - UiPathOpenAIService, -) -from uipath.platform.common import UiPathApiConfig - - -def get_env_var(name: str) -> str: - """Get environment variable or skip test if not present.""" - value = os.environ.get(name) - if value is None: - pytest.skip(f"Environment variable {name} is not set") - return value - - -def get_access_token() -> str: - try: - client_id = get_env_var("UIPATH_CLIENT_ID") - client_secret = get_env_var("UIPATH_CLIENT_SECRET") - payload = { - "client_id": client_id, - "client_secret": client_secret, - "grant_type": "client_credentials", - } - headers = { - "Content-Type": "application/x-www-form-urlencoded", - } - url = f"{get_env_var('UIPATH_BASE_URL')}/identity_/connect/token" - response = httpx.post(url, data=payload, headers=headers) - json = response.json() - token = json.get("access_token") - - return token - except Exception: - pytest.skip("Failed to get access token. Check your credentials.") - - -class TestLLMIntegration: - @pytest.fixture - def llm_service(self): - """Create an OpenAIService instance with environment variables.""" - # skip tests on CI, only run locally - pytest.skip("Failed to get access token. Check your credentials.") - - base_url = get_env_var("UIPATH_URL") - api_key = get_access_token() - - config = UiPathApiConfig(base_url=base_url, secret=api_key) - execution_context = UiPathExecutionContext() - return UiPathOpenAIService(config=config, execution_context=execution_context) - - @pytest.mark.asyncio - async def test_embeddings_real(self, llm_service): - """Test the embeddings function with a real API call.""" - input_text = "This is a test for embedding a sentence." - - # Make the actual API call - result = await llm_service.embeddings(input=input_text) - - # Validate the response - assert result is not None - assert hasattr(result, "data") - assert len(result.data) > 0 - assert hasattr(result.data[0], "embedding") - assert len(result.data[0].embedding) > 0 - assert hasattr(result, "model") - assert hasattr(result, "usage") - assert result.usage.prompt_tokens > 0 - - @pytest.mark.asyncio - async def test_chat_completions_real(self, llm_service): - """Test the chat_completions function with a real API call.""" - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "What is the capital of France?"}, - ] - - # Make the actual API call - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=50, - temperature=0.7, - ) - - # Validate the response - assert result is not None - assert hasattr(result, "id") - assert hasattr(result, "choices") - assert len(result.choices) > 0 - assert hasattr(result.choices[0], "message") - assert hasattr(result.choices[0].message, "content") - assert result.choices[0].message.content.strip() != "" - assert hasattr(result, "usage") - assert result.usage.prompt_tokens > 0 - - @pytest.mark.asyncio - async def test_embeddings_with_custom_model_real(self, llm_service): - """Test the embeddings function with a custom model.""" - input_text = "Testing embeddings with a different model." - - # Make the actual API call with a specific embedding model - result = await llm_service.embeddings( - input=input_text, embedding_model=EmbeddingModels.text_embedding_3_large - ) - - # Validate the response - assert result is not None - assert hasattr(result, "data") - assert len(result.data) > 0 - assert hasattr(result.data[0], "embedding") - assert len(result.data[0].embedding) > 0 - assert result.model == "text-embedding-3-large" diff --git a/tests/sdk/services/test_llm_schema_cleanup.py b/tests/sdk/services/test_llm_schema_cleanup.py deleted file mode 100644 index 5cddddbc0..000000000 --- a/tests/sdk/services/test_llm_schema_cleanup.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Tests for the _cleanup_schema function in LLM Gateway Service.""" - -from pydantic import BaseModel - -from uipath.platform.chat._llm_gateway_service import _cleanup_schema - - -# Simple test models -class SimpleModel(BaseModel): - name: str - age: int - active: bool - - -class ModelWithList(BaseModel): - names: list[str] - numbers: list[int] - - -class ModelWithOptional(BaseModel): - required_field: str - optional_field: str | None = None - - -# Complex nested models for comprehensive testing -class Task(BaseModel): - task_id: int - description: str - completed: bool - - -class Project(BaseModel): - project_id: int - name: str - tasks: list[Task] - - -class Team(BaseModel): - team_id: int - team_name: str - members: list[str] - projects: list[Project] - - -class Department(BaseModel): - department_id: int - department_name: str - teams: list[Team] - - -class Company(BaseModel): - company_id: int - company_name: str - departments: list[Department] - - -class TestCleanupSchema: - """Test cases for the _cleanup_schema function.""" - - def test_simple_model_cleanup(self): - """Test cleanup of a simple model without nested structures.""" - schema = _cleanup_schema(SimpleModel.model_json_schema()) - - assert schema["type"] == "object" - assert schema["additionalProperties"] is False - assert "required" in schema - assert set(schema["required"]) == {"name", "age", "active"} - - # Check properties are cleaned (no titles) - properties = schema["properties"] - assert "name" in properties - assert "age" in properties - assert "active" in properties - - # Ensure no 'title' fields are present - for _prop_name, prop_def in properties.items(): - assert "title" not in prop_def - - def test_model_with_list_cleanup(self): - """Test cleanup of a model with list fields.""" - schema = _cleanup_schema(ModelWithList.model_json_schema()) - - assert schema["type"] == "object" - assert schema["additionalProperties"] is False - - # Check list properties - names_prop = schema["properties"]["names"] - assert names_prop["type"] == "array" - assert "items" in names_prop - assert names_prop["items"]["type"] == "string" - # Ensure no 'title' in items - assert "title" not in names_prop["items"] - - numbers_prop = schema["properties"]["numbers"] - assert numbers_prop["type"] == "array" - assert "items" in numbers_prop - assert numbers_prop["items"]["type"] == "integer" - assert "title" not in numbers_prop["items"] - - def test_model_with_optional_cleanup(self): - """Test cleanup of a model with optional fields.""" - schema = _cleanup_schema(ModelWithOptional.model_json_schema()) - - assert schema["type"] == "object" - assert schema["additionalProperties"] is False - - # Only required_field should be in required array - assert schema["required"] == ["required_field", "optional_field"] - - # Both fields should be in properties - assert "required_field" in schema["properties"] - assert "optional_field" in schema["properties"] - - def test_complex_nested_model_cleanup(self): - """Test cleanup of the complex nested Company model.""" - schema = _cleanup_schema(Company.model_json_schema()) - - assert schema["type"] == "object" - assert schema["additionalProperties"] is False - assert set(schema["required"]) == {"company_id", "company_name", "departments"} - - # Check top-level properties - properties = schema["properties"] - assert "company_id" in properties - assert "company_name" in properties - assert "departments" in properties - - # Check departments is array of objects - departments_prop = properties["departments"] - assert departments_prop["type"] == "array" - assert "items" in departments_prop - assert "title" not in departments_prop["items"] - - # Verify no 'title' fields exist anywhere in the schema - self._assert_no_titles_recursive(schema) - - def test_schema_structure_integrity(self): - """Test that the cleaned schema maintains proper JSON Schema structure.""" - schema = _cleanup_schema(Company.model_json_schema()) - - # Must have these top-level keys - required_keys = {"type", "properties", "required", "additionalProperties"} - assert all(key in schema for key in required_keys) - - # Type must be object - assert schema["type"] == "object" - - # additionalProperties must be False - assert schema["additionalProperties"] is False - - # Properties must be a dict - assert isinstance(schema["properties"], dict) - - # Required must be a list - assert isinstance(schema["required"], list) - - def test_email_field_handling(self): - """Test that EmailStr fields are properly handled.""" - schema = _cleanup_schema(Team.model_json_schema()) - - members_prop = schema["properties"]["members"] - assert members_prop["type"] == "array" - - # EmailStr should be treated as string with format - items = members_prop["items"] - assert items["type"] == "string" - # Email format might be present - if "format" in items: - assert items["format"] == "email" - - def test_nested_objects_cleanup(self): - """Test that nested objects are properly cleaned.""" - schema = _cleanup_schema(Department.model_json_schema()) - - # Check teams property (array of Team objects) - teams_prop = schema["properties"]["teams"] - assert teams_prop["type"] == "array" - assert "items" in teams_prop - - # The items should not have title - team_items = teams_prop["items"] - assert "title" not in team_items - - # If it's a nested object, check its properties - if "properties" in team_items: - for _prop_name, prop_def in team_items["properties"].items(): - assert "title" not in prop_def - - def _assert_no_titles_recursive(self, obj): - """Recursively assert that no 'title' fields exist in the schema.""" - if isinstance(obj, dict): - assert "title" not in obj, f"Found 'title' field in: {obj}" - for value in obj.values(): - self._assert_no_titles_recursive(value) - elif isinstance(obj, list): - for item in obj: - self._assert_no_titles_recursive(item) - - def test_function_returns_dict(self): - """Test that the function returns a dictionary.""" - result = _cleanup_schema(SimpleModel.model_json_schema()) - assert isinstance(result, dict) - - def test_function_with_inheritance(self): - """Test cleanup with model inheritance.""" - - class BaseEntity(BaseModel): - id: int - created_at: str - - class ExtendedEntity(BaseEntity): - name: str - description: str | None = None - - schema = _cleanup_schema(ExtendedEntity.model_json_schema()) - - # Should include fields from both base and derived class - properties = schema["properties"] - assert "id" in properties - assert "created_at" in properties - assert "name" in properties - assert "description" in properties - - # Required fields from both classes - required_fields = set(schema["required"]) - assert "id" in required_fields - assert "created_at" in required_fields - assert "name" in required_fields - assert "description" in required_fields diff --git a/tests/sdk/services/test_llm_service.py b/tests/sdk/services/test_llm_service.py deleted file mode 100644 index e1ab8299b..000000000 --- a/tests/sdk/services/test_llm_service.py +++ /dev/null @@ -1,545 +0,0 @@ -import json -from unittest.mock import MagicMock, patch - -import pytest -from pydantic import BaseModel - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.chat import ( - ChatModels, - EmbeddingModels, - TextEmbedding, - UiPathOpenAIService, -) - - -class TestOpenAIService: - @pytest.fixture - def config(self): - return UiPathApiConfig(base_url="https://example.com", secret="test_secret") - - @pytest.fixture - def execution_context(self): - return UiPathExecutionContext() - - @pytest.fixture - def openai_service(self, config, execution_context): - return UiPathOpenAIService(config=config, execution_context=execution_context) - - @pytest.fixture - def llm_service(self, config, execution_context): - return UiPathOpenAIService(config=config, execution_context=execution_context) - - def test_init(self, config, execution_context): - service = UiPathOpenAIService( - config=config, execution_context=execution_context - ) - assert service._config == config - assert service._execution_context == execution_context - - @patch.object(UiPathOpenAIService, "request_async") - @pytest.mark.asyncio - async def test_embeddings(self, mock_request, openai_service): - # Mock response - mock_response = MagicMock() - mock_response.json.return_value = { - "data": [{"embedding": [0.1, 0.2, 0.3], "index": 0, "object": "embedding"}], - "model": "text-embedding-ada-002", - "object": "list", - "usage": {"prompt_tokens": 4, "total_tokens": 4}, - } - mock_request.return_value = mock_response - - # Call the method - result = await openai_service.embeddings(input="Test input") - - # Assertions - mock_request.assert_called_once() - assert isinstance(result, TextEmbedding) - assert result.data[0].embedding == [0.1, 0.2, 0.3] - assert result.model == "text-embedding-ada-002" - assert result.usage.prompt_tokens == 4 - - @patch.object(UiPathOpenAIService, "request_async") - @pytest.mark.asyncio - async def test_embeddings_with_custom_model(self, mock_request, openai_service): - # Mock response - mock_response = MagicMock() - mock_response.json.return_value = { - "data": [{"embedding": [0.1, 0.2, 0.3], "index": 0, "object": "embedding"}], - "model": "text-embedding-3-large", - "object": "list", - "usage": {"prompt_tokens": 4, "total_tokens": 4}, - } - mock_request.return_value = mock_response - - # Call the method with custom model - result = await openai_service.embeddings( - input="Test input", embedding_model=EmbeddingModels.text_embedding_3_large - ) - - # Assertions for the result - mock_request.assert_called_once() - assert result.model == "text-embedding-3-large" - assert len(result.data) == 1 - assert result.data[0].embedding == [0.1, 0.2, 0.3] - assert result.data[0].index == 0 - assert result.object == "list" - assert result.usage.prompt_tokens == 4 - assert result.usage.total_tokens == 4 - - @patch.object(UiPathOpenAIService, "request_async") - @pytest.mark.asyncio - async def test_complex_company_pydantic_model(self, mock_request, llm_service): - """Test using complex Company Pydantic model as response_format.""" - - # Define the complex nested models - class Task(BaseModel): - task_id: int - description: str - completed: bool - - class Project(BaseModel): - project_id: int - name: str - tasks: list[Task] - - class Team(BaseModel): - team_id: int - team_name: str - members: list[str] - projects: list[Project] - - class Department(BaseModel): - department_id: int - department_name: str - teams: list[Team] - - class Company(BaseModel): - company_id: int - company_name: str - departments: list[Department] - - # Mock response - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "chatcmpl-test123", - "object": "chat.completion", - "created": 1234567890, - "model": "gpt-4o-mini-2024-07-18", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": json.dumps( - { - "company_id": 1, - "company_name": "FutureTech Ltd", - "departments": [ - { - "department_id": 1, - "department_name": "Engineering", - "teams": [ - { - "team_id": 1, - "team_name": "Backend Team", - "members": [ - "john@futuretech.com", - "jane@futuretech.com", - ], - "projects": [ - { - "project_id": 1, - "name": "API Development", - "tasks": [ - { - "task_id": 1, - "description": "Design REST endpoints", - "completed": True, - }, - { - "task_id": 2, - "description": "Implement authentication", - "completed": False, - }, - ], - } - ], - } - ], - }, - { - "department_id": 2, - "department_name": "Marketing", - "teams": [ - { - "team_id": 2, - "team_name": "Digital Marketing", - "members": ["sarah@futuretech.com"], - "projects": [ - { - "project_id": 2, - "name": "Social Media Campaign", - "tasks": [ - { - "task_id": 3, - "description": "Create content calendar", - "completed": True, - } - ], - } - ], - } - ], - }, - ], - } - ), - }, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 150, - "completion_tokens": 300, - "total_tokens": 450, - }, - } - mock_request.return_value = mock_response - - messages = [ - { - "role": "system", - "content": ( - "You are a helpful assistant. Respond with structured JSON according to this schema:\n" - "Company -> departments -> teams -> projects -> tasks.\n" - "Each company has a company_id and company_name.\n" - "Each department has a department_id and department_name.\n" - "Each team has a team_id, team_name, members (email addresses), and projects.\n" - "Each project has a project_id, name, and tasks.\n" - "Each task has a task_id, description, and completed status." - ), - }, - { - "role": "user", - "content": ( - "Give me an example of a software company called 'FutureTech Ltd' with two departments: " - "Engineering and Marketing. Each department should have at least one team, with projects and tasks." - ), - }, - ] - - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - response_format=Company, # Pass BaseModel directly instead of dict - max_tokens=2000, - temperature=0, - ) - - # Validate the response - assert result is not None - assert len(result.choices) > 0 - assert result.choices[0].message.content is not None - - # Parse and validate the JSON response - response_json = json.loads(result.choices[0].message.content) - - # Validate the structure matches our Company model - assert "company_id" in response_json - assert "company_name" in response_json - assert "departments" in response_json - assert response_json["company_name"] == "FutureTech Ltd" - assert len(response_json["departments"]) >= 2 - - # Check for Engineering and Marketing departments - dept_names = [dept["department_name"] for dept in response_json["departments"]] - assert "Engineering" in dept_names - assert "Marketing" in dept_names - - # Validate that each department has teams with proper structure - for department in response_json["departments"]: - assert "teams" in department - assert len(department["teams"]) >= 1 - - # Validate team structure - for team in department["teams"]: - assert "team_id" in team - assert "team_name" in team - assert "members" in team - assert "projects" in team - - # Validate projects and tasks - for project in team["projects"]: - assert "project_id" in project - assert "name" in project - assert "tasks" in project - - for task in project["tasks"]: - assert "task_id" in task - assert "description" in task - assert "completed" in task - - # Try to parse it with our Pydantic model to ensure it's completely valid - company_instance = Company.model_validate(response_json) - assert company_instance.company_name == "FutureTech Ltd" - assert len(company_instance.departments) >= 2 - - @patch.object(UiPathOpenAIService, "request_async") - @pytest.mark.asyncio - async def test_optional_request_format_model(self, mock_request, llm_service): - """Test using complex Company Pydantic model as response_format.""" - - class Article(BaseModel): - title: str | None = None - - # Mock response - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "chatcmpl-test123", - "object": "chat.completion", - "created": 1234567890, - "model": "gpt-4o-mini-2024-07-18", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "{}", - }, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 150, - "completion_tokens": 300, - "total_tokens": 450, - }, - } - mock_request.return_value = mock_response - - messages = [ - { - "role": "system", - "content": "system-content", - }, - { - "role": "user", - "content": "user-content", - }, - ] - - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - response_format=Article, # Pass BaseModel directly instead of dict - max_tokens=2000, - temperature=0, - ) - captured_request = mock_request.call_args[1]["json"] - expected_request = { - "messages": [ - {"role": "system", "content": "system-content"}, - {"role": "user", "content": "user-content"}, - ], - "max_tokens": 2000, - "temperature": 0, - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "article", - "strict": True, - "schema": { - "type": "object", - "additionalProperties": False, - "properties": { - "title": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "default": None, - } - }, - "required": ["title"], - }, - }, - }, - } - - # validate the request to LLM gateway - assert expected_request == captured_request - - # Validate the response - assert result is not None - assert len(result.choices) > 0 - assert result.choices[0].message.content is not None - - # Parse and validate the JSON response - response_json = json.loads(result.choices[0].message.content) - - # Validate the structure matches our Company model - assert response_json == {} - - # Try to parse it with our Pydantic model to ensure it's completely valid - article_instance = Article.model_validate(response_json) - assert article_instance.title is None - - -class TestNormalizedLlmServiceClaudeFiltering: - """Test that Claude models correctly filter out OpenAI-specific parameters. - - The UiPath Normalized API gateway passes parameters through to the underlying - provider. Claude/Anthropic models do NOT support n, frequency_penalty, - presence_penalty, or top_p, and sending them causes 400 errors. - """ - - @pytest.fixture - def config(self): - return UiPathApiConfig(base_url="https://example.com", secret="test_secret") - - @pytest.fixture - def execution_context(self): - return UiPathExecutionContext() - - @pytest.fixture - def llm_service(self, config, execution_context): - from uipath.platform.chat._llm_gateway_service import UiPathLlmChatService - - return UiPathLlmChatService(config=config, execution_context=execution_context) - - @patch( - "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" - ) - @pytest.mark.asyncio - async def test_claude_model_excludes_openai_params(self, mock_request, llm_service): - """Test that Claude models do not include n, frequency_penalty, presence_penalty, top_p.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1234567890, - "model": "anthropic.claude-haiku-4-5-20251001-v1:0", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "Hello"}, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, - } - mock_request.return_value = mock_response - - await llm_service.chat_completions( - messages=[{"role": "user", "content": "Hello"}], - model="anthropic.claude-haiku-4-5-20251001-v1:0", - max_tokens=1000, - temperature=0, - ) - - # Get the request body and headers - call_kwargs = mock_request.call_args[1] - request_body = call_kwargs["json"] - headers = call_kwargs["headers"] - - # Claude models should NOT have these OpenAI-specific params - assert "n" not in request_body, "Claude request must not include 'n'" - assert "frequency_penalty" not in request_body, ( - "Claude request must not include 'frequency_penalty'" - ) - assert "presence_penalty" not in request_body, ( - "Claude request must not include 'presence_penalty'" - ) - assert "top_p" not in request_body, "Claude request must not include 'top_p'" - - # Model is sent in headers, not body (Normalized API pattern) - assert ( - headers["X-UiPath-LlmGateway-NormalizedApi-ModelName"] - == "anthropic.claude-haiku-4-5-20251001-v1:0" - ) - # Basic params should still be in the body - assert request_body["max_tokens"] == 1000 - assert request_body["temperature"] == 0 - - @patch( - "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" - ) - @pytest.mark.asyncio - async def test_openai_model_includes_all_params(self, mock_request, llm_service): - """Test that OpenAI models DO include n, frequency_penalty, presence_penalty.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1234567890, - "model": "gpt-4o-mini-2024-07-18", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "Hello"}, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, - } - mock_request.return_value = mock_response - - await llm_service.chat_completions( - messages=[{"role": "user", "content": "Hello"}], - model="gpt-4o-mini-2024-07-18", - max_tokens=1000, - temperature=0, - ) - - call_kwargs = mock_request.call_args[1] - request_body = call_kwargs["json"] - - # OpenAI models should have all params - assert "n" in request_body, "OpenAI request must include 'n'" - assert "frequency_penalty" in request_body, ( - "OpenAI request must include 'frequency_penalty'" - ) - assert "presence_penalty" in request_body, ( - "OpenAI request must include 'presence_penalty'" - ) - - @patch( - "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" - ) - @pytest.mark.asyncio - async def test_claude_sonnet_45_excluded_params(self, mock_request, llm_service): - """Test Claude Sonnet 4.5 specifically, since it was failing in production.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1234567890, - "model": "anthropic.claude-sonnet-4-5-20250929-v1:0", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "Hello"}, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, - } - mock_request.return_value = mock_response - - await llm_service.chat_completions( - messages=[{"role": "user", "content": "Hello"}], - model="anthropic.claude-sonnet-4-5-20250929-v1:0", - max_tokens=8000, - temperature=0, - ) - - call_kwargs = mock_request.call_args[1] - request_body = call_kwargs["json"] - - assert "n" not in request_body - assert "frequency_penalty" not in request_body - assert "presence_penalty" not in request_body - assert "top_p" not in request_body - assert request_body["max_tokens"] == 8000 diff --git a/tests/sdk/services/test_llm_throttle.py b/tests/sdk/services/test_llm_throttle.py deleted file mode 100644 index 733cc9275..000000000 --- a/tests/sdk/services/test_llm_throttle.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Tests for LLM request throttling functionality.""" - -import asyncio -from unittest.mock import MagicMock, patch - -import pytest - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.chat import UiPathLlmChatService, UiPathOpenAIService -from uipath.platform.chat.llm_throttle import ( - DEFAULT_LLM_CONCURRENCY, - get_llm_semaphore, - set_llm_concurrency, -) - - -class TestLLMThrottling: - """Tests for LLM throttling mechanism.""" - - @pytest.fixture(autouse=True) - def reset_semaphore(self): - """Reset the global semaphore and limit before each test.""" - import uipath.platform.chat.llm_throttle as module - - module._llm_semaphore = None - module._llm_semaphore_loop = None - module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY - yield - module._llm_semaphore = None - module._llm_semaphore_loop = None - module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY - - @pytest.fixture - def config(self): - """Create a test config.""" - return UiPathApiConfig(base_url="https://example.com", secret="test_secret") - - @pytest.fixture - def execution_context(self): - """Create a test execution context.""" - return UiPathExecutionContext() - - @pytest.fixture - def openai_service(self, config, execution_context): - """Create an OpenAI service instance.""" - return UiPathOpenAIService(config=config, execution_context=execution_context) - - @pytest.fixture - def llm_service(self, config, execution_context): - """Create an LLM chat service instance.""" - return UiPathLlmChatService(config=config, execution_context=execution_context) - - def test_default_concurrency_constant(self): - """Test that DEFAULT_LLM_CONCURRENCY is set correctly.""" - assert DEFAULT_LLM_CONCURRENCY == 20 - - @pytest.mark.asyncio - async def testget_llm_semaphore_creates_semaphore(self): - """Test that get_llm_semaphore creates a semaphore with default limit.""" - semaphore = get_llm_semaphore() - assert isinstance(semaphore, asyncio.Semaphore) - # Semaphore should allow DEFAULT_LLM_CONCURRENCY concurrent acquisitions - assert semaphore._value == DEFAULT_LLM_CONCURRENCY - - @pytest.mark.asyncio - async def testget_llm_semaphore_returns_same_instance(self): - """Test that get_llm_semaphore returns the same semaphore instance.""" - semaphore1 = get_llm_semaphore() - semaphore2 = get_llm_semaphore() - assert semaphore1 is semaphore2 - - @pytest.mark.asyncio - async def test_set_llm_concurrency_changes_limit(self): - """Test that set_llm_concurrency sets a custom limit.""" - set_llm_concurrency(5) - semaphore = get_llm_semaphore() - assert semaphore._value == 5 - - @pytest.mark.asyncio - async def test_throttle_limits_concurrency(self): - """Test that throttling actually limits concurrent operations.""" - set_llm_concurrency(2) - - concurrent_count = 0 - max_concurrent = 0 - - async def task(): - nonlocal concurrent_count, max_concurrent - async with get_llm_semaphore(): - concurrent_count += 1 - max_concurrent = max(max_concurrent, concurrent_count) - await asyncio.sleep(0.05) - concurrent_count -= 1 - - # Run 10 tasks with concurrency limit of 2 - await asyncio.gather(*[task() for _ in range(10)]) - - assert max_concurrent == 2 - - @patch.object(UiPathOpenAIService, "request_async") - @pytest.mark.asyncio - async def test_openai_service_uses_throttle(self, mock_request, openai_service): - """Test that OpenAI service chat_completions uses throttling.""" - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "test", - "object": "chat.completion", - "created": 1234567890, - "model": "gpt-4o-mini", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "Hello"}, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, - } - mock_request.return_value = mock_response - - set_llm_concurrency(1) - semaphore = get_llm_semaphore() - - # Verify semaphore is used during the call - initial_value = semaphore._value - - await openai_service.chat_completions( - messages=[{"role": "user", "content": "Hi"}] - ) - - # After the call, semaphore should be back to initial value - assert semaphore._value == initial_value - - @patch.object(UiPathLlmChatService, "request_async") - @pytest.mark.asyncio - async def test_llm_service_uses_throttle(self, mock_request, llm_service): - """Test that LLM chat service chat_completions uses throttling.""" - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "test", - "object": "chat.completion", - "created": 1234567890, - "model": "gpt-4o-mini", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "Hello"}, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, - } - mock_request.return_value = mock_response - - set_llm_concurrency(1) - semaphore = get_llm_semaphore() - - initial_value = semaphore._value - - await llm_service.chat_completions(messages=[{"role": "user", "content": "Hi"}]) - - assert semaphore._value == initial_value - - @patch.object(UiPathOpenAIService, "request_async") - @pytest.mark.asyncio - async def test_embeddings_uses_throttle(self, mock_request, openai_service): - """Test that embeddings endpoint uses throttling.""" - mock_response = MagicMock() - mock_response.json.return_value = { - "data": [{"embedding": [0.1, 0.2, 0.3], "index": 0, "object": "embedding"}], - "model": "text-embedding-ada-002", - "object": "list", - "usage": {"prompt_tokens": 4, "total_tokens": 4}, - } - mock_request.return_value = mock_response - - set_llm_concurrency(1) - semaphore = get_llm_semaphore() - - initial_value = semaphore._value - - await openai_service.embeddings(input="Test input") - - assert semaphore._value == initial_value - - -class TestEventLoopBug: - """Tests for the event loop binding bug. - - The bug: If set_llm_concurrency() creates the semaphore before asyncio.run(), - the semaphore is bound to the wrong event loop and will fail with: - RuntimeError: Semaphore object is bound to a different event loop - - The fix: set_llm_concurrency() only stores the limit, doesn't create semaphore. - """ - - @pytest.fixture(autouse=True) - def reset_semaphore(self): - """Reset the global semaphore and limit before each test.""" - import uipath.platform.chat.llm_throttle as module - - module._llm_semaphore = None - module._llm_semaphore_loop = None - module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY - yield - module._llm_semaphore = None - module._llm_semaphore_loop = None - module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY - - def test_set_llm_concurrency_before_asyncio_run(self): - """Test that set_llm_concurrency called before asyncio.run causes issues. - - This test reproduces the bug where the semaphore is created in one - event loop context but used in another (created by asyncio.run). - """ - # This simulates what happens in cli_eval.py: - # 1. set_llm_concurrency() is called (creates semaphore) - # 2. asyncio.run() starts a NEW event loop - # 3. Code tries to use the semaphore in the new loop - - # Step 1: Call set_llm_concurrency outside any event loop - # (simulating CLI code before asyncio.run) - set_llm_concurrency(5) - - # Step 2 & 3: Run async code in a new event loop - async def use_semaphore(): - semaphore = get_llm_semaphore() - async with semaphore: - pass - - # This should raise RuntimeError if the bug exists - # because the semaphore was created in a different loop context - try: - asyncio.run(use_semaphore()) - # If we get here, either: - # a) The bug is fixed (semaphore created lazily in correct loop) - # b) Python version handles this gracefully - bug_exists = False - except RuntimeError as e: - if "different event loop" in str( - e - ) or "attached to a different loop" in str(e): - bug_exists = True - else: - raise - - # This assertion documents expected behavior: - # - If bug_exists is True, the fix is needed - # - If bug_exists is False, the fix has been applied or Python handles it - # Currently we expect the bug to exist (test should fail after fix is applied) - assert not bug_exists, ( - "Event loop bug detected! The semaphore was created outside the running " - "event loop. Fix: set_llm_concurrency should only store the limit, not " - "create the semaphore." - ) - - def test_lazy_semaphore_creation_in_correct_loop(self): - """Test that semaphore created inside asyncio.run works correctly. - - This is the expected behavior after the fix is applied. - """ - import uipath.platform.chat.llm_throttle as module - - # Ensure semaphore is None (not pre-created) - module._llm_semaphore = None - - async def use_semaphore(): - # Semaphore should be created here, inside the running loop - semaphore = get_llm_semaphore() - async with semaphore: - return True - - # This should work because semaphore is created in the correct loop - result = asyncio.run(use_semaphore()) - assert result is True - - def test_set_llm_concurrency_does_not_create_semaphore(self): - """Test that set_llm_concurrency only stores limit, doesn't create semaphore. - - This is the key fix - the semaphore should be created lazily inside - the running event loop, not when set_llm_concurrency is called. - """ - import uipath.platform.chat.llm_throttle as module - - # Ensure semaphore is None initially - module._llm_semaphore = None - - # Call set_llm_concurrency - set_llm_concurrency(5) - - # Verify semaphore is still None (not created yet) - assert module._llm_semaphore is None - - # Verify limit was stored - assert module._llm_concurrency_limit == 5 - - # Now when we get the semaphore, it should be created with the stored limit - async def get_sem(): - return get_llm_semaphore() - - semaphore = asyncio.run(get_sem()) - assert semaphore._value == 5 - - -class TestMultipleEventLoops: - """Tests for semaphore behavior across multiple event loops. - - This tests the scenario where: - 1. First asyncio.run() creates semaphore bound to loop A - 2. Loop A closes - 3. Second asyncio.run() creates loop B - 4. Code tries to use semaphore still bound to dead loop A - 5. Should NOT crash - semaphore should be recreated for loop B - """ - - @pytest.fixture(autouse=True) - def reset_semaphore(self): - """Reset the global semaphore before each test.""" - import uipath.platform.chat.llm_throttle as module - - module._llm_semaphore = None - module._llm_semaphore_loop = None - module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY - yield - module._llm_semaphore = None - module._llm_semaphore_loop = None - module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY - - def test_semaphore_works_across_multiple_asyncio_runs(self): - """Test that semaphore works correctly across multiple asyncio.run() calls. - - This is the key test for the event-loop binding bug. Without the fix, - this test will fail with: - RuntimeError: Semaphore object is bound to a different event loop - - NOTE: The bug only triggers when there's CONTENTION on the semaphore - (multiple tasks competing). Without contention, _get_loop() is not - called and the semaphore doesn't bind to the event loop. - """ - # Use a limit of 1 to force contention - set_llm_concurrency(1) - - async def use_semaphore_with_contention(): - """Use the semaphore with contention to trigger loop binding.""" - semaphore = get_llm_semaphore() - - async def contender(): - async with semaphore: - await asyncio.sleep(0.001) - - # Hold the semaphore while another task tries to acquire it - async with semaphore: - # Create a contending task - this forces _get_loop() to be called - task = asyncio.create_task(contender()) - await asyncio.sleep(0.001) - - await task - return True - - # First run - creates semaphore and binds to loop A due to contention - result1 = asyncio.run(use_semaphore_with_contention()) - assert result1 is True - - # Second run - loop A is closed, loop B is created - # Without fix: crashes because semaphore is still bound to loop A - # With fix: should work because semaphore is recreated for loop B - result2 = asyncio.run(use_semaphore_with_contention()) - assert result2 is True - - # Third run - just to be sure - result3 = asyncio.run(use_semaphore_with_contention()) - assert result3 is True - - def test_semaphore_limit_preserved_across_loops(self): - """Test that concurrency limit is preserved when semaphore is recreated.""" - set_llm_concurrency(3) - - async def get_semaphore_value(): - semaphore = get_llm_semaphore() - return semaphore._value - - # First run - value1 = asyncio.run(get_semaphore_value()) - assert value1 == 3 - - # Second run - should still have limit of 3 - value2 = asyncio.run(get_semaphore_value()) - assert value2 == 3 - - -class TestConcurrencyValidation: - """Tests for input validation of concurrency settings.""" - - @pytest.fixture(autouse=True) - def reset_semaphore(self): - """Reset the global semaphore before each test.""" - import uipath.platform.chat.llm_throttle as module - - module._llm_semaphore = None - module._llm_semaphore_loop = None - module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY - yield - module._llm_semaphore = None - module._llm_semaphore_loop = None - module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY - - def test_set_llm_concurrency_zero_raises_error(self): - """Test that setting concurrency to 0 raises ValueError. - - A semaphore with value 0 would deadlock all requests. - """ - with pytest.raises(ValueError, match="must be at least 1"): - set_llm_concurrency(0) - - def test_set_llm_concurrency_negative_raises_error(self): - """Test that setting negative concurrency raises ValueError.""" - with pytest.raises(ValueError, match="must be at least 1"): - set_llm_concurrency(-1) - - def test_set_llm_concurrency_one_is_valid(self): - """Test that setting concurrency to 1 (minimum valid) works.""" - set_llm_concurrency(1) - - async def check_semaphore(): - semaphore = get_llm_semaphore() - return semaphore._value - - value = asyncio.run(check_semaphore()) - assert value == 1 diff --git a/tests/sdk/services/test_mcp_service.py b/tests/sdk/services/test_mcp_service.py deleted file mode 100644 index 2ff3ffb64..000000000 --- a/tests/sdk/services/test_mcp_service.py +++ /dev/null @@ -1,541 +0,0 @@ -from unittest.mock import Mock, patch - -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.orchestrator import McpService -from uipath.platform.orchestrator._folder_service import FolderService -from uipath.platform.orchestrator.mcp import McpServer - - -@pytest.fixture -def folders_service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> FolderService: - monkeypatch.setenv("UIPATH_FOLDER_KEY", "test-folder-key") - return FolderService(config=config, execution_context=execution_context) - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - folders_service: FolderService, - monkeypatch: pytest.MonkeyPatch, -) -> McpService: - monkeypatch.setenv("UIPATH_FOLDER_KEY", "test-folder-key") - return McpService( - config=config, - execution_context=execution_context, - folders_service=folders_service, - ) - - -class TestMcpService: - class TestListServers: - def test_list_with_folder_path( - self, - httpx_mock: HTTPXMock, - service: McpService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test listing MCP servers with a folder_path parameter that gets resolved.""" - mock_servers = [ - { - "id": "server-id-1", - "name": "Test MCP Server", - "slug": "test-mcp-server", - "description": "Test description", - "version": "1.0.0", - "createdAt": "2025-07-24T11:30:52.031427", - "updatedAt": "2025-07-24T12:29:53.4765887", - "isActive": True, - "type": 2, - "status": 1, - "command": "", - "arguments": "", - "environmentVariables": "", - "processKey": "test-process-key", - "folderKey": "test-folder-key", - "runtimesCount": 0, - "mcpUrl": "https://test.com/mcp/test-mcp-server", - } - ] - - 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": "resolved-folder-key", - "FullyQualifiedName": "test-folder-path", - } - ], - }, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/agenthub_/api/servers", - status_code=200, - json=mock_servers, - ) - - servers = service.list(folder_path="test-folder-path") - - assert len(servers) == 1 - assert isinstance(servers[0], McpServer) - assert servers[0].name == "Test MCP Server" - - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - servers_request = requests[1] - assert servers_request.method == "GET" - assert ( - servers_request.url == f"{base_url}{org}{tenant}/agenthub_/api/servers" - ) - assert HEADER_FOLDER_KEY in servers_request.headers - assert servers_request.headers[HEADER_FOLDER_KEY] == "resolved-folder-key" - - def test_list_without_folder_raises_error( - self, - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test that listing servers without a folder_path raises ValueError.""" - monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) - monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) - - folders_service = FolderService( - config=config, execution_context=execution_context - ) - service = McpService( - config=config, - execution_context=execution_context, - folders_service=folders_service, - ) - - with pytest.raises( - ValueError, - match="Cannot obtain folder_key without providing folder_path", - ): - service.list() - - @pytest.mark.anyio - async def test_list_async( - self, - httpx_mock: HTTPXMock, - service: McpService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test asynchronously listing MCP servers.""" - mock_servers = [ - { - "id": "server-id-1", - "name": "Async Test Server", - "slug": "async-test-server", - "description": "Async test description", - "version": "1.0.0", - "createdAt": "2025-07-24T11:30:52.031427", - "updatedAt": "2025-07-24T12:29:53.4765887", - "isActive": True, - "type": 2, - "status": 1, - "command": "", - "arguments": "", - "environmentVariables": "", - "processKey": "test-process-key", - "folderKey": "test-folder-key", - "runtimesCount": 0, - "mcpUrl": "https://test.com/mcp/async-test-server", - } - ] - - 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}/agenthub_/api/servers", - status_code=200, - json=mock_servers, - ) - - servers = await service.list_async(folder_path="test-folder-path") - - assert len(servers) == 1 - assert isinstance(servers[0], McpServer) - assert servers[0].name == "Async Test Server" - - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - servers_request = requests[1] - assert servers_request.method == "GET" - assert ( - servers_request.url == f"{base_url}{org}{tenant}/agenthub_/api/servers" - ) - assert HEADER_FOLDER_KEY in servers_request.headers - assert servers_request.headers[HEADER_FOLDER_KEY] == "test-folder-key" - assert HEADER_USER_AGENT in servers_request.headers - assert ( - servers_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.list_async/{version}" - ) - - class TestRetrieveServer: - def test_retrieve_server_with_folder_path( - self, - httpx_mock: HTTPXMock, - service: McpService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test retrieving a specific MCP server by slug with folder_path.""" - mock_server = { - "id": "server-id-1", - "name": "Test MCP Server", - "slug": "test-mcp-server", - "description": "A test server", - "version": "1.0.0", - "createdAt": "2025-07-24T11:30:52.031427", - "updatedAt": "2025-07-24T12:29:53.4765887", - "isActive": True, - "type": 2, - "status": 1, - "command": "", - "arguments": "", - "environmentVariables": "", - "processKey": "test-process-key", - "folderKey": "test-folder-key", - "runtimesCount": 0, - "mcpUrl": "https://test.com/mcp/test-mcp-server", - } - - 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}/agenthub_/api/servers/test-mcp-server", - status_code=200, - json=mock_server, - ) - - server = service.retrieve("test-mcp-server", folder_path="test-folder-path") - - assert isinstance(server, McpServer) - assert server.name == "Test MCP Server" - assert server.slug == "test-mcp-server" - - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - retrieve_request = requests[1] - assert retrieve_request.method == "GET" - assert ( - retrieve_request.url - == f"{base_url}{org}{tenant}/agenthub_/api/servers/test-mcp-server" - ) - assert HEADER_FOLDER_KEY in retrieve_request.headers - assert retrieve_request.headers[HEADER_FOLDER_KEY] == "test-folder-key" - assert HEADER_USER_AGENT in retrieve_request.headers - assert ( - retrieve_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.retrieve/{version}" - ) - - @pytest.mark.anyio - async def test_retrieve_server_async( - self, - httpx_mock: HTTPXMock, - service: McpService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - """Test asynchronously retrieving a specific MCP server.""" - mock_server = { - "id": "server-id-1", - "name": "Async Test Server", - "slug": "async-test-server", - "description": "Async test server", - "version": "1.0.0", - "createdAt": "2025-07-24T11:30:52.031427", - "updatedAt": "2025-07-24T12:29:53.4765887", - "isActive": True, - "type": 2, - "status": 1, - "command": "", - "arguments": "", - "environmentVariables": "", - "processKey": "test-process-key", - "folderKey": "test-folder-key", - "runtimesCount": 0, - "mcpUrl": "https://test.com/mcp/async-test-server", - } - - 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}/agenthub_/api/servers/async-test-server", - status_code=200, - json=mock_server, - ) - - server = await service.retrieve_async( - "async-test-server", folder_path="test-folder-path" - ) - - assert isinstance(server, McpServer) - assert server.name == "Async Test Server" - - requests = httpx_mock.get_requests() - assert len(requests) == 2 - - retrieve_request = requests[1] - assert retrieve_request.method == "GET" - assert ( - retrieve_request.url - == f"{base_url}{org}{tenant}/agenthub_/api/servers/async-test-server" - ) - assert HEADER_FOLDER_KEY in retrieve_request.headers - assert retrieve_request.headers[HEADER_FOLDER_KEY] == "test-folder-key" - assert HEADER_USER_AGENT in retrieve_request.headers - assert ( - retrieve_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.retrieve_async/{version}" - ) - - class TestRequestKwargs: - """Test that all methods pass the correct kwargs to request/request_async.""" - - def test_list_passes_all_kwargs(self, service: McpService) -> None: - """Test that list passes all kwargs to request.""" - mock_response = Mock() - mock_response.json.return_value = [ - { - "id": "test-id", - "name": "Test Server", - "slug": "test-server", - "description": "Test", - "version": "1.0.0", - "createdAt": "2025-07-24T11:30:52.031427", - "updatedAt": "2025-07-24T12:29:53.4765887", - "isActive": True, - "type": 2, - "status": 1, - "processKey": "test-process-key", - "folderKey": "test-folder-key", - "mcpUrl": "https://test.com/mcp/test", - } - ] - - with patch.object( - service._folders_service, - "retrieve_folder_key", - return_value="test-folder-key", - ): - with patch.object( - service, "request", return_value=mock_response - ) as mock_request: - service.list(folder_path="test-folder-path") - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - assert call_kwargs.args[0] == "GET" - - assert HEADER_FOLDER_KEY in call_kwargs.kwargs["headers"] - assert ( - call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] - == "test-folder-key" - ) - - @pytest.mark.anyio - async def test_list_async_passes_all_kwargs(self, service: McpService) -> None: - """Test that list_async passes all kwargs to request_async.""" - mock_response = Mock() - mock_response.json.return_value = [ - { - "id": "test-id", - "name": "Test Server", - "slug": "test-server", - "description": "Test", - "version": "1.0.0", - "createdAt": "2025-07-24T11:30:52.031427", - "updatedAt": "2025-07-24T12:29:53.4765887", - "isActive": True, - "type": 2, - "status": 1, - "processKey": "test-process-key", - "folderKey": "test-folder-key", - "mcpUrl": "https://test.com/mcp/test", - } - ] - - with patch.object( - service._folders_service, - "retrieve_folder_key", - return_value="test-folder-key", - ): - with patch.object( - service, "request_async", return_value=mock_response - ) as mock_request: - await service.list_async(folder_path="test-folder-path") - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - assert call_kwargs.args[0] == "GET" - - assert HEADER_FOLDER_KEY in call_kwargs.kwargs["headers"] - assert ( - call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] - == "test-folder-key" - ) - - def test_retrieve_passes_all_kwargs(self, service: McpService) -> None: - """Test that retrieve passes all kwargs to request.""" - mock_response = Mock() - mock_response.json.return_value = { - "id": "test-id", - "name": "Test Server", - "slug": "test-server", - "description": "Test", - "version": "1.0.0", - "createdAt": "2025-07-24T11:30:52.031427", - "updatedAt": "2025-07-24T12:29:53.4765887", - "isActive": True, - "type": 2, - "status": 1, - "processKey": "test-process-key", - "folderKey": "test-folder-key", - "mcpUrl": "https://test.com/mcp/test", - } - - with patch.object( - service._folders_service, - "retrieve_folder_key", - return_value="test-folder-key", - ): - with patch.object( - service, "request", return_value=mock_response - ) as mock_request: - service.retrieve("test-server", folder_path="test-folder-path") - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - assert call_kwargs.args[0] == "GET" - - assert HEADER_FOLDER_KEY in call_kwargs.kwargs["headers"] - assert ( - call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] - == "test-folder-key" - ) - - @pytest.mark.anyio - async def test_retrieve_async_passes_all_kwargs( - self, service: McpService - ) -> None: - """Test that retrieve_async passes all kwargs to request_async.""" - mock_response = Mock() - mock_response.json.return_value = { - "id": "test-id", - "name": "Test Server", - "slug": "test-server", - "description": "Test", - "version": "1.0.0", - "createdAt": "2025-07-24T11:30:52.031427", - "updatedAt": "2025-07-24T12:29:53.4765887", - "isActive": True, - "type": 2, - "status": 1, - "processKey": "test-process-key", - "folderKey": "test-folder-key", - "mcpUrl": "https://test.com/mcp/test", - } - - with patch.object( - service._folders_service, - "retrieve_folder_key", - return_value="test-folder-key", - ): - with patch.object( - service, "request_async", return_value=mock_response - ) as mock_request: - await service.retrieve_async( - "test-server", folder_path="test-folder-path" - ) - - mock_request.assert_called_once() - call_kwargs = mock_request.call_args - - assert "url" in call_kwargs.kwargs - assert "params" in call_kwargs.kwargs - assert "headers" in call_kwargs.kwargs - - assert call_kwargs.args[0] == "GET" - - assert HEADER_FOLDER_KEY in call_kwargs.kwargs["headers"] - assert ( - call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] - == "test-folder-key" - ) diff --git a/tests/sdk/services/test_processes_service.py b/tests/sdk/services/test_processes_service.py deleted file mode 100644 index 0bb21835c..000000000 --- a/tests/sdk/services/test_processes_service.py +++ /dev/null @@ -1,473 +0,0 @@ -import json -import uuid - -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.orchestrator import Job -from uipath.platform.orchestrator._attachments_service import AttachmentsService -from uipath.platform.orchestrator._processes_service import ProcessesService - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> ProcessesService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - attachments_service = AttachmentsService( - config=config, execution_context=execution_context - ) - return ProcessesService( - config=config, - execution_context=execution_context, - attachment_service=attachments_service, - ) - - -class TestProcessesService: - def test_invoke( - self, - httpx_mock: HTTPXMock, - service: ProcessesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - process_name = "test-process" - input_arguments = {"key": "value"} - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", - status_code=200, - json={ - "value": [ - { - "Key": "test-job-key", - "State": "Running", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - job = service.invoke(process_name, input_arguments) - - assert isinstance(job, Job) - assert job.key == "test-job-key" - assert job.state == "Running" - assert job.start_time == "2024-01-01T00:00:00Z" - assert job.id == 123 - assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" - ) - assert sent_request.content.decode("utf-8") == json.dumps( - { - "startInfo": { - "ReleaseName": process_name, - "InputArguments": json.dumps(input_arguments), - } - }, - separators=(",", ":"), - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke/{version}" - ) - - def test_invoke_without_input_arguments( - self, - httpx_mock: HTTPXMock, - service: ProcessesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - process_name = "test-process" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", - status_code=200, - json={ - "value": [ - { - "Key": "test-job-key", - "State": "Running", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - job = service.invoke(process_name) - - assert isinstance(job, Job) - assert job.key == "test-job-key" - assert job.state == "Running" - assert job.start_time == "2024-01-01T00:00:00Z" - assert job.id == 123 - assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" - ) - assert sent_request.content.decode("utf-8") == json.dumps( - { - "startInfo": { - "ReleaseName": process_name, - "InputArguments": "{}", - } - }, - separators=(",", ":"), - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke/{version}" - ) - - def test_invoke_over_10k_limit_input( - self, - httpx_mock: HTTPXMock, - service: ProcessesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - process_name = "test-process" - # Create input arguments that exceed 10k characters - large_text = "a" * 10001 - input_arguments = {"large_text": large_text} - - test_attachment_id = uuid.uuid4() - blob_uri = "https://test-storage.com/test-container/test-blob" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", - method="POST", - status_code=201, - json={ - "Id": str(test_attachment_id), - "Name": "test-input.json", - "BlobFileAccess": { - "Uri": blob_uri, - "Headers": { - "Keys": ["x-ms-blob-type", "Content-Type"], - "Values": ["BlockBlob", "application/json"], - }, - "RequiresAuth": False, - }, - }, - ) - - httpx_mock.add_response( - url=blob_uri, - method="PUT", - status_code=201, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", - status_code=200, - json={ - "value": [ - { - "Key": "test-job-key", - "State": "Running", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - job = service.invoke(process_name, input_arguments) - - assert isinstance(job, Job) - assert job.key == "test-job-key" - assert job.state == "Running" - assert job.start_time == "2024-01-01T00:00:00Z" - assert job.id == 123 - assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" - - # attachment creation, blob upload, job start - requests = httpx_mock.get_requests() - assert len(requests) == 3 - - attachment_request = requests[0] - assert attachment_request.method == "POST" - assert "Attachments" in str(attachment_request.url) - - blob_request = requests[1] - assert blob_request.method == "PUT" - assert blob_request.url == blob_uri - - job_request = requests[2] - assert job_request.method == "POST" - assert ( - job_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" - ) - - # verify InputFile is used - job_content = json.loads(job_request.content.decode("utf-8").replace("'", '"')) - assert "startInfo" in job_content - assert "ReleaseName" in job_content["startInfo"] - assert job_content["startInfo"]["ReleaseName"] == process_name - assert "InputFile" in job_content["startInfo"] - assert "InputArguments" not in job_content["startInfo"] - assert job_content["startInfo"]["InputFile"] == str(test_attachment_id) - - assert HEADER_USER_AGENT in job_request.headers - assert ( - job_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke/{version}" - ) - - @pytest.mark.asyncio - async def test_invoke_async( - self, - httpx_mock: HTTPXMock, - service: ProcessesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - process_name = "test-process" - input_arguments = {"key": "value"} - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", - status_code=200, - json={ - "value": [ - { - "Key": "test-job-key", - "State": "Running", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - job = await service.invoke_async(process_name, input_arguments) - - assert isinstance(job, Job) - assert job.key == "test-job-key" - assert job.state == "Running" - assert job.start_time == "2024-01-01T00:00:00Z" - assert job.id == 123 - assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" - ) - assert sent_request.content.decode("utf-8") == json.dumps( - { - "startInfo": { - "ReleaseName": process_name, - "InputArguments": json.dumps(input_arguments), - } - }, - separators=(",", ":"), - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke_async/{version}" - ) - - @pytest.mark.asyncio - async def test_invoke_async_without_input_arguments( - self, - httpx_mock: HTTPXMock, - service: ProcessesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - process_name = "test-process" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", - status_code=200, - json={ - "value": [ - { - "Key": "test-job-key", - "State": "Running", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - job = await service.invoke_async(process_name) - - assert isinstance(job, Job) - assert job.key == "test-job-key" - assert job.state == "Running" - assert job.start_time == "2024-01-01T00:00:00Z" - assert job.id == 123 - assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" - ) - assert sent_request.content.decode("utf-8") == json.dumps( - { - "startInfo": { - "ReleaseName": process_name, - "InputArguments": "{}", - } - }, - separators=(",", ":"), - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke_async/{version}" - ) - - @pytest.mark.asyncio - async def test_invoke_async_over_10k_limit_input( - self, - httpx_mock: HTTPXMock, - service: ProcessesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - process_name = "test-process" - # Create input arguments that exceed 10k characters - large_text = "a" * 10001 - input_arguments = {"large_text": large_text} - - test_attachment_id = uuid.uuid4() - blob_uri = "https://test-storage.com/test-container/test-blob" - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", - method="POST", - status_code=201, - json={ - "Id": str(test_attachment_id), - "Name": "test-input.json", - "BlobFileAccess": { - "Uri": blob_uri, - "Headers": { - "Keys": ["x-ms-blob-type", "Content-Type"], - "Values": ["BlockBlob", "application/json"], - }, - "RequiresAuth": False, - }, - }, - ) - - httpx_mock.add_response( - url=blob_uri, - method="PUT", - status_code=201, - ) - - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", - status_code=200, - json={ - "value": [ - { - "Key": "test-job-key", - "State": "Running", - "StartTime": "2024-01-01T00:00:00Z", - "Id": 123, - "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", - } - ] - }, - ) - - job = await service.invoke_async(process_name, input_arguments) - - assert isinstance(job, Job) - assert job.key == "test-job-key" - assert job.state == "Running" - assert job.start_time == "2024-01-01T00:00:00Z" - assert job.id == 123 - assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" - - # attachment creation, blob upload, job start - requests = httpx_mock.get_requests() - assert len(requests) == 3 - - attachment_request = requests[0] - assert attachment_request.method == "POST" - assert "Attachments" in str(attachment_request.url) - - blob_request = requests[1] - assert blob_request.method == "PUT" - assert blob_request.url == blob_uri - - job_request = requests[2] - assert job_request.method == "POST" - assert ( - job_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" - ) - - # verify InputFile is used - job_content = json.loads(job_request.content.decode("utf-8").replace("'", '"')) - assert "startInfo" in job_content - assert "ReleaseName" in job_content["startInfo"] - assert job_content["startInfo"]["ReleaseName"] == process_name - assert "InputFile" in job_content["startInfo"] - assert "InputArguments" not in job_content["startInfo"] - assert job_content["startInfo"]["InputFile"] == str(test_attachment_id) - - assert HEADER_USER_AGENT in job_request.headers - assert ( - job_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke_async/{version}" - ) diff --git a/tests/sdk/services/test_queues_service.py b/tests/sdk/services/test_queues_service.py deleted file mode 100644 index 691ed7ec0..000000000 --- a/tests/sdk/services/test_queues_service.py +++ /dev/null @@ -1,808 +0,0 @@ -import json - -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.orchestrator import ( - CommitType, - QueueItem, - QueueItemPriority, - TransactionItem, - TransactionItemResult, -) -from uipath.platform.orchestrator._queues_service import QueuesService - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - monkeypatch: pytest.MonkeyPatch, -) -> QueuesService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return QueuesService(config=config, execution_context=execution_context) - - -class TestQueuesService: - def test_list_items( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems", - status_code=200, - json={ - "value": [ - { - "Id": 1, - "Name": "test-queue", - "Priority": "High", - } - ] - }, - ) - - response = service.list_items() - - assert response["value"][0]["Id"] == 1 - assert response["value"][0]["Name"] == "test-queue" - assert response["value"][0]["Priority"] == "High" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.list_items/{version}" - ) - - @pytest.mark.asyncio - async def test_list_items_async( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems", - status_code=200, - json={ - "value": [ - { - "Id": 1, - "Name": "test-queue", - "Priority": "High", - } - ] - }, - ) - - response = await service.list_items_async() - - assert response["value"][0]["Id"] == 1 - assert response["value"][0]["Name"] == "test-queue" - assert response["value"][0]["Priority"] == "High" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems" - ) - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.list_items_async/{version}" - ) - - def test_create_item( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - queue_item = QueueItem( - name="test-queue", - priority=QueueItemPriority.HIGH, - specific_content={"key": "value"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem", - status_code=200, - json={ - "Id": 1, - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - }, - ) - - response = service.create_item(queue_item) - - assert response["Id"] == 1 - assert response["Name"] == "test-queue" - assert response["Priority"] == "High" - assert response["SpecificContent"] == {"key": "value"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" - ) - assert json.loads(sent_request.content.decode()) == { - "itemData": { - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - } - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item/{version}" - ) - - @pytest.mark.asyncio - async def test_create_item_async( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - queue_item = QueueItem( - name="test-queue", - priority=QueueItemPriority.HIGH, - specific_content={"key": "value"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem", - status_code=200, - json={ - "Id": 1, - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - }, - ) - - response = await service.create_item_async(queue_item) - - assert response["Id"] == 1 - assert response["Name"] == "test-queue" - assert response["Priority"] == "High" - assert response["SpecificContent"] == {"key": "value"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" - ) - assert json.loads(sent_request.content.decode()) == { - "itemData": { - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - } - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item_async/{version}" - ) - - def test_create_items( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - queue_items = [ - QueueItem( - name="test-queue", - priority=QueueItemPriority.HIGH, - specific_content={"key": "value"}, - ), - QueueItem( - name="test-queue", - priority=QueueItemPriority.LOW, - specific_content={"key2": "value2"}, - ), - ] - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems", - status_code=200, - json={ - "value": [ - { - "Id": 1, - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - }, - { - "Id": 2, - "Name": "test-queue", - "Priority": "Low", - "SpecificContent": {"key2": "value2"}, - }, - ] - }, - ) - - response = service.create_items( - queue_items, "test-queue", CommitType.ALL_OR_NOTHING - ) - - assert len(response["value"]) == 2 - assert response["value"][0]["Id"] == 1 - assert response["value"][0]["Name"] == "test-queue" - assert response["value"][0]["Priority"] == "High" - assert response["value"][0]["SpecificContent"] == {"key": "value"} - assert response["value"][1]["Id"] == 2 - assert response["value"][1]["Name"] == "test-queue" - assert response["value"][1]["Priority"] == "Low" - assert response["value"][1]["SpecificContent"] == {"key2": "value2"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems" - ) - assert json.loads(sent_request.content.decode()) == { - "queueName": "test-queue", - "commitType": "AllOrNothing", - "queueItems": [ - { - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - }, - { - "Name": "test-queue", - "Priority": "Low", - "SpecificContent": {"key2": "value2"}, - }, - ], - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_items/{version}" - ) - - @pytest.mark.asyncio - async def test_create_items_async( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - queue_items = [ - QueueItem( - name="test-queue", - priority=QueueItemPriority.HIGH, - specific_content={"key": "value"}, - ), - QueueItem( - name="test-queue", - priority=QueueItemPriority.LOW, - specific_content={"key2": "value2"}, - ), - ] - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems", - status_code=200, - json={ - "value": [ - { - "Id": 1, - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - }, - { - "Id": 2, - "Name": "test-queue", - "Priority": "Low", - "SpecificContent": {"key2": "value2"}, - }, - ] - }, - ) - - response = await service.create_items_async( - queue_items, "test-queue", CommitType.ALL_OR_NOTHING - ) - - assert len(response["value"]) == 2 - assert response["value"][0]["Id"] == 1 - assert response["value"][0]["Name"] == "test-queue" - assert response["value"][0]["Priority"] == "High" - assert response["value"][0]["SpecificContent"] == {"key": "value"} - assert response["value"][1]["Id"] == 2 - assert response["value"][1]["Name"] == "test-queue" - assert response["value"][1]["Priority"] == "Low" - assert response["value"][1]["SpecificContent"] == {"key2": "value2"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems" - ) - assert json.loads(sent_request.content.decode()) == { - "queueName": "test-queue", - "commitType": "AllOrNothing", - "queueItems": [ - { - "Name": "test-queue", - "Priority": "High", - "SpecificContent": {"key": "value"}, - }, - { - "Name": "test-queue", - "Priority": "Low", - "SpecificContent": {"key2": "value2"}, - }, - ], - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_items_async/{version}" - ) - - def test_create_item_with_reference( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - reference_value = "TEST-REF-12345" - queue_item = QueueItem( - name="test-queue", - reference=reference_value, - priority=QueueItemPriority.HIGH, - specific_content={"invoice_id": "INV-001"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem", - status_code=200, - json={ - "Id": 1, - "Name": "test-queue", - "Reference": reference_value, - "Priority": "High", - "SpecificContent": {"invoice_id": "INV-001"}, - }, - ) - - response = service.create_item(queue_item) - - assert response["Id"] == 1 - assert response["Name"] == "test-queue" - assert response["Reference"] == reference_value - assert response["Priority"] == "High" - assert response["SpecificContent"] == {"invoice_id": "INV-001"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" - ) - assert json.loads(sent_request.content.decode()) == { - "itemData": { - "Name": "test-queue", - "Reference": reference_value, - "Priority": "High", - "SpecificContent": {"invoice_id": "INV-001"}, - } - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item/{version}" - ) - - @pytest.mark.asyncio - async def test_create_item_with_reference_async( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - reference_value = "TEST-REF-12345" - queue_item = QueueItem( - name="test-queue", - reference=reference_value, - priority=QueueItemPriority.HIGH, - specific_content={"invoice_id": "INV-001"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem", - status_code=200, - json={ - "Id": 1, - "Name": "test-queue", - "Reference": reference_value, - "Priority": "High", - "SpecificContent": {"invoice_id": "INV-001"}, - }, - ) - - response = await service.create_item_async(queue_item) - - assert response["Id"] == 1 - assert response["Name"] == "test-queue" - assert response["Reference"] == reference_value - assert response["Priority"] == "High" - assert response["SpecificContent"] == {"invoice_id": "INV-001"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" - ) - assert json.loads(sent_request.content.decode()) == { - "itemData": { - "Name": "test-queue", - "Reference": reference_value, - "Priority": "High", - "SpecificContent": {"invoice_id": "INV-001"}, - } - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item_async/{version}" - ) - - def test_create_transaction_item( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - transaction_item = TransactionItem( - name="test-queue", - specific_content={"key": "value"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", - status_code=200, - json={ - "Id": 1, - "Name": "test-queue", - "SpecificContent": {"key": "value"}, - }, - ) - - response = service.create_transaction_item(transaction_item) - - assert response["Id"] == 1 - assert response["Name"] == "test-queue" - assert response["SpecificContent"] == {"key": "value"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction" - ) - assert json.loads(sent_request.content.decode()) == { - "transactionData": { - "Name": "test-queue", - "RobotIdentifier": "test-robot-key", - "SpecificContent": {"key": "value"}, - } - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_transaction_item/{version}" - ) - - @pytest.mark.asyncio - async def test_create_transaction_item_async( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - transaction_item = TransactionItem( - name="test-queue", - specific_content={"key": "value"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", - status_code=200, - json={ - "Id": 1, - "Name": "test-queue", - "SpecificContent": {"key": "value"}, - }, - ) - - response = await service.create_transaction_item_async(transaction_item) - - assert response["Id"] == 1 - assert response["Name"] == "test-queue" - assert response["SpecificContent"] == {"key": "value"} - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction" - ) - assert json.loads(sent_request.content.decode()) == { - "transactionData": { - "Name": "test-queue", - "RobotIdentifier": "test-robot-key", - "SpecificContent": {"key": "value"}, - } - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_transaction_item_async/{version}" - ) - - def test_update_progress_of_transaction_item( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - transaction_key = "test-transaction-key" - progress = "Processing..." - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress", - status_code=200, - json={"status": "success"}, - ) - - response = service.update_progress_of_transaction_item( - transaction_key, progress - ) - - assert response["status"] == "success" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress" - ) - assert json.loads(sent_request.content.decode()) == {"progress": progress} - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.update_progress_of_transaction_item/{version}" - ) - - @pytest.mark.asyncio - async def test_update_progress_of_transaction_item_async( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - transaction_key = "test-transaction-key" - progress = "Processing..." - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress", - status_code=200, - json={"status": "success"}, - ) - - response = await service.update_progress_of_transaction_item_async( - transaction_key, progress - ) - - assert response["status"] == "success" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress" - ) - assert json.loads(sent_request.content.decode()) == {"progress": progress} - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.update_progress_of_transaction_item_async/{version}" - ) - - def test_complete_transaction_item( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - transaction_key = "test-transaction-key" - result = TransactionItemResult( - is_successful=True, - output={"result": "success"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult", - status_code=200, - json={"status": "success"}, - ) - - response = service.complete_transaction_item(transaction_key, result) - - assert response["status"] == "success" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult" - ) - assert json.loads(sent_request.content.decode()) == { - "transactionResult": { - "IsSuccessful": True, - "Output": {"result": "success"}, - } - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.complete_transaction_item/{version}" - ) - - @pytest.mark.asyncio - async def test_complete_transaction_item_async( - self, - httpx_mock: HTTPXMock, - service: QueuesService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - transaction_key = "test-transaction-key" - result = TransactionItemResult( - is_successful=True, - output={"result": "success"}, - ) - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult", - status_code=200, - json={"status": "success"}, - ) - - response = await service.complete_transaction_item_async( - transaction_key, result - ) - - assert response["status"] == "success" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "POST" - assert ( - sent_request.url - == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult" - ) - assert json.loads(sent_request.content.decode()) == { - "transactionResult": { - "IsSuccessful": True, - "Output": {"result": "success"}, - } - } - - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.complete_transaction_item_async/{version}" - ) diff --git a/tests/sdk/services/test_resource_catalog_service.py b/tests/sdk/services/test_resource_catalog_service.py deleted file mode 100644 index 56e5ae6cd..000000000 --- a/tests/sdk/services/test_resource_catalog_service.py +++ /dev/null @@ -1,861 +0,0 @@ -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest -from pytest_httpx import HTTPXMock - -from uipath._utils.constants import HEADER_USER_AGENT -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.orchestrator._folder_service import FolderService -from uipath.platform.resource_catalog import ResourceType -from uipath.platform.resource_catalog._resource_catalog_service import ( - ResourceCatalogService, -) - - -@pytest.fixture -def mock_folder_service() -> MagicMock: - """Mock FolderService for testing.""" - service = MagicMock(spec=FolderService) - service.retrieve_folder_key.return_value = "test-folder-key" - service.retrieve_folder_key_async = AsyncMock(return_value="test-folder-key") - return service - - -@pytest.fixture -def service( - config: UiPathApiConfig, - execution_context: UiPathExecutionContext, - mock_folder_service: MagicMock, - monkeypatch: pytest.MonkeyPatch, -) -> ResourceCatalogService: - monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") - return ResourceCatalogService( - config=config, - execution_context=execution_context, - folder_service=mock_folder_service, - ) - - -class TestResourceCatalogService: - @staticmethod - def _mock_response( - entity_id: str, - name: str, - entity_type: str, - entity_sub_type: str = "default", - description: str = "", - folder_key: str = "test-folder-key", - **extra_fields, - ) -> dict[str, Any]: - """Generate a mock Resource response.""" - response = { - "entityKey": entity_id, - "name": name, - "entityType": entity_type, - "entitySubType": entity_sub_type, - "description": description, - "scope": "Tenant", - "searchState": "Available", - "timestamp": "2024-01-01T00:00:00Z", - "folderKey": folder_key, - "folderKeys": [folder_key], - "tenantKey": "test-tenant-key", - "accountKey": "test-account-key", - "userKey": "test-user-key", - "tags": [], - "folders": [], - "linkedFoldersCount": 0, - "dependencies": [], - } - response.update(extra_fields) - return response - - class TestSearchResources: - def test_search_resources_with_name_filter( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=0&take=20&name=invoice", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="invoice-processor", - entity_type="process", - entity_sub_type="automation", - description="Process invoice documents", - ), - TestResourceCatalogService._mock_response( - entity_id="2", - name="invoice-queue", - entity_type="queue", - entity_sub_type="transactional", - description="Queue for invoice processing", - ), - ] - }, - ) - - resources = list(service.search(name="invoice")) - - assert len(resources) == 2 - assert resources[0].name == "invoice-processor" - assert resources[0].resource_type == "process" - assert resources[1].name == "invoice-queue" - assert resources[1].resource_type == "queue" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert "name=invoice" in str(sent_request.url) - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ResourceCatalogService.search/{version}" - ) - - def test_search_resources_with_resource_types_filter( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=0&take=20&entityTypes=asset&entityTypes=queue", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="3", - name="config-asset", - entity_type="asset", - entity_sub_type="text", - ), - TestResourceCatalogService._mock_response( - entity_id="4", - name="work-queue", - entity_type="queue", - entity_sub_type="transactional", - ), - ] - }, - ) - - resources = list( - service.search(resource_types=[ResourceType.ASSET, ResourceType.QUEUE]) - ) - - assert len(resources) == 2 - assert resources[0].resource_type == "asset" - assert resources[1].resource_type == "queue" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert "entityTypes=asset" in str( - sent_request.url - ) or "entityTypes%5B%5D=asset" in str(sent_request.url) - assert "entityTypes=queue" in str( - sent_request.url - ) or "entityTypes%5B%5D=queue" in str(sent_request.url) - - def test_search_resources_pagination( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - base_url: str, - org: str, - tenant: str, - ) -> None: - # First page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=0&take=2", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - "1", "resource-1", "asset" - ), - TestResourceCatalogService._mock_response( - "2", "resource-2", "queue" - ), - ] - }, - ) - # Second page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=2&take=2", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - "3", "resource-3", "process" - ), - ] - }, - ) - - resources = list(service.search(page_size=2)) - - assert len(resources) == 3 - assert resources[0].name == "resource-1" - assert resources[1].name == "resource-2" - assert resources[2].name == "resource-3" - - class TestListResources: - def test_list_resources_without_filters( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="test-asset", - entity_type="asset", - entity_sub_type="text", - ), - TestResourceCatalogService._mock_response( - entity_id="2", - name="test-queue", - entity_type="queue", - entity_sub_type="transactional", - ), - ] - }, - ) - - resources = list(service.list()) - - assert len(resources) == 2 - assert resources[0].name == "test-asset" - assert resources[1].name == "test-queue" - mock_folder_service.retrieve_folder_key.assert_called_once_with(None) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert str(sent_request.url).endswith("/Entities?skip=0&take=20") - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ResourceCatalogService.list/{version}" - ) - - def test_list_resources_with_folder_path( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20&entityTypes=asset", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="finance-asset", - entity_type="asset", - entity_sub_type="number", - ) - ] - }, - ) - - resources = list( - service.list( - folder_path="/Shared/Finance", resource_types=[ResourceType.ASSET] - ) - ) - - assert len(resources) == 1 - assert resources[0].name == "finance-asset" - mock_folder_service.retrieve_folder_key.assert_called_once_with( - "/Shared/Finance" - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert "X-UIPATH-FolderKey" in sent_request.headers - - def test_list_resources_with_resource_filters( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20&entityTypes=process&entityTypes=mcpserver&entitySubType=automation", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="automation-process", - entity_type="process", - entity_sub_type="automation", - ) - ] - }, - ) - - resources = list( - service.list( - resource_types=[ResourceType.PROCESS, ResourceType.MCP_SERVER], - resource_sub_types=["automation"], - ) - ) - - assert len(resources) == 1 - assert resources[0].resource_type == "process" - assert resources[0].resource_sub_type == "automation" - - def test_list_resources_pagination( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - # First page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=3", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - "1", "resource-1", "asset" - ), - TestResourceCatalogService._mock_response( - "2", "resource-2", "queue" - ), - TestResourceCatalogService._mock_response( - "3", "resource-3", "process" - ), - ] - }, - ) - # Second page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=3&take=3", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - "4", "resource-4", "bucket" - ), - ] - }, - ) - - resources = list(service.list(page_size=3)) - - assert len(resources) == 4 - assert resources[0].name == "resource-1" - assert resources[3].name == "resource-4" - - def test_list_resources_invalid_page_size( - self, - service: ResourceCatalogService, - ) -> None: - with pytest.raises(ValueError, match="page_size must be greater than 0"): - list(service.list(page_size=0)) - - with pytest.raises(ValueError, match="page_size must be greater than 0"): - list(service.list(page_size=-1)) - - class TestListResourcesByType: - def test_list_by_type_basic( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - version: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/asset?skip=0&take=20", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="config-asset", - entity_type="asset", - entity_sub_type="text", - ), - TestResourceCatalogService._mock_response( - entity_id="2", - name="number-asset", - entity_type="asset", - entity_sub_type="number", - ), - ] - }, - ) - - resources = list(service.list_by_type(resource_type=ResourceType.ASSET)) - - assert len(resources) == 2 - assert resources[0].name == "config-asset" - assert resources[0].resource_type == "asset" - assert resources[1].name == "number-asset" - assert resources[1].resource_type == "asset" - mock_folder_service.retrieve_folder_key.assert_called_once_with(None) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert sent_request.method == "GET" - assert str(sent_request.url).endswith("/Entities/asset?skip=0&take=20") - assert HEADER_USER_AGENT in sent_request.headers - assert ( - sent_request.headers[HEADER_USER_AGENT] - == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ResourceCatalogService.list_by_type/{version}" - ) - - def test_list_by_type_with_name_filter( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/process?skip=0&take=20&name=invoice", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="invoice-processor", - entity_type="process", - entity_sub_type="automation", - ) - ] - }, - ) - - resources = list( - service.list_by_type(resource_type=ResourceType.PROCESS, name="invoice") - ) - - assert len(resources) == 1 - assert resources[0].name == "invoice-processor" - assert resources[0].resource_type == "process" - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert "name=invoice" in str(sent_request.url) - - def test_list_by_type_with_folder_and_subtype( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/asset?skip=0&take=20&entitySubType=number", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="finance-number", - entity_type="asset", - entity_sub_type="number", - ) - ] - }, - ) - - resources = list( - service.list_by_type( - resource_type=ResourceType.ASSET, - folder_path="/Shared/Finance", - resource_sub_types=["number"], - ) - ) - - assert len(resources) == 1 - assert resources[0].resource_sub_type == "number" - mock_folder_service.retrieve_folder_key.assert_called_once_with( - "/Shared/Finance" - ) - - sent_request = httpx_mock.get_request() - if sent_request is None: - raise Exception("No request was sent") - - assert "entitySubType=number" in str(sent_request.url) - assert "X-UIPATH-FolderKey" in sent_request.headers - - def test_list_by_type_pagination( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - # First page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/queue?skip=0&take=2", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - "1", "queue-1", "queue" - ), - TestResourceCatalogService._mock_response( - "2", "queue-2", "queue" - ), - ] - }, - ) - # Second page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/queue?skip=2&take=2", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - "3", "queue-3", "queue" - ), - ] - }, - ) - - resources = list( - service.list_by_type(resource_type=ResourceType.QUEUE, page_size=2) - ) - - assert len(resources) == 3 - assert all(r.resource_type == "queue" for r in resources) - - def test_list_by_type_invalid_page_size( - self, - service: ResourceCatalogService, - ) -> None: - with pytest.raises(ValueError, match="page_size must be greater than 0"): - list( - service.list_by_type(resource_type=ResourceType.ASSET, page_size=0) - ) - - with pytest.raises(ValueError, match="page_size must be greater than 0"): - list( - service.list_by_type(resource_type=ResourceType.ASSET, page_size=-1) - ) - - class TestAsyncMethods: - @pytest.mark.asyncio - async def test_search_async( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=0&take=20&name=test", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="test-resource", - entity_type="asset", - ) - ] - }, - ) - - resources = [] - async for resource in service.search_async(name="test"): - resources.append(resource) - - assert len(resources) == 1 - assert resources[0].name == "test-resource" - - @pytest.mark.asyncio - async def test_list_resources_async( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="async-resource", - entity_type="queue", - ) - ] - }, - ) - - resources = [] - async for resource in service.list_async(): - resources.append(resource) - - assert len(resources) == 1 - assert resources[0].name == "async-resource" - mock_folder_service.retrieve_folder_key_async.assert_called_once_with(None) - - @pytest.mark.asyncio - async def test_list_resources_async_with_filters( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20&entityTypes=asset&entitySubType=text", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="text-asset", - entity_type="asset", - entity_sub_type="text", - ) - ] - }, - ) - - resources = [] - async for resource in service.list_async( - resource_types=[ResourceType.ASSET], - resource_sub_types=["text"], - folder_path="/Test/Folder", - ): - resources.append(resource) - - assert len(resources) == 1 - assert resources[0].resource_sub_type == "text" - mock_folder_service.retrieve_folder_key_async.assert_called_once_with( - "/Test/Folder" - ) - - @pytest.mark.asyncio - async def test_list_by_type_async_basic( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/asset?skip=0&take=20", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="async-asset-1", - entity_type="asset", - entity_sub_type="text", - ), - TestResourceCatalogService._mock_response( - entity_id="2", - name="async-asset-2", - entity_type="asset", - entity_sub_type="number", - ), - ] - }, - ) - - resources = [] - async for resource in service.list_by_type_async( - resource_type=ResourceType.ASSET - ): - resources.append(resource) - - assert len(resources) == 2 - assert resources[0].name == "async-asset-1" - assert resources[0].resource_type == "asset" - assert resources[1].name == "async-asset-2" - assert resources[1].resource_type == "asset" - mock_folder_service.retrieve_folder_key_async.assert_called_once_with(None) - - @pytest.mark.asyncio - async def test_list_by_type_async_with_name_filter( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/process?skip=0&take=20&name=workflow", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="workflow-processor", - entity_type="process", - entity_sub_type="automation", - ) - ] - }, - ) - - resources = [] - async for resource in service.list_by_type_async( - resource_type=ResourceType.PROCESS, name="workflow" - ): - resources.append(resource) - - assert len(resources) == 1 - assert resources[0].name == "workflow-processor" - assert resources[0].resource_type == "process" - - @pytest.mark.asyncio - async def test_list_by_type_async_with_folder_and_subtype( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/queue?skip=0&take=20&entitySubType=transactional", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - entity_id="1", - name="transactional-queue", - entity_type="queue", - entity_sub_type="transactional", - ) - ] - }, - ) - - resources = [] - async for resource in service.list_by_type_async( - resource_type=ResourceType.QUEUE, - folder_path="/Production", - resource_sub_types=["transactional"], - ): - resources.append(resource) - - assert len(resources) == 1 - assert resources[0].resource_sub_type == "transactional" - mock_folder_service.retrieve_folder_key_async.assert_called_once_with( - "/Production" - ) - - @pytest.mark.asyncio - async def test_list_by_type_async_pagination( - self, - httpx_mock: HTTPXMock, - service: ResourceCatalogService, - mock_folder_service: MagicMock, - base_url: str, - org: str, - tenant: str, - ) -> None: - # First page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/bucket?skip=0&take=2", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - "1", "bucket-1", "bucket" - ), - TestResourceCatalogService._mock_response( - "2", "bucket-2", "bucket" - ), - ] - }, - ) - # Second page - httpx_mock.add_response( - url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/bucket?skip=2&take=2", - status_code=200, - json={ - "value": [ - TestResourceCatalogService._mock_response( - "3", "bucket-3", "bucket" - ), - ] - }, - ) - - resources = [] - async for resource in service.list_by_type_async( - resource_type=ResourceType.BUCKET, page_size=2 - ): - resources.append(resource) - - assert len(resources) == 3 - assert all(r.resource_type == "bucket" for r in resources) diff --git a/tests/sdk/services/test_uipath_llm_integration.py b/tests/sdk/services/test_uipath_llm_integration.py deleted file mode 100644 index 124ccad8b..000000000 --- a/tests/sdk/services/test_uipath_llm_integration.py +++ /dev/null @@ -1,510 +0,0 @@ -import os -from unittest.mock import MagicMock, patch - -import pytest - -from uipath.platform import UiPathApiConfig, UiPathExecutionContext -from uipath.platform.chat import ( - AutoToolChoice, - ChatModels, - SpecificToolChoice, - ToolDefinition, - ToolFunctionDefinition, - ToolParametersDefinition, - ToolPropertyDefinition, - UiPathLlmChatService, -) - - -def get_env_var(name: str) -> str: - """Get environment variable or skip test if not present.""" - value = os.environ.get(name) - if value is None: - pytest.skip(f"Environment variable {name} is not set") - return value - - -class TestUiPathLLMIntegration: - @pytest.fixture - def llm_service(self): - """Create a UiPathLLMService instance with environment variables.""" - # skip tests on CI, only run locally - pytest.skip("Failed to get access token. Check your credentials.") - - # In a real-world scenario, these would be environment variables - base_url = get_env_var("UIPATH_URL") - api_key = get_env_var("UIPATH_ACCESS_TOKEN") - - config = UiPathApiConfig(base_url=base_url, secret=api_key) - execution_context = UiPathExecutionContext() - return UiPathLlmChatService(config=config, execution_context=execution_context) - - @pytest.mark.asyncio - async def test_basic_chat_completions(self, llm_service): - """Test basic chat completions functionality.""" - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "What is the capital of France?"}, - ] - - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=50, - temperature=0, - ) - - # Validate the response - assert result is not None - assert hasattr(result, "id") - assert hasattr(result, "choices") - assert len(result.choices) > 0 - assert hasattr(result.choices[0], "message") - assert hasattr(result.choices[0].message, "content") - assert "Paris" in result.choices[0].message.content - - @pytest.mark.asyncio - async def test_tool_call_required(self, llm_service): - """Test the tool call functionality with a specific required tool.""" - messages = [ - { - "role": "system", - "content": "You are given two tools/functions and a user and password. You must first call test_tool with the given credentials then call submit_answer with the result. If the result is nested, extract the result string and pass it to submit_answer. Do not respond with text, only call the tools/functions.", - }, - {"role": "user", "content": "username: John, password: 1234"}, - ] - - # Define the test_tool - test_tool = ToolDefinition( - type="function", - function=ToolFunctionDefinition( - name="test_tool", - description="call this to obtain the result", - parameters=ToolParametersDefinition( - type="object", - properties={ - "name": ToolPropertyDefinition( - type="string", description="the name of the user" - ), - "password": ToolPropertyDefinition( - type="string", description="the password of the user" - ), - }, - required=["name", "password"], - ), - ), - ) - - # Define tool choice to specifically use test_tool - tool_choice = SpecificToolChoice(type="tool", name="test_tool") - - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=250, - temperature=0, - tools=[test_tool], - tool_choice=tool_choice, - ) - - # Validate the response - assert result is not None - assert len(result.choices) > 0 - assert result.choices[0].message.tool_calls is not None - assert len(result.choices[0].message.tool_calls) > 0 - assert result.choices[0].message.tool_calls[0].name == "test_tool" - assert "name" in result.choices[0].message.tool_calls[0].arguments - assert result.choices[0].message.tool_calls[0].arguments["name"] == "John" - assert "password" in result.choices[0].message.tool_calls[0].arguments - assert result.choices[0].message.tool_calls[0].arguments["password"] == "1234" - - @pytest.mark.asyncio - async def test_chat_with_conversation_history(self, llm_service): - """Test chat completions with a conversation history including assistant messages.""" - messages = [ - {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "Hi my name is John"}, - {"content": "Hello John! How can I assist you today?", "role": "assistant"}, - {"role": "user", "content": "What is my name?"}, - ] - - # Define the test_tool but with auto tool choice - test_tool = ToolDefinition( - type="function", - function=ToolFunctionDefinition( - name="test_tool", - description="call this to obtain the result", - parameters=ToolParametersDefinition( - type="object", - properties={ - "name": ToolPropertyDefinition( - type="string", description="the name of the user" - ), - "password": ToolPropertyDefinition( - type="string", description="the password of the user" - ), - }, - required=["name", "password"], - ), - ), - ) - - # Use auto tool choice - tool_choice = AutoToolChoice(type="auto") - - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=250, - temperature=0, - frequency_penalty=1, - presence_penalty=1, - tools=[test_tool], - tool_choice=tool_choice, - ) - - # Validate the response - assert result is not None - assert len(result.choices) > 0 - assert result.choices[0].message.content is not None - assert "John" in result.choices[0].message.content - # The model chose to respond with text instead of using the tool - assert ( - result.choices[0].message.tool_calls is None - or len(result.choices[0].message.tool_calls) == 0 - ) - - @pytest.mark.asyncio - async def test_no_tools(self, llm_service): - """Test chat completions without any tools.""" - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Write a haiku about Python programming."}, - ] - - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=100, - temperature=0.7, - ) - - # Validate the response - assert result is not None - assert len(result.choices) > 0 - assert result.choices[0].message.content is not None - assert len(result.choices[0].message.content.strip()) > 0 - # No tools were provided, so no tool calls should be in the response - assert ( - result.choices[0].message.tool_calls is None - or len(result.choices[0].message.tool_calls) == 0 - ) - - -class TestUiPathLLMServiceMocked: - @pytest.fixture - def config(self): - return UiPathApiConfig(base_url="https://example.com", secret="test_secret") - - @pytest.fixture - def execution_context(self): - return UiPathExecutionContext() - - @pytest.fixture - def llm_service(self, config, execution_context): - return UiPathLlmChatService(config=config, execution_context=execution_context) - - def test_init(self, config, execution_context): - service = UiPathLlmChatService( - config=config, execution_context=execution_context - ) - assert service._config == config - assert service._execution_context == execution_context - - @pytest.mark.asyncio - @patch.object(UiPathLlmChatService, "request_async") - async def test_basic_chat_completions_mocked(self, mock_request, llm_service): - # Mock response - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "chatcmpl-123", - "object": "chat.completion", - "created": 1677858242, - "model": "gpt-4o-mini-2024-07-18", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "The capital of France is Paris.", - }, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 30, - "completion_tokens": 10, - "total_tokens": 40, - "cache_read_input_tokens": None, - }, - } - mock_request.return_value = mock_response - - # Test messages - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "What is the capital of France?"}, - ] - - # Call the method - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=50, - temperature=0, - ) - - # Assertions - mock_request.assert_called_once() - assert result.id == "chatcmpl-123" - assert len(result.choices) == 1 - assert result.choices[0].message.content == "The capital of France is Paris." - assert "Paris" in result.choices[0].message.content - assert result.usage.prompt_tokens == 30 - assert result.usage.completion_tokens == 10 - - # Verify the correct endpoint and payload - args, kwargs = mock_request.call_args - assert "/orchestrator_/llm/api/chat/completions" in args[1] - assert kwargs["json"]["messages"] == messages - assert kwargs["json"]["max_tokens"] == 50 - assert kwargs["json"]["temperature"] == 0 - - @pytest.mark.asyncio - @patch.object(UiPathLlmChatService, "request_async") - async def test_tool_call_required_mocked(self, mock_request, llm_service): - # Mock response - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "chatcmpl-456", - "object": "chat.completion", - "created": 1677858242, - "model": "gpt-4o-mini-2024-07-18", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call_abc123", - "name": "test_tool", - "arguments": {"name": "John", "password": "1234"}, - } - ], - }, - "finish_reason": "tool_calls", - } - ], - "usage": { - "prompt_tokens": 50, - "completion_tokens": 25, - "total_tokens": 75, - "cache_read_input_tokens": None, - }, - } - mock_request.return_value = mock_response - - # Test messages - messages = [ - { - "role": "system", - "content": "You are given two tools/functions and a user and password. You must first call test_tool with the given credentials then call submit_answer with the result. If the result is nested, extract the result string and pass it to submit_answer. Do not respond with text, only call the tools/functions.", - }, - {"role": "user", "content": "username: John, password: 1234"}, - ] - - # Define the test_tool - test_tool = ToolDefinition( - type="function", - function=ToolFunctionDefinition( - name="test_tool", - description="call this to obtain the result", - parameters=ToolParametersDefinition( - type="object", - properties={ - "name": ToolPropertyDefinition( - type="string", description="the name of the user" - ), - "password": ToolPropertyDefinition( - type="string", description="the password of the user" - ), - }, - required=["name", "password"], - ), - ), - ) - - # Define tool choice - tool_choice = SpecificToolChoice(type="tool", name="test_tool") - - # Call the method - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=250, - temperature=0, - tools=[test_tool], - tool_choice=tool_choice, - ) - - # Assertions - mock_request.assert_called_once() - assert result.id == "chatcmpl-456" - assert len(result.choices) == 1 - assert result.choices[0].message.tool_calls is not None - assert len(result.choices[0].message.tool_calls) == 1 - assert result.choices[0].message.tool_calls[0].name == "test_tool" - assert result.choices[0].message.tool_calls[0].arguments["name"] == "John" - assert result.choices[0].message.tool_calls[0].arguments["password"] == "1234" - - @pytest.mark.asyncio - @patch.object(UiPathLlmChatService, "request_async") - async def test_chat_with_conversation_history_mocked( - self, mock_request, llm_service - ): - # Mock response - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "chatcmpl-789", - "object": "chat.completion", - "created": 1677858242, - "model": "gpt-4o-mini-2024-07-18", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Your name is John, as you mentioned earlier.", - }, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 70, - "completion_tokens": 15, - "total_tokens": 85, - "cache_read_input_tokens": None, - }, - } - mock_request.return_value = mock_response - - # Test messages with conversation history - messages = [ - {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "Hi my name is John"}, - {"content": "Hello John! How can I assist you today?", "role": "assistant"}, - {"role": "user", "content": "What is my name?"}, - ] - - # Define test tool - test_tool = ToolDefinition( - type="function", - function=ToolFunctionDefinition( - name="test_tool", - description="call this to obtain the result", - parameters=ToolParametersDefinition( - type="object", - properties={ - "name": ToolPropertyDefinition( - type="string", description="the name of the user" - ), - "password": ToolPropertyDefinition( - type="string", description="the password of the user" - ), - }, - required=["name", "password"], - ), - ), - ) - - # Use auto tool choice - tool_choice = AutoToolChoice(type="auto") - - # Call the method - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=250, - temperature=0, - frequency_penalty=1, - presence_penalty=1, - tools=[test_tool], - tool_choice=tool_choice, - ) - - # Assertions - mock_request.assert_called_once() - assert result.id == "chatcmpl-789" - assert len(result.choices) == 1 - assert result.choices[0].message.content is not None - assert "John" in result.choices[0].message.content - assert result.choices[0].message.tool_calls is None - - @pytest.mark.asyncio - @patch.object(UiPathLlmChatService, "request_async") - async def test_no_tools_mocked(self, mock_request, llm_service): - # Mock response - mock_response = MagicMock() - mock_response.json.return_value = { - "id": "chatcmpl-abc", - "object": "chat.completion", - "created": 1677858242, - "model": "gpt-4o-mini-2024-07-18", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Silently coding,\nPython's logic unfolds clear,\nBugs hide, then reveal.", - }, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 40, - "completion_tokens": 20, - "total_tokens": 60, - "cache_read_input_tokens": None, - }, - } - mock_request.return_value = mock_response - - # Test messages - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Write a haiku about Python programming."}, - ] - - # Call the method - result = await llm_service.chat_completions( - messages=messages, - model=ChatModels.gpt_4_1_mini_2025_04_14, - max_tokens=100, - temperature=0.7, - ) - - # Assertions - mock_request.assert_called_once() - assert result.id == "chatcmpl-abc" - assert len(result.choices) == 1 - assert result.choices[0].message.content is not None - assert len(result.choices[0].message.content.strip()) > 0 - assert result.choices[0].message.tool_calls is None - - # Verify the correct payload was sent - args, kwargs = mock_request.call_args - assert kwargs["json"]["messages"] == messages - assert kwargs["json"]["max_tokens"] == 100 - assert kwargs["json"]["temperature"] == 0.7 diff --git a/tests/sdk/services/tests_data/documents_service/classification_response.json b/tests/sdk/services/tests_data/documents_service/classification_response.json deleted file mode 100644 index a815fc783..000000000 --- a/tests/sdk/services/tests_data/documents_service/classification_response.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "classificationResults": [ - { - "DocumentTypeId": "0d209d75-9afd-ef11-aaa7-000d3a234147", - "DocumentId": "0a9f9927-e6af-f011-8e60-6045bd9ba6d0", - "Confidence": 0.53288215, - "OcrConfidence": -1.0, - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DocumentBounds": { - "StartPage": 0, - "PageCount": 1, - "TextStartIndex": 0, - "TextLength": 629, - "PageRange": "1" - }, - "ClassifierName": "Production_classifier" - } - ] -} diff --git a/tests/sdk/services/tests_data/documents_service/extraction_validation_action_response_completed.json b/tests/sdk/services/tests_data/documents_service/extraction_validation_action_response_completed.json deleted file mode 100644 index 0f340b00e..000000000 --- a/tests/sdk/services/tests_data/documents_service/extraction_validation_action_response_completed.json +++ /dev/null @@ -1,1277 +0,0 @@ -{ - "actionData": { - "type": "Validation", - "id": 10432640, - "status": "Completed", - "title": "Test Validation Action", - "priority": "Medium", - "taskCatalogName": "default_du_actions", - "taskUrl": "https://dummy.uipath.com/82e69757-09ff-4e6d-83e7-d530f2a99999/bd829329-42ff-40aa-96dc-95a781699999/actions_/tasks/10499999", - "folderPath": "Shared", - "folderId": 1744784, - "data": { - "automaticExtractionResultsPath": "TestDirectory/12c04908-67f0-f011-8196-6045bd99999/input_results.zip", - "validatedExtractionResultsPath": "TestDirectory/12c04908-67f0-f011-8196-6045bd99999/output_results.zip", - "documentRejectionDetails": null - }, - "action": "Completed", - "isDeleted": false, - "assignedToUser": { - "id": 4303796, - "emailAddress": "dummy.dummy@dummy.com" - }, - "creatorUser": null, - "deleterUser": null, - "lastModifierUser": { - "id": 4303796, - "emailAddress": "dummy.dummy@dummy.com" - }, - "completedByUser": { - "id": 4303796, - "emailAddress": "dummy.dummy@dummy.com" - }, - "creationTime": "2026-01-22T10:03:02.9434351Z", - "lastAssignedTime": "2026-01-22T10:03:45.587Z", - "completionTime": "2026-01-22T10:03:56.71Z", - "processingTime": null - }, - "validatedExtractionResults": { - "DocumentId": "47f037fe-66f0-f011-8196-6045bd99999", - "ResultsVersion": 1, - "ResultsDocument": { - "Bounds": { - "StartPage": 0, - "PageCount": 1, - "TextStartIndex": 0, - "TextLength": 635, - "PageRange": "1" - }, - "Language": "eng", - "DocumentGroup": "", - "DocumentCategory": "", - "DocumentTypeId": "00000000-0000-0000-0000-000000000000", - "DocumentTypeName": "Default", - "DocumentTypeDataVersion": 0, - "DataVersion": 1, - "DocumentTypeSource": "Automatic", - "DocumentTypeField": { - "Components": [], - "Value": "Default", - "UnformattedValue": "", - "Reference": { "TextStartIndex": 0, "TextLength": 0, "Tokens": [] }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - "Fields": [ - { - "FieldId": "Uber Receipt", - "FieldName": "Uber Receipt", - "FieldType": "Table", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Uber Receipt.Header", - "FieldName": "Header", - "FieldType": "Internal", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Date of Trip", - "FieldName": "Date of Trip", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Date of Trip", - "UnformattedValue": "Date of Trip", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Customer Name", - "FieldName": "Customer Name", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Customer Name", - "UnformattedValue": "Customer Name", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Total Amount Paid", - "FieldName": "Total Amount Paid", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Total Amount Paid", - "UnformattedValue": "Total Amount Paid", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Uber Receipt.Body", - "FieldName": "Body", - "FieldType": "Internal", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Date of Trip", - "FieldName": "Date of Trip", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "12/11/2023 00:00:00", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 565, - "TextLength": 17, - "Tokens": [ - { - "TextStartIndex": 565, - "TextLength": 8, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [47.2702, 464.3362, 33.9578, 5.9088] - ] - }, - { - "TextStartIndex": 574, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [47.2702, 500.5087, 6.6439, 5.9088] - ] - }, - { - "TextStartIndex": 574, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [47.2702, 507.8908, 1.4764, 5.9088] - ] - }, - { - "TextStartIndex": 578, - "TextLength": 4, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [47.2702, 511.5819, 16.2407, 5.9088] - ] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Customer Name", - "FieldName": "Customer Name", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Alex", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 41, - "TextLength": 5, - "Tokens": [ - { - "TextStartIndex": 41, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [84.2, 275.3536, 33.9578, 13.2947] - ] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Total Amount Paid", - "FieldName": "Total Amount Paid", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "66.79 RON", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 584, - "TextLength": 9, - "Tokens": [ - { - "TextStartIndex": 584, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [146.2421, 473.933, 22.1464, 8.1246] - ] - }, - { - "TextStartIndex": 588, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [146.2421, 500.5087, 26.5757, 8.1246] - ] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Uber Receipt > Fare Breakdown", - "FieldName": "Uber Receipt > Fare Breakdown", - "FieldType": "Table", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Uber Receipt > Fare Breakdown.Header", - "FieldName": "Header", - "FieldType": "Internal", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Fare Amount", - "FieldName": "Fare Amount", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Fare Amount", - "UnformattedValue": "Fare Amount", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Subtotal", - "FieldName": "Subtotal", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Subtotal", - "UnformattedValue": "Subtotal", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Promotion Amount", - "FieldName": "Promotion Amount", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Promotion Amount", - "UnformattedValue": "Promotion Amount", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Uber Receipt > Fare Breakdown.Body", - "FieldName": "Body", - "FieldType": "Internal", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Fare Amount", - "FieldName": "Fare Amount", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "65.29 RON", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 595, - "TextLength": 9, - "Tokens": [ - { - "TextStartIndex": 595, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [187.6035, 493.8648, 14.026, 4.4316] - ] - }, - { - "TextStartIndex": 599, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [187.6035, 509.3672, 16.9789, 4.4316] - ] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Subtotal", - "FieldName": "Subtotal", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "65.29 RON", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 606, - "TextLength": 9, - "Tokens": [ - { - "TextStartIndex": 606, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [224.5333, 491.6501, 14.7643, 5.1702] - ] - }, - { - "TextStartIndex": 610, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [224.5333, 508.629, 18.4553, 5.1702] - ] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Promotion Amount", - "FieldName": "Promotion Amount", - "FieldType": "Text", - "IsMissing": true, - "DataSource": "Automatic", - "Values": [], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": true, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Tables": [ - { - "FieldId": "Uber Receipt", - "FieldName": "Uber Receipt", - "IsMissing": false, - "DataSource": "Automatic", - "DataVersion": 0, - "OperatorConfirmed": true, - "Values": [ - { - "OperatorConfirmed": true, - "Confidence": 1.0, - "OcrConfidence": 1.0, - "Cells": [ - { - "RowIndex": 0, - "ColumnIndex": 0, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Date of Trip", - "UnformattedValue": "Date of Trip", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 0, - "ColumnIndex": 1, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Customer Name", - "UnformattedValue": "Customer Name", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 0, - "ColumnIndex": 2, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Total Amount Paid", - "UnformattedValue": "Total Amount Paid", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 0, - "IsHeader": false, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "12/11/2023 00:00:00", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 565, - "TextLength": 17, - "Tokens": [ - { - "TextStartIndex": 565, - "TextLength": 8, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[47.2702, 464.3362, 33.9578, 5.9088]] - }, - { - "TextStartIndex": 574, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[47.2702, 500.5087, 6.6439, 5.9088]] - }, - { - "TextStartIndex": 574, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[47.2702, 507.8908, 1.4764, 5.9088]] - }, - { - "TextStartIndex": 578, - "TextLength": 4, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[47.2702, 511.5819, 16.2407, 5.9088]] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 1, - "IsHeader": false, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Alex", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 41, - "TextLength": 5, - "Tokens": [ - { - "TextStartIndex": 41, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[84.2, 275.3536, 33.9578, 13.2947]] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 2, - "IsHeader": false, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "66.79 RON", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 584, - "TextLength": 9, - "Tokens": [ - { - "TextStartIndex": 584, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[146.2421, 473.933, 22.1464, 8.1246]] - }, - { - "TextStartIndex": 588, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[146.2421, 500.5087, 26.5757, 8.1246]] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - } - ], - "ColumnInfo": [ - { - "FieldId": "Date of Trip", - "FieldName": "Date of Trip", - "FieldType": "Text" - }, - { - "FieldId": "Customer Name", - "FieldName": "Customer Name", - "FieldType": "Text" - }, - { - "FieldId": "Total Amount Paid", - "FieldName": "Total Amount Paid", - "FieldType": "Text" - } - ], - "NumberOfRows": 2, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Uber Receipt > Fare Breakdown", - "FieldName": "Uber Receipt > Fare Breakdown", - "IsMissing": false, - "DataSource": "Automatic", - "DataVersion": 0, - "OperatorConfirmed": true, - "Values": [ - { - "OperatorConfirmed": true, - "Confidence": 1.0, - "OcrConfidence": 1.0, - "Cells": [ - { - "RowIndex": 0, - "ColumnIndex": 0, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Fare Amount", - "UnformattedValue": "Fare Amount", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 0, - "ColumnIndex": 1, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Subtotal", - "UnformattedValue": "Subtotal", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 0, - "ColumnIndex": 2, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Promotion Amount", - "UnformattedValue": "Promotion Amount", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 0, - "IsHeader": false, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "65.29 RON", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 595, - "TextLength": 9, - "Tokens": [ - { - "TextStartIndex": 595, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[187.6035, 493.8648, 14.026, 4.4316]] - }, - { - "TextStartIndex": 599, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[187.6035, 509.3672, 16.9789, 4.4316]] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 1, - "IsHeader": false, - "IsMissing": false, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "65.29 RON", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 606, - "TextLength": 9, - "Tokens": [ - { - "TextStartIndex": 606, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[224.5333, 491.6501, 14.7643, 5.1702]] - }, - { - "TextStartIndex": 610, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[224.5333, 508.629, 18.4553, 5.1702]] - } - ] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 2, - "IsHeader": false, - "IsMissing": true, - "OperatorConfirmed": true, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [] - } - ], - "ColumnInfo": [ - { - "FieldId": "Fare Amount", - "FieldName": "Fare Amount", - "FieldType": "Text" - }, - { - "FieldId": "Subtotal", - "FieldName": "Subtotal", - "FieldType": "Text" - }, - { - "FieldId": "Promotion Amount", - "FieldName": "Promotion Amount", - "FieldType": "Text" - } - ], - "NumberOfRows": 2, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - "ExtractorPayloads": null, - "BusinessRulesResults": null - }, - "dataProjection": [ - { - "fieldGroupName": "Uber Receipt", - "fieldValues": [ - { - "id": "Date of Trip", - "name": "Date of Trip", - "value": "12/11/2023 00:00:00", - "unformattedValue": "", - "confidence": 1, - "ocrConfidence": 1, - "type": "Text" - }, - { - "id": "Customer Name", - "name": "Customer Name", - "value": "Alex", - "unformattedValue": "", - "confidence": 1, - "ocrConfidence": 1, - "type": "Text" - }, - { - "id": "Total Amount Paid", - "name": "Total Amount Paid", - "value": "66.79 RON", - "unformattedValue": "", - "confidence": 1, - "ocrConfidence": 1, - "type": "Text" - } - ] - }, - { - "fieldGroupName": "Uber Receipt > Fare Breakdown", - "fieldValues": [ - { - "id": "Fare Amount", - "name": "Fare Amount", - "value": "65.29 RON", - "unformattedValue": "", - "confidence": 1, - "ocrConfidence": 1, - "type": "Text" - }, - { - "id": "Subtotal", - "name": "Subtotal", - "value": "65.29 RON", - "unformattedValue": "", - "confidence": 1, - "ocrConfidence": 1, - "type": "Text" - }, - { - "id": "Promotion Amount", - "name": "Promotion Amount", - "value": null, - "unformattedValue": null, - "confidence": null, - "ocrConfidence": null, - "type": "Text" - } - ] - } - ], - "actionStatus": "Completed" -} diff --git a/tests/sdk/services/tests_data/documents_service/extraction_validation_action_response_unassigned.json b/tests/sdk/services/tests_data/documents_service/extraction_validation_action_response_unassigned.json deleted file mode 100644 index d3342117a..000000000 --- a/tests/sdk/services/tests_data/documents_service/extraction_validation_action_response_unassigned.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "actionData": { - "type": "Validation", - "id": 7372483, - "status": "Unassigned", - "title": "Auto Validation", - "priority": "Medium", - "taskCatalogName": "default_du_actions", - "taskUrl": "https://dummy.uipath.com/40921a85-2b9d-44ea-8655-abf869de8588/f994a44a-10d7-44cf-a1c1-a9f20b469ac3/actions_/tasks/7372483", - "folderPath": "Shared", - "folderId": 249142, - "data": { - "automaticExtractionResultsPath": "Dummy/e777cf4c-2083-f011-b484-000d3a2a9e3c/input_results.zip", - "validatedExtractionResultsPath": "Dummy/e777cf4c-2083-f011-b484-000d3a2a9e3c/output_results.zip", - "documentRejectionDetails": null - }, - "action": null, - "isDeleted": false, - "assignedToUser": null, - "creatorUser": null, - "deleterUser": null, - "lastModifierUser": null, - "completedByUser": null, - "creationTime": "2025-08-27T08:32:02.7115597Z", - "lastAssignedTime": null, - "completionTime": null - }, - "actionStatus": "Unassigned" -} diff --git a/tests/sdk/services/tests_data/documents_service/ixp_extraction_response.json b/tests/sdk/services/tests_data/documents_service/ixp_extraction_response.json deleted file mode 100644 index 219f37e5f..000000000 --- a/tests/sdk/services/tests_data/documents_service/ixp_extraction_response.json +++ /dev/null @@ -1,1078 +0,0 @@ -{ - "extractionResult": { - "DocumentId": "8253c809-2dbe-f011-8194-000d3a296f55", - "ResultsVersion": 0, - "ResultsDocument": { - "Bounds": { - "StartPage": 0, - "PageCount": 1, - "TextStartIndex": 0, - "TextLength": 629, - "PageRange": "1" - }, - "Language": "eng", - "DocumentGroup": "", - "DocumentCategory": "", - "DocumentTypeId": "00000000-0000-0000-0000-000000000000", - "DocumentTypeName": "Default", - "DocumentTypeDataVersion": 0, - "DataVersion": 0, - "DocumentTypeSource": "Automatic", - "DocumentTypeField": { - "Components": [], - "Value": "Default", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": false, - "OcrConfidence": -1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - "Fields": [ - { - "FieldId": "Details", - "FieldName": "Details", - "FieldType": "Table", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Details.Header", - "FieldName": "Header", - "FieldType": "Internal", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Total", - "FieldName": "Total", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Total", - "UnformattedValue": "Total", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "From", - "FieldName": "From", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "From", - "UnformattedValue": "From", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "To", - "FieldName": "To", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "To", - "UnformattedValue": "To", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Dummy field", - "FieldName": "Dummy field", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Dummy field", - "UnformattedValue": "Dummy field", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Details.Body", - "FieldName": "Body", - "FieldType": "Internal", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [ - { - "FieldId": "Total", - "FieldName": "Total", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "66.79 RON", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 620, - "TextLength": 9, - "Tokens": [ - { - "TextStartIndex": 620, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [304.3017, 479.8387, 19.9318, 6.6474] - ] - }, - { - "TextStartIndex": 624, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [304.3017, 503.4615, 23.6228, 6.6474] - ] - } - ] - }, - "DerivedFields": [], - "Confidence": 0.9975757, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "From", - "FieldName": "From", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Aleea Muscel 9, Cluj-Napoca 400347, Romania", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 337, - "TextLength": 43, - "Tokens": [ - { - "TextStartIndex": 337, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 122.5434, 19.1935, 7.386] - ] - }, - { - "TextStartIndex": 343, - "TextLength": 6, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 143.9516, 22.8846, 7.386] - ] - }, - { - "TextStartIndex": 350, - "TextLength": 2, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 168.3126, 4.4293, 7.386] - ] - }, - { - "TextStartIndex": 350, - "TextLength": 2, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 172.7419, 2.2147, 7.386] - ] - }, - { - "TextStartIndex": 353, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 176.433, 12.5496, 7.386] - ] - }, - { - "TextStartIndex": 353, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 188.9826, 2.9528, 7.386] - ] - }, - { - "TextStartIndex": 353, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 191.1973, 24.361, 7.386] - ] - }, - { - "TextStartIndex": 365, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 217.773, 24.361, 7.386] - ] - }, - { - "TextStartIndex": 365, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 242.134, 2.2147, 7.386] - ] - }, - { - "TextStartIndex": 373, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [423.9544, 246.5633, 28.7903, 7.386] - ] - } - ] - }, - "DerivedFields": [], - "Confidence": 0.9979634, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "To", - "FieldName": "To", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "Strada Traian Vuia 149-151, Cluj-Napoca 400397, Romania", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 391, - "TextLength": 55, - "Tokens": [ - { - "TextStartIndex": 391, - "TextLength": 6, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 123.2816, 22.1464, 7.3859] - ] - }, - { - "TextStartIndex": 398, - "TextLength": 6, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 146.9045, 19.9318, 7.3859] - ] - }, - { - "TextStartIndex": 405, - "TextLength": 4, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 168.3126, 15.5025, 7.3859] - ] - }, - { - "TextStartIndex": 410, - "TextLength": 8, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 186.0298, 25.0992, 7.3859] - ] - }, - { - "TextStartIndex": 410, - "TextLength": 8, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 211.8672, 2.2147, 7.3859] - ] - }, - { - "TextStartIndex": 419, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 215.5583, 12.5496, 7.3859] - ] - }, - { - "TextStartIndex": 419, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 228.1079, 2.9528, 7.3859] - ] - }, - { - "TextStartIndex": 419, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 231.0608, 24.361, 7.3859] - ] - }, - { - "TextStartIndex": 431, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 256.8983, 25.0992, 7.3859] - ] - }, - { - "TextStartIndex": 431, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 281.2593, 2.2147, 7.3859] - ] - }, - { - "TextStartIndex": 439, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [ - [443.1579, 285.6886, 28.7903, 7.3859] - ] - } - ] - }, - "DerivedFields": [], - "Confidence": 0.99986005, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "Dummy field", - "FieldName": "Dummy field", - "FieldType": "Text", - "IsMissing": true, - "DataSource": "Automatic", - "Values": [], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 0.9975757, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Value": "", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 0.9975757, - "OperatorConfirmed": true, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Tables": [ - { - "FieldId": "Details", - "FieldName": "Details", - "IsMissing": false, - "DataSource": "Automatic", - "DataVersion": 0, - "OperatorConfirmed": false, - "Values": [ - { - "OperatorConfirmed": true, - "Confidence": 0.9975757, - "OcrConfidence": 1.0, - "Cells": [ - { - "RowIndex": 0, - "ColumnIndex": 0, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": false, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Total", - "UnformattedValue": "Total", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 0, - "ColumnIndex": 1, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": false, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "From", - "UnformattedValue": "From", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 0, - "ColumnIndex": 2, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": false, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "To", - "UnformattedValue": "To", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 0, - "ColumnIndex": 3, - "IsHeader": true, - "IsMissing": false, - "OperatorConfirmed": false, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Dummy field", - "UnformattedValue": "Dummy field", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": -1.0, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 0, - "IsHeader": false, - "IsMissing": false, - "OperatorConfirmed": false, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "66.79 RON", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 620, - "TextLength": 9, - "Tokens": [ - { - "TextStartIndex": 620, - "TextLength": 3, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[304.3017, 479.8387, 19.9318, 6.6474]] - }, - { - "TextStartIndex": 624, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[304.3017, 503.4615, 23.6228, 6.6474]] - } - ] - }, - "DerivedFields": [], - "Confidence": 0.9975757, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 1, - "IsHeader": false, - "IsMissing": false, - "OperatorConfirmed": false, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Aleea Muscel 9, Cluj-Napoca 400347, Romania", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 337, - "TextLength": 43, - "Tokens": [ - { - "TextStartIndex": 337, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 122.5434, 19.1935, 7.386]] - }, - { - "TextStartIndex": 343, - "TextLength": 6, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 143.9516, 22.8846, 7.386]] - }, - { - "TextStartIndex": 350, - "TextLength": 2, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 168.3126, 4.4293, 7.386]] - }, - { - "TextStartIndex": 350, - "TextLength": 2, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 172.7419, 2.2147, 7.386]] - }, - { - "TextStartIndex": 353, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 176.433, 12.5496, 7.386]] - }, - { - "TextStartIndex": 353, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 188.9826, 2.9528, 7.386]] - }, - { - "TextStartIndex": 353, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 191.1973, 24.361, 7.386]] - }, - { - "TextStartIndex": 365, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 217.773, 24.361, 7.386]] - }, - { - "TextStartIndex": 365, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 242.134, 2.2147, 7.386]] - }, - { - "TextStartIndex": 373, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[423.9544, 246.5633, 28.7903, 7.386]] - } - ] - }, - "DerivedFields": [], - "Confidence": 0.9979634, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 2, - "IsHeader": false, - "IsMissing": false, - "OperatorConfirmed": false, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [ - { - "Components": [], - "Value": "Strada Traian Vuia 149-151, Cluj-Napoca 400397, Romania", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 391, - "TextLength": 55, - "Tokens": [ - { - "TextStartIndex": 391, - "TextLength": 6, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 123.2816, 22.1464, 7.3859]] - }, - { - "TextStartIndex": 398, - "TextLength": 6, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 146.9045, 19.9318, 7.3859]] - }, - { - "TextStartIndex": 405, - "TextLength": 4, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 168.3126, 15.5025, 7.3859]] - }, - { - "TextStartIndex": 410, - "TextLength": 8, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 186.0298, 25.0992, 7.3859]] - }, - { - "TextStartIndex": 410, - "TextLength": 8, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 211.8672, 2.2147, 7.3859]] - }, - { - "TextStartIndex": 419, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 215.5583, 12.5496, 7.3859]] - }, - { - "TextStartIndex": 419, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 228.1079, 2.9528, 7.3859]] - }, - { - "TextStartIndex": 419, - "TextLength": 11, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 231.0608, 24.361, 7.3859]] - }, - { - "TextStartIndex": 431, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 256.8983, 25.0992, 7.3859]] - }, - { - "TextStartIndex": 431, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 281.2593, 2.2147, 7.3859]] - }, - { - "TextStartIndex": 439, - "TextLength": 7, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[443.1579, 285.6886, 28.7903, 7.3859]] - } - ] - }, - "DerivedFields": [], - "Confidence": 0.99986005, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - { - "RowIndex": 1, - "ColumnIndex": 3, - "IsHeader": false, - "IsMissing": true, - "OperatorConfirmed": false, - "DataSource": "Automatic", - "DataVersion": 0, - "Values": [] - } - ], - "ColumnInfo": [ - { - "FieldId": "Total", - "FieldName": "Total", - "FieldType": "Text" - }, - { - "FieldId": "From", - "FieldName": "From", - "FieldType": "Text" - }, - { - "FieldId": "To", - "FieldName": "To", - "FieldType": "Text" - }, - { - "FieldId": "Dummy field", - "FieldName": "Dummy field", - "FieldType": "Text" - } - ], - "NumberOfRows": 2, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ] - }, - "ExtractorPayloads": null, - "BusinessRulesResults": null - }, - "projectId": "c390c3ab-358a-80ee-9dcf-9ea0d5ca7bb3", - "projectType": "IXP", - "tag": "live", - "documentTypeId": "00000000-0000-0000-0000-000000000000", - "dataProjection": [ - { - "fieldGroupName": "Details", - "fieldValues": [ - { - "id": "Total", - "name": "Total", - "value": "66.79 RON", - "unformattedValue": "", - "confidence": 0.9975757, - "ocrConfidence": 1.0, - "type": "Text" - }, - { - "id": "From", - "name": "From", - "value": "Aleea Muscel 9, Cluj-Napoca 400347, Romania", - "unformattedValue": "", - "confidence": 0.9979634, - "ocrConfidence": 1.0, - "type": "Text" - }, - { - "id": "To", - "name": "To", - "value": "Strada Traian Vuia 149-151, Cluj-Napoca 400397, Romania", - "unformattedValue": "", - "confidence": 0.99986005, - "ocrConfidence": 1.0, - "type": "Text" - }, - { - "id": "Dummy field", - "name": "Dummy field", - "value": null, - "unformattedValue": null, - "confidence": null, - "ocrConfidence": null, - "type": "Text" - } - ] - } - ] -} diff --git a/tests/sdk/services/tests_data/documents_service/modern_extraction_response.json b/tests/sdk/services/tests_data/documents_service/modern_extraction_response.json deleted file mode 100644 index 141919c16..000000000 --- a/tests/sdk/services/tests_data/documents_service/modern_extraction_response.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "extractionResult": { - "DocumentId": "da303456-7ba3-f011-8e60-6045bd9ba6d0", - "ResultsVersion": 0, - "ResultsDocument": { - "Bounds": { - "StartPage": 0, - "PageCount": 1, - "TextStartIndex": 0, - "TextLength": 629, - "PageRange": "1" - }, - "Language": "eng", - "DocumentGroup": "", - "DocumentCategory": "", - "DocumentTypeId": "2e4e0ad9-72a3-f011-8e61-000d3a395253", - "DocumentTypeName": "receipts", - "DocumentTypeDataVersion": 0, - "DataVersion": 0, - "DocumentTypeSource": "Automatic", - "DocumentTypeField": { - "Components": [], - "Value": "receipts", - "UnformattedValue": "", - "Reference": { - "TextStartIndex": 0, - "TextLength": 0, - "Tokens": [] - }, - "DerivedFields": [], - "Confidence": 1.0, - "OperatorConfirmed": false, - "OcrConfidence": -1.0, - "TextType": "Unknown", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - "Fields": [ - { - "FieldId": "field-2", - "FieldName": "To", - "FieldType": "Text", - "IsMissing": true, - "DataSource": "Automatic", - "Values": [], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "field-1", - "FieldName": "From", - "FieldType": "Text", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "with", - "UnformattedValue": "with", - "Reference": { - "TextStartIndex": 275, - "TextLength": 4, - "Tokens": [ - { - "TextStartIndex": 275, - "TextLength": 4, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[378.08, 96.32, 12.27, 8.54]] - } - ] - }, - "DerivedFields": [], - "Confidence": 0.56592697, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "date", - "FieldName": "Transaction Date", - "FieldType": "Date", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "2025-12-01", - "UnformattedValue": "December", - "Reference": { - "TextStartIndex": 559, - "TextLength": 8, - "Tokens": [ - { - "TextStartIndex": 559, - "TextLength": 8, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[45.09, 464.79, 33.09, 9.87]] - } - ] - }, - "DerivedFields": [ - { - "FieldId": "Year", - "Value": "2025" - }, - { - "FieldId": "Month", - "Value": "12" - }, - { - "FieldId": "Day", - "Value": "1" - } - ], - "Confidence": 0.8514132, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - }, - { - "FieldId": "total", - "FieldName": "Total Amount", - "FieldType": "Number", - "IsMissing": false, - "DataSource": "Automatic", - "Values": [ - { - "Components": [], - "Value": "66.79", - "UnformattedValue": "66.79", - "Reference": { - "TextStartIndex": 582, - "TextLength": 5, - "Tokens": [ - { - "TextStartIndex": 582, - "TextLength": 5, - "Page": 0, - "PageWidth": 595.0, - "PageHeight": 842.0, - "Boxes": [[143.28, 500.81, 26.95, 14.68]] - } - ] - }, - "DerivedFields": [ - { - "FieldId": "Value", - "Value": "66.79" - } - ], - "Confidence": 0.9245848, - "OperatorConfirmed": false, - "OcrConfidence": 1.0, - "TextType": "Text", - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "DataVersion": 0, - "OperatorConfirmed": false, - "ValidatorNotes": "", - "ValidatorNotesInfo": "" - } - ], - "Tables": [] - }, - "ExtractorPayloads": null, - "BusinessRulesResults": null - } -} diff --git a/tests/tracing/test_span_utils.py b/tests/tracing/test_span_utils.py deleted file mode 100644 index 04c05809f..000000000 --- a/tests/tracing/test_span_utils.py +++ /dev/null @@ -1,429 +0,0 @@ -import json -import os -from datetime import datetime -from unittest.mock import Mock, patch - -import pytest -from opentelemetry.sdk.trace import Span as OTelSpan -from opentelemetry.trace import SpanContext, StatusCode - -from uipath.tracing._utils import UiPathSpan, _SpanUtils - - -class TestNormalizeIds: - """Tests for OTEL ID normalization functions.""" - - def test_normalize_trace_id_from_hex(self): - """Test normalizing a 32-char hex trace ID.""" - trace_id = "1234567890abcdef1234567890abcdef" - result = _SpanUtils.normalize_trace_id(trace_id) - assert result == "1234567890abcdef1234567890abcdef" - - def test_normalize_trace_id_from_uuid(self): - """Test normalizing a UUID format trace ID to hex.""" - trace_id = "12345678-90ab-cdef-1234-567890abcdef" - result = _SpanUtils.normalize_trace_id(trace_id) - assert result == "1234567890abcdef1234567890abcdef" - - def test_normalize_trace_id_uppercase(self): - """Test normalizing uppercase hex to lowercase.""" - trace_id = "1234567890ABCDEF1234567890ABCDEF" - result = _SpanUtils.normalize_trace_id(trace_id) - assert result == "1234567890abcdef1234567890abcdef" - - def test_normalize_trace_id_invalid_length(self): - """Test that invalid length raises ValueError.""" - with pytest.raises(ValueError, match="Invalid trace ID format"): - _SpanUtils.normalize_trace_id("1234") - - def test_normalize_span_id_from_hex(self): - """Test normalizing a 16-char hex span ID.""" - span_id = "1234567890abcdef" - result = _SpanUtils.normalize_span_id(span_id) - assert result == "1234567890abcdef" - - def test_normalize_span_id_from_uuid(self): - """Test normalizing a UUID format span ID (takes last 16 chars).""" - span_id = "00000000-0000-0000-1234-567890abcdef" - result = _SpanUtils.normalize_span_id(span_id) - assert result == "1234567890abcdef" - - def test_normalize_span_id_uppercase(self): - """Test normalizing uppercase hex to lowercase.""" - span_id = "1234567890ABCDEF" - result = _SpanUtils.normalize_span_id(span_id) - assert result == "1234567890abcdef" - - def test_normalize_span_id_invalid_length(self): - """Test that invalid length raises ValueError.""" - with pytest.raises(ValueError, match="Invalid span ID format"): - _SpanUtils.normalize_span_id("1234") - - -class TestSpanUtils: - @patch.dict( - os.environ, - { - "UIPATH_ORGANIZATION_ID": "test-org", - "UIPATH_TENANT_ID": "test-tenant", - "UIPATH_FOLDER_KEY": "test-folder", - "UIPATH_PROCESS_UUID": "test-process", - "UIPATH_JOB_KEY": "test-job", - }, - ) - def test_otel_span_to_uipath_span(self): - # Create a mock OTel span - mock_span = Mock(spec=OTelSpan) - - # Set span context - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) - mock_span.get_span_context.return_value = mock_context - - # Set span properties - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - mock_span.attributes = { - "key1": "value1", - "key2": 123, - "span_type": "CustomSpanType", - } - mock_span.events = [] - mock_span.links = [] - - # Set times - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 # 1ms later - - # Convert to UiPath span - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - - # Verify the conversion - assert isinstance(uipath_span, UiPathSpan) - assert uipath_span.name == "test-span" - assert uipath_span.status == 1 # OK - assert uipath_span.span_type == "CustomSpanType" - - # Verify IDs are in OTEL hex format - assert uipath_span.trace_id == "123456789abcdef0123456789abcdef0" # 32-char hex - assert uipath_span.id == "0123456789abcdef" # 16-char hex - assert uipath_span.parent_id is None - - # Verify attributes - attributes_value = uipath_span.attributes - attributes = ( - json.loads(attributes_value) - if isinstance(attributes_value, str) - else attributes_value - ) - assert attributes["key1"] == "value1" - assert attributes["key2"] == 123 - - # Test with error status - mock_span.status.description = "Test error description" - mock_span.status.status_code = StatusCode.ERROR - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - assert uipath_span.status == 2 # Error - - @patch.dict( - os.environ, - { - "UIPATH_ORGANIZATION_ID": "test-org", - "UIPATH_TENANT_ID": "test-tenant", - }, - ) - def test_otel_span_to_uipath_span_optimized_path(self): - """Test the optimized path where attributes are kept as dict.""" - # Create a mock OTel span - mock_span = Mock(spec=OTelSpan) - - # Set span context - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) - mock_span.get_span_context.return_value = mock_context - - # Set span properties - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - mock_span.attributes = { - "key1": "value1", - "key2": 123, - } - mock_span.events = [] - mock_span.links = [] - - # Set times - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 - - # Test optimized path: serialize_attributes=False - uipath_span = _SpanUtils.otel_span_to_uipath_span( - mock_span, serialize_attributes=False - ) - - # Verify attributes is a dict (not JSON string) - assert isinstance(uipath_span.attributes, dict) - assert uipath_span.attributes["key1"] == "value1" - assert uipath_span.attributes["key2"] == 123 - - # Test to_dict with serialize_attributes=False - span_dict = uipath_span.to_dict(serialize_attributes=False) - assert isinstance(span_dict["Attributes"], dict) - assert span_dict["Attributes"]["key1"] == "value1" - - # Test to_dict with serialize_attributes=True - span_dict_serialized = uipath_span.to_dict(serialize_attributes=True) - assert isinstance(span_dict_serialized["Attributes"], str) - attrs = json.loads(span_dict_serialized["Attributes"]) - assert attrs["key1"] == "value1" - assert attrs["key2"] == 123 - - @patch.dict(os.environ, {"UIPATH_TRACE_ID": "00000000-0000-4000-8000-000000000000"}) - def test_otel_span_to_uipath_span_with_env_trace_id_uuid_format(self): - """Test that UUID format UIPATH_TRACE_ID is normalized to hex.""" - # Create a mock OTel span - mock_span = Mock(spec=OTelSpan) - - # Set span context - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext( - trace_id=trace_id, - span_id=span_id, - is_remote=False, - ) - mock_span.get_span_context.return_value = mock_context - - # Set span properties - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - mock_span.attributes = {} - mock_span.events = [] - mock_span.links = [] - - # Set times - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 # 1ms later - - # Convert to UiPath span - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - - # Verify the trace ID is normalized to 32-char hex format - assert uipath_span.trace_id == "00000000000040008000000000000000" - - @patch.dict(os.environ, {"UIPATH_TRACE_ID": "1234567890abcdef1234567890abcdef"}) - def test_otel_span_to_uipath_span_with_env_trace_id_hex_format(self): - """Test that hex format UIPATH_TRACE_ID is used directly.""" - # Create a mock OTel span - mock_span = Mock(spec=OTelSpan) - - # Set span context - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext( - trace_id=trace_id, - span_id=span_id, - is_remote=False, - ) - mock_span.get_span_context.return_value = mock_context - - # Set span properties - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - mock_span.attributes = {} - mock_span.events = [] - mock_span.links = [] - - # Set times - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 # 1ms later - - # Convert to UiPath span - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - - # Verify the trace ID is used as-is (lowercase) - assert uipath_span.trace_id == "1234567890abcdef1234567890abcdef" - - @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) - def test_uipath_span_includes_execution_type(self): - """Test that executionType from attributes becomes top-level ExecutionType.""" - mock_span = Mock(spec=OTelSpan) - - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) - mock_span.get_span_context.return_value = mock_context - - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - mock_span.attributes = {"executionType": 0} - mock_span.events = [] - mock_span.links = [] - - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 - - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - span_dict = uipath_span.to_dict() - - assert span_dict["ExecutionType"] == 0 - assert uipath_span.execution_type == 0 - - @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) - def test_uipath_span_includes_agent_version(self): - """Test that agentVersion from attributes becomes top-level AgentVersion.""" - mock_span = Mock(spec=OTelSpan) - - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) - mock_span.get_span_context.return_value = mock_context - - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - mock_span.attributes = {"agentVersion": "2.0.0"} - mock_span.events = [] - mock_span.links = [] - - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 - - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - span_dict = uipath_span.to_dict() - - assert span_dict["AgentVersion"] == "2.0.0" - assert uipath_span.agent_version == "2.0.0" - - @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) - def test_uipath_span_execution_type_and_agent_version_both(self): - """Test that both executionType and agentVersion are extracted together.""" - mock_span = Mock(spec=OTelSpan) - - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) - mock_span.get_span_context.return_value = mock_context - - mock_span.name = "Agent run - Agent" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - mock_span.attributes = {"executionType": 1, "agentVersion": "1.0.0"} - mock_span.events = [] - mock_span.links = [] - - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 - - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - span_dict = uipath_span.to_dict() - - assert span_dict["ExecutionType"] == 1 - assert span_dict["AgentVersion"] == "1.0.0" - - @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) - def test_uipath_span_missing_execution_type_and_agent_version(self): - """Test that missing executionType and agentVersion default to None.""" - mock_span = Mock(spec=OTelSpan) - - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) - mock_span.get_span_context.return_value = mock_context - - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - mock_span.attributes = {"someOtherAttr": "value"} - mock_span.events = [] - mock_span.links = [] - - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 - - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - span_dict = uipath_span.to_dict() - - assert span_dict["ExecutionType"] is None - assert span_dict["AgentVersion"] is None - - @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) - def test_uipath_span_source_defaults_to_robots(self): - """Test that Source defaults to 4 (Robots) and ignores attributes.source.""" - mock_span = Mock(spec=OTelSpan) - - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) - mock_span.get_span_context.return_value = mock_context - - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - # source in attributes should NOT override top-level Source - mock_span.attributes = {"source": "runtime"} - mock_span.events = [] - mock_span.links = [] - - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 - - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - span_dict = uipath_span.to_dict() - - # Top-level Source should be 4 (Robots), string "runtime" is ignored - assert uipath_span.source == 4 - assert span_dict["Source"] == 4 - - # attributes.source string should still be in Attributes JSON - attrs = json.loads(span_dict["Attributes"]) - assert attrs["source"] == "runtime" - - @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) - def test_uipath_span_source_override_with_uipath_source(self): - """Test that uipath.source attribute overrides default (for low-code agents).""" - mock_span = Mock(spec=OTelSpan) - - trace_id = 0x123456789ABCDEF0123456789ABCDEF0 - span_id = 0x0123456789ABCDEF - mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) - mock_span.get_span_context.return_value = mock_context - - mock_span.name = "test-span" - mock_span.parent = None - mock_span.status.status_code = StatusCode.OK - # uipath.source=1 (Agents) overrides default of 4 (Robots) - mock_span.attributes = {"uipath.source": 1, "source": "runtime"} - mock_span.events = [] - mock_span.links = [] - - current_time_ns = int(datetime.now().timestamp() * 1e9) - mock_span.start_time = current_time_ns - mock_span.end_time = current_time_ns + 1000000 - - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - span_dict = uipath_span.to_dict() - - # uipath.source overrides - low-code agents use 1 (Agents) - assert uipath_span.source == 1 - assert span_dict["Source"] == 1 - - # String source still in Attributes JSON - attrs = json.loads(span_dict["Attributes"]) - assert attrs["source"] == "runtime" diff --git a/uv.lock b/uv.lock index ca928b78e..4d82d61a5 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.11" +[manifest] +overrides = [{ name = "uipath-core", specifier = "==0.5.1.dev1000450221", index = "https://test.pypi.org/simple/" }] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -2531,7 +2534,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.8.46" +version = "2.9.0" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2551,6 +2554,7 @@ dependencies = [ { name = "tenacity" }, { name = "truststore" }, { name = "uipath-core" }, + { name = "uipath-platform" }, { name = "uipath-runtime" }, ] @@ -2601,7 +2605,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.0,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.0,<0.6.0", index = "https://test.pypi.org/simple/" }, + { name = "uipath-platform", specifier = "==0.0.1.dev1000020013", index = "https://test.pypi.org/simple/" }, { name = "uipath-runtime", specifier = ">=0.9.0,<0.10.0" }, ] @@ -2636,16 +2641,32 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } +version = "0.5.1.dev1000450221" +source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/c2/afdf9b6500e5a026c4921dfce2c6f5c586b0114c9d006fd02b31a252c238/uipath_core-0.5.0.tar.gz", hash = "sha256:8035335a1b548475cca7dc99621be644fe0b63f8791c0f01fbb911295cefb143", size = 116691, upload-time = "2026-02-18T21:04:32.815Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/8b/88/57dc9b7fda6c3c47b86a497e4edacd6264ebf9d6320dcd2ae1eb4e64b27f/uipath_core-0.5.1.dev1000450221.tar.gz", hash = "sha256:2cf7c29049846764d0c593716df114fd3d628d1c7195f8836f75c6686ed22690", size = 123627, upload-time = "2026-02-20T09:48:21.027Z" } +wheels = [ + { url = "https://test-files.pythonhosted.org/packages/bc/aa/c0d55138f3d72d414d57a833a6ef5b881a20b3701be1a6614b87a093456a/uipath_core-0.5.1.dev1000450221-py3-none-any.whl", hash = "sha256:f2f7d992a08b45f60913d2bf283964f318d87a7befa5f47e26856580a70f5ea7", size = 44916, upload-time = "2026-02-20T09:48:19.477Z" }, +] + +[[package]] +name = "uipath-platform" +version = "0.0.1.dev1000020013" +source = { registry = "https://test.pypi.org/simple/" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic-function-models" }, + { name = "tenacity" }, + { name = "truststore" }, + { name = "uipath-core" }, +] +sdist = { url = "https://test-files.pythonhosted.org/packages/62/a7/7083c7640659bffdbeecfbd6284b87c26fa245f9007d5597abf101f22cd2/uipath_platform-0.0.1.dev1000020013.tar.gz", hash = "sha256:f9d50903a9219230d0656f69d65194d076ff196cbe00a47fb22a7a454794481b", size = 247372, upload-time = "2026-02-20T12:11:18.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/b2/fa4b2c77f9fe8c794e1d47083654a32b9e0843e4e353f0df5650b719d189/uipath_core-0.5.0-py3-none-any.whl", hash = "sha256:ae8eb511a8228bdf0b42561eae772404e05431623c1be3cade16d5639fca9cac", size = 39403, upload-time = "2026-02-18T21:04:30.987Z" }, + { url = "https://test-files.pythonhosted.org/packages/1d/a4/519279eb239eeb96a034b160de520b6dc81d362cef3bfe7ef59aa9ea302b/uipath_platform-0.0.1.dev1000020013-py3-none-any.whl", hash = "sha256:1107df0b1299f35df973bfbf40900e076d2a22d14d5a8d1d32b23cd41fcb8556", size = 148435, upload-time = "2026-02-20T12:11:20.258Z" }, ] [[package]] From 8c2266222c366721cc6b874d7766bd131b6b242e Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Fri, 20 Feb 2026 14:39:56 +0200 Subject: [PATCH 2/5] wip: uipath-platform --- src/uipath/agent/models/agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uipath/agent/models/agent.py b/src/uipath/agent/models/agent.py index 13bfba3de..9a9f94a66 100644 --- a/src/uipath/agent/models/agent.py +++ b/src/uipath/agent/models/agent.py @@ -31,13 +31,13 @@ SpecificFieldsSelector, UniversalRule, ) - -from uipath.agent.models._legacy import normalize_legacy_format from uipath.platform.connections import Connection from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, ) +from uipath.agent.models._legacy import normalize_legacy_format + EMPTY_SCHEMA = {"type": "object", "properties": {}} From 14aa0b3210cb5cbbcf7969fcfeb7337c6ac0b576 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Fri, 20 Feb 2026 15:30:42 +0200 Subject: [PATCH 3/5] wip: uipath-platform --- pyproject.toml | 10 +- src/uipath/_cli/_utils/_common.py | 7 +- src/uipath/_cli/_utils/_studio_project.py | 11 +- src/uipath/_cli/cli_debug.py | 3 +- src/uipath/_cli/cli_eval.py | 5 +- src/uipath/_cli/cli_run.py | 3 +- src/uipath/_utils/__init__.py | 3 +- src/uipath/_utils/_bindings.py | 268 ------------------ ...test_legacy_context_precision_evaluator.py | 1 + .../test_legacy_faithfulness_evaluator.py | 1 + tests/evaluators/test_evaluator_methods.py | 217 +++++++------- .../test_resource_overrides.py | 9 +- tests/sdk/test_bindings.py | 7 +- uv.lock | 16 +- 14 files changed, 142 insertions(+), 419 deletions(-) delete mode 100644 src/uipath/_utils/_bindings.py diff --git a/pyproject.toml b/pyproject.toml index 0d658fd8e..4dc7e9df2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "mermaid-builder==0.0.3", "graphtty==0.1.8", "applicationinsights>=0.11.10", - "uipath-platform==0.0.1.dev1000020013", + "uipath-platform==0.0.1.dev1000020014", ] classifiers = [ "Intended Audience :: Developers", @@ -132,9 +132,9 @@ init_typed = true warn_required_dynamic_aliases = true [tool.pytest.ini_options] -testpaths = ["tests"] -python_files = "test_*.py" -addopts = "-ra -q --cov" +#testpaths = ["tests"] +#python_files = "test_*.py" +#addopts = "-ra -q --cov" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" @@ -156,5 +156,5 @@ uipath-core = { index = "testpypi" } [tool.uv] override-dependencies = [ - "uipath-core==0.5.1.dev1000450221" + "uipath-core==0.5.1.dev1000450222", ] diff --git a/src/uipath/_cli/_utils/_common.py b/src/uipath/_cli/_utils/_common.py index 8fbb660ad..96ce64d8e 100644 --- a/src/uipath/_cli/_utils/_common.py +++ b/src/uipath/_cli/_utils/_common.py @@ -7,9 +7,12 @@ from urllib.parse import urlparse import click -from uipath.platform.common import UiPathConfig +from uipath.platform.common import ( + ResourceOverwrite, + ResourceOverwriteParser, + UiPathConfig, +) -from ..._utils._bindings import ResourceOverwrite, ResourceOverwriteParser from ..._utils.constants import ENV_UIPATH_ACCESS_TOKEN from ..spinner import Spinner from ._console import ConsoleLogger diff --git a/src/uipath/_cli/_utils/_studio_project.py b/src/uipath/_cli/_utils/_studio_project.py index 0e4ffc439..c37138525 100644 --- a/src/uipath/_cli/_utils/_studio_project.py +++ b/src/uipath/_cli/_utils/_studio_project.py @@ -8,8 +8,14 @@ import click from pydantic import BaseModel, ConfigDict, Field, field_validator +from uipath.platform import UiPath +from uipath.platform.common import ( + ResourceOverwrite, + ResourceOverwriteParser, + UiPathConfig, +) +from uipath.platform.errors import EnrichedException -from uipath._utils._bindings import ResourceOverwrite, ResourceOverwriteParser from uipath._utils.constants import ( ENV_TENANT_ID, HEADER_SW_LOCK_KEY, @@ -17,9 +23,6 @@ PYTHON_CONFIGURATION_FILE, STUDIO_METADATA_FILE, ) -from uipath.platform import UiPath -from uipath.platform.common import UiPathConfig -from uipath.platform.errors import EnrichedException from uipath.tracing import traced logger = logging.getLogger(__name__) diff --git a/src/uipath/_cli/cli_debug.py b/src/uipath/_cli/cli_debug.py index 21c04f799..1c99d584f 100644 --- a/src/uipath/_cli/cli_debug.py +++ b/src/uipath/_cli/cli_debug.py @@ -6,7 +6,7 @@ import click from uipath.core.tracing import UiPathTraceManager -from uipath.platform.common import UiPathConfig +from uipath.platform.common import ResourceOverwritesContext, UiPathConfig from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeContext, @@ -32,7 +32,6 @@ ) from uipath._cli._utils._debug import setup_debugging from uipath._cli._utils._studio_project import StudioClient -from uipath._utils._bindings import ResourceOverwritesContext from uipath.tracing import LiveTrackingSpanProcessor, LlmOpsHttpExporter from ._utils._console import ConsoleLogger diff --git a/src/uipath/_cli/cli_eval.py b/src/uipath/_cli/cli_eval.py index 84b59af9c..783c9d1b4 100644 --- a/src/uipath/_cli/cli_eval.py +++ b/src/uipath/_cli/cli_eval.py @@ -7,6 +7,8 @@ import click from uipath.core.tracing import UiPathTraceManager +from uipath.platform.chat import set_llm_concurrency +from uipath.platform.common import ResourceOverwritesContext, UiPathConfig from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactoryRegistry, @@ -27,10 +29,7 @@ from uipath._cli._utils._studio_project import StudioClient from uipath._cli.middlewares import Middlewares from uipath._events._event_bus import EventBus -from uipath._utils._bindings import ResourceOverwritesContext from uipath.eval._helpers import auto_discover_entrypoint -from uipath.platform.chat import set_llm_concurrency -from uipath.platform.common import UiPathConfig from uipath.telemetry._track import flush_events from uipath.tracing import ( JsonLinesFileExporter, diff --git a/src/uipath/_cli/cli_run.py b/src/uipath/_cli/cli_run.py index 317d7653a..142b10519 100644 --- a/src/uipath/_cli/cli_run.py +++ b/src/uipath/_cli/cli_run.py @@ -2,6 +2,7 @@ import click from uipath.core.tracing import UiPathTraceManager +from uipath.platform.common import ResourceOverwritesContext, UiPathConfig from uipath.runtime import ( UiPathExecuteOptions, UiPathRuntimeFactoryProtocol, @@ -20,8 +21,6 @@ from uipath._cli._debug._bridge import ConsoleDebugBridge from uipath._cli._utils._common import read_resource_overwrites_from_file from uipath._cli._utils._debug import setup_debugging -from uipath._utils._bindings import ResourceOverwritesContext -from uipath.platform.common import UiPathConfig from uipath.tracing import ( JsonLinesFileExporter, LiveTrackingSpanProcessor, diff --git a/src/uipath/_utils/__init__.py b/src/uipath/_utils/__init__.py index 934dd129a..3dba85eeb 100644 --- a/src/uipath/_utils/__init__.py +++ b/src/uipath/_utils/__init__.py @@ -1,4 +1,5 @@ -from ._bindings import resource_override +from uipath.platform.common import resource_override + from ._endpoint import Endpoint from ._logs import setup_logging from ._request_override import header_folder diff --git a/src/uipath/_utils/_bindings.py b/src/uipath/_utils/_bindings.py deleted file mode 100644 index 01ff732a4..000000000 --- a/src/uipath/_utils/_bindings.py +++ /dev/null @@ -1,268 +0,0 @@ -import functools -import inspect -import logging -from abc import ABC, abstractmethod -from contextvars import ContextVar, Token -from typing import ( - Annotated, - Any, - Callable, - Coroutine, - Literal, - Optional, - TypeVar, - Union, -) - -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter - -logger = logging.getLogger(__name__) - -T = TypeVar("T") - - -class ResourceOverwrite(BaseModel, ABC): - """Abstract base class for resource overwrites. - - Subclasses must implement properties to provide resource and folder identifiers - appropriate for their resource type. - """ - - model_config = ConfigDict(populate_by_name=True) - - @property - @abstractmethod - def resource_identifier(self) -> str: - """The identifier used to reference this resource.""" - pass - - @property - @abstractmethod - def folder_identifier(self) -> str: - """The folder location identifier for this resource.""" - pass - - -class GenericResourceOverwrite(ResourceOverwrite): - resource_type: Literal["process", "index", "app", "asset", "bucket", "mcpServer"] - name: str = Field(alias="name") - folder_path: str = Field(alias="folderPath") - - @property - def resource_identifier(self) -> str: - return self.name - - @property - def folder_identifier(self) -> str: - return self.folder_path - - -class ConnectionResourceOverwrite(ResourceOverwrite): - resource_type: Literal["connection"] - # In eval context, studio web provides "ConnectionId". - connection_id: str = Field( - alias="connectionId", - validation_alias=AliasChoices("connectionId", "ConnectionId"), - ) - folder_key: str = Field(alias="folderKey") - - model_config = ConfigDict( - populate_by_name=True, - extra="ignore", - ) - - @property - def resource_identifier(self) -> str: - return self.connection_id - - @property - def folder_identifier(self) -> str: - return self.folder_key - - -ResourceOverwriteUnion = Annotated[ - Union[GenericResourceOverwrite, ConnectionResourceOverwrite], - Field(discriminator="resource_type"), -] - - -class ResourceOverwriteParser: - """Parser for resource overwrite configurations. - - Handles parsing of resource overwrites from key-value pairs where the key - contains the resource type prefix (e.g., "process.name", "connection.key"). - """ - - _adapter: TypeAdapter[ResourceOverwriteUnion] = TypeAdapter(ResourceOverwriteUnion) - - @classmethod - def parse(cls, key: str, value: dict[str, Any]) -> ResourceOverwrite: - """Parse a resource overwrite from a key-value pair. - - Extracts the resource type from the key prefix and injects it into the value - for discriminated union validation. - - Args: - key: The resource key (e.g., "process.MyProcess", "connection.abc-123") - value: The resource data dictionary - - Returns: - The appropriate ResourceOverwrite subclass instance - """ - resource_type = key.split(".")[0] - value_with_type = {"resource_type": resource_type, **value} - return cls._adapter.validate_python(value_with_type) - - -_resource_overwrites: ContextVar[Optional[dict[str, ResourceOverwrite]]] = ContextVar( - "resource_overwrites", default=None -) - - -class ResourceOverwritesContext: - def __init__( - self, - get_overwrites_callable: Callable[ - [], Coroutine[Any, Any, dict[str, ResourceOverwrite]] - ], - ): - self.get_overwrites_callable = get_overwrites_callable - self._token: Optional[Token[Optional[dict[str, ResourceOverwrite]]]] = None - self.overwrites_count = 0 - - async def __aenter__(self) -> "ResourceOverwritesContext": - existing = _resource_overwrites.get() - if existing is not None: - logger.warning( - "Entering ResourceOverwritesContext while another context is already active (%d existing overwrite(s))", - len(existing), - ) - overwrites = await self.get_overwrites_callable() - self._token = _resource_overwrites.set(overwrites) - self.overwrites_count = len(overwrites) - if overwrites: - logger.info( - "Resource overwrites context entered: %d overwrite(s) loaded, keys=%s", - len(overwrites), - list(overwrites.keys()), - ) - else: - logger.debug("Resource overwrites context entered: no overwrites loaded") - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - if self._token: - logger.debug( - "Resource overwrites context exited: %d overwrite(s) cleared", - self.overwrites_count, - ) - _resource_overwrites.reset(self._token) - - -def resource_override( - resource_type: str, - resource_identifier: str = "name", - folder_identifier: str = "folder_path", -) -> Callable[..., Any]: - """Decorator for applying resource overrides for an overridable resource. - - It checks the current ContextVar to identify the requested overrides and, if any key matches, it invokes the decorated function - with the extracted resource and folder identifiers. - - Args: - resource_type: Type of resource to check for overrides (e.g., "asset", "bucket") - resource_identifier: Key name for the resource ID in override data (default: "name") - folder_identifier: Key name for the folder path in override data (default: "folder_path") - - Returns: - Decorated function that receives overridden resource identifiers when applicable - - Note: - Must be applied BEFORE the @traced decorator to ensure proper execution order. - """ - - def decorator(func: Callable[..., Any]): - sig = inspect.signature(func) - - def process_args(args, kwargs) -> dict[str, Any]: - """Process arguments and apply resource overrides if applicable.""" - # convert both args and kwargs to single dict - bound = sig.bind_partial(*args, **kwargs) - bound.apply_defaults() - all_args = dict(bound.arguments) - if ( - "kwargs" in sig.parameters - and sig.parameters["kwargs"].kind == inspect.Parameter.VAR_KEYWORD - ): - extra_kwargs = all_args.pop("kwargs", {}) - all_args.update(extra_kwargs) - - # Get overwrites from context variable - context_overwrites = _resource_overwrites.get() - - if context_overwrites is not None: - resource_identifier_value = all_args.get(resource_identifier) - folder_identifier_value = all_args.get(folder_identifier) - - key = f"{resource_type}.{resource_identifier_value}" - # try to apply folder path, fallback to resource_type.resource_name - if folder_identifier_value: - key = ( - f"{key}.{folder_identifier_value}" - if f"{key}.{folder_identifier_value}" in context_overwrites - else key - ) - - matched_overwrite = context_overwrites.get(key) - - # Apply the matched overwrite - if matched_overwrite is not None: - old_resource = all_args.get(resource_identifier) - old_folder = all_args.get(folder_identifier) - if resource_identifier in sig.parameters: - all_args[resource_identifier] = ( - matched_overwrite.resource_identifier - ) - if folder_identifier in sig.parameters: - all_args[folder_identifier] = ( - matched_overwrite.folder_identifier - ) - logger.debug( - "Resource overwrite applied for %s on %s: %s='%s' -> '%s', %s='%s' -> '%s'", - resource_type, - func.__name__, - resource_identifier, - old_resource, - matched_overwrite.resource_identifier, - folder_identifier, - old_folder, - matched_overwrite.folder_identifier, - ) - else: - logger.debug( - "No resource overwrite matched for %s key='%s' on %s", - resource_type, - key, - func.__name__, - ) - - return all_args - - if inspect.iscoroutinefunction(func): - - @functools.wraps(func) - async def async_wrapper(*args, **kwargs): - all_args = process_args(args, kwargs) - return await func(**all_args) - - return async_wrapper - else: - - @functools.wraps(func) - def wrapper(*args, **kwargs): - all_args = process_args(args, kwargs) - return func(**all_args) - - return wrapper - - return decorator diff --git a/tests/cli/evaluators/test_legacy_context_precision_evaluator.py b/tests/cli/evaluators/test_legacy_context_precision_evaluator.py index d6d425bee..19723eb25 100644 --- a/tests/cli/evaluators/test_legacy_context_precision_evaluator.py +++ b/tests/cli/evaluators/test_legacy_context_precision_evaluator.py @@ -49,6 +49,7 @@ def evaluator_with_mocked_llm(): **_make_base_params(), model="gpt-4.1-2025-04-14", ) + evaluator.llm = AsyncMock() return evaluator diff --git a/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py b/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py index c95fd6c57..68d8b407b 100644 --- a/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py +++ b/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py @@ -49,6 +49,7 @@ def evaluator_with_mocked_llm(): **_make_base_params(), model="gpt-4.1-2025-04-14", ) + evaluator.llm = AsyncMock() return evaluator diff --git a/tests/evaluators/test_evaluator_methods.py b/tests/evaluators/test_evaluator_methods.py index 772a4af3c..d3a285f87 100644 --- a/tests/evaluators/test_evaluator_methods.py +++ b/tests/evaluators/test_evaluator_methods.py @@ -803,12 +803,6 @@ async def test_llm_judge_basic_evaluation( self, sample_agent_execution: AgentExecution, mocker: MockerFixture ) -> None: """Test LLM as judge basic evaluation functionality with function calling.""" - # Mock the UiPath constructor to avoid authentication - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - - # Mock the chat completions response with tool call (function calling approach) mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -824,13 +818,17 @@ async def test_llm_judge_basic_evaluation( ) ] - # Make chat_completions an async method async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - mock_llm.chat_completions = mock_chat_completions + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = mock_chat_completions - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) config = { "name": "LlmJudgeTest", @@ -902,12 +900,6 @@ async def test_llm_judge_validate_and_evaluate_criteria( self, sample_agent_execution: AgentExecution, mocker: MockerFixture ) -> None: """Test LLM judge using validate_and_evaluate_criteria with function calling.""" - # Mock the UiPath constructor to avoid authentication - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - - # Mock tool call for function calling approach mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -923,14 +915,17 @@ async def test_llm_judge_validate_and_evaluate_criteria( ) ] - # Make chat_completions an async method async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - mock_llm.chat_completions = mock_chat_completions + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = mock_chat_completions - # Mock the UiPath import and constructor - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) config = { "name": "LlmJudgeTest", @@ -960,12 +955,6 @@ async def test_llm_trajectory_basic_evaluation( self, sample_agent_execution: AgentExecution, mocker: MockerFixture ) -> None: """Test LLM trajectory judge basic evaluation functionality with function calling.""" - # Mock the UiPath constructor to avoid authentication - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - - # Mock tool call for function calling approach mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -981,14 +970,17 @@ async def test_llm_trajectory_basic_evaluation( ) ] - # Make chat_completions an async method async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - mock_llm.chat_completions = mock_chat_completions + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = mock_chat_completions - # Mock the UiPath import and constructor - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) config = { "name": "LlmTrajectoryTest", @@ -1015,12 +1007,6 @@ async def test_llm_trajectory_validate_and_evaluate_criteria( self, sample_agent_execution: AgentExecution, mocker: MockerFixture ) -> None: """Test LLM trajectory judge using validate_and_evaluate_criteria with function calling.""" - # Mock the UiPath constructor to avoid authentication - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - - # Mock tool call for function calling approach mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -1036,14 +1022,17 @@ async def test_llm_trajectory_validate_and_evaluate_criteria( ) ] - # Make chat_completions an async method async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - mock_llm.chat_completions = mock_chat_completions + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = mock_chat_completions - # Mock the UiPath import and constructor - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) config = { "name": "LlmTrajectoryTest", @@ -1288,12 +1277,6 @@ async def test_llm_judge_output_evaluator_justification( self, sample_agent_execution: AgentExecution, mocker: MockerFixture ) -> None: """Test that LLMJudgeOutputEvaluator handles str justification correctly with function calling.""" - # Mock the UiPath constructor to avoid authentication - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - - # Mock tool call for function calling approach mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -1309,12 +1292,17 @@ async def test_llm_judge_output_evaluator_justification( ) ] - # Make chat_completions an async method async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - mock_llm.chat_completions = mock_chat_completions - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = mock_chat_completions + + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) config = { "name": "LlmJudgeTest", @@ -1345,13 +1333,6 @@ async def test_llm_judge_trajectory_evaluator_justification( self, sample_agent_execution: AgentExecution, mocker: MockerFixture ) -> None: """Test that LLMJudgeTrajectoryEvaluator handles str justification correctly.""" - # Mock the UiPath constructor to avoid authentication - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - - # Mock the chat completions response with justification using tool_call format - mock_response = mocker.MagicMock() mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -1360,18 +1341,24 @@ async def test_llm_judge_trajectory_evaluator_justification( "justification": "The agent trajectory shows good decision making and follows expected behavior patterns", } + mock_response = mocker.MagicMock() mock_response.choices = [ mocker.MagicMock( message=mocker.MagicMock(content=None, tool_calls=[mock_tool_call]) ) ] - # Make chat_completions an async method async def mock_chat_completions(*args: Any, **kwargs: Any) -> Any: return mock_response - mock_llm.chat_completions = mock_chat_completions - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = mock_chat_completions + + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) config = { "name": "LlmTrajectoryTest", @@ -1492,12 +1479,6 @@ async def test_llm_judge_omits_max_tokens_when_none( self, sample_agent_execution: AgentExecution, mocker: MockerFixture ) -> None: """Test that max_tokens is omitted from API request when None (fixes 400 error).""" - # Mock the UiPath constructor to avoid authentication - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - - # Mock tool call response mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -1516,7 +1497,6 @@ async def test_llm_judge_omits_max_tokens_when_none( mock_response = mocker.MagicMock() mock_response.choices = [mock_choice] - # Capture the request data passed to chat_completions captured_request = {} async def capture_chat_completions(**kwargs: Any) -> Any: @@ -1524,44 +1504,43 @@ async def capture_chat_completions(**kwargs: Any) -> Any: captured_request = kwargs return mock_response - mock_llm.chat_completions = capture_chat_completions + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = capture_chat_completions - # Patch UiPath to return our mock - with mocker.patch("uipath.platform.UiPath", return_value=mock_uipath): - # Create evaluator with max_tokens=None (the default) - config = { - "name": "TestMaxTokensNone", - "model": "gpt-4o-mini-2024-07-18", - "prompt": "Evaluate the output", - # max_tokens is intentionally omitted (defaults to None) - } - evaluator = LLMJudgeOutputEvaluator.model_validate( - {"evaluatorConfig": config, "id": str(uuid.uuid4())} - ) + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) - # Evaluate - result = await evaluator.evaluate( - agent_execution=sample_agent_execution, - evaluation_criteria=OutputEvaluationCriteria(expected_output="42"), - ) + config = { + "name": "TestMaxTokensNone", + "model": "gpt-4o-mini-2024-07-18", + "prompt": "Evaluate the output", + } + evaluator = LLMJudgeOutputEvaluator.model_validate( + {"evaluatorConfig": config, "id": str(uuid.uuid4())} + ) - # Verify max_tokens was NOT included in the request - assert "max_tokens" not in captured_request, ( - "max_tokens should be omitted when None, not passed as None " - "(this was causing 400 errors from LLM Gateway API)" - ) + result = await evaluator.evaluate( + agent_execution=sample_agent_execution, + evaluation_criteria=OutputEvaluationCriteria(expected_output="42"), + ) - # Verify other expected fields ARE included - assert "model" in captured_request - assert "temperature" in captured_request - assert "tools" in captured_request - assert "tool_choice" in captured_request - assert "messages" in captured_request + assert "max_tokens" not in captured_request, ( + "max_tokens should be omitted when None, not passed as None " + "(this was causing 400 errors from LLM Gateway API)" + ) - # Verify evaluation result - assert result.score == 0.8 - assert isinstance(result.details, LLMJudgeJustification) - assert result.details.justification == "Good response" + assert "model" in captured_request + assert "temperature" in captured_request + assert "tools" in captured_request + assert "tool_choice" in captured_request + assert "messages" in captured_request + + assert result.score == 0.8 + assert isinstance(result.details, LLMJudgeJustification) + assert result.details.justification == "Good response" class TestClaude45ModelSupport: @@ -1588,10 +1567,6 @@ async def test_claude_45_evaluator_uses_function_calling( mocker: MockerFixture, ) -> None: """Test that Claude 4.5 evaluators use function calling (tools/tool_choice).""" - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -1614,8 +1589,14 @@ async def capture_chat_completions(**kwargs: Any) -> Any: captured_request = kwargs return mock_response - mock_llm.chat_completions = capture_chat_completions - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = capture_chat_completions + + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) config = { "name": f"Claude45Test-{model_name}", @@ -1659,10 +1640,6 @@ async def test_claude_45_sets_default_max_tokens( mocker: MockerFixture, ) -> None: """Test that Claude 4.5 models get default max_tokens=8000 when not configured.""" - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -1682,8 +1659,14 @@ async def capture_chat_completions(**kwargs: Any) -> Any: captured_request = kwargs return mock_response - mock_llm.chat_completions = capture_chat_completions - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = capture_chat_completions + + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) # No max_tokens in config - should default to 8000 for Claude 4.5 config = { @@ -1712,10 +1695,6 @@ async def test_claude_45_respects_configured_max_tokens( mocker: MockerFixture, ) -> None: """Test that explicitly configured max_tokens overrides the Claude 4.5 default.""" - mock_uipath = mocker.MagicMock() - mock_llm = mocker.MagicMock() - mock_uipath.llm = mock_llm - mock_tool_call = mocker.MagicMock() mock_tool_call.id = "call_1" mock_tool_call.name = "submit_evaluation" @@ -1735,8 +1714,14 @@ async def capture_chat_completions(**kwargs: Any) -> Any: captured_request = kwargs return mock_response - mock_llm.chat_completions = capture_chat_completions - mocker.patch("uipath.platform.UiPath", return_value=mock_uipath) + mock_llm_instance = mocker.MagicMock() + mock_llm_instance.chat_completions = capture_chat_completions + + mocker.patch("uipath.platform.UiPath") + mocker.patch( + "uipath.platform.chat.UiPathLlmChatService", + return_value=mock_llm_instance, + ) config = { "name": "Claude45CustomMaxTokens", diff --git a/tests/resource_overrides/test_resource_overrides.py b/tests/resource_overrides/test_resource_overrides.py index bd4d2ad90..c11be5d44 100644 --- a/tests/resource_overrides/test_resource_overrides.py +++ b/tests/resource_overrides/test_resource_overrides.py @@ -9,10 +9,10 @@ from click.testing import CliRunner from opentelemetry.sdk.trace.export import SpanExporter from pytest_httpx import HTTPXMock +from uipath.platform.common import ResourceOverwriteParser from uipath.runtime import UiPathRuntimeResult from uipath._cli import cli -from uipath._utils._bindings import ResourceOverwriteParser @pytest.fixture @@ -309,7 +309,7 @@ def test_parse_overwrites_with_type_adapter(self, overwrites_data): assert mcp_server.folder_identifier == "Overwritten/MCPServer/Folder" def test_overrides_decorator_should_pop_kwargs_dict_when_present(self): - from uipath._utils import resource_override + from uipath.platform.common import resource_override @resource_override(resource_type="asset") def some_method(a: str, b: str, **kwargs): @@ -513,11 +513,12 @@ async def test_traced_span_shows_overridden_resource_name( ): """Verify that spans show the overridden resource name, not the original value.""" - from uipath._utils import resource_override - from uipath._utils._bindings import ( + from uipath.platform.common import ( GenericResourceOverwrite, ResourceOverwritesContext, + resource_override, ) + from uipath.tracing import traced provider, captured_spans = tracer_provider_with_memory_exporter diff --git a/tests/sdk/test_bindings.py b/tests/sdk/test_bindings.py index 5aaf776bd..a02ff2dbd 100644 --- a/tests/sdk/test_bindings.py +++ b/tests/sdk/test_bindings.py @@ -1,15 +1,14 @@ # type: ignore import pytest - -from uipath._utils import resource_override -from uipath._utils._bindings import ( +from uipath.platform.common import ( ConnectionResourceOverwrite, GenericResourceOverwrite, ResourceOverwriteParser, ResourceOverwritesContext, - _resource_overwrites, + resource_override, ) +from uipath.platform.common._bindings import _resource_overwrites class TestBindingsInference: diff --git a/uv.lock b/uv.lock index 4d82d61a5..7ac633cb0 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [manifest] -overrides = [{ name = "uipath-core", specifier = "==0.5.1.dev1000450221", index = "https://test.pypi.org/simple/" }] +overrides = [{ name = "uipath-core", specifier = "==0.5.1.dev1000450222", index = "https://test.pypi.org/simple/" }] [[package]] name = "aiohappyeyeballs" @@ -2606,7 +2606,7 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", specifier = ">=0.5.0,<0.6.0", index = "https://test.pypi.org/simple/" }, - { name = "uipath-platform", specifier = "==0.0.1.dev1000020013", index = "https://test.pypi.org/simple/" }, + { name = "uipath-platform", specifier = "==0.0.1.dev1000020014", index = "https://test.pypi.org/simple/" }, { name = "uipath-runtime", specifier = ">=0.9.0,<0.10.0" }, ] @@ -2641,21 +2641,21 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.1.dev1000450221" +version = "0.5.1.dev1000450222" source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://test-files.pythonhosted.org/packages/8b/88/57dc9b7fda6c3c47b86a497e4edacd6264ebf9d6320dcd2ae1eb4e64b27f/uipath_core-0.5.1.dev1000450221.tar.gz", hash = "sha256:2cf7c29049846764d0c593716df114fd3d628d1c7195f8836f75c6686ed22690", size = 123627, upload-time = "2026-02-20T09:48:21.027Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/3e/49/04157b027c605b12f4b361ab642557b701c08049c95ce16944c182f7994d/uipath_core-0.5.1.dev1000450222.tar.gz", hash = "sha256:e0ad2fde6e186cf14ccfbdbe56e03a61abcc714237d1589dcb1eab50bb8ef75a", size = 123653, upload-time = "2026-02-20T12:50:51.905Z" } wheels = [ - { url = "https://test-files.pythonhosted.org/packages/bc/aa/c0d55138f3d72d414d57a833a6ef5b881a20b3701be1a6614b87a093456a/uipath_core-0.5.1.dev1000450221-py3-none-any.whl", hash = "sha256:f2f7d992a08b45f60913d2bf283964f318d87a7befa5f47e26856580a70f5ea7", size = 44916, upload-time = "2026-02-20T09:48:19.477Z" }, + { url = "https://test-files.pythonhosted.org/packages/28/57/3ae57e63b38946ba4fd717c489a9008f08fed7ac4fc0ed4d142b562937f5/uipath_core-0.5.1.dev1000450222-py3-none-any.whl", hash = "sha256:27d3ee0fd294ca66dde98d2d3dc378ded4c717d054425a4a988f4c01c49e4880", size = 44971, upload-time = "2026-02-20T12:50:53.292Z" }, ] [[package]] name = "uipath-platform" -version = "0.0.1.dev1000020013" +version = "0.0.1.dev1000020014" source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "httpx" }, @@ -2664,9 +2664,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://test-files.pythonhosted.org/packages/62/a7/7083c7640659bffdbeecfbd6284b87c26fa245f9007d5597abf101f22cd2/uipath_platform-0.0.1.dev1000020013.tar.gz", hash = "sha256:f9d50903a9219230d0656f69d65194d076ff196cbe00a47fb22a7a454794481b", size = 247372, upload-time = "2026-02-20T12:11:18.808Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/af/b3/d95ce88f6d86845b82f92c4b188ead46d90d6ea82ab02ebef4af27f42d36/uipath_platform-0.0.1.dev1000020014.tar.gz", hash = "sha256:7668ce3d6e0e3a3e30b7a123f04727ab506774a08e5b6a5c13bdd76648f0c6f6", size = 247531, upload-time = "2026-02-20T13:27:10.508Z" } wheels = [ - { url = "https://test-files.pythonhosted.org/packages/1d/a4/519279eb239eeb96a034b160de520b6dc81d362cef3bfe7ef59aa9ea302b/uipath_platform-0.0.1.dev1000020013-py3-none-any.whl", hash = "sha256:1107df0b1299f35df973bfbf40900e076d2a22d14d5a8d1d32b23cd41fcb8556", size = 148435, upload-time = "2026-02-20T12:11:20.258Z" }, + { url = "https://test-files.pythonhosted.org/packages/32/03/7b8f64a28fbeeafd2787187cea01c7044b72a2fc962f63b6881f271247a0/uipath_platform-0.0.1.dev1000020014-py3-none-any.whl", hash = "sha256:aff67fb4d8cca7d513cbc424b22ae88c26117c648ed878f6b087406e14956949", size = 148497, upload-time = "2026-02-20T13:27:11.539Z" }, ] [[package]] From 430c4f650ce9430443019562e32afcf32ceac96e Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Fri, 20 Feb 2026 17:48:10 +0200 Subject: [PATCH 4/5] wip: uipath-platform --- pyproject.toml | 8 ++------ src/uipath/_cli/_evals/mocks/llm_mocker.py | 3 ++- src/uipath/tracing/_otel_exporters.py | 2 +- uv.lock | 13 +++++-------- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4dc7e9df2..ddc97edb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.0, <0.6.0", - "uipath-runtime>=0.9.0, <0.10.0", + "uipath-runtime==0.9.0.dev1000950338", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", @@ -153,8 +153,4 @@ explicit = true [tool.uv.sources] uipath-platform = { index = "testpypi" } uipath-core = { index = "testpypi" } - -[tool.uv] -override-dependencies = [ - "uipath-core==0.5.1.dev1000450222", -] +uipath-runtime = { index = "testpypi" } diff --git a/src/uipath/_cli/_evals/mocks/llm_mocker.py b/src/uipath/_cli/_evals/mocks/llm_mocker.py index 92194211a..11f6a6c5e 100644 --- a/src/uipath/_cli/_evals/mocks/llm_mocker.py +++ b/src/uipath/_cli/_evals/mocks/llm_mocker.py @@ -5,7 +5,8 @@ from typing import Any, Callable from pydantic import BaseModel, TypeAdapter -from uipath.core.tracing import _SpanUtils, traced +from uipath.core.tracing import traced +from uipath.platform.common import _SpanUtils from uipath._cli._evals.mocks.types import ( LLMMockingStrategy, diff --git a/src/uipath/tracing/_otel_exporters.py b/src/uipath/tracing/_otel_exporters.py index 15bcbad13..262dee5ef 100644 --- a/src/uipath/tracing/_otel_exporters.py +++ b/src/uipath/tracing/_otel_exporters.py @@ -10,7 +10,7 @@ SpanExporter, SpanExportResult, ) -from uipath.core.tracing import _SpanUtils +from uipath.platform.common import _SpanUtils from uipath._utils._ssl_context import get_httpx_client_kwargs diff --git a/uv.lock b/uv.lock index 7ac633cb0..2320c4874 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.11" -[manifest] -overrides = [{ name = "uipath-core", specifier = "==0.5.1.dev1000450222", index = "https://test.pypi.org/simple/" }] - [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -2607,7 +2604,7 @@ requires-dist = [ { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", specifier = ">=0.5.0,<0.6.0", index = "https://test.pypi.org/simple/" }, { name = "uipath-platform", specifier = "==0.0.1.dev1000020014", index = "https://test.pypi.org/simple/" }, - { name = "uipath-runtime", specifier = ">=0.9.0,<0.10.0" }, + { name = "uipath-runtime", specifier = "==0.9.0.dev1000950338", index = "https://test.pypi.org/simple/" }, ] [package.metadata.requires-dev] @@ -2671,14 +2668,14 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } +version = "0.9.0.dev1000950338" +source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/ce/82c4edfe6fa11807ea99babf7a53afbced4d3a17b3e020dfa6474ee4d73f/uipath_runtime-0.9.0.tar.gz", hash = "sha256:bc24f6f96fe0ad1d8549b16df51607d4e85558afe04abee294c9dff9790ccc96", size = 109587, upload-time = "2026-02-18T22:04:43.65Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/af/e2/9e52ee2e1d8af96539adab50b6d2ba337e10b2a0c35fc97bdea28c8b2c85/uipath_runtime-0.9.0.dev1000950338.tar.gz", hash = "sha256:7a94b126463c7e12aa3e0445b005d973b0f091557b84b4d5a31d74343c3cbb83", size = 137857, upload-time = "2026-02-20T15:45:51.622Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/e7/c8d0bd9316f4a528e36c52971df4dc01f96fbaf716dd62921253c69ab159/uipath_runtime-0.9.0-py3-none-any.whl", hash = "sha256:803010831cdead1d2563eed39025bb555be01626677bb23eecc918981cf93941", size = 41865, upload-time = "2026-02-18T22:04:42.03Z" }, + { url = "https://test-files.pythonhosted.org/packages/7b/a3/f0fdfc20b732e14b3915d2dc74d24949211f9027c70357f02beedaac8af8/uipath_runtime-0.9.0.dev1000950338-py3-none-any.whl", hash = "sha256:da1fa22749058abefda615a6148c2168c2f14a52f4ad6ccd640af0014e96171a", size = 41409, upload-time = "2026-02-20T15:45:50.651Z" }, ] [[package]] From eb7a9f30a89c2926c5ab637f05807dae40ec0328 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Fri, 20 Feb 2026 18:00:40 +0200 Subject: [PATCH 5/5] wip: uipath-platform --- pyproject.toml | 4 ++-- src/uipath/_cli/_chat/_bridge.py | 2 +- src/uipath/_cli/_debug/_bridge.py | 2 +- uv.lock | 22 +++++++++++----------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ddc97edb5..0140b6562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.0, <0.6.0", - "uipath-runtime==0.9.0.dev1000950338", + "uipath-runtime==0.9.0.dev1000950339", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", @@ -23,7 +23,7 @@ dependencies = [ "mermaid-builder==0.0.3", "graphtty==0.1.8", "applicationinsights>=0.11.10", - "uipath-platform==0.0.1.dev1000020014", + "uipath-platform==0.0.1.dev1000020019", ] classifiers = [ "Intended Audience :: Developers", diff --git a/src/uipath/_cli/_chat/_bridge.py b/src/uipath/_cli/_chat/_bridge.py index 26e85a0c6..a454afa17 100644 --- a/src/uipath/_cli/_chat/_bridge.py +++ b/src/uipath/_cli/_chat/_bridge.py @@ -18,7 +18,7 @@ UiPathConversationToolCallConfirmationInterruptStartEvent, UiPathConversationToolCallConfirmationValue, ) -from uipath.runtime import UiPathResumeTrigger +from uipath.core.triggers import UiPathResumeTrigger from uipath.runtime.chat import UiPathChatProtocol from uipath.runtime.context import UiPathRuntimeContext diff --git a/src/uipath/_cli/_debug/_bridge.py b/src/uipath/_cli/_debug/_bridge.py index 05cc83f8a..5ec3a3555 100644 --- a/src/uipath/_cli/_debug/_bridge.py +++ b/src/uipath/_cli/_debug/_bridge.py @@ -11,6 +11,7 @@ from rich.console import Console from rich.tree import Tree from uipath.core.serialization import serialize_object +from uipath.core.triggers import UiPathResumeTriggerType from uipath.runtime import ( UiPathBreakpointResult, UiPathRuntimeContext, @@ -19,7 +20,6 @@ ) from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugQuitError from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase -from uipath.runtime.resumable import UiPathResumeTriggerType logger = logging.getLogger(__name__) diff --git a/uv.lock b/uv.lock index 2320c4874..acb917c04 100644 --- a/uv.lock +++ b/uv.lock @@ -2603,8 +2603,8 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", specifier = ">=0.5.0,<0.6.0", index = "https://test.pypi.org/simple/" }, - { name = "uipath-platform", specifier = "==0.0.1.dev1000020014", index = "https://test.pypi.org/simple/" }, - { name = "uipath-runtime", specifier = "==0.9.0.dev1000950338", index = "https://test.pypi.org/simple/" }, + { name = "uipath-platform", specifier = "==0.0.1.dev1000020019", index = "https://test.pypi.org/simple/" }, + { name = "uipath-runtime", specifier = "==0.9.0.dev1000950339", index = "https://test.pypi.org/simple/" }, ] [package.metadata.requires-dev] @@ -2638,21 +2638,21 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.1.dev1000450222" +version = "0.5.1.dev1000450224" source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://test-files.pythonhosted.org/packages/3e/49/04157b027c605b12f4b361ab642557b701c08049c95ce16944c182f7994d/uipath_core-0.5.1.dev1000450222.tar.gz", hash = "sha256:e0ad2fde6e186cf14ccfbdbe56e03a61abcc714237d1589dcb1eab50bb8ef75a", size = 123653, upload-time = "2026-02-20T12:50:51.905Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/d5/db/ee198b6578e5387b51987a58e559653ab06788ca488b4850a2f3998fe7cf/uipath_core-0.5.1.dev1000450224.tar.gz", hash = "sha256:72b6f6c88d08d81fb8ebb35a83f52d3606cbbca55225bfd530f821d143a3a5b5", size = 117921, upload-time = "2026-02-20T15:41:21.871Z" } wheels = [ - { url = "https://test-files.pythonhosted.org/packages/28/57/3ae57e63b38946ba4fd717c489a9008f08fed7ac4fc0ed4d142b562937f5/uipath_core-0.5.1.dev1000450222-py3-none-any.whl", hash = "sha256:27d3ee0fd294ca66dde98d2d3dc378ded4c717d054425a4a988f4c01c49e4880", size = 44971, upload-time = "2026-02-20T12:50:53.292Z" }, + { url = "https://test-files.pythonhosted.org/packages/56/be/86d133e2da74bd79c19695eadacfd6ad69175bdf6aaf77f7489cdbcd237f/uipath_core-0.5.1.dev1000450224-py3-none-any.whl", hash = "sha256:3a7ceb5cbbd3e367e3a8c1cd22289dc4f3ee95a87294e2ac91bc74b396c4af61", size = 41135, upload-time = "2026-02-20T15:41:20.506Z" }, ] [[package]] name = "uipath-platform" -version = "0.0.1.dev1000020014" +version = "0.0.1.dev1000020019" source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "httpx" }, @@ -2661,21 +2661,21 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://test-files.pythonhosted.org/packages/af/b3/d95ce88f6d86845b82f92c4b188ead46d90d6ea82ab02ebef4af27f42d36/uipath_platform-0.0.1.dev1000020014.tar.gz", hash = "sha256:7668ce3d6e0e3a3e30b7a123f04727ab506774a08e5b6a5c13bdd76648f0c6f6", size = 247531, upload-time = "2026-02-20T13:27:10.508Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/10/1e/b0275d41f663f36894d64623a65ce9c571dd5794a360fa489c7208fcb3ca/uipath_platform-0.0.1.dev1000020019.tar.gz", hash = "sha256:4691e4d25a80533a5ba55e71915ec59f3a6de463b2c71a1e10a1f96c491a4091", size = 253878, upload-time = "2026-02-20T15:56:21.254Z" } wheels = [ - { url = "https://test-files.pythonhosted.org/packages/32/03/7b8f64a28fbeeafd2787187cea01c7044b72a2fc962f63b6881f271247a0/uipath_platform-0.0.1.dev1000020014-py3-none-any.whl", hash = "sha256:aff67fb4d8cca7d513cbc424b22ae88c26117c648ed878f6b087406e14956949", size = 148497, upload-time = "2026-02-20T13:27:11.539Z" }, + { url = "https://test-files.pythonhosted.org/packages/76/d2/b3c587540fa2f6c720647845002ab8c753382ca768fb26f3e76d80c263ef/uipath_platform-0.0.1.dev1000020019-py3-none-any.whl", hash = "sha256:4ddd03319aea1a730c49d37718163ecc6a160ab48cde0c722b983b7887a64740", size = 152908, upload-time = "2026-02-20T15:56:20.242Z" }, ] [[package]] name = "uipath-runtime" -version = "0.9.0.dev1000950338" +version = "0.9.0.dev1000950339" source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://test-files.pythonhosted.org/packages/af/e2/9e52ee2e1d8af96539adab50b6d2ba337e10b2a0c35fc97bdea28c8b2c85/uipath_runtime-0.9.0.dev1000950338.tar.gz", hash = "sha256:7a94b126463c7e12aa3e0445b005d973b0f091557b84b4d5a31d74343c3cbb83", size = 137857, upload-time = "2026-02-20T15:45:51.622Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/4b/87/c9b69aca464aa5187a3c1e15b3d9b8ccf0a4797a3bf66083bdbd44b77115/uipath_runtime-0.9.0.dev1000950339.tar.gz", hash = "sha256:1ef0aa9739cf1b3b2b28aa0b2bd24d0a5e8f283c28e63d9063ff749494b42bb7", size = 137861, upload-time = "2026-02-20T15:51:50.916Z" } wheels = [ - { url = "https://test-files.pythonhosted.org/packages/7b/a3/f0fdfc20b732e14b3915d2dc74d24949211f9027c70357f02beedaac8af8/uipath_runtime-0.9.0.dev1000950338-py3-none-any.whl", hash = "sha256:da1fa22749058abefda615a6148c2168c2f14a52f4ad6ccd640af0014e96171a", size = 41409, upload-time = "2026-02-20T15:45:50.651Z" }, + { url = "https://test-files.pythonhosted.org/packages/8e/28/7b9cc2ad7ffb9f6e707b0b900aa8257f43bf225e5dee964396bb2222d0bb/uipath_runtime-0.9.0.dev1000950339-py3-none-any.whl", hash = "sha256:bc026aab27b5aa1fc2e33ec5f59f3c068b779838f97a7e72a98b17e36f8b6d2d", size = 41410, upload-time = "2026-02-20T15:51:49.646Z" }, ] [[package]]