Skip to content

Commit 594eda0

Browse files
authored
feat: infer attachment on init (#1093)
1 parent d6e1846 commit 594eda0

File tree

8 files changed

+210
-100
lines changed

8 files changed

+210
-100
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
[project]
22
name = "uipath"
3-
version = "2.4.13"
3+
version = "2.4.14"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
88
"uipath-core>=0.1.4, <0.2.0",
9-
"uipath-runtime>=0.4.0, <0.5.0",
9+
"uipath-runtime>=0.4.1, <0.5.0",
1010
"click>=8.3.1",
1111
"httpx>=0.28.1",
1212
"pyjwt>=2.10.1",

src/uipath/_cli/cli_init.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ def write_entry_points_file(entry_points: list[UiPathRuntimeSchema]) -> Path:
173173
"$schema": "https://cloud.uipath.com/draft/2024-12/entry-point",
174174
"$id": "entry-points.json",
175175
"entryPoints": [
176-
ep.model_dump(by_alias=True, exclude_unset=True) for ep in entry_points
176+
ep.model_dump(
177+
by_alias=True,
178+
exclude_unset=True,
179+
)
180+
for ep in entry_points
177181
],
178182
}
179183

@@ -297,6 +301,7 @@ async def initialize() -> None:
297301
entrypoint_name, runtime_id="default"
298302
)
299303
schema = await runtime.get_schema()
304+
300305
entry_point_schemas.append(schema)
301306
finally:
302307
if runtime:

src/uipath/functions/runtime.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
UiPathErrorContract,
2323
UiPathRuntimeError,
2424
)
25-
from uipath.runtime.schema import UiPathRuntimeSchema
25+
from uipath.runtime.schema import UiPathRuntimeSchema, transform_attachments
2626

2727
from .schema_gen import get_type_schema
2828
from .type_conversion import (
@@ -174,7 +174,8 @@ async def get_schema(self) -> UiPathRuntimeSchema:
174174
input_schema = {}
175175
else:
176176
input_param_name = next(iter(sig.parameters))
177-
input_schema = get_type_schema(hints.get(input_param_name))
177+
schema = get_type_schema(hints.get(input_param_name))
178+
input_schema = transform_attachments(schema)
178179

179180
# Determine output schema
180181
output_schema = get_type_schema(hints.get("return"))

src/uipath/functions/schema_gen.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, Union, get_args, get_origin
88

99
from pydantic import BaseModel
10+
from uipath.runtime.schema import transform_nullable_types, transform_references
1011

1112
TYPE_MAP: dict[str, str] = {
1213
"int": "integer",
@@ -91,17 +92,20 @@ def _get_enum_schema(enum_class: type[Enum]) -> dict[str, Any]:
9192

9293

9394
def _get_pydantic_schema(model_class: type[BaseModel]) -> dict[str, Any]:
94-
"""Generate schema for Pydantic models."""
95-
properties = {}
96-
required = []
97-
98-
for field_name, field_info in model_class.model_fields.items():
99-
schema_field_name = field_info.alias or field_name
100-
properties[schema_field_name] = get_type_schema(field_info.annotation)
101-
if field_info.is_required():
102-
required.append(schema_field_name)
103-
104-
return {"type": "object", "properties": properties, "required": required}
95+
"""Generate schema for Pydantic models using Pydantic's built-in schema generation."""
96+
schema = model_class.model_json_schema()
97+
98+
resolved_schema, _ = transform_references(schema)
99+
processed_properties = transform_nullable_types(resolved_schema)
100+
assert isinstance(processed_properties, dict)
101+
schema = {
102+
"type": "object",
103+
"properties": processed_properties.get("properties", {}),
104+
"required": processed_properties.get("required", []),
105+
}
106+
if (title := processed_properties.get("title", None)) is not None:
107+
schema["title"] = title
108+
return schema
105109

106110

107111
def _get_dataclass_schema(dataclass_type: type) -> dict[str, Any]:
@@ -111,6 +115,7 @@ def _get_dataclass_schema(dataclass_type: type) -> dict[str, Any]:
111115

112116
for field in fields(dataclass_type):
113117
properties[field.name] = get_type_schema(field.type)
118+
114119
# Field is required if it has no default value and no default_factory
115120
if field.default == field.default_factory == field.default.__class__.__name__:
116121
required.append(field.name)

src/uipath/platform/attachments/attachments.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class Attachment(BaseModel):
2121
id: Optional[uuid.UUID] = Field(None, alias="ID")
2222
full_name: str = Field(..., alias="FullName")
2323
mime_type: str = Field(..., alias="MimeType")
24+
model_config = {
25+
"title": "UiPathAttachment",
26+
}
2427

2528

2629
@dataclass

tests/cli/test_init.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
from unittest.mock import patch
44

5+
import pytest
56
from click.testing import CliRunner
67

78
from uipath._cli import cli
@@ -362,3 +363,102 @@ def test_bindings_and_entrypoints_files_creation(
362363
config = json.load(f)
363364
assert "functions" in config
364365
assert config["functions"]["main"] == "main.py:main"
366+
367+
@pytest.mark.parametrize(
368+
("input_model", "verify_other_field"),
369+
[
370+
(
371+
"""
372+
# pydantic BaseModel
373+
374+
from pydantic import BaseModel, Field
375+
class InputModel(BaseModel):
376+
input_file: Attachment
377+
other_field: int | None = Field(default=None)""",
378+
True,
379+
),
380+
(
381+
"""
382+
# dataclass
383+
384+
from dataclasses import dataclass
385+
@dataclass
386+
class InputModel:
387+
input_file: Attachment
388+
other_field: int | None = None""",
389+
True,
390+
),
391+
(
392+
"""
393+
# regular class
394+
395+
class InputModel:
396+
input_file: Attachment
397+
other_field: int | None = None
398+
399+
def __init__(self, input_file: Attachment, other_field: int | None = None):
400+
self.input_file = input_file
401+
self.other_field = other_field""",
402+
True,
403+
),
404+
(
405+
"""
406+
# attachment class itself
407+
408+
409+
from typing import TypeAlias
410+
InputModel: TypeAlias = Attachment
411+
""",
412+
False,
413+
),
414+
],
415+
)
416+
def test_schema_generation_resolves_attachments_pydantic_dataclass(
417+
self, runner: CliRunner, temp_dir: str, input_model: str, verify_other_field
418+
) -> None:
419+
"""Test that attachments are resolved in entry-points schema"""
420+
421+
with runner.isolated_filesystem(temp_dir=temp_dir):
422+
with open("main.py", "w") as f:
423+
f.write(f"""
424+
from uipath.platform.attachments import Attachment
425+
{input_model}
426+
def main(input: InputModel) -> InputModel: return input""")
427+
428+
uipath_config = {"functions": {"main": "main.py:main"}}
429+
with open("uipath.json", "w") as f:
430+
json.dump(uipath_config, f)
431+
432+
result = runner.invoke(cli, ["init"], env={})
433+
434+
assert result.exit_code == 0
435+
assert "Created 'bindings.json' file" in result.output
436+
assert "Created 'entry-points.json' file" in result.output
437+
438+
# Verify entry-points.json contains attachments definition
439+
with open("entry-points.json", "r") as f:
440+
entrypoints = json.load(f)
441+
input_schema = entrypoints["entryPoints"][0]["input"]
442+
assert "definitions" in input_schema
443+
assert "job-attachment" in input_schema["definitions"]
444+
assert input_schema["definitions"]["job-attachment"]["type"] == "object"
445+
assert (
446+
input_schema["definitions"]["job-attachment"][
447+
"x-uipath-resource-kind"
448+
]
449+
== "JobAttachment"
450+
)
451+
assert all(
452+
prop_name
453+
in input_schema["definitions"]["job-attachment"]["properties"]
454+
for prop_name in ["ID", "FullName", "MimeType", "Metadata"]
455+
)
456+
if not verify_other_field:
457+
return
458+
459+
assert len(input_schema["properties"]) == 2
460+
assert all(
461+
prop_name in input_schema["properties"]
462+
for prop_name in ["input_file", "other_field"]
463+
)
464+
assert input_schema["required"] == ["input_file"]

tests/cli/test_input_args.py

Lines changed: 75 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -37,82 +37,78 @@ class SimpleDataClass:
3737
value: int = 42
3838

3939

40-
def test_pydantic_model_with_aliases():
41-
"""Test that Pydantic model schemas use field aliases when defined."""
42-
schema = get_type_schema(EventArguments)
43-
44-
assert schema["type"] == "object"
45-
assert "properties" in schema
46-
47-
# Check that aliases are used in property names
48-
expected_properties = {
49-
"UiPathEventConnector",
50-
"UiPathEvent",
51-
"UiPathEventObjectType",
52-
"UiPathEventObjectId",
53-
"UiPathAdditionalEventData",
54-
}
55-
actual_properties = set(schema["properties"].keys())
56-
assert actual_properties == expected_properties
57-
58-
# All fields have defaults, so none should be required
59-
assert schema["required"] == []
60-
61-
62-
def test_pydantic_model_required_fields():
63-
"""Test that required fields are correctly identified in Pydantic models."""
64-
schema = get_type_schema(RequiredFieldsModel)
65-
66-
assert schema["type"] == "object"
67-
assert "properties" in schema
68-
69-
# Check properties include both field names and aliases
70-
expected_properties = {
71-
"required_field", # field name (no alias)
72-
"optional_field", # field name (no alias)
73-
"AliasedRequired", # alias
74-
"AliasedOptional", # alias
75-
}
76-
actual_properties = set(schema["properties"].keys())
77-
assert actual_properties == expected_properties
78-
79-
# Check required fields (using aliases where defined)
80-
expected_required = {"required_field", "AliasedRequired"}
81-
actual_required = set(schema["required"])
82-
assert actual_required == expected_required
83-
84-
85-
def test_dataclass_still_works():
86-
"""Test that dataclass functionality is not broken."""
87-
schema = get_type_schema(SimpleDataClass)
88-
89-
assert schema["type"] == "object"
90-
assert "properties" in schema
91-
92-
# Dataclass should use field names (no alias support)
93-
expected_properties = {"name", "value"}
94-
actual_properties = set(schema["properties"].keys())
95-
assert actual_properties == expected_properties
96-
97-
# Field with default should not be required
98-
assert schema["required"] == ["name"]
99-
100-
101-
def test_primitive_types():
102-
"""Test that primitive type handling still works."""
103-
assert get_type_schema(str) == {"type": "string"}
104-
assert get_type_schema(int) == {"type": "integer"}
105-
assert get_type_schema(float) == {"type": "number"}
106-
assert get_type_schema(bool) == {"type": "boolean"}
107-
108-
109-
def test_optional_types():
110-
"""Test handling of Optional types."""
111-
schema = get_type_schema(Optional[str])
112-
assert schema == {"type": "string"} # Should unwrap Optional
113-
114-
115-
def test_optional_union_types():
116-
"""Test handling of Optional types."""
117-
schema = get_type_schema(str | None)
118-
assert schema == {"type": "string"} # Should unwrap Optional
40+
class TestInputArgs:
41+
def test_pydantic_model_with_aliases(self):
42+
"""Test that Pydantic model schemas use field aliases when defined."""
43+
schema = get_type_schema(EventArguments)
44+
45+
assert schema["type"] == "object"
46+
assert "properties" in schema
47+
48+
# Check that aliases are used in property names
49+
expected_properties = {
50+
"UiPathEventConnector",
51+
"UiPathEvent",
52+
"UiPathEventObjectType",
53+
"UiPathEventObjectId",
54+
"UiPathAdditionalEventData",
55+
}
56+
actual_properties = set(schema["properties"].keys())
57+
assert actual_properties == expected_properties
58+
59+
# All fields have defaults, so none should be required
60+
assert schema["required"] == []
61+
62+
def test_pydantic_model_required_fields(self):
63+
"""Test that required fields are correctly identified in Pydantic models."""
64+
schema = get_type_schema(RequiredFieldsModel)
65+
66+
assert schema["type"] == "object"
67+
assert "properties" in schema
68+
69+
# Check properties include both field names and aliases
70+
expected_properties = {
71+
"required_field", # field name (no alias)
72+
"optional_field", # field name (no alias)
73+
"AliasedRequired", # alias
74+
"AliasedOptional", # alias
75+
}
76+
actual_properties = set(schema["properties"].keys())
77+
assert actual_properties == expected_properties
78+
79+
# Check required fields (using aliases where defined)
80+
expected_required = {"required_field", "AliasedRequired"}
81+
actual_required = set(schema["required"])
82+
assert actual_required == expected_required
83+
84+
def test_dataclass_still_works(self):
85+
"""Test that dataclass functionality is not broken."""
86+
schema = get_type_schema(SimpleDataClass)
87+
88+
assert schema["type"] == "object"
89+
assert "properties" in schema
90+
91+
# Dataclass should use field names (no alias support)
92+
expected_properties = {"name", "value"}
93+
actual_properties = set(schema["properties"].keys())
94+
assert actual_properties == expected_properties
95+
96+
# Field with default should not be required
97+
assert schema["required"] == ["name"]
98+
99+
def test_primitive_types(self):
100+
"""Test that primitive type handling still works."""
101+
assert get_type_schema(str) == {"type": "string"}
102+
assert get_type_schema(int) == {"type": "integer"}
103+
assert get_type_schema(float) == {"type": "number"}
104+
assert get_type_schema(bool) == {"type": "boolean"}
105+
106+
def test_optional_types(self):
107+
"""Test handling of Optional types."""
108+
response = get_type_schema(Optional[str])
109+
assert response == {"type": "string"} # Should unwrap Optional
110+
111+
def test_optional_union_types(self):
112+
"""Test handling of Optional types."""
113+
response = get_type_schema(str | None)
114+
assert response == {"type": "string"} # Should unwrap Optional

0 commit comments

Comments
 (0)