diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b8effe0f0..868a66130 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.73" +version = "0.1.74" 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/_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/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 9f696bfe4..144cd43a1 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.73" +version = "0.1.74" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 1bf92e0e1..84ad749a0 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.11.9" +version = "2.11.10" 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.73, <0.2.0", + "uipath-platform>=0.1.74, <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..b22732ab4 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -102,6 +102,7 @@ class AgentResourceType(str, CaseInsensitiveEnum): ESCALATION = "escalation" MCP = "mcp" A2A = "a2a" + ONTOLOGY = "ontology" UNKNOWN = "unknown" # fallback branch discriminator @@ -341,6 +342,7 @@ class BaseAgentResourceConfig(BaseCfg): AgentResourceType.ESCALATION, AgentResourceType.MCP, AgentResourceType.A2A, + AgentResourceType.ONTOLOGY, AgentResourceType.UNKNOWN, ] = Field(alias="$resourceType") @@ -427,6 +429,27 @@ class AgentContextSettings(BaseCfg): ) +class AgentOntologyResourceConfig(BaseAgentResourceConfig): + """A Data Fabric ontology as a standalone, first-class agent resource. + + Promoted from a nested context field to its own ``resources[]`` entry so a + single ontology can be defined once and referenced by one or more Data + Fabric contexts (see ``AgentContextResourceConfig.ontology_refs``). Each + ontology carries its own ``folderId`` so it resolves from its own folder, + independent of the entities (which may also span several folders). + + ``name`` (inherited) is the ontology name used both to reference this + resource from a context and to fetch it from the QueryEngine ontology API. + """ + + resource_type: Literal[AgentResourceType.ONTOLOGY] = Field( + alias="$resourceType", default=AgentResourceType.ONTOLOGY, frozen=True + ) + id: Optional[str] = Field(None, alias="id") + ontology_key: Optional[str] = Field(None, alias="referenceKey") + folder_key: str = Field(..., alias="folderId") + + class AgentContextResourceConfig(BaseAgentResourceConfig): """Agent context resource configuration model.""" @@ -440,6 +463,15 @@ class AgentContextResourceConfig(BaseAgentResourceConfig): None, description="Context settings" ) entity_set: Optional[List[DataFabricEntityItem]] = Field(None, alias="entitySet") + ontology_refs: Optional[List[str]] = Field( + None, + alias="ontologyRefs", + description=( + "Names of standalone ontology resources " + "(AgentOntologyResourceConfig) this context is grounded by. " + "Resolved against the agent's resources at runtime." + ), + ) argument_properties: Dict[str, AgentToolArgumentProperties] = Field( {}, alias="argumentProperties" ) @@ -1145,6 +1177,7 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): EscalationResourceConfig, # nested discrim on 'escalation_type' AgentMcpResourceConfig, AgentA2aResourceConfig, + AgentOntologyResourceConfig, AgentUnknownResourceConfig, # when parent sets resource_type="Unknown" ], Field(discriminator="resource_type"), @@ -1498,7 +1531,7 @@ def _normalize_guardrails(v: Dict[str, Any]) -> None: @staticmethod def _normalize_resources(v: Dict[str, Any]) -> None: - KNOWN_RES = {"tool", "context", "escalation", "mcp", "a2a"} + KNOWN_RES = {"tool", "context", "escalation", "mcp", "a2a", "ontology"} TOOL_MAP = { "agent": "Agent", "process": "Process", diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 202962c64..9babeb78e 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -35,8 +35,10 @@ AgentMessageRole, AgentNumberOperator, AgentNumberRule, + AgentOntologyResourceConfig, AgentProcessToolResourceConfig, AgentQuickFormChannelProperties, + AgentResourceConfig, AgentResourceType, AgentToolArgumentPropertiesVariant, AgentToolType, @@ -3915,6 +3917,177 @@ 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_ontology_resource_parses_standalone(self): + """A standalone ontology resource parses via the resource union.""" + config = { + "$resourceType": "ontology", + "name": "ecommerce", + "description": "E-commerce domain ontology", + "folderId": "f1", + } + + parsed: AgentResourceConfig = TypeAdapter(AgentResourceConfig).validate_python( + config + ) + + assert isinstance(parsed, AgentOntologyResourceConfig) + assert parsed.resource_type == AgentResourceType.ONTOLOGY + assert parsed.name == "ecommerce" + assert parsed.folder_key == "f1" + assert parsed.ontology_key is None + + def test_ontology_resource_parses_optional_fields(self): + """id / referenceKey are optional and aliased on the ontology resource.""" + config = { + "$resourceType": "ontology", + "id": "o2", + "referenceKey": "ont-ref", + "name": "finance", + "description": "Finance domain", + "folderId": "f2", + } + + parsed: AgentResourceConfig = TypeAdapter(AgentResourceConfig).validate_python( + config + ) + + assert isinstance(parsed, AgentOntologyResourceConfig) + assert parsed.id == "o2" + assert parsed.ontology_key == "ont-ref" + assert parsed.folder_key == "f2" + assert parsed.description == "Finance domain" + + def test_ontology_resource_dumps_by_alias(self): + """The ontology resource round-trips back to aliased JSON keys.""" + parsed: AgentResourceConfig = TypeAdapter(AgentResourceConfig).validate_python( + { + "$resourceType": "ontology", + "referenceKey": "ont-ref", + "name": "finance", + "description": "Finance domain", + "folderId": "f2", + } + ) + dumped = parsed.model_dump(by_alias=True, exclude_none=True) + + assert dumped["$resourceType"] == "ontology" + assert dumped["name"] == "finance" + assert dumped["folderId"] == "f2" + assert dumped["referenceKey"] == "ont-ref" + + def test_ontology_resource_requires_folder_id(self): + """folderId is required on an ontology resource.""" + config = { + "$resourceType": "ontology", + "name": "ecommerce", + "description": "", + # missing folderId + } + + with pytest.raises(ValidationError): + TypeAdapter(AgentResourceConfig).validate_python(config) + + def test_context_config_parses_ontology_refs(self): + """ontologyRefs parses into a list of ontology resource names.""" + config = { + "$resourceType": "context", + "name": "TestDataFabric", + "description": "", + "contextType": "datafabricentityset", + "entitySet": [{"id": "e1", "name": "Customers", "folderId": "f1"}], + "ontologyRefs": ["ecommerce", "finance"], + } + + parsed = AgentContextResourceConfig.model_validate(config) + + assert parsed.ontology_refs == ["ecommerce", "finance"] + + def test_context_config_dumps_ontology_refs_by_alias(self): + """ontology_refs round-trips back to the ontologyRefs alias.""" + parsed = AgentContextResourceConfig.model_validate( + { + "$resourceType": "context", + "name": "TestDataFabric", + "description": "", + "contextType": "datafabricentityset", + "ontologyRefs": ["library"], + } + ) + dumped = parsed.model_dump(by_alias=True, exclude_none=True) + + assert dumped["ontologyRefs"] == ["library"] + + def test_context_config_without_ontology_refs(self): + """ontology_refs 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_refs is None + + def test_ontology_resource_survives_full_definition_normalization(self): + """Regression: an ontology resource parses through the full + AgentDefinition normalizer (not coerced to Unknown). + + The per-resource union recognises ``$resourceType: "ontology"``, but the + runtime parses the whole AgentDefinition, which runs + ``_normalize_resources``. That normalizer's known-type set must include + "ontology"; otherwise it is rewritten to "unknown", parses as + AgentUnknownResourceConfig, and the context's ``ontologyRefs`` never + resolve at runtime. + """ + json_data = { + "id": "test-ontology-def", + "name": "Agent with ontology resource", + "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"} + ], + "ontologyRefs": ["library"], + }, + { + "$resourceType": "ontology", + "name": "library", + "description": "Library ontology", + "folderId": "f1", + }, + ], + "messages": [{"role": "system", "content": "Test system message"}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + type_names = {type(r).__name__ for r in config.resources} + assert "AgentOntologyResourceConfig" in type_names + assert "AgentUnknownResourceConfig" not in type_names + onto = next( + r for r in config.resources if isinstance(r, AgentOntologyResourceConfig) + ) + assert onto.name == "library" + assert onto.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 94b6b38fb..2fc09e8bf 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.9" +version = "2.11.10" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.73" +version = "0.1.74" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },