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 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()">&times;</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
+ [![PyPI Version](https://img.shields.io/badge/pypi-v0.1.0-blue.svg)](https://pypi.org/project/pyrelay/)
25
+ [![Python versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue.svg)](https://www.python.org/)
26
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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