Coverage for smartmdao / models.py: 100%
55 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-05 16:16 +0200
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-05 16:16 +0200
1import inspect
2from dataclasses import dataclass, is_dataclass
3from typing import Callable, Dict, Optional, List, get_args, get_origin, get_type_hints
5@dataclass(eq=False)
6class Step:
7 """
8 Represents a single node in the computation graph.
9 eq=False ensures hashability is based on object identity.
10 """
11 fn: Callable
12 manual_outputs: Optional[List[str]] = None
14 @property
15 def name(self) -> str:
16 return self.fn.__name__
18 def get_signature(self) -> inspect.Signature:
19 """
20 Robustly retrieves the signature of the underlying function,
21 peeling off any decorators (like @cached) to find the real inputs.
22 """
23 original_fn = inspect.unwrap(self.fn)
24 return inspect.signature(original_fn)
26 def resolve_output_names(self) -> List[str]:
27 """Determines variable names this step produces."""
28 if self.manual_outputs:
29 return self.manual_outputs
31 # FIX: Use get_type_hints to correctly resolve string annotations
32 # (common with 'from __future__ import annotations' or forward refs)
33 try:
34 original_fn = inspect.unwrap(self.fn)
35 hints = get_type_hints(original_fn)
36 ann = hints.get('return')
37 except Exception:
38 # Fallback to standard inspection if get_type_hints fails
39 # (e.g., closures without global context)
40 sig = self.get_signature()
41 ann = sig.return_annotation
43 # If the function returns a Dataclass, use field names
44 if isinstance(ann, type) and is_dataclass(ann):
45 return list(ann.__dataclass_fields__.keys())
47 # Default: use function name
48 return [self.name]
50 def resolve_input_types(self) -> Dict[str, type]:
51 """
52 Maps each declared parameter name to its annotated type.
53 Parameters without a type hint are omitted (nothing to validate against).
54 """
55 original_fn = inspect.unwrap(self.fn)
56 try:
57 hints = get_type_hints(original_fn)
58 except Exception:
59 # Unresolvable annotations (e.g. forward refs without global context):
60 # skip type validation for this step rather than fail construction.
61 return {}
62 hints.pop('return', None)
63 return hints
65 def resolve_output_types(self) -> Dict[str, type]:
66 """
67 Maps each output variable name (see `resolve_output_names`) to its
68 declared type, inferred from the function's return annotation:
69 - Dataclass return -> per-field types.
70 - Tuple return matched against manual `outputs=[...]` -> per-position types.
71 - Single output -> the return annotation itself.
72 Falls back to an empty mapping wherever a type can't be confidently inferred.
73 """
74 original_fn = inspect.unwrap(self.fn)
75 try:
76 hints = get_type_hints(original_fn)
77 except Exception:
78 return {}
80 ann = hints.get('return')
81 if ann is None:
82 return {}
84 output_names = self.resolve_output_names()
86 if isinstance(ann, type) and is_dataclass(ann):
87 field_hints = get_type_hints(ann)
88 return {name: field_hints[name] for name in output_names if name in field_hints}
90 if self.manual_outputs and get_origin(ann) is tuple:
91 args = get_args(ann)
92 if len(args) == len(output_names) and Ellipsis not in args:
93 return dict(zip(output_names, args))
94 return {}
96 if len(output_names) == 1:
97 return {output_names[0]: ann}
99 return {}