diff --git a/pyproject.toml b/pyproject.toml index 10daf9d..46cddc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.4.0" +version = "0.4.1" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/schema.py b/src/uipath/runtime/schema.py index 45261a9..f9a25a3 100644 --- a/src/uipath/runtime/schema.py +++ b/src/uipath/runtime/schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -68,9 +69,174 @@ class UiPathRuntimeSchema(BaseModel): model_config = COMMON_MODEL_SCHEMA +def _get_job_attachment_definition() -> dict[str, Any]: + """Get the job-attachment definition schema for UiPath attachments. + + Returns: + The JSON schema definition for a UiPath job attachment. + """ + return { + "type": "object", + "required": ["ID"], + "x-uipath-resource-kind": "JobAttachment", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + "MimeType": {"type": "string"}, + "Metadata": { + "type": "object", + "additionalProperties": {"type": "string"}, + }, + }, + } + + +def transform_attachments(schema: dict[str, Any]) -> dict[str, Any]: + """Transform UiPathAttachment references in a JSON schema to use $ref. + + This function recursively traverses a JSON schema and replaces any objects + with title="UiPathAttachment" with a $ref to "#/definitions/job-attachment", + adding the job-attachment definition to the schema's definitions section. + + Args: + schema: The JSON schema to transform (will not be modified in-place). + + Returns: + A new schema with UiPathAttachment references replaced by $ref. + + Example: + >>> schema = { + ... "type": "object", + ... "properties": { + ... "file": { + ... "title": "UiPathAttachment", + ... "type": "object", + ... "properties": {...} + ... } + ... } + ... } + >>> result = transform_attachments(schema) + >>> result["properties"]["file"] + {"$ref": "#/definitions/job-attachment"} + >>> "job-attachment" in result["definitions"] + True + """ + result = copy.deepcopy(schema) + has_attachments = False + + def transform_recursive(obj: Any) -> Any: + """Recursively transform the schema object.""" + nonlocal has_attachments + + if isinstance(obj, dict): + if obj.get("title") == "UiPathAttachment" and obj.get("type") == "object": + has_attachments = True + return {"$ref": "#/definitions/job-attachment"} + + return {key: transform_recursive(value) for key, value in obj.items()} + + elif isinstance(obj, list): + return [transform_recursive(item) for item in obj] + + else: + # Return primitive values as-is + return obj + + result = transform_recursive(result) + + # add the job-attachment definition if any are present + if has_attachments: + if "definitions" not in result: + result["definitions"] = {} + result["definitions"]["job-attachment"] = _get_job_attachment_definition() + + return result + + +def transform_references(schema, root=None, visited=None): + """Recursively resolves $ref references in a JSON schema, handling circular references. + + Returns: + tuple: (resolved_schema, has_circular_dependency) + """ + if root is None: + root = schema + + if visited is None: + visited = set() + + has_circular = False + + if isinstance(schema, dict): + if "$ref" in schema: + ref_path = schema["$ref"] + + if ref_path in visited: + # Circular dependency detected + return { + "type": "object", + "description": f"Circular reference to {ref_path}", + }, True + + visited.add(ref_path) + + # Resolve the reference + ref_parts = ref_path.lstrip("#/").split("/") + ref_schema = root + for part in ref_parts: + ref_schema = ref_schema.get(part, {}) + + result, circular = transform_references(ref_schema, root, visited) + has_circular = has_circular or circular + + # Remove from visited after resolution (allows the same ref in different branches) + visited.discard(ref_path) + + return result, has_circular + + resolved_dict = {} + for k, v in schema.items(): + resolved_value, circular = transform_references(v, root, visited) + resolved_dict[k] = resolved_value + has_circular = has_circular or circular + return resolved_dict, has_circular + + elif isinstance(schema, list): + resolved_list = [] + for item in schema: + resolved_item, circular = transform_references(item, root, visited) + resolved_list.append(resolved_item) + has_circular = has_circular or circular + return resolved_list, has_circular + + return schema, False + + +def transform_nullable_types( + schema: dict[str, Any] | list[Any] | Any, +) -> dict[str, Any] | list[Any]: + """Process the schema to handle nullable types by removing anyOf with null and keeping the base type.""" + if isinstance(schema, dict): + if "anyOf" in schema and len(schema["anyOf"]) == 2: + types = [t.get("type") for t in schema["anyOf"]] + if "null" in types: + non_null_type = next( + t for t in schema["anyOf"] if t.get("type") != "null" + ) + return non_null_type + + return {k: transform_nullable_types(v) for k, v in schema.items()} + elif isinstance(schema, list): + return [transform_nullable_types(item) for item in schema] + return schema + + __all__ = [ "UiPathRuntimeSchema", "UiPathRuntimeGraph", "UiPathRuntimeNode", "UiPathRuntimeEdge", + "transform_nullable_types", + "transform_references", + "transform_attachments", ] diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..3dd737d --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,297 @@ +from typing import Any + +from pydantic import BaseModel + +from uipath.runtime.schema import ( + transform_attachments, + transform_nullable_types, + transform_references, +) + + +class TestSchemaGenerationHelpers: + def test_transform_attachments_simple(self): + """Test transforming a simple schema with UiPathAttachment.""" + schema = { + "type": "object", + "properties": { + "abc": {"type": "string"}, + "file": { + "title": "UiPathAttachment", + "type": "object", + "properties": { + "ID": {"type": "string"}, + "FullName": {"type": "string"}, + }, + }, + }, + } + + result = transform_attachments(schema) + + assert result["properties"]["file"] == {"$ref": "#/definitions/job-attachment"} + + # Check that abc is unchanged + assert result["properties"]["abc"] == {"type": "string"} + + # Check that definitions were added + assert "definitions" in result + assert "job-attachment" in result["definitions"] + + # Check the job-attachment definition structure + job_def = result["definitions"]["job-attachment"] + assert job_def["type"] == "object" + assert job_def["x-uipath-resource-kind"] == "JobAttachment" + assert "ID" in job_def["required"] + assert "ID" in job_def["properties"] + assert "FullName" in job_def["properties"] + assert "MimeType" in job_def["properties"] + assert "Metadata" in job_def["properties"] + + def test_transform_attachments_nested(self): + """Test transforming nested properties with UiPathAttachment.""" + schema = { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "attachment": { + "title": "UiPathAttachment", + "type": "object", + "properties": {}, + } + }, + } + }, + } + + result = transform_attachments(schema) + + # Check that the nested attachment was replaced + assert result["properties"]["data"]["properties"]["attachment"] == { + "$ref": "#/definitions/job-attachment" + } + + # Check definitions + assert "definitions" in result + assert "job-attachment" in result["definitions"] + + def test_transform_attachments_multiple(self): + """Test transforming multiple UiPathAttachment references.""" + schema = { + "type": "object", + "properties": { + "file1": { + "title": "UiPathAttachment", + "type": "object", + }, + "file2": { + "title": "UiPathAttachment", + "type": "object", + }, + }, + } + + result = transform_attachments(schema) + + # Both should be replaced with $ref + assert result["properties"]["file1"] == {"$ref": "#/definitions/job-attachment"} + assert result["properties"]["file2"] == {"$ref": "#/definitions/job-attachment"} + + # Should only have one definition + assert "definitions" in result + assert "job-attachment" in result["definitions"] + assert len(result["definitions"]) == 1 + + def test_transform_attachments_in_array(self): + """Test transforming UiPathAttachment inside array items.""" + schema = { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "title": "UiPathAttachment", + "type": "object", + }, + } + }, + } + + result = transform_attachments(schema) + + # Array items should be replaced + assert result["properties"]["files"]["items"] == { + "$ref": "#/definitions/job-attachment" + } + + # Check definitions + assert "definitions" in result + assert "job-attachment" in result["definitions"] + + def test_transform_attachments_no_attachments(self): + """Test transforming schema without any UiPathAttachment.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + } + + result = transform_attachments(schema) + + # Schema should be unchanged (except for deep copy) + assert result["properties"]["name"] == {"type": "string"} + assert result["properties"]["age"] == {"type": "integer"} + + # No definitions should be added + assert "definitions" not in result + + def test_transform_attachments_preserves_existing_definitions(self): + """Test that existing definitions are preserved.""" + schema = { + "type": "object", + "properties": { + "file": { + "title": "UiPathAttachment", + "type": "object", + } + }, + "definitions": { + "custom-type": { + "type": "string", + "enum": ["a", "b"], + } + }, + } + + result = transform_attachments(schema) + + # Check that both definitions exist + assert "definitions" in result + assert "job-attachment" in result["definitions"] + assert "custom-type" in result["definitions"] + + # Check that custom-type is unchanged + assert result["definitions"]["custom-type"] == { + "type": "string", + "enum": ["a", "b"], + } + + def test_transform_attachments_immutable(self): + """Test that the original schema is not modified.""" + + schema: dict[str, Any] = { + "type": "object", + "properties": { + "file": { + "title": "UiPathAttachment", + "type": "object", + } + }, + } + + original_properties = schema["properties"]["file"].copy() + result = transform_attachments(schema) + + # Original should be unchanged + assert schema["properties"]["file"] == original_properties + assert "definitions" not in schema + + # Result should be transformed + assert result["properties"]["file"] == {"$ref": "#/definitions/job-attachment"} + assert "definitions" in result + + def test_transform_attachments_only_with_title_and_type(self): + """Test that only objects with both title and type=object are transformed.""" + schema = { + "type": "object", + "properties": { + "only_title": { + "title": "UiPathAttachment", + # Missing type: "object" + }, + "only_type": { + "type": "object", + # Missing title + }, + "wrong_title": { + "title": "SomethingElse", + "type": "object", + }, + "correct": { + "title": "UiPathAttachment", + "type": "object", + }, + }, + } + + result = transform_attachments(schema) + + # Only the correct one should be transformed + assert "only_title" in result["properties"] + assert result["properties"]["only_title"].get("$ref") is None + + assert "only_type" in result["properties"] + assert result["properties"]["only_type"].get("$ref") is None + + assert "wrong_title" in result["properties"] + assert result["properties"]["wrong_title"].get("$ref") is None + + assert result["properties"]["correct"] == { + "$ref": "#/definitions/job-attachment" + } + + # Should have definitions + assert "definitions" in result + assert "job-attachment" in result["definitions"] + + def test_transform_references_and_transform_nullable_types(self): + """Test that transform_references and transform_nullable_types work correctly together.""" + + class InnerModel(BaseModel): + inner_model_property: str + + class TestModel(BaseModel): + test_model_property: str | None = None + inner_model_instance: InnerModel + + resolved_schema, _ = transform_references(TestModel.model_json_schema()) + result = transform_nullable_types(resolved_schema) + + expected = { + "$defs": { + "InnerModel": { + "properties": { + "inner_model_property": { + "title": "Inner Model Property", + "type": "string", + } + }, + "required": ["inner_model_property"], + "title": "InnerModel", + "type": "object", + } + }, + "properties": { + "test_model_property": {"type": "string"}, + "inner_model_instance": { + "properties": { + "inner_model_property": { + "title": "Inner Model Property", + "type": "string", + } + }, + "required": ["inner_model_property"], + "title": "InnerModel", + "type": "object", + }, + }, + "required": ["inner_model_instance"], + "title": "TestModel", + "type": "object", + } + + assert result == expected diff --git a/uv.lock b/uv.lock index f847a43..8ee96e4 100644 --- a/uv.lock +++ b/uv.lock @@ -1005,7 +1005,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "uipath-core" },