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..d024c5649 --- /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 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 +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..87da14d77 --- /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 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. +""" + +from uipath.core.tracing import traced + +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 + +_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"