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/task.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Task base class with typed inputs/outputs and type introspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Generic, TypeVar, get_args
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from taskmaestro.context import ExecutionContext
|
|
12
|
+
|
|
13
|
+
I = TypeVar("I", bound=BaseModel)
|
|
14
|
+
O = TypeVar("O", bound=BaseModel)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Task(ABC, Generic[I, O]):
|
|
18
|
+
"""Base class for all tasks.
|
|
19
|
+
|
|
20
|
+
Parameterized by input type I and output type O, both Pydantic models.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
timeout_seconds: float | None = None
|
|
25
|
+
|
|
26
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
27
|
+
super().__init_subclass__(**kwargs)
|
|
28
|
+
if "name" not in cls.__dict__:
|
|
29
|
+
cls.name = cls.__name__
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def run(self, input: I, ctx: ExecutionContext) -> O:
|
|
33
|
+
"""Execute the task. Receives validated input and context, returns validated output."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_input_type(task_cls: type[Task[Any, Any]]) -> type[BaseModel]:
|
|
38
|
+
"""Extract the concrete input type (I) from a Task subclass.
|
|
39
|
+
|
|
40
|
+
Checks for an inner ``Inputs`` class first, then falls back to
|
|
41
|
+
generic type argument introspection.
|
|
42
|
+
"""
|
|
43
|
+
inner: type[Any] | None = getattr(task_cls, "Inputs", None)
|
|
44
|
+
if inner is not None and isinstance(inner, type) and issubclass(inner, BaseModel):
|
|
45
|
+
return inner
|
|
46
|
+
return _get_type_arg(task_cls, 0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_output_type(task_cls: type[Task[Any, Any]]) -> type[BaseModel]:
|
|
50
|
+
"""Extract the concrete output type (O) from a Task subclass.
|
|
51
|
+
|
|
52
|
+
Checks for an inner ``Outputs`` class first, then falls back to
|
|
53
|
+
generic type argument introspection.
|
|
54
|
+
"""
|
|
55
|
+
inner: type[Any] | None = getattr(task_cls, "Outputs", None)
|
|
56
|
+
if inner is not None and isinstance(inner, type) and issubclass(inner, BaseModel):
|
|
57
|
+
return inner
|
|
58
|
+
return _get_type_arg(task_cls, 1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_type_arg(task_cls: type[Task[Any, Any]], index: int) -> type[BaseModel]:
|
|
62
|
+
"""Walk the MRO to find concrete type arguments for Task[I, O]."""
|
|
63
|
+
for base in task_cls.__mro__:
|
|
64
|
+
for orig_base in getattr(base, "__orig_bases__", ()):
|
|
65
|
+
origin = typing.get_origin(orig_base)
|
|
66
|
+
if origin is Task or (origin is not None and issubclass(origin, Task)):
|
|
67
|
+
args = get_args(orig_base)
|
|
68
|
+
if args and len(args) > index:
|
|
69
|
+
arg = args[index]
|
|
70
|
+
if isinstance(arg, type) and issubclass(arg, BaseModel):
|
|
71
|
+
return arg
|
|
72
|
+
if isinstance(arg, TypeVar):
|
|
73
|
+
continue
|
|
74
|
+
raise TypeError(
|
|
75
|
+
f"Cannot resolve type argument {index} for {task_cls.__name__}. "
|
|
76
|
+
f"Ensure the class directly specifies concrete types in Task[I, O]."
|
|
77
|
+
)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Mermaid diagram visualization for workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from taskmaestro.task import get_input_type, get_output_type
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from taskmaestro.job import JobConfiguration
|
|
12
|
+
from taskmaestro.workflow import Workflow
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _safe_type_name(tp: type, context_cls: type | None = None) -> str:
|
|
16
|
+
"""Return a Mermaid-safe type name, resolving module-level aliases.
|
|
17
|
+
|
|
18
|
+
When *context_cls* is provided, its module namespace is scanned for a
|
|
19
|
+
variable that refers to *tp*, so that ``GridCase = ObjectModel[X]``
|
|
20
|
+
renders as ``GridCase`` instead of ``ObjectModel[X]``.
|
|
21
|
+
"""
|
|
22
|
+
name = tp.__name__ if hasattr(tp, "__name__") else str(tp)
|
|
23
|
+
if "[" not in name:
|
|
24
|
+
return name
|
|
25
|
+
# Try to find a module-level alias name for this generic type
|
|
26
|
+
module_name = getattr(context_cls, "__module__", None) if context_cls else None
|
|
27
|
+
if module_name and module_name in sys.modules:
|
|
28
|
+
mod = sys.modules[module_name]
|
|
29
|
+
for attr_name, attr_val in list(vars(mod).items()):
|
|
30
|
+
if attr_val is tp and not attr_name.startswith("_"):
|
|
31
|
+
return attr_name
|
|
32
|
+
return name.replace("[", "‹").replace("]", "›")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _field_type_label(task_by_name: dict[str, type], upstream_name: str, field_name: str) -> str:
|
|
36
|
+
"""Return ``'.field: FieldType'`` for a field-ref edge."""
|
|
37
|
+
upstream_cls = task_by_name[upstream_name]
|
|
38
|
+
output_model = get_output_type(upstream_cls)
|
|
39
|
+
field_info = output_model.model_fields[field_name]
|
|
40
|
+
annotation = field_info.annotation
|
|
41
|
+
type_label = _safe_type_name(annotation, upstream_cls) if annotation is not None else "Any"
|
|
42
|
+
return f".{field_name}: {type_label}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _apply_redirect(name: str, redirect: dict[str, str]) -> str:
|
|
46
|
+
"""Replace *name* with its redirect target if one exists."""
|
|
47
|
+
return redirect.get(name, name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _emit_edges(
|
|
51
|
+
tasks: list[tuple[str, type]],
|
|
52
|
+
workflow: Workflow,
|
|
53
|
+
task_by_name: dict[str, type],
|
|
54
|
+
configured_tasks: set[str],
|
|
55
|
+
lines: list[str],
|
|
56
|
+
indent: str,
|
|
57
|
+
source_redirect: dict[str, str],
|
|
58
|
+
target_redirect: dict[str, str],
|
|
59
|
+
*,
|
|
60
|
+
skip_start: bool = False,
|
|
61
|
+
skip_end_sinks: bool = False,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Emit edge lines for a set of tasks, applying redirects."""
|
|
64
|
+
for task_name, task_cls in tasks:
|
|
65
|
+
deps = workflow.get_dependencies(task_name)
|
|
66
|
+
tgt_name = _apply_redirect(task_name, target_redirect)
|
|
67
|
+
|
|
68
|
+
if deps is None:
|
|
69
|
+
if not skip_start and task_name not in configured_tasks:
|
|
70
|
+
input_name = _safe_type_name(get_input_type(task_cls), task_cls)
|
|
71
|
+
lines.append(f"{indent}_start_ -->|{input_name}| {tgt_name}")
|
|
72
|
+
elif isinstance(deps, str):
|
|
73
|
+
upstream_src = _apply_redirect(deps, source_redirect)
|
|
74
|
+
upstream_cls = task_by_name[deps]
|
|
75
|
+
output_name = _safe_type_name(get_output_type(upstream_cls), upstream_cls)
|
|
76
|
+
lines.append(f"{indent}{upstream_src} -->|{output_name}| {tgt_name}")
|
|
77
|
+
elif isinstance(deps, tuple):
|
|
78
|
+
upstream_name, field_name = deps
|
|
79
|
+
upstream_src = _apply_redirect(upstream_name, source_redirect)
|
|
80
|
+
label = _field_type_label(task_by_name, upstream_name, field_name)
|
|
81
|
+
lines.append(f"{indent}{upstream_src} -->|{label}| {tgt_name}")
|
|
82
|
+
elif isinstance(deps, dict):
|
|
83
|
+
for down_field, upstream_ref in sorted(deps.items()):
|
|
84
|
+
if isinstance(upstream_ref, tuple):
|
|
85
|
+
upstream_name, up_field = upstream_ref
|
|
86
|
+
upstream_src = _apply_redirect(upstream_name, source_redirect)
|
|
87
|
+
label = _field_type_label(task_by_name, upstream_name, up_field)
|
|
88
|
+
lines.append(f"{indent}{upstream_src} -->|{down_field}: {label}| {tgt_name}")
|
|
89
|
+
else:
|
|
90
|
+
upstream_src = _apply_redirect(upstream_ref, source_redirect)
|
|
91
|
+
up_cls = task_by_name[upstream_ref]
|
|
92
|
+
output_name = _safe_type_name(get_output_type(up_cls), up_cls)
|
|
93
|
+
lines.append(
|
|
94
|
+
f"{indent}{upstream_src} -->|{down_field}: {output_name}| {tgt_name}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def to_mermaid(
|
|
99
|
+
workflow: Workflow,
|
|
100
|
+
*,
|
|
101
|
+
job_configuration: JobConfiguration | None = None,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Generate a Mermaid diagram string for a workflow.
|
|
104
|
+
|
|
105
|
+
Returns a ``graph TD`` block with a start node, task nodes, and edges
|
|
106
|
+
labeled with the data types flowing between them.
|
|
107
|
+
|
|
108
|
+
When *job_configuration* is provided, adds a ``_job_config_`` node with
|
|
109
|
+
dashed edges to each configured task.
|
|
110
|
+
|
|
111
|
+
Tasks created via ``workflow_task`` are expanded into Mermaid subgraphs
|
|
112
|
+
showing the inner workflow's structure.
|
|
113
|
+
"""
|
|
114
|
+
from taskmaestro.workflow import _extract_upstream_names
|
|
115
|
+
|
|
116
|
+
lines: list[str] = ["---", f"title: {workflow.name}", "---", "graph TD"]
|
|
117
|
+
|
|
118
|
+
tasks = workflow.topological_order()
|
|
119
|
+
task_by_name: dict[str, type] = {name: cls for name, cls in tasks}
|
|
120
|
+
|
|
121
|
+
# Find sink tasks (no downstream dependents)
|
|
122
|
+
has_dependents: set[str] = set()
|
|
123
|
+
for task_name, _task_cls in tasks:
|
|
124
|
+
deps = workflow.get_dependencies(task_name)
|
|
125
|
+
has_dependents.update(_extract_upstream_names(deps))
|
|
126
|
+
sinks = [(name, cls) for name, cls in tasks if name not in has_dependents]
|
|
127
|
+
|
|
128
|
+
# Collect tasks with config_fields
|
|
129
|
+
configured_tasks = {name for name, _cls in tasks if workflow.get_config_fields(name)}
|
|
130
|
+
|
|
131
|
+
# Detect workflow_task nodes and build redirect maps
|
|
132
|
+
source_redirect: dict[str, str] = {}
|
|
133
|
+
target_redirect: dict[str, str] = {}
|
|
134
|
+
workflow_task_nodes: dict[str, Workflow] = {}
|
|
135
|
+
|
|
136
|
+
for task_name, task_cls in tasks:
|
|
137
|
+
inner_wf = getattr(task_cls, "_inner_workflow", None)
|
|
138
|
+
if inner_wf is not None:
|
|
139
|
+
workflow_task_nodes[task_name] = inner_wf
|
|
140
|
+
# Find inner root(s): tasks with no dependencies and no config_fields
|
|
141
|
+
inner_tasks = inner_wf.topological_order()
|
|
142
|
+
for iname, _icls in inner_tasks:
|
|
143
|
+
ideps = inner_wf.get_dependencies(iname)
|
|
144
|
+
if ideps is None and not inner_wf.get_config_fields(iname):
|
|
145
|
+
target_redirect[task_name] = f"{task_name}__{iname}"
|
|
146
|
+
break
|
|
147
|
+
# Result task → source redirect
|
|
148
|
+
source_redirect[task_name] = f"{task_name}__{inner_wf.result_task_name}"
|
|
149
|
+
|
|
150
|
+
# Start and end nodes
|
|
151
|
+
lines.append(' _start_(("start"))')
|
|
152
|
+
lines.append(' _end_(("end"))')
|
|
153
|
+
|
|
154
|
+
# JobConfiguration node (if there are configured tasks)
|
|
155
|
+
if configured_tasks:
|
|
156
|
+
lines.append(' _job_config_[("JobConfiguration")]')
|
|
157
|
+
|
|
158
|
+
# Task node definitions
|
|
159
|
+
for task_name, _task_cls in tasks:
|
|
160
|
+
if task_name in workflow_task_nodes:
|
|
161
|
+
# Render as subgraph
|
|
162
|
+
inner_wf = workflow_task_nodes[task_name]
|
|
163
|
+
inner_tasks = inner_wf.topological_order()
|
|
164
|
+
inner_task_by_name: dict[str, type] = {n: c for n, c in inner_tasks}
|
|
165
|
+
inner_configured = {n for n, _c in inner_tasks if inner_wf.get_config_fields(n)}
|
|
166
|
+
|
|
167
|
+
lines.append(f' subgraph {task_name}["{task_name}"]')
|
|
168
|
+
|
|
169
|
+
# Inner node definitions
|
|
170
|
+
for iname, _icls in inner_tasks:
|
|
171
|
+
prefixed = f"{task_name}__{iname}"
|
|
172
|
+
lines.append(f' {prefixed}["{iname}"]')
|
|
173
|
+
|
|
174
|
+
# Inner edges (skip start/end — handled by outer redirects)
|
|
175
|
+
inner_source_redirect: dict[str, str] = {
|
|
176
|
+
n: f"{task_name}__{n}" for n, _c in inner_tasks
|
|
177
|
+
}
|
|
178
|
+
inner_target_redirect: dict[str, str] = {
|
|
179
|
+
n: f"{task_name}__{n}" for n, _c in inner_tasks
|
|
180
|
+
}
|
|
181
|
+
_emit_edges(
|
|
182
|
+
inner_tasks,
|
|
183
|
+
inner_wf,
|
|
184
|
+
inner_task_by_name,
|
|
185
|
+
inner_configured,
|
|
186
|
+
lines,
|
|
187
|
+
" ",
|
|
188
|
+
inner_source_redirect,
|
|
189
|
+
inner_target_redirect,
|
|
190
|
+
skip_start=True,
|
|
191
|
+
skip_end_sinks=True,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
lines.append(" end")
|
|
195
|
+
else:
|
|
196
|
+
lines.append(f' {task_name}["{task_name}"]')
|
|
197
|
+
|
|
198
|
+
# Outer edge definitions
|
|
199
|
+
_emit_edges(
|
|
200
|
+
tasks,
|
|
201
|
+
workflow,
|
|
202
|
+
task_by_name,
|
|
203
|
+
configured_tasks,
|
|
204
|
+
lines,
|
|
205
|
+
" ",
|
|
206
|
+
source_redirect,
|
|
207
|
+
target_redirect,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# JobConfiguration dashed edges to configured tasks
|
|
211
|
+
if configured_tasks:
|
|
212
|
+
for task_name in sorted(configured_tasks):
|
|
213
|
+
cf = workflow.get_config_fields(task_name)
|
|
214
|
+
label = ", ".join(sorted(cf))
|
|
215
|
+
lines.append(f" _job_config_ -.->|{label}| {task_name}")
|
|
216
|
+
|
|
217
|
+
# Sink tasks: edge to end, labeled with output type
|
|
218
|
+
for task_name, task_cls in sinks:
|
|
219
|
+
src = _apply_redirect(task_name, source_redirect)
|
|
220
|
+
output_name = _safe_type_name(get_output_type(task_cls), task_cls)
|
|
221
|
+
lines.append(f" {src} -->|{output_name}| _end_")
|
|
222
|
+
|
|
223
|
+
return "\n".join(lines) + "\n"
|