-
Notifications
You must be signed in to change notification settings - Fork 19
feat: refactor runtime system for extensible multi-entrypoint support #750
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -76,31 +76,26 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: | |
| console = ConsoleLogger() | ||
|
|
||
|
|
||
| def get_user_script(directory: str, entrypoint: Optional[str] = None) -> Optional[str]: | ||
| """Find the Python script to process.""" | ||
| def get_user_scripts(directory: str, entrypoint: Optional[str] = None) -> List[str]: | ||
| """Find all Python scripts to process.""" | ||
| if entrypoint: | ||
| script_path = os.path.join(directory, entrypoint) | ||
| if not os.path.isfile(script_path): | ||
| console.error( | ||
| f"The {entrypoint} file does not exist in the current directory." | ||
| ) | ||
| return None | ||
| return script_path | ||
| return [] | ||
| return [script_path] | ||
|
|
||
| python_files = [f for f in os.listdir(directory) if f.endswith(".py")] | ||
|
|
||
| if not python_files: | ||
| console.error( | ||
| "No python files found in the current directory.\nPlease specify the entrypoint: `uipath init <entrypoint_path>`" | ||
| ) | ||
| return None | ||
| elif len(python_files) == 1: | ||
| return os.path.join(directory, python_files[0]) | ||
| else: | ||
| console.error( | ||
| "Multiple python files found in the current directory.\nPlease specify the entrypoint: `uipath init <entrypoint_path>`" | ||
| ) | ||
| return None | ||
| return [] | ||
|
|
||
| return [os.path.join(directory, f) for f in python_files] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Beautiful! :) However, I do think we need to filter this. The ScriptExecutor does this: so let's ensure that the files have these defined, yea? |
||
|
|
||
|
|
||
| class UiPathScriptRuntime(UiPathRuntime): | ||
|
|
@@ -122,18 +117,26 @@ def get_binding_resources(self) -> List[BindingResource]: | |
|
|
||
| Returns: A list of binding resources. | ||
| """ | ||
| working_dir = self.context.runtime_dir or os.getcwd() | ||
| script_path = get_user_script(working_dir, entrypoint=self.context.entrypoint) | ||
| working_dir = os.getcwd() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would recommend keeping this: Same below. |
||
| script_paths = get_user_scripts(working_dir, self.context.entrypoint) | ||
| if not script_paths: | ||
| raise ValueError( | ||
| "No Python scripts found. Please specify an entrypoint or ensure Python files exist in the directory." | ||
| ) | ||
| script_path = script_paths[0] | ||
| bindings = generate_bindings(script_path) | ||
| return bindings.resources | ||
|
|
||
| @cached_property | ||
| @override | ||
| def get_entrypoint(self) -> Entrypoint: | ||
| working_dir = self.context.runtime_dir or os.getcwd() | ||
| script_path = get_user_script(working_dir, entrypoint=self.context.entrypoint) | ||
| if not script_path: | ||
| raise ValueError("Entrypoint not found.") | ||
| working_dir = os.getcwd() | ||
| script_paths = get_user_scripts(working_dir, self.context.entrypoint) | ||
| if not script_paths: | ||
| raise ValueError( | ||
| "No Python scripts found. Please specify an entrypoint or ensure Python files exist in the directory." | ||
| ) | ||
| script_path = script_paths[0] | ||
| relative_path = Path(script_path).relative_to(working_dir).as_posix() | ||
| args = generate_args(script_path) | ||
| return Entrypoint( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,53 @@ | ||
| import os | ||
| from typing import List, Optional | ||
|
|
||
| from uipath._cli._runtime._contracts import ( | ||
| UiPathBaseRuntime, | ||
| UiPathRuntimeContext, | ||
| UiPathRuntimeFactory, | ||
| ) | ||
| from uipath._cli._runtime._runtime import UiPathScriptRuntime | ||
| from uipath._cli._runtime._runtime import UiPathScriptRuntime, get_user_scripts | ||
|
|
||
|
|
||
| class UiPathScriptRuntimeFactory( | ||
| UiPathRuntimeFactory[UiPathScriptRuntime, UiPathRuntimeContext] | ||
| ): | ||
| """Factory for Python script runtimes.""" | ||
|
|
||
| def generate_runtime_factory() -> UiPathRuntimeFactory[ | ||
| UiPathBaseRuntime, UiPathRuntimeContext | ||
| ]: | ||
| runtime_factory: UiPathRuntimeFactory[UiPathBaseRuntime, UiPathRuntimeContext] = ( | ||
| UiPathRuntimeFactory( | ||
| def __init__(self): | ||
| super().__init__( | ||
| UiPathScriptRuntime, | ||
| UiPathRuntimeContext, | ||
| context_generator=lambda **kwargs: UiPathRuntimeContext.with_defaults( | ||
| **kwargs | ||
| ), | ||
| ) | ||
| ) | ||
| return runtime_factory | ||
|
|
||
| def discover_all_runtimes(self) -> List[UiPathScriptRuntime]: | ||
| """Get a list of all available Python script runtimes.""" | ||
| scripts = get_user_scripts(os.getcwd()) | ||
|
|
||
| runtimes = [] | ||
| for script_path in scripts: | ||
| runtime = self._create_runtime(script_path) | ||
| if runtime: | ||
| runtimes.append(runtime) | ||
|
|
||
| return runtimes | ||
|
|
||
| def get_runtime(self, entrypoint: str) -> Optional[UiPathScriptRuntime]: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned above this method is redundant -- can be safely moved to |
||
| """Get runtime for a specific Python script.""" | ||
| if not os.path.isabs(entrypoint): | ||
| script_path = os.path.abspath(entrypoint) | ||
| else: | ||
| script_path = entrypoint | ||
|
|
||
| if not os.path.isfile(script_path) or not script_path.endswith(".py"): | ||
| return None | ||
|
|
||
| return self._create_runtime(script_path) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned above, this should also have one of the three special functions defined. |
||
|
|
||
| def _create_runtime(self, script_path: str) -> Optional[UiPathScriptRuntime]: | ||
| """Create runtime instance for a script path.""" | ||
| context = self.new_context(entrypoint=script_path) | ||
| runtime = self.from_context(context) | ||
| return runtime | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,6 @@ | |
| UiPathEvalContext, | ||
| UiPathEvalRuntime, | ||
| ) | ||
| from uipath._cli._runtime._runtime_factory import generate_runtime_factory | ||
| from uipath._cli._utils._constants import UIPATH_PROJECT_ID | ||
| from uipath._cli._utils._folders import get_personal_workspace_key_async | ||
| from uipath._cli.middlewares import Middlewares | ||
|
|
@@ -138,7 +137,9 @@ def eval( | |
| asyncio.run(console_reporter.subscribe_to_eval_runtime_events(event_bus)) | ||
|
|
||
| try: | ||
| runtime_factory = generate_runtime_factory() | ||
| # Last one is the Python script runtime factory | ||
| runtime_factory = Middlewares._runtime_factories[-1] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This does not have the fallback logic. Ideally we want to have here and in
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes the LangGraph eval and run logic are still handled in their own middleware - we will refactor those in a separate PR.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I mean is, if someone imports langgraph library but doesn't define a graph, |
||
|
|
||
| if eval_context.job_id: | ||
| runtime_factory.add_span_exporter(LlmOpsHttpExporter()) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |
| from dataclasses import dataclass | ||
| from typing import Any, Callable, Dict, List, Optional | ||
|
|
||
| from ._runtime._contracts import UiPathBaseRuntime, UiPathRuntimeFactory | ||
| from ._runtime._runtime_factory import UiPathScriptRuntimeFactory | ||
| from ._utils._console import ConsoleLogger | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
@@ -33,6 +35,7 @@ class Middlewares: | |
| "eval": [], | ||
| "debug": [], | ||
| } | ||
| _runtime_factories: List[UiPathRuntimeFactory[Any, Any]] = [] | ||
| _plugins_loaded = False | ||
|
|
||
| @classmethod | ||
|
|
@@ -45,6 +48,50 @@ def register(cls, command: str, middleware: MiddlewareFunc) -> None: | |
| f"Registered middleware for command '{command}': {middleware.__name__}" | ||
| ) | ||
|
|
||
| @classmethod | ||
| def register_runtime_factory(cls, factory: UiPathRuntimeFactory[Any, Any]) -> None: | ||
| """Register a runtime factory in the chain.""" | ||
| cls._runtime_factories.append(factory) | ||
| logger.debug(f"Registered runtime factory: {factory.__class__.__name__}") | ||
|
|
||
| @classmethod | ||
| def discover_all_runtimes(cls) -> List[UiPathBaseRuntime]: | ||
| """Discover all runtimes in the current directory using registered runtime factories.""" | ||
| if not cls._plugins_loaded: | ||
| cls.load_plugins() | ||
|
|
||
| for factory in cls._runtime_factories: | ||
| try: | ||
| runtimes = factory.discover_all_runtimes() | ||
| if runtimes: | ||
| return runtimes | ||
| except Exception as e: | ||
| logger.error( | ||
| f"Runtime factory {factory.__class__.__name__} discovery failed: {e}" | ||
| ) | ||
| raise | ||
|
|
||
| return [] | ||
|
|
||
| @classmethod | ||
| def get_runtime(cls, entrypoint: str) -> Optional[UiPathBaseRuntime]: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's change this to Maybe in order to support this, we need to update This would mean discovering everytime the agent runs but looks like we don't have a choice right?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, nvm - usually there's only one valid runtime factory, so let's change impl to |
||
| """Get runtime for a specific entrypoint.""" | ||
| if not cls._plugins_loaded: | ||
| cls.load_plugins() | ||
|
|
||
| for factory in cls._runtime_factories: | ||
| try: | ||
| runtime = factory.get_runtime(entrypoint) | ||
| if runtime: | ||
| return runtime | ||
| except Exception as e: | ||
| logger.error( | ||
| f"Runtime factory {factory.__class__.__name__} failed for {entrypoint}: {e}" | ||
| ) | ||
| raise | ||
|
|
||
| return None | ||
|
|
||
| @classmethod | ||
| def get(cls, command: str) -> List[MiddlewareFunc]: | ||
| """Get all middlewares for a specific command.""" | ||
|
|
@@ -104,7 +151,7 @@ def clear(cls, command: Optional[str] = None) -> None: | |
|
|
||
| @classmethod | ||
| def load_plugins(cls) -> None: | ||
| """Load all middlewares registered via entry points.""" | ||
| """Load all plugins and register runtime factories.""" | ||
| if cls._plugins_loaded: | ||
| return | ||
|
|
||
|
|
@@ -137,6 +184,8 @@ def load_plugins(cls) -> None: | |
| else: | ||
| logger.debug("No middleware plugins found") | ||
|
|
||
| # Register the default runtime factory after all the plugin ones | ||
| cls.register_runtime_factory(UiPathScriptRuntimeFactory()) | ||
| except Exception as e: | ||
| logger.error(f"No middleware plugins loaded: {str(e)}") | ||
| finally: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This need not exist.
<*>Runtime.from_contextis sufficient here as context contains entrypoint.