Coverage for smartmdao / executor.py: 100%
79 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-05 17:29 +0200
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-05 17:29 +0200
1import inspect
2import logging
3from dataclasses import is_dataclass, asdict
4from typing import Dict, Any, Optional
5from .models import Step
6from .validation import TypeChecker, TypeMismatchError
8# Initialize module-level logger
9logger = logging.getLogger(__name__)
11class StepExecutor:
12 """
13 Static helper responsible for binding arguments from memory
14 and updating memory with results.
15 """
16 @staticmethod
17 def run_step(step: Step, memory: Dict[str, Any], type_checker: Optional[TypeChecker] = None):
18 logger.debug(f"Preparing to execute step '{step.name}'")
20 # Use the robust unwrapped signature to find expected parameters
21 sig = step.get_signature()
23 # 1. Bind Arguments
24 params = {}
25 missing_required = []
27 for name, param in sig.parameters.items():
28 if name in memory:
29 params[name] = memory[name]
30 elif param.default == inspect.Parameter.empty:
31 missing_required.append(name)
33 if missing_required:
34 error_msg = (f"Step '{step.name}' cannot run. Missing inputs: {missing_required}. "
35 f"Available in memory: {list(memory.keys())}")
36 logger.error(error_msg)
37 raise KeyError(error_msg)
39 # 1b. Optional Runtime Input Type Check
40 if type_checker is not None:
41 StepExecutor._check_input_types(step, params, type_checker)
43 # 2. Execute
44 try:
45 logger.debug(f"Invoking '{step.name}' with inputs: {list(params.keys())}")
46 result = step.fn(**params)
47 except Exception as e:
48 logger.error(f"Error executing step '{step.name}': {e}", exc_info=True)
49 raise RuntimeError(f"Error executing step '{step.name}': {e}") from e
51 # 3. Store Result
52 StepExecutor._update_memory(step, result, memory)
54 # 3b. Optional Runtime Output Type Check
55 if type_checker is not None and result is not None:
56 StepExecutor._check_output_types(step, memory, type_checker)
58 logger.debug(f"Finished step '{step.name}'.")
60 @staticmethod
61 def _check_input_types(step: Step, params: Dict[str, Any], type_checker: TypeChecker):
62 expected_types = step.resolve_input_types()
63 for name, value in params.items():
64 expected = expected_types.get(name)
65 if expected is None:
66 continue
67 if not type_checker.check_value(value, expected):
68 raise TypeMismatchError(
69 f"Step '{step.name}' received {name}={value!r} ({type(value).__name__}), "
70 f"expected {name}: {getattr(expected, '__name__', expected)}."
71 )
73 @staticmethod
74 def _check_output_types(step: Step, memory: Dict[str, Any], type_checker: TypeChecker):
75 # `resolve_output_types()` keys are always a subset of `resolve_output_names()`,
76 # and `_update_memory` guarantees every one of those names lands in `memory`
77 # whenever it returns without raising - so a plain lookup is safe here.
78 expected_types = step.resolve_output_types()
79 for name, expected in expected_types.items():
80 value = memory[name]
81 if not type_checker.check_value(value, expected):
82 raise TypeMismatchError(
83 f"Step '{step.name}' produced {name}={value!r} ({type(value).__name__}), "
84 f"expected {name}: {getattr(expected, '__name__', expected)}."
85 )
87 @staticmethod
88 def _update_memory(step: Step, result: Any, memory: Dict[str, Any]):
89 output_keys = step.resolve_output_names()
91 if result is None:
92 logger.debug(f"Step '{step.name}' returned None. No outputs stored.")
93 return
95 # Case A: Explicit Manual Outputs (e.g. outputs=['a', 'b'])
96 if step.manual_outputs:
97 if len(output_keys) == 1:
98 memory[output_keys[0]] = result
99 return
101 # Handle Dictionary Return with Manual Outputs
102 if isinstance(result, dict):
103 for k in output_keys:
104 if k not in result:
105 logger.error(f"Step '{step.name}' missing output key '{k}' in returned dict.")
106 raise KeyError(f"Step '{step.name}' expected output key '{k}' but it was missing in returned dict.")
107 memory[k] = result[k]
108 return
110 # Handle Tuple/List Return with Manual Outputs
111 if not isinstance(result, (list, tuple)):
112 raise TypeError(f"Step '{step.name}' expected iterable (or dict) output for keys {output_keys}, got {type(result)}")
114 if len(result) != len(output_keys):
115 raise ValueError(f"Step '{step.name}' returned {len(result)} items, expected {len(output_keys)}")
117 for k, v in zip(output_keys, result):
118 memory[k] = v
119 return
121 # Case B: Dataclass Expansion (Auto-unpacking based on type hint/runtime check)
122 if is_dataclass(result):
123 memory.update(asdict(result))
124 return
126 # Case C: Single Default Output
127 memory[output_keys[0]] = result