From 6cd90027b7bfddbbf2c907046d3801d5d738ae38 Mon Sep 17 00:00:00 2001 From: rohankharvi Date: Tue, 7 Jul 2026 18:35:04 +0530 Subject: [PATCH] feat(entities): support v1, v2, and v3 Data Fabric entities APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entities SDK previously talked to the Data Service through hardcoded endpoint strings with no version concept. Add selectable v2 and v3 surfaces alongside the existing (unchanged) v1 `sdk.entities`: - `sdk.entities` -> v1 (unchanged) - `sdk.entities_v2` -> v2 (partial: query + read + list_entities, v1 models) - `sdk.entities_v3` -> v3 (mirrors the v1 op set on /api/v3/entities/*, returning new V3 models) v2 shares v1's wire shape, so `EntitiesServiceV2` reuses v1 parsing/models and only re-points endpoints; it exposes exactly what the v2 backend implements. v3 is a redesign: new models in `entities_v3.py` (`EntityWriteResponseV3` with the root-field flattening converter, `QueryResponseV3`, `BatchOperationResponse`, `EntityRecordV3`, `CompositeEntityMetadataResponse`, `GetAllResponseV3`). Federated SQL and entity-set resolution stay version-agnostic; CSV import raises NotImplementedError on v3 (no v3 endpoint). Extracted the shared `build_query_body()` helper (used by v1/v2/v3). No change to v1 behavior — all 130 existing entity tests pass; 22 new v2/v3 tests added. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/uipath/platform/_uipath.py | 16 +- .../src/uipath/platform/entities/__init__.py | 28 + .../platform/entities/_entities_service_v2.py | 247 +++++++ .../platform/entities/_entities_service_v3.py | 607 +++++++++++++++++ .../platform/entities/_entity_data_service.py | 111 ++-- .../entities/_entity_data_service_v3.py | 615 ++++++++++++++++++ .../entities/_entity_schema_service_v3.py | 179 +++++ .../uipath/platform/entities/entities_v3.py | 247 +++++++ .../tests/services/test_entities_v2.py | 140 ++++ .../services/test_entities_v3_service.py | 424 ++++++++++++ 10 files changed, 2574 insertions(+), 40 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entities_service_v2.py create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entities_service_v3.py create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entity_data_service_v3.py create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service_v3.py create mode 100644 packages/uipath-platform/src/uipath/platform/entities/entities_v3.py create mode 100644 packages/uipath-platform/tests/services/test_entities_v2.py create mode 100644 packages/uipath-platform/tests/services/test_entities_v3_service.py diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 98af7b8b6..4d9c4c972 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -19,7 +19,7 @@ from .connections import ConnectionsService from .context_grounding import ContextGroundingService from .documents import DocumentsService -from .entities import EntitiesService +from .entities import EntitiesService, EntitiesServiceV2, EntitiesServiceV3 from .errors import BaseUrlMissingError, SecretMissingError from .external_applications import ExternalApplicationService from .governance import GovernanceService @@ -152,6 +152,20 @@ def entities(self) -> EntitiesService: self._config, self._execution_context, folders_service=self.folders ) + @property + def entities_v2(self) -> EntitiesServiceV2: + """Entities service targeting the v2 API (preview, limited surface).""" + return EntitiesServiceV2( + self._config, self._execution_context, folders_service=self.folders + ) + + @property + def entities_v3(self) -> EntitiesServiceV3: + """Entities service targeting the v3 API (preview).""" + return EntitiesServiceV3( + self._config, self._execution_context, folders_service=self.folders + ) + @cached_property def resource_catalog(self) -> ResourceCatalogService: return ResourceCatalogService( diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index 67572ec87..5edeb30a5 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -4,6 +4,8 @@ """ from ._entities_service import EntitiesService +from ._entities_service_v2 import EntitiesServiceV2 +from ._entities_service_v3 import EntitiesServiceV3 from .entities import ( AggregateRow, ChoiceSetValue, @@ -41,12 +43,34 @@ RetrieveEntityRecordsResponse, SourceJoinCriteria, ) +from .entities_v3 import ( + BatchOperationFailureRecord, + BatchOperationResponse, + ChildArrayBlock, + ChildArrayPaginationRef, + CompositeEntityMetadataResponse, + CompositeInfo, + CompositeMemberMetadata, + EntityRecordV3, + EntityWriteResponseV3, + GetAllResponseV3, + QueryResponseV3, +) __all__ = [ "AggregateRow", + "BatchOperationFailureRecord", + "BatchOperationResponse", + "ChildArrayBlock", + "ChildArrayPaginationRef", "ChoiceSetValue", + "CompositeEntityMetadataResponse", + "CompositeInfo", + "CompositeMemberMetadata", "DataFabricEntityItem", "EntitiesService", + "EntitiesServiceV2", + "EntitiesServiceV3", "Entity", "EntityAggregate", "EntityAggregateFunction", @@ -63,8 +87,10 @@ "EntityQueryFilterGroup", "EntityQuerySortOption", "EntityRecord", + "EntityRecordV3", "EntityRecordsBatchResponse", "EntityRecordsListResponse", + "EntityWriteResponseV3", "EntityRouting", "EntitySetResolution", "ExternalField", @@ -73,8 +99,10 @@ "FailureRecord", "FieldDataType", "FieldMetadata", + "GetAllResponseV3", "LogicalOperator", "QueryFilterOperator", + "QueryResponseV3", "QueryRoutingOverrideContext", "ReferenceType", "RetrieveEntityRecordsResponse", diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service_v2.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service_v2.py new file mode 100644 index 000000000..939dec5b7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service_v2.py @@ -0,0 +1,247 @@ +"""Public facade for the v2 Data Fabric entities API (preview). + +The v2 backend is a **partial** surface: it re-implements only structured +``query`` and single-record ``read`` for records, plus entity listing. Its +response shapes are identical to v1, so :class:`EntitiesServiceV2` reuses the v1 +models (:class:`RetrieveEntityRecordsResponse`, :class:`EntityRecord`, +:class:`Entity`) and parsing, overriding only the endpoints to target +``datafabric_/api/v2/...``. + +Reach it via ``sdk.entities_v2``. Operations the v2 backend does not implement +(insert/update/delete, batch, attachments, schema writes, choice-set values) +are intentionally absent — use ``sdk.entities`` (v1) or ``sdk.entities_v3`` for +those. +""" + +import logging +from typing import Any, List, Optional + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from ._entity_data_service import EntityDataService, build_query_body +from ._entity_resolution import create_routing_strategy +from ._entity_schema_service import EntitySchemaService +from .entities import ( + Entity, + EntityAggregate, + EntityBinning, + EntityJoin, + EntityQueryFilterGroup, + EntityQuerySortOption, + EntityRecord, + QueryRoutingOverrideContext, + RetrieveEntityRecordsResponse, +) + +logger = logging.getLogger(__name__) + + +class EntityDataServiceV2(EntityDataService): + """v1 data service with the two record endpoints v2 implements re-pointed to v2.""" + + @staticmethod + def _retrieve_records_spec( + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[Any]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RequestSpec: + """Build the v2 structured-query spec (always the ``v2`` endpoint).""" + body = build_query_body( + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) + params: dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/v2/EntityService/entity/{entity_key}/query" + ), + params=params, + json=body, + ) + + @staticmethod + def _get_record_spec( + entity_key: str, + record_id: str, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + """Build the v2 single-record read spec.""" + params: dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"datafabric_/api/v2/EntityService/entity/{entity_key}/read/{record_id}" + ), + params=params, + ) + + +class EntitySchemaServiceV2(EntitySchemaService): + """v1 schema service with entity listing re-pointed to the v2 endpoint.""" + + @staticmethod + def _list_entities_spec() -> RequestSpec: + """Build the v2 list-entities spec.""" + return RequestSpec(method="GET", endpoint=Endpoint("datafabric_/api/v2/Entity")) + + +class EntitiesServiceV2(BaseService): + """Service for UiPath Data Service entities using the **v2** API (preview). + + Exposes only the operations the v2 backend implements: structured queries + (:meth:`retrieve_records`), single-record reads (:meth:`get_record`), and + entity listing (:meth:`list_entities`). Responses use the v1 models. + + !!! warning "Preview Feature" + This service is experimental and intentionally limited. Behavior and + parameters are subject to change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + folders_map: Optional[dict[str, str]] = None, + entity_name_overrides: Optional[dict[str, str]] = None, + routing_context: Optional[QueryRoutingOverrideContext] = None, + ) -> None: + """Initialise the v2 facade and its underlying schema and data services.""" + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + self._routing_strategy = create_routing_strategy( + folders_map=folders_map, + effective_entity_names=entity_name_overrides, + routing_context=routing_context, + folders_service=folders_service, + ) + self._schema = EntitySchemaServiceV2( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + self._data = EntityDataServiceV2( + config=config, + execution_context=execution_context, + folders_service=folders_service, + routing_strategy=self._routing_strategy, + ) + + @traced(name="list_entities", run_type="uipath") + def list_entities(self) -> List[Entity]: + """List all entities in Data Service (v2).""" + return self._schema.list_entities() + + @traced(name="list_entities", run_type="uipath") + async def list_entities_async(self) -> List[Entity]: + """Async variant of :meth:`list_entities`.""" + return await self._schema.list_entities_async() + + @traced(name="entity_get_record", run_type="uipath") + def get_record( + self, entity_key: str, record_id: str, expansion_level: Optional[int] = None + ) -> EntityRecord: + """Fetch a single entity record by its id (v2).""" + return self._data.get_record( + entity_key, record_id, expansion_level=expansion_level + ) + + @traced(name="entity_get_record", run_type="uipath") + async def get_record_async( + self, entity_key: str, record_id: str, expansion_level: Optional[int] = None + ) -> EntityRecord: + """Async variant of :meth:`get_record`.""" + return await self._data.get_record_async( + entity_key, record_id, expansion_level=expansion_level + ) + + @traced(name="entity_retrieve_records", run_type="uipath") + def retrieve_records( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Retrieve records with structured filters, sorting, and aggregates (v2).""" + return self._data.retrieve_records( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) + + @traced(name="entity_retrieve_records", run_type="uipath") + async def retrieve_records_async( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RetrieveEntityRecordsResponse: + """Async variant of :meth:`retrieve_records`.""" + return await self._data.retrieve_records_async( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service_v3.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service_v3.py new file mode 100644 index 000000000..1b73b35c9 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service_v3.py @@ -0,0 +1,607 @@ +"""Public facade for the v3 Data Fabric entities API (preview). + +:class:`EntitiesServiceV3` exposes the same operation set as the v1 +:class:`~uipath.platform.entities._entities_service.EntitiesService`, but routes +to the ``datafabric_/api/v3/entities/...`` endpoints and returns the v3 response +models (:class:`EntityWriteResponseV3`, :class:`QueryResponseV3`, +:class:`BatchOperationResponse`, :class:`EntityRecordV3`, ...). + +Reach it via ``sdk.entities_v3``. ``sdk.entities`` (v1) is unchanged. + +Notes: + * ``entity_key`` is used as the v3 ``{entityName}`` path segment. + * :meth:`query_entity_records` (federated SQL) is version-agnostic and runs + through the shared ``/api/v1/query/execute`` engine. + * :meth:`import_records` (CSV bulk upload) has no v3 equivalent and raises + :class:`NotImplementedError`. +""" + +import logging +from typing import Any, Dict, List, Optional + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..errors._datafabric_error import attach_datafabric_error_mapping +from ..orchestrator._folder_service import FolderService +from ._entity_data_service import EntityDataService, FileContent +from ._entity_data_service_v3 import EntityDataServiceV3 +from ._entity_resolution import create_routing_strategy +from ._entity_schema_service_v3 import EntitySchemaServiceV3 +from .entities import ( + ChoiceSetValue, + EntityAggregate, + EntityBinning, + EntityCreateFieldOptions, + EntityCreateOptions, + EntityJoin, + EntityMetadataUpdateOptions, + EntityQueryFilterGroup, + EntityQuerySortOption, + QueryRoutingOverrideContext, +) +from .entities_v3 import ( + BatchOperationResponse, + CompositeEntityMetadataResponse, + EntityRecordV3, + EntityWriteResponseV3, + GetAllResponseV3, + QueryResponseV3, +) + +logger = logging.getLogger(__name__) + + +class EntitiesServiceV3(BaseService): + """Service for UiPath Data Service entities using the **v3** API (preview). + + !!! warning "Preview Feature" + This service is experimental. Behavior and parameters are subject to + change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + folders_map: Optional[Dict[str, str]] = None, + entity_name_overrides: Optional[Dict[str, str]] = None, + routing_context: Optional[QueryRoutingOverrideContext] = None, + ) -> None: + """Initialise the v3 facade and its underlying schema and data services.""" + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + self._routing_strategy = create_routing_strategy( + folders_map=folders_map, + effective_entity_names=entity_name_overrides, + routing_context=routing_context, + folders_service=folders_service, + ) + self._schema = EntitySchemaServiceV3( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + self._data = EntityDataServiceV3( + config=config, + execution_context=execution_context, + folders_service=folders_service, + routing_strategy=self._routing_strategy, + ) + # Federated SQL is version-agnostic; delegate to the shared v1 engine. + self._federated = EntityDataService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + routing_strategy=self._routing_strategy, + ) + + # ------------------------------------------------------------------ + # Schema operations + # ------------------------------------------------------------------ + + @traced(name="entity_retrieve", run_type="uipath") + def retrieve(self, entity_id: str) -> EntityRecordV3: + """Retrieve an entity definition by id (v3).""" + return self._schema.retrieve(entity_id) + + @traced(name="entity_retrieve", run_type="uipath") + async def retrieve_async(self, entity_id: str) -> EntityRecordV3: + """Async variant of :meth:`retrieve`.""" + return await self._schema.retrieve_async(entity_id) + + @traced(name="entity_retrieve_by_name", run_type="uipath") + def retrieve_by_name( + self, entity_name: str, folder_key: Optional[str] = None + ) -> CompositeEntityMetadataResponse: + """Retrieve composite-aware entity metadata by name (v3).""" + return self._schema.retrieve_by_name(entity_name, folder_key=folder_key) + + @traced(name="entity_retrieve_by_name", run_type="uipath") + async def retrieve_by_name_async( + self, entity_name: str, folder_key: Optional[str] = None + ) -> CompositeEntityMetadataResponse: + """Async variant of :meth:`retrieve_by_name`.""" + return await self._schema.retrieve_by_name_async( + entity_name, folder_key=folder_key + ) + + @traced(name="list_entities", run_type="uipath") + def list_entities(self) -> List[EntityRecordV3]: + """List all entity definitions (v3).""" + return self._schema.list_entities() + + @traced(name="list_entities", run_type="uipath") + async def list_entities_async(self) -> List[EntityRecordV3]: + """Async variant of :meth:`list_entities`.""" + return await self._schema.list_entities_async() + + @traced(name="list_choicesets", run_type="uipath") + def list_choicesets(self) -> List[Dict[str, Any]]: + """List all choice sets (v3, via the ``/all`` catalog).""" + return self._schema.get_all().choicesets + + @traced(name="list_choicesets", run_type="uipath") + async def list_choicesets_async(self) -> List[Dict[str, Any]]: + """Async variant of :meth:`list_choicesets`.""" + return (await self._schema.get_all_async()).choicesets + + @traced(name="entity_get_all", run_type="uipath") + def get_all(self, start: int = 0, limit: int = 1000) -> GetAllResponseV3: + """Return the full schema catalog — entities and choice sets (v3).""" + return self._schema.get_all(start=start, limit=limit) + + @traced(name="entity_get_all", run_type="uipath") + async def get_all_async( + self, start: int = 0, limit: int = 1000 + ) -> GetAllResponseV3: + """Async variant of :meth:`get_all`.""" + return await self._schema.get_all_async(start=start, limit=limit) + + @traced(name="entity_create", run_type="uipath") + def create_entity( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Create a new entity and return its id (v3).""" + return self._schema.create_entity(name, fields, options) + + @traced(name="entity_create", run_type="uipath") + async def create_entity_async( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Async variant of :meth:`create_entity`.""" + return await self._schema.create_entity_async(name, fields, options) + + @traced(name="entity_delete", run_type="uipath") + def delete_entity(self, entity_id: str) -> None: + """Delete an entity and all of its records (v3).""" + self._schema.delete_entity(entity_id) + + @traced(name="entity_delete", run_type="uipath") + async def delete_entity_async(self, entity_id: str) -> None: + """Async variant of :meth:`delete_entity`.""" + await self._schema.delete_entity_async(entity_id) + + @traced(name="entity_update_metadata", run_type="uipath") + def update_entity_metadata( + self, entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any] + ) -> None: + """Update an entity's display name, description, and/or RBAC flag (v3).""" + self._schema.update_entity_metadata(entity_id, metadata) + + @traced(name="entity_update_metadata", run_type="uipath") + async def update_entity_metadata_async( + self, entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any] + ) -> None: + """Async variant of :meth:`update_entity_metadata`.""" + await self._schema.update_entity_metadata_async(entity_id, metadata) + + # ------------------------------------------------------------------ + # Data operations + # ------------------------------------------------------------------ + + @traced(name="get_choiceset_values", run_type="uipath") + def get_choiceset_values( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Get the values of a choice set by id (v3).""" + return self._data.get_choiceset_values(choiceset_id, start=start, limit=limit) + + @traced(name="get_choiceset_values", run_type="uipath") + async def get_choiceset_values_async( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Async variant of :meth:`get_choiceset_values`.""" + return await self._data.get_choiceset_values_async( + choiceset_id, start=start, limit=limit + ) + + @traced(name="entity_list_records", run_type="uipath") + def list_records( + self, + entity_key: str, + start: int = 0, + limit: int = 100, + expansion_level: Optional[int] = None, + ) -> QueryResponseV3: + """Read a page of records from an entity (v3). + + v3 paged read supports ``start`` / ``limit`` / ``expansion_level`` only; + for filtering, sorting, and projection use :meth:`retrieve_records`. + """ + return self._data.list_records( + entity_key, start=start, limit=limit, expansion_level=expansion_level + ) + + @traced(name="entity_list_records", run_type="uipath") + async def list_records_async( + self, + entity_key: str, + start: int = 0, + limit: int = 100, + expansion_level: Optional[int] = None, + ) -> QueryResponseV3: + """Async variant of :meth:`list_records`.""" + return await self._data.list_records_async( + entity_key, start=start, limit=limit, expansion_level=expansion_level + ) + + @traced(name="entity_insert_record", run_type="uipath") + def insert_record( + self, entity_key: str, data: Any, expansion_level: Optional[int] = None + ) -> EntityWriteResponseV3: + """Insert a single record (v3).""" + return self._data.insert_record( + entity_key, data, expansion_level=expansion_level + ) + + @traced(name="entity_insert_record", run_type="uipath") + async def insert_record_async( + self, entity_key: str, data: Any, expansion_level: Optional[int] = None + ) -> EntityWriteResponseV3: + """Async variant of :meth:`insert_record`.""" + return await self._data.insert_record_async( + entity_key, data, expansion_level=expansion_level + ) + + @traced(name="entity_get_record", run_type="uipath") + def get_record( + self, entity_key: str, record_id: str, expansion_level: Optional[int] = None + ) -> EntityWriteResponseV3: + """Fetch a single record by id (v3).""" + return self._data.get_record( + entity_key, record_id, expansion_level=expansion_level + ) + + @traced(name="entity_get_record", run_type="uipath") + async def get_record_async( + self, entity_key: str, record_id: str, expansion_level: Optional[int] = None + ) -> EntityWriteResponseV3: + """Async variant of :meth:`get_record`.""" + return await self._data.get_record_async( + entity_key, record_id, expansion_level=expansion_level + ) + + @traced(name="entity_update_record", run_type="uipath") + def update_record( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityWriteResponseV3: + """Update a single record by id (v3).""" + return self._data.update_record( + entity_key, record_id, data, expansion_level=expansion_level + ) + + @traced(name="entity_update_record", run_type="uipath") + async def update_record_async( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityWriteResponseV3: + """Async variant of :meth:`update_record`.""" + return await self._data.update_record_async( + entity_key, record_id, data, expansion_level=expansion_level + ) + + @traced(name="entity_delete_record", run_type="uipath") + def delete_record(self, entity_key: str, record_id: str) -> EntityWriteResponseV3: + """Delete a single record by id (v3).""" + return self._data.delete_record(entity_key, record_id) + + @traced(name="entity_delete_record", run_type="uipath") + async def delete_record_async( + self, entity_key: str, record_id: str + ) -> EntityWriteResponseV3: + """Async variant of :meth:`delete_record`.""" + return await self._data.delete_record_async(entity_key, record_id) + + @traced(name="entity_record_insert_batch", run_type="uipath") + def insert_records( + self, + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Insert multiple records in a single batch (v3).""" + return self._data.insert_records( + entity_key, + records, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + + @traced(name="entity_record_insert_batch", run_type="uipath") + async def insert_records_async( + self, + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Async variant of :meth:`insert_records`.""" + return await self._data.insert_records_async( + entity_key, + records, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + + @traced(name="entity_record_update_batch", run_type="uipath") + def update_records( + self, + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Update multiple records in a single batch (v3).""" + return self._data.update_records( + entity_key, + records, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + + @traced(name="entity_record_update_batch", run_type="uipath") + async def update_records_async( + self, + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Async variant of :meth:`update_records`.""" + return await self._data.update_records_async( + entity_key, + records, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + ) + + @traced(name="entity_record_delete_batch", run_type="uipath") + def delete_records( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Delete multiple records by id in a single batch (v3).""" + return self._data.delete_records( + entity_key, record_ids, fail_on_first=fail_on_first + ) + + @traced(name="entity_record_delete_batch", run_type="uipath") + async def delete_records_async( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Async variant of :meth:`delete_records`.""" + return await self._data.delete_records_async( + entity_key, record_ids, fail_on_first=fail_on_first + ) + + @traced(name="entity_retrieve_records", run_type="uipath") + def retrieve_records( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + child_limit: Optional[int] = None, + ) -> QueryResponseV3: + """Retrieve records with structured filters, sorting, and aggregates (v3). + + ``child_limit`` bounds the per-parent child rows returned for composite + entities (v3-only). + """ + return self._data.retrieve_records( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + child_limit=child_limit, + ) + + @traced(name="entity_retrieve_records", run_type="uipath") + async def retrieve_records_async( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + child_limit: Optional[int] = None, + ) -> QueryResponseV3: + """Async variant of :meth:`retrieve_records`.""" + return await self._data.retrieve_records_async( + entity_key, + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + expansion_level=expansion_level, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + child_limit=child_limit, + ) + + @attach_datafabric_error_mapping("query_entity_records") + @traced(name="entity_query_records", run_type="uipath") + def query_entity_records(self, sql_query: str) -> List[Dict[str, Any]]: + """Query entity records using a validated SQL query (version-agnostic).""" + return self._federated.query_entity_records(sql_query) + + @attach_datafabric_error_mapping("query_entity_records_async") + @traced(name="entity_query_records", run_type="uipath") + async def query_entity_records_async(self, sql_query: str) -> List[Dict[str, Any]]: + """Async variant of :meth:`query_entity_records`.""" + return await self._federated.query_entity_records_async(sql_query) + + @traced(name="entity_upload_attachment", run_type="uipath") + def upload_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Upload a file attachment to a File-type field on a record (v3).""" + return self._data.upload_attachment( + entity_id, + record_id, + field_name, + file=file, + file_path=file_path, + expansion_level=expansion_level, + ) + + @traced(name="entity_upload_attachment", run_type="uipath") + async def upload_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Async variant of :meth:`upload_attachment`.""" + return await self._data.upload_attachment_async( + entity_id, + record_id, + field_name, + file=file, + file_path=file_path, + expansion_level=expansion_level, + ) + + @traced(name="entity_download_attachment", run_type="uipath") + def download_attachment( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Download a file attached to a record (v3).""" + return self._data.download_attachment(entity_id, record_id, field_name) + + @traced(name="entity_download_attachment", run_type="uipath") + async def download_attachment_async( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Async variant of :meth:`download_attachment`.""" + return await self._data.download_attachment_async( + entity_id, record_id, field_name + ) + + @traced(name="entity_delete_attachment", run_type="uipath") + def delete_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Remove the file attached to a File-type field on a record (v3).""" + return self._data.delete_attachment( + entity_id, record_id, field_name, expansion_level=expansion_level + ) + + @traced(name="entity_delete_attachment", run_type="uipath") + async def delete_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Async variant of :meth:`delete_attachment`.""" + return await self._data.delete_attachment_async( + entity_id, record_id, field_name, expansion_level=expansion_level + ) + + def import_records(self, *args: Any, **kwargs: Any) -> Any: + """Not available in v3 — CSV bulk import has no v3 endpoint. + + Use ``sdk.entities.import_records`` (v1) instead. + """ + raise NotImplementedError( + "CSV bulk import is not available in the Entities v3 API; " + "use sdk.entities.import_records (v1)." + ) + + async def import_records_async(self, *args: Any, **kwargs: Any) -> Any: + """Not available in v3 — see :meth:`import_records`.""" + raise NotImplementedError( + "CSV bulk import is not available in the Entities v3 API; " + "use sdk.entities.import_records_async (v1)." + ) diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py index 60cfb99b4..34944750d 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py @@ -62,6 +62,66 @@ _AGGREGATE_FUNCTIONS = ("COUNT", "SUM", "AVG", "MIN", "MAX") +def build_query_body( + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + aggregates: Optional[List[Any]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + child_limit: Optional[int] = None, +) -> Dict[str, Any]: + """Assemble the JSON body for a structured entity query. + + Shared by the v1/v2 (:class:`EntityDataService`) and v3 + (``EntityDataServiceV3``) query endpoints, which take the same + ``EntityDataQueryRequest`` body. ``child_limit`` applies only to v3 + composite queries and is omitted when ``None``. + """ + body: Dict[str, Any] = {} + if filter_group is not None: + body["filterGroup"] = filter_group.model_dump(by_alias=True, exclude_none=True) + if sort_options: + body["sortOptions"] = [ + opt.model_dump(by_alias=True, exclude_none=True) for opt in sort_options + ] + if selected_fields: + body["selectedFields"] = list(selected_fields) + if expansions: + body["expansions"] = [ + e.model_dump(by_alias=True, exclude_none=True) + if isinstance(e, BaseModel) + else e + for e in expansions + ] + if aggregates: + body["aggregates"] = [ + a.model_dump(by_alias=True, exclude_none=True) + if isinstance(a, BaseModel) + else a + for a in aggregates + ] + if group_by: + body["groupBy"] = list(group_by) + if joins: + body["joins"] = [j.model_dump(by_alias=True, exclude_none=True) for j in joins] + if binnings: + body["binnings"] = [ + b.model_dump(by_alias=True, exclude_none=True) for b in binnings + ] + if start is not None: + body["start"] = start + if limit is not None: + body["limit"] = limit + if child_limit is not None: + body["childLimit"] = child_limit + return body + + class EntityDataService(BaseService): """HTTP service for entity-record and attachment operations. @@ -892,45 +952,18 @@ def _retrieve_records_spec( ``expansionLevel`` is a URL query parameter. The V2 endpoint is used only when ``binnings`` are supplied. """ - body: Dict[str, Any] = {} - if filter_group is not None: - body["filterGroup"] = filter_group.model_dump( - by_alias=True, exclude_none=True - ) - if sort_options: - body["sortOptions"] = [ - opt.model_dump(by_alias=True, exclude_none=True) for opt in sort_options - ] - if selected_fields: - body["selectedFields"] = list(selected_fields) - if expansions: - body["expansions"] = [ - e.model_dump(by_alias=True, exclude_none=True) - if isinstance(e, BaseModel) - else e - for e in expansions - ] - if aggregates: - body["aggregates"] = [ - a.model_dump(by_alias=True, exclude_none=True) - if isinstance(a, BaseModel) - else a - for a in aggregates - ] - if group_by: - body["groupBy"] = list(group_by) - if joins: - body["joins"] = [ - j.model_dump(by_alias=True, exclude_none=True) for j in joins - ] - if binnings: - body["binnings"] = [ - b.model_dump(by_alias=True, exclude_none=True) for b in binnings - ] - if start is not None: - body["start"] = start - if limit is not None: - body["limit"] = limit + body = build_query_body( + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + ) params: Dict[str, Any] = {} if expansion_level is not None: diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service_v3.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service_v3.py new file mode 100644 index 000000000..c9e02b4db --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service_v3.py @@ -0,0 +1,615 @@ +"""Data-side operations for the v3 Data Fabric entities API (preview). + +Mirrors the record CRUD / query / attachment operations of the v1 +:class:`~uipath.platform.entities._entity_data_service.EntityDataService`, but +targets the ``datafabric_/api/v3/entities/{entityName}/...`` routes and returns +the v3 response models (:class:`EntityWriteResponseV3`, :class:`QueryResponseV3`, +:class:`BatchOperationResponse`). Query-body assembly, record normalisation, and +file handling are reused from the v1 service. +""" + +import logging +from typing import Any, Dict, List, Optional + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from ._entity_data_service import EntityDataService, FileContent, build_query_body +from ._entity_resolution import RoutingStrategy, create_routing_strategy +from .entities import ( + ChoiceSetValue, + EntityAggregate, + EntityBinning, + EntityJoin, + EntityQueryFilterGroup, + EntityQuerySortOption, + QueryRoutingOverrideContext, +) +from .entities_v3 import ( + BatchOperationResponse, + EntityWriteResponseV3, + QueryResponseV3, +) + +logger = logging.getLogger(__name__) + +_V3_BASE = "datafabric_/api/v3/entities" + + +class EntityDataServiceV3(BaseService): + """HTTP service for v3 entity-record and attachment operations. + + Backend target: ``datafabric_/api/v3/entities/{entityName}/...``. The + ``entity_key`` argument is used as the ``{entityName}`` path segment (v3 + name-based routing). + + !!! warning "Preview Feature" + This service is experimental. Behavior and parameters are subject to + change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + routing_strategy: Optional[RoutingStrategy] = None, + folders_map: Optional[Dict[str, str]] = None, + entity_name_overrides: Optional[Dict[str, str]] = None, + routing_context: Optional[QueryRoutingOverrideContext] = None, + ) -> None: + """Initialise the v3 data service.""" + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + self._routing_strategy: RoutingStrategy = ( + routing_strategy + if routing_strategy is not None + else create_routing_strategy( + folders_map=folders_map, + effective_entity_names=entity_name_overrides, + routing_context=routing_context, + folders_service=folders_service, + ) + ) + + # ------------------------------------------------------------------ + # Choice-set value lookup + # ------------------------------------------------------------------ + + def get_choiceset_values( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """v3 choice-set values; see :meth:`EntitiesServiceV3.get_choiceset_values`.""" + spec = self._choiceset_values_spec(choiceset_id, start=start, limit=limit) + response = self.request(spec.method, spec.endpoint, json=spec.json) + return EntityDataService._parse_choiceset_values(response) + + async def get_choiceset_values_async( + self, + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[ChoiceSetValue]: + """Async variant of :meth:`get_choiceset_values`.""" + spec = self._choiceset_values_spec(choiceset_id, start=start, limit=limit) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + return EntityDataService._parse_choiceset_values(response) + + # ------------------------------------------------------------------ + # List records (paged read) + # ------------------------------------------------------------------ + + def list_records( + self, + entity_key: str, + start: int = 0, + limit: int = 100, + expansion_level: Optional[int] = None, + ) -> QueryResponseV3: + """v3 paged read; see :meth:`EntitiesServiceV3.list_records`.""" + spec = self._read_spec(entity_key, start, limit, expansion_level) + response = self.request(spec.method, spec.endpoint, params=spec.params) + return QueryResponseV3.model_validate(response.json() or {}) + + async def list_records_async( + self, + entity_key: str, + start: int = 0, + limit: int = 100, + expansion_level: Optional[int] = None, + ) -> QueryResponseV3: + """Async variant of :meth:`list_records`.""" + spec = self._read_spec(entity_key, start, limit, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params + ) + return QueryResponseV3.model_validate(response.json() or {}) + + # ------------------------------------------------------------------ + # Single-record operations + # ------------------------------------------------------------------ + + def insert_record( + self, entity_key: str, data: Any, expansion_level: Optional[int] = None + ) -> EntityWriteResponseV3: + """v3 single insert; see :meth:`EntitiesServiceV3.insert_record`.""" + spec = self._write_spec(f"{entity_key}/insert", data, expansion_level) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityWriteResponseV3.model_validate(response.json()) + + async def insert_record_async( + self, entity_key: str, data: Any, expansion_level: Optional[int] = None + ) -> EntityWriteResponseV3: + """Async variant of :meth:`insert_record`.""" + spec = self._write_spec(f"{entity_key}/insert", data, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityWriteResponseV3.model_validate(response.json()) + + def get_record( + self, entity_key: str, record_id: str, expansion_level: Optional[int] = None + ) -> EntityWriteResponseV3: + """v3 read by id; see :meth:`EntitiesServiceV3.get_record`.""" + spec = self._read_record_spec(entity_key, record_id, expansion_level) + response = self.request(spec.method, spec.endpoint, params=spec.params) + return EntityWriteResponseV3.model_validate(response.json()) + + async def get_record_async( + self, entity_key: str, record_id: str, expansion_level: Optional[int] = None + ) -> EntityWriteResponseV3: + """Async variant of :meth:`get_record`.""" + spec = self._read_record_spec(entity_key, record_id, expansion_level) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params + ) + return EntityWriteResponseV3.model_validate(response.json()) + + def update_record( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityWriteResponseV3: + """v3 single update; see :meth:`EntitiesServiceV3.update_record`.""" + spec = self._write_spec( + f"{entity_key}/update/{record_id}", data, expansion_level + ) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityWriteResponseV3.model_validate(response.json()) + + async def update_record_async( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityWriteResponseV3: + """Async variant of :meth:`update_record`.""" + spec = self._write_spec( + f"{entity_key}/update/{record_id}", data, expansion_level + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return EntityWriteResponseV3.model_validate(response.json()) + + def delete_record(self, entity_key: str, record_id: str) -> EntityWriteResponseV3: + """v3 single delete; see :meth:`EntitiesServiceV3.delete_record`.""" + endpoint = Endpoint(f"{_V3_BASE}/{entity_key}/delete/{record_id}") + response = self.request("DELETE", endpoint) + return EntityWriteResponseV3.model_validate(response.json()) + + async def delete_record_async( + self, entity_key: str, record_id: str + ) -> EntityWriteResponseV3: + """Async variant of :meth:`delete_record`.""" + endpoint = Endpoint(f"{_V3_BASE}/{entity_key}/delete/{record_id}") + response = await self.request_async("DELETE", endpoint) + return EntityWriteResponseV3.model_validate(response.json()) + + # ------------------------------------------------------------------ + # Batch record operations + # ------------------------------------------------------------------ + + def insert_records( + self, + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """v3 batch insert; see :meth:`EntitiesServiceV3.insert_records`.""" + spec = self._batch_spec( + f"{entity_key}/insert-batch", records, expansion_level, fail_on_first + ) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return BatchOperationResponse.model_validate(response.json()) + + async def insert_records_async( + self, + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Async variant of :meth:`insert_records`.""" + spec = self._batch_spec( + f"{entity_key}/insert-batch", records, expansion_level, fail_on_first + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return BatchOperationResponse.model_validate(response.json()) + + def update_records( + self, + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """v3 batch update; see :meth:`EntitiesServiceV3.update_records`.""" + spec = self._batch_spec( + f"{entity_key}/update-batch", records, expansion_level, fail_on_first + ) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return BatchOperationResponse.model_validate(response.json()) + + async def update_records_async( + self, + entity_key: str, + records: List[Any], + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Async variant of :meth:`update_records`.""" + spec = self._batch_spec( + f"{entity_key}/update-batch", records, expansion_level, fail_on_first + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return BatchOperationResponse.model_validate(response.json()) + + def delete_records( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """v3 batch delete; see :meth:`EntitiesServiceV3.delete_records`.""" + spec = self._delete_batch_spec(entity_key, record_ids, fail_on_first) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return BatchOperationResponse.model_validate(response.json()) + + async def delete_records_async( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> BatchOperationResponse: + """Async variant of :meth:`delete_records`.""" + spec = self._delete_batch_spec(entity_key, record_ids, fail_on_first) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return BatchOperationResponse.model_validate(response.json()) + + # ------------------------------------------------------------------ + # Structured query + # ------------------------------------------------------------------ + + def retrieve_records( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + child_limit: Optional[int] = None, + ) -> QueryResponseV3: + """v3 structured query; see :meth:`EntitiesServiceV3.retrieve_records`.""" + spec = self._query_spec( + entity_key, + filter_group, + sort_options, + selected_fields, + expansions, + expansion_level, + aggregates, + group_by, + joins, + binnings, + start, + limit, + child_limit, + ) + response = self.request( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return QueryResponseV3.model_validate(response.json() or {}) + + async def retrieve_records_async( + self, + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup] = None, + sort_options: Optional[List[EntityQuerySortOption]] = None, + selected_fields: Optional[List[str]] = None, + expansions: Optional[List[Any]] = None, + expansion_level: Optional[int] = None, + aggregates: Optional[List[EntityAggregate]] = None, + group_by: Optional[List[str]] = None, + joins: Optional[List[EntityJoin]] = None, + binnings: Optional[List[EntityBinning]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + child_limit: Optional[int] = None, + ) -> QueryResponseV3: + """Async variant of :meth:`retrieve_records`.""" + spec = self._query_spec( + entity_key, + filter_group, + sort_options, + selected_fields, + expansions, + expansion_level, + aggregates, + group_by, + joins, + binnings, + start, + limit, + child_limit, + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, json=spec.json + ) + return QueryResponseV3.model_validate(response.json() or {}) + + # ------------------------------------------------------------------ + # Attachments + # ------------------------------------------------------------------ + + def upload_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """v3 attachment upload; see :meth:`EntitiesServiceV3.upload_attachment`.""" + spec = self._attachment_spec(entity_id, record_id, field_name, expansion_level) + with EntityDataService._open_file(file, file_path) as handle: + response = self.request( + "POST", spec.endpoint, params=spec.params, files={"file": handle} + ) + return response.json() if response.content else {} + + async def upload_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Async variant of :meth:`upload_attachment`.""" + spec = self._attachment_spec(entity_id, record_id, field_name, expansion_level) + with EntityDataService._open_file(file, file_path) as handle: + response = await self.request_async( + "POST", spec.endpoint, params=spec.params, files={"file": handle} + ) + return response.json() if response.content else {} + + def download_attachment( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """v3 attachment download; see :meth:`EntitiesServiceV3.download_attachment`.""" + spec = self._attachment_spec(entity_id, record_id, field_name) + response = self.request("GET", spec.endpoint) + return response.content + + async def download_attachment_async( + self, entity_id: str, record_id: str, field_name: str + ) -> bytes: + """Async variant of :meth:`download_attachment`.""" + spec = self._attachment_spec(entity_id, record_id, field_name) + response = await self.request_async("GET", spec.endpoint) + return response.content + + def delete_attachment( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """v3 attachment delete; see :meth:`EntitiesServiceV3.delete_attachment`.""" + spec = self._attachment_spec(entity_id, record_id, field_name, expansion_level) + response = self.request("DELETE", spec.endpoint, params=spec.params) + return response.json() if response.content else {} + + async def delete_attachment_async( + self, + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> Dict[str, Any]: + """Async variant of :meth:`delete_attachment`.""" + spec = self._attachment_spec(entity_id, record_id, field_name, expansion_level) + response = await self.request_async("DELETE", spec.endpoint, params=spec.params) + return response.json() if response.content else {} + + # ------------------------------------------------------------------ + # Request-spec builders + # ------------------------------------------------------------------ + + @staticmethod + def _choiceset_values_spec( + choiceset_id: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_V3_BASE}/entity/{choiceset_id}/query_expansion"), + json=build_query_body(start=start, limit=limit), + ) + + @staticmethod + def _read_spec( + entity_key: str, + start: int, + limit: int, + expansion_level: Optional[int], + ) -> RequestSpec: + params: Dict[str, Any] = {"start": start, "limit": limit} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="GET", + endpoint=Endpoint(f"{_V3_BASE}/{entity_key}/read"), + params=params, + ) + + @staticmethod + def _read_record_spec( + entity_key: str, record_id: str, expansion_level: Optional[int] + ) -> RequestSpec: + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="GET", + endpoint=Endpoint(f"{_V3_BASE}/{entity_key}/read/{record_id}"), + params=params, + ) + + @staticmethod + def _write_spec( + path: str, data: Any, expansion_level: Optional[int] + ) -> RequestSpec: + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_V3_BASE}/{path}"), + params=params, + json=EntityDataService._record_to_dict(data), + ) + + @staticmethod + def _batch_spec( + path: str, + records: List[Any], + expansion_level: Optional[int], + fail_on_first: Optional[bool], + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_V3_BASE}/{path}"), + params=EntityDataService._batch_params( + expansion_level=expansion_level, fail_on_first=fail_on_first + ), + json=[EntityDataService._record_to_dict(record) for record in records], + ) + + @staticmethod + def _delete_batch_spec( + entity_key: str, record_ids: List[str], fail_on_first: Optional[bool] + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_V3_BASE}/{entity_key}/delete-batch"), + params=EntityDataService._batch_params(fail_on_first=fail_on_first), + json=list(record_ids), + ) + + @staticmethod + def _query_spec( + entity_key: str, + filter_group: Optional[EntityQueryFilterGroup], + sort_options: Optional[List[EntityQuerySortOption]], + selected_fields: Optional[List[str]], + expansions: Optional[List[Any]], + expansion_level: Optional[int], + aggregates: Optional[List[EntityAggregate]], + group_by: Optional[List[str]], + joins: Optional[List[EntityJoin]], + binnings: Optional[List[EntityBinning]], + start: Optional[int], + limit: Optional[int], + child_limit: Optional[int], + ) -> RequestSpec: + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint(f"{_V3_BASE}/{entity_key}/query"), + params=params, + json=build_query_body( + filter_group=filter_group, + sort_options=sort_options, + selected_fields=selected_fields, + expansions=expansions, + aggregates=aggregates, + group_by=group_by, + joins=joins, + binnings=binnings, + start=start, + limit=limit, + child_limit=child_limit, + ), + ) + + @staticmethod + def _attachment_spec( + entity_id: str, + record_id: str, + field_name: str, + expansion_level: Optional[int] = None, + ) -> RequestSpec: + params: Dict[str, Any] = {} + if expansion_level is not None: + params["expansionLevel"] = expansion_level + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"{_V3_BASE}/{entity_id}/records/{record_id}/attachments/{field_name}" + ), + params=params, + ) + + def _query_entities_for_records(self, sql_query: str) -> Any: + """Federated SQL is version-agnostic; delegated by the facade to v1.""" + raise NotImplementedError # pragma: no cover - not routed here diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service_v3.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service_v3.py new file mode 100644 index 000000000..05a08e2cb --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service_v3.py @@ -0,0 +1,179 @@ +"""Schema-side operations for the v3 Data Fabric entities API (preview). + +Mirrors the entity/choice-set lifecycle of the v1 +:class:`~uipath.platform.entities._entity_schema_service.EntitySchemaService`, +targeting the ``datafabric_/api/v3/entities`` routes and returning the v3 +schema models (:class:`EntityRecordV3`, :class:`CompositeEntityMetadataResponse`, +:class:`GetAllResponseV3`). Payload building and validation are reused from the +v1 service. +""" + +from typing import Any, Dict, List, Optional + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from ._entity_schema_service import EntitySchemaService +from .entities import ( + EntityCreateFieldOptions, + EntityCreateOptions, + EntityMetadataUpdateOptions, +) +from .entities_v3 import ( + CompositeEntityMetadataResponse, + EntityRecordV3, + GetAllResponseV3, +) + +_V3_BASE = "datafabric_/api/v3/entities" + + +class EntitySchemaServiceV3(BaseService): + """HTTP service for v3 entity-schema operations. + + Backend target: ``datafabric_/api/v3/entities``. + + !!! warning "Preview Feature" + This service is experimental. Behavior and parameters are subject to + change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Optional[FolderService] = None, + ) -> None: + """Initialise the v3 schema service.""" + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + def retrieve(self, entity_id: str) -> EntityRecordV3: + """v3 retrieve entity definition by id.""" + response = self.request("GET", Endpoint(f"{_V3_BASE}/{entity_id}")) + return EntityRecordV3.model_validate(response.json()) + + async def retrieve_async(self, entity_id: str) -> EntityRecordV3: + """Async variant of :meth:`retrieve`.""" + response = await self.request_async("GET", Endpoint(f"{_V3_BASE}/{entity_id}")) + return EntityRecordV3.model_validate(response.json()) + + def retrieve_by_name( + self, entity_name: str, folder_key: Optional[str] = None + ) -> CompositeEntityMetadataResponse: + """v3 retrieve entity metadata (composite-aware) by name.""" + headers = EntitySchemaService._folder_key_headers(folder_key) + response = self.request( + "GET", Endpoint(f"{_V3_BASE}/{entity_name}/metadata"), headers=headers + ) + return CompositeEntityMetadataResponse.model_validate(response.json()) + + async def retrieve_by_name_async( + self, entity_name: str, folder_key: Optional[str] = None + ) -> CompositeEntityMetadataResponse: + """Async variant of :meth:`retrieve_by_name`.""" + headers = EntitySchemaService._folder_key_headers(folder_key) + response = await self.request_async( + "GET", Endpoint(f"{_V3_BASE}/{entity_name}/metadata"), headers=headers + ) + return CompositeEntityMetadataResponse.model_validate(response.json()) + + def list_entities(self) -> List[EntityRecordV3]: + """v3 list all entity definitions.""" + response = self.request("GET", Endpoint(_V3_BASE)) + return [EntityRecordV3.model_validate(item) for item in response.json()] + + async def list_entities_async(self) -> List[EntityRecordV3]: + """Async variant of :meth:`list_entities`.""" + response = await self.request_async("GET", Endpoint(_V3_BASE)) + return [EntityRecordV3.model_validate(item) for item in response.json()] + + def get_all(self, start: int = 0, limit: int = 1000) -> GetAllResponseV3: + """v3 full schema catalog (entities + choice sets).""" + response = self.request( + "GET", Endpoint(f"{_V3_BASE}/all"), params={"start": start, "limit": limit} + ) + return GetAllResponseV3.model_validate(response.json() or {}) + + async def get_all_async( + self, start: int = 0, limit: int = 1000 + ) -> GetAllResponseV3: + """Async variant of :meth:`get_all`.""" + response = await self.request_async( + "GET", Endpoint(f"{_V3_BASE}/all"), params={"start": start, "limit": limit} + ) + return GetAllResponseV3.model_validate(response.json() or {}) + + def create_entity( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """v3 create an entity (reuses v1 payload building + validation).""" + spec = self._create_entity_spec(name, fields, options) + response = self.request(spec.method, spec.endpoint, json=spec.json) + return EntitySchemaService._extract_entity_id(response) + + async def create_entity_async( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Async variant of :meth:`create_entity`.""" + spec = self._create_entity_spec(name, fields, options) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + return EntitySchemaService._extract_entity_id(response) + + def delete_entity(self, entity_id: str) -> None: + """v3 soft-delete an entity.""" + self.request("DELETE", Endpoint(f"{_V3_BASE}/{entity_id}")) + + async def delete_entity_async(self, entity_id: str) -> None: + """Async variant of :meth:`delete_entity`.""" + await self.request_async("DELETE", Endpoint(f"{_V3_BASE}/{entity_id}")) + + def update_entity_metadata( + self, entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any] + ) -> None: + """v3 update entity metadata.""" + spec = self._update_metadata_spec(entity_id, metadata) + self.request(spec.method, spec.endpoint, json=spec.json) + + async def update_entity_metadata_async( + self, entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any] + ) -> None: + """Async variant of :meth:`update_entity_metadata`.""" + spec = self._update_metadata_spec(entity_id, metadata) + await self.request_async(spec.method, spec.endpoint, json=spec.json) + + # ------------------------------------------------------------------ + # Request-spec builders (reuse v1 payload building, v3 endpoints) + # ------------------------------------------------------------------ + + @staticmethod + def _create_entity_spec( + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> RequestSpec: + v1_spec = EntitySchemaService._create_entity_spec(name, fields, options) + return RequestSpec( + method="POST", endpoint=Endpoint(_V3_BASE), json=v1_spec.json + ) + + @staticmethod + def _update_metadata_spec( + entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any] + ) -> RequestSpec: + if not isinstance(metadata, EntityMetadataUpdateOptions): + metadata = EntityMetadataUpdateOptions.model_validate(metadata) + body = metadata.model_dump(by_alias=True, exclude_none=True) + return RequestSpec( + method="PATCH", + endpoint=Endpoint(f"{_V3_BASE}/{entity_id}/metadata"), + json=body, + ) diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities_v3.py b/packages/uipath-platform/src/uipath/platform/entities/entities_v3.py new file mode 100644 index 000000000..ac030965f --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/entities_v3.py @@ -0,0 +1,247 @@ +"""Response models for the v3 Data Fabric entities API (preview). + +The v3 API returns a different wire shape than v1/v2. Record reads and writes +return an :class:`EntityWriteResponseV3` *envelope* whose root-entity field +values are flattened onto the top-level JSON object alongside the reserved +envelope keys (``Id``, ``children``, ``cascadeDeletedChildren``, +``deletedCount``, ``updatedCount``). These models mirror the backend +``StorageManager/Models`` and ``Common/Model`` types and are returned by +:class:`~uipath.platform.entities._entities_service_v3.EntitiesServiceV3`. +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterator, List, Optional, overload + +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator + +from .entities import Entity + +# Reserved top-level keys on a v3 write/query envelope. Every other top-level +# key is a flattened root-entity field folded into ``root_fields``. +_ENVELOPE_KEYS = frozenset( + { + "Id", + "id", + "children", + "cascadeDeletedChildren", + "deletedCount", + "updatedCount", + } +) + + +class ChildArrayPaginationRef(BaseModel): + """Pagination pointer for a composite child array that has more records.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + query_url: Optional[str] = Field(default=None, alias="queryUrl") + query_request: Optional[Dict[str, Any]] = Field(default=None, alias="queryRequest") + + +class ChildArrayBlock(BaseModel): + """A page of child-member records nested under a composite parent record.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + records: List[Dict[str, Any]] = Field(default_factory=list, alias="records") + has_more: bool = Field(default=False, alias="hasMore") + ref: Optional[ChildArrayPaginationRef] = Field(default=None, alias="ref") + + +class EntityWriteResponseV3(BaseModel): + """A single v3 record envelope returned by reads, writes, and queries. + + Root-entity field values live in :attr:`root_fields`; the backend flattens + them onto the top-level JSON object, so on parse any top-level key that is + not a reserved envelope key is folded into ``root_fields``. Serialization + reproduces that flattening. ``children`` and the ``*_count`` fields carry + composite/write metadata and are empty/zero for plain single-entity records. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + id: Optional[str] = Field(default=None, alias="Id") + children: Dict[str, ChildArrayBlock] = Field(default_factory=dict, alias="children") + cascade_deleted_children: Dict[str, int] = Field( + default_factory=dict, alias="cascadeDeletedChildren" + ) + deleted_count: int = Field(default=0, alias="deletedCount") + updated_count: int = Field(default=0, alias="updatedCount") + root_fields: Dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="before") + @classmethod + def _fold_root_fields(cls, data: Any) -> Any: + """Fold flattened top-level tenant fields into ``root_fields``.""" + if not isinstance(data, dict): + return data + root: Dict[str, Any] = dict( + data.get("root_fields") or data.get("rootFields") or {} + ) + normalized: Dict[str, Any] = {} + for key, value in data.items(): + if key in ("root_fields", "rootFields"): + continue + if key in _ENVELOPE_KEYS: + normalized[key] = value + else: + root[key] = value + normalized["root_fields"] = root + return normalized + + @model_serializer(mode="plain") + def _serialize(self) -> Dict[str, Any]: + """Reproduce the flattened wire shape (root fields at the top level).""" + out: Dict[str, Any] = {} + if self.id is not None: + out["Id"] = self.id + out.update(self.root_fields) + out["children"] = { + name: block.model_dump(by_alias=True) + for name, block in self.children.items() + } + out["cascadeDeletedChildren"] = dict(self.cascade_deleted_children) + out["deletedCount"] = self.deleted_count + # The backend omits updatedCount when zero; mirror that. + if self.updated_count: + out["updatedCount"] = self.updated_count + return out + + def __getitem__(self, key: str) -> Any: + """Access a flattened root-entity field by name.""" + return self.root_fields[key] + + def get(self, key: str, default: Any = None) -> Any: + """Return a flattened root-entity field, or ``default`` if absent.""" + return self.root_fields.get(key, default) + + +class QueryResponseV3(BaseModel): + """Result of a v3 structured query. + + ``value`` is a list of :class:`EntityWriteResponseV3` envelopes. + ``total_record_count_is_estimate`` is ``True`` only when the backend used a + bounded-fetch fallback and the count is approximate. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + value: List[EntityWriteResponseV3] = Field(default_factory=list, alias="value") + total_record_count: int = Field(default=0, alias="totalRecordCount") + total_record_count_is_estimate: bool = Field( + default=False, alias="totalRecordCountIsEstimate" + ) + + def __iter__(self) -> Iterator[EntityWriteResponseV3]: # type: ignore[override] + """Iterate over the record envelopes (delegates to ``self.value``).""" + return iter(self.value) + + def __len__(self) -> int: + """Return the number of records (delegates to ``self.value``).""" + return len(self.value) + + @overload + def __getitem__(self, index: int) -> EntityWriteResponseV3: ... + + @overload + def __getitem__(self, index: slice) -> List[EntityWriteResponseV3]: ... + + def __getitem__( + self, index: int | slice + ) -> EntityWriteResponseV3 | List[EntityWriteResponseV3]: + """Index or slice the record envelopes (delegates to ``self.value``).""" + return self.value[index] + + +class BatchOperationFailureRecord(BaseModel): + """A single record that failed within a v3 batch operation.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + error: Optional[str] = Field(default=None, alias="error") + record: Optional[Dict[str, Any]] = Field(default=None, alias="record") + + +class BatchOperationResponse(BaseModel): + """Result of a v3 batch insert/update/delete. + + ``success_records`` are flat field-value dicts (not envelopes); + ``failure_records`` carry the per-record error plus the original payload. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + success_records: List[Dict[str, Any]] = Field( + default_factory=list, alias="successRecords" + ) + failure_records: List[BatchOperationFailureRecord] = Field( + default_factory=list, alias="failureRecords" + ) + + +class EntityRecordV3(Entity): + """A v3 entity *definition* (schema), extending :class:`Entity`. + + Adds ``entity_class`` (``Native`` / ``Federated`` / ``Case``), a computed + classification the v3 schema endpoints return. + """ + + entity_class: Optional[str] = Field(default=None, alias="entityClass") + + +class CompositeMemberMetadata(BaseModel): + """Metadata for a single member of a composite entity.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + entity_id: str = Field(alias="entityId") + entity_name: str = Field(alias="entityName") + display_name: Optional[str] = Field(default=None, alias="displayName") + is_root: bool = Field(default=False, alias="isRoot") + parent_entity_name: Optional[str] = Field(default=None, alias="parentEntityName") + foreign_key_field_name: Optional[str] = Field( + default=None, alias="foreignKeyFieldName" + ) + parent_target_field_name: Optional[str] = Field( + default=None, alias="parentTargetFieldName" + ) + + +class CompositeInfo(BaseModel): + """Structure of a composite entity: its root and member entities.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + entity_id: str = Field(alias="entityId") + root_entity_name: Optional[str] = Field(default=None, alias="rootEntityName") + members: List[CompositeMemberMetadata] = Field( + default_factory=list, alias="members" + ) + + +class CompositeEntityMetadataResponse(EntityRecordV3): + """v3 entity metadata; extends :class:`EntityRecordV3` with composite info.""" + + is_composite: bool = Field(default=False, alias="isComposite") + composite_info: Optional[CompositeInfo] = Field(default=None, alias="compositeInfo") + + +class GetAllResponseV3(BaseModel): + """Full v3 schema catalog: all entities plus all choice sets in one call.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + entities: List[EntityRecordV3] = Field(default_factory=list, alias="entities") + choicesets: List[Dict[str, Any]] = Field(default_factory=list, alias="choicesets") diff --git a/packages/uipath-platform/tests/services/test_entities_v2.py b/packages/uipath-platform/tests/services/test_entities_v2.py new file mode 100644 index 000000000..3feef97cd --- /dev/null +++ b/packages/uipath-platform/tests/services/test_entities_v2.py @@ -0,0 +1,140 @@ +"""Tests for the v2 entities facade (``sdk.entities_v2``).""" + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.entities import ( + EntitiesServiceV2, + EntityQueryFilter, + EntityQueryFilterGroup, + QueryFilterOperator, + RetrieveEntityRecordsResponse, +) + + +@pytest.fixture +def service_v2( + config: UiPathApiConfig, execution_context: UiPathExecutionContext +) -> EntitiesServiceV2: + return EntitiesServiceV2(config=config, execution_context=execution_context) + + +def test_retrieve_records_hits_v2_endpoint( + service_v2: EntitiesServiceV2, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + entity_key = "Customers" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v2/EntityService/entity/{entity_key}/query", + method="POST", + json={"value": [{"Id": "r1", "name": "Alice"}], "totalRecordCount": 1}, + ) + + result = service_v2.retrieve_records( + entity_key, + filter_group=EntityQueryFilterGroup( + query_filters=[ + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.Equals, + value="active", + ) + ] + ), + ) + + assert isinstance(result, RetrieveEntityRecordsResponse) + assert result.total_count == 1 + assert result.items[0].id == "r1" + sent = httpx_mock.get_request() + assert sent is not None + assert "/datafabric_/api/v2/EntityService/entity/Customers/query" in str(sent.url) + + +def test_get_record_hits_v2_endpoint( + service_v2: EntitiesServiceV2, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v2/EntityService/entity/Customers/read/rec-1", + method="GET", + json={"Id": "rec-1", "name": "Alice"}, + ) + + record = service_v2.get_record("Customers", "rec-1") + + assert record.id == "rec-1" + sent = httpx_mock.get_request() + assert "/datafabric_/api/v2/EntityService/entity/Customers/read/rec-1" in str( + sent.url + ) + + +def test_list_entities_hits_v2_endpoint( + service_v2: EntitiesServiceV2, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v2/Entity", + method="GET", + json=[ + { + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "isRbacEnabled": False, + "id": "e1", + } + ], + ) + + entities = service_v2.list_entities() + + assert len(entities) == 1 + assert entities[0].name == "Customers" + assert "/datafabric_/api/v2/Entity" in str(httpx_mock.get_request().url) + + +def test_v2_omits_unsupported_operations(service_v2: EntitiesServiceV2) -> None: + # v2 is a limited surface: write/batch/attachment/schema-write ops are not + # exposed because the backend does not implement them. + for name in ( + "insert_record", + "update_record", + "delete_record", + "insert_records", + "delete_records", + "create_entity", + "upload_attachment", + "get_choiceset_values", + ): + assert not hasattr(service_v2, name), f"v2 should not expose {name}" + + +async def test_retrieve_records_async_hits_v2_endpoint( + service_v2: EntitiesServiceV2, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v2/EntityService/entity/Customers/query", + method="POST", + json={"value": [], "totalRecordCount": 0}, + ) + + result = await service_v2.retrieve_records_async("Customers") + + assert isinstance(result, RetrieveEntityRecordsResponse) + assert result.total_count == 0 diff --git a/packages/uipath-platform/tests/services/test_entities_v3_service.py b/packages/uipath-platform/tests/services/test_entities_v3_service.py new file mode 100644 index 000000000..130ff0af8 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_entities_v3_service.py @@ -0,0 +1,424 @@ +"""Tests for the v3 entities facade (``sdk.entities_v3``) and v3 models.""" + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.entities import ( + BatchOperationResponse, + CompositeEntityMetadataResponse, + EntitiesServiceV3, + EntityRecordV3, + EntityWriteResponseV3, + GetAllResponseV3, + QueryResponseV3, +) + +_V3 = "datafabric_/api/v3/entities" + + +@pytest.fixture +def service_v3( + config: UiPathApiConfig, execution_context: UiPathExecutionContext +) -> EntitiesServiceV3: + return EntitiesServiceV3(config=config, execution_context=execution_context) + + +# --------------------------------------------------------------------------- +# Models (unit) — the EntityWriteResponseV3 flattening contract +# --------------------------------------------------------------------------- + + +def test_write_response_folds_and_reflattens() -> None: + payload = { + "Id": "r1", + "CaseId": "CASE-001", + "CaseStatus": "Open", + "children": {}, + "cascadeDeletedChildren": {}, + "deletedCount": 0, + } + w = EntityWriteResponseV3.model_validate(payload) + assert w.id == "r1" + assert w.root_fields == {"CaseId": "CASE-001", "CaseStatus": "Open"} + assert w.get("CaseId") == "CASE-001" + # updatedCount omitted when zero; root fields flattened back to top level + dumped = w.model_dump() + assert dumped["Id"] == "r1" + assert dumped["CaseId"] == "CASE-001" + assert "updatedCount" not in dumped + assert dumped["deletedCount"] == 0 + + +def test_write_response_keeps_children_and_updated_count() -> None: + w = EntityWriteResponseV3.model_validate( + { + "Id": "r1", + "Name": "x", + "children": { + "Lines": {"records": [{"Id": "c1"}], "hasMore": True, "ref": None} + }, + "cascadeDeletedChildren": {"Lines": 2}, + "deletedCount": 0, + "updatedCount": 3, + } + ) + assert w.children["Lines"].has_more is True + assert w.children["Lines"].records == [{"Id": "c1"}] + assert w.cascade_deleted_children == {"Lines": 2} + assert w.updated_count == 3 + assert w.model_dump()["updatedCount"] == 3 + + +# --------------------------------------------------------------------------- +# Data operations +# --------------------------------------------------------------------------- + + +def test_retrieve_records_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/query", + method="POST", + json={ + "value": [ + {"Id": "r1", "name": "Alice", "children": {}}, + {"Id": "r2", "name": "Bob", "children": {}}, + ], + "totalRecordCount": 2, + "totalRecordCountIsEstimate": False, + }, + ) + + result = service_v3.retrieve_records("Customers", selected_fields=["name"]) + + assert isinstance(result, QueryResponseV3) + assert result.total_record_count == 2 + assert len(result) == 2 + assert result[0].id == "r1" + assert result[0].get("name") == "Alice" + assert f"/{_V3}/Customers/query" in str(httpx_mock.get_request().url) + + +def test_insert_record_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/insert", + method="POST", + json={"Id": "r1", "name": "Alice", "children": {}, "deletedCount": 0}, + ) + + record = service_v3.insert_record("Customers", {"name": "Alice"}) + + assert isinstance(record, EntityWriteResponseV3) + assert record.id == "r1" + assert record.get("name") == "Alice" + sent = httpx_mock.get_request() + assert f"/{_V3}/Customers/insert" in str(sent.url) + + +def test_get_record_v3_folds_flat_dict( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/read/rec-1", + method="GET", + json={"Id": "rec-1", "name": "Alice", "age": 30}, + ) + + record = service_v3.get_record("Customers", "rec-1") + + assert record.id == "rec-1" + assert record.root_fields == {"name": "Alice", "age": 30} + + +def test_update_record_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/update/rec-1", + method="POST", + json={"Id": "rec-1", "name": "Alice2"}, + ) + + record = service_v3.update_record("Customers", "rec-1", {"name": "Alice2"}) + + assert record.get("name") == "Alice2" + assert f"/{_V3}/Customers/update/rec-1" in str(httpx_mock.get_request().url) + + +def test_delete_record_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/delete/rec-1", + method="DELETE", + json={"Id": "rec-1", "deletedCount": 1}, + ) + + record = service_v3.delete_record("Customers", "rec-1") + + assert record.deleted_count == 1 + sent = httpx_mock.get_request() + assert sent.method == "DELETE" + assert f"/{_V3}/Customers/delete/rec-1" in str(sent.url) + + +def test_insert_records_batch_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/insert-batch", + method="POST", + json={ + "successRecords": [{"Id": "r1", "name": "Alice"}], + "failureRecords": [{"error": "dup", "record": {"name": "Bob"}}], + }, + ) + + result = service_v3.insert_records( + "Customers", [{"name": "Alice"}, {"name": "Bob"}] + ) + + assert isinstance(result, BatchOperationResponse) + assert result.success_records[0]["Id"] == "r1" + assert result.failure_records[0].error == "dup" + + +def test_delete_records_batch_v3_sends_id_list( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + import json + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/delete-batch", + method="POST", + json={"successRecords": [], "failureRecords": []}, + ) + + service_v3.delete_records("Customers", ["id-1", "id-2"]) + + sent = httpx_mock.get_request() + assert json.loads(sent.content) == ["id-1", "id-2"] + assert f"/{_V3}/Customers/delete-batch" in str(sent.url) + + +def test_list_records_v3_sends_paging( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/read?start=0&limit=100", + method="GET", + json={"value": [{"Id": "r1"}], "totalRecordCount": 1}, + ) + + result = service_v3.list_records("Customers") + + assert isinstance(result, QueryResponseV3) + assert result[0].id == "r1" + + +def test_upload_attachment_v3_endpoint( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/records/rec-1/attachments/Contract", + method="POST", + json={"Id": "rec-1"}, + ) + + service_v3.upload_attachment("Customers", "rec-1", "Contract", file=b"data") + + sent = httpx_mock.get_request() + assert "/records/rec-1/attachments/Contract" in str(sent.url) + + +def test_import_records_not_supported_in_v3(service_v3: EntitiesServiceV3) -> None: + with pytest.raises(NotImplementedError): + service_v3.import_records("Customers", file=b"csv") + + +# --------------------------------------------------------------------------- +# Schema operations +# --------------------------------------------------------------------------- + + +def test_list_entities_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}", + method="GET", + json=[ + { + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "isRbacEnabled": False, + "id": "e1", + "entityClass": "Native", + } + ], + ) + + entities = service_v3.list_entities() + + assert isinstance(entities[0], EntityRecordV3) + assert entities[0].entity_class == "Native" + + +def test_retrieve_by_name_v3_metadata( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Orders/metadata", + method="GET", + json={ + "name": "Orders", + "displayName": "Orders", + "entityType": "Entity", + "isRbacEnabled": False, + "id": "e2", + "isComposite": True, + "compositeInfo": { + "entityId": "e2", + "rootEntityName": "Orders", + "members": [ + {"entityId": "e2", "entityName": "Orders", "isRoot": True}, + { + "entityId": "e3", + "entityName": "OrderLines", + "isRoot": False, + "parentEntityName": "Orders", + }, + ], + }, + }, + ) + + meta = service_v3.retrieve_by_name("Orders") + + assert isinstance(meta, CompositeEntityMetadataResponse) + assert meta.is_composite is True + assert meta.composite_info is not None + assert len(meta.composite_info.members) == 2 + assert meta.composite_info.members[1].entity_name == "OrderLines" + + +def test_get_all_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/all?start=0&limit=1000", + method="GET", + json={ + "entities": [ + { + "name": "Customers", + "displayName": "Customers", + "entityType": "Entity", + "isRbacEnabled": False, + "id": "e1", + } + ], + "choicesets": [{"id": "cs1", "name": "Colors"}], + }, + ) + + catalog = service_v3.get_all() + + assert isinstance(catalog, GetAllResponseV3) + assert catalog.entities[0].name == "Customers" + assert catalog.choicesets[0]["name"] == "Colors" + + +def test_create_entity_v3_posts_to_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + from uipath.platform.entities import EntityCreateFieldOptions, EntityFieldDataType + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}", + method="POST", + json="new-entity-id", + ) + + entity_id = service_v3.create_entity( + "Products", + [EntityCreateFieldOptions(field_name="title", type=EntityFieldDataType.STRING)], + ) + + assert entity_id == "new-entity-id" + assert str(httpx_mock.get_request().url).endswith(f"/{_V3}") + + +async def test_insert_record_async_v3( + service_v3: EntitiesServiceV3, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, +) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/{_V3}/Customers/insert", + method="POST", + json={"Id": "r1", "name": "Async"}, + ) + + record = await service_v3.insert_record_async("Customers", {"name": "Async"}) + + assert record.get("name") == "Async"