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,455 @@
1
+ """YAML-based workflow configuration loader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import typing
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+ from pydantic import BaseModel, Field, ValidationError, model_validator
13
+
14
+ from taskmaestro.context import ExecutionContext
15
+ from taskmaestro.exceptions import ConfigLoadError
16
+ from taskmaestro.hooks.base import BaseHook
17
+ from taskmaestro.job import EmptyConfig, Job, JobConfiguration
18
+ from taskmaestro.runner import Runner
19
+ from taskmaestro.task import Task, get_input_type
20
+ from taskmaestro.workflow import Workflow, WorkflowBuilder
21
+
22
+ # --- Pydantic schema models for YAML validation ---
23
+
24
+
25
+ class TaskConfig(BaseModel):
26
+ """A single task entry in the YAML workflow config."""
27
+
28
+ task: str | None = None
29
+ workflow: str | None = None
30
+ workflow_input: str | None = None
31
+ name: str | None = None
32
+ depends_on: str | list[str] | dict[str, Any] | None = None
33
+ config_fields: list[str] | None = None
34
+
35
+ @model_validator(mode="after")
36
+ def _check_task_or_workflow(self) -> TaskConfig:
37
+ if self.task and self.workflow:
38
+ raise ValueError("Specify either 'task' or 'workflow', not both")
39
+ if not self.task and not self.workflow:
40
+ raise ValueError("Must specify either 'task' or 'workflow'")
41
+ if self.workflow_input and not self.workflow:
42
+ raise ValueError("'workflow_input' requires 'workflow'")
43
+ return self
44
+
45
+
46
+ class HookConfig(BaseModel):
47
+ """A single hook entry in the YAML runner config."""
48
+
49
+ hook: str
50
+ params: dict[str, Any] = Field(default_factory=dict)
51
+
52
+
53
+ class RunnerConfig(BaseModel):
54
+ """Runner configuration section."""
55
+
56
+ timeout_seconds: float | None = None
57
+ hooks: list[HookConfig] = Field(default_factory=list)
58
+
59
+
60
+ class ContextConfig(BaseModel):
61
+ """Context configuration section."""
62
+
63
+ correlation_id: str | None = None
64
+ scratch_dir: str | None = None
65
+ services: dict[str, Any] = Field(default_factory=dict)
66
+
67
+
68
+ class WorkflowSectionConfig(BaseModel):
69
+ """Workflow section of the YAML config."""
70
+
71
+ name: str
72
+ result_task: str | None = None
73
+ tasks: list[TaskConfig] = Field(min_length=1)
74
+
75
+
76
+ class YamlWorkflowConfig(BaseModel):
77
+ """Top-level YAML workflow configuration."""
78
+
79
+ workflow: WorkflowSectionConfig
80
+ runner: RunnerConfig = Field(default_factory=RunnerConfig)
81
+ context: ContextConfig = Field(default_factory=ContextConfig)
82
+
83
+
84
+ # --- Utilities ---
85
+
86
+
87
+ def import_class(dotted_path: str) -> type[Any]:
88
+ """Import a class from a dotted path like 'pkg.mod.ClassName'.
89
+
90
+ Raises ConfigLoadError if the module or class cannot be found.
91
+ """
92
+ parts = dotted_path.rsplit(".", 1)
93
+ if len(parts) != 2:
94
+ raise ConfigLoadError(f"Invalid import path '{dotted_path}': expected 'module.ClassName'")
95
+ module_path, class_name = parts
96
+ try:
97
+ module = importlib.import_module(module_path)
98
+ except ModuleNotFoundError as exc:
99
+ raise ConfigLoadError(f"Cannot import module '{module_path}': {exc}") from exc
100
+ try:
101
+ cls = getattr(module, class_name)
102
+ except AttributeError:
103
+ raise ConfigLoadError(f"Module '{module_path}' has no attribute '{class_name}'") from None
104
+ return cls # type: ignore[no-any-return]
105
+
106
+
107
+ def _coerce_hook_params(hook_cls: type[Any], params: dict[str, Any]) -> dict[str, Any]:
108
+ """Inspect hook __init__ signature and coerce string values to Path where annotated."""
109
+ coerced = dict(params)
110
+ try:
111
+ hints = typing.get_type_hints(hook_cls.__init__)
112
+ except Exception:
113
+ return coerced
114
+ for param_name, annotation in hints.items():
115
+ if param_name == "return":
116
+ continue
117
+ if param_name in coerced and annotation is Path:
118
+ coerced[param_name] = Path(coerced[param_name])
119
+ return coerced
120
+
121
+
122
+ # --- LoadedWorkflow ---
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class LoadedWorkflow:
127
+ """A fully resolved workflow ready to execute."""
128
+
129
+ workflow: Workflow
130
+ runner: Runner
131
+ job: Job[Any]
132
+ context: ExecutionContext
133
+ _timeout: float | None = None
134
+
135
+ def run(self) -> Job[Any]:
136
+ """Execute the loaded workflow."""
137
+ return self.runner.run(
138
+ self.job,
139
+ ctx=self.context,
140
+ timeout_seconds=self._timeout,
141
+ )
142
+
143
+ @property
144
+ def _timeout_seconds(self) -> float | None:
145
+ """Access timeout value."""
146
+ return self._timeout
147
+
148
+
149
+ # --- Main loader ---
150
+
151
+
152
+ def _load_workflow_only(
153
+ workflow_path: Path,
154
+ input_path: Path | None = None,
155
+ ) -> tuple[Workflow, JobConfiguration | None]:
156
+ """Build a Workflow and optional JobConfiguration from YAML files.
157
+
158
+ This is the core logic shared by ``load_workflow_from_yaml`` and
159
+ recursive ``workflow:`` references in YAML configs.
160
+
161
+ Returns (workflow, job_configuration).
162
+ """
163
+ from taskmaestro.workflow_task import workflow_task as _workflow_task
164
+
165
+ # 1. Parse workflow YAML
166
+ try:
167
+ raw = yaml.safe_load(workflow_path.read_text())
168
+ except yaml.YAMLError as exc:
169
+ raise ConfigLoadError(f"YAML parse error: {exc}") from exc
170
+ except OSError as exc:
171
+ raise ConfigLoadError(f"Cannot read file '{workflow_path}': {exc}") from exc
172
+
173
+ if not isinstance(raw, dict):
174
+ raise ConfigLoadError("YAML file must contain a mapping at top level")
175
+
176
+ # 2. Parse input YAML (optional for inner workflows)
177
+ raw_input: dict[str, Any] = {}
178
+ if input_path is not None:
179
+ try:
180
+ raw_input = yaml.safe_load(input_path.read_text())
181
+ except yaml.YAMLError as exc:
182
+ raise ConfigLoadError(f"Input YAML parse error: {exc}") from exc
183
+ except OSError as exc:
184
+ raise ConfigLoadError(f"Cannot read input file '{input_path}': {exc}") from exc
185
+
186
+ if not isinstance(raw_input, dict):
187
+ raise ConfigLoadError("Input YAML file must contain a mapping")
188
+
189
+ # 3. Schema validation
190
+ try:
191
+ config = YamlWorkflowConfig.model_validate(raw)
192
+ except ValidationError as exc:
193
+ raise ConfigLoadError(f"YAML schema validation error: {exc}") from exc
194
+
195
+ # 4. Resolve task import paths (handles both task: and workflow: entries)
196
+ base_dir = workflow_path.parent
197
+ task_classes: dict[str, type[Task[Any, Any]]] = {}
198
+ for task_config in config.workflow.tasks:
199
+ if task_config.workflow:
200
+ # Recursive workflow reference
201
+ inner_wf_path = base_dir / task_config.workflow
202
+ inner_input_path = (
203
+ base_dir / task_config.workflow_input if task_config.workflow_input else None
204
+ )
205
+ inner_wf, inner_jc = _load_workflow_only(inner_wf_path, inner_input_path)
206
+ inner_name = task_config.name if task_config.name else inner_wf.name
207
+ wrapped_cls = _workflow_task(inner_wf, name=inner_name, job_configuration=inner_jc)
208
+ # Use a synthetic key for this entry (the workflow path)
209
+ task_classes[task_config.workflow] = wrapped_cls
210
+ else:
211
+ assert task_config.task is not None
212
+ cls = import_class(task_config.task)
213
+ if not (isinstance(cls, type) and issubclass(cls, Task)):
214
+ raise ConfigLoadError(f"'{task_config.task}' is not a Task subclass")
215
+ task_classes[task_config.task] = cls
216
+
217
+ # Helper to get the lookup key for a task config entry
218
+ def _task_key(tc: TaskConfig) -> str:
219
+ return tc.workflow if tc.workflow else tc.task # type: ignore[return-value]
220
+
221
+ # 5. Build a lookup from instance names and import paths to registered names.
222
+ name_lookup: dict[str, str] = {}
223
+ for task_config in config.workflow.tasks:
224
+ key = _task_key(task_config)
225
+ registered_name = task_config.name if task_config.name else task_classes[key].name
226
+ name_lookup[key] = registered_name
227
+ if task_config.name:
228
+ name_lookup[task_config.name] = registered_name
229
+
230
+ def _resolve_yaml_dep(dep_str: str, context_task: str) -> str:
231
+ """Resolve a YAML dependency string to a registered task name."""
232
+ if dep_str in name_lookup:
233
+ return name_lookup[dep_str]
234
+ raise ConfigLoadError(f"Dependency '{dep_str}' for task '{context_task}' not found")
235
+
236
+ # 6. Detect linear vs DAG mode
237
+ has_depends_on = any(tc.depends_on is not None for tc in config.workflow.tasks)
238
+
239
+ # 6b. Detect per-task config format early (before building workflow)
240
+ all_registered_names: set[str] = set()
241
+ for task_config in config.workflow.tasks:
242
+ key = _task_key(task_config)
243
+ registered_name = task_config.name if task_config.name else task_classes[key].name
244
+ all_registered_names.add(registered_name)
245
+
246
+ is_per_task_config = bool(raw_input) and all(
247
+ key in all_registered_names and isinstance(raw_input[key], (dict, type(None)))
248
+ for key in raw_input
249
+ )
250
+
251
+ per_task_data: dict[str, dict[str, Any]] = {}
252
+ per_task_cfg_fields: dict[str, list[str]] = {}
253
+ if is_per_task_config:
254
+ for task_name, task_values in raw_input.items():
255
+ per_task_data[task_name] = dict(task_values) if task_values else {}
256
+ if task_values:
257
+ per_task_cfg_fields[task_name] = list(task_values.keys())
258
+
259
+ # 7. Resolve result_task
260
+ result_task_name: str | None = None
261
+ if config.workflow.result_task:
262
+ if config.workflow.result_task in name_lookup:
263
+ result_task_name = name_lookup[config.workflow.result_task]
264
+ else:
265
+ raise ConfigLoadError(f"result_task '{config.workflow.result_task}' not found")
266
+
267
+ # 8. Build Workflow
268
+ if not has_depends_on:
269
+ task_list = [task_classes[_task_key(tc)] for tc in config.workflow.tasks]
270
+ result_task_cls = (
271
+ task_classes[config.workflow.result_task] if config.workflow.result_task else None
272
+ )
273
+ workflow = Workflow(
274
+ name=config.workflow.name,
275
+ tasks=task_list,
276
+ result_task=result_task_cls,
277
+ )
278
+ for task_config in config.workflow.tasks:
279
+ key = _task_key(task_config)
280
+ registered_name = task_config.name if task_config.name else task_classes[key].name
281
+ cfg = task_config.config_fields or per_task_cfg_fields.get(registered_name)
282
+ if cfg:
283
+ workflow._config_fields[registered_name] = set(cfg)
284
+ else:
285
+ builder = WorkflowBuilder(
286
+ config.workflow.name,
287
+ result_task=result_task_name,
288
+ )
289
+ for task_config in config.workflow.tasks:
290
+ key = _task_key(task_config)
291
+ cls = task_classes[key]
292
+ deps = task_config.depends_on
293
+ registered_name = name_lookup[key]
294
+ instance_name = task_config.name
295
+ cfg_fields = task_config.config_fields or per_task_cfg_fields.get(registered_name)
296
+ if deps is None:
297
+ builder.add_task(cls, name=instance_name, config_fields=cfg_fields)
298
+ elif isinstance(deps, str):
299
+ resolved_dep = _resolve_yaml_dep(deps, key)
300
+ builder.add_task(
301
+ cls, name=instance_name, depends_on=resolved_dep, config_fields=cfg_fields
302
+ )
303
+ elif isinstance(deps, list):
304
+ if len(deps) != 2 or not all(isinstance(e, str) for e in deps):
305
+ raise ConfigLoadError(
306
+ f"List depends_on must be [task_path, field_name], "
307
+ f"got {deps!r} for task '{key}'"
308
+ )
309
+ upstream_path, field_name = deps
310
+ resolved_dep = _resolve_yaml_dep(upstream_path, key)
311
+ builder.add_task(
312
+ cls,
313
+ name=instance_name,
314
+ depends_on=(resolved_dep, field_name),
315
+ config_fields=cfg_fields,
316
+ )
317
+ elif isinstance(deps, dict):
318
+ fan_in: dict[
319
+ str, type[Task[Any, Any]] | str | tuple[type[Task[Any, Any]] | str, str]
320
+ ] = {}
321
+ for field_name, upstream_ref in deps.items():
322
+ if isinstance(upstream_ref, list):
323
+ if len(upstream_ref) != 2 or not all(
324
+ isinstance(e, str) for e in upstream_ref
325
+ ):
326
+ raise ConfigLoadError(
327
+ f"List dep must be [task_path, field_name], "
328
+ f"got {upstream_ref!r} for field '{field_name}' "
329
+ f"on task '{key}'"
330
+ )
331
+ up_path, up_field = upstream_ref
332
+ resolved_dep = _resolve_yaml_dep(up_path, key)
333
+ fan_in[field_name] = (resolved_dep, up_field)
334
+ else:
335
+ resolved_dep = _resolve_yaml_dep(upstream_ref, key)
336
+ fan_in[field_name] = resolved_dep
337
+ builder.add_task(
338
+ cls, name=instance_name, depends_on=fan_in, config_fields=cfg_fields
339
+ )
340
+ try:
341
+ workflow = builder.build()
342
+ except Exception as exc:
343
+ raise ConfigLoadError(f"Workflow validation failed: {exc}") from exc
344
+
345
+ # 9. Build JobConfiguration if per-task config detected
346
+ job_configuration: JobConfiguration | None = None
347
+ if is_per_task_config:
348
+ job_configuration = JobConfiguration(per_task_data)
349
+
350
+ return workflow, job_configuration
351
+
352
+
353
+ def load_workflow_from_yaml(workflow_path: str | Path, input_path: str | Path) -> LoadedWorkflow:
354
+ """Load a complete workflow configuration from a workflow YAML file and an input YAML file.
355
+
356
+ Returns a LoadedWorkflow with workflow, runner, job, and context
357
+ ready to execute.
358
+
359
+ Raises ConfigLoadError for any loading or validation failure.
360
+ """
361
+ workflow_path = Path(workflow_path)
362
+ input_path = Path(input_path)
363
+
364
+ # 1. Parse workflow YAML (needed for runner/context config)
365
+ try:
366
+ raw = yaml.safe_load(workflow_path.read_text())
367
+ except yaml.YAMLError as exc:
368
+ raise ConfigLoadError(f"YAML parse error: {exc}") from exc
369
+ except OSError as exc:
370
+ raise ConfigLoadError(f"Cannot read file '{workflow_path}': {exc}") from exc
371
+
372
+ if not isinstance(raw, dict):
373
+ raise ConfigLoadError("YAML file must contain a mapping at top level")
374
+
375
+ # 2. Parse input YAML
376
+ try:
377
+ raw_input = yaml.safe_load(input_path.read_text())
378
+ except yaml.YAMLError as exc:
379
+ raise ConfigLoadError(f"Input YAML parse error: {exc}") from exc
380
+ except OSError as exc:
381
+ raise ConfigLoadError(f"Cannot read input file '{input_path}': {exc}") from exc
382
+
383
+ if not isinstance(raw_input, dict):
384
+ raise ConfigLoadError("Input YAML file must contain a mapping")
385
+
386
+ # 3. Schema validation (for runner/context sections)
387
+ try:
388
+ config = YamlWorkflowConfig.model_validate(raw)
389
+ except ValidationError as exc:
390
+ raise ConfigLoadError(f"YAML schema validation error: {exc}") from exc
391
+
392
+ # 4. Build workflow and job_configuration via shared helper
393
+ workflow, job_configuration = _load_workflow_only(workflow_path, input_path)
394
+
395
+ # 5. Validate input and build Job
396
+ job: Job[Any]
397
+ if job_configuration is not None:
398
+ job = Job(workflow, EmptyConfig(), job_configuration=job_configuration)
399
+ else:
400
+ # Flat config mode: find root tasks from the built workflow
401
+ root_task_classes = [
402
+ workflow._tasks[task_name]
403
+ for task_name, deps in workflow._dependencies.items()
404
+ if deps is None and not workflow.get_config_fields(task_name)
405
+ ]
406
+ assert root_task_classes, (
407
+ "job_configuration is None yet no roots found without config_fields"
408
+ )
409
+
410
+ input_type = get_input_type(root_task_classes[0])
411
+ try:
412
+ validated_input = input_type.model_validate(raw_input)
413
+ except ValidationError as exc:
414
+ raise ConfigLoadError(f"Input validation error: {exc}") from exc
415
+
416
+ job = Job(workflow, validated_input)
417
+
418
+ # 6. Instantiate hooks
419
+ hooks: list[BaseHook] = []
420
+ for hook_config in config.runner.hooks:
421
+ hook_cls = import_class(hook_config.hook)
422
+ if not (isinstance(hook_cls, type) and issubclass(hook_cls, BaseHook)):
423
+ raise ConfigLoadError(f"'{hook_config.hook}' is not a BaseHook subclass")
424
+ coerced_params = _coerce_hook_params(hook_cls, hook_config.params)
425
+ try:
426
+ hooks.append(hook_cls(**coerced_params))
427
+ except TypeError as exc:
428
+ raise ConfigLoadError(f"Cannot instantiate hook '{hook_config.hook}': {exc}") from exc
429
+
430
+ runner = Runner(hooks=hooks)
431
+
432
+ # 7. Build ExecutionContext
433
+ ctx_kwargs: dict[str, Any] = {}
434
+ if config.context.correlation_id:
435
+ ctx_kwargs["correlation_id"] = config.context.correlation_id
436
+ if config.context.scratch_dir:
437
+ ctx_kwargs["scratch_dir"] = Path(config.context.scratch_dir)
438
+ context = ExecutionContext(**ctx_kwargs)
439
+ for key, value in config.context.services.items():
440
+ context.register(key, value)
441
+
442
+ # 8. Return LoadedWorkflow
443
+ return LoadedWorkflow(
444
+ workflow=workflow,
445
+ runner=runner,
446
+ job=job,
447
+ context=context,
448
+ _timeout=config.runner.timeout_seconds,
449
+ )
450
+
451
+
452
+ def run_workflow_from_yaml(workflow_path: str | Path, input_path: str | Path) -> Job[Any]:
453
+ """Load and run a workflow from a workflow YAML file and an input YAML file in one step."""
454
+ loaded = load_workflow_from_yaml(workflow_path, input_path)
455
+ return loaded.run()