taskmaestro 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.
- taskmaestro/__init__.py +57 -0
- taskmaestro/context.py +37 -0
- taskmaestro/exceptions.py +37 -0
- taskmaestro/hooks/__init__.py +15 -0
- taskmaestro/hooks/base.py +57 -0
- taskmaestro/hooks/logging.py +44 -0
- taskmaestro/hooks/persistence.py +24 -0
- taskmaestro/hooks/timing.py +46 -0
- taskmaestro/job.py +116 -0
- taskmaestro/object_model.py +19 -0
- taskmaestro/py.typed +0 -0
- taskmaestro/runner.py +201 -0
- taskmaestro/task.py +77 -0
- taskmaestro/visualization.py +223 -0
- taskmaestro/workflow.py +484 -0
- taskmaestro/workflow_task.py +94 -0
- taskmaestro/yaml_config.py +455 -0
- taskmaestro-0.1.0.dist-info/METADATA +358 -0
- taskmaestro-0.1.0.dist-info/RECORD +21 -0
- taskmaestro-0.1.0.dist-info/WHEEL +4 -0
- taskmaestro-0.1.0.dist-info/licenses/LICENSE +674 -0
taskmaestro/workflow.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Workflow (DAG) definition with linear shorthand and builder pattern."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from taskmaestro.job import JobConfiguration
|
|
11
|
+
|
|
12
|
+
from taskmaestro.exceptions import (
|
|
13
|
+
CycleDetectedError,
|
|
14
|
+
IncompleteInputError,
|
|
15
|
+
WorkflowDefinitionError,
|
|
16
|
+
)
|
|
17
|
+
from taskmaestro.task import Task, get_input_type, get_output_type
|
|
18
|
+
|
|
19
|
+
# Stored dependency types after name resolution:
|
|
20
|
+
# None — root task
|
|
21
|
+
# str — single upstream (whole output)
|
|
22
|
+
# tuple[str, str] — single upstream, specific field
|
|
23
|
+
# dict[str, str | tuple[str, str]] — fan-in (values may be field refs)
|
|
24
|
+
DepValue = Union[str, "tuple[str, str]"]
|
|
25
|
+
StoredDeps = Union[dict[str, DepValue], str, "tuple[str, str]", None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract_upstream_names(deps: StoredDeps) -> set[str]:
|
|
29
|
+
"""Return the set of upstream task names referenced by *deps*."""
|
|
30
|
+
if deps is None:
|
|
31
|
+
return set()
|
|
32
|
+
if isinstance(deps, str):
|
|
33
|
+
return {deps}
|
|
34
|
+
if isinstance(deps, tuple):
|
|
35
|
+
return {deps[0]}
|
|
36
|
+
# dict
|
|
37
|
+
names: set[str] = set()
|
|
38
|
+
for v in deps.values():
|
|
39
|
+
if isinstance(v, tuple):
|
|
40
|
+
names.add(v[0])
|
|
41
|
+
else:
|
|
42
|
+
names.add(v)
|
|
43
|
+
return names
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Workflow:
|
|
47
|
+
"""A DAG of tasks. Linear pipelines are a special case."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
name: str,
|
|
52
|
+
tasks: list[type[Task[Any, Any]]] | None = None,
|
|
53
|
+
*,
|
|
54
|
+
result_task: type[Task[Any, Any]] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Linear shorthand: auto-chain tasks[0] -> tasks[1] -> ... -> tasks[N].
|
|
57
|
+
|
|
58
|
+
For DAG construction, use Workflow.builder() instead.
|
|
59
|
+
"""
|
|
60
|
+
self.name = name
|
|
61
|
+
self._tasks: dict[str, type[Task[Any, Any]]] = {}
|
|
62
|
+
self._dependencies: dict[str, StoredDeps] = {}
|
|
63
|
+
self._config_fields: dict[str, set[str]] = {}
|
|
64
|
+
self._result_task_name: str | None = None
|
|
65
|
+
|
|
66
|
+
if tasks:
|
|
67
|
+
for i, task_cls in enumerate(tasks):
|
|
68
|
+
self._tasks[task_cls.name] = task_cls
|
|
69
|
+
if i == 0:
|
|
70
|
+
self._dependencies[task_cls.name] = None
|
|
71
|
+
else:
|
|
72
|
+
prev = tasks[i - 1]
|
|
73
|
+
self._dependencies[task_cls.name] = prev.name
|
|
74
|
+
self._result_task_name = tasks[-1].name
|
|
75
|
+
self._validate()
|
|
76
|
+
|
|
77
|
+
if result_task is not None:
|
|
78
|
+
self._result_task_name = result_task.name
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def builder(
|
|
82
|
+
cls,
|
|
83
|
+
name: str,
|
|
84
|
+
*,
|
|
85
|
+
result_task: type[Task[Any, Any]] | str | None = None,
|
|
86
|
+
) -> WorkflowBuilder:
|
|
87
|
+
"""Return a builder for DAG construction."""
|
|
88
|
+
return WorkflowBuilder(name, result_task=result_task)
|
|
89
|
+
|
|
90
|
+
def topological_order(self) -> list[tuple[str, type[Task[Any, Any]]]]:
|
|
91
|
+
"""Return (name, task_class) pairs in a valid execution order (Kahn's algorithm)."""
|
|
92
|
+
in_degree: dict[str, int] = {name: 0 for name in self._tasks}
|
|
93
|
+
adjacency: dict[str, list[str]] = {name: [] for name in self._tasks}
|
|
94
|
+
|
|
95
|
+
for name, deps in self._dependencies.items():
|
|
96
|
+
for upstream in _extract_upstream_names(deps):
|
|
97
|
+
in_degree[name] += 1
|
|
98
|
+
adjacency[upstream].append(name)
|
|
99
|
+
|
|
100
|
+
queue = [name for name, deg in in_degree.items() if deg == 0]
|
|
101
|
+
result: list[tuple[str, type[Task[Any, Any]]]] = []
|
|
102
|
+
|
|
103
|
+
while queue:
|
|
104
|
+
queue.sort()
|
|
105
|
+
current = queue.pop(0)
|
|
106
|
+
result.append((current, self._tasks[current]))
|
|
107
|
+
for neighbor in adjacency[current]:
|
|
108
|
+
in_degree[neighbor] -= 1
|
|
109
|
+
if in_degree[neighbor] == 0:
|
|
110
|
+
queue.append(neighbor)
|
|
111
|
+
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def result_task(self) -> type[Task[Any, Any]]:
|
|
116
|
+
"""The task whose output becomes job.result."""
|
|
117
|
+
if self._result_task_name is None:
|
|
118
|
+
raise WorkflowDefinitionError("No result task set")
|
|
119
|
+
return self._tasks[self._result_task_name]
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def result_task_name(self) -> str:
|
|
123
|
+
"""The registered name of the result task."""
|
|
124
|
+
if self._result_task_name is None:
|
|
125
|
+
raise WorkflowDefinitionError("No result task set")
|
|
126
|
+
return self._result_task_name
|
|
127
|
+
|
|
128
|
+
def get_dependencies(self, task_name: str) -> StoredDeps:
|
|
129
|
+
"""Return the dependency spec for a task."""
|
|
130
|
+
return self._dependencies[task_name]
|
|
131
|
+
|
|
132
|
+
def get_config_fields(self, task_name: str) -> set[str]:
|
|
133
|
+
"""Return the set of config field names for a task, or empty set."""
|
|
134
|
+
return self._config_fields.get(task_name, set())
|
|
135
|
+
|
|
136
|
+
def _validate(self) -> None:
|
|
137
|
+
self._validate_unique_names()
|
|
138
|
+
self._validate_references()
|
|
139
|
+
self._validate_acyclic()
|
|
140
|
+
self._validate_types()
|
|
141
|
+
self._validate_result_task()
|
|
142
|
+
|
|
143
|
+
def _validate_unique_names(self) -> None:
|
|
144
|
+
"""Raise WorkflowDefinitionError on duplicate task names."""
|
|
145
|
+
# Already handled by dict keys in _tasks; duplicates would overwrite.
|
|
146
|
+
# For linear shorthand, check the input list explicitly.
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def _validate_references(self) -> None:
|
|
150
|
+
"""Ensure all dependency references point to registered task names."""
|
|
151
|
+
for name, deps in self._dependencies.items():
|
|
152
|
+
for upstream in _extract_upstream_names(deps):
|
|
153
|
+
if upstream not in self._tasks:
|
|
154
|
+
raise WorkflowDefinitionError(
|
|
155
|
+
f"Task '{name}' depends on '{upstream}', "
|
|
156
|
+
f"which is not registered in the workflow"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _validate_acyclic(self) -> None:
|
|
160
|
+
"""Detect cycles via DFS. Raise CycleDetectedError."""
|
|
161
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
162
|
+
color: dict[str, int] = {name: WHITE for name in self._tasks}
|
|
163
|
+
|
|
164
|
+
def _build_adjacency() -> dict[str, list[str]]:
|
|
165
|
+
adj: dict[str, list[str]] = {name: [] for name in self._tasks}
|
|
166
|
+
for name, deps in self._dependencies.items():
|
|
167
|
+
for upstream in _extract_upstream_names(deps):
|
|
168
|
+
adj[upstream].append(name)
|
|
169
|
+
return adj
|
|
170
|
+
|
|
171
|
+
adjacency = _build_adjacency()
|
|
172
|
+
|
|
173
|
+
def dfs(node: str) -> None:
|
|
174
|
+
color[node] = GRAY
|
|
175
|
+
for neighbor in adjacency[node]:
|
|
176
|
+
if color[neighbor] == GRAY:
|
|
177
|
+
raise CycleDetectedError(f"Cycle detected involving task '{neighbor}'")
|
|
178
|
+
if color[neighbor] == WHITE:
|
|
179
|
+
dfs(neighbor)
|
|
180
|
+
color[node] = BLACK
|
|
181
|
+
|
|
182
|
+
for node in self._tasks:
|
|
183
|
+
if color[node] == WHITE:
|
|
184
|
+
dfs(node)
|
|
185
|
+
|
|
186
|
+
def _validate_types(self) -> None:
|
|
187
|
+
"""Validate type compatibility for all edges."""
|
|
188
|
+
for name, deps in self._dependencies.items():
|
|
189
|
+
task_cls = self._tasks[name]
|
|
190
|
+
cf = self._config_fields.get(name, set())
|
|
191
|
+
if deps is None:
|
|
192
|
+
# Root task — validated at Job creation time, or via config_fields
|
|
193
|
+
if cf:
|
|
194
|
+
downstream_input = get_input_type(task_cls)
|
|
195
|
+
model_fields = downstream_input.model_fields
|
|
196
|
+
# Validate config_fields cover all required input fields
|
|
197
|
+
for field_name, field_info in model_fields.items():
|
|
198
|
+
if field_name not in cf and field_info.is_required():
|
|
199
|
+
raise IncompleteInputError(
|
|
200
|
+
f"Required field '{field_name}' on "
|
|
201
|
+
f"{downstream_input.__name__} is not covered by "
|
|
202
|
+
f"config_fields for root task '{name}'"
|
|
203
|
+
)
|
|
204
|
+
# Validate config field names exist on the model
|
|
205
|
+
for field_name in cf:
|
|
206
|
+
if field_name not in model_fields:
|
|
207
|
+
raise WorkflowDefinitionError(
|
|
208
|
+
f"Config field '{field_name}' not found on "
|
|
209
|
+
f"{downstream_input.__name__} (input of '{name}')"
|
|
210
|
+
)
|
|
211
|
+
continue
|
|
212
|
+
elif isinstance(deps, str):
|
|
213
|
+
# Single dependency (whole output)
|
|
214
|
+
upstream_cls = self._tasks[deps]
|
|
215
|
+
upstream_output = get_output_type(upstream_cls)
|
|
216
|
+
downstream_input = get_input_type(task_cls)
|
|
217
|
+
if cf:
|
|
218
|
+
# With config_fields: check upstream output fields exist in
|
|
219
|
+
# downstream input with compatible types, and that upstream
|
|
220
|
+
# fields + config_fields cover all required fields
|
|
221
|
+
up_fields = upstream_output.model_fields
|
|
222
|
+
down_fields = downstream_input.model_fields
|
|
223
|
+
# Validate config field names exist on the model
|
|
224
|
+
for field_name in cf:
|
|
225
|
+
if field_name not in down_fields:
|
|
226
|
+
raise WorkflowDefinitionError(
|
|
227
|
+
f"Config field '{field_name}' not found on "
|
|
228
|
+
f"{downstream_input.__name__} (input of '{name}')"
|
|
229
|
+
)
|
|
230
|
+
# Check upstream output fields that match downstream input
|
|
231
|
+
for field_name, field_info in up_fields.items():
|
|
232
|
+
if field_name in down_fields:
|
|
233
|
+
up_annotation = field_info.annotation
|
|
234
|
+
down_annotation = down_fields[field_name].annotation
|
|
235
|
+
if (
|
|
236
|
+
up_annotation is not None
|
|
237
|
+
and down_annotation is not None
|
|
238
|
+
and not issubclass(up_annotation, down_annotation)
|
|
239
|
+
):
|
|
240
|
+
raise WorkflowDefinitionError(
|
|
241
|
+
f"Type mismatch: {deps}.{field_name} is "
|
|
242
|
+
f"{up_annotation.__name__} but {name}.{field_name} "
|
|
243
|
+
f"expects {down_annotation.__name__}"
|
|
244
|
+
)
|
|
245
|
+
# Check all required fields are covered by upstream or config
|
|
246
|
+
covered = set(up_fields.keys()) | cf
|
|
247
|
+
for field_name, field_info in down_fields.items():
|
|
248
|
+
if field_name not in covered and field_info.is_required():
|
|
249
|
+
raise IncompleteInputError(
|
|
250
|
+
f"Required field '{field_name}' on "
|
|
251
|
+
f"{downstream_input.__name__} is not covered by "
|
|
252
|
+
f"upstream output or config_fields"
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
if upstream_output is not downstream_input:
|
|
256
|
+
raise WorkflowDefinitionError(
|
|
257
|
+
f"Type mismatch: {deps} outputs "
|
|
258
|
+
f"{upstream_output.__name__} but {name} expects "
|
|
259
|
+
f"{downstream_input.__name__}"
|
|
260
|
+
)
|
|
261
|
+
elif isinstance(deps, tuple):
|
|
262
|
+
# Single dependency, specific output field
|
|
263
|
+
upstream_name, field_name = deps
|
|
264
|
+
upstream_cls = self._tasks[upstream_name]
|
|
265
|
+
upstream_output = get_output_type(upstream_cls)
|
|
266
|
+
upstream_fields = upstream_output.model_fields
|
|
267
|
+
if field_name not in upstream_fields:
|
|
268
|
+
raise WorkflowDefinitionError(
|
|
269
|
+
f"Field '{field_name}' not found on "
|
|
270
|
+
f"{upstream_output.__name__} (output of {upstream_name})"
|
|
271
|
+
)
|
|
272
|
+
field_annotation = upstream_fields[field_name].annotation
|
|
273
|
+
downstream_input = get_input_type(task_cls)
|
|
274
|
+
if field_annotation is not None and downstream_input is not field_annotation:
|
|
275
|
+
raise WorkflowDefinitionError(
|
|
276
|
+
f"Type mismatch: {upstream_name}.{field_name} is "
|
|
277
|
+
f"{field_annotation.__name__} but {name} expects "
|
|
278
|
+
f"{downstream_input.__name__}"
|
|
279
|
+
)
|
|
280
|
+
elif isinstance(deps, dict):
|
|
281
|
+
# Fan-in: validate each field
|
|
282
|
+
downstream_input = get_input_type(task_cls)
|
|
283
|
+
assert issubclass(downstream_input, BaseModel) # guaranteed by Task[I, O] bound
|
|
284
|
+
model_fields = downstream_input.model_fields
|
|
285
|
+
# Check that every mapped field exists and types match
|
|
286
|
+
for field_name, upstream_ref in deps.items():
|
|
287
|
+
if field_name not in model_fields:
|
|
288
|
+
raise WorkflowDefinitionError(
|
|
289
|
+
f"Fan-in field '{field_name}' not found on {downstream_input.__name__}"
|
|
290
|
+
)
|
|
291
|
+
if isinstance(upstream_ref, tuple):
|
|
292
|
+
up_name, up_field = upstream_ref
|
|
293
|
+
up_cls = self._tasks[up_name]
|
|
294
|
+
up_output = get_output_type(up_cls)
|
|
295
|
+
up_fields = up_output.model_fields
|
|
296
|
+
if up_field not in up_fields:
|
|
297
|
+
raise WorkflowDefinitionError(
|
|
298
|
+
f"Field '{up_field}' not found on "
|
|
299
|
+
f"{up_output.__name__} (output of {up_name})"
|
|
300
|
+
)
|
|
301
|
+
resolved_type = up_fields[up_field].annotation
|
|
302
|
+
else:
|
|
303
|
+
up_cls = self._tasks[upstream_ref]
|
|
304
|
+
resolved_type = get_output_type(up_cls)
|
|
305
|
+
field_annotation = model_fields[field_name].annotation
|
|
306
|
+
if (
|
|
307
|
+
field_annotation is not None
|
|
308
|
+
and resolved_type is not None
|
|
309
|
+
and not issubclass(resolved_type, field_annotation)
|
|
310
|
+
):
|
|
311
|
+
raise WorkflowDefinitionError(
|
|
312
|
+
f"Fan-in type mismatch: {upstream_ref} outputs "
|
|
313
|
+
f"{resolved_type.__name__} but field '{field_name}' "
|
|
314
|
+
f"on {downstream_input.__name__} expects "
|
|
315
|
+
f"{field_annotation.__name__}"
|
|
316
|
+
)
|
|
317
|
+
# Validate config field names exist on the model
|
|
318
|
+
for field_name in cf:
|
|
319
|
+
if field_name not in model_fields:
|
|
320
|
+
raise WorkflowDefinitionError(
|
|
321
|
+
f"Config field '{field_name}' not found on "
|
|
322
|
+
f"{downstream_input.__name__} (input of '{name}')"
|
|
323
|
+
)
|
|
324
|
+
# Check all required fields are covered by deps or config_fields
|
|
325
|
+
covered = set(deps.keys()) | cf
|
|
326
|
+
for field_name, field_info in model_fields.items():
|
|
327
|
+
if field_name not in covered and field_info.is_required():
|
|
328
|
+
raise IncompleteInputError(
|
|
329
|
+
f"Required field '{field_name}' on "
|
|
330
|
+
f"{downstream_input.__name__} is not mapped to any "
|
|
331
|
+
f"upstream task"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _validate_result_task(self) -> None:
|
|
335
|
+
"""Ensure result_task is set. Default to sole sink; raise if ambiguous."""
|
|
336
|
+
sinks = self._find_sinks()
|
|
337
|
+
if self._result_task_name is None:
|
|
338
|
+
if len(sinks) == 1:
|
|
339
|
+
self._result_task_name = sinks[0]
|
|
340
|
+
else:
|
|
341
|
+
raise WorkflowDefinitionError(
|
|
342
|
+
f"Workflow '{self.name}' has {len(sinks)} sink tasks "
|
|
343
|
+
f"({sinks}); specify result_task explicitly"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def to_mermaid(self, *, job_configuration: JobConfiguration | None = None) -> str:
|
|
347
|
+
"""Return a Mermaid diagram string for this workflow."""
|
|
348
|
+
from taskmaestro.visualization import to_mermaid
|
|
349
|
+
|
|
350
|
+
return to_mermaid(self, job_configuration=job_configuration)
|
|
351
|
+
|
|
352
|
+
def _find_sinks(self) -> list[str]:
|
|
353
|
+
"""Return task names with no downstream dependents."""
|
|
354
|
+
has_dependents: set[str] = set()
|
|
355
|
+
for deps in self._dependencies.values():
|
|
356
|
+
has_dependents.update(_extract_upstream_names(deps))
|
|
357
|
+
return [name for name in self._tasks if name not in has_dependents]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class WorkflowBuilder:
|
|
361
|
+
"""Fluent builder for DAG workflows."""
|
|
362
|
+
|
|
363
|
+
def __init__(
|
|
364
|
+
self,
|
|
365
|
+
name: str,
|
|
366
|
+
*,
|
|
367
|
+
result_task: type[Task[Any, Any]] | str | None = None,
|
|
368
|
+
) -> None:
|
|
369
|
+
self._workflow = Workflow.__new__(Workflow)
|
|
370
|
+
self._workflow.name = name
|
|
371
|
+
self._workflow._tasks = {}
|
|
372
|
+
self._workflow._dependencies = {}
|
|
373
|
+
self._workflow._config_fields = {}
|
|
374
|
+
self._workflow._result_task_name = None
|
|
375
|
+
# Store the raw result_task ref for resolution at build() time
|
|
376
|
+
self._result_task_ref: type[Task[Any, Any]] | str | None = result_task
|
|
377
|
+
|
|
378
|
+
def _resolve_dep_name(self, dep_cls: type[Task[Any, Any]]) -> str:
|
|
379
|
+
"""Resolve a class reference to its registered name.
|
|
380
|
+
|
|
381
|
+
Raises WorkflowDefinitionError if the class is not registered or
|
|
382
|
+
is registered under multiple names (ambiguous).
|
|
383
|
+
"""
|
|
384
|
+
wf = self._workflow
|
|
385
|
+
matches = [name for name, cls in wf._tasks.items() if cls is dep_cls]
|
|
386
|
+
if len(matches) == 1:
|
|
387
|
+
return matches[0]
|
|
388
|
+
if len(matches) == 0:
|
|
389
|
+
raise WorkflowDefinitionError(
|
|
390
|
+
f"Task class '{dep_cls.__name__}' not found in workflow. "
|
|
391
|
+
f"Add it with add_task() before referencing it as a dependency."
|
|
392
|
+
)
|
|
393
|
+
raise WorkflowDefinitionError(
|
|
394
|
+
f"Ambiguous reference to task class '{dep_cls.__name__}' — "
|
|
395
|
+
f"it is registered under multiple names: {matches}. "
|
|
396
|
+
f"Use a string name to disambiguate."
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _resolve_dep_ref(
|
|
400
|
+
self,
|
|
401
|
+
dep: type[Task[Any, Any]] | str,
|
|
402
|
+
) -> str:
|
|
403
|
+
"""Resolve a dependency reference (class or string) to a registered name.
|
|
404
|
+
|
|
405
|
+
String references are accepted as-is (validated at build time),
|
|
406
|
+
allowing forward references to tasks not yet added.
|
|
407
|
+
"""
|
|
408
|
+
if isinstance(dep, str):
|
|
409
|
+
return dep
|
|
410
|
+
return self._resolve_dep_name(dep)
|
|
411
|
+
|
|
412
|
+
def add_task(
|
|
413
|
+
self,
|
|
414
|
+
task_cls: type[Task[Any, Any]],
|
|
415
|
+
*,
|
|
416
|
+
name: str | None = None,
|
|
417
|
+
depends_on: (
|
|
418
|
+
type[Task[Any, Any]]
|
|
419
|
+
| str
|
|
420
|
+
| tuple[type[Task[Any, Any]] | str, str]
|
|
421
|
+
| dict[str, type[Task[Any, Any]] | str | tuple[type[Task[Any, Any]] | str, str]]
|
|
422
|
+
| None
|
|
423
|
+
) = None,
|
|
424
|
+
config_fields: list[str] | None = None,
|
|
425
|
+
) -> WorkflowBuilder:
|
|
426
|
+
"""Add a task to the DAG. Returns self for chaining.
|
|
427
|
+
|
|
428
|
+
``name`` optionally overrides the task class's default name,
|
|
429
|
+
allowing the same class to appear multiple times with different names.
|
|
430
|
+
|
|
431
|
+
``depends_on`` accepts:
|
|
432
|
+
- ``None`` — root task (no upstream)
|
|
433
|
+
- ``TaskClass`` — single upstream, whole output
|
|
434
|
+
- ``"task_name"`` — single upstream by registered name
|
|
435
|
+
- ``(TaskClass | "name", "field")`` — single upstream, specific output field
|
|
436
|
+
- ``{"field": TaskClass | "name", ...}`` — fan-in, whole outputs
|
|
437
|
+
- ``{"field": (TaskClass | "name", "f"), ...}`` — fan-in with field routing
|
|
438
|
+
"""
|
|
439
|
+
wf = self._workflow
|
|
440
|
+
task_name = name if name is not None else task_cls.name
|
|
441
|
+
if task_name in wf._tasks:
|
|
442
|
+
raise WorkflowDefinitionError(f"Duplicate task name '{task_name}'")
|
|
443
|
+
wf._tasks[task_name] = task_cls
|
|
444
|
+
|
|
445
|
+
if depends_on is None:
|
|
446
|
+
wf._dependencies[task_name] = None
|
|
447
|
+
elif isinstance(depends_on, tuple):
|
|
448
|
+
dep_ref, field = depends_on
|
|
449
|
+
resolved_name = self._resolve_dep_ref(dep_ref)
|
|
450
|
+
wf._dependencies[task_name] = (resolved_name, field)
|
|
451
|
+
elif isinstance(depends_on, dict):
|
|
452
|
+
resolved: dict[str, DepValue] = {}
|
|
453
|
+
for field, dep in depends_on.items():
|
|
454
|
+
if isinstance(dep, tuple):
|
|
455
|
+
dep_ref, dep_field = dep
|
|
456
|
+
resolved_name = self._resolve_dep_ref(dep_ref)
|
|
457
|
+
resolved[field] = (resolved_name, dep_field)
|
|
458
|
+
else:
|
|
459
|
+
resolved[field] = self._resolve_dep_ref(dep)
|
|
460
|
+
wf._dependencies[task_name] = resolved
|
|
461
|
+
elif isinstance(depends_on, str):
|
|
462
|
+
resolved_name = self._resolve_dep_ref(depends_on)
|
|
463
|
+
wf._dependencies[task_name] = resolved_name
|
|
464
|
+
else:
|
|
465
|
+
# Class reference
|
|
466
|
+
resolved_name = self._resolve_dep_name(depends_on)
|
|
467
|
+
wf._dependencies[task_name] = resolved_name
|
|
468
|
+
|
|
469
|
+
if config_fields is not None:
|
|
470
|
+
wf._config_fields[task_name] = set(config_fields)
|
|
471
|
+
|
|
472
|
+
return self
|
|
473
|
+
|
|
474
|
+
def build(self) -> Workflow:
|
|
475
|
+
"""Finalize and validate the workflow. Returns an immutable Workflow."""
|
|
476
|
+
# Resolve result_task ref
|
|
477
|
+
ref = self._result_task_ref
|
|
478
|
+
if ref is not None:
|
|
479
|
+
if isinstance(ref, str):
|
|
480
|
+
self._workflow._result_task_name = ref
|
|
481
|
+
else:
|
|
482
|
+
self._workflow._result_task_name = self._resolve_dep_name(ref)
|
|
483
|
+
self._workflow._validate()
|
|
484
|
+
return self._workflow
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Factory for wrapping a Workflow as a single Task."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from taskmaestro.context import ExecutionContext
|
|
8
|
+
from taskmaestro.exceptions import WorkflowDefinitionError
|
|
9
|
+
from taskmaestro.job import EmptyConfig, Job, JobConfiguration, JobStatus
|
|
10
|
+
from taskmaestro.runner import Runner
|
|
11
|
+
from taskmaestro.task import Task, get_input_type, get_output_type
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def workflow_task(
|
|
15
|
+
workflow: Any,
|
|
16
|
+
*,
|
|
17
|
+
name: str | None = None,
|
|
18
|
+
job_configuration: JobConfiguration | None = None,
|
|
19
|
+
) -> type[Task[Any, Any]]:
|
|
20
|
+
"""Create a Task subclass that wraps an entire workflow as a single task.
|
|
21
|
+
|
|
22
|
+
The generated task's input type is derived from the inner workflow's root task,
|
|
23
|
+
and its output type from the inner workflow's result task.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
workflow: The inner Workflow to wrap.
|
|
27
|
+
name: Optional name override; defaults to workflow.name.
|
|
28
|
+
job_configuration: Optional JobConfiguration for inner tasks with config_fields.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A new Task subclass that runs the inner workflow.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
WorkflowDefinitionError: If the inner workflow does not have exactly one
|
|
35
|
+
root task without config_fields (unless all roots are covered by
|
|
36
|
+
job_configuration).
|
|
37
|
+
"""
|
|
38
|
+
# Find root tasks: tasks with deps=None and no config_fields
|
|
39
|
+
roots: list[tuple[str, type[Task[Any, Any]]]] = []
|
|
40
|
+
for task_name, deps in workflow._dependencies.items():
|
|
41
|
+
if deps is None:
|
|
42
|
+
config_fields = workflow.get_config_fields(task_name)
|
|
43
|
+
if not config_fields:
|
|
44
|
+
roots.append((task_name, workflow._tasks[task_name]))
|
|
45
|
+
|
|
46
|
+
all_roots_configured = False
|
|
47
|
+
|
|
48
|
+
if len(roots) == 0:
|
|
49
|
+
if job_configuration is not None:
|
|
50
|
+
# All roots have config_fields covered by job_configuration.
|
|
51
|
+
# The wrapper is self-contained — use EmptyConfig as input type.
|
|
52
|
+
all_roots_configured = True
|
|
53
|
+
input_type: type[Any] = EmptyConfig
|
|
54
|
+
else:
|
|
55
|
+
raise WorkflowDefinitionError(
|
|
56
|
+
f"Inner workflow '{workflow.name}' has no root tasks without config_fields; "
|
|
57
|
+
f"cannot derive input type for workflow_task"
|
|
58
|
+
)
|
|
59
|
+
elif len(roots) > 1:
|
|
60
|
+
root_names = [r[0] for r in roots]
|
|
61
|
+
raise WorkflowDefinitionError(
|
|
62
|
+
f"Inner workflow '{workflow.name}' has multiple root tasks without "
|
|
63
|
+
f"config_fields ({root_names}); workflow_task requires exactly one"
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
input_type = get_input_type(roots[0][1])
|
|
67
|
+
|
|
68
|
+
result_task_cls = workflow.result_task
|
|
69
|
+
output_type = get_output_type(result_task_cls)
|
|
70
|
+
|
|
71
|
+
resolved_name = name if name is not None else workflow.name
|
|
72
|
+
inner_wf = workflow
|
|
73
|
+
inner_jc = job_configuration
|
|
74
|
+
_all_configured = all_roots_configured
|
|
75
|
+
|
|
76
|
+
class _WorkflowTask(Task[input_type, output_type]): # type: ignore[valid-type]
|
|
77
|
+
name = resolved_name
|
|
78
|
+
_inner_workflow = inner_wf
|
|
79
|
+
|
|
80
|
+
def run(self, input: Any, ctx: ExecutionContext) -> Any:
|
|
81
|
+
cfg = EmptyConfig() if _all_configured else input
|
|
82
|
+
job = Job(workflow=inner_wf, config=cfg, job_configuration=inner_jc)
|
|
83
|
+
result_job = Runner().run(job, ctx=ctx)
|
|
84
|
+
if result_job.status == JobStatus.FAILED:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
f"Inner workflow '{inner_wf.name}' failed at task "
|
|
87
|
+
f"'{result_job.failed_task}': {result_job.error}"
|
|
88
|
+
)
|
|
89
|
+
return result_job.result
|
|
90
|
+
|
|
91
|
+
_WorkflowTask.__name__ = f"WorkflowTask_{resolved_name}"
|
|
92
|
+
_WorkflowTask.__qualname__ = f"WorkflowTask_{resolved_name}"
|
|
93
|
+
|
|
94
|
+
return _WorkflowTask
|