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.
@@ -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