Skip to content

Commit f380e04

Browse files
authored
Merge pull request #575 from UiPath/akshaya/run_function_backwards_compatible
refactor(RuntimeUpdates): decouple script runtime from a generic runtime
2 parents 8137795 + 99383a4 commit f380e04

6 files changed

Lines changed: 307 additions & 266 deletions

File tree

src/uipath/_cli/_runtime/_contracts.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,21 @@ async def __aenter__(self):
473473
with open(self.context.input_file) as f:
474474
self.context.input = f.read()
475475

476+
try:
477+
if self.context.input:
478+
self.context.input_json = json.loads(self.context.input)
479+
if self.context.input_json is None:
480+
self.context.input_json = {}
481+
except json.JSONDecodeError as e:
482+
raise UiPathRuntimeError(
483+
"INPUT_INVALID_JSON",
484+
"Invalid JSON input",
485+
f"The input data is not valid JSON: {str(e)}",
486+
UiPathErrorCategory.USER,
487+
) from e
488+
489+
await self.validate()
490+
476491
# Intercept all stdout/stderr/logs and write them to a file (runtime/evals), stdout (debug)
477492
self.logs_interceptor = LogsInterceptor(
478493
min_level=self.context.logs_min_level,
Lines changed: 26 additions & 259 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,37 @@
11
"""Python script runtime implementation for executing and managing python scripts."""
22

3-
import importlib.util
4-
import inspect
5-
import json
63
import logging
7-
import os
8-
from dataclasses import asdict, is_dataclass
9-
from typing import Any, Dict, Optional, Type, TypeVar, cast, get_type_hints
10-
11-
from pydantic import BaseModel
4+
from typing import Any, Awaitable, Callable, Optional, TypeVar
125

136
from ._contracts import (
147
UiPathBaseRuntime,
158
UiPathErrorCategory,
9+
UiPathRuntimeContext,
1610
UiPathRuntimeError,
1711
UiPathRuntimeResult,
1812
UiPathRuntimeStatus,
1913
)
14+
from ._script_executor import ScriptExecutor
2015

2116
logger = logging.getLogger(__name__)
2217

2318
T = TypeVar("T")
19+
R = TypeVar("R")
20+
AsyncFunc = Callable[[T], Awaitable[R]]
2421

2522

2623
class UiPathRuntime(UiPathBaseRuntime):
27-
"""Runtime for executing Python scripts."""
24+
def __init__(self, context: UiPathRuntimeContext, executor: AsyncFunc[Any, Any]):
25+
self.context = context
26+
self.executor = executor
27+
28+
async def cleanup(self) -> None:
29+
"""Cleanup runtime resources."""
30+
pass
31+
32+
async def validate(self):
33+
"""Validate runtime context."""
34+
pass
2835

2936
async def execute(self) -> Optional[UiPathRuntimeResult]:
3037
"""Execute the Python script with the provided input and configuration.
@@ -35,15 +42,8 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
3542
Raises:
3643
UiPathRuntimeError: If execution fails
3744
"""
38-
await self.validate()
39-
4045
try:
41-
if self.context.entrypoint is None:
42-
return None
43-
44-
script_result = await self._execute_python_script(
45-
self.context.entrypoint, self.context.input_json
46-
)
46+
script_result = await self.executor(self.context.input_json)
4747

4848
if self.context.job_id is None:
4949
logger.info(script_result)
@@ -65,248 +65,15 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
6565
UiPathErrorCategory.SYSTEM,
6666
) from e
6767

68-
async def validate(self) -> None:
69-
"""Validate runtime inputs."""
70-
if not self.context.entrypoint:
71-
raise UiPathRuntimeError(
72-
"ENTRYPOINT_MISSING",
73-
"No entrypoint specified",
74-
"Please provide a path to a Python script.",
75-
UiPathErrorCategory.USER,
76-
)
77-
78-
if not os.path.exists(self.context.entrypoint):
79-
raise UiPathRuntimeError(
80-
"ENTRYPOINT_NOT_FOUND",
81-
"Script not found",
82-
f"Script not found at path {self.context.entrypoint}.",
83-
UiPathErrorCategory.USER,
84-
)
85-
86-
try:
87-
if self.context.input:
88-
self.context.input_json = json.loads(self.context.input)
89-
if self.context.input_json is None:
90-
self.context.input_json = {}
91-
except json.JSONDecodeError as e:
92-
raise UiPathRuntimeError(
93-
"INPUT_INVALID_JSON",
94-
"Invalid JSON input",
95-
f"The input data is not valid JSON: {str(e)}",
96-
UiPathErrorCategory.USER,
97-
) from e
98-
99-
async def cleanup(self) -> None:
100-
"""Cleanup runtime resources."""
101-
pass
102-
103-
async def _execute_python_script(self, script_path: str, input_data: Any) -> Any:
104-
"""Execute the Python script with the given input."""
105-
spec = importlib.util.spec_from_file_location("dynamic_module", script_path)
106-
if not spec or not spec.loader:
107-
raise UiPathRuntimeError(
108-
"IMPORT_ERROR",
109-
"Module import failed",
110-
f"Could not load spec for {script_path}",
111-
UiPathErrorCategory.USER,
112-
)
113-
114-
module = importlib.util.module_from_spec(spec)
115-
try:
116-
spec.loader.exec_module(module)
117-
except Exception as e:
118-
raise UiPathRuntimeError(
119-
"MODULE_EXECUTION_ERROR",
120-
"Module execution failed",
121-
f"Error executing module: {str(e)}",
122-
UiPathErrorCategory.USER,
123-
) from e
124-
125-
for func_name in ["main", "run", "execute"]:
126-
if hasattr(module, func_name):
127-
main_func = getattr(module, func_name)
128-
sig = inspect.signature(main_func)
129-
params = list(sig.parameters.values())
130-
131-
# Check if the function is asynchronous
132-
is_async = inspect.iscoroutinefunction(main_func)
133-
134-
# Case 1: No parameters
135-
if not params:
136-
try:
137-
result = await main_func() if is_async else main_func()
138-
return (
139-
self._convert_from_class(result)
140-
if result is not None
141-
else {}
142-
)
143-
except Exception as e:
144-
raise UiPathRuntimeError(
145-
"FUNCTION_EXECUTION_ERROR",
146-
f"Error executing {func_name} function",
147-
f"Error: {str(e)}",
148-
UiPathErrorCategory.USER,
149-
) from e
150-
151-
input_param = params[0]
152-
input_type = input_param.annotation
153-
154-
# Case 2: Class, dataclass, or Pydantic model parameter
155-
if input_type != inspect.Parameter.empty and (
156-
is_dataclass(input_type)
157-
or self._is_pydantic_model(input_type)
158-
or hasattr(input_type, "__annotations__")
159-
):
160-
try:
161-
valid_type = cast(Type[Any], input_type)
162-
typed_input = self._convert_to_class(input_data, valid_type)
163-
result = (
164-
await main_func(typed_input)
165-
if is_async
166-
else main_func(typed_input)
167-
)
168-
return (
169-
self._convert_from_class(result)
170-
if result is not None
171-
else {}
172-
)
173-
except Exception as e:
174-
raise UiPathRuntimeError(
175-
"FUNCTION_EXECUTION_ERROR",
176-
f"Error executing {func_name} function with typed input",
177-
f"Error: {str(e)}",
178-
UiPathErrorCategory.USER,
179-
) from e
180-
181-
# Case 3: Dict parameter
182-
else:
183-
try:
184-
result = (
185-
await main_func(input_data)
186-
if is_async
187-
else main_func(input_data)
188-
)
189-
return (
190-
self._convert_from_class(result)
191-
if result is not None
192-
else {}
193-
)
194-
except Exception as e:
195-
raise UiPathRuntimeError(
196-
"FUNCTION_EXECUTION_ERROR",
197-
f"Error executing {func_name} function with dictionary input",
198-
f"Error: {str(e)}",
199-
UiPathErrorCategory.USER,
200-
) from e
201-
202-
raise UiPathRuntimeError(
203-
"ENTRYPOINT_FUNCTION_MISSING",
204-
"No entry function found",
205-
f"No main function (main, run, or execute) found in {script_path}",
206-
UiPathErrorCategory.USER,
207-
)
208-
209-
def _convert_to_class(self, data: Dict[str, Any], cls: Type[T]) -> T:
210-
"""Convert a dictionary to either a dataclass, Pydantic model, or regular class instance."""
211-
# Handle Pydantic models
212-
try:
213-
if inspect.isclass(cls) and issubclass(cls, BaseModel):
214-
return cls.model_validate(data)
215-
except TypeError:
216-
# issubclass can raise TypeError if cls is not a class
217-
pass
218-
219-
# Handle dataclasses
220-
if is_dataclass(cls):
221-
field_types = get_type_hints(cls)
222-
converted_data = {}
22368

224-
for field_name, field_type in field_types.items():
225-
if field_name not in data:
226-
continue
227-
228-
value = data[field_name]
229-
if (
230-
is_dataclass(field_type)
231-
or self._is_pydantic_model(field_type)
232-
or hasattr(field_type, "__annotations__")
233-
) and isinstance(value, dict):
234-
typed_field = cast(Type[Any], field_type)
235-
value = self._convert_to_class(value, typed_field)
236-
converted_data[field_name] = value
237-
238-
return cls(**converted_data)
239-
240-
# Handle regular classes
241-
else:
242-
sig = inspect.signature(cls.__init__)
243-
params = sig.parameters
244-
245-
init_args = {}
246-
247-
for param_name, param in params.items():
248-
if param_name == "self":
249-
continue
250-
251-
if param_name in data:
252-
value = data[param_name]
253-
param_type = (
254-
param.annotation
255-
if param.annotation != inspect.Parameter.empty
256-
else Any
257-
)
258-
259-
if (
260-
is_dataclass(param_type)
261-
or self._is_pydantic_model(param_type)
262-
or hasattr(param_type, "__annotations__")
263-
) and isinstance(value, dict):
264-
typed_param = cast(Type[Any], param_type)
265-
value = self._convert_to_class(value, typed_param)
266-
267-
init_args[param_name] = value
268-
elif param.default != inspect.Parameter.empty:
269-
init_args[param_name] = param.default
270-
271-
return cls(**init_args)
272-
273-
def _is_pydantic_model(self, cls: Type[Any]) -> bool:
274-
"""Safely check if a class is a Pydantic model."""
275-
try:
276-
return inspect.isclass(cls) and issubclass(cls, BaseModel)
277-
except TypeError:
278-
# issubclass can raise TypeError if cls is not a class
279-
return False
280-
281-
def _convert_from_class(self, obj: Any) -> Dict[str, Any]:
282-
"""Convert a class instance (dataclass, Pydantic model, or regular) to a dictionary."""
283-
if obj is None:
284-
return {}
285-
286-
# Handle Pydantic models
287-
if isinstance(obj, BaseModel):
288-
return obj.model_dump()
69+
class UiPathScriptRuntime(UiPathRuntime):
70+
"""Runtime for executing Python scripts."""
28971

290-
# Handle dataclasses
291-
elif is_dataclass(obj):
292-
# Make sure obj is an instance, not a class
293-
if isinstance(obj, type):
294-
return {}
295-
return asdict(obj)
72+
def __init__(self, context: UiPathRuntimeContext, entrypoint: str):
73+
executor = ScriptExecutor(entrypoint)
74+
super().__init__(context, executor)
29675

297-
# Handle regular classes
298-
elif hasattr(obj, "__dict__"):
299-
result = {}
300-
for key, value in obj.__dict__.items():
301-
# Skip private attributes
302-
if not key.startswith("_"):
303-
if (
304-
isinstance(value, BaseModel)
305-
or hasattr(value, "__dict__")
306-
or is_dataclass(value)
307-
):
308-
result[key] = self._convert_from_class(value)
309-
else:
310-
result[key] = value
311-
return result
312-
return {} if obj is None else {str(type(obj).__name__): str(obj)} # Fallback
76+
@classmethod
77+
def from_context(cls, context: UiPathRuntimeContext):
78+
"""Create runtime instance from context."""
79+
return UiPathScriptRuntime(context, context.entrypoint or "")

0 commit comments

Comments
 (0)