diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index fe8dc8a9c..2f1b6a7c8 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.23" +version = "0.2.24" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index 8c323eca3..4cfcf0b5f 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -8,11 +8,19 @@ from .entities import ( AggregateRow, ChoiceSetValue, + DataDirectionType, DataFabricEntityItem, Entity, EntityAggregate, EntityAggregateFunction, EntityBinning, + EntityClass, + EntityClassId, + EntityCreateExternalConnection, + EntityCreateExternalField, + EntityCreateExternalFieldMapping, + EntityCreateExternalObject, + EntityCreateExternalSource, EntityCreateFieldOptions, EntityCreateOptions, EntityField, @@ -35,17 +43,24 @@ FailureRecord, FieldDataType, FieldMetadata, + JoinType, LogicalOperator, + NativeConnectionDetail, QueryFilterOperator, QueryRoutingOverrideContext, ReferenceType, RetrieveEntityRecordsResponse, + Searchability, + SearchabilityNamedSearch, + SearchabilityOperator, + SourceJoinConditionDetail, SourceJoinCriteria, ) __all__ = [ "AggregateRow", "ChoiceSetValue", + "DataDirectionType", "DataFabricEntityItem", "DataFabricOntologyItem", "EntitiesService", @@ -53,6 +68,13 @@ "EntityAggregate", "EntityAggregateFunction", "EntityBinning", + "EntityClass", + "EntityClassId", + "EntityCreateExternalConnection", + "EntityCreateExternalField", + "EntityCreateExternalFieldMapping", + "EntityCreateExternalObject", + "EntityCreateExternalSource", "EntityCreateFieldOptions", "EntityCreateOptions", "EntityField", @@ -75,10 +97,16 @@ "FailureRecord", "FieldDataType", "FieldMetadata", + "JoinType", "LogicalOperator", + "NativeConnectionDetail", "QueryFilterOperator", "QueryRoutingOverrideContext", "ReferenceType", "RetrieveEntityRecordsResponse", + "Searchability", + "SearchabilityNamedSearch", + "SearchabilityOperator", + "SourceJoinConditionDetail", "SourceJoinCriteria", ] diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 2c3c84a51..4307a1469 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -18,6 +18,7 @@ from typing import Any, Dict, List, Optional, Type from httpx import Response +from typing_extensions import deprecated from uipath.core.tracing import traced from ..common._base_service import BaseService @@ -129,6 +130,7 @@ async def aclose(self) -> None: # Schema operations — delegate to EntitySchemaService # ------------------------------------------------------------------ + @deprecated("Deprecated; use retrieve_v3 (v3 API, supports Federated entities).") @traced(name="entity_retrieve", run_type="uipath") def retrieve(self, entity_key: str) -> Entity: """Retrieve an entity by its key. @@ -164,6 +166,18 @@ def retrieve(self, entity_key: str) -> Entity: """ return self._schema.retrieve(entity_key) + @traced(name="entity_retrieve_v3", run_type="uipath") + def retrieve_v3(self, entity_key: str) -> Entity: + """Retrieve an entity by key via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`retrieve` for parameter and return details. + """ + return self._schema.retrieve(entity_key, use_v3=True) + + @deprecated( + "Deprecated; use retrieve_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_retrieve", run_type="uipath") async def retrieve_async(self, entity_key: str) -> Entity: """Asynchronously retrieve an entity by its key. @@ -199,6 +213,14 @@ async def retrieve_async(self, entity_key: str) -> Entity: """ return await self._schema.retrieve_async(entity_key) + @traced(name="entity_retrieve_v3", run_type="uipath") + async def retrieve_v3_async(self, entity_key: str) -> Entity: + """Async variant of :meth:`retrieve_v3`.""" + return await self._schema.retrieve_async(entity_key, use_v3=True) + + @deprecated( + "Deprecated; use retrieve_by_name_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_retrieve_by_name", run_type="uipath") def retrieve_by_name( self, entity_name: str, folder_key: Optional[str] = None @@ -215,6 +237,22 @@ def retrieve_by_name( """ return self._schema.retrieve_by_name(entity_name, folder_key=folder_key) + @traced(name="entity_retrieve_by_name_v3", run_type="uipath") + def retrieve_by_name_v3( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Retrieve an entity by name via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`retrieve_by_name` for parameter and return details. + """ + return self._schema.retrieve_by_name( + entity_name, folder_key=folder_key, use_v3=True + ) + + @deprecated( + "Deprecated; use retrieve_by_name_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_retrieve_by_name", run_type="uipath") async def retrieve_by_name_async( self, entity_name: str, folder_key: Optional[str] = None @@ -233,6 +271,18 @@ async def retrieve_by_name_async( entity_name, folder_key=folder_key ) + @traced(name="entity_retrieve_by_name_v3", run_type="uipath") + async def retrieve_by_name_v3_async( + self, entity_name: str, folder_key: Optional[str] = None + ) -> Entity: + """Async variant of :meth:`retrieve_by_name_v3`.""" + return await self._schema.retrieve_by_name_async( + entity_name, folder_key=folder_key, use_v3=True + ) + + @deprecated( + "Deprecated; use list_entities_v3 (v3 API, supports Federated entities)." + ) @traced(name="list_entities", run_type="uipath") def list_entities(self) -> List[Entity]: """List all entities in Data Service. @@ -272,6 +322,18 @@ def list_entities(self) -> List[Entity]: """ return self._schema.list_entities() + @traced(name="list_entities_v3", run_type="uipath") + def list_entities_v3(self) -> List[Entity]: + """List all entities via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`list_entities` for parameter and return details. + """ + return self._schema.list_entities(use_v3=True) + + @deprecated( + "Deprecated; use list_entities_v3_async (v3 API, supports Federated entities)." + ) @traced(name="list_entities", run_type="uipath") async def list_entities_async(self) -> List[Entity]: """Asynchronously list all entities in the Data Service. @@ -311,6 +373,11 @@ async def list_entities_async(self) -> List[Entity]: """ return await self._schema.list_entities_async() + @traced(name="list_entities_v3", run_type="uipath") + async def list_entities_v3_async(self) -> List[Entity]: + """Async variant of :meth:`list_entities_v3`.""" + return await self._schema.list_entities_async(use_v3=True) + @traced(name="list_choicesets", run_type="uipath") def list_choicesets(self) -> List[Entity]: """List all choice sets in Data Service. @@ -336,6 +403,9 @@ async def list_choicesets_async(self) -> List[Entity]: """ return await self._schema.list_choicesets_async() + @deprecated( + "Deprecated; use create_entity_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_create", run_type="uipath") def create_entity( self, @@ -398,6 +468,23 @@ def create_entity( """ return self._schema.create_entity(name, fields, options) + @traced(name="entity_create_v3", run_type="uipath") + def create_entity_v3( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Create an entity via the v3 API (supports Federated entities). + + Experimental v3 surface (serves Federated entities); see + :meth:`create_entity` for parameter and return details. + """ + return self._schema.create_entity(name, fields, options, use_v3=True) + + @deprecated( + "Deprecated; use create_entity_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_create", run_type="uipath") async def create_entity_async( self, @@ -439,6 +526,21 @@ async def create_entity_async( """ return await self._schema.create_entity_async(name, fields, options) + @traced(name="entity_create_v3", run_type="uipath") + async def create_entity_v3_async( + self, + name: str, + fields: List[EntityCreateFieldOptions], + options: Optional[EntityCreateOptions] = None, + ) -> str: + """Async variant of :meth:`create_entity_v3`.""" + return await self._schema.create_entity_async( + name, fields, options, use_v3=True + ) + + @deprecated( + "Deprecated; use delete_entity_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_delete", run_type="uipath") def delete_entity(self, entity_id: str) -> None: """Delete an entity and all of its records. @@ -453,6 +555,18 @@ def delete_entity(self, entity_id: str) -> None: """ self._schema.delete_entity(entity_id) + @traced(name="entity_delete_v3", run_type="uipath") + def delete_entity_v3(self, entity_id: str) -> None: + """Delete an entity via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`delete_entity` for parameter and return details. + """ + self._schema.delete_entity(entity_id, use_v3=True) + + @deprecated( + "Deprecated; use delete_entity_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_delete", run_type="uipath") async def delete_entity_async(self, entity_id: str) -> None: """Asynchronously delete an entity and all of its records. @@ -467,6 +581,14 @@ async def delete_entity_async(self, entity_id: str) -> None: """ await self._schema.delete_entity_async(entity_id) + @traced(name="entity_delete_v3", run_type="uipath") + async def delete_entity_v3_async(self, entity_id: str) -> None: + """Async variant of :meth:`delete_entity_v3`.""" + await self._schema.delete_entity_async(entity_id, use_v3=True) + + @deprecated( + "Deprecated; use update_entity_metadata_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_update_metadata", run_type="uipath") def update_entity_metadata( self, @@ -506,6 +628,20 @@ def update_entity_metadata( """ self._schema.update_entity_metadata(entity_id, metadata) + @traced(name="entity_update_metadata_v3", run_type="uipath") + def update_entity_metadata_v3( + self, entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any] + ) -> None: + """Update entity metadata via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`update_entity_metadata` for parameter and return details. + """ + self._schema.update_entity_metadata(entity_id, metadata, use_v3=True) + + @deprecated( + "Deprecated; use update_entity_metadata_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_update_metadata", run_type="uipath") async def update_entity_metadata_async( self, @@ -537,6 +673,15 @@ async def update_entity_metadata_async( # Data operations — delegate to EntityDataService # ------------------------------------------------------------------ + @traced(name="entity_update_metadata_v3", run_type="uipath") + async def update_entity_metadata_v3_async( + self, entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any] + ) -> None: + """Async variant of :meth:`update_entity_metadata_v3`.""" + await self._schema.update_entity_metadata_async( + entity_id, metadata, use_v3=True + ) + @traced(name="get_choiceset_values", run_type="uipath") def get_choiceset_values( self, @@ -585,6 +730,9 @@ async def get_choiceset_values_async( choiceset_id, start=start, limit=limit ) + @deprecated( + "Deprecated; use list_records_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_list_records", run_type="uipath") def list_records( self, @@ -709,6 +857,32 @@ class CustomerRecord: expansion_level=expansion_level, ) + @traced(name="entity_list_records_v3", run_type="uipath") + def list_records_v3( + self, + entity_key: str, + schema: Optional[Type[Any]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + expansion_level: Optional[int] = None, + ) -> EntityRecordsListResponse: + """List entity records via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`list_records` for parameter and return details. + """ + return self._data.list_records( + entity_key, + schema=schema, + start=start, + limit=limit, + expansion_level=expansion_level, + use_v3=True, + ) + + @deprecated( + "Deprecated; use list_records_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_list_records", run_type="uipath") async def list_records_async( self, @@ -821,6 +995,28 @@ class CustomerRecord: expansion_level=expansion_level, ) + @traced(name="entity_list_records_v3", run_type="uipath") + async def list_records_v3_async( + self, + entity_key: str, + schema: Optional[Type[Any]] = None, + start: Optional[int] = None, + limit: Optional[int] = None, + expansion_level: Optional[int] = None, + ) -> EntityRecordsListResponse: + """Async variant of :meth:`list_records_v3`.""" + return await self._data.list_records_async( + entity_key, + schema=schema, + start=start, + limit=limit, + expansion_level=expansion_level, + use_v3=True, + ) + + @deprecated( + "Deprecated; use insert_record_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_insert_record", run_type="uipath") def insert_record( self, @@ -871,6 +1067,22 @@ class CustomerInput(BaseModel): entity_key, data, expansion_level=expansion_level ) + @traced(name="entity_insert_record_v3", run_type="uipath") + def insert_record_v3( + self, entity_key: str, data: Any, expansion_level: Optional[int] = None + ) -> EntityRecord: + """Insert a single record via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`insert_record` for parameter and return details. + """ + return self._data.insert_record( + entity_key, data, expansion_level=expansion_level, use_v3=True + ) + + @deprecated( + "Deprecated; use insert_record_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_insert_record", run_type="uipath") async def insert_record_async( self, @@ -908,6 +1120,16 @@ async def insert_record_async( entity_key, data, expansion_level=expansion_level ) + @traced(name="entity_insert_record_v3", run_type="uipath") + async def insert_record_v3_async( + self, entity_key: str, data: Any, expansion_level: Optional[int] = None + ) -> EntityRecord: + """Async variant of :meth:`insert_record_v3`.""" + return await self._data.insert_record_async( + entity_key, data, expansion_level=expansion_level, use_v3=True + ) + + @deprecated("Deprecated; use get_record_v3 (v3 API, supports Federated entities).") @traced(name="entity_get_record", run_type="uipath") def get_record( self, @@ -943,6 +1165,22 @@ def get_record( entity_key, record_id, expansion_level=expansion_level ) + @traced(name="entity_get_record_v3", run_type="uipath") + def get_record_v3( + self, entity_key: str, record_id: str, expansion_level: Optional[int] = None + ) -> EntityRecord: + """Fetch a single record by id via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`get_record` for parameter and return details. + """ + return self._data.get_record( + entity_key, record_id, expansion_level=expansion_level, use_v3=True + ) + + @deprecated( + "Deprecated; use get_record_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_get_record", run_type="uipath") async def get_record_async( self, @@ -971,6 +1209,18 @@ async def get_record_async( entity_key, record_id, expansion_level=expansion_level ) + @traced(name="entity_get_record_v3", run_type="uipath") + async def get_record_v3_async( + self, entity_key: str, record_id: str, expansion_level: Optional[int] = None + ) -> EntityRecord: + """Async variant of :meth:`get_record_v3`.""" + return await self._data.get_record_async( + entity_key, record_id, expansion_level=expansion_level, use_v3=True + ) + + @deprecated( + "Deprecated; use update_record_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_update_record", run_type="uipath") def update_record( self, @@ -1020,6 +1270,26 @@ def update_record( entity_key, record_id, data, expansion_level=expansion_level ) + @traced(name="entity_update_record_v3", run_type="uipath") + def update_record_v3( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Update a single record by id via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`update_record` for parameter and return details. + """ + return self._data.update_record( + entity_key, record_id, data, expansion_level=expansion_level, use_v3=True + ) + + @deprecated( + "Deprecated; use update_record_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_update_record", run_type="uipath") async def update_record_async( self, @@ -1057,6 +1327,22 @@ async def update_record_async( entity_key, record_id, data, expansion_level=expansion_level ) + @traced(name="entity_update_record_v3", run_type="uipath") + async def update_record_v3_async( + self, + entity_key: str, + record_id: str, + data: Any, + expansion_level: Optional[int] = None, + ) -> EntityRecord: + """Async variant of :meth:`update_record_v3`.""" + return await self._data.update_record_async( + entity_key, record_id, data, expansion_level=expansion_level, use_v3=True + ) + + @deprecated( + "Deprecated; use delete_record_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_delete_record", run_type="uipath") def delete_record(self, entity_key: str, record_id: str) -> None: """Delete a single record by id. @@ -1077,6 +1363,18 @@ def delete_record(self, entity_key: str, record_id: str) -> None: """ self._data.delete_record(entity_key, record_id) + @traced(name="entity_delete_record_v3", run_type="uipath") + def delete_record_v3(self, entity_key: str, record_id: str) -> None: + """Delete a single record by id via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`delete_record` for parameter and return details. + """ + self._data.delete_record(entity_key, record_id, use_v3=True) + + @deprecated( + "Deprecated; use delete_record_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_delete_record", run_type="uipath") async def delete_record_async(self, entity_key: str, record_id: str) -> None: """Asynchronously delete a single record by id. @@ -1096,6 +1394,11 @@ async def delete_record_async(self, entity_key: str, record_id: str) -> None: """ await self._data.delete_record_async(entity_key, record_id) + @traced(name="entity_delete_record_v3", run_type="uipath") + async def delete_record_v3_async(self, entity_key: str, record_id: str) -> None: + """Async variant of :meth:`delete_record_v3`.""" + await self._data.delete_record_async(entity_key, record_id, use_v3=True) + async def get_ontology_file_async( self, ontology_name: str, @@ -1119,6 +1422,9 @@ async def get_ontology_file_async( """ return await self._ontology.get_file_async(ontology_name, file_type, folder_key) + @deprecated( + "Deprecated; use insert_records_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_record_insert_batch", run_type="uipath") def insert_records( self, @@ -1213,6 +1519,32 @@ def __init__(self, name, email, age): fail_on_first=fail_on_first, ) + @traced(name="entity_record_insert_batch_v3", run_type="uipath") + def insert_records_v3( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Batch-insert records via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`insert_records` for parameter and return details. + """ + return self._data.insert_records( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + use_v3=True, + ) + + @deprecated( + "Deprecated; use insert_records_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_record_insert_batch", run_type="uipath") async def insert_records_async( self, @@ -1298,6 +1630,28 @@ def __init__(self, name, email, age): fail_on_first=fail_on_first, ) + @traced(name="entity_record_insert_batch_v3", run_type="uipath") + async def insert_records_v3_async( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`insert_records_v3`.""" + return await self._data.insert_records_async( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + use_v3=True, + ) + + @deprecated( + "Deprecated; use update_records_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_record_update_batch", run_type="uipath") def update_records( self, @@ -1384,6 +1738,32 @@ class CustomerSchema: fail_on_first=fail_on_first, ) + @traced(name="entity_record_update_batch_v3", run_type="uipath") + def update_records_v3( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Batch-update records via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`update_records` for parameter and return details. + """ + return self._data.update_records( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + use_v3=True, + ) + + @deprecated( + "Deprecated; use update_records_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_record_update_batch", run_type="uipath") async def update_records_async( self, @@ -1470,6 +1850,28 @@ class CustomerSchema: fail_on_first=fail_on_first, ) + @traced(name="entity_record_update_batch_v3", run_type="uipath") + async def update_records_v3_async( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + expansion_level: Optional[int] = None, + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`update_records_v3`.""" + return await self._data.update_records_async( + entity_key, + records, + schema=schema, + expansion_level=expansion_level, + fail_on_first=fail_on_first, + use_v3=True, + ) + + @deprecated( + "Deprecated; use delete_records_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_record_delete_batch", run_type="uipath") def delete_records( self, @@ -1531,6 +1933,25 @@ def delete_records( entity_key, record_ids, fail_on_first=fail_on_first ) + @traced(name="entity_record_delete_batch_v3", run_type="uipath") + def delete_records_v3( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Batch-delete records via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`delete_records` for parameter and return details. + """ + return self._data.delete_records( + entity_key, record_ids, fail_on_first=fail_on_first, use_v3=True + ) + + @deprecated( + "Deprecated; use delete_records_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_record_delete_batch", run_type="uipath") async def delete_records_async( self, @@ -1592,6 +2013,21 @@ async def delete_records_async( entity_key, record_ids, fail_on_first=fail_on_first ) + @traced(name="entity_record_delete_batch_v3", run_type="uipath") + async def delete_records_v3_async( + self, + entity_key: str, + record_ids: List[str], + fail_on_first: Optional[bool] = None, + ) -> EntityRecordsBatchResponse: + """Async variant of :meth:`delete_records_v3`.""" + return await self._data.delete_records_async( + entity_key, record_ids, fail_on_first=fail_on_first, use_v3=True + ) + + @deprecated( + "Deprecated; use retrieve_records_v3 (v3 API, supports Federated entities)." + ) @traced(name="entity_retrieve_records", run_type="uipath") def retrieve_records( self, @@ -1718,6 +2154,46 @@ def retrieve_records( limit=limit, ) + @traced(name="entity_retrieve_records_v3", run_type="uipath") + def retrieve_records_v3( + 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: + """Run a structured record query via the v3 API. + + Experimental v3 surface (serves Federated entities); see + :meth:`retrieve_records` for parameter and return details. + """ + 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, + use_v3=True, + ) + + @deprecated( + "Deprecated; use retrieve_records_v3_async (v3 API, supports Federated entities)." + ) @traced(name="entity_retrieve_records", run_type="uipath") async def retrieve_records_async( self, @@ -1807,6 +2283,39 @@ async def retrieve_records_async( limit=limit, ) + @traced(name="entity_retrieve_records_v3", run_type="uipath") + async def retrieve_records_v3_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_v3`.""" + 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, + use_v3=True, + ) + @attach_datafabric_error_mapping("query_entity_records") @traced(name="entity_query_records", run_type="uipath") def query_entity_records( @@ -2189,6 +2698,9 @@ def validate_entity_batch( # Cross-cutting — entity-set resolution for agent overrides # ------------------------------------------------------------------ + @deprecated( + "Deprecated; use resolve_entity_set_v3 (v3 API, supports Federated entities)." + ) @traced(name="resolve_entity_set", run_type="uipath") def resolve_entity_set( self, @@ -2222,6 +2734,9 @@ def resolve_entity_set( entities_service=resolution_service, ) + @deprecated( + "Deprecated; use resolve_entity_set_v3_async (v3 API, supports Federated entities)." + ) @traced(name="resolve_entity_set", run_type="uipath") async def resolve_entity_set_async( self, @@ -2259,6 +2774,81 @@ async def _resolve_folder_path(folder_path: str) -> Optional[str]: entities_service=resolution_service, ) + @traced(name="resolve_entity_set_v3", run_type="uipath") + def resolve_entity_set_v3( + self, + items: List[DataFabricEntityItem], + ) -> EntitySetResolution: + """Resolve an agent entity set via the v3 API (serves Federated entities). + + Experimental v3 surface; behaves like :meth:`resolve_entity_set` but + fetches entity metadata from ``datafabric_/api/v3/entities`` so Federated + entities resolve with their external field definitions. + """ + plan = create_resolution_plan( + items, + _resource_overwrites.get() or {}, + lambda folder_path: ( + self._folders_service.retrieve_key(folder_path=folder_path) + if self._folders_service is not None + else None + ), + ) + entities = fetch_resolved_entities( + plan, + self.retrieve_v3, + self.retrieve_by_name_v3, + logger, + ) + resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + plan=plan, + service_factory=EntitiesService, + ) + return EntitySetResolution( + entities=entities, + entities_service=resolution_service, + ) + + @traced(name="resolve_entity_set_v3", run_type="uipath") + async def resolve_entity_set_v3_async( + self, + items: List[DataFabricEntityItem], + ) -> EntitySetResolution: + """Async variant of :meth:`resolve_entity_set_v3`.""" + + async def _resolve_folder_path(folder_path: str) -> Optional[str]: + if self._folders_service is None: + return None + return await self._folders_service.retrieve_key_async( + folder_path=folder_path + ) + + plan = await create_resolution_plan_async( + items, + _resource_overwrites.get() or {}, + _resolve_folder_path, + ) + entities = await fetch_resolved_entities_async( + plan, + self.retrieve_v3_async, + self.retrieve_by_name_v3_async, + logger, + ) + resolution_service: EntitiesService = build_resolution_service( # type: ignore[assignment] + config=self._config, + execution_context=self._execution_context, + folders_service=self._folders_service, + plan=plan, + service_factory=EntitiesService, + ) + return EntitySetResolution( + entities=entities, + entities_service=resolution_service, + ) + # Resolve the forward reference to EntitiesService in EntitySetResolution. # The model uses TYPE_CHECKING to avoid circular imports in entities.py, 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 5be4541bb..68465c3cb 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 @@ -43,6 +43,17 @@ logger = logging.getLogger(__name__) +_V1_RECORD_ROOT = "datafabric_/api/EntityService/entity" +_V2_RECORD_ROOT = "datafabric_/api/v2/EntityService/entity" +_V3_RECORD_ROOT = "datafabric_/api/v3/entities/entity" + + +def _record_base(entity_key: str, use_v3: bool) -> str: + """Return the record-endpoint base for the requested API version.""" + root = _V3_RECORD_ROOT if use_v3 else _V1_RECORD_ROOT + return f"{root}/{entity_key}" + + FileContent = bytes | bytearray | memoryview """Acceptable raw bytes types for attachment and CSV uploads.""" @@ -144,6 +155,7 @@ def list_records( start: Optional[int] = None, limit: Optional[int] = None, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> EntityRecordsListResponse: """Internal implementation; see :meth:`EntitiesService.list_records`.""" spec = self._list_records_spec( @@ -151,6 +163,7 @@ def list_records( start=start, limit=limit, expansion_level=expansion_level, + use_v3=use_v3, ) response = self.request(spec.method, spec.endpoint, params=spec.params) return self._build_records_list_response(response, schema, start, limit) @@ -162,6 +175,7 @@ async def list_records_async( start: Optional[int] = None, limit: Optional[int] = None, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> EntityRecordsListResponse: """Async variant of :meth:`list_records`.""" spec = self._list_records_spec( @@ -169,6 +183,7 @@ async def list_records_async( start=start, limit=limit, expansion_level=expansion_level, + use_v3=use_v3, ) response = await self.request_async( spec.method, spec.endpoint, params=spec.params @@ -184,9 +199,12 @@ def insert_record( entity_key: str, data: Any, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> EntityRecord: """Internal implementation; see :meth:`EntitiesService.insert_record`.""" - spec = self._insert_record_spec(entity_key, data, expansion_level) + spec = self._insert_record_spec( + entity_key, data, expansion_level, use_v3=use_v3 + ) response = self.request( spec.method, spec.endpoint, params=spec.params, json=spec.json ) @@ -197,9 +215,12 @@ async def insert_record_async( entity_key: str, data: Any, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> EntityRecord: """Async variant of :meth:`insert_record`.""" - spec = self._insert_record_spec(entity_key, data, expansion_level) + spec = self._insert_record_spec( + entity_key, data, expansion_level, use_v3=use_v3 + ) response = await self.request_async( spec.method, spec.endpoint, params=spec.params, json=spec.json ) @@ -210,9 +231,12 @@ def get_record( entity_key: str, record_id: str, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> EntityRecord: """Fetch a single record by its id.""" - spec = self._get_record_spec(entity_key, record_id, expansion_level) + spec = self._get_record_spec( + entity_key, record_id, expansion_level, use_v3=use_v3 + ) response = self.request(spec.method, spec.endpoint, params=spec.params) return EntityRecord.model_validate(response.json()) @@ -221,9 +245,12 @@ async def get_record_async( entity_key: str, record_id: str, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> EntityRecord: """Async variant of :meth:`get_record`.""" - spec = self._get_record_spec(entity_key, record_id, expansion_level) + spec = self._get_record_spec( + entity_key, record_id, expansion_level, use_v3=use_v3 + ) response = await self.request_async( spec.method, spec.endpoint, params=spec.params ) @@ -235,9 +262,12 @@ def update_record( record_id: str, data: Any, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> EntityRecord: """Internal implementation; see :meth:`EntitiesService.update_record`.""" - spec = self._update_record_spec(entity_key, record_id, data, expansion_level) + spec = self._update_record_spec( + entity_key, record_id, data, expansion_level, use_v3=use_v3 + ) response = self.request( spec.method, spec.endpoint, params=spec.params, json=spec.json ) @@ -249,22 +279,29 @@ async def update_record_async( record_id: str, data: Any, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> EntityRecord: """Async variant of :meth:`update_record`.""" - spec = self._update_record_spec(entity_key, record_id, data, expansion_level) + spec = self._update_record_spec( + entity_key, record_id, data, expansion_level, use_v3=use_v3 + ) response = await self.request_async( spec.method, spec.endpoint, params=spec.params, json=spec.json ) return EntityRecord.model_validate(response.json()) - def delete_record(self, entity_key: str, record_id: str) -> None: + def delete_record( + self, entity_key: str, record_id: str, use_v3: bool = False + ) -> None: """Delete a single record by id.""" - spec = self._delete_record_spec(entity_key, record_id) + spec = self._delete_record_spec(entity_key, record_id, use_v3=use_v3) self.request(spec.method, spec.endpoint) - async def delete_record_async(self, entity_key: str, record_id: str) -> None: + async def delete_record_async( + self, entity_key: str, record_id: str, use_v3: bool = False + ) -> None: """Async variant of :meth:`delete_record`.""" - spec = self._delete_record_spec(entity_key, record_id) + spec = self._delete_record_spec(entity_key, record_id, use_v3=use_v3) await self.request_async(spec.method, spec.endpoint) # ------------------------------------------------------------------ @@ -278,6 +315,7 @@ def insert_records( schema: Optional[Type[Any]] = None, expansion_level: Optional[int] = None, fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> EntityRecordsBatchResponse: """Internal implementation; see :meth:`EntitiesService.insert_records`.""" spec = self._insert_batch_spec( @@ -285,6 +323,7 @@ def insert_records( records, expansion_level=expansion_level, fail_on_first=fail_on_first, + use_v3=use_v3, ) response = self._request_or_extract_batch( sync_call=lambda: self.request( @@ -302,6 +341,7 @@ async def insert_records_async( schema: Optional[Type[Any]] = None, expansion_level: Optional[int] = None, fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> EntityRecordsBatchResponse: """Async variant of :meth:`insert_records`.""" spec = self._insert_batch_spec( @@ -309,6 +349,7 @@ async def insert_records_async( records, expansion_level=expansion_level, fail_on_first=fail_on_first, + use_v3=use_v3, ) async def _send_batch() -> Response: @@ -328,6 +369,7 @@ def update_records( schema: Optional[Type[Any]] = None, expansion_level: Optional[int] = None, fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> EntityRecordsBatchResponse: """Internal implementation; see :meth:`EntitiesService.update_records`.""" normalized = [self._record_to_dict(record) for record in records] @@ -340,6 +382,7 @@ def update_records( normalized, expansion_level=expansion_level, fail_on_first=fail_on_first, + use_v3=use_v3, ) response = self._request_or_extract_batch( sync_call=lambda: self.request( @@ -357,6 +400,7 @@ async def update_records_async( schema: Optional[Type[Any]] = None, expansion_level: Optional[int] = None, fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> EntityRecordsBatchResponse: """Async variant of :meth:`update_records`.""" normalized = [self._record_to_dict(record) for record in records] @@ -369,6 +413,7 @@ async def update_records_async( normalized, expansion_level=expansion_level, fail_on_first=fail_on_first, + use_v3=use_v3, ) async def _send_batch() -> Response: @@ -386,10 +431,11 @@ def delete_records( entity_key: str, record_ids: List[str], fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> EntityRecordsBatchResponse: """Delete multiple records by id in a single batch.""" spec = self._delete_batch_spec( - entity_key, record_ids, fail_on_first=fail_on_first + entity_key, record_ids, fail_on_first=fail_on_first, use_v3=use_v3 ) result = self._request_or_extract_batch( sync_call=lambda: self.request( @@ -405,10 +451,11 @@ async def delete_records_async( entity_key: str, record_ids: List[str], fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> EntityRecordsBatchResponse: """Async variant of :meth:`delete_records`.""" spec = self._delete_batch_spec( - entity_key, record_ids, fail_on_first=fail_on_first + entity_key, record_ids, fail_on_first=fail_on_first, use_v3=use_v3 ) async def _send_batch() -> Response: @@ -439,6 +486,7 @@ def retrieve_records( binnings: Optional[List[EntityBinning]] = None, start: Optional[int] = None, limit: Optional[int] = None, + use_v3: bool = False, ) -> RetrieveEntityRecordsResponse: """Internal implementation; see :meth:`EntitiesService.retrieve_records`.""" spec = self._retrieve_records_spec( @@ -454,6 +502,7 @@ def retrieve_records( binnings=binnings, start=start, limit=limit, + use_v3=use_v3, ) response = self.request( spec.method, spec.endpoint, params=spec.params, json=spec.json @@ -474,6 +523,7 @@ async def retrieve_records_async( binnings: Optional[List[EntityBinning]] = None, start: Optional[int] = None, limit: Optional[int] = None, + use_v3: bool = False, ) -> RetrieveEntityRecordsResponse: """Async variant of :meth:`retrieve_records`.""" spec = self._retrieve_records_spec( @@ -489,6 +539,7 @@ async def retrieve_records_async( binnings=binnings, start=start, limit=limit, + use_v3=use_v3, ) response = await self.request_async( spec.method, spec.endpoint, params=spec.params, json=spec.json @@ -698,6 +749,7 @@ def _list_records_spec( start: Optional[int] = None, limit: Optional[int] = None, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> RequestSpec: """Build the GET spec for the multi-record read endpoint. @@ -715,9 +767,7 @@ def _list_records_spec( params["expansionLevel"] = expansion_level return RequestSpec( method="GET", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/read" - ), + endpoint=Endpoint(f"{_record_base(entity_key, use_v3)}/read"), params=params, ) @@ -726,6 +776,7 @@ def _insert_record_spec( entity_key: str, data: Any, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> RequestSpec: """Build the POST spec for inserting a single record.""" params: Dict[str, Any] = {} @@ -733,9 +784,7 @@ def _insert_record_spec( params["expansionLevel"] = expansion_level return RequestSpec( method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/insert" - ), + endpoint=Endpoint(f"{_record_base(entity_key, use_v3)}/insert"), params=params, json=EntityDataService._record_to_dict(data), ) @@ -745,6 +794,7 @@ def _get_record_spec( entity_key: str, record_id: str, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> RequestSpec: """Build the GET spec for fetching a single record by id.""" params: Dict[str, Any] = {} @@ -752,9 +802,7 @@ def _get_record_spec( params["expansionLevel"] = expansion_level return RequestSpec( method="GET", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/read/{record_id}" - ), + endpoint=Endpoint(f"{_record_base(entity_key, use_v3)}/read/{record_id}"), params=params, ) @@ -764,6 +812,7 @@ def _update_record_spec( record_id: str, data: Any, expansion_level: Optional[int] = None, + use_v3: bool = False, ) -> RequestSpec: """Build the POST spec for updating a single record by id.""" params: Dict[str, Any] = {} @@ -771,21 +820,19 @@ def _update_record_spec( params["expansionLevel"] = expansion_level return RequestSpec( method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/update/{record_id}" - ), + endpoint=Endpoint(f"{_record_base(entity_key, use_v3)}/update/{record_id}"), params=params, json=EntityDataService._record_to_dict(data), ) @staticmethod - def _delete_record_spec(entity_key: str, record_id: str) -> RequestSpec: + def _delete_record_spec( + entity_key: str, record_id: str, use_v3: bool = False + ) -> RequestSpec: """Build the DELETE spec for removing a single record by id.""" return RequestSpec( method="DELETE", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/delete/{record_id}" - ), + endpoint=Endpoint(f"{_record_base(entity_key, use_v3)}/delete/{record_id}"), ) @staticmethod @@ -794,6 +841,7 @@ def _insert_batch_spec( records: List[Any], expansion_level: Optional[int] = None, fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> RequestSpec: """Build the POST spec for the batch-insert endpoint.""" params = EntityDataService._batch_params( @@ -801,9 +849,7 @@ def _insert_batch_spec( ) return RequestSpec( method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/insert-batch" - ), + endpoint=Endpoint(f"{_record_base(entity_key, use_v3)}/insert-batch"), params=params, json=[EntityDataService._record_to_dict(record) for record in records], ) @@ -814,6 +860,7 @@ def _update_batch_spec( records: List[Dict[str, Any]], expansion_level: Optional[int] = None, fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> RequestSpec: """Build the POST spec for the batch-update endpoint.""" params = EntityDataService._batch_params( @@ -821,9 +868,7 @@ def _update_batch_spec( ) return RequestSpec( method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/update-batch" - ), + endpoint=Endpoint(f"{_record_base(entity_key, use_v3)}/update-batch"), params=params, json=records, ) @@ -833,14 +878,13 @@ def _delete_batch_spec( entity_key: str, record_ids: List[str], fail_on_first: Optional[bool] = None, + use_v3: bool = False, ) -> RequestSpec: """Build the POST spec for the batch-delete endpoint.""" params = EntityDataService._batch_params(fail_on_first=fail_on_first) return RequestSpec( method="POST", - endpoint=Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/delete-batch" - ), + endpoint=Endpoint(f"{_record_base(entity_key, use_v3)}/delete-batch"), params=params, json=record_ids, ) @@ -872,13 +916,19 @@ def _retrieve_records_spec( binnings: Optional[List[EntityBinning]] = None, start: Optional[int] = None, limit: Optional[int] = None, + use_v3: bool = False, ) -> RequestSpec: """Build the request spec for the structured-query endpoint. Filters, sorting, projection, expansions, aggregates, group-by, joins, binnings, ``start``, and ``limit`` are placed in the JSON body; - ``expansionLevel`` is a URL query parameter. The V2 endpoint is used - only when ``binnings`` are supplied. + ``expansionLevel`` is a URL query parameter. + + On v1, ``binnings`` route to the v2 by-key endpoint and everything else + to the v1 by-key endpoint. On v3, multi-entity ``joins`` stay on the v1 + by-key endpoint (the v3 by-id query rejects ``joins`` with a 400) while + everything else — including binnings and aggregates — uses the v3 by-id + query, which also serves Federated entities. """ body: Dict[str, Any] = {} if filter_group is not None: @@ -924,14 +974,15 @@ def _retrieve_records_spec( if expansion_level is not None: params["expansionLevel"] = expansion_level - if binnings: - endpoint = Endpoint( - f"datafabric_/api/v2/EntityService/entity/{entity_key}/query" - ) + if not use_v3: + if binnings: + endpoint = Endpoint(f"{_V2_RECORD_ROOT}/{entity_key}/query") + else: + endpoint = Endpoint(f"{_V1_RECORD_ROOT}/{entity_key}/query") + elif joins: + endpoint = Endpoint(f"{_V1_RECORD_ROOT}/{entity_key}/query") else: - endpoint = Endpoint( - f"datafabric_/api/EntityService/entity/{entity_key}/query" - ) + endpoint = Endpoint(f"{_V3_RECORD_ROOT}/{entity_key}/query") return RequestSpec( method="POST", diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py index a73b894ec..d3fc43c57 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_schema_service.py @@ -7,7 +7,7 @@ """ import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence from httpx import Response @@ -19,11 +19,15 @@ from ..common._models import Endpoint, RequestSpec from ..orchestrator._folder_service import FolderService from .entities import ( + ENTITY_CLASS_TO_ID_MAP, ENTITY_FIELD_CONSTRAINT_DEFAULTS, ENTITY_FIELD_CONSTRAINT_SPEC, ENTITY_SCHEMA_FIELD_TYPE_MAP, RESERVED_FIELD_NAMES, Entity, + EntityClass, + EntityCreateExternalField, + EntityCreateExternalSource, EntityCreateFieldOptions, EntityCreateOptions, EntityFieldDataType, @@ -32,6 +36,15 @@ DATA_FABRIC_TENANT_FOLDER_ID = "00000000-0000-0000-0000-000000000000" +_V1_ENTITIES = "datafabric_/api/Entity" +_V3_ENTITIES = "datafabric_/api/v3/entities" + + +def _schema_base(use_v3: bool) -> str: + """Return the entity-schema endpoint root for the requested API version.""" + return _V3_ENTITIES if use_v3 else _V1_ENTITIES + + _NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9]*$") """Entity and field name pattern: must start with a letter, then letters and digits only. @@ -49,7 +62,9 @@ class EntitySchemaService(BaseService): """HTTP service for entity-schema operations. Provides retrieval and lifecycle management for entities and choice sets. - Backend target: ``datafabric_/api/Entity``. + Each operation targets either the legacy v1 ``datafabric_/api/Entity`` + surface or the ``datafabric_/api/v3/entities`` surface depending on + ``use_v3``; only v3 serves Federated entities. Choice-set listing is v1 only. See Also: https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction @@ -69,46 +84,52 @@ def __init__( super().__init__(config=config, execution_context=execution_context) self._folders_service = folders_service - def retrieve(self, entity_key: str) -> Entity: + def retrieve(self, entity_key: str, use_v3: bool = False) -> Entity: """Internal implementation; see :meth:`EntitiesService.retrieve`.""" - spec = self._retrieve_spec(entity_key) + spec = self._retrieve_spec(entity_key, use_v3=use_v3) response = self.request(spec.method, spec.endpoint) return Entity.model_validate(response.json()) - async def retrieve_async(self, entity_key: str) -> Entity: + async def retrieve_async(self, entity_key: str, use_v3: bool = False) -> Entity: """Async variant of :meth:`retrieve`.""" - spec = self._retrieve_spec(entity_key) + spec = self._retrieve_spec(entity_key, use_v3=use_v3) response = await self.request_async(spec.method, spec.endpoint) return Entity.model_validate(response.json()) def retrieve_by_name( - self, entity_name: str, folder_key: Optional[str] = None + self, + entity_name: str, + folder_key: Optional[str] = None, + use_v3: bool = False, ) -> Entity: """Internal implementation; see :meth:`EntitiesService.retrieve_by_name`.""" - spec = self._retrieve_by_name_spec(entity_name) + spec = self._retrieve_by_name_spec(entity_name, use_v3=use_v3) headers = self._folder_key_headers(folder_key) response = self.request(spec.method, spec.endpoint, headers=headers) return Entity.model_validate(response.json()) async def retrieve_by_name_async( - self, entity_name: str, folder_key: Optional[str] = None + self, + entity_name: str, + folder_key: Optional[str] = None, + use_v3: bool = False, ) -> Entity: """Async variant of :meth:`retrieve_by_name`.""" - spec = self._retrieve_by_name_spec(entity_name) + spec = self._retrieve_by_name_spec(entity_name, use_v3=use_v3) headers = self._folder_key_headers(folder_key) response = await self.request_async(spec.method, spec.endpoint, headers=headers) return Entity.model_validate(response.json()) - def list_entities(self) -> List[Entity]: + def list_entities(self, use_v3: bool = False) -> List[Entity]: """Internal implementation; see :meth:`EntitiesService.list_entities`.""" - spec = self._list_entities_spec() + spec = self._list_entities_spec(use_v3=use_v3) response = self.request(spec.method, spec.endpoint) entities_data = response.json() return [Entity.model_validate(entity) for entity in entities_data] - async def list_entities_async(self) -> List[Entity]: + async def list_entities_async(self, use_v3: bool = False) -> List[Entity]: """Async variant of :meth:`list_entities`.""" - spec = self._list_entities_spec() + spec = self._list_entities_spec(use_v3=use_v3) response = await self.request_async(spec.method, spec.endpoint) entities_data = response.json() return [Entity.model_validate(entity) for entity in entities_data] @@ -130,9 +151,10 @@ def create_entity( name: str, fields: List[EntityCreateFieldOptions], options: Optional[EntityCreateOptions] = None, + use_v3: bool = False, ) -> str: """Internal implementation; see :meth:`EntitiesService.create_entity`.""" - spec = self._create_entity_spec(name, fields, options) + spec = self._create_entity_spec(name, fields, options, use_v3=use_v3) response = self.request(spec.method, spec.endpoint, json=spec.json) return self._extract_entity_id(response) @@ -141,38 +163,41 @@ async def create_entity_async( name: str, fields: List[EntityCreateFieldOptions], options: Optional[EntityCreateOptions] = None, + use_v3: bool = False, ) -> str: """Async variant of :meth:`create_entity`.""" - spec = self._create_entity_spec(name, fields, options) + spec = self._create_entity_spec(name, fields, options, use_v3=use_v3) response = await self.request_async(spec.method, spec.endpoint, json=spec.json) return self._extract_entity_id(response) - def delete_entity(self, entity_id: str) -> None: + def delete_entity(self, entity_id: str, use_v3: bool = False) -> None: """Delete an entity and all of its records.""" - spec = self._delete_entity_spec(entity_id) + spec = self._delete_entity_spec(entity_id, use_v3=use_v3) self.request(spec.method, spec.endpoint) - async def delete_entity_async(self, entity_id: str) -> None: + async def delete_entity_async(self, entity_id: str, use_v3: bool = False) -> None: """Async variant of :meth:`delete_entity`.""" - spec = self._delete_entity_spec(entity_id) + spec = self._delete_entity_spec(entity_id, use_v3=use_v3) await self.request_async(spec.method, spec.endpoint) def update_entity_metadata( self, entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any], + use_v3: bool = False, ) -> None: """Internal implementation; see :meth:`EntitiesService.update_entity_metadata`.""" - spec = self._update_entity_metadata_spec(entity_id, metadata) + spec = self._update_entity_metadata_spec(entity_id, metadata, use_v3=use_v3) self.request(spec.method, spec.endpoint, json=spec.json) async def update_entity_metadata_async( self, entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any], + use_v3: bool = False, ) -> None: """Async variant of :meth:`update_entity_metadata`.""" - spec = self._update_entity_metadata_spec(entity_id, metadata) + spec = self._update_entity_metadata_spec(entity_id, metadata, use_v3=use_v3) await self.request_async(spec.method, spec.endpoint, json=spec.json) # ------------------------------------------------------------------ @@ -180,19 +205,19 @@ async def update_entity_metadata_async( # ------------------------------------------------------------------ @staticmethod - def _retrieve_spec(entity_key: str) -> RequestSpec: + def _retrieve_spec(entity_key: str, use_v3: bool = False) -> RequestSpec: """Build the GET spec for fetching an entity by key.""" return RequestSpec( method="GET", - endpoint=Endpoint(f"datafabric_/api/Entity/{entity_key}"), + endpoint=Endpoint(f"{_schema_base(use_v3)}/{entity_key}"), ) @staticmethod - def _retrieve_by_name_spec(entity_name: str) -> RequestSpec: + def _retrieve_by_name_spec(entity_name: str, use_v3: bool = False) -> RequestSpec: """Build the GET spec for fetching an entity by name.""" return RequestSpec( method="GET", - endpoint=Endpoint(f"datafabric_/api/Entity/{entity_name}/metadata"), + endpoint=Endpoint(f"{_schema_base(use_v3)}/{entity_name}/metadata"), ) @staticmethod @@ -203,11 +228,11 @@ def _folder_key_headers(folder_key: Optional[str]) -> Dict[str, str]: return {} @staticmethod - def _list_entities_spec() -> RequestSpec: + def _list_entities_spec(use_v3: bool = False) -> RequestSpec: """Build the GET spec for listing all entities (non-choice-sets).""" return RequestSpec( method="GET", - endpoint=Endpoint("datafabric_/api/Entity"), + endpoint=Endpoint(_schema_base(use_v3)), ) @staticmethod @@ -224,15 +249,85 @@ def _create_entity_spec( name: str, fields: List[EntityCreateFieldOptions], options: Optional[EntityCreateOptions] = None, + use_v3: bool = False, ) -> RequestSpec: - """Build the POST spec for creating an entity with its field schema.""" + """Build the POST spec for creating an entity with its field schema. + + On v3, a federated entity is created by passing + ``options.entity_class = EntityClass.Federated`` with at least one + source in ``options.external_fields`` and any cross-source joins in + ``options.source_join_condition_details``. The legacy v1 endpoint does + not support Federated entities. + """ cls._validate_name(name, "entity") for field in fields: cls._validate_name(field.field_name, "field") opts = options or EntityCreateOptions() - # The user-facing option ``is_analytics_enabled`` maps to the legacy - # backend field name ``isInsightsEnabled`` — the wire name predates - # the "Analytics" UI rename. + + if not use_v3: + return cls._create_entity_spec_v1(name, fields, opts) + + entity_class_id: Optional[int] = None + if opts.entity_class is not None: + if opts.entity_class not in (EntityClass.Native, EntityClass.Federated): + raise ValueError( + f"entityClass {opts.entity_class.value!r} is not creatable. " + "Use EntityClass.Native or EntityClass.Federated." + ) + entity_class_id = int(ENTITY_CLASS_TO_ID_MAP[opts.entity_class]) + if opts.entity_class is EntityClass.Federated and not opts.external_fields: + raise ValueError( + "Federated entities require at least one external source in " + "external_fields." + ) + + entity_definition: Dict[str, Any] = { + "name": name, + "fields": [cls._build_schema_field_payload(f) for f in fields], + "folderId": opts.folder_key or DATA_FABRIC_TENANT_FOLDER_ID, + "isRbacEnabled": bool(opts.is_rbac_enabled or False), + "isInsightsEnabled": bool(opts.is_analytics_enabled or False), + "externalFields": cls._build_external_sources_payload(opts.external_fields), + } + if entity_class_id is not None: + entity_definition["entityClassId"] = entity_class_id + if opts.source_join_condition_details is not None: + entity_definition["sourceJoinConditionDetails"] = [ + j.model_dump(by_alias=True, exclude_none=True, mode="json") + for j in opts.source_join_condition_details + ] + + payload: Dict[str, Any] = { + "displayName": opts.display_name or name, + "entityDefinition": entity_definition, + } + if opts.description is not None: + payload["description"] = opts.description + return RequestSpec( + method="POST", + endpoint=Endpoint(_V3_ENTITIES), + json=payload, + ) + + @classmethod + def _create_entity_spec_v1( + cls, + name: str, + fields: List[EntityCreateFieldOptions], + opts: EntityCreateOptions, + ) -> RequestSpec: + """Build the legacy v1 create-entity POST spec (no Federated support). + + The user-facing option ``is_analytics_enabled`` maps to the legacy + backend field name ``isInsightsEnabled`` — the wire name predates the + "Analytics" UI rename. + """ + external_fields = [ + source.model_dump(by_alias=True, exclude_none=True, mode="json") + if isinstance(source, EntityCreateExternalSource) + else source + for source in (opts.external_fields or []) + ] payload: Dict[str, Any] = { "displayName": opts.display_name or name, "entityDefinition": { @@ -241,29 +336,92 @@ def _create_entity_spec( "folderId": opts.folder_key or DATA_FABRIC_TENANT_FOLDER_ID, "isRbacEnabled": bool(opts.is_rbac_enabled or False), "isInsightsEnabled": bool(opts.is_analytics_enabled or False), - "externalFields": opts.external_fields or [], + "externalFields": external_fields, }, } if opts.description is not None: payload["description"] = opts.description return RequestSpec( method="POST", - endpoint=Endpoint("datafabric_/api/Entity"), + endpoint=Endpoint(_V1_ENTITIES), json=payload, ) + @classmethod + def _build_external_sources_payload( + cls, + sources: Optional[Sequence[EntityCreateExternalSource | Dict[str, Any]]], + ) -> List[Dict[str, Any]]: + """Build the wire ``externalFields`` payload for a federated entity. + + Each source's internal columns run through the same field pipeline as + native fields (so ``fieldDefinition`` is identical to a native field), + paired with its external mapping and source connection/object details. + Dict inputs are validated through :class:`EntityCreateExternalSource`. + """ + if not sources: + return [] + built: List[Dict[str, Any]] = [] + for source in sources: + if not isinstance(source, EntityCreateExternalSource): + source = EntityCreateExternalSource.model_validate(source) + entry: Dict[str, Any] = { + "fields": cls._build_external_fields_payload(source.fields), + "externalObjectDetail": source.external_object_detail.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + } + if source.external_connection_detail is not None: + entry["externalConnectionDetail"] = ( + source.external_connection_detail.model_dump( + by_alias=True, exclude_none=True, mode="json" + ) + ) + if source.native_connection_detail is not None: + entry["nativeConnectionDetail"] = ( + source.native_connection_detail.model_dump( + by_alias=True, exclude_none=True, mode="json" + ) + ) + built.append(entry) + return built + + @classmethod + def _build_external_fields_payload( + cls, + fields: Optional[List[EntityCreateExternalField]], + ) -> List[Dict[str, Any]]: + """Build the wire ``fields`` payload for a federated source. + + Produces ``{fieldDefinition, externalFieldMappingDetail}`` per field — + ``fieldDefinition`` is the native field payload, ``externalFieldMappingDetail`` + the source mapping (``directionType`` numeric, per :class:`DataDirectionType`). + """ + if not fields: + return [] + return [ + { + "fieldDefinition": cls._build_schema_field_payload(f.field), + "externalFieldMappingDetail": f.external_field_mapping_detail.model_dump( + by_alias=True, exclude_none=True, mode="json" + ), + } + for f in fields + ] + @staticmethod - def _delete_entity_spec(entity_id: str) -> RequestSpec: + def _delete_entity_spec(entity_id: str, use_v3: bool = False) -> RequestSpec: """Build the DELETE spec for removing an entity.""" return RequestSpec( method="DELETE", - endpoint=Endpoint(f"datafabric_/api/Entity/{entity_id}"), + endpoint=Endpoint(f"{_schema_base(use_v3)}/{entity_id}"), ) @staticmethod def _update_entity_metadata_spec( entity_id: str, metadata: EntityMetadataUpdateOptions | Dict[str, Any], + use_v3: bool = False, ) -> RequestSpec: """Build the PATCH spec for updating entity metadata. @@ -277,7 +435,7 @@ def _update_entity_metadata_spec( body = metadata.model_dump(by_alias=True, exclude_none=True) return RequestSpec( method="PATCH", - endpoint=Endpoint(f"datafabric_/api/Entity/{entity_id}/metadata"), + endpoint=Endpoint(f"{_schema_base(use_v3)}/{entity_id}/metadata"), json=body, ) diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index 51eea2d1b..44b76e9d4 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -48,17 +48,37 @@ class FieldDisplayType(Enum): AutoNumber = "AutoNumber" -class DataDirectionType(Enum): - """Enum representing data direction types for fields in entities.""" +class DataDirectionType(IntEnum): + """Data direction for an external field mapping. - ReadOnly = "ReadOnly" - ReadAndWrite = "ReadAndWrite" + Numeric on the wire — the string form (e.g. ``"ReadOnly"``) is rejected by + the v3 create/upsert endpoint with a 500. ``ReadOnly`` is read-only, + ``ReadAndWrite`` supports reads and writes. + + For backward compatibility, the legacy string member names (``"ReadOnly"`` / + ``"ReadAndWrite"``, case-insensitive) are still accepted as input — e.g. from + older persisted payloads — and normalise to the numeric members. + """ + + ReadOnly = 0 + ReadAndWrite = 1 + + @classmethod + def _missing_(cls, value: object) -> "DataDirectionType | None": + """Accept legacy string member names as input, mapping them to the enum.""" + if isinstance(value, str): + key = value.strip().lower() + for member in cls: + if member.name.lower() == key: + return member + return None -class JoinType(Enum): +class JoinType(str, Enum): """Enum representing types of joins between entities.""" LeftJoin = "LeftJoin" + InnerJoin = "InnerJoin" class EntityType(Enum): @@ -70,6 +90,33 @@ class EntityType(Enum): SystemEntity = "SystemEntity" +class EntityClass(str, Enum): + """Product class of a Data Fabric entity. + + ``Native`` stores its data in UiPath. ``Federated`` is an experimental, + read-only unified view over one or more external / native sources. ``Case`` + is read-only and only ever returned by reads (never creatable). + """ + + Native = "Native" + Federated = "Federated" + Case = "Case" + + +class EntityClassId(IntEnum): + """Internal numeric discriminator for :class:`EntityClass` on the wire.""" + + Native = 9 + Federated = 10 + + +ENTITY_CLASS_TO_ID_MAP: Dict[EntityClass, EntityClassId] = { + EntityClass.Native: EntityClassId.Native, + EntityClass.Federated: EntityClassId.Federated, +} +"""Maps a public :class:`EntityClass` to its wire :class:`EntityClassId`.""" + + class EntityFieldMetadata(BaseModel): """Model representing metadata for an entity field.""" @@ -766,6 +813,156 @@ class EntityCreateFieldOptions(BaseModel): ) +# -------------------------------------------------------------------------- +# Federated entity create/update types (experimental) +# -------------------------------------------------------------------------- + + +class SearchabilityOperator(BaseModel): + """Operator-based searchability for a federated external field mapping.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + searchable_operators: Optional[List[str]] = Field( + default=None, alias="searchableOperators" + ) + + +class SearchabilityNamedSearch(BaseModel): + """Named-search searchability for a federated external field mapping.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + searchable_names: Optional[List[str]] = Field(default=None, alias="searchableNames") + + +class Searchability(BaseModel): + """Searchability metadata attached to a federated external field mapping.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + searchable: bool + supports_operators: Optional[SearchabilityOperator] = Field( + default=None, alias="supportsOperators" + ) + supports_named_search: Optional[SearchabilityNamedSearch] = Field( + default=None, alias="supportsNamedSearch" + ) + + +class EntityCreateExternalConnection(BaseModel): + """Integration Service connection backing a federated external source.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + connection_id: str = Field(alias="connectionId") + element_instance_id: Optional[int] = Field(default=None, alias="elementInstanceId") + folder_key: Optional[str] = Field(default=None, alias="folderKey") + connector_key: str = Field(alias="connectorKey") + connector_name: str = Field(alias="connectorName") + connection_name: Optional[str] = Field(default=None, alias="connectionName") + + +class NativeConnectionDetail(BaseModel): + """Reference to another UiPath entity used as a federated native source.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + entity_id: str = Field(alias="entityId") + folder_key: str = Field(alias="folderKey") + + +class EntityCreateExternalObject(BaseModel): + """Identity of the object a federated source reads from.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + external_object_name: str = Field(alias="externalObjectName") + external_object_display_name: Optional[str] = Field( + default=None, alias="externalObjectDisplayName" + ) + primary_key: Optional[str] = Field(default=None, alias="primaryKey") + is_primary_source: Optional[bool] = Field(default=None, alias="isPrimarySource") + method: Optional[str] = Field(default=None, alias="method") + + +class EntityCreateExternalFieldMapping(BaseModel): + """Maps an internal column to a field on the external source. + + ``direction_type`` is numeric on the wire (see :class:`DataDirectionType`). + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + external_field_name: str = Field(alias="externalFieldName") + external_field_display_name: Optional[str] = Field( + default=None, alias="externalFieldDisplayName" + ) + external_field_type: Optional[str] = Field(default=None, alias="externalFieldType") + direction_type: DataDirectionType = Field( + default=DataDirectionType.ReadOnly, alias="directionType" + ) + searchability: Optional[Searchability] = Field(default=None, alias="searchability") + is_required_for_read: Optional[bool] = Field( + default=None, alias="isRequiredForRead" + ) + sortable: Optional[bool] = Field(default=None, alias="sortable") + + +class EntityCreateExternalField(BaseModel): + """A field a federated source contributes: internal column + external mapping.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + field: EntityCreateFieldOptions + external_field_mapping_detail: EntityCreateExternalFieldMapping = Field( + alias="externalFieldMappingDetail" + ) + + +class EntityCreateExternalSource(BaseModel): + """A single source on a federated entity (connector object or native entity). + + Use ``external_connection_detail`` for an Integration Service connector + source, or ``native_connection_detail`` for another UiPath entity. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + fields: Optional[List[EntityCreateExternalField]] = Field(default=None) + external_object_detail: EntityCreateExternalObject = Field( + alias="externalObjectDetail" + ) + external_connection_detail: Optional[EntityCreateExternalConnection] = Field( + default=None, alias="externalConnectionDetail" + ) + native_connection_detail: Optional[NativeConnectionDetail] = Field( + default=None, alias="nativeConnectionDetail" + ) + + +class SourceJoinConditionDetail(BaseModel): + """Write-shape join between two federated sources (names + connection ids). + + Each side's connection id is the Integration Service ``connectionId`` for a + connector source, or the DF entity id for a native source. + """ + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + source_object_name: str = Field(alias="sourceObjectName") + source_join_field: str = Field(alias="sourceJoinField") + source_object_connection_id: Optional[str] = Field( + default=None, alias="sourceObjectConnectionId" + ) + join_type: JoinType = Field(default=JoinType.LeftJoin, alias="joinType") + related_source_object_name: str = Field(alias="relatedSourceObjectName") + related_source_join_field: str = Field(alias="relatedSourceJoinField") + related_source_object_connection_id: Optional[str] = Field( + default=None, alias="relatedSourceObjectConnectionId" + ) + + class EntityCreateOptions(BaseModel): """Options for creating a new Data Fabric entity.""" @@ -780,8 +977,12 @@ class EntityCreateOptions(BaseModel): is_analytics_enabled: Optional[bool] = Field( default=None, alias="isAnalyticsEnabled" ) - external_fields: Optional[List[Dict[str, Any]]] = Field( - default=None, alias="externalFields" + entity_class: Optional[EntityClass] = Field(default=None, alias="entityClass") + external_fields: Optional[List[EntityCreateExternalSource | Dict[str, Any]]] = ( + Field(default=None, alias="externalFields") + ) + source_join_condition_details: Optional[List[SourceJoinConditionDetail]] = Field( + default=None, alias="sourceJoinConditionDetails" ) diff --git a/packages/uipath-platform/tests/services/test_entities_service_v3.py b/packages/uipath-platform/tests/services/test_entities_service_v3.py new file mode 100644 index 000000000..b8a94fe8e --- /dev/null +++ b/packages/uipath-platform/tests/services/test_entities_service_v3.py @@ -0,0 +1,790 @@ +"""Tests for the experimental v3 EntitiesService methods (``*_v3``). + +The v1 methods are covered in ``test_entities_service.py`` and remain on the +legacy ``datafabric_/api/Entity`` / ``EntityService`` surfaces; these tests lock +the parallel ``*_v3`` methods to the ``datafabric_/api/v3/entities`` surface. +""" + +import json +import re +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._bindings import ( + EntityResourceOverwrite, + _resource_overwrites, +) +from uipath.platform.entities import DataFabricEntityItem, Entity +from uipath.platform.entities._entities_service import EntitiesService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +) -> EntitiesService: + return EntitiesService(config=config, execution_context=execution_context) + + +class TestEntitiesServiceV3: + def test_retrieve_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/{entity_key}", + status_code=200, + json={ + "name": "E", + "displayName": "E", + "entityType": "E", + "isRbacEnabled": False, + "id": f"{entity_key}", + }, + ) + + entity = service.retrieve_v3(entity_key=str(entity_key)) + + assert isinstance(entity, Entity) + assert entity.id == f"{entity_key}" + sent = httpx_mock.get_request() + assert sent is not None + assert "/v3/entities/" in str(sent.url) + + async def test_retrieve_v3_async_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/{entity_key}", + status_code=200, + json={ + "name": "E", + "displayName": "E", + "entityType": "E", + "isRbacEnabled": False, + "id": f"{entity_key}", + }, + ) + + entity = await service.retrieve_v3_async(entity_key=str(entity_key)) + + assert entity.id == f"{entity_key}" + + def test_retrieve_by_name_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/Customers/metadata", + status_code=200, + json={ + "name": "Customers", + "displayName": "Customers", + "entityType": "Customers", + "isRbacEnabled": False, + "id": "cust-1", + }, + ) + + entity = service.retrieve_by_name_v3("Customers") + + assert entity.name == "Customers" + sent = httpx_mock.get_request() + assert sent is not None + assert "/v3/entities/Customers/metadata" in str(sent.url) + + def test_list_entities_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities", + status_code=200, + json=[ + { + "name": "A", + "displayName": "A", + "entityType": "A", + "isRbacEnabled": False, + "id": "a-1", + } + ], + ) + + entities = service.list_entities_v3() + + assert [e.name for e in entities] == ["A"] + + def test_create_entity_v3_native_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import ( + EntityCreateFieldOptions, + EntityFieldDataType, + ) + + new_id = str(uuid.uuid4()) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities", + method="POST", + status_code=200, + json=new_id, + ) + + created = service.create_entity_v3( + name="ProductCatalog", + fields=[ + EntityCreateFieldOptions( + field_name="productName", type=EntityFieldDataType.STRING + ) + ], + ) + + assert created == new_id + sent = httpx_mock.get_request() + assert sent is not None + assert str(sent.url).endswith("/v3/entities") + + def test_delete_entity_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-1" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/{entity_id}", + method="DELETE", + status_code=200, + ) + + service.delete_entity_v3(entity_id) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "DELETE" + + def test_update_entity_metadata_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_id = "ent-1" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/{entity_id}/metadata", + method="PATCH", + status_code=200, + ) + + service.update_entity_metadata_v3(entity_id, {"display_name": "New"}) + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "PATCH" + assert "/v3/entities/ent-1/metadata" in str(sent.url) + + def test_list_records_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/read.*" + ), + status_code=200, + json={"value": [{"Id": "1"}], "totalRecordCount": 1}, + ) + + result = service.list_records_v3(entity_key=str(entity_key), start=0, limit=1) + + assert result.total_count == 1 + sent = httpx_mock.get_request() + assert sent is not None + assert "/v3/entities/entity/" in str(sent.url) + + def test_insert_record_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/insert.*" + ), + method="POST", + status_code=200, + json={"Id": "1", "name": "a"}, + ) + + record = service.insert_record_v3( + entity_key=str(entity_key), data={"name": "a"} + ) + + assert record.id == "1" + + def test_get_record_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/read/7", + status_code=200, + json={"Id": "7"}, + ) + + record = service.get_record_v3(entity_key=str(entity_key), record_id="7") + + assert record.id == "7" + + def test_update_record_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/update/7", + method="POST", + status_code=200, + json={"Id": "7", "name": "u"}, + ) + + record = service.update_record_v3( + entity_key=str(entity_key), record_id="7", data={"name": "u"} + ) + + assert record.id == "7" + + def test_delete_record_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/delete/7", + method="DELETE", + status_code=200, + ) + + service.delete_record_v3(entity_key=str(entity_key), record_id="7") + + sent = httpx_mock.get_request() + assert sent is not None + assert sent.method == "DELETE" + + def test_insert_records_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/insert-batch.*" + ), + method="POST", + status_code=200, + json={"successRecords": [{"Id": "1"}], "failureRecords": []}, + ) + + result = service.insert_records_v3( + entity_key=str(entity_key), records=[{"name": "a"}] + ) + + assert len(result.success_records) == 1 + + def test_update_records_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/update-batch", + method="POST", + status_code=200, + json={"successRecords": [{"Id": "1"}], "failureRecords": []}, + ) + + result = service.update_records_v3( + entity_key=str(entity_key), records=[{"Id": "1", "name": "x"}] + ) + + assert len(result.success_records) == 1 + + def test_delete_records_v3_hits_v3_surface( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/delete-batch", + method="POST", + status_code=200, + json={"successRecords": [{"Id": "1"}], "failureRecords": []}, + ) + + result = service.delete_records_v3(entity_key=str(entity_key), record_ids=["1"]) + + assert len(result.success_records) == 1 + + def test_v1_methods_emit_deprecation_warning( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_key}", + status_code=200, + json={ + "name": "E", + "displayName": "E", + "entityType": "E", + "isRbacEnabled": False, + "id": f"{entity_key}", + }, + ) + + with pytest.warns(DeprecationWarning): + service.retrieve(entity_key=str(entity_key)) + + async def test_resolve_entity_set_v3_uses_v3_metadata_reads( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + folders_service = MagicMock() + folders_service.retrieve_key_async = AsyncMock(return_value="resolved-folder") + service = EntitiesService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + service.retrieve_by_name_v3_async = AsyncMock( # type: ignore[method-assign] + return_value=MagicMock(spec=Entity) + ) + service.retrieve_by_name_async = AsyncMock( # type: ignore[method-assign] + return_value=MagicMock(spec=Entity) + ) + + overwrite = EntityResourceOverwrite( + resource_type="entity", + name="Customers", + folder_path="Shared/Finance", + ) + token = _resource_overwrites.set({"entity.e1": overwrite}) + try: + resolution = await service.resolve_entity_set_v3_async( + [DataFabricEntityItem(id="e1", name="Customers", folder_key="fk")] + ) + finally: + _resource_overwrites.reset(token) + + assert resolution.entities is not None + service.retrieve_by_name_v3_async.assert_awaited_once() + service.retrieve_by_name_async.assert_not_awaited() + + def test_query_v3_by_id_with_filter_and_pagination( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import ( + EntityQueryFilter, + EntityQueryFilterGroup, + EntityQuerySortOption, + LogicalOperator, + QueryFilterOperator, + ) + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/query.*" + ), + status_code=200, + json={ + "value": [{"Id": "1", "name": "alice"}, {"Id": "2", "name": "bob"}], + "totalRecordCount": 5, + }, + ) + + result = service.retrieve_records_v3( + entity_key=str(entity_key), + filter_group=EntityQueryFilterGroup( + logical_operator=LogicalOperator.And, + query_filters=[ + EntityQueryFilter( + field_name="status", + operator=QueryFilterOperator.Equals, + value="active", + ) + ], + ), + sort_options=[EntityQuerySortOption(field_name="name", is_descending=True)], + selected_fields=["Id", "name"], + start=0, + limit=2, + expansion_level=1, + ) + + assert result.total_count == 5 + assert len(result.items) == 2 + assert result.has_next_page is True + # Backend doesn't return next_cursor on this endpoint — caller paginates + # by passing the next ``start`` themselves. + assert result.next_cursor is None + + sent = httpx_mock.get_request() + assert sent is not None + assert "/query" in str(sent.url) and "/v2/" not in str(sent.url) + # expansionLevel is a URL query param, not body + assert sent.url.params.get("expansionLevel") == "1" + body = json.loads(sent.content) + assert body["filterGroup"]["logicalOperator"] == 0 # And + assert body["filterGroup"]["queryFilters"][0]["fieldName"] == "status" + assert body["sortOptions"][0]["fieldName"] == "name" + assert body["selectedFields"] == ["Id", "name"] + # start/limit go in BODY, not as $top/$skip query params + assert body["start"] == 0 + assert body["limit"] == 2 + + def test_query_binnings_route_to_v3( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Binnings go to the v3 by-id query (which supports them, FF-gated), not v2.""" + from uipath.platform.entities import EntityAggregateFunction, EntityBinning + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/query.*" + ), + status_code=200, + json={"value": [], "totalRecordCount": 0}, + ) + + service.retrieve_records_v3( + entity_key=str(entity_key), + binnings=[ + EntityBinning( + field_name="status", + aggregate_function=EntityAggregateFunction.Count, + alias="total", + ) + ], + ) + + sent = httpx_mock.get_request() + assert sent is not None + assert "/v3/entities/entity/" in str(sent.url) + assert "/v2/" not in str(sent.url) + body = json.loads(sent.content) + assert body["binnings"][0]["fieldName"] == "status" + + def test_query_with_joins_routes_to_v1( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Multi-entity joins stay on the v1 by-key endpoint (v3 by-id rejects joins).""" + from uipath.platform.entities import EntityJoin + + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/query.*" + ), + status_code=200, + json={"value": [], "totalRecordCount": 0}, + ) + + service.retrieve_records_v3( + entity_key=str(entity_key), + joins=[ + EntityJoin( + entity_name="Order", + join_type="LeftJoin", + join_field_name="customerId", + related_entity_name="Customer", + related_field_name="Id", + ) + ], + ) + + sent = httpx_mock.get_request() + assert sent is not None + # v1 by-key path — not the v3 by-id path. + assert "/api/EntityService/entity/" in str(sent.url) + assert "/v3/" not in str(sent.url) + body = json.loads(sent.content) + assert body["joins"][0]["entityName"] == "Order" + + def test_create_federated_entity_builds_v3_payload( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + from uipath.platform.entities import ( + EntityClass, + EntityCreateExternalConnection, + EntityCreateExternalField, + EntityCreateExternalFieldMapping, + EntityCreateExternalObject, + EntityCreateExternalSource, + EntityCreateFieldOptions, + EntityCreateOptions, + NativeConnectionDetail, + SourceJoinConditionDetail, + ) + + new_entity_id = str(uuid.uuid4()) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/v3/entities", + method="POST", + status_code=200, + json=new_entity_id, + ) + + connector_source = EntityCreateExternalSource( + external_object_detail=EntityCreateExternalObject( + external_object_name="sys_user", + primary_key="sys_id", + is_primary_source=True, + method="{}", + ), + external_connection_detail=EntityCreateExternalConnection( + connection_id="conn-1", + element_instance_id=373410, + connector_key="uipath-servicenow-servicenow", + connector_name="ServiceNow", + ), + fields=[ + EntityCreateExternalField( + field=EntityCreateFieldOptions(field_name="UserName"), + external_field_mapping_detail=EntityCreateExternalFieldMapping( + external_field_name="user_name" + ), + ) + ], + ) + native_source = EntityCreateExternalSource( + external_object_detail=EntityCreateExternalObject( + external_object_name="UserDept", primary_key="Id" + ), + native_connection_detail=NativeConnectionDetail( + entity_id="dept-entity-id", folder_key="folder-1" + ), + fields=[ + EntityCreateExternalField( + field=EntityCreateFieldOptions(field_name="Department"), + external_field_mapping_detail=EntityCreateExternalFieldMapping( + external_field_name="Department" + ), + ) + ], + ) + + created_id = service.create_entity_v3( + name="UserDirectory", + fields=[], + options=EntityCreateOptions( + entity_class=EntityClass.Federated, + external_fields=[connector_source, native_source], + source_join_condition_details=[ + SourceJoinConditionDetail( + source_object_name="sys_user", + source_join_field="user_name", + source_object_connection_id="conn-1", + related_source_object_name="UserDept", + related_source_join_field="UserNameDept", + related_source_object_connection_id="dept-entity-id", + ) + ], + ), + ) + + assert created_id == new_entity_id + sent = httpx_mock.get_request() + assert sent is not None + definition = json.loads(sent.content)["entityDefinition"] + # Federated class discriminator (numeric wire id). + assert definition["entityClassId"] == 10 + # Two sources; the connector source's field wraps in `fieldDefinition` + # and its mapping uses the numeric directionType. + sources = definition["externalFields"] + assert len(sources) == 2 + connector = sources[0] + assert connector["externalObjectDetail"]["externalObjectName"] == "sys_user" + assert connector["externalConnectionDetail"]["elementInstanceId"] == 373410 + field = connector["fields"][0] + assert field["fieldDefinition"]["name"] == "UserName" + assert field["externalFieldMappingDetail"]["externalFieldName"] == "user_name" + assert field["externalFieldMappingDetail"]["directionType"] == 0 + # Native source carries its entity reference, not a connector connection. + assert sources[1]["nativeConnectionDetail"]["entityId"] == "dept-entity-id" + # Join is passed through in write shape. + join = definition["sourceJoinConditionDetails"][0] + assert join["sourceObjectName"] == "sys_user" + assert join["relatedSourceObjectName"] == "UserDept" + assert join["joinType"] == "LeftJoin" + + def test_create_federated_entity_requires_sources( + self, service: EntitiesService + ) -> None: + from uipath.platform.entities import EntityClass, EntityCreateOptions + + with pytest.raises(ValueError, match="at least one external source"): + service.create_entity_v3( + name="EmptyFederated", + fields=[], + options=EntityCreateOptions(entity_class=EntityClass.Federated), + ) + + def test_create_entity_rejects_uncreatable_class( + self, service: EntitiesService + ) -> None: + from uipath.platform.entities import EntityClass, EntityCreateOptions + + with pytest.raises(ValueError, match="is not creatable"): + service.create_entity_v3( + name="CaseEntity", + fields=[], + options=EntityCreateOptions(entity_class=EntityClass.Case), + ) + + def test_data_direction_type_accepts_legacy_string_names(self) -> None: + """Legacy string member names normalise to the numeric members (back-compat).""" + from uipath.platform.entities import ( + DataDirectionType, + EntityCreateExternalFieldMapping, + ) + + # Legacy string input is intentionally off-type (the field expects the + # numeric enum); `_missing_` normalises it, so silence the type checker. + assert DataDirectionType("ReadOnly") is DataDirectionType.ReadOnly # type: ignore[arg-type] + assert DataDirectionType("readandwrite") is DataDirectionType.ReadAndWrite # type: ignore[arg-type] + assert int(DataDirectionType.ReadOnly) == 0 + # A mapping built from a legacy string serialises to the numeric wire form. + mapping = EntityCreateExternalFieldMapping( + external_field_name="user_name", + direction_type="ReadOnly", # type: ignore[arg-type] + ) + dumped = mapping.model_dump(by_alias=True, exclude_none=True, mode="json") + assert dumped["directionType"] == 0 + + async def test_query_async_v3_by_id( + self, httpx_mock, service, base_url, org, tenant, version + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=re.compile( + rf"{base_url}{org}{tenant}/datafabric_/api/v3/entities/entity/{entity_key}/query" + ), + status_code=200, + json={"value": [{"Id": "1"}], "totalRecordCount": 1}, + ) + result = await service.retrieve_records_v3_async(entity_key=str(entity_key)) + assert result.total_count == 1 diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2172f797c..931b1cd38 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.23" +version = "0.2.24" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bf9895bf0..a26f9d797 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.23" +version = "0.2.24" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },