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/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Workflow Runner: typed DAG task workflows with Pydantic models."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from taskmaestro.context import ExecutionContext
|
|
6
|
+
from taskmaestro.exceptions import (
|
|
7
|
+
ConfigLoadError,
|
|
8
|
+
CycleDetectedError,
|
|
9
|
+
IncompleteInputError,
|
|
10
|
+
JobStateError,
|
|
11
|
+
TaskExecutionError,
|
|
12
|
+
TaskOutputTypeError,
|
|
13
|
+
TaskTimeoutError,
|
|
14
|
+
WorkflowDefinitionError,
|
|
15
|
+
WorkflowRunnerError,
|
|
16
|
+
)
|
|
17
|
+
from taskmaestro.job import EmptyConfig, Job, JobConfiguration, JobStatus, TaskResult, TaskStatus
|
|
18
|
+
from taskmaestro.object_model import ObjectModel
|
|
19
|
+
from taskmaestro.runner import Runner
|
|
20
|
+
from taskmaestro.task import Task
|
|
21
|
+
from taskmaestro.visualization import to_mermaid
|
|
22
|
+
from taskmaestro.workflow import Workflow, WorkflowBuilder
|
|
23
|
+
from taskmaestro.workflow_task import workflow_task
|
|
24
|
+
from taskmaestro.yaml_config import (
|
|
25
|
+
LoadedWorkflow,
|
|
26
|
+
load_workflow_from_yaml,
|
|
27
|
+
run_workflow_from_yaml,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ConfigLoadError",
|
|
32
|
+
"CycleDetectedError",
|
|
33
|
+
"EmptyConfig",
|
|
34
|
+
"ExecutionContext",
|
|
35
|
+
"IncompleteInputError",
|
|
36
|
+
"Job",
|
|
37
|
+
"JobConfiguration",
|
|
38
|
+
"JobStateError",
|
|
39
|
+
"JobStatus",
|
|
40
|
+
"LoadedWorkflow",
|
|
41
|
+
"ObjectModel",
|
|
42
|
+
"Runner",
|
|
43
|
+
"Task",
|
|
44
|
+
"TaskExecutionError",
|
|
45
|
+
"TaskOutputTypeError",
|
|
46
|
+
"TaskResult",
|
|
47
|
+
"TaskStatus",
|
|
48
|
+
"TaskTimeoutError",
|
|
49
|
+
"Workflow",
|
|
50
|
+
"WorkflowBuilder",
|
|
51
|
+
"WorkflowDefinitionError",
|
|
52
|
+
"WorkflowRunnerError",
|
|
53
|
+
"load_workflow_from_yaml",
|
|
54
|
+
"run_workflow_from_yaml",
|
|
55
|
+
"to_mermaid",
|
|
56
|
+
"workflow_task",
|
|
57
|
+
]
|
taskmaestro/context.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Execution context passed to every task during execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import tempfile
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExecutionContext:
|
|
13
|
+
"""Cross-cutting state passed to every task during execution.
|
|
14
|
+
|
|
15
|
+
Carries a correlation ID, logger, scratch directory, and a simple
|
|
16
|
+
service registry for injecting dependencies (DB connections, HTTP
|
|
17
|
+
clients, etc.) into tasks.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
correlation_id: str | None = None,
|
|
23
|
+
logger: logging.Logger | None = None,
|
|
24
|
+
scratch_dir: Path | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.correlation_id = correlation_id or str(uuid.uuid4())
|
|
27
|
+
self.logger = logger or logging.getLogger("taskmaestro")
|
|
28
|
+
self.scratch_dir = scratch_dir or Path(tempfile.gettempdir()) / self.correlation_id
|
|
29
|
+
self._registry: dict[str, Any] = {}
|
|
30
|
+
|
|
31
|
+
def register(self, key: str, service: Any) -> None:
|
|
32
|
+
"""Register a service by key."""
|
|
33
|
+
self._registry[key] = service
|
|
34
|
+
|
|
35
|
+
def resolve(self, key: str) -> Any:
|
|
36
|
+
"""Retrieve a registered service. Raises KeyError if not found."""
|
|
37
|
+
return self._registry[key]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exception hierarchy for the workflow runner library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WorkflowRunnerError(Exception):
|
|
5
|
+
"""Base exception for all workflow runner errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WorkflowDefinitionError(WorkflowRunnerError):
|
|
9
|
+
"""Raised at workflow construction time for invalid definitions."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CycleDetectedError(WorkflowDefinitionError):
|
|
13
|
+
"""Dependency graph contains a cycle."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IncompleteInputError(WorkflowDefinitionError):
|
|
17
|
+
"""Fan-in input has required fields not mapped to any upstream task."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JobStateError(WorkflowRunnerError):
|
|
21
|
+
"""Job not in expected state (e.g., attempting to re-run a completed job)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TaskExecutionError(WorkflowRunnerError):
|
|
25
|
+
"""Raised during task execution."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TaskOutputTypeError(TaskExecutionError):
|
|
29
|
+
"""Task returned an output whose type doesn't match the declared output type."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TaskTimeoutError(TaskExecutionError):
|
|
33
|
+
"""Raised when a task exceeds its timeout_seconds."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigLoadError(WorkflowRunnerError):
|
|
37
|
+
"""Raised when YAML config loading fails (parse errors, import failures, validation)."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Built-in hooks for the workflow runner."""
|
|
2
|
+
|
|
3
|
+
from taskmaestro.hooks.base import BaseHook, Event, Hook
|
|
4
|
+
from taskmaestro.hooks.logging import LoggingHook
|
|
5
|
+
from taskmaestro.hooks.persistence import ResultPersistenceHook
|
|
6
|
+
from taskmaestro.hooks.timing import TimingHook
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseHook",
|
|
10
|
+
"Event",
|
|
11
|
+
"Hook",
|
|
12
|
+
"LoggingHook",
|
|
13
|
+
"ResultPersistenceHook",
|
|
14
|
+
"TimingHook",
|
|
15
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Hook protocol and base implementation for lifecycle events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from taskmaestro.job import Job
|
|
12
|
+
from taskmaestro.task import Task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Event(StrEnum):
|
|
16
|
+
"""Lifecycle events emitted by the runner."""
|
|
17
|
+
|
|
18
|
+
JOB_START = "job_start"
|
|
19
|
+
JOB_COMPLETE = "job_complete"
|
|
20
|
+
JOB_FAIL = "job_fail"
|
|
21
|
+
TASK_START = "task_start"
|
|
22
|
+
TASK_COMPLETE = "task_complete"
|
|
23
|
+
TASK_FAIL = "task_fail"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class Hook(Protocol):
|
|
28
|
+
"""Protocol for lifecycle event hooks."""
|
|
29
|
+
|
|
30
|
+
def on_job_start(self, job: Job[Any]) -> None: ...
|
|
31
|
+
def on_job_complete(self, job: Job[Any]) -> None: ...
|
|
32
|
+
def on_job_fail(self, job: Job[Any]) -> None: ...
|
|
33
|
+
def on_task_start(self, job: Job[Any], task: Task[Any, Any]) -> None: ...
|
|
34
|
+
def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None: ...
|
|
35
|
+
def on_task_fail(self, job: Job[Any], task: Task[Any, Any], error: Exception) -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BaseHook:
|
|
39
|
+
"""Base hook with no-op defaults for all events."""
|
|
40
|
+
|
|
41
|
+
def on_job_start(self, job: Job[Any]) -> None:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def on_job_complete(self, job: Job[Any]) -> None:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def on_job_fail(self, job: Job[Any]) -> None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def on_task_start(self, job: Job[Any], task: Task[Any, Any]) -> None:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def on_task_fail(self, job: Job[Any], task: Task[Any, Any], error: Exception) -> None:
|
|
57
|
+
pass
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Logging hook that logs lifecycle events via Python logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from taskmaestro.hooks.base import BaseHook
|
|
11
|
+
from taskmaestro.job import Job
|
|
12
|
+
from taskmaestro.task import Task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoggingHook(BaseHook):
|
|
16
|
+
"""Logs all lifecycle events via Python's logging module."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, level: int = logging.INFO) -> None:
|
|
19
|
+
self._level = level
|
|
20
|
+
self._logger = logging.getLogger("taskmaestro.hooks.logging")
|
|
21
|
+
|
|
22
|
+
def on_job_start(self, job: Job[Any]) -> None:
|
|
23
|
+
self._logger.log(self._level, "Job started: workflow=%s", job.workflow.name)
|
|
24
|
+
|
|
25
|
+
def on_job_complete(self, job: Job[Any]) -> None:
|
|
26
|
+
self._logger.log(self._level, "Job completed: workflow=%s", job.workflow.name)
|
|
27
|
+
|
|
28
|
+
def on_job_fail(self, job: Job[Any]) -> None:
|
|
29
|
+
self._logger.log(
|
|
30
|
+
self._level,
|
|
31
|
+
"Job failed: workflow=%s, failed_task=%s, error=%s",
|
|
32
|
+
job.workflow.name,
|
|
33
|
+
job.failed_task,
|
|
34
|
+
job.error,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def on_task_start(self, job: Job[Any], task: Task[Any, Any]) -> None:
|
|
38
|
+
self._logger.log(self._level, "Task started: %s", task.name)
|
|
39
|
+
|
|
40
|
+
def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None:
|
|
41
|
+
self._logger.log(self._level, "Task completed: %s", task.name)
|
|
42
|
+
|
|
43
|
+
def on_task_fail(self, job: Job[Any], task: Task[Any, Any], error: Exception) -> None:
|
|
44
|
+
self._logger.log(self._level, "Task failed: %s, error=%s", task.name, error)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Result persistence hook that writes task outputs to JSON files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from taskmaestro.hooks.base import BaseHook
|
|
11
|
+
from taskmaestro.job import Job
|
|
12
|
+
from taskmaestro.task import Task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResultPersistenceHook(BaseHook):
|
|
16
|
+
"""Writes {task_name}.json per completed task to an output directory."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, output_dir: Path) -> None:
|
|
19
|
+
self.output_dir = output_dir
|
|
20
|
+
|
|
21
|
+
def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None:
|
|
22
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
output_path = self.output_dir / f"{task.name}.json"
|
|
24
|
+
output_path.write_text(output.model_dump_json(indent=2))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Timing hook that records wall-clock durations for jobs and tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from taskmaestro.hooks.base import BaseHook
|
|
11
|
+
from taskmaestro.job import Job
|
|
12
|
+
from taskmaestro.task import Task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TimingHook(BaseHook):
|
|
16
|
+
"""Records wall-clock duration per task and total job time via time.monotonic()."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.job_duration: float | None = None
|
|
20
|
+
self.task_timings: dict[str, float] = {}
|
|
21
|
+
self._job_start: float | None = None
|
|
22
|
+
self._task_starts: dict[str, float] = {}
|
|
23
|
+
|
|
24
|
+
def on_job_start(self, job: Job[Any]) -> None:
|
|
25
|
+
self._job_start = time.monotonic()
|
|
26
|
+
|
|
27
|
+
def on_job_complete(self, job: Job[Any]) -> None:
|
|
28
|
+
if self._job_start is not None:
|
|
29
|
+
self.job_duration = time.monotonic() - self._job_start
|
|
30
|
+
|
|
31
|
+
def on_job_fail(self, job: Job[Any]) -> None:
|
|
32
|
+
if self._job_start is not None:
|
|
33
|
+
self.job_duration = time.monotonic() - self._job_start
|
|
34
|
+
|
|
35
|
+
def on_task_start(self, job: Job[Any], task: Task[Any, Any]) -> None:
|
|
36
|
+
self._task_starts[task.name] = time.monotonic()
|
|
37
|
+
|
|
38
|
+
def on_task_complete(self, job: Job[Any], task: Task[Any, Any], output: BaseModel) -> None:
|
|
39
|
+
start = self._task_starts.get(task.name)
|
|
40
|
+
if start is not None:
|
|
41
|
+
self.task_timings[task.name] = time.monotonic() - start
|
|
42
|
+
|
|
43
|
+
def on_task_fail(self, job: Job[Any], task: Task[Any, Any], error: Exception) -> None:
|
|
44
|
+
start = self._task_starts.get(task.name)
|
|
45
|
+
if start is not None:
|
|
46
|
+
self.task_timings[task.name] = time.monotonic() - start
|
taskmaestro/job.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Job: a workflow bound to a specific config, ready to execute."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from taskmaestro.exceptions import WorkflowDefinitionError
|
|
13
|
+
from taskmaestro.task import get_input_type
|
|
14
|
+
from taskmaestro.workflow import Workflow
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobStatus(StrEnum):
|
|
18
|
+
"""Status of a job."""
|
|
19
|
+
|
|
20
|
+
PENDING = "pending"
|
|
21
|
+
RUNNING = "running"
|
|
22
|
+
COMPLETED = "completed"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskStatus(StrEnum):
|
|
27
|
+
"""Status of an individual task execution."""
|
|
28
|
+
|
|
29
|
+
COMPLETED = "completed"
|
|
30
|
+
FAILED = "failed"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TaskResult:
|
|
35
|
+
"""Record of a single task's execution within a job."""
|
|
36
|
+
|
|
37
|
+
task_name: str
|
|
38
|
+
status: TaskStatus
|
|
39
|
+
output: BaseModel | None
|
|
40
|
+
started_at: datetime
|
|
41
|
+
duration_seconds: float
|
|
42
|
+
error: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EmptyConfig(BaseModel):
|
|
46
|
+
"""Sentinel config for workflows where all root tasks use JobConfiguration."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class JobConfiguration:
|
|
50
|
+
"""Per-task configuration values, mapping task names to config field dicts.
|
|
51
|
+
|
|
52
|
+
Used to provide static configuration values (from YAML or code) that get
|
|
53
|
+
merged with upstream outputs when constructing task inputs.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, config: dict[str, dict[str, Any]]) -> None:
|
|
57
|
+
self._config = config
|
|
58
|
+
|
|
59
|
+
def get_config_for_task(self, name: str) -> dict[str, Any]:
|
|
60
|
+
"""Return config values for a task, or empty dict if none."""
|
|
61
|
+
return dict(self._config.get(name, {}))
|
|
62
|
+
|
|
63
|
+
def configured_tasks(self) -> set[str]:
|
|
64
|
+
"""Return set of task names that have configuration."""
|
|
65
|
+
return set(self._config.keys())
|
|
66
|
+
|
|
67
|
+
def config_fields_for_task(self, name: str) -> set[str]:
|
|
68
|
+
"""Return set of field names configured for a task."""
|
|
69
|
+
return set(self._config.get(name, {}).keys())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
C = TypeVar("C", bound=BaseModel)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Job(Generic[C]):
|
|
76
|
+
"""A workflow bound to a specific config, ready to execute.
|
|
77
|
+
|
|
78
|
+
Generic over C so that job.config retains its concrete type.
|
|
79
|
+
Validates that the config type matches the input type of all root tasks.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
workflow: Workflow,
|
|
85
|
+
config: C,
|
|
86
|
+
*,
|
|
87
|
+
job_configuration: JobConfiguration | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.workflow = workflow
|
|
90
|
+
self.config: C = config
|
|
91
|
+
self.job_configuration = job_configuration
|
|
92
|
+
self.status: JobStatus = JobStatus.PENDING
|
|
93
|
+
self.result: BaseModel | None = None
|
|
94
|
+
self.error: str | None = None
|
|
95
|
+
self.failed_task: str | None = None
|
|
96
|
+
self.started_at: datetime | None = None
|
|
97
|
+
self.completed_at: datetime | None = None
|
|
98
|
+
self.task_results: list[TaskResult] = []
|
|
99
|
+
|
|
100
|
+
self._validate_root_task_inputs(config)
|
|
101
|
+
|
|
102
|
+
def _validate_root_task_inputs(self, config: C) -> None:
|
|
103
|
+
"""Validate that config type matches the input type of all root tasks."""
|
|
104
|
+
for task_name, deps in self.workflow._dependencies.items():
|
|
105
|
+
if deps is None:
|
|
106
|
+
# Skip validation for root tasks that have config_fields
|
|
107
|
+
config_fields = self.workflow.get_config_fields(task_name)
|
|
108
|
+
if config_fields:
|
|
109
|
+
continue
|
|
110
|
+
task_cls = self.workflow._tasks[task_name]
|
|
111
|
+
expected_input = get_input_type(task_cls)
|
|
112
|
+
if not isinstance(config, expected_input):
|
|
113
|
+
raise WorkflowDefinitionError(
|
|
114
|
+
f"Root task '{task_name}' expects input type "
|
|
115
|
+
f"{expected_input.__name__} but got {type(config).__name__}"
|
|
116
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Generic base model for wrapping arbitrary (non-Pydantic) objects."""
|
|
2
|
+
|
|
3
|
+
from typing import Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ObjectModel(BaseModel, Generic[T]):
|
|
11
|
+
"""Base model for wrapping arbitrary (non-Pydantic) objects.
|
|
12
|
+
|
|
13
|
+
Enables arbitrary_types_allowed so fields can hold native library
|
|
14
|
+
objects like database connections, API clients, etc.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
18
|
+
|
|
19
|
+
value: T
|
taskmaestro/py.typed
ADDED
|
File without changes
|
taskmaestro/runner.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Runner: the synchronous execution engine for workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import signal
|
|
6
|
+
import warnings
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from taskmaestro.context import ExecutionContext
|
|
13
|
+
from taskmaestro.exceptions import (
|
|
14
|
+
JobStateError,
|
|
15
|
+
TaskOutputTypeError,
|
|
16
|
+
TaskTimeoutError,
|
|
17
|
+
)
|
|
18
|
+
from taskmaestro.hooks.base import BaseHook, Event
|
|
19
|
+
from taskmaestro.job import Job, JobStatus, TaskResult, TaskStatus
|
|
20
|
+
from taskmaestro.task import get_input_type, get_output_type
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Runner:
|
|
24
|
+
"""Synchronous execution engine for workflows.
|
|
25
|
+
|
|
26
|
+
Iterates tasks in topological order, assembling each task's input
|
|
27
|
+
from the outputs of its upstream dependencies.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, hooks: list[BaseHook] | None = None) -> None:
|
|
31
|
+
self.hooks: list[BaseHook] = hooks or []
|
|
32
|
+
|
|
33
|
+
def run(
|
|
34
|
+
self,
|
|
35
|
+
job: Job[Any],
|
|
36
|
+
ctx: ExecutionContext | None = None,
|
|
37
|
+
timeout_seconds: float | None = None,
|
|
38
|
+
) -> Job[Any]:
|
|
39
|
+
"""Execute all tasks in topological order. Stops on first failure."""
|
|
40
|
+
if job.status != JobStatus.PENDING:
|
|
41
|
+
raise JobStateError(f"Cannot run job with status '{job.status}'; expected 'pending'")
|
|
42
|
+
|
|
43
|
+
ctx = ctx or ExecutionContext()
|
|
44
|
+
workflow = job.workflow
|
|
45
|
+
|
|
46
|
+
self._emit(Event.JOB_START, job)
|
|
47
|
+
job.status = JobStatus.RUNNING
|
|
48
|
+
job.started_at = datetime.now()
|
|
49
|
+
|
|
50
|
+
# Set up job-level timeout
|
|
51
|
+
job_alarm_set = False
|
|
52
|
+
if timeout_seconds is not None:
|
|
53
|
+
job_alarm_set = self._set_alarm(timeout_seconds, "Job")
|
|
54
|
+
|
|
55
|
+
outputs: dict[str, BaseModel] = {}
|
|
56
|
+
job_config = job.job_configuration
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
for task_name, task_cls in workflow.topological_order():
|
|
60
|
+
task = task_cls()
|
|
61
|
+
task.name = task_name # instance-level override for named instances
|
|
62
|
+
deps = workflow.get_dependencies(task_name)
|
|
63
|
+
config_fields = workflow.get_config_fields(task_name)
|
|
64
|
+
config_values = (
|
|
65
|
+
job_config.get_config_for_task(task_name)
|
|
66
|
+
if job_config and config_fields
|
|
67
|
+
else {}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Assemble input based on dependency type
|
|
71
|
+
if deps is None:
|
|
72
|
+
if config_values:
|
|
73
|
+
# Root task with config: build input from config values
|
|
74
|
+
input_type = get_input_type(task_cls)
|
|
75
|
+
task_input = input_type.model_validate(config_values)
|
|
76
|
+
else:
|
|
77
|
+
task_input = job.config
|
|
78
|
+
elif isinstance(deps, str):
|
|
79
|
+
if config_values:
|
|
80
|
+
# Single dep with config: decompose upstream, merge with config
|
|
81
|
+
input_type = get_input_type(task_cls)
|
|
82
|
+
upstream_data = outputs[deps].model_dump()
|
|
83
|
+
down_fields = input_type.model_fields
|
|
84
|
+
merged: dict[str, object] = {
|
|
85
|
+
k: v for k, v in upstream_data.items() if k in down_fields
|
|
86
|
+
}
|
|
87
|
+
merged.update(config_values)
|
|
88
|
+
task_input = input_type.model_validate(merged)
|
|
89
|
+
else:
|
|
90
|
+
task_input = outputs[deps]
|
|
91
|
+
elif isinstance(deps, tuple):
|
|
92
|
+
upstream_name, field_name = deps
|
|
93
|
+
task_input = getattr(outputs[upstream_name], field_name)
|
|
94
|
+
elif isinstance(deps, dict):
|
|
95
|
+
input_type = get_input_type(task_cls)
|
|
96
|
+
field_values: dict[str, object] = {}
|
|
97
|
+
for fname, upstream_ref in deps.items():
|
|
98
|
+
if isinstance(upstream_ref, tuple):
|
|
99
|
+
up_name, up_field = upstream_ref
|
|
100
|
+
field_values[fname] = getattr(outputs[up_name], up_field)
|
|
101
|
+
else:
|
|
102
|
+
field_values[fname] = outputs[upstream_ref]
|
|
103
|
+
if config_values:
|
|
104
|
+
field_values.update(config_values)
|
|
105
|
+
task_input = input_type.model_validate(field_values)
|
|
106
|
+
else:
|
|
107
|
+
task_input = job.config # pragma: no cover
|
|
108
|
+
|
|
109
|
+
task_started = datetime.now()
|
|
110
|
+
self._emit(Event.TASK_START, job, task)
|
|
111
|
+
|
|
112
|
+
# Set up per-task timeout
|
|
113
|
+
task_alarm_set = False
|
|
114
|
+
if task.timeout_seconds is not None:
|
|
115
|
+
task_alarm_set = self._set_alarm(task.timeout_seconds, task.name)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
output = task.run(task_input, ctx)
|
|
119
|
+
|
|
120
|
+
# Validate output matches declared type
|
|
121
|
+
expected_output_type = get_output_type(task_cls)
|
|
122
|
+
if not isinstance(output, expected_output_type):
|
|
123
|
+
raise TaskOutputTypeError(
|
|
124
|
+
f"Task '{task.name}' returned {type(output).__name__}, "
|
|
125
|
+
f"expected {expected_output_type.__name__}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
duration = (datetime.now() - task_started).total_seconds()
|
|
129
|
+
outputs[task.name] = output
|
|
130
|
+
job.task_results.append(
|
|
131
|
+
TaskResult(
|
|
132
|
+
task_name=task.name,
|
|
133
|
+
status=TaskStatus.COMPLETED,
|
|
134
|
+
output=output,
|
|
135
|
+
started_at=task_started,
|
|
136
|
+
duration_seconds=duration,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
self._emit(Event.TASK_COMPLETE, job, task, output)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
duration = (datetime.now() - task_started).total_seconds()
|
|
142
|
+
job.status = JobStatus.FAILED
|
|
143
|
+
job.error = str(exc)
|
|
144
|
+
job.failed_task = task.name
|
|
145
|
+
job.completed_at = datetime.now()
|
|
146
|
+
job.task_results.append(
|
|
147
|
+
TaskResult(
|
|
148
|
+
task_name=task.name,
|
|
149
|
+
status=TaskStatus.FAILED,
|
|
150
|
+
output=None,
|
|
151
|
+
started_at=task_started,
|
|
152
|
+
duration_seconds=duration,
|
|
153
|
+
error=str(exc),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
self._emit(Event.TASK_FAIL, job, task, exc)
|
|
157
|
+
self._emit(Event.JOB_FAIL, job)
|
|
158
|
+
return job
|
|
159
|
+
finally:
|
|
160
|
+
if task_alarm_set:
|
|
161
|
+
signal.alarm(0)
|
|
162
|
+
finally:
|
|
163
|
+
if job_alarm_set:
|
|
164
|
+
signal.alarm(0)
|
|
165
|
+
|
|
166
|
+
job.status = JobStatus.COMPLETED
|
|
167
|
+
job.result = outputs[workflow.result_task_name]
|
|
168
|
+
job.completed_at = datetime.now()
|
|
169
|
+
self._emit(Event.JOB_COMPLETE, job)
|
|
170
|
+
return job
|
|
171
|
+
|
|
172
|
+
def _set_alarm(self, seconds: float, label: str) -> bool:
|
|
173
|
+
"""Set a signal.alarm for timeout. Returns True if alarm was set."""
|
|
174
|
+
try:
|
|
175
|
+
|
|
176
|
+
def _handler(signum: int, frame: Any) -> None:
|
|
177
|
+
raise TaskTimeoutError(f"{label} timed out after {seconds}s")
|
|
178
|
+
|
|
179
|
+
signal.signal(signal.SIGALRM, _handler)
|
|
180
|
+
signal.alarm(int(seconds) if seconds >= 1 else 1)
|
|
181
|
+
return True
|
|
182
|
+
except (AttributeError, OSError):
|
|
183
|
+
warnings.warn(
|
|
184
|
+
f"signal.alarm not available on this platform; "
|
|
185
|
+
f"timeout for {label} will not be enforced",
|
|
186
|
+
stacklevel=2,
|
|
187
|
+
)
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def _emit(self, event: Event, *args: object) -> None:
|
|
191
|
+
"""Dispatch event to all hooks, swallowing any hook errors."""
|
|
192
|
+
for hook in self.hooks:
|
|
193
|
+
handler = getattr(hook, f"on_{event}", None)
|
|
194
|
+
if handler is not None:
|
|
195
|
+
try:
|
|
196
|
+
handler(*args)
|
|
197
|
+
except Exception:
|
|
198
|
+
warnings.warn(
|
|
199
|
+
f"Hook {type(hook).__name__} raised during {event}",
|
|
200
|
+
stacklevel=2,
|
|
201
|
+
)
|