diff --git a/pyproject.toml b/pyproject.toml index 45d21f5e8..c162a0293 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.4.13" +version = "2.4.14" 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.1.4, <0.2.0", - "uipath-runtime>=0.4.0, <0.5.0", + "uipath-runtime>=0.4.1, <0.5.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/src/uipath/_cli/cli_init.py b/src/uipath/_cli/cli_init.py index 08ffbf3cb..35d112121 100644 --- a/src/uipath/_cli/cli_init.py +++ b/src/uipath/_cli/cli_init.py @@ -173,7 +173,11 @@ def write_entry_points_file(entry_points: list[UiPathRuntimeSchema]) -> Path: "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", "$id": "entry-points.json", "entryPoints": [ - ep.model_dump(by_alias=True, exclude_unset=True) for ep in entry_points + ep.model_dump( + by_alias=True, + exclude_unset=True, + ) + for ep in entry_points ], } @@ -297,6 +301,7 @@ async def initialize() -> None: entrypoint_name, runtime_id="default" ) schema = await runtime.get_schema() + entry_point_schemas.append(schema) finally: if runtime: diff --git a/src/uipath/functions/runtime.py b/src/uipath/functions/runtime.py index 3267216fd..0e59096b1 100644 --- a/src/uipath/functions/runtime.py +++ b/src/uipath/functions/runtime.py @@ -22,7 +22,7 @@ UiPathErrorContract, UiPathRuntimeError, ) -from uipath.runtime.schema import UiPathRuntimeSchema +from uipath.runtime.schema import UiPathRuntimeSchema, transform_attachments from .schema_gen import get_type_schema from .type_conversion import ( @@ -174,7 +174,8 @@ async def get_schema(self) -> UiPathRuntimeSchema: input_schema = {} else: input_param_name = next(iter(sig.parameters)) - input_schema = get_type_schema(hints.get(input_param_name)) + schema = get_type_schema(hints.get(input_param_name)) + input_schema = transform_attachments(schema) # Determine output schema output_schema = get_type_schema(hints.get("return")) diff --git a/src/uipath/functions/schema_gen.py b/src/uipath/functions/schema_gen.py index 74cb85789..751fb4278 100644 --- a/src/uipath/functions/schema_gen.py +++ b/src/uipath/functions/schema_gen.py @@ -7,6 +7,7 @@ from typing import Any, Union, get_args, get_origin from pydantic import BaseModel +from uipath.runtime.schema import transform_nullable_types, transform_references TYPE_MAP: dict[str, str] = { "int": "integer", @@ -91,17 +92,20 @@ def _get_enum_schema(enum_class: type[Enum]) -> dict[str, Any]: def _get_pydantic_schema(model_class: type[BaseModel]) -> dict[str, Any]: - """Generate schema for Pydantic models.""" - properties = {} - required = [] - - for field_name, field_info in model_class.model_fields.items(): - schema_field_name = field_info.alias or field_name - properties[schema_field_name] = get_type_schema(field_info.annotation) - if field_info.is_required(): - required.append(schema_field_name) - - return {"type": "object", "properties": properties, "required": required} + """Generate schema for Pydantic models using Pydantic's built-in schema generation.""" + schema = model_class.model_json_schema() + + resolved_schema, _ = transform_references(schema) + processed_properties = transform_nullable_types(resolved_schema) + assert isinstance(processed_properties, dict) + schema = { + "type": "object", + "properties": processed_properties.get("properties", {}), + "required": processed_properties.get("required", []), + } + if (title := processed_properties.get("title", None)) is not None: + schema["title"] = title + return schema def _get_dataclass_schema(dataclass_type: type) -> dict[str, Any]: @@ -111,6 +115,7 @@ def _get_dataclass_schema(dataclass_type: type) -> dict[str, Any]: for field in fields(dataclass_type): properties[field.name] = get_type_schema(field.type) + # Field is required if it has no default value and no default_factory if field.default == field.default_factory == field.default.__class__.__name__: required.append(field.name) diff --git a/src/uipath/platform/attachments/attachments.py b/src/uipath/platform/attachments/attachments.py index 345c149ac..a14508a3a 100644 --- a/src/uipath/platform/attachments/attachments.py +++ b/src/uipath/platform/attachments/attachments.py @@ -21,6 +21,9 @@ class Attachment(BaseModel): id: Optional[uuid.UUID] = Field(None, alias="ID") full_name: str = Field(..., alias="FullName") mime_type: str = Field(..., alias="MimeType") + model_config = { + "title": "UiPathAttachment", + } @dataclass diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index feb564ee6..a4196483a 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -2,6 +2,7 @@ import os from unittest.mock import patch +import pytest from click.testing import CliRunner from uipath._cli import cli @@ -362,3 +363,102 @@ def test_bindings_and_entrypoints_files_creation( config = json.load(f) assert "functions" in config assert config["functions"]["main"] == "main.py:main" + + @pytest.mark.parametrize( + ("input_model", "verify_other_field"), + [ + ( + """ +# pydantic BaseModel + +from pydantic import BaseModel, Field +class InputModel(BaseModel): + input_file: Attachment + other_field: int | None = Field(default=None)""", + True, + ), + ( + """ +# dataclass + +from dataclasses import dataclass +@dataclass +class InputModel: + input_file: Attachment + other_field: int | None = None""", + True, + ), + ( + """ +# regular class + +class InputModel: + input_file: Attachment + other_field: int | None = None + + def __init__(self, input_file: Attachment, other_field: int | None = None): + self.input_file = input_file + self.other_field = other_field""", + True, + ), + ( + """ +# attachment class itself + + +from typing import TypeAlias +InputModel: TypeAlias = Attachment +""", + False, + ), + ], + ) + def test_schema_generation_resolves_attachments_pydantic_dataclass( + self, runner: CliRunner, temp_dir: str, input_model: str, verify_other_field + ) -> None: + """Test that attachments are resolved in entry-points schema""" + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write(f""" +from uipath.platform.attachments import Attachment +{input_model} +def main(input: InputModel) -> InputModel: return input""") + + uipath_config = {"functions": {"main": "main.py:main"}} + with open("uipath.json", "w") as f: + json.dump(uipath_config, f) + + result = runner.invoke(cli, ["init"], env={}) + + assert result.exit_code == 0 + assert "Created 'bindings.json' file" in result.output + assert "Created 'entry-points.json' file" in result.output + + # Verify entry-points.json contains attachments definition + with open("entry-points.json", "r") as f: + entrypoints = json.load(f) + input_schema = entrypoints["entryPoints"][0]["input"] + assert "definitions" in input_schema + assert "job-attachment" in input_schema["definitions"] + assert input_schema["definitions"]["job-attachment"]["type"] == "object" + assert ( + input_schema["definitions"]["job-attachment"][ + "x-uipath-resource-kind" + ] + == "JobAttachment" + ) + assert all( + prop_name + in input_schema["definitions"]["job-attachment"]["properties"] + for prop_name in ["ID", "FullName", "MimeType", "Metadata"] + ) + if not verify_other_field: + return + + assert len(input_schema["properties"]) == 2 + assert all( + prop_name in input_schema["properties"] + for prop_name in ["input_file", "other_field"] + ) + assert input_schema["required"] == ["input_file"] diff --git a/tests/cli/test_input_args.py b/tests/cli/test_input_args.py index f0062275d..00a7d51da 100644 --- a/tests/cli/test_input_args.py +++ b/tests/cli/test_input_args.py @@ -37,82 +37,78 @@ class SimpleDataClass: value: int = 42 -def test_pydantic_model_with_aliases(): - """Test that Pydantic model schemas use field aliases when defined.""" - schema = get_type_schema(EventArguments) - - assert schema["type"] == "object" - assert "properties" in schema - - # Check that aliases are used in property names - expected_properties = { - "UiPathEventConnector", - "UiPathEvent", - "UiPathEventObjectType", - "UiPathEventObjectId", - "UiPathAdditionalEventData", - } - actual_properties = set(schema["properties"].keys()) - assert actual_properties == expected_properties - - # All fields have defaults, so none should be required - assert schema["required"] == [] - - -def test_pydantic_model_required_fields(): - """Test that required fields are correctly identified in Pydantic models.""" - schema = get_type_schema(RequiredFieldsModel) - - assert schema["type"] == "object" - assert "properties" in schema - - # Check properties include both field names and aliases - expected_properties = { - "required_field", # field name (no alias) - "optional_field", # field name (no alias) - "AliasedRequired", # alias - "AliasedOptional", # alias - } - actual_properties = set(schema["properties"].keys()) - assert actual_properties == expected_properties - - # Check required fields (using aliases where defined) - expected_required = {"required_field", "AliasedRequired"} - actual_required = set(schema["required"]) - assert actual_required == expected_required - - -def test_dataclass_still_works(): - """Test that dataclass functionality is not broken.""" - schema = get_type_schema(SimpleDataClass) - - assert schema["type"] == "object" - assert "properties" in schema - - # Dataclass should use field names (no alias support) - expected_properties = {"name", "value"} - actual_properties = set(schema["properties"].keys()) - assert actual_properties == expected_properties - - # Field with default should not be required - assert schema["required"] == ["name"] - - -def test_primitive_types(): - """Test that primitive type handling still works.""" - assert get_type_schema(str) == {"type": "string"} - assert get_type_schema(int) == {"type": "integer"} - assert get_type_schema(float) == {"type": "number"} - assert get_type_schema(bool) == {"type": "boolean"} - - -def test_optional_types(): - """Test handling of Optional types.""" - schema = get_type_schema(Optional[str]) - assert schema == {"type": "string"} # Should unwrap Optional - - -def test_optional_union_types(): - """Test handling of Optional types.""" - schema = get_type_schema(str | None) - assert schema == {"type": "string"} # Should unwrap Optional +class TestInputArgs: + def test_pydantic_model_with_aliases(self): + """Test that Pydantic model schemas use field aliases when defined.""" + schema = get_type_schema(EventArguments) + + assert schema["type"] == "object" + assert "properties" in schema + + # Check that aliases are used in property names + expected_properties = { + "UiPathEventConnector", + "UiPathEvent", + "UiPathEventObjectType", + "UiPathEventObjectId", + "UiPathAdditionalEventData", + } + actual_properties = set(schema["properties"].keys()) + assert actual_properties == expected_properties + + # All fields have defaults, so none should be required + assert schema["required"] == [] + + def test_pydantic_model_required_fields(self): + """Test that required fields are correctly identified in Pydantic models.""" + schema = get_type_schema(RequiredFieldsModel) + + assert schema["type"] == "object" + assert "properties" in schema + + # Check properties include both field names and aliases + expected_properties = { + "required_field", # field name (no alias) + "optional_field", # field name (no alias) + "AliasedRequired", # alias + "AliasedOptional", # alias + } + actual_properties = set(schema["properties"].keys()) + assert actual_properties == expected_properties + + # Check required fields (using aliases where defined) + expected_required = {"required_field", "AliasedRequired"} + actual_required = set(schema["required"]) + assert actual_required == expected_required + + def test_dataclass_still_works(self): + """Test that dataclass functionality is not broken.""" + schema = get_type_schema(SimpleDataClass) + + assert schema["type"] == "object" + assert "properties" in schema + + # Dataclass should use field names (no alias support) + expected_properties = {"name", "value"} + actual_properties = set(schema["properties"].keys()) + assert actual_properties == expected_properties + + # Field with default should not be required + assert schema["required"] == ["name"] + + def test_primitive_types(self): + """Test that primitive type handling still works.""" + assert get_type_schema(str) == {"type": "string"} + assert get_type_schema(int) == {"type": "integer"} + assert get_type_schema(float) == {"type": "number"} + assert get_type_schema(bool) == {"type": "boolean"} + + def test_optional_types(self): + """Test handling of Optional types.""" + response = get_type_schema(Optional[str]) + assert response == {"type": "string"} # Should unwrap Optional + + def test_optional_union_types(self): + """Test handling of Optional types.""" + response = get_type_schema(str | None) + assert response == {"type": "string"} # Should unwrap Optional diff --git a/uv.lock b/uv.lock index 7a1755e54..79dfaec13 100644 --- a/uv.lock +++ b/uv.lock @@ -2486,7 +2486,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.4.13" +version = "2.4.14" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2553,7 +2553,7 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", specifier = ">=0.1.4,<0.2.0" }, - { name = "uipath-runtime", specifier = ">=0.4.0,<0.5.0" }, + { name = "uipath-runtime", specifier = ">=0.4.1,<0.5.0" }, ] [package.metadata.requires-dev] @@ -2599,14 +2599,14 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.4.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/6f/683b258720c18f8ec0e68ec712a05f42ede6ecf63e75710aa555b8d52092/uipath_runtime-0.4.0.tar.gz", hash = "sha256:129933b08c6f589d13c2c0e7045ddf61ca144029340c1482134d127dd15563e3", size = 99934, upload-time = "2026-01-03T05:44:33.712Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/2a/8373a1c1118442b000c5a89e864a61e8548e6e1575c30fb21501b0e60652/uipath_runtime-0.4.1.tar.gz", hash = "sha256:ddcb26c02833993432a4c19c3306a55858a14afecaffcf32195601564bb44585", size = 102875, upload-time = "2026-01-13T13:34:50.803Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/46/402708653a197c7f0b1d9de66b235f8a5798f814c775bab575cd2d7e2539/uipath_runtime-0.4.0-py3-none-any.whl", hash = "sha256:f49a23ed24f7cfaa736f99a5763bcf314234c67b727c40ec891a0a3d10140027", size = 38359, upload-time = "2026-01-03T05:44:31.817Z" }, + { url = "https://files.pythonhosted.org/packages/4a/58/14c89ba528c4e69683d0e19b43026bc8102dab02ec66c4a0d9f2a0fc4ae9/uipath_runtime-0.4.1-py3-none-any.whl", hash = "sha256:b10c7072246066c8e525eb602a9e04f5497a7da6af871d1bd27693fb9e910d7b", size = 39834, upload-time = "2026-01-13T13:34:49.421Z" }, ] [[package]]