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.
- pyoco/cli/main.py +182 -23
- pyoco/client.py +29 -9
- pyoco/core/context.py +81 -1
- pyoco/core/engine.py +182 -3
- pyoco/core/exceptions.py +15 -0
- pyoco/core/models.py +130 -1
- pyoco/discovery/loader.py +32 -1
- pyoco/discovery/plugins.py +148 -0
- pyoco/dsl/expressions.py +160 -0
- pyoco/dsl/nodes.py +56 -0
- pyoco/dsl/syntax.py +241 -95
- pyoco/dsl/validator.py +104 -0
- pyoco/server/api.py +59 -18
- pyoco/server/metrics.py +113 -0
- pyoco/server/models.py +2 -0
- pyoco/server/store.py +153 -16
- pyoco/server/webhook.py +108 -0
- pyoco/socketless_reset.py +7 -0
- pyoco/worker/runner.py +3 -8
- {pyoco-0.3.0.dist-info → pyoco-0.5.1.dist-info}/METADATA +16 -1
- pyoco-0.5.1.dist-info/RECORD +33 -0
- pyoco-0.3.0.dist-info/RECORD +0 -25
- {pyoco-0.3.0.dist-info → pyoco-0.5.1.dist-info}/WHEEL +0 -0
- {pyoco-0.3.0.dist-info → pyoco-0.5.1.dist-info}/top_level.txt +0 -0
pyoco/dsl/expressions.py
ADDED
|
@@ -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
|
|
2
|
-
from ..core.models import Task, Flow
|
|
3
|
-
from ..core import engine
|
|
1
|
+
from __future__ import annotations
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
158
|
+
"""Represents `A | B` OR-branches (legacy)."""
|
|
159
|
+
|
|
14
160
|
def __rshift__(self, other):
|
|
15
|
-
|
|
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,
|
|
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
|
|
172
|
+
"""Represents `A & B` parallel branches (legacy)."""
|
|
173
|
+
|
|
38
174
|
def __rshift__(self, other):
|
|
39
|
-
|
|
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,
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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.")
|