planai 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.
- planai/__init__.py +33 -0
- planai/cached_task.py +113 -0
- planai/dispatcher.py +236 -0
- planai/graph.py +172 -0
- planai/joined_task.py +56 -0
- planai/llm_interface.py +197 -0
- planai/llm_task.py +115 -0
- planai/openai.py +92 -0
- planai/remote_ollama.py +187 -0
- planai/ssh.py +150 -0
- planai/task.py +395 -0
- planai/templates/index.html +251 -0
- planai/utils.py +61 -0
- planai/web_interface.py +79 -0
- planai-0.1.0.dist-info/LICENSE +201 -0
- planai-0.1.0.dist-info/METADATA +141 -0
- planai-0.1.0.dist-info/RECORD +19 -0
- planai-0.1.0.dist-info/WHEEL +4 -0
- planai-0.1.0.dist-info/entry_points.txt +3 -0
planai/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2024 Niels Provos
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
# src/planai/__init__.py
|
|
15
|
+
|
|
16
|
+
from .cached_task import CachedTaskWorker
|
|
17
|
+
from .graph import Graph
|
|
18
|
+
from .joined_task import JoinedTaskWorker
|
|
19
|
+
from .llm_interface import llm_from_config
|
|
20
|
+
from .llm_task import CachedLLMTaskWorker, LLMTaskWorker
|
|
21
|
+
from .task import TaskWorker, TaskWorkItem
|
|
22
|
+
|
|
23
|
+
# If you want to control what gets imported with "from planai import *"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Graph",
|
|
26
|
+
"llm_from_config",
|
|
27
|
+
"LLMTaskWorker",
|
|
28
|
+
"TaskWorker",
|
|
29
|
+
"TaskWorkItem",
|
|
30
|
+
"CachedTaskWorker",
|
|
31
|
+
"CachedLLMTaskWorker",
|
|
32
|
+
"JoinedTaskWorker",
|
|
33
|
+
]
|
planai/cached_task.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Copyright 2024 Niels Provos
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
from typing import List, Type
|
|
18
|
+
|
|
19
|
+
from diskcache import Cache
|
|
20
|
+
from pydantic import Field, PrivateAttr
|
|
21
|
+
|
|
22
|
+
from .task import TaskWorker, TaskWorkItem
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CachedTaskWorker(TaskWorker):
|
|
26
|
+
cache_dir: str = Field("./cache", description="Directory to store the cache")
|
|
27
|
+
cache_size_limit: int = Field(
|
|
28
|
+
25_000_000_000, description="Cache size limit in bytes"
|
|
29
|
+
)
|
|
30
|
+
_cache: Cache = PrivateAttr()
|
|
31
|
+
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
|
|
32
|
+
|
|
33
|
+
def __init__(self, **data):
|
|
34
|
+
super().__init__(**data)
|
|
35
|
+
self._cache = Cache(self.cache_dir, size_limit=self.cache_size_limit)
|
|
36
|
+
|
|
37
|
+
def _pre_consume_work(self, task: TaskWorkItem):
|
|
38
|
+
self.pre_consume_work(task)
|
|
39
|
+
|
|
40
|
+
cache_key = self._get_cache_key(task)
|
|
41
|
+
cached_results = self._cache.get(cache_key)
|
|
42
|
+
|
|
43
|
+
if cached_results is not None:
|
|
44
|
+
logging.info("Cache hit for %s with key: %s", self.name, cache_key)
|
|
45
|
+
self._publish_cached_results(cached_results, task)
|
|
46
|
+
else:
|
|
47
|
+
logging.info("Cache miss for %s with key: %s", self.name, cache_key)
|
|
48
|
+
self.consume_work(task)
|
|
49
|
+
|
|
50
|
+
self.post_consume_work(task)
|
|
51
|
+
|
|
52
|
+
def pre_consume_work(self, task: TaskWorkItem):
|
|
53
|
+
"""
|
|
54
|
+
This method is called before consuming the work item. It will be called even if the task has been cached.
|
|
55
|
+
It can be used for state manipulation, e.g. changing state for a class specific cache key.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
task (TaskWorkItem): The work item to be consumed.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
None
|
|
62
|
+
"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def post_consume_work(self, task: TaskWorkItem):
|
|
66
|
+
"""
|
|
67
|
+
This method is called after consuming a work item in the task. It will be called even if the task has been cached.
|
|
68
|
+
It can be used for state manipulation, e.g. changing state for a class specific cache key.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
task (TaskWorkItem): The work item that was consumed.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
None
|
|
75
|
+
"""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def _get_cache_key(self, task: TaskWorkItem) -> str:
|
|
79
|
+
"""Generate a unique cache key for the input task."""
|
|
80
|
+
task_dict = task.model_dump()
|
|
81
|
+
task_str = str(sorted(task_dict.items())) # Ensure consistent ordering
|
|
82
|
+
task_str += f" - {self.name}" # Include the task name to avoid collisions
|
|
83
|
+
for output_type in self.output_types:
|
|
84
|
+
task_str += f" - {output_type.__name__}"
|
|
85
|
+
if extra_key := self.extra_cache_key(task):
|
|
86
|
+
task_str += f" - {extra_key}"
|
|
87
|
+
return hashlib.sha1(task_str.encode()).hexdigest()
|
|
88
|
+
|
|
89
|
+
def extra_cache_key(self, task: TaskWorkItem) -> str:
|
|
90
|
+
"""Can be implemented by subclasses to provide additional cache key information."""
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
def _publish_cached_results(
|
|
94
|
+
self, cached_results: List[TaskWorkItem], input_task: TaskWorkItem
|
|
95
|
+
):
|
|
96
|
+
"""Publish cached results."""
|
|
97
|
+
for result in cached_results:
|
|
98
|
+
super().publish_work(result, input_task=input_task)
|
|
99
|
+
|
|
100
|
+
def publish_work(self, task: TaskWorkItem, input_task: TaskWorkItem):
|
|
101
|
+
"""Publish work and cache the results."""
|
|
102
|
+
super().publish_work(task, input_task=input_task)
|
|
103
|
+
|
|
104
|
+
cache_key = self._get_cache_key(input_task)
|
|
105
|
+
try:
|
|
106
|
+
# since the cache key includes the name of this task worker, we can use the lock in this class
|
|
107
|
+
with self._lock:
|
|
108
|
+
cached_results = self._cache.get(cache_key, default=[])
|
|
109
|
+
cached_results.append(task)
|
|
110
|
+
self._cache.set(cache_key, cached_results)
|
|
111
|
+
logging.info("Task %s cached results for key: %s", self.name, cache_key)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logging.error("Error caching results for key %s: %s", cache_key, str(e))
|
planai/dispatcher.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Copyright 2024 Niels Provos
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from collections import defaultdict, deque
|
|
18
|
+
from queue import Empty, Queue
|
|
19
|
+
from threading import Event, Lock
|
|
20
|
+
from typing import TYPE_CHECKING, DefaultDict, Dict, List, Tuple, Type
|
|
21
|
+
|
|
22
|
+
from .task import TaskWorker, TaskWorkItem
|
|
23
|
+
from .web_interface import is_quit_requested, run_web_interface
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .graph import Graph
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Type aliases
|
|
30
|
+
TaskID = int
|
|
31
|
+
TaskName = str
|
|
32
|
+
ProvenanceChain = Tuple[Tuple[TaskName, TaskID], ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Dispatcher:
|
|
36
|
+
def __init__(self, graph: "Graph", web_port=5000):
|
|
37
|
+
self.graph = graph
|
|
38
|
+
self.work_queue = Queue()
|
|
39
|
+
self.provenance: DefaultDict[ProvenanceChain, int] = defaultdict(int)
|
|
40
|
+
self.notifiers: DefaultDict[ProvenanceChain, List[TaskWorker]] = defaultdict(
|
|
41
|
+
list
|
|
42
|
+
)
|
|
43
|
+
self.provenance_lock = Lock()
|
|
44
|
+
self.notifiers_lock = Lock()
|
|
45
|
+
self.stop_event = Event()
|
|
46
|
+
self.active_tasks = 0
|
|
47
|
+
self.task_completion_event = threading.Event()
|
|
48
|
+
self.web_port = web_port
|
|
49
|
+
self.debug_active_tasks: Dict[str, Tuple[TaskWorker, TaskWorkItem]] = {}
|
|
50
|
+
self.completed_tasks: deque = deque(maxlen=100) # Keep last 100 completed tasks
|
|
51
|
+
self.total_completed_tasks = 0
|
|
52
|
+
self.task_id_counter = 0
|
|
53
|
+
self.task_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
def _add_provenance(self, task: TaskWorkItem):
|
|
56
|
+
with self.provenance_lock:
|
|
57
|
+
for i in range(1, len(task._provenance) + 1):
|
|
58
|
+
prefix = tuple(task._provenance[:i])
|
|
59
|
+
self.provenance[prefix] = self.provenance.get(prefix, 0) + 1
|
|
60
|
+
|
|
61
|
+
def _remove_provenance(self, task: TaskWorkItem):
|
|
62
|
+
to_notify = set()
|
|
63
|
+
with self.provenance_lock:
|
|
64
|
+
for i in range(1, len(task._provenance) + 1):
|
|
65
|
+
prefix = tuple(task._provenance[:i])
|
|
66
|
+
self.provenance[prefix] -= 1
|
|
67
|
+
if self.provenance[prefix] == 0:
|
|
68
|
+
del self.provenance[prefix]
|
|
69
|
+
to_notify.add(prefix)
|
|
70
|
+
|
|
71
|
+
for prefix in to_notify:
|
|
72
|
+
self._notify_task_completion(prefix)
|
|
73
|
+
|
|
74
|
+
def watch(self, prefix: ProvenanceChain, notifier: TaskWorker) -> bool:
|
|
75
|
+
if not isinstance(prefix, tuple):
|
|
76
|
+
raise ValueError("Prefix must be a tuple")
|
|
77
|
+
with self.notifiers_lock:
|
|
78
|
+
if notifier not in self.notifiers[prefix]:
|
|
79
|
+
self.notifiers[prefix].append(notifier)
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def unwatch(self, prefix: ProvenanceChain, notifier: TaskWorker) -> bool:
|
|
84
|
+
if not isinstance(prefix, tuple):
|
|
85
|
+
raise ValueError("Prefix must be a tuple")
|
|
86
|
+
with self.notifiers_lock:
|
|
87
|
+
if prefix in self.notifiers and notifier in self.notifiers[prefix]:
|
|
88
|
+
self.notifiers[prefix].remove(notifier)
|
|
89
|
+
if not self.notifiers[prefix]:
|
|
90
|
+
del self.notifiers[prefix]
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
def _notify_task_completion(self, prefix: tuple):
|
|
95
|
+
to_notify = []
|
|
96
|
+
with self.notifiers_lock:
|
|
97
|
+
for notifier in self.notifiers[prefix]:
|
|
98
|
+
to_notify.append((notifier, prefix))
|
|
99
|
+
|
|
100
|
+
for notifier, prefix in to_notify:
|
|
101
|
+
self.active_tasks += 1
|
|
102
|
+
future = self.graph._thread_pool.submit(notifier.notify, prefix)
|
|
103
|
+
future.add_done_callback(self._notify_completed)
|
|
104
|
+
|
|
105
|
+
def _dispatch_once(self) -> bool:
|
|
106
|
+
try:
|
|
107
|
+
worker, task = self.work_queue.get(timeout=1)
|
|
108
|
+
self.active_tasks += 1
|
|
109
|
+
future = self.graph._thread_pool.submit(self._execute_task, worker, task)
|
|
110
|
+
|
|
111
|
+
# Use a named function instead of a lambda to avoid closure issues
|
|
112
|
+
def task_completed_callback(future, task=task):
|
|
113
|
+
self._task_completed(task, future)
|
|
114
|
+
|
|
115
|
+
future.add_done_callback(task_completed_callback)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
except Empty:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def dispatch(self):
|
|
122
|
+
while (
|
|
123
|
+
not self.stop_event.is_set()
|
|
124
|
+
or not self.work_queue.empty()
|
|
125
|
+
or self.active_tasks > 0
|
|
126
|
+
):
|
|
127
|
+
self._dispatch_once()
|
|
128
|
+
|
|
129
|
+
def _execute_task(self, worker: TaskWorker, task: TaskWorkItem):
|
|
130
|
+
task_id = self._get_next_task_id()
|
|
131
|
+
with self.task_lock:
|
|
132
|
+
self.debug_active_tasks[task_id] = (worker, task)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
worker._pre_consume_work(task)
|
|
136
|
+
finally:
|
|
137
|
+
with self.task_lock:
|
|
138
|
+
if task_id in self.debug_active_tasks:
|
|
139
|
+
del self.debug_active_tasks[task_id]
|
|
140
|
+
self.completed_tasks.appendleft(
|
|
141
|
+
(task_id, worker, task)
|
|
142
|
+
) # may need to move this to _task_completed
|
|
143
|
+
|
|
144
|
+
def _get_next_task_id(self):
|
|
145
|
+
with self.task_lock:
|
|
146
|
+
self.task_id_counter += 1
|
|
147
|
+
return self.task_id_counter
|
|
148
|
+
|
|
149
|
+
def _task_to_dict(self, worker: TaskWorker, task: TaskWorkItem) -> Dict:
|
|
150
|
+
return {
|
|
151
|
+
"id": self._get_task_id(task),
|
|
152
|
+
"type": type(task).__name__,
|
|
153
|
+
"worker": worker.name,
|
|
154
|
+
"provenance": [f"{worker}_{id}" for worker, id in task._provenance],
|
|
155
|
+
"input_provenance": [
|
|
156
|
+
input_task.model_dump() for input_task in task._input_provenance
|
|
157
|
+
],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def get_queued_tasks(self) -> List[Dict]:
|
|
161
|
+
return [
|
|
162
|
+
self._task_to_dict(worker, task) for worker, task in self.work_queue.queue
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
def get_active_tasks(self) -> List[Dict]:
|
|
166
|
+
with self.task_lock:
|
|
167
|
+
return [
|
|
168
|
+
self._task_to_dict(worker, task)
|
|
169
|
+
for task_id, (worker, task) in self.debug_active_tasks.items()
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
def get_completed_tasks(self) -> List[Dict]:
|
|
173
|
+
with self.task_lock:
|
|
174
|
+
return [
|
|
175
|
+
self._task_to_dict(worker, task)
|
|
176
|
+
for task_id, worker, task in self.completed_tasks
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
def _get_task_id(self, task: TaskWorkItem) -> str:
|
|
180
|
+
# Use the last entry in the _provenance list as the task ID
|
|
181
|
+
if task._provenance:
|
|
182
|
+
return f"{task._provenance[-1][0]}_{task._provenance[-1][1]}"
|
|
183
|
+
else:
|
|
184
|
+
# Fallback in case _provenance is empty
|
|
185
|
+
return f"unknown_{id(task)}"
|
|
186
|
+
|
|
187
|
+
def _notify_completed(self, future):
|
|
188
|
+
self.active_tasks -= 1
|
|
189
|
+
if self.active_tasks == 0 and self.work_queue.empty():
|
|
190
|
+
self.task_completion_event.set()
|
|
191
|
+
|
|
192
|
+
def _task_completed(self, task: TaskWorkItem, future):
|
|
193
|
+
try:
|
|
194
|
+
# This will raise an exception if the task failed
|
|
195
|
+
result = future.result()
|
|
196
|
+
|
|
197
|
+
# Handle successful task completion
|
|
198
|
+
logging.info(f"Task {task} completed successfully")
|
|
199
|
+
self.total_completed_tasks += 1
|
|
200
|
+
|
|
201
|
+
except Exception as e:
|
|
202
|
+
# Handle task failure
|
|
203
|
+
logging.exception(f"Task {task} failed with exception: {str(e)}")
|
|
204
|
+
|
|
205
|
+
# Anything else that needs to be done when a task fails?
|
|
206
|
+
|
|
207
|
+
finally:
|
|
208
|
+
# This code will run whether the task succeeded or failed
|
|
209
|
+
self._remove_provenance(task)
|
|
210
|
+
self.active_tasks -= 1
|
|
211
|
+
if self.active_tasks == 0 and self.work_queue.empty():
|
|
212
|
+
self.task_completion_event.set()
|
|
213
|
+
|
|
214
|
+
def add_work(self, worker: TaskWorker, task: TaskWorkItem):
|
|
215
|
+
self._add_provenance(task)
|
|
216
|
+
self.work_queue.put((worker, task))
|
|
217
|
+
|
|
218
|
+
def stop(self):
|
|
219
|
+
self.stop_event.set()
|
|
220
|
+
|
|
221
|
+
def wait_for_completion(self, wait_for_quit=False):
|
|
222
|
+
self.task_completion_event.wait()
|
|
223
|
+
|
|
224
|
+
if wait_for_quit:
|
|
225
|
+
while not is_quit_requested():
|
|
226
|
+
# Sleep for a short time to avoid busy waiting
|
|
227
|
+
time.sleep(0.1)
|
|
228
|
+
|
|
229
|
+
def start_web_interface(self):
|
|
230
|
+
web_thread = threading.Thread(
|
|
231
|
+
target=run_web_interface, args=(self, self.web_port)
|
|
232
|
+
)
|
|
233
|
+
web_thread.daemon = (
|
|
234
|
+
True # This ensures the web thread will exit when the main thread exits
|
|
235
|
+
)
|
|
236
|
+
web_thread.start()
|
planai/graph.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Copyright 2024 Niels Provos
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from threading import Thread
|
|
16
|
+
from typing import Dict, List, Optional, Set, Tuple, Type
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
19
|
+
|
|
20
|
+
from .dispatcher import Dispatcher
|
|
21
|
+
from .task import TaskWorker, TaskWorkItem
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Graph(BaseModel):
|
|
25
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
workers: Set[TaskWorker] = Field(default_factory=set)
|
|
29
|
+
dependencies: Dict[TaskWorker, List[TaskWorker]] = Field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
_dispatcher: Optional[Dispatcher] = PrivateAttr(default=None)
|
|
32
|
+
_thread_pool: Optional[ThreadPoolExecutor] = PrivateAttr(default=None)
|
|
33
|
+
|
|
34
|
+
def __init__(self, **data):
|
|
35
|
+
super().__init__(**data)
|
|
36
|
+
if self._thread_pool is None:
|
|
37
|
+
self._thread_pool = ThreadPoolExecutor()
|
|
38
|
+
|
|
39
|
+
def add_worker(self, task: TaskWorker) -> "Graph":
|
|
40
|
+
"""Add a task to the Graph."""
|
|
41
|
+
if task in self.workers:
|
|
42
|
+
raise ValueError(f"Task {task} already exists in the Graph.")
|
|
43
|
+
self.workers.add(task)
|
|
44
|
+
self.dependencies[task] = []
|
|
45
|
+
task.set_graph(self)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def add_workers(self, *workers: TaskWorker) -> "Graph":
|
|
49
|
+
"""Add multiple tasks to the Graph."""
|
|
50
|
+
for worker in workers:
|
|
51
|
+
self.add_worker(worker)
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def set_dependency(
|
|
55
|
+
self, upstream: TaskWorker, downstream: TaskWorker
|
|
56
|
+
) -> TaskWorker:
|
|
57
|
+
"""Set a dependency between two tasks."""
|
|
58
|
+
if upstream not in self.workers or downstream not in self.workers:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"Both tasks must be added to the Graph before setting dependencies."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if downstream not in self.dependencies[upstream]:
|
|
64
|
+
self.dependencies[upstream].append(downstream)
|
|
65
|
+
upstream.register_consumer(
|
|
66
|
+
task_cls=downstream.get_taskworkitem_class(),
|
|
67
|
+
consumer=downstream,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return downstream
|
|
71
|
+
|
|
72
|
+
def validate_graph(self) -> None:
|
|
73
|
+
"""Return the execution order of tasks based on dependencies."""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def run(self, initial_tasks: List[Tuple[TaskWorker, TaskWorkItem]]) -> None:
|
|
77
|
+
"""Execute the Graph by initiating source tasks."""
|
|
78
|
+
dispatcher = Dispatcher(self)
|
|
79
|
+
dispatch_thread = Thread(target=dispatcher.dispatch)
|
|
80
|
+
dispatch_thread.start()
|
|
81
|
+
dispatcher.start_web_interface()
|
|
82
|
+
self._dispatcher = dispatcher
|
|
83
|
+
|
|
84
|
+
# let the workers now that we are about to start
|
|
85
|
+
for worker in self.workers:
|
|
86
|
+
worker.init()
|
|
87
|
+
|
|
88
|
+
for worker, task in initial_tasks:
|
|
89
|
+
success, error = worker.validate_taskworkitem(type(task), worker)
|
|
90
|
+
if not success:
|
|
91
|
+
raise error
|
|
92
|
+
|
|
93
|
+
dispatcher.add_work(worker, task)
|
|
94
|
+
|
|
95
|
+
# Wait for all tasks to complete
|
|
96
|
+
dispatcher.wait_for_completion(wait_for_quit=True)
|
|
97
|
+
dispatcher.stop()
|
|
98
|
+
dispatch_thread.join()
|
|
99
|
+
self._thread_pool.shutdown(wait=True)
|
|
100
|
+
|
|
101
|
+
for worker in self.workers:
|
|
102
|
+
worker.completed()
|
|
103
|
+
|
|
104
|
+
def __str__(self) -> str:
|
|
105
|
+
return f"Graph: {self.name} with {len(self.workers)} tasks"
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str:
|
|
108
|
+
return self.__str__()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main():
|
|
112
|
+
# Define custom TaskWorkItem classes
|
|
113
|
+
class Task1WorkItem(TaskWorkItem):
|
|
114
|
+
data: str
|
|
115
|
+
|
|
116
|
+
class Task2WorkItem(TaskWorkItem):
|
|
117
|
+
processed_data: str
|
|
118
|
+
|
|
119
|
+
class Task3WorkItem(TaskWorkItem):
|
|
120
|
+
final_result: str
|
|
121
|
+
|
|
122
|
+
# Define custom TaskWorker classes
|
|
123
|
+
class Task1Worker(TaskWorker):
|
|
124
|
+
output_types: Set[Type[TaskWorkItem]] = {Task2WorkItem}
|
|
125
|
+
|
|
126
|
+
def consume_work(self, task: Task1WorkItem):
|
|
127
|
+
print(f"Task1 consuming: {task.data}")
|
|
128
|
+
processed = f"Processed: {task.data.upper()}"
|
|
129
|
+
self.publish_work(Task2WorkItem(processed_data=processed), input_task=task)
|
|
130
|
+
|
|
131
|
+
class Task2Worker(TaskWorker):
|
|
132
|
+
output_types: Set[Type[TaskWorkItem]] = {Task3WorkItem}
|
|
133
|
+
|
|
134
|
+
def consume_work(self, task: Task2WorkItem):
|
|
135
|
+
print(f"Task2 consuming: {task.processed_data}")
|
|
136
|
+
final = f"Final: {task.processed_data}!"
|
|
137
|
+
self.publish_work(Task3WorkItem(final_result=final), input_task=task)
|
|
138
|
+
|
|
139
|
+
class Task3Worker(TaskWorker):
|
|
140
|
+
output_types: Set[Type[TaskWorkItem]] = set()
|
|
141
|
+
|
|
142
|
+
def consume_work(self, task: Task3WorkItem):
|
|
143
|
+
print(f"Task3 consuming: {task.final_result}")
|
|
144
|
+
print("Workflow complete!")
|
|
145
|
+
|
|
146
|
+
# Create Graph
|
|
147
|
+
graph = Graph(name="Simple Workflow")
|
|
148
|
+
|
|
149
|
+
# Create tasks
|
|
150
|
+
task1 = Task1Worker()
|
|
151
|
+
task2 = Task2Worker()
|
|
152
|
+
task3 = Task3Worker()
|
|
153
|
+
|
|
154
|
+
# Add tasks to Graph
|
|
155
|
+
graph.add_worker(task1).add_worker(task2).add_worker(task3)
|
|
156
|
+
|
|
157
|
+
# Set dependencies
|
|
158
|
+
graph.set_dependency(task1, task2).next(task3)
|
|
159
|
+
|
|
160
|
+
# Validate Graph
|
|
161
|
+
execution_order = graph.validate_graph()
|
|
162
|
+
print(f"Execution order: {execution_order}")
|
|
163
|
+
|
|
164
|
+
# Prepare initial work item
|
|
165
|
+
initial_work = [(task1, Task1WorkItem(data="Hello, Graph!"))]
|
|
166
|
+
|
|
167
|
+
# Run the Graph
|
|
168
|
+
graph.run(initial_work)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
main()
|
planai/joined_task.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright 2024 Niels Provos
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
from abc import abstractmethod
|
|
15
|
+
from typing import Dict, List, Type
|
|
16
|
+
|
|
17
|
+
from pydantic import PrivateAttr
|
|
18
|
+
|
|
19
|
+
from .cached_task import CachedTaskWorker
|
|
20
|
+
from .task import TaskWorker, TaskWorkItem
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JoinedTaskWorker(TaskWorker):
|
|
24
|
+
join_type: Type[TaskWorker]
|
|
25
|
+
_joined_results: Dict[tuple, List[TaskWorkItem]] = PrivateAttr(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def consume_work_joined(self, task: List[TaskWorkItem]):
|
|
29
|
+
"""
|
|
30
|
+
A subclass needs to implement consume_work only for the type hint.
|
|
31
|
+
It still needs to call the super() method in consume_work.
|
|
32
|
+
All accumulated results will be delivered to this method.
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def consume_work(self, task: TaskWorkItem):
|
|
37
|
+
prefix = task.prefix_for_input_task(self.join_type)
|
|
38
|
+
if prefix is None:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Task {task} does not have a prefix for {self.join_type.__name__} in provenance."
|
|
41
|
+
)
|
|
42
|
+
if not prefix in self._joined_results:
|
|
43
|
+
# we will register the watch for the prefix when we see it for the first time.
|
|
44
|
+
if not self.watch(prefix):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Task {task} does not have a watch for {self.join_type} in provenance."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# we accumulate the results by the prefix described by join_type
|
|
50
|
+
# we will deliver them to the sub-class when we get notified
|
|
51
|
+
self._joined_results.setdefault(prefix, []).append(task)
|
|
52
|
+
|
|
53
|
+
def notify(self, prefix: str):
|
|
54
|
+
if prefix not in self._joined_results:
|
|
55
|
+
raise ValueError(f"Task {prefix} does not have any results to join.")
|
|
56
|
+
self.consume_work_joined(self._joined_results.pop(prefix))
|