Coverage for smartmdao / validation.py: 100%
62 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 logging
2from typing import Any, Dict, List, Optional, Protocol, Union, get_args, get_origin, runtime_checkable
4from .graph import map_producers
5from .models import Step
7# Initialize module-level logger
8logger = logging.getLogger(__name__)
11class TypeMismatchError(TypeError):
12 """Raised when a value, or a declared type, doesn't satisfy an expected type annotation."""
15@runtime_checkable
16class TypeChecker(Protocol):
17 """
18 Pluggable strategy deciding what "type compatible" means.
20 Implement this to customize how strict/lenient SmartMDAO's type
21 enforcement is - e.g. to accept numpy scalars, or to allow `int`
22 where a `float` is declared - without touching the core framework.
23 """
25 def check_value(self, value: Any, expected: type) -> bool:
26 """Does `value` satisfy the `expected` annotation?"""
27 ...
29 def check_types(self, produced: type, expected: type) -> bool:
30 """Is everything satisfying `produced` guaranteed to also satisfy `expected`?"""
31 ...
34def _concrete_classes(annotation: Any) -> tuple:
35 """
36 Reduces a (possibly Optional/Union) type annotation down to a flat
37 tuple of concrete classes suitable for isinstance/issubclass checks.
39 Returns () for annotations we can't resolve to concrete classes
40 (e.g. `typing.Any`, a bare `TypeVar`) - callers should treat that as
41 "no constraint" rather than a failure.
42 """
43 if annotation is Any:
44 return ()
46 origin = get_origin(annotation)
47 if origin is Union:
48 classes = []
49 for arg in get_args(annotation):
50 classes.extend(_concrete_classes(arg))
51 return tuple(classes)
53 # Generic containers (e.g. list[float], dict[str, int]) are checked
54 # structurally on their origin only - contents are not inspected.
55 if origin is not None:
56 return (origin,)
58 if isinstance(annotation, type):
59 return (annotation,)
61 return ()
64def _format_type(tp: Any) -> str:
65 return getattr(tp, "__name__", str(tp))
68class StandardTypeChecker:
69 """
70 Strict, dependency-free type checker built on `isinstance`/`issubclass`.
72 Understands `Optional[X]`, `Union[X, Y]`, generic containers (checked
73 structurally on their origin), and `typing.Any` (always satisfied).
74 Does not consider `int` compatible with `float` - if you need that,
75 write a `TypeChecker` that loosens `_concrete_classes` accordingly.
76 """
78 def check_value(self, value: Any, expected: type) -> bool:
79 classes = _concrete_classes(expected)
80 return not classes or isinstance(value, classes)
82 def check_types(self, produced: type, expected: type) -> bool:
83 expected_classes = _concrete_classes(expected)
84 if not expected_classes:
85 return True
87 produced_classes = _concrete_classes(produced)
88 if not produced_classes:
89 return True
91 return all(issubclass(p, expected_classes) for p in produced_classes)
94def validate_structure(steps: List[Step], checker: Optional[TypeChecker] = None) -> None:
95 """
96 Statically validates every producer -> consumer edge in the pipeline:
97 the type a step declares for an output must be compatible with the
98 type every consumer of that variable declares for its input.
100 This is pure structural analysis - no step is ever executed - so it
101 only needs to run once per pipeline shape, regardless of how many
102 times the pipeline is later evaluated (e.g. inside an optimization loop).
103 """
104 checker = checker or StandardTypeChecker()
105 producers = map_producers(steps)
107 for consumer in steps:
108 for param_name, expected_type in consumer.resolve_input_types().items():
109 producer = producers.get(param_name)
110 if producer is None:
111 continue # External input; validated per-call in validate_external_inputs.
113 produced_type = producer.resolve_output_types().get(param_name)
114 if produced_type is None:
115 continue # Producer didn't declare a type for this output.
117 if not checker.check_types(produced_type, expected_type):
118 raise TypeMismatchError(
119 f"Type mismatch on '{param_name}': step '{producer.name}' declares "
120 f"{param_name} -> {_format_type(produced_type)}, but step "
121 f"'{consumer.name}' expects {param_name}: {_format_type(expected_type)}."
122 )
124 logger.debug(f"Structural type validation passed for {len(steps)} steps.")
127def validate_external_inputs(
128 steps: List[Step], inputs: Dict[str, Any], checker: Optional[TypeChecker] = None
129) -> None:
130 """
131 Validates the concrete input values passed into `Pipeline.run(**inputs)`
132 against the declared parameter types of the steps that consume them
133 directly (i.e. variables that aren't produced internally by another step).
134 """
135 checker = checker or StandardTypeChecker()
136 producers = map_producers(steps)
138 for consumer in steps:
139 for param_name, expected_type in consumer.resolve_input_types().items():
140 if param_name in producers or param_name not in inputs:
141 continue
143 value = inputs[param_name]
144 if not checker.check_value(value, expected_type):
145 raise TypeMismatchError(
146 f"Input '{param_name}'={value!r} ({type(value).__name__}) does not match "
147 f"the type expected by step '{consumer.name}': {param_name}: {_format_type(expected_type)}."
148 )