pyrelay-workflow 0.1.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.
- pyrelay/__init__.py +14 -0
- pyrelay/core.py +220 -0
- pyrelay/dashboard.py +231 -0
- pyrelay/engine.py +191 -0
- pyrelay/exceptions.py +19 -0
- pyrelay/visualizer.py +461 -0
- pyrelay_workflow-0.1.0.dist-info/METADATA +163 -0
- pyrelay_workflow-0.1.0.dist-info/RECORD +11 -0
- pyrelay_workflow-0.1.0.dist-info/WHEEL +5 -0
- pyrelay_workflow-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyrelay_workflow-0.1.0.dist-info/top_level.txt +1 -0
pyrelay/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .core import task, workflow, WorkflowRun
|
|
2
|
+
from .exceptions import WorkflowError, TaskFailedError, TaskTimeoutError
|
|
3
|
+
# Import visualizer to trigger WorkflowRun monkeypatching
|
|
4
|
+
from . import visualizer
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"task",
|
|
8
|
+
"workflow",
|
|
9
|
+
"WorkflowRun",
|
|
10
|
+
"WorkflowError",
|
|
11
|
+
"TaskFailedError",
|
|
12
|
+
"TaskTimeoutError",
|
|
13
|
+
]
|
|
14
|
+
|
pyrelay/core.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextvars
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Callable, Dict, Optional, Set
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
# Async context storage for active workflow run
|
|
9
|
+
active_run_context: contextvars.ContextVar[Optional["WorkflowRun"]] = contextvars.ContextVar(
|
|
10
|
+
"active_run_context", default=None
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskStatus(str, Enum):
|
|
15
|
+
PENDING = "PENDING"
|
|
16
|
+
running = "RUNNING" # Case-insensitive compatibility
|
|
17
|
+
RUNNING = "RUNNING"
|
|
18
|
+
SUCCESS = "SUCCESS"
|
|
19
|
+
FAILED = "FAILED"
|
|
20
|
+
SKIPPED = "SKIPPED"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Task:
|
|
24
|
+
"""Decorator wrapper that stores task metadata and intercepts calls inside workflows."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
func: Callable[..., Any],
|
|
29
|
+
name: Optional[str] = None,
|
|
30
|
+
retries: int = 0,
|
|
31
|
+
retry_delay: float = 1.0,
|
|
32
|
+
backoff_factor: float = 2.0,
|
|
33
|
+
timeout: Optional[float] = None,
|
|
34
|
+
):
|
|
35
|
+
self.func = func
|
|
36
|
+
self.name = name or func.__name__
|
|
37
|
+
self.retries = retries
|
|
38
|
+
self.retry_delay = retry_delay
|
|
39
|
+
self.backoff_factor = backoff_factor
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
self.is_coroutine = asyncio.iscoroutinefunction(func)
|
|
42
|
+
|
|
43
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
44
|
+
run = active_run_context.get()
|
|
45
|
+
if not run:
|
|
46
|
+
# Standalone invocation (useful for unit testing)
|
|
47
|
+
return self.func(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
return run.execute_task(self, args, kwargs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TaskInstance:
|
|
53
|
+
"""Tracks state and telemetry for a single task execution."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, task: Task, run_id: str):
|
|
56
|
+
self.id = str(uuid.uuid4())
|
|
57
|
+
self.task = task
|
|
58
|
+
self.name = task.name
|
|
59
|
+
self.run_id = run_id
|
|
60
|
+
self.status = TaskStatus.PENDING
|
|
61
|
+
|
|
62
|
+
self.start_time: Optional[datetime] = None
|
|
63
|
+
self.end_time: Optional[datetime] = None
|
|
64
|
+
self.duration: float = 0.0
|
|
65
|
+
|
|
66
|
+
self.attempts = 0
|
|
67
|
+
self.inputs: Dict[str, Any] = {}
|
|
68
|
+
self.result: Any = None
|
|
69
|
+
self.error: Optional[str] = None
|
|
70
|
+
self.predecessors: Set[str] = set()
|
|
71
|
+
|
|
72
|
+
def start(self, inputs: Dict[str, Any]) -> None:
|
|
73
|
+
self.status = TaskStatus.RUNNING
|
|
74
|
+
self.start_time = datetime.now()
|
|
75
|
+
self.inputs = inputs
|
|
76
|
+
self.attempts += 1
|
|
77
|
+
|
|
78
|
+
def complete(self, result: Any) -> None:
|
|
79
|
+
self.status = TaskStatus.SUCCESS
|
|
80
|
+
self.end_time = datetime.now()
|
|
81
|
+
if self.start_time:
|
|
82
|
+
self.duration = (self.end_time - self.start_time).total_seconds()
|
|
83
|
+
self.result = result
|
|
84
|
+
|
|
85
|
+
def fail(self, error: Exception) -> None:
|
|
86
|
+
self.status = TaskStatus.FAILED
|
|
87
|
+
self.end_time = datetime.now()
|
|
88
|
+
if self.start_time:
|
|
89
|
+
self.duration = (self.end_time - self.start_time).total_seconds()
|
|
90
|
+
self.error = f"{type(error).__name__}: {str(error)}"
|
|
91
|
+
|
|
92
|
+
def skip(self) -> None:
|
|
93
|
+
self.status = TaskStatus.SKIPPED
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"id": self.id,
|
|
98
|
+
"name": self.name,
|
|
99
|
+
"status": self.status.value,
|
|
100
|
+
"start_time": self.start_time.isoformat() if self.start_time else None,
|
|
101
|
+
"end_time": self.end_time.isoformat() if self.end_time else None,
|
|
102
|
+
"duration": self.duration,
|
|
103
|
+
"attempts": self.attempts,
|
|
104
|
+
"inputs": str(self.inputs),
|
|
105
|
+
"result": str(self.result) if self.result is not None else None,
|
|
106
|
+
"error": self.error,
|
|
107
|
+
"predecessors": list(self.predecessors),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Workflow:
|
|
112
|
+
"""Blueprint representing a workflow defined with `@workflow`."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, func: Callable[..., Any], name: Optional[str] = None):
|
|
115
|
+
self.func = func
|
|
116
|
+
self.name = name or func.__name__
|
|
117
|
+
|
|
118
|
+
def run(self, *args: Any, **kwargs: Any) -> "WorkflowRun":
|
|
119
|
+
run = WorkflowRun(self)
|
|
120
|
+
return run.run_sync(*args, **kwargs)
|
|
121
|
+
|
|
122
|
+
async def run_async(self, *args: Any, **kwargs: Any) -> "WorkflowRun":
|
|
123
|
+
run = WorkflowRun(self)
|
|
124
|
+
await run.run_async(*args, **kwargs)
|
|
125
|
+
return run
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class WorkflowRun:
|
|
129
|
+
"""Coordinates the execution trace and lifecycle events for a single pipeline run."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, workflow: Workflow):
|
|
132
|
+
self.id = str(uuid.uuid4())
|
|
133
|
+
self.workflow = workflow
|
|
134
|
+
self.status = "PENDING"
|
|
135
|
+
self.start_time: Optional[datetime] = None
|
|
136
|
+
self.end_time: Optional[datetime] = None
|
|
137
|
+
self.duration: float = 0.0
|
|
138
|
+
self.result: Any = None
|
|
139
|
+
self.error: Optional[str] = None
|
|
140
|
+
|
|
141
|
+
self.tasks: Dict[str, TaskInstance] = {}
|
|
142
|
+
self.event_callbacks: List[Callable[[str, Any], None]] = []
|
|
143
|
+
self.executor_hook: Optional[Callable[[Task, tuple, dict], Any]] = None
|
|
144
|
+
|
|
145
|
+
def add_event_callback(self, callback: Callable[[str, Any], None]) -> None:
|
|
146
|
+
self.event_callbacks.append(callback)
|
|
147
|
+
|
|
148
|
+
def emit_event(self, event_type: str, data: Any) -> None:
|
|
149
|
+
for cb in self.event_callbacks:
|
|
150
|
+
try:
|
|
151
|
+
cb(event_type, data)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
def get_frontier(self) -> Set[str]:
|
|
156
|
+
"""Identifies completed tasks that have no dependent downstream runs.
|
|
157
|
+
|
|
158
|
+
Uses:
|
|
159
|
+
Frontier = {Completed} - {Predecessors of any task}
|
|
160
|
+
"""
|
|
161
|
+
all_completed = {
|
|
162
|
+
t_id for t_id, t in self.tasks.items()
|
|
163
|
+
if t.status in (TaskStatus.SUCCESS, TaskStatus.FAILED, TaskStatus.SKIPPED)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
all_predecessors = set()
|
|
167
|
+
for t in self.tasks.values():
|
|
168
|
+
all_predecessors.update(t.predecessors)
|
|
169
|
+
|
|
170
|
+
return all_completed - all_predecessors
|
|
171
|
+
|
|
172
|
+
def execute_task(self, task: Task, args: tuple, kwargs: dict) -> Any:
|
|
173
|
+
if not self.executor_hook:
|
|
174
|
+
raise RuntimeError("Engine runner not initialized on this WorkflowRun.")
|
|
175
|
+
return self.executor_hook(task, args, kwargs)
|
|
176
|
+
|
|
177
|
+
def run_sync(self, *args: Any, **kwargs: Any) -> "WorkflowRun":
|
|
178
|
+
try:
|
|
179
|
+
loop = asyncio.get_event_loop()
|
|
180
|
+
except RuntimeError:
|
|
181
|
+
loop = asyncio.new_event_loop()
|
|
182
|
+
asyncio.set_event_loop(loop)
|
|
183
|
+
|
|
184
|
+
if loop.is_running():
|
|
185
|
+
# Jupyter and FastAPI loops are already running; patch to prevent loop collision
|
|
186
|
+
import nest_asyncio
|
|
187
|
+
nest_asyncio.apply()
|
|
188
|
+
|
|
189
|
+
loop.run_until_complete(self.run_async(*args, **kwargs))
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
async def run_async(self, *args: Any, **kwargs: Any) -> None:
|
|
193
|
+
from .engine import ExecutionEngine
|
|
194
|
+
engine = ExecutionEngine(self)
|
|
195
|
+
await engine.run(args, kwargs)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def task(
|
|
199
|
+
name: Optional[str] = None,
|
|
200
|
+
retries: int = 0,
|
|
201
|
+
retry_delay: float = 1.0,
|
|
202
|
+
backoff_factor: float = 2.0,
|
|
203
|
+
timeout: Optional[float] = None,
|
|
204
|
+
) -> Callable[[Callable[..., Any]], Task]:
|
|
205
|
+
def decorator(func: Callable[..., Any]) -> Task:
|
|
206
|
+
return Task(
|
|
207
|
+
func=func,
|
|
208
|
+
name=name,
|
|
209
|
+
retries=retries,
|
|
210
|
+
retry_delay=retry_delay,
|
|
211
|
+
backoff_factor=backoff_factor,
|
|
212
|
+
timeout=timeout,
|
|
213
|
+
)
|
|
214
|
+
return decorator
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def workflow(name: Optional[str] = None) -> Callable[[Callable[..., Any]], Workflow]:
|
|
218
|
+
def decorator(func: Callable[..., Any]) -> Workflow:
|
|
219
|
+
return Workflow(func=func, name=name)
|
|
220
|
+
return decorator
|
pyrelay/dashboard.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.live import Live
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.progress import BarColumn, Progress, TextColumn
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from rich.layout import Layout
|
|
11
|
+
from .core import TaskInstance, TaskStatus, WorkflowRun
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsoleDashboard:
|
|
15
|
+
"""Renders a live, real-time dashboard in the terminal for a WorkflowRun."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, run: WorkflowRun):
|
|
18
|
+
self.run = run
|
|
19
|
+
self.console = Console()
|
|
20
|
+
self.logs: List[str] = []
|
|
21
|
+
self.live: Optional[Live] = None
|
|
22
|
+
self.start_time = time.time()
|
|
23
|
+
|
|
24
|
+
# Register callbacks
|
|
25
|
+
self.run.add_event_callback(self.handle_event)
|
|
26
|
+
|
|
27
|
+
def log(self, message: str) -> None:
|
|
28
|
+
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
29
|
+
self.logs.append(f"[{timestamp}] {message}")
|
|
30
|
+
if len(self.logs) > 8:
|
|
31
|
+
self.logs.pop(0)
|
|
32
|
+
|
|
33
|
+
def handle_event(self, event_type: str, data: Any) -> None:
|
|
34
|
+
"""Handles engine events and updates the internal state and logs."""
|
|
35
|
+
if event_type == "workflow_start":
|
|
36
|
+
self.log(f"๐ Workflow '{data.workflow.name}' started (Run ID: {data.id[:8]})")
|
|
37
|
+
# Initialize Live view
|
|
38
|
+
self.live = Live(self.make_layout(), console=self.console, refresh_per_second=4, transient=False)
|
|
39
|
+
self.live.start()
|
|
40
|
+
|
|
41
|
+
elif event_type == "workflow_success":
|
|
42
|
+
self.log(f"โ
Workflow '{data.workflow.name}' completed successfully!")
|
|
43
|
+
self._update_live()
|
|
44
|
+
if self.live:
|
|
45
|
+
self.live.stop()
|
|
46
|
+
self.live = None
|
|
47
|
+
self._print_final_summary()
|
|
48
|
+
|
|
49
|
+
elif event_type == "workflow_failure":
|
|
50
|
+
self.log(f"โ Workflow '{data.workflow.name}' failed: {data.error}")
|
|
51
|
+
self._update_live()
|
|
52
|
+
if self.live:
|
|
53
|
+
self.live.stop()
|
|
54
|
+
self.live = None
|
|
55
|
+
self._print_final_summary()
|
|
56
|
+
|
|
57
|
+
elif event_type == "task_pending":
|
|
58
|
+
self.log(f"โณ Task '{data.name}' pending dependencies")
|
|
59
|
+
self._update_live()
|
|
60
|
+
|
|
61
|
+
elif event_type == "task_start":
|
|
62
|
+
self.log(f"๐ Task '{data.name}' started (Attempt {data.attempts})")
|
|
63
|
+
self._update_live()
|
|
64
|
+
|
|
65
|
+
elif event_type == "task_success":
|
|
66
|
+
self.log(f"โ
Task '{data.name}' finished in {data.duration:.2f}s")
|
|
67
|
+
self._update_live()
|
|
68
|
+
|
|
69
|
+
elif event_type == "task_retry":
|
|
70
|
+
self.log(f"โ ๏ธ Task '{data.name}' failed, retrying (Attempt {data.attempts})...")
|
|
71
|
+
self._update_live()
|
|
72
|
+
|
|
73
|
+
elif event_type == "task_timeout":
|
|
74
|
+
self.log(f"โฑ๏ธ Task '{data.name}' timed out!")
|
|
75
|
+
self._update_live()
|
|
76
|
+
|
|
77
|
+
elif event_type == "task_fail":
|
|
78
|
+
self.log(f"โ Task '{data.name}' failed: {data.error}")
|
|
79
|
+
self._update_live()
|
|
80
|
+
|
|
81
|
+
elif event_type == "task_skip":
|
|
82
|
+
self.log(f"โญ๏ธ Task '{data.name}' skipped due to upstream failure")
|
|
83
|
+
self._update_live()
|
|
84
|
+
|
|
85
|
+
def _update_live(self) -> None:
|
|
86
|
+
if self.live:
|
|
87
|
+
self.live.update(self.make_layout())
|
|
88
|
+
|
|
89
|
+
def make_layout(self) -> Layout:
|
|
90
|
+
"""Constructs the Rich layout split into Header, Body (Tasks), and Footer (Logs)."""
|
|
91
|
+
layout = Layout()
|
|
92
|
+
layout.split_column(
|
|
93
|
+
Layout(name="header", size=4),
|
|
94
|
+
Layout(name="body", ratio=1),
|
|
95
|
+
Layout(name="footer", size=10),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Header panel
|
|
99
|
+
duration = time.time() - self.start_time
|
|
100
|
+
status_colors = {
|
|
101
|
+
"PENDING": "grey50",
|
|
102
|
+
"RUNNING": "bold blue",
|
|
103
|
+
"SUCCESS": "bold green",
|
|
104
|
+
"FAILED": "bold red",
|
|
105
|
+
}
|
|
106
|
+
status_color = status_colors.get(self.run.status, "white")
|
|
107
|
+
|
|
108
|
+
header_text = Text.assemble(
|
|
109
|
+
("โก PYRELAY WORKFLOW RUNNER ", "bold magenta"),
|
|
110
|
+
(f"| Workflow: ", "dim"),
|
|
111
|
+
(f"{self.run.workflow.name} ", "bold white"),
|
|
112
|
+
(f"| Status: ", "dim"),
|
|
113
|
+
(f"{self.run.status} ", status_color),
|
|
114
|
+
(f"| Elapsed: ", "dim"),
|
|
115
|
+
(f"{duration:.2f}s", "bold yellow"),
|
|
116
|
+
)
|
|
117
|
+
layout["header"].update(Panel(header_text, border_style="magenta"))
|
|
118
|
+
|
|
119
|
+
# Body - Tasks Table
|
|
120
|
+
table = Table(expand=True, border_style="dim")
|
|
121
|
+
table.add_column("Task ID", style="dim", width=10)
|
|
122
|
+
table.add_column("Task Name", style="bold white")
|
|
123
|
+
table.add_column("Status", width=12)
|
|
124
|
+
table.add_column("Duration", justify="right", width=10)
|
|
125
|
+
table.add_column("Attempts", justify="right", width=8)
|
|
126
|
+
table.add_column("Predecessors", style="dim")
|
|
127
|
+
table.add_column("Result / Error", ratio=1)
|
|
128
|
+
|
|
129
|
+
status_styles = {
|
|
130
|
+
TaskStatus.PENDING: "[grey50]PENDING[/]",
|
|
131
|
+
TaskStatus.RUNNING: "[bold blue]RUNNING[/]",
|
|
132
|
+
TaskStatus.SUCCESS: "[bold green]SUCCESS[/]",
|
|
133
|
+
TaskStatus.FAILED: "[bold red]FAILED[/]",
|
|
134
|
+
TaskStatus.SKIPPED: "[bold yellow]SKIPPED[/]",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for task_id, t in self.run.tasks.items():
|
|
138
|
+
duration_str = f"{t.duration:.2f}s" if t.duration > 0 else "-"
|
|
139
|
+
status_str = status_styles.get(t.status, str(t.status))
|
|
140
|
+
|
|
141
|
+
# Show output or error summary
|
|
142
|
+
out_str = ""
|
|
143
|
+
if t.status == TaskStatus.SUCCESS:
|
|
144
|
+
out_str = f"[green]{str(t.result)[:50]}[/]"
|
|
145
|
+
if len(str(t.result)) > 50:
|
|
146
|
+
out_str += "..."
|
|
147
|
+
elif t.status == TaskStatus.FAILED:
|
|
148
|
+
out_str = f"[red]{t.error}[/]"
|
|
149
|
+
elif t.status == TaskStatus.SKIPPED:
|
|
150
|
+
out_str = "[yellow]Dependency failed[/]"
|
|
151
|
+
|
|
152
|
+
# Map predecessor names
|
|
153
|
+
preds = []
|
|
154
|
+
for p_id in t.predecessors:
|
|
155
|
+
if p_id in self.run.tasks:
|
|
156
|
+
preds.append(self.run.tasks[p_id].name)
|
|
157
|
+
preds_str = ", ".join(preds) if preds else "-"
|
|
158
|
+
|
|
159
|
+
table.add_row(
|
|
160
|
+
t.id[:8],
|
|
161
|
+
t.name,
|
|
162
|
+
status_str,
|
|
163
|
+
duration_str,
|
|
164
|
+
str(t.attempts),
|
|
165
|
+
preds_str,
|
|
166
|
+
out_str,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
layout["body"].update(Panel(table, title="[bold cyan]Execution Graph Nodes[/]", border_style="cyan"))
|
|
170
|
+
|
|
171
|
+
# Footer - Live Logs
|
|
172
|
+
logs_text = Text()
|
|
173
|
+
for log_line in self.logs:
|
|
174
|
+
if "โ
" in log_line or "success" in log_line.lower():
|
|
175
|
+
logs_text.append(log_line + "\n", style="green")
|
|
176
|
+
elif "โ" in log_line or "fail" in log_line.lower():
|
|
177
|
+
logs_text.append(log_line + "\n", style="red")
|
|
178
|
+
elif "โ ๏ธ" in log_line or "retry" in log_line.lower():
|
|
179
|
+
logs_text.append(log_line + "\n", style="yellow")
|
|
180
|
+
elif "๐" in log_line:
|
|
181
|
+
logs_text.append(log_line + "\n", style="bold white")
|
|
182
|
+
else:
|
|
183
|
+
logs_text.append(log_line + "\n", style="dim white")
|
|
184
|
+
|
|
185
|
+
layout["footer"].update(Panel(logs_text, title="[bold white]Real-Time Log Stream[/]", border_style="dim"))
|
|
186
|
+
return layout
|
|
187
|
+
|
|
188
|
+
def _print_final_summary(self) -> None:
|
|
189
|
+
"""Prints a clean, polished console summary table after execution completes."""
|
|
190
|
+
self.console.print("\n")
|
|
191
|
+
self.console.print(Panel(
|
|
192
|
+
Text.assemble(
|
|
193
|
+
("๐ Workflow execution finished. ", "bold green" if self.run.status == "SUCCESS" else "bold red"),
|
|
194
|
+
(f"Total time: {self.run.duration:.2f}s", "yellow")
|
|
195
|
+
),
|
|
196
|
+
border_style="green" if self.run.status == "SUCCESS" else "red"
|
|
197
|
+
))
|
|
198
|
+
|
|
199
|
+
table = Table(title="Execution Summary", show_header=True, header_style="bold magenta")
|
|
200
|
+
table.add_column("Task Name", style="bold white")
|
|
201
|
+
table.add_column("Status")
|
|
202
|
+
table.add_column("Duration")
|
|
203
|
+
table.add_column("Result / Error Summary", ratio=1)
|
|
204
|
+
|
|
205
|
+
status_colors = {
|
|
206
|
+
TaskStatus.SUCCESS: "green",
|
|
207
|
+
TaskStatus.FAILED: "red",
|
|
208
|
+
TaskStatus.SKIPPED: "yellow",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for t in self.run.tasks.values():
|
|
212
|
+
color = status_colors.get(t.status, "white")
|
|
213
|
+
status_text = Text(t.status.value, style=f"bold {color}")
|
|
214
|
+
|
|
215
|
+
result_summary = ""
|
|
216
|
+
if t.status == TaskStatus.SUCCESS:
|
|
217
|
+
result_summary = str(t.result)[:100]
|
|
218
|
+
if len(str(t.result)) > 100:
|
|
219
|
+
result_summary += "..."
|
|
220
|
+
elif t.status == TaskStatus.FAILED:
|
|
221
|
+
result_summary = t.error or "Unknown error"
|
|
222
|
+
|
|
223
|
+
table.add_row(
|
|
224
|
+
t.name,
|
|
225
|
+
status_text,
|
|
226
|
+
f"{t.duration:.2f}s" if t.duration > 0 else "-",
|
|
227
|
+
result_summary
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
self.console.print(table)
|
|
231
|
+
self.console.print("\n")
|
pyrelay/engine.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import inspect
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Callable, Dict, Tuple
|
|
6
|
+
from .core import Task, TaskInstance, TaskStatus, WorkflowRun, active_run_context
|
|
7
|
+
from .exceptions import TaskFailedError, TaskTimeoutError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExecutionEngine:
|
|
11
|
+
"""Manages scheduling, timeouts, and state-machine transitions for task runs."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, run: WorkflowRun):
|
|
14
|
+
self.workflow_run = run
|
|
15
|
+
self.workflow_run.executor_hook = self.execute_task
|
|
16
|
+
|
|
17
|
+
async def run(self, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None:
|
|
18
|
+
self.workflow_run.status = "RUNNING"
|
|
19
|
+
self.workflow_run.start_time = datetime.now()
|
|
20
|
+
self.workflow_run.emit_event("workflow_start", self.workflow_run)
|
|
21
|
+
|
|
22
|
+
token = active_run_context.set(self.workflow_run)
|
|
23
|
+
try:
|
|
24
|
+
if asyncio.iscoroutinefunction(self.workflow_run.workflow.func):
|
|
25
|
+
result = await self.workflow_run.workflow.func(*args, **kwargs)
|
|
26
|
+
else:
|
|
27
|
+
result = self.workflow_run.workflow.func(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
self.workflow_run.status = "SUCCESS"
|
|
30
|
+
self.workflow_run.result = result
|
|
31
|
+
self.workflow_run.emit_event("workflow_success", self.workflow_run)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
self.workflow_run.status = "FAILED"
|
|
34
|
+
self.workflow_run.error = f"{type(e).__name__}: {str(e)}"
|
|
35
|
+
self.workflow_run.emit_event("workflow_failure", self.workflow_run)
|
|
36
|
+
raise e
|
|
37
|
+
finally:
|
|
38
|
+
self.workflow_run.end_time = datetime.now()
|
|
39
|
+
if self.workflow_run.start_time:
|
|
40
|
+
self.workflow_run.duration = (self.workflow_run.end_time - self.workflow_run.start_time).total_seconds()
|
|
41
|
+
active_run_context.reset(token)
|
|
42
|
+
self.workflow_run.emit_event("workflow_end", self.workflow_run)
|
|
43
|
+
|
|
44
|
+
def execute_task(self, task: Task, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any:
|
|
45
|
+
is_workflow_async = asyncio.iscoroutinefunction(self.workflow_run.workflow.func)
|
|
46
|
+
if is_workflow_async:
|
|
47
|
+
return self._async_task_wrapper(task, args, kwargs)
|
|
48
|
+
return self._sync_task_wrapper(task, args, kwargs)
|
|
49
|
+
|
|
50
|
+
async def _async_task_wrapper(self, task: Task, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any:
|
|
51
|
+
task_instance = TaskInstance(task, self.workflow_run.id)
|
|
52
|
+
task_instance.predecessors = self.workflow_run.get_frontier()
|
|
53
|
+
self.workflow_run.tasks[task_instance.id] = task_instance
|
|
54
|
+
self.workflow_run.emit_event("task_pending", task_instance)
|
|
55
|
+
|
|
56
|
+
inputs = self._bind_arguments(task.func, args, kwargs)
|
|
57
|
+
task_instance.start(inputs)
|
|
58
|
+
self.workflow_run.emit_event("task_start", task_instance)
|
|
59
|
+
|
|
60
|
+
delay = task.retry_delay
|
|
61
|
+
last_exception = None
|
|
62
|
+
|
|
63
|
+
for attempt in range(max(1, task.retries + 1)):
|
|
64
|
+
if attempt > 0:
|
|
65
|
+
task_instance.attempts += 1
|
|
66
|
+
task_instance.status = TaskStatus.RUNNING
|
|
67
|
+
self.workflow_run.emit_event("task_retry", task_instance)
|
|
68
|
+
await asyncio.sleep(delay)
|
|
69
|
+
delay *= task.backoff_factor
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
if task.is_coroutine:
|
|
73
|
+
if task.timeout is not None:
|
|
74
|
+
result = await asyncio.wait_for(
|
|
75
|
+
task.func(*args, **kwargs), timeout=task.timeout
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
result = await task.func(*args, **kwargs)
|
|
79
|
+
else:
|
|
80
|
+
# Offload sync functions to an executor thread to avoid blocking the event loop
|
|
81
|
+
loop = asyncio.get_running_loop()
|
|
82
|
+
if task.timeout is not None:
|
|
83
|
+
result = await asyncio.wait_for(
|
|
84
|
+
loop.run_in_executor(None, lambda: task.func(*args, **kwargs)),
|
|
85
|
+
timeout=task.timeout
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
result = await loop.run_in_executor(None, lambda: task.func(*args, **kwargs))
|
|
89
|
+
|
|
90
|
+
task_instance.complete(result)
|
|
91
|
+
self.workflow_run.emit_event("task_success", task_instance)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
except asyncio.TimeoutError:
|
|
95
|
+
last_exception = TaskTimeoutError(task.name, task.timeout or 0)
|
|
96
|
+
task_instance.fail(last_exception)
|
|
97
|
+
self.workflow_run.emit_event("task_timeout", task_instance)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
last_exception = e
|
|
100
|
+
task_instance.fail(e)
|
|
101
|
+
self.workflow_run.emit_event("task_fail", task_instance)
|
|
102
|
+
|
|
103
|
+
final_err = TaskFailedError(task.name, last_exception or Exception("Unknown error"))
|
|
104
|
+
self._cascade_skipped_tasks(task_instance.id)
|
|
105
|
+
raise final_err
|
|
106
|
+
|
|
107
|
+
def _sync_task_wrapper(self, task: Task, args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any:
|
|
108
|
+
task_instance = TaskInstance(task, self.workflow_run.id)
|
|
109
|
+
task_instance.predecessors = self.workflow_run.get_frontier()
|
|
110
|
+
self.workflow_run.tasks[task_instance.id] = task_instance
|
|
111
|
+
self.workflow_run.emit_event("task_pending", task_instance)
|
|
112
|
+
|
|
113
|
+
inputs = self._bind_arguments(task.func, args, kwargs)
|
|
114
|
+
task_instance.start(inputs)
|
|
115
|
+
self.workflow_run.emit_event("task_start", task_instance)
|
|
116
|
+
|
|
117
|
+
delay = task.retry_delay
|
|
118
|
+
last_exception = None
|
|
119
|
+
|
|
120
|
+
for attempt in range(max(1, task.retries + 1)):
|
|
121
|
+
if attempt > 0:
|
|
122
|
+
task_instance.attempts += 1
|
|
123
|
+
task_instance.status = TaskStatus.RUNNING
|
|
124
|
+
self.workflow_run.emit_event("task_retry", task_instance)
|
|
125
|
+
time.sleep(delay)
|
|
126
|
+
delay *= task.backoff_factor
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
if task.timeout is not None:
|
|
130
|
+
# Sync timeouts are checked by joining a helper execution thread
|
|
131
|
+
import threading
|
|
132
|
+
result_container = []
|
|
133
|
+
error_container = []
|
|
134
|
+
|
|
135
|
+
def target():
|
|
136
|
+
try:
|
|
137
|
+
res = task.func(*args, **kwargs)
|
|
138
|
+
result_container.append(res)
|
|
139
|
+
except Exception as ex:
|
|
140
|
+
error_container.append(ex)
|
|
141
|
+
|
|
142
|
+
t = threading.Thread(target=target)
|
|
143
|
+
t.start()
|
|
144
|
+
t.join(timeout=task.timeout)
|
|
145
|
+
|
|
146
|
+
if t.is_alive():
|
|
147
|
+
raise TaskTimeoutError(task.name, task.timeout)
|
|
148
|
+
if error_container:
|
|
149
|
+
raise error_container[0]
|
|
150
|
+
result = result_container[0]
|
|
151
|
+
else:
|
|
152
|
+
result = task.func(*args, **kwargs)
|
|
153
|
+
|
|
154
|
+
task_instance.complete(result)
|
|
155
|
+
self.workflow_run.emit_event("task_success", task_instance)
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
except TaskTimeoutError as te:
|
|
159
|
+
last_exception = te
|
|
160
|
+
task_instance.fail(te)
|
|
161
|
+
self.workflow_run.emit_event("task_timeout", task_instance)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
last_exception = e
|
|
164
|
+
task_instance.fail(e)
|
|
165
|
+
self.workflow_run.emit_event("task_fail", task_instance)
|
|
166
|
+
|
|
167
|
+
final_err = TaskFailedError(task.name, last_exception or Exception("Unknown error"))
|
|
168
|
+
self._cascade_skipped_tasks(task_instance.id)
|
|
169
|
+
raise final_err
|
|
170
|
+
|
|
171
|
+
def _cascade_skipped_tasks(self, failed_task_id: str) -> None:
|
|
172
|
+
for t_id, t in self.tasks_to_cascade(failed_task_id):
|
|
173
|
+
t.skip()
|
|
174
|
+
self.workflow_run.emit_event("task_skip", t)
|
|
175
|
+
|
|
176
|
+
def tasks_to_cascade(self, parent_id: str) -> list:
|
|
177
|
+
children = []
|
|
178
|
+
for t_id, t in self.workflow_run.tasks.items():
|
|
179
|
+
if parent_id in t.predecessors and t.status == TaskStatus.PENDING:
|
|
180
|
+
children.append((t_id, t))
|
|
181
|
+
children.extend(self.tasks_to_cascade(t_id))
|
|
182
|
+
return children
|
|
183
|
+
|
|
184
|
+
def _bind_arguments(self, func: Callable, args: tuple, kwargs: dict) -> Dict[str, Any]:
|
|
185
|
+
try:
|
|
186
|
+
sig = inspect.signature(func)
|
|
187
|
+
bound = sig.bind(*args, **kwargs)
|
|
188
|
+
bound.apply_defaults()
|
|
189
|
+
return {k: str(v) for k, v in bound.arguments.items()}
|
|
190
|
+
except Exception:
|
|
191
|
+
return {"args": str(args), "kwargs": str(kwargs)}
|
pyrelay/exceptions.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class WorkflowError(Exception):
|
|
2
|
+
"""Base exception for all pyrelay errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskFailedError(WorkflowError):
|
|
7
|
+
"""Raised when a task fails and all retries are exhausted."""
|
|
8
|
+
def __init__(self, task_name: str, exception: Exception):
|
|
9
|
+
super().__init__(f"Task '{task_name}' failed: {exception}")
|
|
10
|
+
self.task_name = task_name
|
|
11
|
+
self.original_exception = exception
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskTimeoutError(WorkflowError):
|
|
15
|
+
"""Raised when a task execution times out."""
|
|
16
|
+
def __init__(self, task_name: str, timeout: float):
|
|
17
|
+
super().__init__(f"Task '{task_name}' timed out after {timeout}s")
|
|
18
|
+
self.task_name = task_name
|
|
19
|
+
self.timeout = timeout
|
pyrelay/visualizer.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
from .core import WorkflowRun, TaskStatus
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HTMLVisualizer:
|
|
8
|
+
"""Generates an interactive web graph for a WorkflowRun using Vis-Network."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, run: WorkflowRun):
|
|
11
|
+
self.run = run
|
|
12
|
+
|
|
13
|
+
def generate_html(self) -> str:
|
|
14
|
+
nodes_data = []
|
|
15
|
+
edges_data = []
|
|
16
|
+
|
|
17
|
+
status_colors = {
|
|
18
|
+
TaskStatus.PENDING.value: {"background": "#374151", "border": "#4b5563", "color": "#9ca3af"},
|
|
19
|
+
TaskStatus.RUNNING.value: {"background": "#1e40af", "border": "#3b82f6", "color": "#eff6ff"},
|
|
20
|
+
TaskStatus.SUCCESS.value: {"background": "#064e3b", "border": "#10b981", "color": "#ecfdf5"},
|
|
21
|
+
TaskStatus.FAILED.value: {"background": "#7f1d1d", "border": "#ef4444", "color": "#fef2f2"},
|
|
22
|
+
TaskStatus.SKIPPED.value: {"background": "#78350f", "border": "#f59e0b", "color": "#fffbeb"},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for t_id, t in self.run.tasks.items():
|
|
26
|
+
colors = status_colors.get(t.status.value, {"background": "#1f2937", "border": "#374151", "color": "#ffffff"})
|
|
27
|
+
|
|
28
|
+
node_label = f"<b>{t.name}</b>\n<i>{t.status.value}</i>"
|
|
29
|
+
if t.duration > 0:
|
|
30
|
+
node_label += f"\n{t.duration:.2f}s"
|
|
31
|
+
|
|
32
|
+
inspect_payload = {
|
|
33
|
+
"id": t.id,
|
|
34
|
+
"name": t.name,
|
|
35
|
+
"status": t.status.value,
|
|
36
|
+
"duration": f"{t.duration:.3f}s" if t.duration > 0 else "-",
|
|
37
|
+
"attempts": t.attempts,
|
|
38
|
+
"start_time": t.start_time.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] if t.start_time else "-",
|
|
39
|
+
"end_time": t.end_time.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] if t.end_time else "-",
|
|
40
|
+
"inputs": t.inputs,
|
|
41
|
+
"result": str(t.result) if t.result is not None else None,
|
|
42
|
+
"error": t.error,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
nodes_data.append({
|
|
46
|
+
"id": t.id,
|
|
47
|
+
"label": node_label,
|
|
48
|
+
"title": f"Task: {t.name} ({t.status.value})",
|
|
49
|
+
"shape": "box",
|
|
50
|
+
"margin": 12,
|
|
51
|
+
"font": {"multi": "html", "color": colors["color"], "size": 14},
|
|
52
|
+
"color": {
|
|
53
|
+
"background": colors["background"],
|
|
54
|
+
"border": colors["border"],
|
|
55
|
+
"highlight": {"background": colors["border"], "border": "#ffffff"}
|
|
56
|
+
},
|
|
57
|
+
"borderWidth": 2,
|
|
58
|
+
"shadow": True,
|
|
59
|
+
"inspect": inspect_payload
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
for pred_id in t.predecessors:
|
|
63
|
+
edge_color = "#4b5563"
|
|
64
|
+
if pred_id in self.run.tasks:
|
|
65
|
+
pred_task = self.run.tasks[pred_id]
|
|
66
|
+
if pred_task.status == TaskStatus.SUCCESS:
|
|
67
|
+
edge_color = "#10b981"
|
|
68
|
+
elif pred_task.status == TaskStatus.FAILED:
|
|
69
|
+
edge_color = "#ef4444"
|
|
70
|
+
|
|
71
|
+
edges_data.append({
|
|
72
|
+
"from": pred_id,
|
|
73
|
+
"to": t.id,
|
|
74
|
+
"arrows": "to",
|
|
75
|
+
"color": {"color": edge_color, "highlight": "#ffffff", "inherit": False},
|
|
76
|
+
"width": 2,
|
|
77
|
+
"smooth": {"type": "cubicBezier", "forceDirection": "horizontal", "roundness": 0.5}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
nodes_json = json.dumps(nodes_data, indent=2)
|
|
81
|
+
edges_json = json.dumps(edges_data, indent=2)
|
|
82
|
+
|
|
83
|
+
html_template = f"""<!DOCTYPE html>
|
|
84
|
+
<html lang="en">
|
|
85
|
+
<head>
|
|
86
|
+
<meta charset="UTF-8">
|
|
87
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
88
|
+
<title>pyrelay DAG Visualizer - {self.run.workflow.name}</title>
|
|
89
|
+
|
|
90
|
+
<!-- Vis Network CSS & JS CDN -->
|
|
91
|
+
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
|
|
92
|
+
|
|
93
|
+
<!-- Google Fonts Outfit -->
|
|
94
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
|
95
|
+
|
|
96
|
+
<style>
|
|
97
|
+
* {{
|
|
98
|
+
box-sizing: border-box;
|
|
99
|
+
margin: 0;
|
|
100
|
+
padding: 0;
|
|
101
|
+
}}
|
|
102
|
+
body {{
|
|
103
|
+
font-family: 'Outfit', sans-serif;
|
|
104
|
+
background-color: #0d0e12;
|
|
105
|
+
color: #e4e4e7;
|
|
106
|
+
height: 100vh;
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
display: flex;
|
|
109
|
+
}}
|
|
110
|
+
#container {{
|
|
111
|
+
flex: 1;
|
|
112
|
+
height: 100%;
|
|
113
|
+
position: relative;
|
|
114
|
+
}}
|
|
115
|
+
#network {{
|
|
116
|
+
width: 100%;
|
|
117
|
+
height: 100%;
|
|
118
|
+
}}
|
|
119
|
+
#header {{
|
|
120
|
+
position: absolute;
|
|
121
|
+
top: 20px;
|
|
122
|
+
left: 20px;
|
|
123
|
+
z-index: 10;
|
|
124
|
+
background: rgba(20, 21, 28, 0.85);
|
|
125
|
+
backdrop-filter: blur(12px);
|
|
126
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
127
|
+
padding: 16px 24px;
|
|
128
|
+
border-radius: 12px;
|
|
129
|
+
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
|
130
|
+
max-width: 400px;
|
|
131
|
+
}}
|
|
132
|
+
#header h1 {{
|
|
133
|
+
font-size: 1.25rem;
|
|
134
|
+
font-weight: 700;
|
|
135
|
+
color: #ff007f;
|
|
136
|
+
margin-bottom: 4px;
|
|
137
|
+
letter-spacing: 0.5px;
|
|
138
|
+
}}
|
|
139
|
+
#header h2 {{
|
|
140
|
+
font-size: 1rem;
|
|
141
|
+
font-weight: 600;
|
|
142
|
+
color: #ffffff;
|
|
143
|
+
margin-bottom: 8px;
|
|
144
|
+
}}
|
|
145
|
+
.meta-item {{
|
|
146
|
+
font-size: 0.85rem;
|
|
147
|
+
margin-bottom: 4px;
|
|
148
|
+
color: #a1a1aa;
|
|
149
|
+
}}
|
|
150
|
+
.meta-value {{
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
color: #f4f4f5;
|
|
153
|
+
}}
|
|
154
|
+
.status-badge {{
|
|
155
|
+
display: inline-block;
|
|
156
|
+
padding: 2px 8px;
|
|
157
|
+
border-radius: 4px;
|
|
158
|
+
font-size: 0.75rem;
|
|
159
|
+
font-weight: 700;
|
|
160
|
+
text-transform: uppercase;
|
|
161
|
+
}}
|
|
162
|
+
.status-SUCCESS {{ background: #064e3b; color: #34d399; border: 1px solid #059669; }}
|
|
163
|
+
.status-FAILED {{ background: #7f1d1d; color: #f87171; border: 1px solid #dc2626; }}
|
|
164
|
+
.status-RUNNING {{ background: #1e40af; color: #60a5fa; border: 1px solid #2563eb; }}
|
|
165
|
+
|
|
166
|
+
#sidebar {{
|
|
167
|
+
width: 400px;
|
|
168
|
+
background-color: #14151c;
|
|
169
|
+
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
|
170
|
+
box-shadow: -8px 0 32px 0 rgba(0, 0, 0, 0.37);
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-column: column;
|
|
173
|
+
flex-direction: column;
|
|
174
|
+
transform: translateX(100%);
|
|
175
|
+
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
176
|
+
position: absolute;
|
|
177
|
+
right: 0;
|
|
178
|
+
top: 0;
|
|
179
|
+
height: 100%;
|
|
180
|
+
z-index: 100;
|
|
181
|
+
}}
|
|
182
|
+
#sidebar.open {{
|
|
183
|
+
transform: translateX(0);
|
|
184
|
+
}}
|
|
185
|
+
#sidebar-header {{
|
|
186
|
+
padding: 24px;
|
|
187
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
188
|
+
display: flex;
|
|
189
|
+
justify-content: space-between;
|
|
190
|
+
align-items: center;
|
|
191
|
+
}}
|
|
192
|
+
#sidebar-title {{
|
|
193
|
+
font-size: 1.25rem;
|
|
194
|
+
font-weight: 700;
|
|
195
|
+
color: #ffffff;
|
|
196
|
+
}}
|
|
197
|
+
#close-btn {{
|
|
198
|
+
background: none;
|
|
199
|
+
border: none;
|
|
200
|
+
color: #a1a1aa;
|
|
201
|
+
font-size: 1.5rem;
|
|
202
|
+
cursor: pointer;
|
|
203
|
+
padding: 4px;
|
|
204
|
+
line-height: 1;
|
|
205
|
+
}}
|
|
206
|
+
#close-btn:hover {{
|
|
207
|
+
color: #ffffff;
|
|
208
|
+
}}
|
|
209
|
+
#sidebar-content {{
|
|
210
|
+
padding: 24px;
|
|
211
|
+
flex: 1;
|
|
212
|
+
overflow-y: auto;
|
|
213
|
+
}}
|
|
214
|
+
.section-title {{
|
|
215
|
+
font-size: 0.8rem;
|
|
216
|
+
font-weight: 700;
|
|
217
|
+
text-transform: uppercase;
|
|
218
|
+
letter-spacing: 1px;
|
|
219
|
+
color: #71717a;
|
|
220
|
+
margin-top: 20px;
|
|
221
|
+
margin-bottom: 8px;
|
|
222
|
+
}}
|
|
223
|
+
.detail-card {{
|
|
224
|
+
background: rgba(255, 255, 255, 0.03);
|
|
225
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
226
|
+
border-radius: 8px;
|
|
227
|
+
padding: 12px;
|
|
228
|
+
margin-bottom: 16px;
|
|
229
|
+
}}
|
|
230
|
+
.pre-container {{
|
|
231
|
+
background: #090a0f;
|
|
232
|
+
border: 1px solid rgba(255, 255, 255, 0.04);
|
|
233
|
+
border-radius: 6px;
|
|
234
|
+
padding: 12px;
|
|
235
|
+
font-family: monospace;
|
|
236
|
+
font-size: 0.85rem;
|
|
237
|
+
overflow-x: auto;
|
|
238
|
+
color: #a5f3fc;
|
|
239
|
+
max-height: 250px;
|
|
240
|
+
}}
|
|
241
|
+
.error-panel {{
|
|
242
|
+
background: rgba(239, 68, 68, 0.1);
|
|
243
|
+
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
244
|
+
color: #fca5a5;
|
|
245
|
+
border-radius: 8px;
|
|
246
|
+
padding: 12px;
|
|
247
|
+
font-family: monospace;
|
|
248
|
+
font-size: 0.85rem;
|
|
249
|
+
margin-top: 10px;
|
|
250
|
+
}}
|
|
251
|
+
#empty-state {{
|
|
252
|
+
display: flex;
|
|
253
|
+
align-items: center;
|
|
254
|
+
justify-content: center;
|
|
255
|
+
height: 100%;
|
|
256
|
+
color: #71717a;
|
|
257
|
+
font-size: 0.95rem;
|
|
258
|
+
text-align: center;
|
|
259
|
+
padding: 40px;
|
|
260
|
+
}}
|
|
261
|
+
.instruction-banner {{
|
|
262
|
+
position: absolute;
|
|
263
|
+
bottom: 20px;
|
|
264
|
+
left: 20px;
|
|
265
|
+
background: rgba(20, 21, 28, 0.7);
|
|
266
|
+
padding: 8px 16px;
|
|
267
|
+
border-radius: 20px;
|
|
268
|
+
font-size: 0.8rem;
|
|
269
|
+
color: #a1a1aa;
|
|
270
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
271
|
+
z-index: 10;
|
|
272
|
+
}}
|
|
273
|
+
</style>
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
<div id="container">
|
|
277
|
+
<div id="header">
|
|
278
|
+
<h1>โก PYRELAY</h1>
|
|
279
|
+
<h2>{self.run.workflow.name}</h2>
|
|
280
|
+
<div class="meta-item">Status: <span class="status-badge status-{self.run.status}">{self.run.status}</span></div>
|
|
281
|
+
<div class="meta-item">Run ID: <span class="meta-value">{self.run.id[:8]}...</span></div>
|
|
282
|
+
<div class="meta-item">Total Duration: <span class="meta-value">{self.run.duration:.3f}s</span></div>
|
|
283
|
+
<div class="meta-item">Tasks Traced: <span class="meta-value">{len(self.run.tasks)}</span></div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<div class="instruction-banner">
|
|
287
|
+
๐ก Click on any task node to inspect inputs, outputs, error traces, and run times.
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<div id="network"></div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div id="sidebar">
|
|
294
|
+
<div id="sidebar-header">
|
|
295
|
+
<span id="sidebar-title">Task Details</span>
|
|
296
|
+
<button id="close-btn" onclick="closeSidebar()">×</button>
|
|
297
|
+
</div>
|
|
298
|
+
<div id="sidebar-content">
|
|
299
|
+
<div id="empty-state">
|
|
300
|
+
Select a task node to view execution telemetry.
|
|
301
|
+
</div>
|
|
302
|
+
<div id="details-view" style="display: none;">
|
|
303
|
+
<div class="meta-item" style="font-size: 1.1rem; color: #fff; margin-bottom: 12px;">
|
|
304
|
+
Task Name: <span id="task-name" style="font-weight: bold; color: #ff007f;">-</span>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<div class="detail-card">
|
|
308
|
+
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
|
309
|
+
<span>Status:</span>
|
|
310
|
+
<span id="task-status" class="status-badge">-</span>
|
|
311
|
+
</div>
|
|
312
|
+
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
|
313
|
+
<span>Duration:</span>
|
|
314
|
+
<span id="task-duration" style="font-weight: 600; color: #fbbf24;">-</span>
|
|
315
|
+
</div>
|
|
316
|
+
<div style="display: flex; justify-content: space-between;">
|
|
317
|
+
<span>Attempts:</span>
|
|
318
|
+
<span id="task-attempts" style="font-weight: 600;">-</span>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div class="section-title">Timeline</div>
|
|
323
|
+
<div class="detail-card" style="font-size: 0.85rem; color: #a1a1aa;">
|
|
324
|
+
<div style="margin-bottom: 6px;">Started: <span id="task-start" style="color: #f4f4f5; font-family: monospace;">-</span></div>
|
|
325
|
+
<div>Finished: <span id="task-end" style="color: #f4f4f5; font-family: monospace;">-</span></div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div class="section-title">Inputs</div>
|
|
329
|
+
<pre id="task-inputs" class="pre-container">-</pre>
|
|
330
|
+
|
|
331
|
+
<div class="section-title">Output / Result</div>
|
|
332
|
+
<pre id="task-result" class="pre-container">-</pre>
|
|
333
|
+
|
|
334
|
+
<div id="error-section" style="display: none;">
|
|
335
|
+
<div class="section-title" style="color: #ef4444;">Exception Trace</div>
|
|
336
|
+
<div id="task-error" class="error-panel">-</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
<script type="text/javascript">
|
|
343
|
+
const nodesData = {nodes_json};
|
|
344
|
+
const edgesData = {edges_json};
|
|
345
|
+
|
|
346
|
+
const nodes = new vis.DataSet(nodesData);
|
|
347
|
+
const edges = new vis.DataSet(edgesData);
|
|
348
|
+
|
|
349
|
+
const container = document.getElementById('network');
|
|
350
|
+
const data = {{ nodes: nodes, edges: edges }};
|
|
351
|
+
|
|
352
|
+
const options = {{
|
|
353
|
+
nodes: {{
|
|
354
|
+
shape: 'box',
|
|
355
|
+
font: {{
|
|
356
|
+
face: 'Outfit',
|
|
357
|
+
}}
|
|
358
|
+
}},
|
|
359
|
+
edges: {{
|
|
360
|
+
font: {{
|
|
361
|
+
face: 'Outfit'
|
|
362
|
+
}}
|
|
363
|
+
}},
|
|
364
|
+
layout: {{
|
|
365
|
+
hierarchical: {{
|
|
366
|
+
enabled: true,
|
|
367
|
+
direction: 'LR',
|
|
368
|
+
sortMethod: 'directed',
|
|
369
|
+
nodeSpacing: 180,
|
|
370
|
+
levelSeparation: 250,
|
|
371
|
+
parentCentralization: true
|
|
372
|
+
}}
|
|
373
|
+
}},
|
|
374
|
+
physics: {{
|
|
375
|
+
hierarchicalRepulsion: {{
|
|
376
|
+
nodeDistance: 200
|
|
377
|
+
}}
|
|
378
|
+
}},
|
|
379
|
+
interaction: {{
|
|
380
|
+
hover: true,
|
|
381
|
+
tooltipDelay: 200
|
|
382
|
+
}}
|
|
383
|
+
}};
|
|
384
|
+
|
|
385
|
+
const network = new vis.Network(container, data, options);
|
|
386
|
+
|
|
387
|
+
const sidebar = document.getElementById('sidebar');
|
|
388
|
+
const emptyState = document.getElementById('empty-state');
|
|
389
|
+
const detailsView = document.getElementById('details-view');
|
|
390
|
+
|
|
391
|
+
network.on("click", function(params) {{
|
|
392
|
+
if (params.nodes.length > 0) {{
|
|
393
|
+
const nodeId = params.nodes[0];
|
|
394
|
+
const node = nodes.get(nodeId);
|
|
395
|
+
|
|
396
|
+
if (node && node.inspect) {{
|
|
397
|
+
showDetails(node.inspect);
|
|
398
|
+
}}
|
|
399
|
+
}} else {{
|
|
400
|
+
closeSidebar();
|
|
401
|
+
}}
|
|
402
|
+
}});
|
|
403
|
+
|
|
404
|
+
function showDetails(inspect) {{
|
|
405
|
+
emptyState.style.display = 'none';
|
|
406
|
+
detailsView.style.display = 'block';
|
|
407
|
+
sidebar.classList.add('open');
|
|
408
|
+
|
|
409
|
+
document.getElementById('task-name').textContent = inspect.name;
|
|
410
|
+
|
|
411
|
+
const statusBadge = document.getElementById('task-status');
|
|
412
|
+
statusBadge.className = 'status-badge status-' + inspect.status;
|
|
413
|
+
statusBadge.textContent = inspect.status;
|
|
414
|
+
|
|
415
|
+
document.getElementById('task-duration').textContent = inspect.duration;
|
|
416
|
+
document.getElementById('task-attempts').textContent = inspect.attempts;
|
|
417
|
+
document.getElementById('task-start').textContent = inspect.start_time;
|
|
418
|
+
document.getElementById('task-end').textContent = inspect.end_time;
|
|
419
|
+
|
|
420
|
+
const inputsElem = document.getElementById('task-inputs');
|
|
421
|
+
try {{
|
|
422
|
+
inputsElem.textContent = JSON.stringify(inspect.inputs, null, 2);
|
|
423
|
+
}} catch(e) {{
|
|
424
|
+
inputsElem.textContent = String(inspect.inputs);
|
|
425
|
+
}}
|
|
426
|
+
|
|
427
|
+
const resultElem = document.getElementById('task-result');
|
|
428
|
+
resultElem.textContent = inspect.result !== null ? inspect.result : 'None';
|
|
429
|
+
|
|
430
|
+
const errorSection = document.getElementById('error-section');
|
|
431
|
+
if (inspect.error) {{
|
|
432
|
+
errorSection.style.display = 'block';
|
|
433
|
+
document.getElementById('task-error').textContent = inspect.error;
|
|
434
|
+
}} else {{
|
|
435
|
+
errorSection.style.display = 'none';
|
|
436
|
+
}}
|
|
437
|
+
}}
|
|
438
|
+
|
|
439
|
+
function closeSidebar() {{
|
|
440
|
+
sidebar.classList.remove('open');
|
|
441
|
+
}}
|
|
442
|
+
</script>
|
|
443
|
+
</body>
|
|
444
|
+
</html>
|
|
445
|
+
"""
|
|
446
|
+
return html_template
|
|
447
|
+
|
|
448
|
+
def save(self, filepath: str) -> None:
|
|
449
|
+
directory = os.path.dirname(filepath)
|
|
450
|
+
if directory and not os.path.exists(directory):
|
|
451
|
+
os.makedirs(directory, exist_ok=True)
|
|
452
|
+
|
|
453
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
454
|
+
f.write(self.generate_html())
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def visualize_run(self: WorkflowRun, filepath: str) -> None:
|
|
458
|
+
visualizer = HTMLVisualizer(self)
|
|
459
|
+
visualizer.save(filepath)
|
|
460
|
+
|
|
461
|
+
WorkflowRun.visualize = visualize_run
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyrelay-workflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, async-first Python workflow engine with dynamic branching and rich visual dashboarding.
|
|
5
|
+
Author-email: Developer <developer@example.com>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: rich>=12.0.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
19
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# โก pyrelay
|
|
23
|
+
|
|
24
|
+
[](https://pypi.org/project/pyrelay/)
|
|
25
|
+
[](https://www.python.org/)
|
|
26
|
+
[](https://opensource.org/licenses/MIT)
|
|
27
|
+
|
|
28
|
+
`pyrelay` is a lightweight, high-performance, async-first Python workflow engine. It allows developers to model, execute, and monitor complex task pipelines with native pythonic flows (`if/else` branching), zero complex DSLs, live terminal dashboards, and interactive web visualizers.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## โจ Features
|
|
33
|
+
|
|
34
|
+
- **๐ Native Python Branching**: Use standard Python `if/else` and loops. The execution graph is discovered dynamically at runtime.
|
|
35
|
+
- **โก Async-First Architecture**: Built natively on top of Python `asyncio`. Executes independent tasks concurrently.
|
|
36
|
+
- **๐งต Auto-Offloading for Sync Tasks**: Run blocking synchronous functions without stalling the async event loop (automatically offloaded to thread executors).
|
|
37
|
+
- **โฑ๏ธ Fault Tolerance**: Custom task-level retry policies, exponential backoff, and timeouts out-of-the-box.
|
|
38
|
+
- **๐ฅ๏ธ Live Console Dashboard**: Animated status trees, progress bars, and log streams in your terminal powered by `rich`.
|
|
39
|
+
- **๐ Draggable HTML/JS Visualizer**: Compile any workflow run into a standalone dark-mode HTML file containing a responsive network chart powered by Vis-Network.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## ๐ฆ Installation
|
|
44
|
+
|
|
45
|
+
Initialize your project and install `pyrelay`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install pyrelay
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
*(Requires Python 3.9+)*
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## ๐ Quickstart
|
|
56
|
+
|
|
57
|
+
Create a workflow by wrapping functions with `@task` and `@workflow`. Awaited tasks automatically map dependencies based on their execution order!
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
from pyrelay import task, workflow, WorkflowRun
|
|
62
|
+
from pyrelay.dashboard import ConsoleDashboard
|
|
63
|
+
|
|
64
|
+
@task(retries=2, retry_delay=0.5)
|
|
65
|
+
async def fetch_user_data(user_id: int):
|
|
66
|
+
await asyncio.sleep(0.5)
|
|
67
|
+
return {"id": user_id, "name": "Alice", "status": "active"}
|
|
68
|
+
|
|
69
|
+
@task()
|
|
70
|
+
async def process_account(user: dict):
|
|
71
|
+
await asyncio.sleep(0.3)
|
|
72
|
+
return f"Account processed for {user['name']}"
|
|
73
|
+
|
|
74
|
+
@workflow(name="Account Pipeline")
|
|
75
|
+
async def account_flow(user_id: int):
|
|
76
|
+
user = await fetch_user_data(user_id)
|
|
77
|
+
result = await process_account(user)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
run = WorkflowRun(account_flow)
|
|
82
|
+
|
|
83
|
+
# Attach the live CLI dashboard
|
|
84
|
+
ConsoleDashboard(run)
|
|
85
|
+
|
|
86
|
+
# Run the workflow blocking/sync
|
|
87
|
+
run.run_sync(user_id=101)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## ๐ฟ Dynamic Branching (Option B)
|
|
93
|
+
|
|
94
|
+
In `pyrelay`, branching is simply Python. The engine tracks exactly which path was taken and marks skipped branches appropriately in reports.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
@task()
|
|
98
|
+
async def process_premium_user(user: dict):
|
|
99
|
+
return "VIP treatment applied"
|
|
100
|
+
|
|
101
|
+
@task()
|
|
102
|
+
async def process_standard_user(user: dict):
|
|
103
|
+
return "Standard access configured"
|
|
104
|
+
|
|
105
|
+
@workflow(name="User Routing")
|
|
106
|
+
async def user_routing_flow(user: dict):
|
|
107
|
+
# Branching happens at runtime based on real data values
|
|
108
|
+
if user["is_premium"]:
|
|
109
|
+
result = await process_premium_user(user)
|
|
110
|
+
else:
|
|
111
|
+
result = await process_standard_user(user)
|
|
112
|
+
return result
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## ๐จ Visualization Telemetry
|
|
118
|
+
|
|
119
|
+
Generating an interactive, visual representation of your executed workflow is a single method call:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# Save interactive HTML dashboard
|
|
123
|
+
run.visualize("workflow_run.html")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Open `workflow_run.html` in any browser to get a premium dark-mode interface where you can:
|
|
127
|
+
1. Pan and zoom around the execution DAG.
|
|
128
|
+
2. Review node colors indicating statuses: **Green (Success)**, **Red (Failed)**, **Blue (Running)**, **Grey (Pending)**.
|
|
129
|
+
3. Click any node to slide open a telemetry inspect panel containing:
|
|
130
|
+
- Inputs and outputs
|
|
131
|
+
- Time elapsed, start/end timestamps
|
|
132
|
+
- Retry counts and exceptions
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## โ๏ธ Configuration Properties
|
|
137
|
+
|
|
138
|
+
The `@task` decorator accepts the following metadata options:
|
|
139
|
+
|
|
140
|
+
| Property | Type | Default | Description |
|
|
141
|
+
| :--- | :--- | :--- | :--- |
|
|
142
|
+
| `name` | `str` | *Function Name* | Identifier for the task (used in dashboard and graphs). |
|
|
143
|
+
| `retries` | `int` | `0` | Number of attempts to retry if the function raises an exception. |
|
|
144
|
+
| `retry_delay` | `float` | `1.0` | Initial delay (seconds) before attempting a retry. |
|
|
145
|
+
| `backoff_factor`| `float` | `2.0` | Exponential multiplier for consecutive retries (delay * backoff_factor^attempt). |
|
|
146
|
+
| `timeout` | `float` | `None` | Max seconds before cancelling execution and marking the task as failed. |
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## ๐งช Running Tests
|
|
151
|
+
|
|
152
|
+
To run the unit and integration test suite, install development dependencies and run `pytest`:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
pip install -e ".[dev]"
|
|
156
|
+
pytest tests/
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## ๐ License
|
|
162
|
+
|
|
163
|
+
Distributed under the MIT License. See `LICENSE` for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyrelay/__init__.py,sha256=4sbmriHWaWkETyLrSzKHYJkN6Y9iioCMKOPS1qnLHE0,333
|
|
2
|
+
pyrelay/core.py,sha256=DqCj4SH4tpaQSIig5LDNIHQH8O_xg98iLXWiwwdy_Qc,7317
|
|
3
|
+
pyrelay/dashboard.py,sha256=ne7-nfUp4wCgfa-pdJAgSV6ZaF6lIbOTkWLTgMupxt8,8978
|
|
4
|
+
pyrelay/engine.py,sha256=HDMjR99MSeXiEkbbfkonBB5pp4w6eCgpEPblecRr-9s,8452
|
|
5
|
+
pyrelay/exceptions.py,sha256=biT9hm5TtDcU1SY316WLC6PX70TVEB3hG0G0s3nanIM,693
|
|
6
|
+
pyrelay/visualizer.py,sha256=6LcRUp-xsoVCSzij-P5NXhXFOjdksmpMqnr5w6sts94,16752
|
|
7
|
+
pyrelay_workflow-0.1.0.dist-info/licenses/LICENSE,sha256=c86TVJSMxIYa5MVAOiXlAkWw5dxf4my46bpPHew0jPA,1066
|
|
8
|
+
pyrelay_workflow-0.1.0.dist-info/METADATA,sha256=fnI6_LJ82qUEaaNP_WpAqGc-RgNZRQbl4vL0SCcOwGg,5820
|
|
9
|
+
pyrelay_workflow-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
pyrelay_workflow-0.1.0.dist-info/top_level.txt,sha256=8HaSBt3fHoKosrbC0-WDSG9L2dJD3CTFNrusjhly9rs,8
|
|
11
|
+
pyrelay_workflow-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Developer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyrelay
|