pyoco 0.3.0__py3-none-any.whl → 0.5.0__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 +136 -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 +28 -1
- pyoco/discovery/plugins.py +92 -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.0.dist-info}/METADATA +14 -1
- pyoco-0.5.0.dist-info/RECORD +33 -0
- pyoco-0.3.0.dist-info/RECORD +0 -25
- {pyoco-0.3.0.dist-info → pyoco-0.5.0.dist-info}/WHEEL +0 -0
- {pyoco-0.3.0.dist-info → pyoco-0.5.0.dist-info}/top_level.txt +0 -0
pyoco/cli/main.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import json
|
|
2
3
|
import sys
|
|
3
4
|
import os
|
|
4
5
|
import signal
|
|
6
|
+
import time
|
|
5
7
|
from ..schemas.config import PyocoConfig
|
|
6
8
|
from ..discovery.loader import TaskLoader
|
|
7
9
|
from ..core.models import Flow
|
|
8
10
|
from ..core.engine import Engine
|
|
9
11
|
from ..trace.console import ConsoleTraceBackend
|
|
10
12
|
from ..client import Client
|
|
13
|
+
from ..discovery.plugins import list_available_plugins
|
|
11
14
|
|
|
12
15
|
def main():
|
|
13
16
|
parser = argparse.ArgumentParser(description="Pyoco Workflow Engine")
|
|
@@ -28,6 +31,8 @@ def main():
|
|
|
28
31
|
check_parser = subparsers.add_parser("check", help="Verify a workflow")
|
|
29
32
|
check_parser.add_argument("--config", required=True, help="Path to flow.yaml")
|
|
30
33
|
check_parser.add_argument("--flow", default="main", help="Flow name to check")
|
|
34
|
+
check_parser.add_argument("--dry-run", action="store_true", help="Traverse flow without executing tasks")
|
|
35
|
+
check_parser.add_argument("--json", action="store_true", help="Output report as JSON")
|
|
31
36
|
|
|
32
37
|
# List tasks command
|
|
33
38
|
list_parser = subparsers.add_parser("list-tasks", help="List available tasks")
|
|
@@ -55,6 +60,8 @@ def main():
|
|
|
55
60
|
runs_list = runs_subparsers.add_parser("list", help="List runs")
|
|
56
61
|
runs_list.add_argument("--server", default="http://localhost:8000", help="Server URL")
|
|
57
62
|
runs_list.add_argument("--status", help="Filter by status")
|
|
63
|
+
runs_list.add_argument("--flow", help="Filter by flow name")
|
|
64
|
+
runs_list.add_argument("--limit", type=int, help="Maximum number of runs to show")
|
|
58
65
|
|
|
59
66
|
runs_show = runs_subparsers.add_parser("show", help="Show run details")
|
|
60
67
|
runs_show.add_argument("run_id", help="Run ID")
|
|
@@ -63,6 +70,24 @@ def main():
|
|
|
63
70
|
runs_cancel = runs_subparsers.add_parser("cancel", help="Cancel a run")
|
|
64
71
|
runs_cancel.add_argument("run_id", help="Run ID")
|
|
65
72
|
runs_cancel.add_argument("--server", default="http://localhost:8000", help="Server URL")
|
|
73
|
+
|
|
74
|
+
runs_inspect = runs_subparsers.add_parser("inspect", help="Inspect run details")
|
|
75
|
+
runs_inspect.add_argument("run_id", help="Run ID")
|
|
76
|
+
runs_inspect.add_argument("--server", default="http://localhost:8000", help="Server URL")
|
|
77
|
+
runs_inspect.add_argument("--json", action="store_true", help="Output JSON payload")
|
|
78
|
+
|
|
79
|
+
runs_logs = runs_subparsers.add_parser("logs", help="Show run logs")
|
|
80
|
+
runs_logs.add_argument("run_id", help="Run ID")
|
|
81
|
+
runs_logs.add_argument("--server", default="http://localhost:8000", help="Server URL")
|
|
82
|
+
runs_logs.add_argument("--task", help="Filter logs by task")
|
|
83
|
+
runs_logs.add_argument("--tail", type=int, help="Show last N log entries")
|
|
84
|
+
runs_logs.add_argument("--follow", action="store_true", help="Stream logs until completion")
|
|
85
|
+
runs_logs.add_argument("--allow-failure", action="store_true", help="Don't exit non-zero when run failed")
|
|
86
|
+
|
|
87
|
+
plugins_parser = subparsers.add_parser("plugins", help="Inspect plug-in entry points")
|
|
88
|
+
plugins_sub = plugins_parser.add_subparsers(dest="plugins_command")
|
|
89
|
+
plugins_list = plugins_sub.add_parser("list", help="List discovered plug-ins")
|
|
90
|
+
plugins_list.add_argument("--json", action="store_true", help="Output JSON payload")
|
|
66
91
|
|
|
67
92
|
args = parser.parse_args()
|
|
68
93
|
|
|
@@ -94,6 +119,21 @@ def main():
|
|
|
94
119
|
print(f" - {name}")
|
|
95
120
|
return
|
|
96
121
|
|
|
122
|
+
if args.command == "plugins":
|
|
123
|
+
infos = list_available_plugins()
|
|
124
|
+
if args.plugins_command == "list":
|
|
125
|
+
if getattr(args, "json", False):
|
|
126
|
+
print(json.dumps(infos, indent=2))
|
|
127
|
+
else:
|
|
128
|
+
if not infos:
|
|
129
|
+
print("No plug-ins registered under group 'pyoco.tasks'.")
|
|
130
|
+
else:
|
|
131
|
+
print("Discovered plug-ins:")
|
|
132
|
+
for info in infos:
|
|
133
|
+
mod = info.get("module") or info.get("value")
|
|
134
|
+
print(f" - {info.get('name')} ({mod})")
|
|
135
|
+
return
|
|
136
|
+
|
|
97
137
|
if args.command == "server":
|
|
98
138
|
if args.server_command == "start":
|
|
99
139
|
import uvicorn
|
|
@@ -113,7 +153,7 @@ def main():
|
|
|
113
153
|
client = Client(args.server)
|
|
114
154
|
try:
|
|
115
155
|
if args.runs_command == "list":
|
|
116
|
-
runs = client.list_runs(status=args.status)
|
|
156
|
+
runs = client.list_runs(status=args.status, flow=args.flow, limit=args.limit)
|
|
117
157
|
print(f"🐇 Active Runs ({len(runs)}):")
|
|
118
158
|
print(f"{'ID':<36} | {'Status':<12} | {'Flow':<15}")
|
|
119
159
|
print("-" * 70)
|
|
@@ -134,6 +174,31 @@ def main():
|
|
|
134
174
|
elif args.runs_command == "cancel":
|
|
135
175
|
client.cancel_run(args.run_id)
|
|
136
176
|
print(f"🛑 Cancellation requested for run {args.run_id}")
|
|
177
|
+
elif args.runs_command == "inspect":
|
|
178
|
+
run = client.get_run(args.run_id)
|
|
179
|
+
if args.json:
|
|
180
|
+
print(json.dumps(run, indent=2))
|
|
181
|
+
else:
|
|
182
|
+
print(f"🐇 Run: {run['run_id']} ({run.get('flow_name', 'n/a')})")
|
|
183
|
+
print(f"Status: {run['status']}")
|
|
184
|
+
if run.get("start_time"):
|
|
185
|
+
print(f"Started: {run['start_time']}")
|
|
186
|
+
if run.get("end_time"):
|
|
187
|
+
print(f"Ended: {run['end_time']}")
|
|
188
|
+
print("Tasks:")
|
|
189
|
+
records = run.get("task_records", {})
|
|
190
|
+
for name, info in records.items():
|
|
191
|
+
state = info.get("state", run["tasks"].get(name))
|
|
192
|
+
duration = info.get("duration_ms")
|
|
193
|
+
duration_str = f"{duration:.2f} ms" if duration else "-"
|
|
194
|
+
print(f" - {name}: {state} ({duration_str})")
|
|
195
|
+
if info.get("error"):
|
|
196
|
+
print(f" error: {info['error']}")
|
|
197
|
+
if not records:
|
|
198
|
+
for t_name, t_state in run.get("tasks", {}).items():
|
|
199
|
+
print(f" - {t_name}: {t_state}")
|
|
200
|
+
elif args.runs_command == "logs":
|
|
201
|
+
_stream_logs(client, args)
|
|
137
202
|
except Exception as e:
|
|
138
203
|
print(f"Error: {e}")
|
|
139
204
|
return
|
|
@@ -164,14 +229,16 @@ def main():
|
|
|
164
229
|
sys.exit(1)
|
|
165
230
|
return
|
|
166
231
|
# Build Flow from graph string
|
|
167
|
-
from ..dsl.syntax import TaskWrapper
|
|
232
|
+
from ..dsl.syntax import TaskWrapper, switch
|
|
168
233
|
eval_context = {name: TaskWrapper(task) for name, task in loader.tasks.items()}
|
|
234
|
+
eval_context["switch"] = switch
|
|
169
235
|
|
|
170
236
|
try:
|
|
171
237
|
# Create Flow and add all loaded tasks
|
|
172
238
|
flow = Flow(name=args.flow)
|
|
173
239
|
for t in loader.tasks.values():
|
|
174
240
|
flow.add_task(t)
|
|
241
|
+
eval_context["flow"] = flow
|
|
175
242
|
|
|
176
243
|
# Evaluate graph to set up dependencies
|
|
177
244
|
exec(flow_conf.graph, {}, eval_context)
|
|
@@ -210,36 +277,36 @@ def main():
|
|
|
210
277
|
|
|
211
278
|
# 1. Check imports (already done by loader.load(), but we can check for missing tasks in graph)
|
|
212
279
|
# 2. Build flow to check graph
|
|
213
|
-
from ..dsl.syntax import TaskWrapper
|
|
280
|
+
from ..dsl.syntax import TaskWrapper, switch
|
|
214
281
|
eval_context = {name: TaskWrapper(task) for name, task in loader.tasks.items()}
|
|
282
|
+
eval_context["switch"] = switch
|
|
215
283
|
|
|
216
284
|
try:
|
|
217
285
|
flow = Flow(name=args.flow)
|
|
218
286
|
for t in loader.tasks.values():
|
|
219
287
|
flow.add_task(t)
|
|
288
|
+
eval_context["flow"] = flow
|
|
220
289
|
|
|
221
290
|
eval(flow_conf.graph, {}, eval_context)
|
|
222
291
|
|
|
223
292
|
# 3. Reachability / Orphans
|
|
224
|
-
# Nodes with no deps and no dependents (except if single node flow)
|
|
225
293
|
if len(flow.tasks) > 1:
|
|
226
294
|
for t in flow.tasks:
|
|
227
295
|
if not t.dependencies and not t.dependents:
|
|
228
296
|
warnings.append(f"Task '{t.name}' is orphaned (no dependencies or dependents).")
|
|
229
297
|
|
|
230
298
|
# 4. Cycles
|
|
231
|
-
# Simple DFS for cycle detection
|
|
232
299
|
visited = set()
|
|
233
300
|
path = set()
|
|
301
|
+
|
|
234
302
|
def visit(node):
|
|
235
303
|
if node in path:
|
|
236
|
-
return True
|
|
304
|
+
return True
|
|
237
305
|
if node in visited:
|
|
238
306
|
return False
|
|
239
|
-
|
|
240
307
|
visited.add(node)
|
|
241
308
|
path.add(node)
|
|
242
|
-
for dep in node.dependencies:
|
|
309
|
+
for dep in node.dependencies:
|
|
243
310
|
if visit(dep):
|
|
244
311
|
return True
|
|
245
312
|
path.remove(node)
|
|
@@ -255,29 +322,75 @@ def main():
|
|
|
255
322
|
for t in flow.tasks:
|
|
256
323
|
sig = inspect.signature(t.func)
|
|
257
324
|
for name, param in sig.parameters.items():
|
|
258
|
-
if name == 'ctx':
|
|
259
|
-
|
|
260
|
-
# This is hard because inputs are resolved at runtime.
|
|
261
|
-
# But we can check if 'inputs' mapping exists for it.
|
|
325
|
+
if name == 'ctx':
|
|
326
|
+
continue
|
|
262
327
|
if name not in t.inputs and name not in flow_conf.defaults:
|
|
263
|
-
# Warning: might be missing input
|
|
264
328
|
warnings.append(f"Task '{t.name}' argument '{name}' might be missing input (not in inputs or defaults).")
|
|
265
329
|
|
|
266
330
|
except Exception as e:
|
|
267
331
|
errors.append(f"Graph evaluation failed: {e}")
|
|
268
332
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
333
|
+
if args.dry_run:
|
|
334
|
+
from ..dsl.validator import FlowValidator
|
|
335
|
+
try:
|
|
336
|
+
validator = FlowValidator(flow)
|
|
337
|
+
dr_report = validator.validate()
|
|
338
|
+
warnings.extend(dr_report.warnings)
|
|
339
|
+
errors.extend(dr_report.errors)
|
|
340
|
+
except Exception as exc:
|
|
341
|
+
print(f"❌ Dry run internal error: {exc}")
|
|
342
|
+
import traceback
|
|
343
|
+
traceback.print_exc()
|
|
344
|
+
sys.exit(3)
|
|
345
|
+
|
|
346
|
+
status = "ok"
|
|
347
|
+
if errors:
|
|
348
|
+
status = "error"
|
|
349
|
+
elif warnings:
|
|
350
|
+
status = "warning"
|
|
351
|
+
|
|
352
|
+
report = {"status": status, "warnings": warnings, "errors": errors}
|
|
353
|
+
|
|
354
|
+
if args.json:
|
|
355
|
+
print(json.dumps(report, indent=2))
|
|
273
356
|
else:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
print(
|
|
278
|
-
|
|
279
|
-
|
|
357
|
+
print("\n--- Check Report ---")
|
|
358
|
+
print(f"Status: {status}")
|
|
359
|
+
if not errors and not warnings:
|
|
360
|
+
print("✅ All checks passed!")
|
|
361
|
+
else:
|
|
362
|
+
for w in warnings:
|
|
363
|
+
print(f"⚠️ {w}")
|
|
364
|
+
for e in errors:
|
|
365
|
+
print(f"❌ {e}")
|
|
366
|
+
|
|
367
|
+
if errors:
|
|
368
|
+
sys.exit(2 if args.dry_run else 1)
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
def _stream_logs(client, args):
|
|
372
|
+
seen_seq = -1
|
|
373
|
+
follow = args.follow
|
|
374
|
+
while True:
|
|
375
|
+
tail = args.tail if (args.tail and seen_seq == -1 and not follow) else None
|
|
376
|
+
data = client.get_run_logs(args.run_id, task=args.task, tail=tail)
|
|
377
|
+
logs = data.get("logs", [])
|
|
378
|
+
logs.sort(key=lambda entry: entry.get("seq", 0))
|
|
379
|
+
for entry in logs:
|
|
380
|
+
seq = entry.get("seq", 0)
|
|
381
|
+
if seq <= seen_seq:
|
|
382
|
+
continue
|
|
383
|
+
line = entry.get("text", "")
|
|
384
|
+
line = line.rstrip("\n")
|
|
385
|
+
print(f"[{entry.get('task', 'unknown')}][{entry.get('stream', '')}] {line}")
|
|
386
|
+
seen_seq = seq
|
|
387
|
+
status = data.get("run_status", "UNKNOWN")
|
|
388
|
+
if not follow or status in ("COMPLETED", "FAILED", "CANCELLED"):
|
|
389
|
+
if status == "FAILED" and not args.allow_failure:
|
|
280
390
|
sys.exit(1)
|
|
391
|
+
break
|
|
392
|
+
time.sleep(1)
|
|
393
|
+
|
|
281
394
|
|
|
282
395
|
if __name__ == "__main__":
|
|
283
396
|
main()
|
pyoco/client.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import httpx
|
|
2
2
|
from typing import Dict, List, Optional, Any
|
|
3
|
-
from .core.models import RunStatus, TaskState
|
|
3
|
+
from .core.models import RunStatus, TaskState, RunContext
|
|
4
4
|
|
|
5
5
|
class Client:
|
|
6
6
|
def __init__(self, server_url: str, client_id: str = "cli"):
|
|
@@ -17,10 +17,19 @@ class Client:
|
|
|
17
17
|
resp.raise_for_status()
|
|
18
18
|
return resp.json()["run_id"]
|
|
19
19
|
|
|
20
|
-
def list_runs(
|
|
20
|
+
def list_runs(
|
|
21
|
+
self,
|
|
22
|
+
status: Optional[str] = None,
|
|
23
|
+
flow: Optional[str] = None,
|
|
24
|
+
limit: Optional[int] = None,
|
|
25
|
+
) -> List[Dict]:
|
|
21
26
|
params = {}
|
|
22
27
|
if status:
|
|
23
28
|
params["status"] = status
|
|
29
|
+
if flow:
|
|
30
|
+
params["flow"] = flow
|
|
31
|
+
if limit:
|
|
32
|
+
params["limit"] = limit
|
|
24
33
|
resp = self.client.get("/runs", params=params)
|
|
25
34
|
resp.raise_for_status()
|
|
26
35
|
return resp.json()
|
|
@@ -49,21 +58,32 @@ class Client:
|
|
|
49
58
|
# print(f"Poll failed: {e}")
|
|
50
59
|
return None
|
|
51
60
|
|
|
52
|
-
def heartbeat(self,
|
|
61
|
+
def heartbeat(self, run_ctx: RunContext) -> bool:
|
|
53
62
|
"""
|
|
54
63
|
Sends heartbeat. Returns True if cancellation is requested.
|
|
55
64
|
"""
|
|
56
65
|
try:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
resp = self.client.post(f"/runs/{run_id}/heartbeat", json={
|
|
66
|
+
states_json = {k: v.value if hasattr(v, 'value') else v for k, v in run_ctx.tasks.items()}
|
|
67
|
+
status_value = run_ctx.status.value if hasattr(run_ctx.status, 'value') else run_ctx.status
|
|
68
|
+
payload = {
|
|
62
69
|
"task_states": states_json,
|
|
70
|
+
"task_records": run_ctx.serialize_task_records(),
|
|
71
|
+
"logs": run_ctx.drain_logs(),
|
|
63
72
|
"run_status": status_value
|
|
64
|
-
}
|
|
73
|
+
}
|
|
74
|
+
resp = self.client.post(f"/runs/{run_ctx.run_id}/heartbeat", json=payload)
|
|
65
75
|
resp.raise_for_status()
|
|
66
76
|
return resp.json().get("cancel_requested", False)
|
|
67
77
|
except Exception as e:
|
|
68
78
|
print(f"Heartbeat failed: {e}")
|
|
69
79
|
return False
|
|
80
|
+
|
|
81
|
+
def get_run_logs(self, run_id: str, task: Optional[str] = None, tail: Optional[int] = None) -> Dict[str, Any]:
|
|
82
|
+
params = {}
|
|
83
|
+
if task:
|
|
84
|
+
params["task"] = task
|
|
85
|
+
if tail:
|
|
86
|
+
params["tail"] = tail
|
|
87
|
+
resp = self.client.get(f"/runs/{run_id}/logs", params=params)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
return resp.json()
|
pyoco/core/context.py
CHANGED
|
@@ -1,8 +1,46 @@
|
|
|
1
1
|
import threading
|
|
2
|
-
from typing import Any, Dict, List, Optional
|
|
2
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
4
|
from .models import RunContext
|
|
5
5
|
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class LoopFrame:
|
|
9
|
+
name: str
|
|
10
|
+
type: str
|
|
11
|
+
index: Optional[int] = None
|
|
12
|
+
iteration: Optional[int] = None
|
|
13
|
+
count: Optional[int] = None
|
|
14
|
+
item: Any = None
|
|
15
|
+
condition: Optional[bool] = None
|
|
16
|
+
path: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LoopStack:
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self._frames: List[LoopFrame] = []
|
|
22
|
+
|
|
23
|
+
def push(self, frame: LoopFrame) -> LoopFrame:
|
|
24
|
+
parent_path = self._frames[-1].path if self._frames else ""
|
|
25
|
+
segment = frame.name
|
|
26
|
+
if frame.index is not None:
|
|
27
|
+
segment = f"{segment}[{frame.index}]"
|
|
28
|
+
frame.path = f"{parent_path}.{segment}" if parent_path else segment
|
|
29
|
+
self._frames.append(frame)
|
|
30
|
+
return frame
|
|
31
|
+
|
|
32
|
+
def pop(self) -> LoopFrame:
|
|
33
|
+
if not self._frames:
|
|
34
|
+
raise RuntimeError("Loop stack underflow")
|
|
35
|
+
return self._frames.pop()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def current(self) -> Optional[LoopFrame]:
|
|
39
|
+
return self._frames[-1] if self._frames else None
|
|
40
|
+
|
|
41
|
+
def snapshot(self) -> Sequence[LoopFrame]:
|
|
42
|
+
return tuple(self._frames)
|
|
43
|
+
|
|
6
44
|
@dataclass
|
|
7
45
|
class Context:
|
|
8
46
|
"""
|
|
@@ -14,11 +52,13 @@ class Context:
|
|
|
14
52
|
artifacts: Dict[str, Any] = field(default_factory=dict)
|
|
15
53
|
env: Dict[str, str] = field(default_factory=dict)
|
|
16
54
|
artifact_dir: Optional[str] = None
|
|
55
|
+
_vars: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
17
56
|
|
|
18
57
|
# Reference to the parent run context (v0.2.0+)
|
|
19
58
|
run_context: Optional[RunContext] = None
|
|
20
59
|
|
|
21
60
|
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
61
|
+
_loop_stack: LoopStack = field(default_factory=LoopStack, repr=False)
|
|
22
62
|
|
|
23
63
|
@property
|
|
24
64
|
def is_cancelled(self) -> bool:
|
|
@@ -27,6 +67,29 @@ class Context:
|
|
|
27
67
|
return self.run_context.status in [RunStatus.CANCELLING, RunStatus.CANCELLED]
|
|
28
68
|
return False
|
|
29
69
|
|
|
70
|
+
@property
|
|
71
|
+
def loop(self) -> Optional[LoopFrame]:
|
|
72
|
+
return self._loop_stack.current
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def loops(self) -> Sequence[LoopFrame]:
|
|
76
|
+
return self._loop_stack.snapshot()
|
|
77
|
+
|
|
78
|
+
def push_loop(self, frame: LoopFrame) -> LoopFrame:
|
|
79
|
+
return self._loop_stack.push(frame)
|
|
80
|
+
|
|
81
|
+
def pop_loop(self) -> LoopFrame:
|
|
82
|
+
return self._loop_stack.pop()
|
|
83
|
+
|
|
84
|
+
def set_var(self, name: str, value: Any):
|
|
85
|
+
self._vars[name] = value
|
|
86
|
+
|
|
87
|
+
def get_var(self, name: str, default=None):
|
|
88
|
+
return self._vars.get(name, default)
|
|
89
|
+
|
|
90
|
+
def clear_var(self, name: str):
|
|
91
|
+
self._vars.pop(name, None)
|
|
92
|
+
|
|
30
93
|
def __post_init__(self):
|
|
31
94
|
# Ensure artifact directory exists
|
|
32
95
|
if self.artifact_dir is None:
|
|
@@ -124,3 +187,20 @@ class Context:
|
|
|
124
187
|
|
|
125
188
|
return value
|
|
126
189
|
|
|
190
|
+
def expression_data(self) -> Dict[str, Any]:
|
|
191
|
+
data: Dict[str, Any] = {}
|
|
192
|
+
data.update(self._vars)
|
|
193
|
+
data["params"] = self.params
|
|
194
|
+
data["results"] = self.results
|
|
195
|
+
data["scratch"] = self.scratch
|
|
196
|
+
data["artifacts"] = self.artifacts
|
|
197
|
+
data["loop"] = self.loop
|
|
198
|
+
data["loops"] = list(self.loops)
|
|
199
|
+
return data
|
|
200
|
+
|
|
201
|
+
def env_data(self) -> Dict[str, str]:
|
|
202
|
+
import os
|
|
203
|
+
|
|
204
|
+
env_data = dict(os.environ)
|
|
205
|
+
env_data.update(self.env)
|
|
206
|
+
return env_data
|
pyoco/core/engine.py
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import time
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
import traceback
|
|
2
5
|
from typing import Dict, Any, List, Set, Optional
|
|
6
|
+
import contextlib
|
|
3
7
|
from .models import Flow, Task, RunContext, TaskState, RunStatus
|
|
4
|
-
from .context import Context
|
|
8
|
+
from .context import Context, LoopFrame
|
|
9
|
+
from .exceptions import UntilMaxIterationsExceeded
|
|
5
10
|
from ..trace.backend import TraceBackend
|
|
6
11
|
from ..trace.console import ConsoleTraceBackend
|
|
12
|
+
from ..dsl.nodes import TaskNode, RepeatNode, ForEachNode, UntilNode, SwitchNode, DEFAULT_CASE_VALUE
|
|
13
|
+
from ..dsl.expressions import Expression
|
|
14
|
+
|
|
15
|
+
class TeeStream:
|
|
16
|
+
def __init__(self, original):
|
|
17
|
+
self.original = original
|
|
18
|
+
self.buffer = io.StringIO()
|
|
19
|
+
|
|
20
|
+
def write(self, data):
|
|
21
|
+
self.original.write(data)
|
|
22
|
+
self.buffer.write(data)
|
|
23
|
+
return len(data)
|
|
24
|
+
|
|
25
|
+
def flush(self):
|
|
26
|
+
self.original.flush()
|
|
27
|
+
|
|
28
|
+
def getvalue(self):
|
|
29
|
+
return self.buffer.getvalue()
|
|
7
30
|
|
|
8
31
|
class Engine:
|
|
9
32
|
"""
|
|
@@ -44,16 +67,31 @@ class Engine:
|
|
|
44
67
|
run_context = RunContext()
|
|
45
68
|
|
|
46
69
|
run_ctx = run_context
|
|
70
|
+
run_ctx.flow_name = flow.name
|
|
71
|
+
run_ctx.params = params or {}
|
|
47
72
|
|
|
48
73
|
# Initialize all tasks as PENDING
|
|
49
74
|
for task in flow.tasks:
|
|
50
75
|
run_ctx.tasks[task.name] = TaskState.PENDING
|
|
76
|
+
run_ctx.ensure_task_record(task.name)
|
|
51
77
|
|
|
52
78
|
ctx = Context(params=params or {}, run_context=run_ctx)
|
|
53
79
|
self.trace.on_flow_start(flow.name, run_id=run_ctx.run_id)
|
|
54
80
|
|
|
55
81
|
# Register active run
|
|
56
82
|
self.active_runs[run_ctx.run_id] = run_ctx
|
|
83
|
+
|
|
84
|
+
if flow.has_control_flow():
|
|
85
|
+
try:
|
|
86
|
+
program = flow.build_program()
|
|
87
|
+
self._execute_subflow(program, ctx)
|
|
88
|
+
run_ctx.status = RunStatus.COMPLETED
|
|
89
|
+
except Exception:
|
|
90
|
+
run_ctx.status = RunStatus.FAILED
|
|
91
|
+
run_ctx.end_time = time.time()
|
|
92
|
+
raise
|
|
93
|
+
run_ctx.end_time = time.time()
|
|
94
|
+
return ctx
|
|
57
95
|
|
|
58
96
|
try:
|
|
59
97
|
executed: Set[Task] = set()
|
|
@@ -264,12 +302,130 @@ class Engine:
|
|
|
264
302
|
run_ctx.end_time = time.time()
|
|
265
303
|
return ctx
|
|
266
304
|
|
|
305
|
+
def _execute_subflow(self, subflow, ctx: Context):
|
|
306
|
+
for node in subflow.steps:
|
|
307
|
+
self._execute_node(node, ctx)
|
|
308
|
+
|
|
309
|
+
def _execute_node(self, node, ctx: Context):
|
|
310
|
+
if isinstance(node, TaskNode):
|
|
311
|
+
self._execute_task(node.task, ctx)
|
|
312
|
+
elif isinstance(node, RepeatNode):
|
|
313
|
+
self._execute_repeat(node, ctx)
|
|
314
|
+
elif isinstance(node, ForEachNode):
|
|
315
|
+
self._execute_foreach(node, ctx)
|
|
316
|
+
elif isinstance(node, UntilNode):
|
|
317
|
+
self._execute_until(node, ctx)
|
|
318
|
+
elif isinstance(node, SwitchNode):
|
|
319
|
+
self._execute_switch(node, ctx)
|
|
320
|
+
else:
|
|
321
|
+
raise TypeError(f"Unknown node type: {type(node)}")
|
|
322
|
+
|
|
323
|
+
def _execute_repeat(self, node: RepeatNode, ctx: Context):
|
|
324
|
+
count_value = self._resolve_repeat_count(node.count, ctx)
|
|
325
|
+
for index in range(count_value):
|
|
326
|
+
frame = LoopFrame(name="repeat", type="repeat", index=index, iteration=index + 1, count=count_value)
|
|
327
|
+
ctx.push_loop(frame)
|
|
328
|
+
try:
|
|
329
|
+
self._execute_subflow(node.body, ctx)
|
|
330
|
+
finally:
|
|
331
|
+
ctx.pop_loop()
|
|
332
|
+
|
|
333
|
+
def _execute_foreach(self, node: ForEachNode, ctx: Context):
|
|
334
|
+
sequence = self._eval_expression(node.source, ctx)
|
|
335
|
+
if not isinstance(sequence, (list, tuple)):
|
|
336
|
+
raise TypeError("ForEach source must evaluate to a list or tuple.")
|
|
337
|
+
|
|
338
|
+
total = len(sequence)
|
|
339
|
+
label = node.alias or node.source.source
|
|
340
|
+
for index, item in enumerate(sequence):
|
|
341
|
+
frame = LoopFrame(
|
|
342
|
+
name=f"foreach:{label}",
|
|
343
|
+
type="foreach",
|
|
344
|
+
index=index,
|
|
345
|
+
iteration=index + 1,
|
|
346
|
+
count=total,
|
|
347
|
+
item=item,
|
|
348
|
+
)
|
|
349
|
+
ctx.push_loop(frame)
|
|
350
|
+
if node.alias:
|
|
351
|
+
ctx.set_var(node.alias, item)
|
|
352
|
+
try:
|
|
353
|
+
self._execute_subflow(node.body, ctx)
|
|
354
|
+
finally:
|
|
355
|
+
if node.alias:
|
|
356
|
+
ctx.clear_var(node.alias)
|
|
357
|
+
ctx.pop_loop()
|
|
358
|
+
|
|
359
|
+
def _execute_until(self, node: UntilNode, ctx: Context):
|
|
360
|
+
max_iter = node.max_iter or 1000
|
|
361
|
+
iteration = 0
|
|
362
|
+
last_condition = None
|
|
363
|
+
while True:
|
|
364
|
+
iteration += 1
|
|
365
|
+
frame = LoopFrame(
|
|
366
|
+
name="until",
|
|
367
|
+
type="until",
|
|
368
|
+
index=iteration - 1,
|
|
369
|
+
iteration=iteration,
|
|
370
|
+
condition=last_condition,
|
|
371
|
+
count=max_iter,
|
|
372
|
+
)
|
|
373
|
+
ctx.push_loop(frame)
|
|
374
|
+
try:
|
|
375
|
+
self._execute_subflow(node.body, ctx)
|
|
376
|
+
condition_result = bool(self._eval_expression(node.condition, ctx))
|
|
377
|
+
finally:
|
|
378
|
+
ctx.pop_loop()
|
|
379
|
+
|
|
380
|
+
last_condition = condition_result
|
|
381
|
+
if condition_result:
|
|
382
|
+
break
|
|
383
|
+
if iteration >= max_iter:
|
|
384
|
+
raise UntilMaxIterationsExceeded(node.condition.source, max_iter)
|
|
385
|
+
|
|
386
|
+
def _execute_switch(self, node: SwitchNode, ctx: Context):
|
|
387
|
+
value = self._eval_expression(node.expression, ctx)
|
|
388
|
+
default_case = None
|
|
389
|
+
for case in node.cases:
|
|
390
|
+
if case.value == DEFAULT_CASE_VALUE:
|
|
391
|
+
if default_case is None:
|
|
392
|
+
default_case = case
|
|
393
|
+
continue
|
|
394
|
+
if case.value == value:
|
|
395
|
+
self._execute_subflow(case.target, ctx)
|
|
396
|
+
return
|
|
397
|
+
if default_case:
|
|
398
|
+
self._execute_subflow(default_case.target, ctx)
|
|
399
|
+
def _resolve_repeat_count(self, count_value, ctx: Context) -> int:
|
|
400
|
+
if isinstance(count_value, Expression):
|
|
401
|
+
resolved = self._eval_expression(count_value, ctx)
|
|
402
|
+
else:
|
|
403
|
+
resolved = count_value
|
|
404
|
+
if not isinstance(resolved, int):
|
|
405
|
+
raise TypeError("Repeat count must evaluate to an integer.")
|
|
406
|
+
if resolved < 0:
|
|
407
|
+
raise ValueError("Repeat count cannot be negative.")
|
|
408
|
+
return resolved
|
|
409
|
+
|
|
410
|
+
def _eval_expression(self, expression, ctx: Context):
|
|
411
|
+
if isinstance(expression, Expression):
|
|
412
|
+
return expression.evaluate(ctx=ctx.expression_data(), env=ctx.env_data())
|
|
413
|
+
return expression
|
|
414
|
+
|
|
267
415
|
def _execute_task(self, task: Task, ctx: Context):
|
|
268
416
|
# Update state to RUNNING
|
|
269
417
|
from .models import TaskState
|
|
418
|
+
run_ctx = ctx.run_context
|
|
270
419
|
if ctx.run_context:
|
|
271
420
|
ctx.run_context.tasks[task.name] = TaskState.RUNNING
|
|
272
|
-
|
|
421
|
+
record = ctx.run_context.ensure_task_record(task.name)
|
|
422
|
+
record.state = TaskState.RUNNING
|
|
423
|
+
record.started_at = time.time()
|
|
424
|
+
record.error = None
|
|
425
|
+
record.traceback = None
|
|
426
|
+
else:
|
|
427
|
+
record = None
|
|
428
|
+
|
|
273
429
|
self.trace.on_node_start(task.name)
|
|
274
430
|
start_time = time.time()
|
|
275
431
|
# Retry loop
|
|
@@ -301,8 +457,17 @@ class Engine:
|
|
|
301
457
|
elif param_name in ctx.results:
|
|
302
458
|
kwargs[param_name] = ctx.results[param_name]
|
|
303
459
|
|
|
304
|
-
|
|
460
|
+
if record:
|
|
461
|
+
record.inputs = {k: v for k, v in kwargs.items() if k != "ctx"}
|
|
462
|
+
|
|
463
|
+
stdout_capture = TeeStream(sys.stdout)
|
|
464
|
+
stderr_capture = TeeStream(sys.stderr)
|
|
465
|
+
with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(stderr_capture):
|
|
466
|
+
result = task.func(**kwargs)
|
|
305
467
|
ctx.set_result(task.name, result)
|
|
468
|
+
if run_ctx:
|
|
469
|
+
run_ctx.append_log(task.name, "stdout", stdout_capture.getvalue())
|
|
470
|
+
run_ctx.append_log(task.name, "stderr", stderr_capture.getvalue())
|
|
306
471
|
|
|
307
472
|
# Handle outputs saving
|
|
308
473
|
for target_path in task.outputs:
|
|
@@ -333,10 +498,24 @@ class Engine:
|
|
|
333
498
|
# Update state to SUCCEEDED
|
|
334
499
|
if ctx.run_context:
|
|
335
500
|
ctx.run_context.tasks[task.name] = TaskState.SUCCEEDED
|
|
501
|
+
if record:
|
|
502
|
+
record.state = TaskState.SUCCEEDED
|
|
503
|
+
record.ended_at = time.time()
|
|
504
|
+
record.duration_ms = (record.ended_at - record.started_at) * 1000
|
|
505
|
+
record.output = result
|
|
336
506
|
|
|
337
507
|
return # Success
|
|
338
508
|
|
|
339
509
|
except Exception as e:
|
|
510
|
+
if run_ctx:
|
|
511
|
+
run_ctx.append_log(task.name, "stdout", stdout_capture.getvalue() if 'stdout_capture' in locals() else "")
|
|
512
|
+
run_ctx.append_log(task.name, "stderr", stderr_capture.getvalue() if 'stderr_capture' in locals() else "")
|
|
513
|
+
if record:
|
|
514
|
+
record.state = TaskState.FAILED
|
|
515
|
+
record.ended_at = time.time()
|
|
516
|
+
record.duration_ms = (record.ended_at - record.started_at) * 1000
|
|
517
|
+
record.error = str(e)
|
|
518
|
+
record.traceback = traceback.format_exc()
|
|
340
519
|
if retries_left > 0:
|
|
341
520
|
retries_left -= 1
|
|
342
521
|
# Log retry?
|