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
|
@@ -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()
|