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 90a6ca1cb..e87b8d8f4 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -1,4 +1,5 @@ -from typing import Any, List, Optional, Type +import re +from typing import Any, Dict, List, Optional, Type from httpx import Response from uipath.core.tracing import traced @@ -13,6 +14,31 @@ EntityRecordsBatchResponse, ) +_FORBIDDEN_SQL_KEYWORDS = { + "INSERT", + "UPDATE", + "DELETE", + "MERGE", + "DROP", + "ALTER", + "CREATE", + "TRUNCATE", + "REPLACE", +} +_DISALLOWED_SQL_OPERATORS = [ + "WITH", + "UNION", + "INTERSECT", + "EXCEPT", + "OVER", + "ROLLUP", + "CUBE", + "GROUPING SETS", + "PARTITION BY", +] +_SQL_KEYWORD_PATTERN = re.compile(r"\b([A-Z]+(?:\s+BY|\s+SETS)?)\b") +_QUOTED_STRING_PATTERN = re.compile(r"'[^']*'") + class EntitiesService(BaseService): """Service for managing UiPath Data Service entities. @@ -391,6 +417,66 @@ class CustomerRecord: EntityRecord.from_data(data=record, model=schema) for record in records_data ] + @traced(name="entity_query_records", run_type="uipath") + def query_entity_records( + self, + sql_query: str, + ) -> List[Dict[str, Any]]: + """Query entity records using a validated SQL query. + + PREVIEW: This method is in preview and may change in future releases. + + Args: + sql_query (str): A SQL SELECT query to execute against Data Service entities. + Only SELECT statements are allowed. Queries without WHERE must include + a LIMIT clause. Subqueries and multi-statement queries are not permitted. + + Returns: + List[Dict[str, Any]]: A list of result records as dictionaries. + + Raises: + ValueError: If the SQL query fails validation (e.g., non-SELECT, missing + WHERE/LIMIT, forbidden keywords, subqueries). + """ + return self._query_entities_for_records(sql_query) + + @traced(name="entity_query_records", run_type="uipath") + async def query_entity_records_async( + self, + sql_query: str, + ) -> List[Dict[str, Any]]: + """Asynchronously query entity records using a validated SQL query. + + PREVIEW: This method is in preview and may change in future releases. + + Args: + sql_query (str): A SQL SELECT query to execute against Data Service entities. + Only SELECT statements are allowed. Queries without WHERE must include + a LIMIT clause. Subqueries and multi-statement queries are not permitted. + + Returns: + List[Dict[str, Any]]: A list of result records as dictionaries. + + Raises: + ValueError: If the SQL query fails validation (e.g., non-SELECT, missing + WHERE/LIMIT, forbidden keywords, subqueries). + """ + return await self._query_entities_for_records_async(sql_query) + + def _query_entities_for_records(self, sql_query: str) -> List[Dict[str, Any]]: + self._validate_sql_query(sql_query) + spec = self._query_entity_records_spec(sql_query) + response = self.request(spec.method, spec.endpoint, json=spec.json) + return response.json().get("results", []) + + async def _query_entities_for_records_async( + self, sql_query: str + ) -> List[Dict[str, Any]]: + self._validate_sql_query(sql_query) + spec = self._query_entity_records_spec(sql_query) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + return response.json().get("results", []) + @traced(name="entity_record_insert_batch", run_type="uipath") def insert_records( self, @@ -874,6 +960,16 @@ def _list_records_spec( params=({"start": start, "limit": limit}), ) + def _query_entity_records_spec( + self, + sql_query: str, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint("datafabric_/api/v1/query/execute"), + json={"query": sql_query}, + ) + def _insert_batch_spec(self, entity_key: str, records: List[Any]) -> RequestSpec: return RequestSpec( method="POST", @@ -902,3 +998,58 @@ def _delete_batch_spec(self, entity_key: str, record_ids: List[str]) -> RequestS ), json=record_ids, ) + + def _validate_sql_query(self, sql_query: str) -> None: + query = sql_query.strip().rstrip(";").strip() + if not query: + raise ValueError("SQL query cannot be empty.") + + # Strip quoted strings before checking for semicolons so that + # values like WHERE name = 'foo;bar' don't trigger a false positive. + unquoted = _QUOTED_STRING_PATTERN.sub("''", query) + if ";" in unquoted: + raise ValueError("Only a single SELECT statement is allowed.") + + normalized_query = re.sub(r"\s+", " ", query).strip() + normalized_upper = normalized_query.upper() + extracted_keywords = set(_SQL_KEYWORD_PATTERN.findall(normalized_upper)) + + if not normalized_upper.startswith("SELECT "): + if not normalized_upper.startswith("WITH "): + raise ValueError("Only SELECT statements are allowed.") + + for keyword in _FORBIDDEN_SQL_KEYWORDS: + if keyword in extracted_keywords: + raise ValueError(f"SQL keyword '{keyword}' is not allowed.") + + for operator in _DISALLOWED_SQL_OPERATORS: + if operator in extracted_keywords: + raise ValueError( + f"SQL construct '{operator}' is not allowed in entity queries." + ) + + if re.search(r"\(\s*SELECT\b", normalized_upper): + raise ValueError("Subqueries are not allowed.") + + has_where = bool(re.search(r"\bWHERE\b", normalized_upper)) + has_limit = bool(re.search(r"\bLIMIT\s+\d+\b", normalized_upper)) + if not has_where and not has_limit: + raise ValueError("Queries without WHERE must include a LIMIT clause.") + + projection = self._projection_segment(normalized_query) + if "*" in projection and not has_where: + raise ValueError("SELECT * without filtering is not allowed.") + if not has_where and self._projection_column_count(projection) > 4: + raise ValueError( + "Selecting more than 4 columns without filtering is not allowed." + ) + + def _projection_segment(self, normalized_query: str) -> str: + match = re.match(r"(?is)\s*SELECT\s+(.*?)\s+FROM\s+", normalized_query) + return match.group(1) if match else "" + + def _projection_column_count(self, projection: str) -> int: + cleaned = projection.strip() + if not cleaned: + return 0 + return len([part for part in cleaned.split(",") if part.strip()]) diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 4c6c85882..b7173e510 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -1,6 +1,8 @@ +import re import uuid from dataclasses import make_dataclass from typing import Optional +from unittest.mock import AsyncMock, MagicMock import pytest from pytest_httpx import HTTPXMock @@ -260,3 +262,124 @@ def test_retrieve_records_with_optional_fields( start=0, limit=1, ) + + @pytest.mark.parametrize( + "sql_query", + [ + "SELECT id FROM Customers WHERE id = 1", + "SELECT id, name FROM Customers LIMIT 10", + "SELECT * FROM Customers WHERE status = 'Active'", + "SELECT id, name, email, phone FROM Customers LIMIT 5", + "SELECT DISTINCT id FROM Customers WHERE id > 100", + "SELECT id FROM Customers WHERE name = 'foo;bar'", + "SELECT id FROM Customers WHERE id = 1;", + ], + ) + def test_validate_sql_query_allows_supported_select_queries( + self, sql_query: str, service: EntitiesService + ) -> None: + service._validate_sql_query(sql_query) + + @pytest.mark.parametrize( + "sql_query,error_message", + [ + ("", "SQL query cannot be empty."), + (" ", "SQL query cannot be empty."), + ( + "SELECT id FROM Customers; SELECT id FROM Orders", + "Only a single SELECT statement is allowed.", + ), + ("INSERT INTO Customers VALUES (1)", "Only SELECT statements are allowed."), + ( + "WITH cte AS (SELECT id FROM Customers) SELECT id FROM cte", + "SQL construct 'WITH' is not allowed in entity queries.", + ), + ( + "SELECT id FROM Customers UNION SELECT id FROM Orders", + "SQL construct 'UNION' is not allowed in entity queries.", + ), + ( + "SELECT id, SUM(amount) OVER (PARTITION BY id) FROM Orders LIMIT 10", + "SQL construct 'OVER' is not allowed in entity queries.", + ), + ( + "SELECT id FROM (SELECT id FROM Customers) c", + "Subqueries are not allowed.", + ), + ( + "SELECT id FROM Customers", + "Queries without WHERE must include a LIMIT clause.", + ), + ( + "SELECT * FROM Customers LIMIT 10", + "SELECT * without filtering is not allowed.", + ), + ( + "SELECT id, name, email, phone, address FROM Customers LIMIT 10", + "Selecting more than 4 columns without filtering is not allowed.", + ), + ], + ) + def test_validate_sql_query_rejects_disallowed_queries( + self, sql_query: str, error_message: str, service: EntitiesService + ) -> None: + with pytest.raises(ValueError, match=re.escape(error_message)): + service._validate_sql_query(sql_query) + + def test_query_entity_records_rejects_invalid_sql_before_network_call( + self, + service: EntitiesService, + ) -> None: + service.request = MagicMock() # type: ignore[method-assign] + + with pytest.raises( + ValueError, match=re.escape("Only SELECT statements are allowed.") + ): + service.query_entity_records("UPDATE Customers SET name = 'X'") + + service.request.assert_not_called() # type: ignore[attr-defined] + + def test_query_entity_records_calls_request_for_valid_sql( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": [{"id": 1}, {"id": 2}]} + + service.request = MagicMock(return_value=response) # type: ignore[method-assign] + + result = service.query_entity_records("SELECT id FROM Customers WHERE id > 0") + + assert result == [{"id": 1}, {"id": 2}] + service.request.assert_called_once() # type: ignore[attr-defined] + + @pytest.mark.anyio + async def test_query_entity_records_async_rejects_invalid_sql_before_network_call( + self, + service: EntitiesService, + ) -> None: + service.request_async = AsyncMock() # type: ignore[method-assign] + + with pytest.raises(ValueError, match=re.escape("Subqueries are not allowed.")): + await service.query_entity_records_async( + "SELECT id FROM Customers WHERE id IN (SELECT id FROM Orders)" + ) + + service.request_async.assert_not_called() # type: ignore[attr-defined] + + @pytest.mark.anyio + async def test_query_entity_records_async_calls_request_for_valid_sql( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": [{"id": "c1"}]} + + service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + result = await service.query_entity_records_async( + "SELECT id FROM Customers WHERE id = 'c1'" + ) + + assert result == [{"id": "c1"}] + service.request_async.assert_called_once() # type: ignore[attr-defined] diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 695f52305..a1c53c23b 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1048,15 +1048,33 @@ wheels = [ [[package]] name = "uipath-core" version = "0.5.6" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/8a/d129d33a81865f99d9134391a52f8691f557d95a18a38df4d88917b3e235/uipath_core-0.5.6.tar.gz", hash = "sha256:bebaf2e62111e844739e4f4e4dc47c48bac93b7e6fce6754502a9f4979c41888", size = 112659, upload-time = "2026-03-04T18:04:42.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/8f/77ab712518aa2a8485a558a0de245ac425e07fd8b74cfa8951550f0aea63/uipath_core-0.5.6-py3-none-any.whl", hash = "sha256:4a741fc760605165b0541b3abb6ade728bfa386e000ace00054bc43995720e5b", size = 42047, upload-time = "2026-03-04T18:04:41.606Z" }, + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-instrumentation", specifier = ">=0.60b0,<1.0.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2.0.0" }, + { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, ] [[package]] @@ -1092,7 +1110,7 @@ requires-dist = [ { name = "pydantic-function-models", specifier = ">=0.1.11" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, - { name = "uipath-core", specifier = ">=0.5.4,<0.6.0" }, + { name = "uipath-core", editable = "../uipath-core" }, ] [package.metadata.requires-dev] diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index a1e30f8e6..4a563708f 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "mermaid-builder==0.0.3", "graphtty==0.1.8", "applicationinsights>=0.11.10", + "sqlparse>=0.4.4", ] classifiers = [ "Intended Audience :: Developers", diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 6ad1932d9..c2c09533c 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -142,9 +142,18 @@ class AgentContextRetrievalMode(str, CaseInsensitiveEnum): STRUCTURED = "Structured" DEEP_RAG = "DeepRAG" BATCH_TRANSFORM = "BatchTransform" + DATA_FABRIC = "DataFabric" UNKNOWN = "Unknown" # fallback branch discriminator +class AgentContextType(str, CaseInsensitiveEnum): + """Agent context type enumeration.""" + + INDEX = "index" + ATTACHMENTS = "attachments" + DATA_FABRIC_ENTITY_SET = "datafabricentityset" + + class AgentMessageRole(str, CaseInsensitiveEnum): """Agent message role enumeration.""" @@ -378,19 +387,45 @@ class AgentContextSettings(BaseCfg): ) +class DataFabricEntityItem(BaseCfg): + """A single Data Fabric entity reference.""" + + id: str + reference_key: Optional[str] = Field(None, alias="referenceKey") + name: str + folder_id: str = Field(alias="folderId") + description: Optional[str] = None + + class AgentContextResourceConfig(BaseAgentResourceConfig): """Agent context resource configuration model.""" resource_type: Literal[AgentResourceType.CONTEXT] = Field( alias="$resourceType", default=AgentResourceType.CONTEXT, frozen=True ) - folder_path: str = Field(alias="folderPath") - index_name: str = Field(alias="indexName") - settings: AgentContextSettings = Field(..., description="Context settings") + context_type: Optional[AgentContextType] = Field(None, alias="contextType") + folder_path: Optional[str] = Field(None, alias="folderPath") + index_name: Optional[str] = Field(None, alias="indexName") + settings: Optional[AgentContextSettings] = Field( + None, description="Context settings" + ) + entity_set: Optional[List[DataFabricEntityItem]] = Field(None, alias="entitySet") argument_properties: Dict[str, AgentToolArgumentProperties] = Field( {}, alias="argumentProperties" ) + @property + def is_datafabric(self) -> bool: + """Check if this context is a Data Fabric entity set resource.""" + return self.context_type == AgentContextType.DATA_FABRIC_ENTITY_SET + + @property + def datafabric_entity_identifiers(self) -> list[str]: + """Extract entity identifiers from entitySet.""" + if self.entity_set: + return [item.id for item in self.entity_set] + return [] + class AgentMcpTool(BaseCfg): """Agent MCP tool model.""" @@ -1202,6 +1237,7 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "structured": "Structured", "deeprag": "DeepRAG", "batchtransform": "BatchTransform", + "datafabric": "DataFabric", "unknown": "Unknown", } @@ -1231,14 +1267,15 @@ def _normalize_resources(v: Dict[str, Any]) -> None: ) if res["$resourceType"] == "context": - settings = res.get("settings", {}) - rm = settings.get("retrievalMode") or settings.get("retrieval_mode") - settings["retrievalMode"] = ( - CONTEXT_MODE_MAP.get(rm.lower(), "Unknown") - if isinstance(rm, str) - else "Unknown" - ) - res["settings"] = settings + settings = res.get("settings") + if settings is not None: + rm = settings.get("retrievalMode") or settings.get("retrieval_mode") + settings["retrievalMode"] = ( + CONTEXT_MODE_MAP.get(rm.lower(), "Unknown") + if isinstance(rm, str) + else "Unknown" + ) + res["settings"] = settings out.append(res) diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 7b6dababa..93d916acb 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -1,5 +1,3 @@ -from typing import Any - import pytest from pydantic import TypeAdapter @@ -9,6 +7,7 @@ AgentBuiltInValidatorGuardrail, AgentContextResourceConfig, AgentContextRetrievalMode, + AgentContextType, AgentCustomGuardrail, AgentDefinition, AgentEscalationChannel, @@ -1639,6 +1638,7 @@ def test_agent_with_unknown_context_retrieval_mode(self): context_resource = config.resources[0] assert isinstance(context_resource, AgentContextResourceConfig) assert context_resource.resource_type == AgentResourceType.CONTEXT + assert context_resource.settings is not None assert ( context_resource.settings.retrieval_mode == AgentContextRetrievalMode.UNKNOWN @@ -2480,8 +2480,6 @@ def test_agent_with_ixp_vs_escalation(self): "outcomeMapping": None, "recipients": [], "type": "actionCenter", - "taskTitle": "Test IXP Escalation", - "priority": "High", "properties": { "appName": None, "appVersion": 1, @@ -3045,6 +3043,7 @@ def test_case_insensitive_enums(self): context = config.resources[1] assert isinstance(context, AgentContextResourceConfig) assert context.resource_type == AgentResourceType.CONTEXT + assert context.settings is not None assert context.settings.retrieval_mode == AgentContextRetrievalMode.SEMANTIC assert context.settings.query is not None assert ( @@ -3221,212 +3220,98 @@ def test_is_conversational_false_by_default(self): assert config.is_conversational is False -class TestAgentBuilderConfigResources: - """Tests for AgentDefinition resource configuration parsing.""" +class TestDataFabricContextConfig: + """Tests for Data Fabric context resource configuration.""" - def _agent_dict_with_resources(self, resources: list[Any]) -> dict[str, Any]: - """Helper method that returns an agent dict with default fields and provided resources.""" - return { - "version": "1.0.0", - "id": "test-agent-id", - "name": "Test Agent", - "metadata": {"isConversational": False, "storageVersion": "22.0.0"}, - "messages": [ + def test_datafabric_context_config_parses(self): + """Test v48 format with contextType and entitySet.""" + config = { + "$resourceType": "context", + "name": "TestDataFabric", + "description": "", + "contextType": "datafabricentityset", + "entitySet": [ { - "role": "System", - "content": "You are a test agent.", - } - ], - "inputSchema": {"type": "object", "properties": {}}, - "outputSchema": {"type": "object", "properties": {}}, - "settings": { - "model": "gpt-4o", - "maxTokens": 4096, - "temperature": 0, - "engine": "basic-v1", - }, - "resources": resources, - } - - def test_escalation_with_static_group_name_recipient_type(self): - """Test that escalation with StaticGroupName recipientType is parsed correctly.""" - resources = [ - { - "$resourceType": "escalation", - "id": "escalation-1", - "channels": [ - { - "name": "Test Channel", - "description": "Test channel description", - "type": "ActionCenter", - "inputSchema": { - "type": "object", - "properties": {"field": {"type": "string"}}, - }, - "outputSchema": {"type": "object", "properties": {}}, - "outcomeMapping": {"Approve": "continue"}, - "properties": { - "appName": "TestApp", - "appVersion": 1, - "folderName": "TestFolder", - "resourceKey": "test-key", - }, - "recipients": [ - { - "value": "TestGroup", - "type": "staticgroupname", - } - ], - "taskTitle": "Test Task", - "priority": "Medium", - } - ], - "isAgentMemoryEnabled": False, - "escalationType": 0, - "name": "Test Escalation", - "description": "Test escalation", - } - ] - - json_data = self._agent_dict_with_resources(resources) - config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( - json_data - ) - - escalation_resource = config.resources[0] - assert isinstance(escalation_resource, AgentEscalationResourceConfig) - recipient = escalation_resource.channels[0].recipients[0] - assert isinstance(recipient, StandardRecipient) - assert recipient.type == AgentEscalationRecipientType.GROUP_NAME - assert recipient.value == "TestGroup" - - def test_escalation_with_lowercase_userid_recipient_type(self): - """Test that escalation with lowercase userid recipientType is parsed correctly.""" - resources = [ - { - "$resourceType": "escalation", - "id": "escalation-2", - "channels": [ - { - "name": "Test Channel", - "description": "Test channel description", - "type": "ActionCenter", - "inputSchema": { - "type": "object", - "properties": {"field": {"type": "string"}}, - }, - "outputSchema": {"type": "object", "properties": {}}, - "outcomeMapping": {"Approve": "continue"}, - "properties": { - "appName": "TestApp", - "appVersion": 1, - "folderName": "TestFolder", - "resourceKey": "test-key", - }, - "recipients": [ - { - "value": "user-123", - "type": "userid", - } - ], - "taskTitle": "Test Task", - "priority": "Medium", - } - ], - "isAgentMemoryEnabled": False, - "escalationType": 0, - "name": "Test Escalation", - "description": "Test escalation", - } - ] - - json_data = self._agent_dict_with_resources(resources) - config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( - json_data - ) - - escalation_resource = config.resources[0] - assert isinstance(escalation_resource, AgentEscalationResourceConfig) - recipient = escalation_resource.channels[0].recipients[0] - assert isinstance(recipient, StandardRecipient) - assert recipient.type == AgentEscalationRecipientType.USER_ID - assert recipient.value == "user-123" - - def test_process_tool_missing_output_schema(self): - """Test that process tool without outputSchema is parsed correctly.""" - resources = [ - { - "$resourceType": "tool", - "type": "ProcessOrchestration", - "id": "process-tool-1", - "inputSchema": { - "type": "object", - "properties": {"input": {"type": "string"}}, + "id": "abc-123", + "name": "Customers", + "folderId": "folder-1", + "description": "Customer records", }, - "arguments": {}, - "settings": {"timeout": 0, "maxAttempts": 0, "retryDelay": 0}, - "properties": { - "processName": "TestProcess", - "folderPath": "TestFolder", + { + "id": "def-456", + "referenceKey": "orders-ref", + "name": "Orders", + "folderId": "folder-2", + "description": None, }, - "name": "Test Process", - "description": "Test process tool", - } - ] - - json_data = self._agent_dict_with_resources(resources) - config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( - json_data - ) - - tool_resource = config.resources[0] - assert isinstance(tool_resource, AgentProcessToolResourceConfig) - assert tool_resource.output_schema == {"type": "object", "properties": {}} - - def test_escalation_missing_escalation_type_defaults_to_zero(self): - """Test that missing escalationType defaults to 0.""" - resources = [ - { - "$resourceType": "escalation", - "id": "escalation-3", - "channels": [ - { - "name": "Test Channel", - "description": "Test channel description", - "type": "ActionCenter", - "inputSchema": { - "type": "object", - "properties": {"field": {"type": "string"}}, - }, - "outputSchema": {"type": "object", "properties": {}}, - "outcomeMapping": {"Approve": "continue"}, - "properties": { - "appName": "TestApp", - "appVersion": 1, - "folderName": "TestFolder", - "resourceKey": "test-key", - }, - "recipients": [ - { - "value": "user-123", - "type": "UserId", - } - ], - "taskTitle": "Test Task", - "priority": "Medium", - } - ], - "isAgentMemoryEnabled": False, - "name": "Test Escalation", - "description": "Test escalation", - } - ] - - json_data = self._agent_dict_with_resources(resources) - config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( - json_data - ) + ], + } - escalation_resource = config.resources[0] - assert isinstance(escalation_resource, AgentEscalationResourceConfig) - assert escalation_resource.escalation_type == 0 + parsed = AgentContextResourceConfig.model_validate(config) + + assert parsed.context_type == AgentContextType.DATA_FABRIC_ENTITY_SET + assert parsed.folder_path is None + assert parsed.index_name is None + assert parsed.settings is None + assert parsed.entity_set is not None + assert len(parsed.entity_set) == 2 + assert parsed.entity_set[0].id == "abc-123" + assert parsed.entity_set[0].name == "Customers" + assert parsed.entity_set[0].folder_id == "folder-1" + assert parsed.entity_set[0].description == "Customer records" + assert parsed.entity_set[0].reference_key is None + assert parsed.entity_set[1].reference_key == "orders-ref" + assert parsed.entity_set[1].description is None + + def test_is_datafabric(self): + """Test is_datafabric property with datafabricentityset contextType.""" + config = { + "$resourceType": "context", + "name": "Test", + "description": "", + "contextType": "datafabricentityset", + "entitySet": [ + {"id": "abc-123", "name": "E", "folderId": "", "description": None} + ], + } + parsed = AgentContextResourceConfig.model_validate(config) + assert parsed.is_datafabric is True + + def test_is_datafabric_false_for_semantic(self): + """Test is_datafabric is False for Semantic contexts.""" + config = { + "$resourceType": "context", + "name": "Test", + "description": "", + "folderPath": "Shared", + "indexName": "my-index", + "settings": {"retrievalMode": "Semantic", "resultCount": 5}, + } + parsed = AgentContextResourceConfig.model_validate(config) + assert parsed.is_datafabric is False + + def test_datafabric_entity_identifiers(self): + """Test entity identifiers extracted from entitySet.""" + config = { + "$resourceType": "context", + "name": "Test", + "description": "", + "contextType": "datafabricentityset", + "entitySet": [ + {"id": "id-1", "name": "E1", "folderId": "", "description": None}, + {"id": "id-2", "name": "E2", "folderId": "", "description": None}, + ], + } + parsed = AgentContextResourceConfig.model_validate(config) + assert parsed.datafabric_entity_identifiers == ["id-1", "id-2"] + + def test_datafabric_entity_identifiers_empty(self): + """Test empty entity identifiers when no entitySet provided.""" + config = { + "$resourceType": "context", + "name": "Test", + "description": "", + "contextType": "datafabricentityset", + } + parsed = AgentContextResourceConfig.model_validate(config) + assert parsed.datafabric_entity_identifiers == [] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f7e3594d4..67fdbf0fd 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2368,6 +2368,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + [[package]] name = "stevedore" version = "5.6.0" @@ -2548,6 +2557,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-socketio" }, { name = "rich" }, + { name = "sqlparse" }, { name = "tenacity" }, { name = "truststore" }, { name = "uipath-core" }, @@ -2600,10 +2610,11 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-socketio", specifier = ">=5.15.0,<6.0.0" }, { name = "rich", specifier = ">=14.2.0" }, + { name = "sqlparse", specifier = ">=0.4.4" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, - { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.0.4,<0.1.0" }, + { name = "uipath-core", editable = "../uipath-core" }, + { name = "uipath-platform", editable = "../uipath-platform" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] @@ -2639,21 +2650,39 @@ dev = [ [[package]] name = "uipath-core" version = "0.5.6" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/8a/d129d33a81865f99d9134391a52f8691f557d95a18a38df4d88917b3e235/uipath_core-0.5.6.tar.gz", hash = "sha256:bebaf2e62111e844739e4f4e4dc47c48bac93b7e6fce6754502a9f4979c41888", size = 112659, upload-time = "2026-03-04T18:04:42.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/8f/77ab712518aa2a8485a558a0de245ac425e07fd8b74cfa8951550f0aea63/uipath_core-0.5.6-py3-none-any.whl", hash = "sha256:4a741fc760605165b0541b3abb6ade728bfa386e000ace00054bc43995720e5b", size = 42047, upload-time = "2026-03-04T18:04:41.606Z" }, + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-instrumentation", specifier = ">=0.60b0,<1.0.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2.0.0" }, + { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, ] [[package]] name = "uipath-platform" -version = "0.0.17" -source = { registry = "https://pypi.org/simple" } +version = "0.0.18" +source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, { name = "pydantic-function-models" }, @@ -2661,9 +2690,29 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/c9/e7568133f3a657af16b67444c4e090259941078acc62acb1e2c072903da4/uipath_platform-0.0.17.tar.gz", hash = "sha256:a2c228462d7e2642dcfc249547d9b8e94ba1c72b68f16ba673ee3e58204e9365", size = 264143, upload-time = "2026-03-06T20:34:22.23Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d1/a1c357dbea16a8b5d5b8ae5311ff2353cc03a8b5dd15ff83b0b693687930/uipath_platform-0.0.17-py3-none-any.whl", hash = "sha256:7b88f2b4eb189877fb2f99d704fc0cbc6e4244f01dac59cf812fa8a03db95e36", size = 159073, upload-time = "2026-03-06T20:34:20.993Z" }, + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic-function-models", specifier = ">=0.1.11" }, + { name = "tenacity", specifier = ">=9.0.0" }, + { name = "truststore", specifier = ">=0.10.1" }, + { name = "uipath-core", editable = "../uipath-core" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.19.0" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, ] [[package]]