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/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"