From 3a75f7ab7c8db25c039a928342c0003526c2351f Mon Sep 17 00:00:00 2001 From: cezara98t Date: Tue, 14 Jul 2026 16:28:28 +0300 Subject: [PATCH 1/2] feat(platform): add IXP designtime projects service [ACTV-89365] First resource-service port on top of the IXP designtime transport (Step 2 of the IXP -> Coded Agents SDK effort). Establishes the template the remaining designtime services follow. - ProjectsService(IxpDesigntimeService): list / retrieve / create / update (title) / delete, each with an async twin. Writes go through the transport's no-retry helpers; list/retrieve keep the platform retry policy. - Pydantic models (Project, ProjectsPage, DeleteProjectResponse): idiomatic snake_case attributes aliased to the API's PascalCase wire keys, CreatedAt parsed to datetime; extra="allow" for forward compatibility. - DocumentProjectsService facade grouping resource services under properties that mirror the CLI command groups (sdk.document_projects.projects.*), wired onto the UiPath client as `document_projects`. - 11 pytest-httpx tests (url/api-version/paging, PascalCase body + snake_case model mapping, name percent-encoding, delete envelope, facade caching, async twins). ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/uipath/platform/_uipath.py | 5 + .../platform/document_projects/__init__.py | 15 +- .../_document_projects_service.py | 41 ++++ .../platform/document_projects/_models.py | 66 ++++++ .../document_projects/_projects_service.py | 193 ++++++++++++++++++ .../test_document_projects_projects.py | 191 +++++++++++++++++ 6 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/document_projects/_document_projects_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/document_projects/_models.py create mode 100644 packages/uipath-platform/src/uipath/platform/document_projects/_projects_service.py create mode 100644 packages/uipath-platform/tests/services/test_document_projects_projects.py diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 98af7b8b6..aab24f1c5 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -18,6 +18,7 @@ from .common.auth import resolve_config_from_env from .connections import ConnectionsService from .context_grounding import ContextGroundingService +from .document_projects import DocumentProjectsService from .documents import DocumentsService from .entities import EntitiesService from .errors import BaseUrlMissingError, SecretMissingError @@ -126,6 +127,10 @@ def memory(self) -> MemoryService: def documents(self) -> DocumentsService: return DocumentsService(self._config, self._execution_context) + @property + def document_projects(self) -> DocumentProjectsService: + return DocumentProjectsService(self._config, self._execution_context) + @property def queues(self) -> QueuesService: return QueuesService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/document_projects/__init__.py b/packages/uipath-platform/src/uipath/platform/document_projects/__init__.py index d3f563b21..0f661ae00 100644 --- a/packages/uipath-platform/src/uipath/platform/document_projects/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/document_projects/__init__.py @@ -1,8 +1,9 @@ """UiPath IXP design-time SDK (``du_/api/designtimeapi``). -This package hosts the IXP design-time services (projects, taxonomy, labellings, -documents, models). It currently provides the shared base service; -resource services are added on top of :class:`IxpDesigntimeService`. +This package hosts the IXP design-time services (projects, and — as they land — +fields, data-types, groups, documents, labellings, models). Reach the surface via +``sdk.document_projects``; resource services build on the shared base service +:class:`IxpDesigntimeService`. """ from ._base_service import ( @@ -10,8 +11,16 @@ DESIGNTIME_API_VERSION, IxpDesigntimeService, ) +from ._document_projects_service import DocumentProjectsService +from ._models import DeleteProjectResponse, Project, ProjectsPage +from ._projects_service import ProjectsService __all__ = [ + "DocumentProjectsService", + "ProjectsService", + "Project", + "ProjectsPage", + "DeleteProjectResponse", "IxpDesigntimeService", "DESIGNTIME_API_BASE", "DESIGNTIME_API_VERSION", diff --git a/packages/uipath-platform/src/uipath/platform/document_projects/_document_projects_service.py b/packages/uipath-platform/src/uipath/platform/document_projects/_document_projects_service.py new file mode 100644 index 000000000..4bbc6b46c --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/document_projects/_document_projects_service.py @@ -0,0 +1,41 @@ +"""Public facade for the IXP design-time surface (``du_/api/designtimeapi``). + +:class:`DocumentProjectsService` is the entry point reached via +``sdk.document_projects``. It groups the design-time resource services under +properties that mirror the CLI's command groups (``uip ixp projects``, +``uip ixp fields``, ...), so callers write ``sdk.document_projects.projects.list()``. + +Only the ``projects`` resource is wired today; sibling resources (fields, +data-types, groups, documents, labellings, deployments) are added as their +services land, each as a new property here. +""" + +from functools import cached_property + +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ._projects_service import ProjectsService + + +class DocumentProjectsService: + """Grouped access to the IXP design-time resource services. + + !!! warning "Preview Feature" + This service is experimental. Behavior and parameters may change in + future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + self._config = config + self._execution_context = execution_context + + @cached_property + def projects(self) -> ProjectsService: + """The projects resource — create, list, retrieve, update, delete.""" + return ProjectsService( + config=self._config, execution_context=self._execution_context + ) diff --git a/packages/uipath-platform/src/uipath/platform/document_projects/_models.py b/packages/uipath-platform/src/uipath/platform/document_projects/_models.py new file mode 100644 index 000000000..0bf283a85 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/document_projects/_models.py @@ -0,0 +1,66 @@ +"""Pydantic models for the IXP design-time projects resource. + +The design-time API serializes with PascalCase keys (``Id``, ``Name``, ...); these +models expose idiomatic snake_case attributes and map to the wire names via field +aliases, so callers work with native Python objects (``project.created_at``) rather +than the raw envelope. ``extra="allow"`` keeps forward compatibility if the API +grows fields before this SDK models them. +""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class Project(BaseModel): + """A design-time project (``GET``/``POST``/``PATCH /api/projects``). + + Mirrors the design-time API ``Project`` contract. ``name`` is the backend slug + (unique within the tenant) used to address the project in every other call; + ``title`` is the human-readable display name. + """ + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + id: str = Field(alias="Id") + name: str = Field(alias="Name") + title: str = Field(alias="Title") + created_at: datetime = Field(alias="CreatedAt") + + +class ProjectsPage(BaseModel): + """A page of projects (``GET /api/projects``). + + ``total`` is the full project count regardless of the window; ``offset`` and + ``limit`` echo the applied paging window. + """ + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + projects: list[Project] = Field(alias="Projects") + total: int = Field(alias="Total") + offset: int = Field(alias="Offset") + limit: int = Field(alias="Limit") + + +class DeleteProjectResponse(BaseModel): + """Result of deleting a project (``DELETE /api/projects/{projectName}``). + + A minimal success confirmation; ``status`` is ``"ok"`` on success. + """ + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + status: str = Field(alias="Status") diff --git a/packages/uipath-platform/src/uipath/platform/document_projects/_projects_service.py b/packages/uipath-platform/src/uipath/platform/document_projects/_projects_service.py new file mode 100644 index 000000000..41fa6d02a --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/document_projects/_projects_service.py @@ -0,0 +1,193 @@ +"""Design-time projects resource service. + +:class:`ProjectsService` covers the project lifecycle exposed by the CLI's +``uip ixp projects`` group core verbs — list, retrieve, create, update (title), +delete — over ``du_/api/designtimeapi``. Sub-resources of a project (fields, +data-types, taxonomy, documents, models, ...) are handled by sibling services. + +It builds on :class:`IxpDesigntimeService`, so writes (create/update/delete) are +**not** retried while list/retrieve keep the platform retry policy. +""" + +from uipath.core.tracing import traced + +from ._models import DeleteProjectResponse, Project, ProjectsPage +from ._base_service import IxpDesigntimeService + +#: Default project-list page size — matches the design-time API's server-side default. +DEFAULT_LIST_LIMIT = 50 + +_PROJECTS = "/api/projects" +_PROJECT = "/api/projects/{name}" + + +class ProjectsService(IxpDesigntimeService): + """Manage IXP design-time projects. + + Accessed as ``sdk.document_projects.projects``. Each method has an async twin + with the ``_async`` suffix. + """ + + @traced(name="projects_list", run_type="uipath") + def list(self, *, offset: int = 0, limit: int = DEFAULT_LIST_LIMIT) -> ProjectsPage: + """List projects visible to the caller. + + Args: + offset: Number of projects to skip (0–1000000). + limit: Maximum number of projects to return (1–10000). + + Returns: + ProjectsPage: The requested window plus the total count. + """ + response = self._get( + self._endpoint(_PROJECTS), params={"offset": offset, "limit": limit} + ) + return ProjectsPage.model_validate(response.json()) + + @traced(name="projects_list", run_type="uipath") + async def list_async( + self, *, offset: int = 0, limit: int = DEFAULT_LIST_LIMIT + ) -> ProjectsPage: + """Asynchronously list projects visible to the caller. + + Args: + offset: Number of projects to skip (0–1000000). + limit: Maximum number of projects to return (1–10000). + + Returns: + ProjectsPage: The requested window plus the total count. + """ + response = await self._get_async( + self._endpoint(_PROJECTS), params={"offset": offset, "limit": limit} + ) + return ProjectsPage.model_validate(response.json()) + + @traced(name="projects_retrieve", run_type="uipath") + def retrieve(self, project_name: str) -> Project: + """Retrieve a project by its (slug) name. + + Args: + project_name: The backend project name (slug), as returned by + :meth:`create` or :meth:`list`. + + Returns: + Project: The project. + """ + response = self._get(self._endpoint(_PROJECT, name=project_name)) + return Project.model_validate(response.json()) + + @traced(name="projects_retrieve", run_type="uipath") + async def retrieve_async(self, project_name: str) -> Project: + """Asynchronously retrieve a project by its (slug) name. + + Args: + project_name: The backend project name (slug), as returned by + :meth:`create_async` or :meth:`list_async`. + + Returns: + Project: The project. + """ + response = await self._get_async(self._endpoint(_PROJECT, name=project_name)) + return Project.model_validate(response.json()) + + @traced(name="projects_create", run_type="uipath") + def create(self, name: str) -> Project: + """Create a project. + + The API slugifies ``name`` server-side; the returned :attr:`Project.name` + is the canonical slug used to address the project in every other call. + + Args: + name: Human-readable project name (1–116 characters). + + Returns: + Project: The created project. + """ + response = self._post(self._endpoint(_PROJECTS), body={"Name": name}) + return Project.model_validate(response.json()) + + @traced(name="projects_create", run_type="uipath") + async def create_async(self, name: str) -> Project: + """Asynchronously create a project. + + The API slugifies ``name`` server-side; the returned :attr:`Project.name` + is the canonical slug used to address the project in every other call. + + Args: + name: Human-readable project name (1–116 characters). + + Returns: + Project: The created project. + """ + response = await self._post_async( + self._endpoint(_PROJECTS), body={"Name": name} + ) + return Project.model_validate(response.json()) + + @traced(name="projects_update", run_type="uipath") + def update(self, project_name: str, *, title: str) -> Project: + """Update a project's display title. + + ``title`` is currently the only mutable field (keyword-only so the + signature can grow without breaking callers). + + Args: + project_name: The backend project name (slug). + title: New display title (1–1024 characters). + + Returns: + Project: The updated project. + """ + response = self._patch( + self._endpoint(_PROJECT, name=project_name), body={"Title": title} + ) + return Project.model_validate(response.json()) + + @traced(name="projects_update", run_type="uipath") + async def update_async(self, project_name: str, *, title: str) -> Project: + """Asynchronously update a project's display title. + + ``title`` is currently the only mutable field (keyword-only so the + signature can grow without breaking callers). + + Args: + project_name: The backend project name (slug). + title: New display title (1–1024 characters). + + Returns: + Project: The updated project. + """ + response = await self._patch_async( + self._endpoint(_PROJECT, name=project_name), body={"Title": title} + ) + return Project.model_validate(response.json()) + + @traced(name="projects_delete", run_type="uipath") + def delete(self, project_name: str) -> DeleteProjectResponse: + """Permanently delete a project and all its documents, taxonomy, and models. + + Irreversible. + + Args: + project_name: The backend project name (slug). + + Returns: + DeleteProjectResponse: A success confirmation (``status == "ok"``). + """ + response = self._delete(self._endpoint(_PROJECT, name=project_name)) + return DeleteProjectResponse.model_validate(response.json()) + + @traced(name="projects_delete", run_type="uipath") + async def delete_async(self, project_name: str) -> DeleteProjectResponse: + """Asynchronously and permanently delete a project and all its contents. + + Irreversible. + + Args: + project_name: The backend project name (slug). + + Returns: + DeleteProjectResponse: A success confirmation (``status == "ok"``). + """ + response = await self._delete_async(self._endpoint(_PROJECT, name=project_name)) + return DeleteProjectResponse.model_validate(response.json()) diff --git a/packages/uipath-platform/tests/services/test_document_projects_projects.py b/packages/uipath-platform/tests/services/test_document_projects_projects.py new file mode 100644 index 000000000..a0c5835bc --- /dev/null +++ b/packages/uipath-platform/tests/services/test_document_projects_projects.py @@ -0,0 +1,191 @@ +"""Tests for the IXP design-time projects service (``document_projects/_projects_service``). + +Cover the projects resource contract: the design-time URL + ``api-version``, the +PascalCase wire ⇄ snake_case model mapping (including ``created_at`` parsed to a +``datetime``), the PascalCase request bodies (``Name`` / ``Title``), project-name +percent-encoding, the paging params, and the facade wiring (``sdk.document_projects +.projects``). The no-retry-on-write contract is inherited from — and tested in — +the transport layer, so it is not re-exercised here. +""" + +import json +from datetime import datetime + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.document_projects import ( + DeleteProjectResponse, + DocumentProjectsService, + Project, + ProjectsPage, + ProjectsService, +) + +_PROJECT_JSON = { + "Id": "3d8f1b67-2c5e-4a09-bf3d-9e1a4c7b820f", + "Name": "my-invoices", + "Title": "My Invoices", + "CreatedAt": "2026-04-01T10:00:00Z", +} + + +@pytest.fixture +def projects_service( + config: UiPathApiConfig, execution_context: UiPathExecutionContext +) -> ProjectsService: + return ProjectsService(config=config, execution_context=execution_context) + + +class TestList: + def test_list_builds_url_with_paging_and_parses_page( + self, httpx_mock: HTTPXMock, projects_service: ProjectsService + ) -> None: + httpx_mock.add_response( + json={"Projects": [_PROJECT_JSON], "Total": 1, "Offset": 5, "Limit": 10} + ) + + page = projects_service.list(offset=5, limit=10) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/org/tenant/du_/api/designtimeapi/api/projects" + assert request.url.params.get("api-version") == "1.0" + assert request.url.params.get("offset") == "5" + assert request.url.params.get("limit") == "10" + + assert isinstance(page, ProjectsPage) + assert page.total == 1 + assert page.offset == 5 + assert page.limit == 10 + assert len(page.projects) == 1 + assert page.projects[0].name == "my-invoices" + + def test_list_defaults_offset_zero_limit_fifty( + self, httpx_mock: HTTPXMock, projects_service: ProjectsService + ) -> None: + httpx_mock.add_response( + json={"Projects": [], "Total": 0, "Offset": 0, "Limit": 50} + ) + + projects_service.list() + + request = httpx_mock.get_request() + assert request is not None + assert request.url.params.get("offset") == "0" + assert request.url.params.get("limit") == "50" + + +class TestRetrieve: + def test_retrieve_encodes_name_and_maps_fields( + self, httpx_mock: HTTPXMock, projects_service: ProjectsService + ) -> None: + httpx_mock.add_response(json=_PROJECT_JSON) + + # A name with a space and slash must survive as %20 / %2F. + project = projects_service.retrieve("a b/c") + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert b"/api/projects/a%20b%2Fc" in request.url.raw_path + + assert isinstance(project, Project) + assert project.id == "3d8f1b67-2c5e-4a09-bf3d-9e1a4c7b820f" + assert project.name == "my-invoices" + assert project.title == "My Invoices" + assert isinstance(project.created_at, datetime) + assert project.created_at.year == 2026 + + +class TestCreate: + def test_create_sends_name_body_and_returns_project( + self, httpx_mock: HTTPXMock, projects_service: ProjectsService + ) -> None: + httpx_mock.add_response(status_code=201, json=_PROJECT_JSON) + + project = projects_service.create("My Invoices") + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/org/tenant/du_/api/designtimeapi/api/projects" + assert json.loads(request.content) == {"Name": "My Invoices"} + assert project.name == "my-invoices" + + +class TestUpdate: + def test_update_sends_title_body_via_patch( + self, httpx_mock: HTTPXMock, projects_service: ProjectsService + ) -> None: + httpx_mock.add_response(json={**_PROJECT_JSON, "Title": "Renamed"}) + + project = projects_service.update("my-invoices", title="Renamed") + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "PATCH" + assert b"/api/projects/my-invoices" in request.url.raw_path + assert json.loads(request.content) == {"Title": "Renamed"} + assert project.title == "Renamed" + + +class TestDelete: + def test_delete_returns_status( + self, httpx_mock: HTTPXMock, projects_service: ProjectsService + ) -> None: + httpx_mock.add_response(json={"Status": "ok"}) + + result = projects_service.delete("my-invoices") + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "DELETE" + assert b"/api/projects/my-invoices" in request.url.raw_path + assert isinstance(result, DeleteProjectResponse) + assert result.status == "ok" + + +class TestFacade: + def test_projects_property_is_cached_projects_service( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + facade = DocumentProjectsService( + config=config, execution_context=execution_context + ) + assert isinstance(facade.projects, ProjectsService) + # cached_property: same instance on repeat access. + assert facade.projects is facade.projects + + +class TestAsync: + @pytest.mark.anyio + async def test_list_async( + self, httpx_mock: HTTPXMock, projects_service: ProjectsService + ) -> None: + httpx_mock.add_response( + json={"Projects": [_PROJECT_JSON], "Total": 1, "Offset": 0, "Limit": 50} + ) + + page = await projects_service.list_async() + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/org/tenant/du_/api/designtimeapi/api/projects" + assert page.projects[0].title == "My Invoices" + + @pytest.mark.anyio + async def test_create_async_sends_name_body( + self, httpx_mock: HTTPXMock, projects_service: ProjectsService + ) -> None: + httpx_mock.add_response(status_code=201, json=_PROJECT_JSON) + + project = await projects_service.create_async("My Invoices") + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert json.loads(request.content) == {"Name": "My Invoices"} + assert project.name == "my-invoices" From 28b085785a3c452d27060c09b3cf0756b8ffbbb6 Mon Sep 17 00:00:00 2001 From: cezara98t Date: Wed, 15 Jul 2026 16:10:06 +0300 Subject: [PATCH 2/2] docs(platform): describe IXP projects service without referencing the CLI Reword the projects service and facade docstrings to describe the design-time resources on their own terms (resource-named properties, the design-time project lifecycle) instead of "the CLI's uip ixp groups". The Python IXP SDK is independent of the CLI. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../document_projects/_document_projects_service.py | 4 ++-- .../platform/document_projects/_projects_service.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/document_projects/_document_projects_service.py b/packages/uipath-platform/src/uipath/platform/document_projects/_document_projects_service.py index 4bbc6b46c..d024c5649 100644 --- a/packages/uipath-platform/src/uipath/platform/document_projects/_document_projects_service.py +++ b/packages/uipath-platform/src/uipath/platform/document_projects/_document_projects_service.py @@ -2,8 +2,8 @@ :class:`DocumentProjectsService` is the entry point reached via ``sdk.document_projects``. It groups the design-time resource services under -properties that mirror the CLI's command groups (``uip ixp projects``, -``uip ixp fields``, ...), so callers write ``sdk.document_projects.projects.list()``. +properties named for each resource (``projects``, ``fields``, ...), so callers +write ``sdk.document_projects.projects.list()``. Only the ``projects`` resource is wired today; sibling resources (fields, data-types, groups, documents, labellings, deployments) are added as their diff --git a/packages/uipath-platform/src/uipath/platform/document_projects/_projects_service.py b/packages/uipath-platform/src/uipath/platform/document_projects/_projects_service.py index 41fa6d02a..87da14d77 100644 --- a/packages/uipath-platform/src/uipath/platform/document_projects/_projects_service.py +++ b/packages/uipath-platform/src/uipath/platform/document_projects/_projects_service.py @@ -1,9 +1,9 @@ """Design-time projects resource service. -:class:`ProjectsService` covers the project lifecycle exposed by the CLI's -``uip ixp projects`` group core verbs — list, retrieve, create, update (title), -delete — over ``du_/api/designtimeapi``. Sub-resources of a project (fields, -data-types, taxonomy, documents, models, ...) are handled by sibling services. +:class:`ProjectsService` covers the design-time project lifecycle — list, +retrieve, create, update (title), delete — over ``du_/api/designtimeapi``. +Sub-resources of a project (fields, data-types, taxonomy, documents, models, +...) are handled by sibling services. It builds on :class:`IxpDesigntimeService`, so writes (create/update/delete) are **not** retried while list/retrieve keep the platform retry policy. @@ -11,8 +11,8 @@ from uipath.core.tracing import traced -from ._models import DeleteProjectResponse, Project, ProjectsPage from ._base_service import IxpDesigntimeService +from ._models import DeleteProjectResponse, Project, ProjectsPage #: Default project-list page size — matches the design-time API's server-side default. DEFAULT_LIST_LIMIT = 50