diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index 67572ec87..1b6e14d11 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -8,6 +8,7 @@ AggregateRow, ChoiceSetValue, DataFabricEntityItem, + DataFabricOntologyItem, Entity, EntityAggregate, EntityAggregateFunction, @@ -46,6 +47,7 @@ "AggregateRow", "ChoiceSetValue", "DataFabricEntityItem", + "DataFabricOntologyItem", "EntitiesService", "Entity", "EntityAggregate", 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 fa3c0da9e..6483bb82d 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -15,6 +15,7 @@ """ import logging +import re from typing import Any, Dict, List, Optional, Type from httpx import Response @@ -24,6 +25,8 @@ from ..common._bindings import _resource_overwrites from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import header_folder +from ..common._models import Endpoint, RequestSpec from ..orchestrator._folder_service import FolderService from ._entity_data_service import EntityDataService, FileContent from ._entity_resolution import ( @@ -58,6 +61,12 @@ logger = logging.getLogger(__name__) +# Ontology name contract (QueryEngine OntologyController): lowercase, starts +# with a letter, max 64 chars. The name becomes a URL path segment. +_ONTOLOGY_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$") +# Allowed ontology component file types (also URL path segments). +_ONTOLOGY_FILE_TYPES = frozenset({"owl", "r2rml", "shacl", "summary", "context"}) + class EntitiesService(BaseService): """Service for managing UiPath Data Service entities. @@ -1100,6 +1109,68 @@ async def delete_record_async(self, entity_key: str, record_id: str) -> None: """ await self._data.delete_record_async(entity_key, record_id) + async def get_ontology_file_async( + self, + ontology_name: str, + file_type: str = "owl", + folder_key: Optional[str] = None, + ) -> Dict[str, Any]: + """Fetch one file of an ontology from Data Fabric. + + !!! warning "Preview Feature" + This method is currently experimental. Behavior and parameters are + subject to change in future versions. + + Ontologies are served by the same QueryEngine service as entity SQL + queries, under ``datafabric_/api/ontologies``. The JSON wrapper is + requested so the result is notation-agnostic — the ``owl`` file content + may be Turtle or OWL Functional Notation. + + Args: + ontology_name: Ontology name. Validated against the QE name contract. + file_type: One of owl, r2rml, shacl, summary, context. + folder_key: Folder the ontology lives in. + + Returns: + Dict[str, Any]: The file record (e.g. ``content``, ``mediaType``). + + Raises: + ValueError: If the ontology name or file type is invalid. + """ + self._validate_ontology_name(ontology_name) + self._validate_file_type(file_type) + spec = self._ontology_file_spec(ontology_name, file_type) + headers = {"Accept": "application/json", **header_folder(folder_key, None)} + response = await self.request_async(spec.method, spec.endpoint, headers=headers) + return response.json() + + @staticmethod + def _validate_ontology_name(ontology_name: str) -> None: + """Validate the ontology name before it becomes a URL path segment.""" + if not _ONTOLOGY_NAME_RE.match(ontology_name or ""): + raise ValueError( + f"Invalid ontology name {ontology_name!r}. " + "Must match ^[a-z][a-z0-9-]{0,63}$." + ) + + @staticmethod + def _validate_file_type(file_type: str) -> None: + """Validate the file type before it becomes a URL path segment.""" + if file_type not in _ONTOLOGY_FILE_TYPES: + allowed = ", ".join(sorted(_ONTOLOGY_FILE_TYPES)) + raise ValueError( + f"Invalid ontology file type {file_type!r}. One of: {allowed}." + ) + + @staticmethod + def _ontology_file_spec(ontology_name: str, file_type: str) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"datafabric_/api/ontologies/{ontology_name}/files/{file_type}" + ), + ) + @traced(name="entity_record_insert_batch", run_type="uipath") def insert_records( self, diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index 51eea2d1b..b16e19000 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -843,6 +843,26 @@ class DataFabricEntityItem(BaseModel): description: Optional[str] = None +class DataFabricOntologyItem(BaseModel): + """A single Data Fabric ontology reference nested in a context's ontologySet. + + Mirrors :class:`DataFabricEntityItem`: the ontology is configured inline on + the Data Fabric context (alongside ``entitySet``), carrying its own + ``folderId`` so it resolves from its own folder. ``name`` is used to fetch + the ontology from the QueryEngine ontology API. + """ + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + name: str + ontology_key: Optional[str] = Field(None, alias="referenceKey") + folder_key: str = Field(alias="folderId") + description: Optional[str] = None + id: Optional[str] = None + + class EntitySetResolution(BaseModel): """Result of resolving an agent entity set with overwrites applied.""" diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 258c3a9d8..54762cf24 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -2647,3 +2647,78 @@ def test_5xx_with_batch_shape_still_propagates( entity_key=str(entity_key), records=[{"name": "x"}], ) + + +class TestGetOntologyFileAsync: + """Tests for EntitiesService.get_ontology_file_async.""" + + @pytest.mark.anyio + async def test_builds_endpoint_and_folder_header( + self, service: EntitiesService + ) -> None: + response = MagicMock() + response.json.return_value = {"content": "OWL", "mediaType": "text/plain"} + service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + result = await service.get_ontology_file_async( + "library", "owl", folder_key="folder-1" + ) + + assert result == {"content": "OWL", "mediaType": "text/plain"} + service.request_async.assert_called_once() + call = service.request_async.call_args + method, endpoint = call.args[0], call.args[1] + headers = call.kwargs["headers"] + assert method == "GET" + assert str(endpoint) == "/datafabric_/api/ontologies/library/files/owl" + assert headers["Accept"] == "application/json" + assert headers["x-uipath-folderkey"] == "folder-1" + + @pytest.mark.anyio + async def test_no_folder_header_when_folder_key_none( + self, service: EntitiesService + ) -> None: + response = MagicMock() + response.json.return_value = {"content": "OWL", "mediaType": "text/plain"} + service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + await service.get_ontology_file_async("library") + + headers = service.request_async.call_args.kwargs["headers"] + assert "x-uipath-folderkey" not in headers + + @pytest.mark.anyio + @pytest.mark.parametrize( + "file_type", ["owl", "r2rml", "shacl", "summary", "context"] + ) + async def test_accepts_allowed_file_types( + self, service: EntitiesService, file_type: str + ) -> None: + response = MagicMock() + response.json.return_value = {"content": "x"} + service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + await service.get_ontology_file_async("library", file_type) + + endpoint = service.request_async.call_args.args[1] + assert str(endpoint) == f"/datafabric_/api/ontologies/library/files/{file_type}" + + @pytest.mark.anyio + async def test_rejects_invalid_ontology_name( + self, service: EntitiesService + ) -> None: + service.request_async = AsyncMock() # type: ignore[method-assign] + + with pytest.raises(ValueError, match="Invalid ontology name"): + await service.get_ontology_file_async("Bad_Name") # uppercase + underscore + + service.request_async.assert_not_called() + + @pytest.mark.anyio + async def test_rejects_invalid_file_type(self, service: EntitiesService) -> None: + service.request_async = AsyncMock() # type: ignore[method-assign] + + with pytest.raises(ValueError, match="Invalid ontology file type"): + await service.get_ontology_file_async("library", "exe") + + service.request_async.assert_not_called() diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 19a00447a..94925ec54 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:55:56.0776194Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7acd8465d..5aac460fc 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.12" +version = "2.11.13" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.76, <0.2.0", + "uipath-platform>=0.1.77, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 916417c94..0694c3474 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -35,7 +35,7 @@ ) from uipath.eval.mocks import ExampleCall from uipath.platform.connections import Connection -from uipath.platform.entities import DataFabricEntityItem +from uipath.platform.entities import DataFabricEntityItem, DataFabricOntologyItem from uipath.platform.guardrails import ( BuiltInValidatorGuardrail, ) @@ -440,6 +440,15 @@ class AgentContextResourceConfig(BaseAgentResourceConfig): None, description="Context settings" ) entity_set: Optional[List[DataFabricEntityItem]] = Field(None, alias="entitySet") + ontology_set: Optional[List[DataFabricOntologyItem]] = Field( + None, + alias="ontologySet", + description=( + "Data Fabric ontologies grounding this context, configured inline " + "alongside the entity set. Each carries its own folderId and is " + "fetched from the QueryEngine ontology API at runtime." + ), + ) argument_properties: Dict[str, AgentToolArgumentProperties] = Field( {}, alias="argumentProperties" ) diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 202962c64..ffbc03e2a 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -3915,6 +3915,124 @@ def test_datafabric_context_config_parses(self): assert parsed.entity_set[1].entity_key == "orders-ref" assert parsed.entity_set[1].description is None + def test_context_config_parses_ontology_set(self): + """ontologySet parses into nested ontology items alongside entitySet.""" + config = { + "$resourceType": "context", + "name": "TestDataFabric", + "description": "", + "contextType": "datafabricentityset", + "entitySet": [{"id": "e1", "name": "Customers", "folderId": "f1"}], + "ontologySet": [ + {"name": "ecommerce", "folderId": "f1", "referenceKey": "ont-1"}, + {"name": "finance", "folderId": "f2"}, + ], + } + + parsed = AgentContextResourceConfig.model_validate(config) + + assert parsed.ontology_set is not None + assert len(parsed.ontology_set) == 2 + assert parsed.ontology_set[0].name == "ecommerce" + assert parsed.ontology_set[0].folder_key == "f1" + assert parsed.ontology_set[0].ontology_key == "ont-1" + assert parsed.ontology_set[1].name == "finance" + assert parsed.ontology_set[1].folder_key == "f2" + assert parsed.ontology_set[1].ontology_key is None + + def test_context_config_ontology_item_requires_folder_id(self): + """folderId is required on a nested ontology item.""" + config = { + "$resourceType": "context", + "name": "TestDataFabric", + "description": "", + "contextType": "datafabricentityset", + "ontologySet": [{"name": "ecommerce"}], # missing folderId + } + + with pytest.raises(ValidationError): + AgentContextResourceConfig.model_validate(config) + + def test_context_config_dumps_ontology_set_by_alias(self): + """ontology_set round-trips back to the ontologySet alias.""" + parsed = AgentContextResourceConfig.model_validate( + { + "$resourceType": "context", + "name": "TestDataFabric", + "description": "", + "contextType": "datafabricentityset", + "ontologySet": [ + {"name": "library", "folderId": "f1", "referenceKey": "ont-ref"} + ], + } + ) + dumped = parsed.model_dump(by_alias=True, exclude_none=True) + + assert dumped["ontologySet"][0]["name"] == "library" + assert dumped["ontologySet"][0]["folderId"] == "f1" + assert dumped["ontologySet"][0]["referenceKey"] == "ont-ref" + + def test_context_config_without_ontology_set(self): + """ontology_set defaults to None when not provided (backward compatible).""" + config = { + "$resourceType": "context", + "name": "TestDataFabric", + "description": "", + "contextType": "datafabricentityset", + "entitySet": [{"id": "e1", "name": "Customers", "folderId": "f1"}], + } + + parsed = AgentContextResourceConfig.model_validate(config) + + assert parsed.ontology_set is None + + def test_ontology_set_survives_full_definition_normalization(self): + """Regression: a context's nested ontologySet survives the full + AgentDefinition normalizer and parses into DataFabricOntologyItem. + + The runtime parses the whole AgentDefinition, which runs + ``_normalize_resources``. The context resource (and its nested + ontologySet) must come through unchanged so the ontologies resolve at + runtime. + """ + json_data = { + "id": "test-ontology-def", + "name": "Agent with nested ontology set", + "version": "1.0.0", + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [ + { + "$resourceType": "context", + "contextType": "datafabricentityset", + "name": "Entities", + "description": "DF context", + "entitySet": [ + {"id": "e1", "name": "LibraryLoan", "folderId": "f1"} + ], + "ontologySet": [{"name": "library", "folderId": "f1"}], + }, + ], + "messages": [{"role": "system", "content": "Test system message"}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + ctx = next( + r for r in config.resources if isinstance(r, AgentContextResourceConfig) + ) + assert ctx.ontology_set is not None + assert ctx.ontology_set[0].name == "library" + assert ctx.ontology_set[0].folder_key == "f1" + def test_is_datafabric(self): """Test is_datafabric property with datafabricentityset contextType.""" config = { diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 0d745bb8f..64fee39bc 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:56:19.8527915Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.12" +version = "2.11.13" source = { editable = "." } dependencies = [ { name = "applicationinsights" },