@@ -59,12 +59,20 @@ def __init__(
5959 is_start_step = False ,
6060 is_end_step = False ,
6161 node_info = None ,
62+ name = None ,
63+ num_args = None ,
6264 ):
63- self .name = func_ast .name
65+ if func_ast is None and name is None :
66+ raise ValueError ("name is required when func_ast is None" )
67+
68+ self .name = func_ast .name if func_ast is not None else name
6469 self .source_file = source_file
6570 # lineno is the start line of decorators in source_file
6671 # func_ast.lineno is lines from decorators start to def of function
67- self .func_lineno = lineno + func_ast .lineno - 1
72+ if func_ast is not None :
73+ self .func_lineno = lineno + func_ast .lineno - 1
74+ else :
75+ self .func_lineno = lineno
6876 self .decorators = decos
6977 self .wrappers = wrappers
7078 self .config_decorators = config_decorators
@@ -83,13 +91,14 @@ def __init__(
8391 self .out_funcs = []
8492 self .has_tail_next = False
8593 self .invalid_tail_next = False
86- self .num_args = 0
94+ self .num_args = 0 if num_args is None else num_args
8795 self .switch_cases = {}
8896 self .condition = None
8997 self .foreach_param = None
9098 self .num_parallel = 0
9199 self .parallel_foreach = False
92- self ._parse (func_ast , lineno )
100+ if func_ast is not None :
101+ self ._parse (func_ast , lineno )
93102
94103 # these attributes are populated by _traverse_graph
95104 self .in_funcs = set ()
@@ -282,48 +291,34 @@ def _identify_start_end(self):
282291 """
283292 Determine the start and end steps.
284293
285- Uses explicit ``@step(start=True)`` / ``@step(end=True)`` annotations
286- if present. Falls back to looking for steps named ``"start"`` /
287- ``"end"`` for backward compatibility.
288-
289- Sets ``self.start_step`` and ``self.end_step`` to step name strings,
290- or ``None`` if the graph is malformed (validated later by lint).
291- Also assigns the ``"start"`` and ``"end"`` node types.
294+ Uses explicit @step(start=True) / @step(end=True) annotations if present.
295+ Falls back to steps named "start" / "end" for backward compatibility.
296+ Sets self.start_step and self.end_step to step name strings, or None
297+ if the graph is malformed (validated later by lint).
292298 """
293- # 1. Look for explicit annotations
294- annotated_start = [
295- name
296- for name , node in self .nodes .items ()
297- if node .is_start_step and not name .startswith ("_" )
298- ]
299- annotated_end = [
300- name
301- for name , node in self .nodes .items ()
302- if node .is_end_step and not name .startswith ("_" )
303- ]
304-
305- # 2. Determine start step (annotation first, then name fallback)
306- if len (annotated_start ) == 1 :
307- self .start_step = annotated_start [0 ]
308- elif len (annotated_start ) == 0 :
309- self .start_step = "start" if "start" in self .nodes else None
310- else :
311- self .start_step = None # Multiple annotated — lint will catch
312299
313- # 3. Determine end step (annotation first, then name fallback)
314- if len (annotated_end ) == 1 :
315- self .end_step = annotated_end [0 ]
316- elif len (annotated_end ) == 0 :
317- self .end_step = "end" if "end" in self .nodes else None
318- else :
319- self .end_step = None # Multiple annotated — lint will catch
300+ def _resolve (attr , fallback_name ):
301+ """Find the unique annotated step, or fall back to a named step."""
302+ annotated = [
303+ name
304+ for name , node in self .nodes .items ()
305+ if getattr (node , attr ) and not name .startswith ("_" )
306+ ]
307+ if len (annotated ) == 1 :
308+ return annotated [0 ]
309+ if len (annotated ) == 0 :
310+ return fallback_name if fallback_name in self .nodes else None
311+ return None # Multiple annotated — lint will catch
312+
313+ self .start_step = _resolve ("is_start_step" , "start" )
314+ self .end_step = _resolve ("is_end_step" , "end" )
320315
321- # 4. Assign types based on identified start/end .
322- # Only upgrade "linear" → "start" for the entry point; do NOT override
316+ # Assign node types for graph traversal .
317+ # Only upgrade "linear" -> "start" for the entry point; do NOT override
323318 # "split", "foreach", etc. since those types are needed for
324319 # split/join balance checking.
325320 if self .start_step and self .start_step == self .end_step :
326- # Single-step flow: terminal node that is also the entry point
321+ # Single-step flow: terminal node that is also the entry point.
327322 self .nodes [self .start_step ].type = "end"
328323 else :
329324 if self .start_step :
@@ -333,29 +328,76 @@ def _identify_start_end(self):
333328 if self .end_step :
334329 self .nodes [self .end_step ].type = "end"
335330
331+ def _create_sourceless_single_step_node (
332+ self , name , func , is_start_step , is_end_step
333+ ):
334+ """Create a DAGNode for a dynamically-generated single-step method.
335+
336+ When ``inspect.getsourcelines()`` fails (e.g. for steps synthesized
337+ via ``compile()`` + ``exec()`` by extension metaclasses like
338+ ``FunctionSpecMeta``), this method builds a DAGNode without AST
339+ parsing. This is safe because single-step flows (``start=True,
340+ end=True``) have no ``self.next()`` transitions to analyze.
341+ """
342+ code = getattr (func , "__code__" , None )
343+ source_file = inspect .getsourcefile (func ) or inspect .getfile (func )
344+ lineno = getattr (code , "co_firstlineno" , 0 )
345+
346+ try :
347+ num_args = len (inspect .signature (func ).parameters )
348+ except (TypeError , ValueError ):
349+ num_args = getattr (code , "co_argcount" , 0 )
350+
351+ return DAGNode (
352+ None ,
353+ func .decorators ,
354+ func .wrappers ,
355+ func .config_decorators ,
356+ func .__doc__ ,
357+ source_file ,
358+ lineno ,
359+ is_start_step = is_start_step ,
360+ is_end_step = is_end_step ,
361+ node_info = getattr (func , "node_info" , None ),
362+ name = name ,
363+ num_args = num_args ,
364+ )
365+
336366 def _create_nodes (self , flow ):
337367 nodes = {}
338368 for element in dir (flow ):
339369 func = getattr (flow , element )
340370 if callable (func ) and hasattr (func , "is_step" ):
341- source_file = inspect .getsourcefile (func )
342- source_lines , lineno = inspect .getsourcelines (func )
343- # This also works for code (strips out leading whitspace based on
344- # first line)
345- source_code = deindent_docstring ("" .join (source_lines ))
346- function_ast = ast .parse (source_code ).body [0 ]
347- node = DAGNode (
348- function_ast ,
349- func .decorators ,
350- func .wrappers ,
351- func .config_decorators ,
352- func .__doc__ ,
353- source_file ,
354- lineno ,
355- is_start_step = getattr (func , "is_start_step" , False ),
356- is_end_step = getattr (func , "is_end_step" , False ),
357- node_info = getattr (func , "node_info" , None ),
358- )
371+ is_start = getattr (func , "is_start_step" , False )
372+ is_end = getattr (func , "is_end_step" , False )
373+
374+ try :
375+ source_file = inspect .getsourcefile (func ) or inspect .getfile (func )
376+ source_lines , lineno = inspect .getsourcelines (func )
377+ except OSError :
378+ if is_start and is_end :
379+ node = self ._create_sourceless_single_step_node (
380+ element , func , is_start , is_end
381+ )
382+ else :
383+ raise
384+ else :
385+ # This also works for code (strips out leading whitespace based on
386+ # first line)
387+ source_code = deindent_docstring ("" .join (source_lines ))
388+ function_ast = ast .parse (source_code ).body [0 ]
389+ node = DAGNode (
390+ function_ast ,
391+ func .decorators ,
392+ func .wrappers ,
393+ func .config_decorators ,
394+ func .__doc__ ,
395+ source_file ,
396+ lineno ,
397+ is_start_step = is_start ,
398+ is_end_step = is_end ,
399+ node_info = getattr (func , "node_info" , None ),
400+ )
359401 nodes [element ] = node
360402 return nodes
361403
@@ -568,6 +610,18 @@ def populate_block(start_name, end_name):
568610 break
569611 return resulting_list
570612
613+ if self .start_step is None or self .end_step is None :
614+ missing = []
615+ if self .start_step is None :
616+ missing .append ("start" )
617+ if self .end_step is None :
618+ missing .append ("end" )
619+ raise ValueError (
620+ "Cannot compute graph structure: no %s step identified. "
621+ "Use @step(start=True)/@step(end=True) or name your steps "
622+ "'start'/'end'." % " or " .join (missing )
623+ )
624+
571625 if self .start_step == self .end_step :
572626 # Single-step flow
573627 graph_structure = []
0 commit comments