From 34f286f41e332750bc08ee4986a6e6e2f9885088 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Mon, 9 Mar 2026 12:46:35 +0200 Subject: [PATCH] feat: filter app schemas by folder key --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/action_center/_tasks_service.py | 63 ++- .../tests/services/test_actions_service.py | 374 +++++++++++++++++- packages/uipath-platform/uv.lock | 2 +- .../test_resource_overrides.py | 1 + 5 files changed, 422 insertions(+), 20 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b9180b83c..45f1ff45b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.0.17" +version = "0.0.18" 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/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py index 95791098b..e5b874045 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -1,7 +1,7 @@ import asyncio import os import uuid -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from uipath.core.tracing import traced @@ -400,7 +400,9 @@ async def create_async( (key, action_schema) = ( (app_key, None) if app_key - else await self._get_app_key_and_schema_async(app_name, app_folder_path) + else await self._get_app_key_and_schema_async( + app_name, app_folder_path, app_folder_key + ) ) spec = _create_spec( title=title, @@ -486,7 +488,7 @@ def create( (key, action_schema) = ( (app_key, None) if app_key - else self._get_app_key_and_schema(app_name, app_folder_path) + else self._get_app_key_and_schema(app_name, app_folder_path, app_folder_key) ) spec = _create_spec( title=title, @@ -588,8 +590,11 @@ async def retrieve_async( 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]]: + self, + app_name: str | None, + app_folder_path: str | None, + app_folder_key: str | None, + ) -> tuple[str, TaskSchema | None]: if not app_name: raise Exception("appName or appKey is required") spec = _retrieve_app_key_spec(app_name=app_name) @@ -603,7 +608,7 @@ async def _get_app_key_and_schema_async( ) try: deployed_app = self._extract_deployed_app( - response.json()["deployed"], app_folder_path + response.json()["deployed"], app_name, app_folder_path, app_folder_key ) action_schema = deployed_app["actionSchema"] deployed_app_key = deployed_app["systemName"] @@ -624,8 +629,11 @@ async def _get_app_key_and_schema_async( 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]]: + self, + app_name: str | None, + app_folder_path: str | None, + app_folder_key: str | None, + ) -> tuple[str, TaskSchema | None]: if not app_name: raise Exception("appName or appKey is required") @@ -641,7 +649,7 @@ def _get_app_key_and_schema( try: deployed_app = self._extract_deployed_app( - response.json()["deployed"], app_folder_path + response.json()["deployed"], app_name, app_folder_path, app_folder_key ) action_schema = deployed_app["actionSchema"] deployed_app_key = deployed_app["systemName"] @@ -663,25 +671,46 @@ def _get_app_key_and_schema( # 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") + self, + deployed_apps: list[dict[str, Any]], + app_name: str, + app_folder_path: str | None, + app_folder_key: str | None, + ) -> dict[str, Any]: + # try extracting the requested app schema. folder path has priority over folder key + folder_identifier = None + filtered_apps_by_name = [ + app for app in deployed_apps if app["deploymentTitle"] == app_name + ] + if len(filtered_apps_by_name) < 1: + raise Exception(f"App '{app_name}' was not found in the current tenant") + try: if app_folder_path: + folder_identifier = ( + f"folder with fully qualified name '{app_folder_path}'" + ) return next( app - for app in deployed_apps + for app in filtered_apps_by_name if app["deploymentFolder"]["fullyQualifiedName"] == app_folder_path ) else: + folder_key = app_folder_key or self._folder_key + if not folder_key: + raise Exception( + f"App '{app_name}' was found but no folder key or folder path was provided to select a deployment" + ) + folder_identifier = f"folder with identifier '{folder_key}'" return next( app - for app in deployed_apps - if app["deploymentFolder"]["key"] == self._folder_key + for app in filtered_apps_by_name + if app["deploymentFolder"]["key"] == folder_key ) except StopIteration: - raise KeyError from StopIteration + raise Exception( + f"App '{app_name}' was not found in {folder_identifier}" + ) from StopIteration @property def custom_headers(self) -> Dict[str, str]: diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index d0eda5a73..7071a9a0e 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from pytest_httpx import HTTPXMock @@ -142,6 +144,7 @@ def test_create_with_assignee( "deployed": [ { "systemName": "test-app", + "deploymentTitle": "test-app", "actionSchema": { "key": "test-key", "inputs": [], @@ -149,7 +152,10 @@ def test_create_with_assignee( "inOuts": [], "outcomes": [], }, - "deploymentFolder": {"fullyQualifiedName": "test-folder-path"}, + "deploymentFolder": { + "fullyQualifiedName": "test-folder-path", + "key": "test-folder-key", + }, } ] }, @@ -177,3 +183,369 @@ def test_create_with_assignee( assert isinstance(action, Task) assert action.id == 1 assert action.title == "Test Action" + + +def _make_deployed_app( + name: str, + folder_path: str, + folder_key: str, + system_name: str | None = None, +) -> dict[str, Any]: + return { + "systemName": system_name or name, + "deploymentTitle": name, + "actionSchema": { + "key": f"{name}-schema-key", + "inputs": [], + "outputs": [], + "inOuts": [], + "outcomes": [], + }, + "deploymentFolder": { + "fullyQualifiedName": folder_path, + "key": folder_key, + }, + } + + +class TestCreateFiltersByFolder: + """Tests for folder filtering when creating tasks via app_name lookup.""" + + def _make_tasks_service( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> TasksService: + if folder_key: + monkeypatch.setenv("UIPATH_FOLDER_KEY", folder_key) + if folder_path: + monkeypatch.setenv("UIPATH_FOLDER_PATH", folder_path) + return TasksService(config=config, execution_context=execution_context) + + def _mock_app_schemas_response( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + app_name: str, + deployed_apps: list[dict[str, Any]], + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}/apps_/default/api/v1/default/deployed-action-apps-schemas?search={app_name}&filterByDeploymentTitle=true", + status_code=200, + json={"deployed": deployed_apps}, + ) + + def _mock_create_task_response( + self, + httpx_mock: HTTPXMock, + 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"}, + ) + + def test_create_filters_by_app_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service(config, execution_context, monkeypatch) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [ + _make_deployed_app( + "my-app", "folder-a", "key-a", system_name="my-app-a" + ), + _make_deployed_app( + "my-app", "folder-b", "key-b", system_name="my-app-b" + ), + ], + ) + self._mock_create_task_response(httpx_mock, base_url, org, tenant) + + task = tasks_service.create( + title="Test", + app_name="my-app", + app_folder_key="key-b", + app_folder_path=None, + ) + + assert isinstance(task, Task) + create_request = [ + r for r in httpx_mock.get_requests() if "CreateAppTask" in str(r.url) + ][0] + # systemName from the key-b deployment is used as appId in the request + assert b"my-app-b" in create_request.content + + @pytest.mark.anyio + async def test_create_async_filters_by_app_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service(config, execution_context, monkeypatch) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [ + _make_deployed_app( + "my-app", "folder-a", "key-a", system_name="my-app-a" + ), + _make_deployed_app( + "my-app", "folder-b", "key-b", system_name="my-app-b" + ), + ], + ) + self._mock_create_task_response(httpx_mock, base_url, org, tenant) + + task = await tasks_service.create_async( + title="Test", + app_name="my-app", + app_folder_key="key-b", + app_folder_path=None, + ) + + assert isinstance(task, Task) + create_request = [ + r for r in httpx_mock.get_requests() if "CreateAppTask" in str(r.url) + ][0] + # systemName from the key-b deployment is used as appId in the request + assert b"my-app-b" in create_request.content + + def test_create_filters_by_app_folder_path( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service(config, execution_context, monkeypatch) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [ + _make_deployed_app("my-app", "folder-a", "key-a"), + _make_deployed_app("my-app", "folder-b", "key-b"), + ], + ) + self._mock_create_task_response(httpx_mock, base_url, org, tenant) + + task = tasks_service.create( + title="Test", + app_name="my-app", + app_folder_path="folder-a", + ) + + assert isinstance(task, Task) + + def test_create_folder_path_takes_priority_over_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service(config, execution_context, monkeypatch) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [ + _make_deployed_app("my-app", "folder-a", "key-a"), + _make_deployed_app("my-app", "folder-b", "key-b"), + ], + ) + self._mock_create_task_response(httpx_mock, base_url, org, tenant) + + task = tasks_service.create( + title="Test", + app_name="my-app", + app_folder_path="folder-a", + app_folder_key="key-b", + ) + + assert isinstance(task, Task) + + def test_create_falls_back_to_env_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service( + config, execution_context, monkeypatch, folder_key="env-key" + ) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [ + _make_deployed_app("my-app", "folder-a", "key-a"), + _make_deployed_app("my-app", "folder-b", "env-key"), + ], + ) + self._mock_create_task_response(httpx_mock, base_url, org, tenant) + + task = tasks_service.create( + title="Test", + app_name="my-app", + ) + + assert isinstance(task, Task) + + def test_create_raises_when_app_not_found_in_tenant( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service( + config, execution_context, monkeypatch, folder_path="folder-a" + ) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [_make_deployed_app("other-app", "folder-a", "key-a")], + ) + + with pytest.raises( + Exception, match="'my-app' was not found in the current tenant" + ): + tasks_service.create(title="Test", app_name="my-app") + + def test_create_raises_when_app_not_found_in_folder_path( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service(config, execution_context, monkeypatch) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [_make_deployed_app("my-app", "folder-a", "key-a")], + ) + + with pytest.raises( + Exception, + match="'my-app' was not found in folder with fully qualified name 'folder-b'", + ): + tasks_service.create( + title="Test", app_name="my-app", app_folder_path="folder-b" + ) + + def test_create_raises_when_app_not_found_in_folder_key( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service(config, execution_context, monkeypatch) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [_make_deployed_app("my-app", "folder-a", "key-a")], + ) + + with pytest.raises( + Exception, + match="'my-app' was not found in folder with identifier 'wrong-key'", + ): + tasks_service.create( + title="Test", + app_name="my-app", + app_folder_key="wrong-key", + app_folder_path=None, + ) + + def test_create_raises_when_no_folder_key_or_path_provided( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, + tenant: str, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + tasks_service = self._make_tasks_service(config, execution_context, monkeypatch) + self._mock_app_schemas_response( + httpx_mock, + base_url, + org, + "my-app", + [_make_deployed_app("my-app", "folder-a", "key-a")], + ) + + with pytest.raises( + Exception, match="no folder key or folder path was provided" + ): + tasks_service.create( + title="Test", + app_name="my-app", + app_folder_path=None, + ) diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index cc5073ff8..695f52305 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1061,7 +1061,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.0.17" +version = "0.0.18" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/tests/resource_overrides/test_resource_overrides.py b/packages/uipath/tests/resource_overrides/test_resource_overrides.py index 9d98defae..c15bc113b 100644 --- a/packages/uipath/tests/resource_overrides/test_resource_overrides.py +++ b/packages/uipath/tests/resource_overrides/test_resource_overrides.py @@ -130,6 +130,7 @@ def _mock_calls( "deployed": [ { "systemName": "app-key-123", + "deploymentTitle": "Overwritten App Name", "deploymentFolder": { "fullyQualifiedName": "Overwritten/App/Folder" },