11"""Orchestrator service that manages executions."""
22
33import uuid
4- from typing import Any
4+ import inspect
5+ import os
6+ import sys
7+ from typing import Any , Callable
8+ from pathlib import Path
59import pydantic_monty
610from sqlalchemy .orm import Session
711from sqlalchemy import Engine
812
913from durable_monty .models import Execution , Call , ExecutionStatus , CallStatus , to_json , from_json
1014
1115
16+ def _resolve_function_path (func : Callable ) -> str :
17+ """
18+ Resolve the full import path for a function, handling __main__ modules.
19+
20+ When a script is run directly (python script.py), functions defined in it
21+ have __module__ = "__main__". This converts it to the actual module path
22+ that can be imported by workers.
23+
24+ Args:
25+ func: Function object
26+
27+ Returns:
28+ Full import path like "examples.with_rq.add"
29+
30+ Raises:
31+ ValueError: If module path cannot be resolved
32+ """
33+ module_name = func .__module__
34+ func_name = func .__name__
35+
36+ # If not __main__, use as-is
37+ if module_name != "__main__" :
38+ return f"{ module_name } .{ func_name } "
39+
40+ # Try to resolve __main__ to actual module path
41+ try :
42+ # Get the file where the function is defined
43+ source_file = inspect .getfile (func )
44+ source_path = Path (source_file ).resolve ()
45+
46+ # Find which sys.path entry contains this file
47+ for path_entry in sys .path :
48+ try :
49+ path_entry = Path (path_entry ).resolve ()
50+ if source_path .is_relative_to (path_entry ):
51+ # Convert file path to module path
52+ rel_path = source_path .relative_to (path_entry )
53+
54+ # Remove .py extension and convert to module notation
55+ module_parts = list (rel_path .parts [:- 1 ]) + [rel_path .stem ]
56+ resolved_module = "." .join (module_parts )
57+
58+ return f"{ resolved_module } .{ func_name } "
59+ except (ValueError , OSError ):
60+ continue
61+
62+ # Could not resolve - provide helpful error
63+ raise ValueError (
64+ f"Cannot resolve module path for function '{ func_name } ' defined in __main__.\n "
65+ f"The function is in '{ source_file } ' which is not in any Python path.\n "
66+ f"Either:\n "
67+ f" 1. Import the function from its module instead of running the script directly\n "
68+ f" 2. Ensure the script's directory is in PYTHONPATH\n "
69+ f" 3. Pass the full import path as a string instead of the function object"
70+ )
71+
72+ except (TypeError , OSError ) as e :
73+ raise ValueError (
74+ f"Cannot determine source file for function '{ func_name } ': { e } \n "
75+ f"Pass the full import path as a string instead of the function object."
76+ )
77+
78+
1279class OrchestratorService :
1380 """Service for managing durable executions."""
1481
@@ -18,18 +85,46 @@ def __init__(self, engine: Engine):
1885 def start_execution (
1986 self ,
2087 code : str ,
21- external_functions : list [str ],
88+ external_functions : list [str | Callable ], # Accept strings or callable objects
2289 inputs : dict | None = None ,
2390 ) -> str :
24- """Schedule a new workflow execution. Returns execution_id."""
91+ """
92+ Schedule a new workflow execution. Returns execution_id.
93+
94+ Args:
95+ code: Python code to execute
96+ external_functions: List of function names (as full paths or callable objects)
97+ inputs: Optional input variables for the code
98+
99+ Example:
100+ # Pass actual function objects (recommended)
101+ exec_id = service.start_execution(code, [add, multiply])
102+
103+ # Or pass full import paths as strings
104+ exec_id = service.start_execution(code, ["myapp.tasks.add"])
105+ """
25106 execution_id = str (uuid .uuid4 ())
26107
108+ # Convert callable objects to {short_name: full_path} mapping
109+ function_mapping = {}
110+ for func in external_functions :
111+ if callable (func ):
112+ # Extract full path and short name from function object
113+ # This handles __main__ module resolution
114+ full_path = _resolve_function_path (func )
115+ short_name = func .__name__
116+ function_mapping [short_name ] = full_path
117+ else :
118+ # String path - extract short name
119+ short_name = func .rsplit ("." , 1 )[- 1 ] if "." in func else func
120+ function_mapping [short_name ] = func
121+
27122 with Session (self .engine ) as session :
28- # Just save to DB - worker will pick it up
123+ # Save mapping to DB - worker will use it
29124 execution = Execution (
30125 id = execution_id ,
31126 code = code ,
32- external_functions = to_json (external_functions ),
127+ external_functions = to_json (function_mapping ),
33128 status = ExecutionStatus .SCHEDULED ,
34129 inputs = to_json (inputs ),
35130 )
@@ -53,14 +148,16 @@ def process_execution(
53148 if not execution :
54149 return
55150
151+ # Load function mapping {short_name: full_path} for converting names
152+ function_mapping = from_json (execution .external_functions )
153+
56154 # Get Monty progress based on execution status
57155 if execution .status == ExecutionStatus .SCHEDULED :
58156 # First time - start fresh
59- external_functions = from_json (execution .external_functions )
60157 inputs = from_json (execution .inputs )
61158 m = pydantic_monty .Monty (
62159 execution .code ,
63- external_functions = external_functions ,
160+ external_functions = list ( function_mapping . keys ()), # Pass short names to Monty
64161 inputs = list (inputs .keys ()) if inputs else None ,
65162 )
66163 progress = m .start (inputs = inputs ) if inputs else m .start ()
@@ -117,12 +214,16 @@ def process_execution(
117214 # Save all calls in this group
118215 for call_id in progress .pending_call_ids :
119216 call_info = pending_calls [call_id ]
217+ # Convert short name to full path for execution
218+ short_name = call_info ["function" ]
219+ full_path = function_mapping .get (short_name , short_name )
120220 call = Call (
121221 execution_id = execution_id ,
122222 resume_group_id = new_resume_group_id ,
123223 call_id = call_id ,
124- function_name = call_info [ "function" ],
224+ function_name = full_path , # Store full path for RQ workers
125225 args = to_json (call_info ["args" ]),
226+ kwargs = to_json (call_info ["kwargs" ]),
126227 status = CallStatus .PENDING ,
127228 )
128229 session .add (call )
@@ -183,6 +284,7 @@ def poll(self, execution_id: str | None = None) -> dict[str, Any] | list[dict[st
183284 "call_id" : c .call_id ,
184285 "function_name" : c .function_name ,
185286 "args" : from_json (c .args ),
287+ "kwargs" : from_json (c .kwargs ),
186288 "status" : c .status ,
187289 }
188290 for c in calls
@@ -245,6 +347,7 @@ def get_pending_calls(self, execution_id: str) -> list[dict]:
245347 "call_id" : c .call_id ,
246348 "function_name" : c .function_name ,
247349 "args" : from_json (c .args ),
350+ "kwargs" : from_json (c .kwargs ),
248351 }
249352 for c in calls
250353 ]
0 commit comments