Skip to content

Commit fa71ac4

Browse files
committed
fix: resolve PEP 563 annotations and propagate errors in FunctionsRuntime
1 parent 95e55d3 commit fa71ac4

5 files changed

Lines changed: 113 additions & 32 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.2"
3+
version = "2.10.3"
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"

src/uipath/functions/runtime.py

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from uipath.runtime.errors import (
2020
UiPathErrorCategory,
2121
UiPathErrorCode,
22-
UiPathErrorContract,
2322
UiPathRuntimeError,
2423
)
2524
from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase
@@ -125,9 +124,16 @@ async def _execute_function(
125124
result = await func() if is_async else func()
126125
return convert_from_class(result) if result is not None else {}
127126

128-
# Get first parameter info
127+
# Get first parameter info.
128+
# Use get_type_hints() to resolve PEP 563 stringified annotations
129+
# (from `from __future__ import annotations`). inspect.signature()
130+
# returns raw strings in that case, which breaks type detection.
129131
input_param = params[0]
130-
input_type = input_param.annotation
132+
try:
133+
hints = get_type_hints(unwrapped)
134+
input_type = hints.get(input_param.name, inspect.Parameter.empty)
135+
except Exception:
136+
input_type = input_param.annotation
131137

132138
# Typed parameter (class, dataclass, or Pydantic)
133139
if input_type != inspect.Parameter.empty and (
@@ -161,17 +167,12 @@ async def execute(
161167
except UiPathRuntimeError:
162168
raise
163169
except Exception as e:
164-
logger.exception(f"Function execution failed: {e}")
165-
return UiPathRuntimeResult(
166-
output=None,
167-
status=UiPathRuntimeStatus.FAULTED,
168-
error=UiPathErrorContract(
169-
code=UiPathErrorCode.FUNCTION_EXECUTION_ERROR,
170-
category=UiPathErrorCategory.USER,
171-
title=f"Function execution failed: {self.function_name}",
172-
detail=str(e),
173-
),
174-
)
170+
raise UiPathRuntimeError(
171+
UiPathErrorCode.FUNCTION_EXECUTION_ERROR,
172+
f"Function execution failed: {self.function_name}",
173+
str(e),
174+
UiPathErrorCategory.USER,
175+
) from e
175176

176177
async def stream(
177178
self,
@@ -189,27 +190,28 @@ async def stream(
189190
payload=input or {},
190191
)
191192

192-
result = await self.execute(input, options)
193-
194-
if result.status == UiPathRuntimeStatus.FAULTED and result.error is not None:
193+
try:
194+
result = await self.execute(input, options)
195+
except UiPathRuntimeError as e:
195196
yield UiPathRuntimeStateEvent(
196197
node_name=self.function_name,
197198
phase=UiPathRuntimeStatePhase.FAULTED,
198-
payload={"error": result.error.model_dump()},
199+
payload={"error": str(e)},
199200
)
201+
raise
202+
203+
output = result.output
204+
if output is None:
205+
completed_payload: dict[str, Any] = {}
206+
elif isinstance(output, dict):
207+
completed_payload = output
200208
else:
201-
output = result.output
202-
if output is None:
203-
completed_payload: dict[str, Any] = {}
204-
elif isinstance(output, dict):
205-
completed_payload = output
206-
else:
207-
completed_payload = {"output": str(output)}
208-
yield UiPathRuntimeStateEvent(
209-
node_name=self.function_name,
210-
phase=UiPathRuntimeStatePhase.COMPLETED,
211-
payload=completed_payload,
212-
)
209+
completed_payload = {"output": str(output)}
210+
yield UiPathRuntimeStateEvent(
211+
node_name=self.function_name,
212+
phase=UiPathRuntimeStatePhase.COMPLETED,
213+
payload=completed_payload,
214+
)
213215

214216
yield result
215217

tests/functions/test_unwrap_decorated.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import pytest
66

77
from uipath.functions.runtime import UiPathFunctionsRuntime
8+
from uipath.runtime.errors import UiPathRuntimeError
9+
from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase
810

911

1012
@pytest.fixture
@@ -96,3 +98,79 @@ async def test_schema_type_reflects_entrypoint_type(decorated_module):
9698
)
9799
schema_agent = await runtime_agent.get_schema()
98100
assert schema_agent.type == "agent"
101+
102+
103+
@pytest.fixture
104+
def future_annotations_module(tmp_path):
105+
"""Create a module using `from __future__ import annotations` (PEP 563)."""
106+
(tmp_path / "future_ann.py").write_text(
107+
textwrap.dedent("""\
108+
from __future__ import annotations
109+
110+
from pydantic import BaseModel
111+
112+
113+
class OrderInput(BaseModel):
114+
customer_name: str
115+
quantity: int = 1
116+
117+
118+
class OrderOutput(BaseModel):
119+
accepted: bool
120+
total: float
121+
122+
123+
def main(order: OrderInput) -> OrderOutput:
124+
return OrderOutput(accepted=True, total=order.quantity * 9.99)
125+
""")
126+
)
127+
return tmp_path / "future_ann.py"
128+
129+
130+
@pytest.mark.asyncio
131+
async def test_execute_resolves_future_annotations(future_annotations_module):
132+
"""PEP 563 stringified annotations should be resolved via get_type_hints."""
133+
runtime = UiPathFunctionsRuntime(
134+
str(future_annotations_module), "main", "future_ann"
135+
)
136+
result = await runtime.execute({"customer_name": "Alice", "quantity": 2})
137+
138+
assert result.output == {"accepted": True, "total": 19.98}
139+
140+
141+
@pytest.fixture
142+
def failing_module(tmp_path):
143+
"""Create a module whose function always raises."""
144+
(tmp_path / "failing.py").write_text(
145+
textwrap.dedent("""\
146+
def main(data: dict) -> dict:
147+
raise ValueError("something went wrong")
148+
""")
149+
)
150+
return tmp_path / "failing.py"
151+
152+
153+
@pytest.mark.asyncio
154+
async def test_execute_raises_on_function_error(failing_module):
155+
"""Function errors should propagate as UiPathRuntimeError, not return FAULTED."""
156+
runtime = UiPathFunctionsRuntime(str(failing_module), "main", "failing")
157+
158+
with pytest.raises(UiPathRuntimeError, match="something went wrong"):
159+
await runtime.execute({"key": "value"})
160+
161+
162+
@pytest.mark.asyncio
163+
async def test_stream_yields_faulted_then_raises(failing_module):
164+
"""stream() should yield STARTED, then FAULTED, then raise UiPathRuntimeError."""
165+
runtime = UiPathFunctionsRuntime(str(failing_module), "main", "failing")
166+
167+
events = []
168+
with pytest.raises(UiPathRuntimeError, match="something went wrong"):
169+
async for event in runtime.stream({"key": "value"}):
170+
events.append(event)
171+
172+
assert len(events) == 2
173+
assert isinstance(events[0], UiPathRuntimeStateEvent)
174+
assert events[0].phase == UiPathRuntimeStatePhase.STARTED
175+
assert isinstance(events[1], UiPathRuntimeStateEvent)
176+
assert events[1].phase == UiPathRuntimeStatePhase.FAULTED

tests/resource_overrides/test_resource_overrides.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ def _mock_calls(
207207
"State": "Running",
208208
"StartTime": "2024-01-01T00:00:00Z",
209209
"Id": 123,
210+
"FolderKey": "test-folder-key",
210211
}
211212
]
212213
},

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)