pyoco 0.3.0__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple, Union
7
+
8
+
9
+ class ExpressionSyntaxError(ValueError):
10
+ pass
11
+
12
+
13
+ class ExpressionEvaluationError(RuntimeError):
14
+ pass
15
+
16
+
17
+ DOT_PATH_RE = re.compile(r"^[A-Za-z_][\w.]*$")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Expression:
22
+ source: str
23
+ _python: str = field(init=False, repr=False)
24
+ _code: object = field(init=False, repr=False)
25
+
26
+ def __post_init__(self):
27
+ if not isinstance(self.source, str):
28
+ raise TypeError("Expression source must be a string.")
29
+ python_expr = translate(self.source)
30
+ object.__setattr__(self, "_python", python_expr)
31
+ object.__setattr__(self, "_code", compile_safely(python_expr))
32
+
33
+ def evaluate(
34
+ self,
35
+ ctx: Optional[Mapping[str, Any]] = None,
36
+ env: Optional[Mapping[str, Any]] = None,
37
+ extras: Optional[Mapping[str, Any]] = None,
38
+ ) -> Any:
39
+ scope = build_eval_scope(ctx or {}, env or {}, extras or {})
40
+ try:
41
+ return eval(self._code, {"__builtins__": {}}, scope) # noqa: S307
42
+ except Exception as exc:
43
+ raise ExpressionEvaluationError(
44
+ f"Failed to evaluate expression '{self.source}': {exc}"
45
+ ) from exc
46
+
47
+
48
+ def ensure_expression(value: Union[str, Expression]) -> Expression:
49
+ if isinstance(value, Expression):
50
+ return value
51
+ if isinstance(value, str):
52
+ return Expression(value.strip())
53
+ raise TypeError(f"Unsupported expression value: {value!r}")
54
+
55
+
56
+ def translate(expr: str) -> str:
57
+ if "_ctx" in expr or "_env" in expr:
58
+ raise ExpressionSyntaxError("Use $ctx/$env references instead of _ctx/_env.")
59
+ def replace_token(match: re.Match[str]) -> str:
60
+ token = match.group(0)
61
+ if token.startswith("$ctx."):
62
+ path = token[len("$ctx.") :]
63
+ return f"_ctx('{path}')"
64
+ if token.startswith("$env."):
65
+ path = token[len("$env.") :]
66
+ return f"_env('{path}')"
67
+ raise ExpressionSyntaxError(f"Unsupported token '{token}'")
68
+
69
+ token_re = re.compile(r"\$(?:ctx|env)\.[A-Za-z_][\w.]*")
70
+ translated = token_re.sub(replace_token, expr.strip())
71
+ if "$" in translated:
72
+ raise ExpressionSyntaxError("All references must use $ctx.xxx or $env.xxx form.")
73
+ return translated
74
+
75
+
76
+ ALLOWED_NODES = {
77
+ ast.Expression,
78
+ ast.BoolOp,
79
+ ast.BinOp,
80
+ ast.UnaryOp,
81
+ ast.Compare,
82
+ ast.And,
83
+ ast.Or,
84
+ ast.Not,
85
+ ast.Eq,
86
+ ast.NotEq,
87
+ ast.Gt,
88
+ ast.GtE,
89
+ ast.Lt,
90
+ ast.LtE,
91
+ ast.In,
92
+ ast.NotIn,
93
+ ast.Add,
94
+ ast.Sub,
95
+ ast.Mult,
96
+ ast.Div,
97
+ ast.Mod,
98
+ ast.Pow,
99
+ ast.USub,
100
+ ast.UAdd,
101
+ ast.Constant,
102
+ ast.Name,
103
+ ast.Load,
104
+ ast.Call,
105
+ }
106
+
107
+
108
+ def compile_safely(python_expr: str):
109
+ try:
110
+ tree = ast.parse(python_expr, mode="eval")
111
+ except SyntaxError as exc:
112
+ raise ExpressionSyntaxError(str(exc)) from exc
113
+
114
+ for node in ast.walk(tree):
115
+ if not isinstance(node, tuple(ALLOWED_NODES)):
116
+ raise ExpressionSyntaxError(f"Unsupported syntax: {type(node).__name__}")
117
+ if isinstance(node, ast.Name) and node.id not in {"_ctx", "_env"}:
118
+ raise ExpressionSyntaxError(f"Unknown identifier '{node.id}' in expression.")
119
+ if isinstance(node, ast.Call):
120
+ if not isinstance(node.func, ast.Name) or node.func.id not in {"_ctx", "_env"}:
121
+ raise ExpressionSyntaxError("Only $ctx/$env references are allowed.")
122
+ if len(node.args) != 1 or not isinstance(node.args[0], ast.Constant):
123
+ raise ExpressionSyntaxError("Context references must be constant strings.")
124
+ return compile(tree, "<expression>", "eval")
125
+
126
+
127
+ def build_eval_scope(
128
+ ctx: Mapping[str, Any], env: Mapping[str, Any], extras: Mapping[str, Any]
129
+ ) -> Dict[str, Callable[[str], Any]]:
130
+ scope = {
131
+ "_ctx": lambda path: resolve_path(ctx, path, "$ctx"),
132
+ "_env": lambda path: resolve_path(env, path, "$env"),
133
+ }
134
+ scope.update(extras)
135
+ return scope
136
+
137
+
138
+ def resolve_path(data: Mapping[str, Any], path: str, root: str):
139
+ if not DOT_PATH_RE.match(path):
140
+ raise ExpressionEvaluationError(f"Invalid path '{path}' for {root}.")
141
+ parts = path.split(".")
142
+ current: Any = data
143
+ for part in parts:
144
+ if isinstance(current, Mapping):
145
+ if part not in current:
146
+ raise ExpressionEvaluationError(f"{root}.{path} not found.")
147
+ current = current[part]
148
+ else:
149
+ if not hasattr(current, part):
150
+ raise ExpressionEvaluationError(f"{root}.{path} not found.")
151
+ current = getattr(current, part)
152
+ return current
153
+
154
+
155
+ __all__ = [
156
+ "Expression",
157
+ "ensure_expression",
158
+ "ExpressionSyntaxError",
159
+ "ExpressionEvaluationError",
160
+ ]
pyoco/dsl/nodes.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional, Sequence as TypingSequence, Union
5
+
6
+ from ..core.models import Task
7
+ from .expressions import Expression, ensure_expression
8
+
9
+
10
+ DEFAULT_CASE_VALUE = "__default__"
11
+
12
+
13
+ class DSLNode:
14
+ """Base class for all DSL AST nodes."""
15
+
16
+
17
+ @dataclass
18
+ class TaskNode(DSLNode):
19
+ task: Task
20
+
21
+
22
+ @dataclass
23
+ class SubFlowNode(DSLNode):
24
+ steps: List[DSLNode] = field(default_factory=list)
25
+
26
+
27
+ @dataclass
28
+ class RepeatNode(DSLNode):
29
+ body: SubFlowNode
30
+ count: Union[int, Expression]
31
+
32
+
33
+ @dataclass
34
+ class ForEachNode(DSLNode):
35
+ body: SubFlowNode
36
+ source: Expression
37
+ alias: Optional[str] = None
38
+
39
+
40
+ @dataclass
41
+ class UntilNode(DSLNode):
42
+ body: SubFlowNode
43
+ condition: Expression
44
+ max_iter: Optional[int] = None
45
+
46
+
47
+ @dataclass
48
+ class CaseNode(DSLNode):
49
+ value: Union[str, int, float, bool]
50
+ target: SubFlowNode
51
+
52
+
53
+ @dataclass
54
+ class SwitchNode(DSLNode):
55
+ expression: Expression
56
+ cases: List[CaseNode] = field(default_factory=list)
pyoco/dsl/syntax.py CHANGED
@@ -1,122 +1,268 @@
1
- from typing import Callable, Union, List, Tuple
2
- from ..core.models import Task, Flow
3
- from ..core import engine
1
+ from __future__ import annotations
4
2
 
5
- # Global context
6
- _active_flow: Flow = None
3
+ from dataclasses import dataclass
4
+ from typing import Callable, Iterable, List, Sequence, Tuple, Union
5
+
6
+ from ..core.models import Task
7
+ from .expressions import Expression, ensure_expression
8
+ from .nodes import (
9
+ CaseNode,
10
+ DSLNode,
11
+ ForEachNode,
12
+ RepeatNode,
13
+ SubFlowNode,
14
+ SwitchNode,
15
+ TaskNode,
16
+ UntilNode,
17
+ DEFAULT_CASE_VALUE,
18
+ )
19
+ RESERVED_CTX_KEYS = {"params", "results", "scratch", "loop", "loops", "env", "artifacts"}
20
+
21
+
22
+ class FlowFragment:
23
+ """
24
+ Represents a fragment of a flow (sequence of DSL nodes). Every DSL
25
+ operator returns a FlowFragment so sub-flows can be composed before
26
+ being attached to a Flow.
27
+ """
28
+
29
+ __slots__ = ("_nodes",)
30
+
31
+ def __init__(self, nodes: Sequence[DSLNode]):
32
+ if not isinstance(nodes, (list, tuple)):
33
+ raise TypeError("FlowFragment expects a list/tuple of nodes.")
34
+ self._nodes: Tuple[DSLNode, ...] = tuple(nodes)
35
+
36
+ # Sequence composition -------------------------------------------------
37
+ def __rshift__(self, other: Union["FlowFragment", "TaskWrapper", Task]) -> "FlowFragment":
38
+ right = ensure_fragment(other)
39
+ self._link_to(right)
40
+ return FlowFragment(self._nodes + right._nodes)
41
+
42
+ # Loop support ---------------------------------------------------------
43
+ def __getitem__(self, selector: Union[int, str, Expression]) -> "FlowFragment":
44
+ """
45
+ Implements [] operator for repeat / for-each loops.
46
+ """
47
+
48
+ body = self.to_subflow()
49
+ if isinstance(selector, int):
50
+ if selector < 0:
51
+ raise ValueError("Repeat count must be non-negative.")
52
+ node = RepeatNode(body=body, count=selector)
53
+ return FlowFragment([node])
54
+
55
+ if isinstance(selector, Expression):
56
+ node = RepeatNode(body=body, count=selector)
57
+ return FlowFragment([node])
58
+
59
+ if isinstance(selector, str):
60
+ source, alias = parse_foreach_selector(selector)
61
+ node = ForEachNode(body=body, source=ensure_expression(source), alias=alias)
62
+ return FlowFragment([node])
63
+
64
+ raise TypeError(f"Unsupported loop selector: {selector!r}")
65
+
66
+ def __mod__(self, value: Union[str, Expression, Tuple[Union[str, Expression], int]]) -> "FlowFragment":
67
+ """
68
+ Implements the % operator for until loops.
69
+ """
70
+
71
+ max_iter = None
72
+ expr_value: Union[str, Expression]
73
+
74
+ if isinstance(value, tuple):
75
+ if len(value) != 2:
76
+ raise ValueError("Until tuple selector must be (expression, max_iter).")
77
+ expr_value, max_iter = value
78
+ else:
79
+ expr_value = value
80
+
81
+ node = UntilNode(
82
+ body=self.to_subflow(),
83
+ condition=ensure_expression(expr_value),
84
+ max_iter=max_iter,
85
+ )
86
+ return FlowFragment([node])
87
+
88
+ # Switch/case ----------------------------------------------------------
89
+ def __rrshift__(self, other: Union[str, int, float, bool]) -> CaseNode:
90
+ """
91
+ Enables `"X" >> fragment` syntax by reversing the operands.
92
+ """
93
+
94
+ if isinstance(other, str) and other == "*":
95
+ value = DEFAULT_CASE_VALUE
96
+ else:
97
+ value = other
98
+ return CaseNode(value=value, target=self.to_subflow())
99
+
100
+ # Helpers --------------------------------------------------------------
101
+ def to_subflow(self) -> SubFlowNode:
102
+ return SubFlowNode(list(self._nodes))
103
+
104
+ def task_nodes(self) -> List[Task]:
105
+ tasks: List[Task] = []
106
+ for node in self._nodes:
107
+ tasks.extend(_collect_tasks(node))
108
+ return tasks
109
+
110
+ def _first_task(self) -> Task | None:
111
+ for node in self._nodes:
112
+ tasks = _collect_tasks(node)
113
+ if tasks:
114
+ return tasks[0]
115
+ return None
116
+
117
+ def _last_task(self) -> Task | None:
118
+ for node in reversed(self._nodes):
119
+ tasks = _collect_tasks(node)
120
+ if tasks:
121
+ return tasks[-1]
122
+ return None
123
+
124
+ def _link_to(self, other: "FlowFragment"):
125
+ left_task = self._last_task()
126
+ right_task = other._first_task()
127
+ if left_task and right_task and left_task is not right_task:
128
+ right_task.dependencies.add(left_task)
129
+ left_task.dependents.add(right_task)
130
+
131
+ def has_control_flow(self) -> bool:
132
+ return any(not isinstance(node, TaskNode) for node in self._nodes)
133
+
134
+
135
+ class TaskWrapper(FlowFragment):
136
+ """
137
+ Wraps a Task to handle DSL operators, while exposing the underlying
138
+ task for legacy access (e.g., `task.task.inputs = ...`).
139
+ """
140
+
141
+ __slots__ = ("task",)
142
+
143
+ def __init__(self, task: Task):
144
+ self.task = task
145
+ super().__init__([TaskNode(task)])
146
+
147
+ def __call__(self, *args, **kwargs) -> "TaskWrapper":
148
+ return self
149
+
150
+ def __and__(self, other):
151
+ return Parallel([self, other])
152
+
153
+ def __or__(self, other):
154
+ return Branch([self, other])
7
155
 
8
- def task(func: Callable) -> Task:
9
- t = Task(func=func, name=func.__name__)
10
- return t
11
156
 
12
157
  class Branch(list):
13
- """Represents a branch of tasks (OR-split/join logic placeholder)."""
158
+ """Represents `A | B` OR-branches (legacy)."""
159
+
14
160
  def __rshift__(self, other):
15
- # (A | B) >> C
16
- # C depends on A and B.
17
- # AND C.trigger_policy = "ANY"
18
-
19
- targets = []
20
- if hasattr(other, 'task'):
21
- targets = [other.task]
22
- elif isinstance(other, (list, tuple)):
23
- for item in other:
24
- if hasattr(item, 'task'):
25
- targets.append(item.task)
26
-
161
+ targets = _collect_target_tasks(other)
27
162
  for target in targets:
28
163
  target.trigger_policy = "ANY"
29
164
  for source in self:
30
- if hasattr(source, 'task'):
165
+ if hasattr(source, "task"):
31
166
  target.dependencies.add(source.task)
32
167
  source.task.dependents.add(target)
33
-
34
168
  return other
35
169
 
170
+
36
171
  class Parallel(list):
37
- """Represents a parallel group of tasks (AND-split/join)."""
172
+ """Represents `A & B` parallel branches (legacy)."""
173
+
38
174
  def __rshift__(self, other):
39
- # (A & B) >> C
40
- # C depends on A AND B.
41
-
42
- targets = []
43
- if hasattr(other, 'task'):
44
- targets = [other.task]
45
- elif isinstance(other, (list, tuple)):
46
- for item in other:
47
- if hasattr(item, 'task'):
48
- targets.append(item.task)
49
-
175
+ targets = _collect_target_tasks(other)
50
176
  for target in targets:
51
177
  for source in self:
52
- if hasattr(source, 'task'):
178
+ if hasattr(source, "task"):
53
179
  target.dependencies.add(source.task)
54
180
  source.task.dependents.add(target)
55
-
56
181
  return other
57
182
 
58
- class TaskWrapper:
59
- """
60
- Wraps a Task to handle DSL operators and registration.
61
- """
62
- def __init__(self, task: Task):
63
- self.task = task
64
-
65
- def __call__(self, *args, **kwargs):
66
- # In this new spec, calling a task might not be strictly necessary for registration
67
- # if we assume tasks are added to flow explicitly or via >>
68
- # But let's keep the pattern: calling it returns a wrapper that can be chained
69
- # We might need to store args/kwargs if we want to support them
70
- return self
71
183
 
72
- def __rshift__(self, other):
73
- # self >> other
74
- if isinstance(other, TaskWrapper):
75
- other.task.dependencies.add(self.task)
76
- self.task.dependents.add(other.task)
77
- return other
78
- elif isinstance(other, (list, tuple)):
79
- # self >> (A & B) or self >> (A | B)
80
- # If it's a Branch (from |), does it imply something different?
81
- # Spec says: "Update Flow to handle Branch >> Task (set trigger_policy=ANY)"
82
- # But here we are doing Task >> Branch.
83
- # Task >> (A | B) means Task triggers both A and B?
84
- # Usually >> means "follows".
85
- # A >> (B | C) -> A triggers B and C?
86
- # Or does it mean B and C depend on A? Yes.
87
- # The difference between & and | is usually how they JOIN later, or how they are triggered?
88
- # In Airflow, >> [A, B] means A and B depend on upstream.
89
- # If we have (A | B) >> C, then C depends on A OR B.
90
- # So if 'other' is a Branch, we just add dependencies as usual.
91
- # The "OR" logic is relevant when 'other' connects to downstream.
92
-
93
- for item in other:
94
- if isinstance(item, TaskWrapper):
95
- item.task.dependencies.add(self.task)
96
- self.task.dependents.add(item.task)
97
- return other
98
- return other
184
+ def switch(expression: Union[str, Expression]) -> "SwitchBuilder":
185
+ return SwitchBuilder(expression=ensure_expression(expression))
99
186
 
100
- def __and__(self, other):
101
- # self & other (Parallel)
102
- return Parallel([self, other])
103
187
 
104
- def __or__(self, other):
105
- # self | other (Branch)
106
- # Return a Branch object containing both
107
- return Branch([self, other])
188
+ @dataclass
189
+ class SwitchBuilder:
190
+ expression: Expression
191
+
192
+ def __getitem__(self, cases: Union[CaseNode, Sequence[CaseNode]]) -> FlowFragment:
193
+ if isinstance(cases, CaseNode):
194
+ case_list = [cases]
195
+ elif isinstance(cases, Sequence):
196
+ case_list = list(cases)
197
+ else:
198
+ raise TypeError("switch()[...] expects CaseNode(s)")
199
+
200
+ if not case_list:
201
+ raise ValueError("switch() requires at least one case.")
202
+ return FlowFragment([SwitchNode(expression=self.expression, cases=case_list)])
203
+
204
+
205
+ # Helper utilities ---------------------------------------------------------
206
+ def ensure_fragment(value: Union[FlowFragment, TaskWrapper, Task]) -> FlowFragment:
207
+ if isinstance(value, FlowFragment):
208
+ return value
209
+ if isinstance(value, TaskWrapper):
210
+ return value
211
+ if hasattr(value, "task"):
212
+ return FlowFragment([TaskNode(value.task)])
213
+ if isinstance(value, Task):
214
+ return TaskWrapper(value)
215
+ raise TypeError(f"Cannot treat {value!r} as a flow fragment.")
216
+
217
+
218
+ def parse_foreach_selector(selector: str) -> Tuple[str, Union[str, None]]:
219
+ token = selector.strip()
220
+ alias = None
221
+ if " as " in token:
222
+ expr, alias = token.split(" as ", 1)
223
+ token = expr.strip()
224
+ alias = alias.strip()
225
+ if not alias or not alias.isidentifier() or alias in RESERVED_CTX_KEYS:
226
+ raise ValueError(f"Invalid foreach alias '{alias}'.")
227
+
228
+ return token, alias
229
+
230
+
231
+ def _collect_tasks(obj) -> List[Task]:
232
+ if isinstance(obj, TaskNode):
233
+ return [obj.task]
234
+ if isinstance(obj, SubFlowNode):
235
+ tasks: List[Task] = []
236
+ for step in obj.steps:
237
+ tasks.extend(_collect_tasks(step))
238
+ return tasks
239
+ if isinstance(obj, RepeatNode):
240
+ return _collect_tasks(obj.body)
241
+ if isinstance(obj, ForEachNode):
242
+ return _collect_tasks(obj.body)
243
+ if isinstance(obj, UntilNode):
244
+ return _collect_tasks(obj.body)
245
+ if isinstance(obj, SwitchNode):
246
+ tasks: List[Task] = []
247
+ for case in obj.cases:
248
+ tasks.extend(_collect_tasks(case.target))
249
+ return tasks
250
+ return []
251
+
252
+
253
+ def _collect_target_tasks(other) -> List[Task]:
254
+ targets = []
255
+ if hasattr(other, "task"):
256
+ targets = [other.task]
257
+ elif isinstance(other, (list, tuple)):
258
+ for item in other:
259
+ if hasattr(item, "task"):
260
+ targets.append(item.task)
261
+ return targets
108
262
 
109
- # We need to adapt the DSL to match the spec:
110
- # @task
111
- # def A(ctx, x:int)->int: ...
112
- # flow = Flow() >> A >> (B & C)
113
263
 
114
- # So A, B, C must be usable in the expression.
115
- # The @task decorator should return something that supports >>, &, |
264
+ def task(func: Callable) -> TaskWrapper:
265
+ return TaskWrapper(Task(func=func, name=func.__name__))
116
266
 
117
- def task_decorator(func: Callable):
118
- t = Task(func=func, name=func.__name__)
119
- return TaskWrapper(t)
120
267
 
121
- # Re-export as task
122
- task = task_decorator
268
+ __all__ = ["task", "FlowFragment", "switch", "TaskWrapper", "Branch", "Parallel"]
pyoco/dsl/validator.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List
5
+
6
+ from ..core.models import Flow
7
+ from .nodes import (
8
+ CaseNode,
9
+ ForEachNode,
10
+ RepeatNode,
11
+ SubFlowNode,
12
+ SwitchNode,
13
+ TaskNode,
14
+ UntilNode,
15
+ DSLNode,
16
+ DEFAULT_CASE_VALUE,
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class ValidationReport:
22
+ warnings: List[str] = field(default_factory=list)
23
+ errors: List[str] = field(default_factory=list)
24
+
25
+ @property
26
+ def status(self) -> str:
27
+ if self.errors:
28
+ return "error"
29
+ if self.warnings:
30
+ return "warning"
31
+ return "ok"
32
+
33
+ def to_dict(self) -> dict:
34
+ return {
35
+ "status": self.status,
36
+ "warnings": list(self.warnings),
37
+ "errors": list(self.errors),
38
+ }
39
+
40
+
41
+ class FlowValidator:
42
+ """
43
+ Traverses a Flow's SubFlow definition and produces warnings/errors for
44
+ problematic control-flow constructs (unbounded loops, duplicate cases, etc.).
45
+ """
46
+
47
+ def __init__(self, flow: Flow):
48
+ self.flow = flow
49
+ self.report = ValidationReport()
50
+
51
+ def validate(self) -> ValidationReport:
52
+ program = self.flow.build_program()
53
+ self._visit_subflow(program, "flow")
54
+ return self.report
55
+
56
+ # Traversal helpers --------------------------------------------------
57
+ def _visit_subflow(self, subflow: SubFlowNode, path: str):
58
+ for idx, node in enumerate(subflow.steps):
59
+ self._visit_node(node, f"{path}.step[{idx}]")
60
+
61
+ def _visit_node(self, node: DSLNode, path: str):
62
+ if isinstance(node, TaskNode):
63
+ return
64
+ if isinstance(node, RepeatNode):
65
+ self._visit_subflow(node.body, f"{path}.repeat")
66
+ elif isinstance(node, ForEachNode):
67
+ self._visit_subflow(node.body, f"{path}.foreach")
68
+ elif isinstance(node, UntilNode):
69
+ self._validate_until(node, path)
70
+ self._visit_subflow(node.body, f"{path}.until")
71
+ elif isinstance(node, SwitchNode):
72
+ self._validate_switch(node, path)
73
+ elif isinstance(node, SubFlowNode):
74
+ self._visit_subflow(node, path)
75
+ else:
76
+ self.report.errors.append(f"{path}: Unknown node type {type(node).__name__}")
77
+
78
+ # Validators ---------------------------------------------------------
79
+ def _validate_until(self, node: UntilNode, path: str):
80
+ if node.max_iter is None:
81
+ self.report.warnings.append(f"{path}: Until loop missing max_iter (defaults to 1000).")
82
+
83
+ def _validate_switch(self, node: SwitchNode, path: str):
84
+ seen_values = set()
85
+ default_count = 0
86
+ for idx, case in enumerate(node.cases):
87
+ case_path = f"{path}.case[{idx}]"
88
+ if case.value == DEFAULT_CASE_VALUE:
89
+ default_count += 1
90
+ if default_count > 1:
91
+ self.report.errors.append(f"{case_path}: Multiple default (*) cases are not allowed.")
92
+ else:
93
+ try:
94
+ key = case.value
95
+ if key in seen_values:
96
+ self.report.errors.append(f"{case_path}: Duplicate switch value '{case.value}'.")
97
+ else:
98
+ seen_values.add(key)
99
+ except TypeError:
100
+ self.report.errors.append(f"{case_path}: Unhashable switch value '{case.value}'.")
101
+ self._visit_subflow(case.target, f"{case_path}.target")
102
+
103
+ if default_count == 0:
104
+ self.report.warnings.append(f"{path}: Switch has no default (*) case.")