pyrelay-workflow 0.1.0__tar.gz
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_workflow-0.1.0/LICENSE +21 -0
- pyrelay_workflow-0.1.0/PKG-INFO +163 -0
- pyrelay_workflow-0.1.0/README.md +142 -0
- pyrelay_workflow-0.1.0/pyproject.toml +35 -0
- pyrelay_workflow-0.1.0/setup.cfg +4 -0
- pyrelay_workflow-0.1.0/src/pyrelay/__init__.py +14 -0
- pyrelay_workflow-0.1.0/src/pyrelay/core.py +220 -0
- pyrelay_workflow-0.1.0/src/pyrelay/dashboard.py +231 -0
- pyrelay_workflow-0.1.0/src/pyrelay/engine.py +191 -0
- pyrelay_workflow-0.1.0/src/pyrelay/exceptions.py +19 -0
- pyrelay_workflow-0.1.0/src/pyrelay/visualizer.py +461 -0
- pyrelay_workflow-0.1.0/src/pyrelay_workflow.egg-info/PKG-INFO +163 -0
- pyrelay_workflow-0.1.0/src/pyrelay_workflow.egg-info/SOURCES.txt +15 -0
- pyrelay_workflow-0.1.0/src/pyrelay_workflow.egg-info/dependency_links.txt +1 -0
- pyrelay_workflow-0.1.0/src/pyrelay_workflow.egg-info/requires.txt +7 -0
- pyrelay_workflow-0.1.0/src/pyrelay_workflow.egg-info/top_level.txt +1 -0
- pyrelay_workflow-0.1.0/tests/test_engine.py +146 -0
|
@@ -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,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,142 @@
|
|
|
1
|
+
# โก pyrelay
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pyrelay/)
|
|
4
|
+
[](https://www.python.org/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
`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.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## โจ Features
|
|
12
|
+
|
|
13
|
+
- **๐ Native Python Branching**: Use standard Python `if/else` and loops. The execution graph is discovered dynamically at runtime.
|
|
14
|
+
- **โก Async-First Architecture**: Built natively on top of Python `asyncio`. Executes independent tasks concurrently.
|
|
15
|
+
- **๐งต Auto-Offloading for Sync Tasks**: Run blocking synchronous functions without stalling the async event loop (automatically offloaded to thread executors).
|
|
16
|
+
- **โฑ๏ธ Fault Tolerance**: Custom task-level retry policies, exponential backoff, and timeouts out-of-the-box.
|
|
17
|
+
- **๐ฅ๏ธ Live Console Dashboard**: Animated status trees, progress bars, and log streams in your terminal powered by `rich`.
|
|
18
|
+
- **๐ Draggable HTML/JS Visualizer**: Compile any workflow run into a standalone dark-mode HTML file containing a responsive network chart powered by Vis-Network.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## ๐ฆ Installation
|
|
23
|
+
|
|
24
|
+
Initialize your project and install `pyrelay`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install pyrelay
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
*(Requires Python 3.9+)*
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## ๐ Quickstart
|
|
35
|
+
|
|
36
|
+
Create a workflow by wrapping functions with `@task` and `@workflow`. Awaited tasks automatically map dependencies based on their execution order!
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from pyrelay import task, workflow, WorkflowRun
|
|
41
|
+
from pyrelay.dashboard import ConsoleDashboard
|
|
42
|
+
|
|
43
|
+
@task(retries=2, retry_delay=0.5)
|
|
44
|
+
async def fetch_user_data(user_id: int):
|
|
45
|
+
await asyncio.sleep(0.5)
|
|
46
|
+
return {"id": user_id, "name": "Alice", "status": "active"}
|
|
47
|
+
|
|
48
|
+
@task()
|
|
49
|
+
async def process_account(user: dict):
|
|
50
|
+
await asyncio.sleep(0.3)
|
|
51
|
+
return f"Account processed for {user['name']}"
|
|
52
|
+
|
|
53
|
+
@workflow(name="Account Pipeline")
|
|
54
|
+
async def account_flow(user_id: int):
|
|
55
|
+
user = await fetch_user_data(user_id)
|
|
56
|
+
result = await process_account(user)
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
run = WorkflowRun(account_flow)
|
|
61
|
+
|
|
62
|
+
# Attach the live CLI dashboard
|
|
63
|
+
ConsoleDashboard(run)
|
|
64
|
+
|
|
65
|
+
# Run the workflow blocking/sync
|
|
66
|
+
run.run_sync(user_id=101)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## ๐ฟ Dynamic Branching (Option B)
|
|
72
|
+
|
|
73
|
+
In `pyrelay`, branching is simply Python. The engine tracks exactly which path was taken and marks skipped branches appropriately in reports.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
@task()
|
|
77
|
+
async def process_premium_user(user: dict):
|
|
78
|
+
return "VIP treatment applied"
|
|
79
|
+
|
|
80
|
+
@task()
|
|
81
|
+
async def process_standard_user(user: dict):
|
|
82
|
+
return "Standard access configured"
|
|
83
|
+
|
|
84
|
+
@workflow(name="User Routing")
|
|
85
|
+
async def user_routing_flow(user: dict):
|
|
86
|
+
# Branching happens at runtime based on real data values
|
|
87
|
+
if user["is_premium"]:
|
|
88
|
+
result = await process_premium_user(user)
|
|
89
|
+
else:
|
|
90
|
+
result = await process_standard_user(user)
|
|
91
|
+
return result
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## ๐จ Visualization Telemetry
|
|
97
|
+
|
|
98
|
+
Generating an interactive, visual representation of your executed workflow is a single method call:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
# Save interactive HTML dashboard
|
|
102
|
+
run.visualize("workflow_run.html")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Open `workflow_run.html` in any browser to get a premium dark-mode interface where you can:
|
|
106
|
+
1. Pan and zoom around the execution DAG.
|
|
107
|
+
2. Review node colors indicating statuses: **Green (Success)**, **Red (Failed)**, **Blue (Running)**, **Grey (Pending)**.
|
|
108
|
+
3. Click any node to slide open a telemetry inspect panel containing:
|
|
109
|
+
- Inputs and outputs
|
|
110
|
+
- Time elapsed, start/end timestamps
|
|
111
|
+
- Retry counts and exceptions
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## โ๏ธ Configuration Properties
|
|
116
|
+
|
|
117
|
+
The `@task` decorator accepts the following metadata options:
|
|
118
|
+
|
|
119
|
+
| Property | Type | Default | Description |
|
|
120
|
+
| :--- | :--- | :--- | :--- |
|
|
121
|
+
| `name` | `str` | *Function Name* | Identifier for the task (used in dashboard and graphs). |
|
|
122
|
+
| `retries` | `int` | `0` | Number of attempts to retry if the function raises an exception. |
|
|
123
|
+
| `retry_delay` | `float` | `1.0` | Initial delay (seconds) before attempting a retry. |
|
|
124
|
+
| `backoff_factor`| `float` | `2.0` | Exponential multiplier for consecutive retries (delay * backoff_factor^attempt). |
|
|
125
|
+
| `timeout` | `float` | `None` | Max seconds before cancelling execution and marking the task as failed. |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## ๐งช Running Tests
|
|
130
|
+
|
|
131
|
+
To run the unit and integration test suite, install development dependencies and run `pytest`:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install -e ".[dev]"
|
|
135
|
+
pytest tests/
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## ๐ License
|
|
141
|
+
|
|
142
|
+
Distributed under the MIT License. See `LICENSE` for details.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyrelay-workflow"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A lightweight, async-first Python workflow engine with dynamic branching and rich visual dashboarding."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Developer", email = "developer@example.com" }
|
|
12
|
+
]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.9"
|
|
21
|
+
dependencies = [
|
|
22
|
+
"rich>=12.0.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=7.0.0",
|
|
28
|
+
"pytest-asyncio>=0.18.0",
|
|
29
|
+
"ruff>=0.1.0",
|
|
30
|
+
"mypy>=1.0.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 88
|
|
35
|
+
target-version = "py39"
|
|
@@ -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
|
+
|
|
@@ -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
|