Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/uipath/_cli/cli_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
],
}

Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions src/uipath/functions/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"))
Expand Down
27 changes: 16 additions & 11 deletions src/uipath/functions/schema_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]:
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/uipath/platform/attachments/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions tests/cli/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
from unittest.mock import patch

import pytest
from click.testing import CliRunner

from uipath._cli import cli
Expand Down Expand Up @@ -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"]
154 changes: 75 additions & 79 deletions tests/cli/test_input_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading