diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 99c6d16ab..342b22d83 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.46" +version = "0.1.47" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/__init__.py b/packages/uipath-platform/src/uipath/platform/orchestrator/__init__.py index 79d6dde30..3c02bacf7 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/__init__.py @@ -27,6 +27,7 @@ CommitType, QueueItem, QueueItemPriority, + Strategy, TransactionItem, TransactionItemResult, ) @@ -56,6 +57,7 @@ "CommitType", "QueueItem", "QueueItemPriority", + "Strategy", "TransactionItem", "TransactionItemResult", "McpServer", diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py index 1a2985072..cc454cd50 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Dict, List, Optional, Union from httpx import Response @@ -12,6 +13,7 @@ from .queues import ( CommitType, QueueItem, + Strategy, TransactionItem, TransactionItemResult, ) @@ -286,6 +288,115 @@ async def create_transaction_item_async( ) return response.json() + @resource_override(resource_type="queue", resource_identifier="queue_name") + @traced(name="queues_start_transaction_item", run_type="uipath") + def start_transaction_item( + self, + queue_name: str, + *, + reference: str | None = None, + reference_filter_option: Strategy | None = None, + defer_date: datetime | None = None, + due_date: datetime | None = None, + parent_operation_id: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> Response: + """Picks up the next available item from a queue and transitions it to ``In Progress``. + + Use this when you want to process an item that already exists in the queue (e.g. one + added previously via `create_item`). Orchestrator returns the item now in + ``In Progress`` status, whose ``Key`` can then be passed to + `update_progress_of_transaction_item` and `complete_transaction_item`. + + The robot identifier is taken from the execution context when running inside an + Orchestrator job; outside that context the field is omitted automatically. + + Args: + queue_name: Name of the queue to pick the next item from. + reference (str | None): If provided, restricts the pickup to items whose + ``Reference`` matches this value (filter strategy controlled by + ``reference_filter_option``). + reference_filter_option (Strategy | None): Strategy used to match + ``reference``. Defaults to ``Strategy.EQUALS`` semantics on the server when + ``reference`` is set. + defer_date (datetime | None): Earliest date/time at which the item is + available for processing. + due_date (datetime | None): Latest date/time by which the item should be + processed. + parent_operation_id (str | None): Operation id of the caller, propagated to + the picked-up item. + folder_key (str | None): The key of the folder. Overrides the default one set in the SDK config. + folder_path (str | None): The path of the folder. Overrides the default one set in the SDK config. + + Returns: + Response: HTTP response containing the queue item now in ``In Progress`` state. + """ + spec = self._start_transaction_item_spec( + queue_name=queue_name, + reference=reference, + reference_filter_option=reference_filter_option, + defer_date=defer_date, + due_date=due_date, + parent_operation_id=parent_operation_id, + folder_key=folder_key, + folder_path=folder_path, + ) + response = self.request( + spec.method, url=spec.endpoint, json=spec.json, headers=spec.headers + ) + return response.json() + + @resource_override(resource_type="queue", resource_identifier="queue_name") + @traced(name="queues_start_transaction_item", run_type="uipath") + async def start_transaction_item_async( + self, + queue_name: str, + *, + reference: str | None = None, + reference_filter_option: Strategy | None = None, + defer_date: datetime | None = None, + due_date: datetime | None = None, + parent_operation_id: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> Response: + """Asynchronously picks up the next available item from a queue and transitions it to ``In Progress``. + + See `start_transaction_item` for behavior details. + + Args: + queue_name: Name of the queue to pick the next item from. + reference (str | None): If provided, restricts the pickup to items whose + ``Reference`` matches this value. + reference_filter_option (Strategy | None): Strategy used to match ``reference``. + defer_date (datetime | None): Earliest date/time at which the item is + available for processing. + due_date (datetime | None): Latest date/time by which the item should be + processed. + parent_operation_id (str | None): Operation id of the caller, propagated to + the picked-up item. + folder_key (str | None): The key of the folder. Overrides the default one set in the SDK config. + folder_path (str | None): The path of the folder. Overrides the default one set in the SDK config. + + Returns: + Response: HTTP response containing the queue item now in ``In Progress`` state. + """ + spec = self._start_transaction_item_spec( + queue_name=queue_name, + reference=reference, + reference_filter_option=reference_filter_option, + defer_date=defer_date, + due_date=due_date, + parent_operation_id=parent_operation_id, + 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 response.json() + @resource_override(resource_type="queue", resource_identifier="queue_name") @traced(name="queues_update_progress_of_transaction_item", run_type="uipath") def update_progress_of_transaction_item( @@ -299,6 +410,10 @@ def update_progress_of_transaction_item( ) -> Response: """Updates the progress of a transaction item. + The item must already be in ``In Progress`` state. Use `create_transaction_item` + to create-and-start a new item, or `start_transaction_item` to pick up an + existing one. + Args: transaction_key: Unique identifier of the transaction. progress: Progress message to set. @@ -332,6 +447,10 @@ async def update_progress_of_transaction_item_async( ) -> Response: """Asynchronously updates the progress of a transaction item. + The item must already be in ``In Progress`` state. Use `create_transaction_item` + to create-and-start a new item, or `start_transaction_item` to pick up an + existing one. + Args: transaction_key: Unique identifier of the transaction. progress: Progress message to set. @@ -365,6 +484,9 @@ def complete_transaction_item( ) -> Response: """Completes a transaction item with the specified result. + The item must already be in ``In Progress`` state, having been created via + `create_transaction_item` or picked up via `start_transaction_item`. + Args: transaction_key: Unique identifier of the transaction to complete. result: Result data for the transaction, either as a dictionary or TransactionItemResult instance. @@ -398,6 +520,9 @@ async def complete_transaction_item_async( ) -> Response: """Asynchronously completes a transaction item with the specified result. + The item must already be in ``In Progress`` state, having been created via + `create_transaction_item` or picked up via `start_transaction_item`. + Args: transaction_key: Unique identifier of the transaction to complete. result: Result data for the transaction, either as a dictionary or TransactionItemResult instance. @@ -545,6 +670,45 @@ def _create_transaction_item_spec( }, ) + def _start_transaction_item_spec( + self, + *, + queue_name: str, + reference: str | None = None, + reference_filter_option: Strategy | None = None, + defer_date: datetime | None = None, + due_date: datetime | None = None, + parent_operation_id: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> RequestSpec: + transaction_data: Dict[str, Any] = {"Name": queue_name} + try: + transaction_data["RobotIdentifier"] = self._execution_context.robot_key + except ValueError: + pass + if reference is not None: + transaction_data["Reference"] = reference + if reference_filter_option is not None: + transaction_data["ReferenceFilterOption"] = reference_filter_option.value + if defer_date is not None: + transaction_data["DeferDate"] = defer_date.isoformat() + if due_date is not None: + transaction_data["DueDate"] = due_date.isoformat() + if parent_operation_id is not None: + transaction_data["ParentOperationId"] = parent_operation_id + + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction" + ), + json={"transactionData": transaction_data}, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + def _update_progress_of_transaction_item_spec( self, transaction_key: str, diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/queues.py b/packages/uipath-platform/src/uipath/platform/orchestrator/queues.py index 94cc9a6a6..bcc0ca2ae 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/queues.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/queues.py @@ -25,6 +25,13 @@ class CommitType(Enum): PROCESS_ALL_INDEPENDENTLY = "ProcessAllIndependently" +class Strategy(Enum): + """Strategy used to filter the Reference value on StartTransaction.""" + + EQUALS = "Equals" + STARTS_WITH = "StartsWith" + + class QueueItem(BaseModel): """Model representing an item in an Orchestrator queue.""" @@ -173,6 +180,18 @@ def warn_name_deprecated(cls, values: Any) -> Any: description="Operation id which created the queue item.", alias="ParentOperationId", ) + reference: ( + Annotated[str, Field(min_length=0, strict=True, max_length=128)] | None + ) = Field( + default=None, + description="An optional, user-specified value for queue item identification.", + alias="Reference", + ) + reference_filter_option: Strategy | None = Field( + default=None, + description="Declares the strategy used to filter the Reference value.", + alias="ReferenceFilterOption", + ) class TransactionItemResult(BaseModel): diff --git a/packages/uipath-platform/tests/services/test_queues_service.py b/packages/uipath-platform/tests/services/test_queues_service.py index 2dd81ccc0..4fd75e89c 100644 --- a/packages/uipath-platform/tests/services/test_queues_service.py +++ b/packages/uipath-platform/tests/services/test_queues_service.py @@ -14,6 +14,7 @@ CommitType, QueueItem, QueueItemPriority, + Strategy, TransactionItem, TransactionItemResult, ) @@ -1204,3 +1205,233 @@ async def test_complete_transaction_item_async_with_folder_path( assert sent_request is not None assert HEADER_FOLDER_PATH in sent_request.headers assert sent_request.headers[HEADER_FOLDER_PATH] == "Custom/Folder/Path" + + def test_start_transaction_item_picks_next_available( + 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/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={"Id": 42, "Key": "abc-123", "Status": "InProgress"}, + ) + + response = service.start_transaction_item(queue_name="test-queue") + + assert response["Id"] == 42 + assert response["Status"] == "InProgress" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "POST" + body = json.loads(sent_request.content.decode()) + assert body == { + "transactionData": { + "Name": "test-queue", + "RobotIdentifier": "test-robot-key", + } + } + assert "SpecificContent" not in body["transactionData"] + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.start_transaction_item/{version}" + ) + + def test_start_transaction_item_with_reference_equals( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={"Id": 1}, + ) + + service.start_transaction_item( + queue_name="test-queue", + reference="ABC-123", + reference_filter_option=Strategy.EQUALS, + ) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + body = json.loads(sent_request.content.decode()) + assert body == { + "transactionData": { + "Name": "test-queue", + "RobotIdentifier": "test-robot-key", + "Reference": "ABC-123", + "ReferenceFilterOption": "Equals", + } + } + + def test_start_transaction_item_with_reference_starts_with( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={"Id": 1}, + ) + + service.start_transaction_item( + queue_name="test-queue", + reference="ABC-", + reference_filter_option=Strategy.STARTS_WITH, + ) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + body = json.loads(sent_request.content.decode()) + assert body["transactionData"]["Reference"] == "ABC-" + assert body["transactionData"]["ReferenceFilterOption"] == "StartsWith" + + def test_start_transaction_item_omits_robot_when_env_unset( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + local_service = QueuesService( + config=config, execution_context=UiPathExecutionContext() + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={"Id": 1}, + ) + + local_service.start_transaction_item(queue_name="test-queue") + + sent_request = httpx_mock.get_request() + assert sent_request is not None + body = json.loads(sent_request.content.decode()) + assert body == {"transactionData": {"Name": "test-queue"}} + assert "RobotIdentifier" not in body["transactionData"] + + def test_start_transaction_item_with_dates_and_parent_operation_id( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={"Id": 1}, + ) + + defer = datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc) + due = datetime(2026, 5, 2, 12, 0, 0, tzinfo=timezone.utc) + service.start_transaction_item( + queue_name="test-queue", + defer_date=defer, + due_date=due, + parent_operation_id="op-123", + ) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + body = json.loads(sent_request.content.decode()) + assert body["transactionData"]["DeferDate"] == defer.isoformat() + assert body["transactionData"]["DueDate"] == due.isoformat() + assert body["transactionData"]["ParentOperationId"] == "op-123" + + def test_start_transaction_item_with_folder_key( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={"Id": 1}, + ) + + service.start_transaction_item( + queue_name="test-queue", folder_key="custom-folder-key" + ) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.headers[HEADER_FOLDER_KEY] == "custom-folder-key" + + @pytest.mark.asyncio + async def test_start_transaction_item_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/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={"Id": 7, "Status": "InProgress"}, + ) + + response = await service.start_transaction_item_async( + queue_name="test-queue", + reference="REF-1", + reference_filter_option=Strategy.EQUALS, + ) + + assert response["Id"] == 7 + sent_request = httpx_mock.get_request() + assert sent_request is not None + body = json.loads(sent_request.content.decode()) + assert body["transactionData"]["Reference"] == "REF-1" + assert body["transactionData"]["ReferenceFilterOption"] == "Equals" + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.start_transaction_item_async/{version}" + ) + + @pytest.mark.asyncio + async def test_start_transaction_item_async_with_folder_path( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={"Id": 1}, + ) + + await service.start_transaction_item_async( + queue_name="test-queue", folder_path="Custom/Folder/Path" + ) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.headers[HEADER_FOLDER_PATH] == "Custom/Folder/Path" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index dbea2b79a..f8fa7aced 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1088,7 +1088,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.46" +version = "0.1.47" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 4339a51d2..e12347835 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2682,7 +2682,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.46" +version = "0.1.47" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },