PyTaskWireCheck 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.
- pytaskwirecheck/__init__.py +32 -0
- pytaskwirecheck/api.py +314 -0
- pytaskwirecheck/cache/__init__.py +0 -0
- pytaskwirecheck/cache/fingerprint.py +176 -0
- pytaskwirecheck/cache/policy.py +49 -0
- pytaskwirecheck/cache/store.py +367 -0
- pytaskwirecheck/cli.py +690 -0
- pytaskwirecheck/config/__init__.py +0 -0
- pytaskwirecheck/config/loader.py +72 -0
- pytaskwirecheck/config/schema.py +170 -0
- pytaskwirecheck/config/validation.py +590 -0
- pytaskwirecheck/diagnostics/__init__.py +31 -0
- pytaskwirecheck/diagnostics/errors.py +86 -0
- pytaskwirecheck/engine/__init__.py +0 -0
- pytaskwirecheck/engine/cancellation.py +38 -0
- pytaskwirecheck/engine/events.py +93 -0
- pytaskwirecheck/engine/executor.py +301 -0
- pytaskwirecheck/engine/scheduler.py +312 -0
- pytaskwirecheck/engine/state.py +123 -0
- pytaskwirecheck/graph/__init__.py +0 -0
- pytaskwirecheck/graph/builder.py +33 -0
- pytaskwirecheck/graph/model.py +104 -0
- pytaskwirecheck/graph/planner.py +155 -0
- pytaskwirecheck/graph/validator.py +92 -0
- pytaskwirecheck/history/__init__.py +0 -0
- pytaskwirecheck/history/store.py +164 -0
- pytaskwirecheck/logging/__init__.py +0 -0
- pytaskwirecheck/logging/artifacts.py +95 -0
- pytaskwirecheck/logging/structured.py +83 -0
- pytaskwirecheck/notifications/__init__.py +0 -0
- pytaskwirecheck/notifications/base.py +42 -0
- pytaskwirecheck/notifications/webhook.py +71 -0
- pytaskwirecheck/processes/__init__.py +0 -0
- pytaskwirecheck/processes/runner.py +265 -0
- pytaskwirecheck/processes/signals.py +65 -0
- pytaskwirecheck/py.typed +0 -0
- pytaskwirecheck/tui/__init__.py +0 -0
- pytaskwirecheck/tui/app.py +204 -0
- pytaskwirecheck/tui/renderer.py +143 -0
- pytaskwirecheck-0.1.0.dist-info/METADATA +316 -0
- pytaskwirecheck-0.1.0.dist-info/RECORD +45 -0
- pytaskwirecheck-0.1.0.dist-info/WHEEL +5 -0
- pytaskwirecheck-0.1.0.dist-info/entry_points.txt +2 -0
- pytaskwirecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- pytaskwirecheck-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""The DAG scheduler.
|
|
2
|
+
|
|
3
|
+
The scheduler owns task-state transitions. It selects ready tasks, respects the
|
|
4
|
+
worker limit, applies failure policy, and drives the executor. It never renders
|
|
5
|
+
output or writes cache artifacts directly; it delegates to the executor and
|
|
6
|
+
publishes events.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Dict, List, Mapping, Optional, Set
|
|
17
|
+
|
|
18
|
+
from pytaskwirecheck.config.schema import ExecutionConfig
|
|
19
|
+
from pytaskwirecheck.diagnostics.errors import ExecutionError
|
|
20
|
+
from pytaskwirecheck.engine.cancellation import CancellationToken
|
|
21
|
+
from pytaskwirecheck.engine.events import (
|
|
22
|
+
EventBus,
|
|
23
|
+
RunFinished,
|
|
24
|
+
RunStarted,
|
|
25
|
+
TaskStateChanged,
|
|
26
|
+
)
|
|
27
|
+
from pytaskwirecheck.engine.executor import TaskExecutor, TaskOutcome
|
|
28
|
+
from pytaskwirecheck.engine.state import TaskRecord, TaskStatus
|
|
29
|
+
from pytaskwirecheck.graph.model import TaskGraph
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class RunResult:
|
|
34
|
+
"""The aggregate outcome of a scheduler run."""
|
|
35
|
+
|
|
36
|
+
run_id: str
|
|
37
|
+
records: Dict[str, TaskRecord]
|
|
38
|
+
duration_seconds: float
|
|
39
|
+
cancelled: bool = False
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def succeeded(self) -> bool:
|
|
43
|
+
for record in self.records.values():
|
|
44
|
+
if not record.status.is_success:
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def counts(self) -> Dict[str, int]:
|
|
50
|
+
counts: Dict[str, int] = {}
|
|
51
|
+
for record in self.records.values():
|
|
52
|
+
key = record.status.value
|
|
53
|
+
counts[key] = counts.get(key, 0) + 1
|
|
54
|
+
return counts
|
|
55
|
+
|
|
56
|
+
def summary(self) -> dict:
|
|
57
|
+
tasks = {}
|
|
58
|
+
for task_id, record in self.records.items():
|
|
59
|
+
tasks[task_id] = {
|
|
60
|
+
"status": record.status.value,
|
|
61
|
+
"exit_code": record.exit_code,
|
|
62
|
+
"attempts": record.attempt,
|
|
63
|
+
"duration_seconds": round(record.duration_seconds, 4),
|
|
64
|
+
"cache_hit": record.cache_hit,
|
|
65
|
+
"message": record.message,
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
"run_id": self.run_id,
|
|
69
|
+
"succeeded": self.succeeded,
|
|
70
|
+
"cancelled": self.cancelled,
|
|
71
|
+
"duration_seconds": round(self.duration_seconds, 4),
|
|
72
|
+
"counts": self.counts,
|
|
73
|
+
"tasks": tasks,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Scheduler:
|
|
78
|
+
"""Executes a selected subgraph of tasks with bounded concurrency."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
graph: TaskGraph,
|
|
84
|
+
execution: ExecutionConfig,
|
|
85
|
+
executor: TaskExecutor,
|
|
86
|
+
events: EventBus,
|
|
87
|
+
cancel_token: CancellationToken,
|
|
88
|
+
use_cache: bool = True,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._graph = graph
|
|
91
|
+
self._execution = execution
|
|
92
|
+
self._executor = executor
|
|
93
|
+
self._events = events
|
|
94
|
+
self._cancel = cancel_token
|
|
95
|
+
self._use_cache = use_cache
|
|
96
|
+
self._lock = threading.Lock()
|
|
97
|
+
|
|
98
|
+
def run(self, selected: Set[str], *, run_id: Optional[str] = None) -> RunResult:
|
|
99
|
+
"""Execute the selected tasks and return a :class:`RunResult`."""
|
|
100
|
+
if not selected:
|
|
101
|
+
raise ExecutionError("no tasks selected for execution")
|
|
102
|
+
|
|
103
|
+
known = set(self._graph.nodes.keys())
|
|
104
|
+
selected = {t for t in selected if t in known}
|
|
105
|
+
order_index = {
|
|
106
|
+
task_id: index for index, task_id in enumerate(self._graph.nodes.keys())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
records: Dict[str, TaskRecord] = {
|
|
110
|
+
task_id: TaskRecord(
|
|
111
|
+
task_id=task_id,
|
|
112
|
+
max_attempts=max(1, self._graph.get(task_id).config.retry.max_attempts),
|
|
113
|
+
)
|
|
114
|
+
for task_id in selected
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
remaining: Dict[str, int] = {}
|
|
118
|
+
for task_id in selected:
|
|
119
|
+
deps = [d for d in self._graph.dependencies(task_id) if d in selected]
|
|
120
|
+
remaining[task_id] = len(deps)
|
|
121
|
+
|
|
122
|
+
ready: List[str] = sorted(
|
|
123
|
+
(t for t in selected if remaining[t] == 0),
|
|
124
|
+
key=order_index.__getitem__,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
run_id = run_id or uuid.uuid4().hex[:12]
|
|
128
|
+
started = time.monotonic()
|
|
129
|
+
self._events.emit(
|
|
130
|
+
RunStarted(
|
|
131
|
+
run_id=run_id, tasks=tuple(sorted(selected)), total_tasks=len(selected)
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
stop_scheduling = False
|
|
136
|
+
future_to_task: Dict[Future, str] = {}
|
|
137
|
+
running: Dict[str, Future] = {}
|
|
138
|
+
|
|
139
|
+
with ThreadPoolExecutor(
|
|
140
|
+
max_workers=self._execution.max_workers,
|
|
141
|
+
thread_name_prefix="pytaskwirecheck-worker",
|
|
142
|
+
) as pool:
|
|
143
|
+
while ready or running:
|
|
144
|
+
if self._cancel.is_cancelled:
|
|
145
|
+
stop_scheduling = True
|
|
146
|
+
|
|
147
|
+
while (
|
|
148
|
+
ready
|
|
149
|
+
and not stop_scheduling
|
|
150
|
+
and len(running) < self._execution.max_workers
|
|
151
|
+
):
|
|
152
|
+
task_id = ready.pop(0)
|
|
153
|
+
record = records[task_id]
|
|
154
|
+
if record.status is not TaskStatus.PENDING:
|
|
155
|
+
continue
|
|
156
|
+
dependency_keys = {
|
|
157
|
+
dep: records[dep].cache_key
|
|
158
|
+
for dep in self._graph.dependencies(task_id)
|
|
159
|
+
if dep in selected
|
|
160
|
+
}
|
|
161
|
+
record.transition(TaskStatus.READY)
|
|
162
|
+
record.transition(TaskStatus.RUNNING)
|
|
163
|
+
record.started_at = time.monotonic()
|
|
164
|
+
self._events.emit(
|
|
165
|
+
TaskStateChanged(task_id=task_id, status=TaskStatus.RUNNING)
|
|
166
|
+
)
|
|
167
|
+
future = pool.submit(self._execute_task, task_id, dependency_keys)
|
|
168
|
+
future_to_task[future] = task_id
|
|
169
|
+
running[task_id] = future
|
|
170
|
+
|
|
171
|
+
if not running:
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
done, _ = wait(list(running.values()), return_when=FIRST_COMPLETED)
|
|
175
|
+
for future in done:
|
|
176
|
+
task_id = future_to_task.pop(future)
|
|
177
|
+
running.pop(task_id, None)
|
|
178
|
+
try:
|
|
179
|
+
outcome = future.result()
|
|
180
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
181
|
+
outcome = TaskOutcome(
|
|
182
|
+
task_id=task_id,
|
|
183
|
+
status=TaskStatus.FAILED,
|
|
184
|
+
message="internal executor error: %s" % exc,
|
|
185
|
+
)
|
|
186
|
+
self._apply_outcome(
|
|
187
|
+
outcome,
|
|
188
|
+
records,
|
|
189
|
+
remaining,
|
|
190
|
+
ready,
|
|
191
|
+
order_index,
|
|
192
|
+
selected,
|
|
193
|
+
)
|
|
194
|
+
if outcome.status.is_failure:
|
|
195
|
+
if self._execution.fail_fast:
|
|
196
|
+
stop_scheduling = True
|
|
197
|
+
if not self._cancel.is_cancelled:
|
|
198
|
+
self._cancel.cancel(
|
|
199
|
+
"fail-fast: task '%s' %s"
|
|
200
|
+
% (task_id, outcome.status.value)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Any task not started (e.g. fail-fast) becomes cancelled.
|
|
204
|
+
for task_id, record in records.items():
|
|
205
|
+
if not record.status.is_terminal:
|
|
206
|
+
record.transition(
|
|
207
|
+
TaskStatus.CANCELLED,
|
|
208
|
+
message="not started",
|
|
209
|
+
)
|
|
210
|
+
self._events.emit(
|
|
211
|
+
TaskStateChanged(
|
|
212
|
+
task_id=task_id,
|
|
213
|
+
status=TaskStatus.CANCELLED,
|
|
214
|
+
message="not started",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
duration = time.monotonic() - started
|
|
219
|
+
result = RunResult(
|
|
220
|
+
run_id=run_id,
|
|
221
|
+
records=records,
|
|
222
|
+
duration_seconds=duration,
|
|
223
|
+
cancelled=self._cancel.is_cancelled,
|
|
224
|
+
)
|
|
225
|
+
self._events.emit(
|
|
226
|
+
RunFinished(
|
|
227
|
+
run_id=run_id,
|
|
228
|
+
succeeded=result.succeeded,
|
|
229
|
+
summary=result.summary(),
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
def _execute_task(
|
|
235
|
+
self, task_id: str, dependency_keys: Mapping[str, Optional[str]]
|
|
236
|
+
) -> TaskOutcome:
|
|
237
|
+
task = self._graph.get(task_id).config
|
|
238
|
+
return self._executor.execute(task, dependency_keys, use_cache=self._use_cache)
|
|
239
|
+
|
|
240
|
+
def _apply_outcome(
|
|
241
|
+
self,
|
|
242
|
+
outcome: TaskOutcome,
|
|
243
|
+
records: Dict[str, TaskRecord],
|
|
244
|
+
remaining: Dict[str, int],
|
|
245
|
+
ready: List[str],
|
|
246
|
+
order_index: Mapping[str, int],
|
|
247
|
+
selected: Set[str],
|
|
248
|
+
) -> None:
|
|
249
|
+
task_id = outcome.task_id
|
|
250
|
+
record = records[task_id]
|
|
251
|
+
record.attempt = outcome.attempts
|
|
252
|
+
record.exit_code = outcome.exit_code
|
|
253
|
+
record.duration_seconds = outcome.duration_seconds
|
|
254
|
+
record.cache_hit = outcome.cache_hit
|
|
255
|
+
record.cache_key = outcome.cache_key
|
|
256
|
+
record.output_bytes = outcome.output_bytes
|
|
257
|
+
record.finished_at = time.monotonic()
|
|
258
|
+
record.transition(outcome.status, message=outcome.message)
|
|
259
|
+
self._events.emit(
|
|
260
|
+
TaskStateChanged(
|
|
261
|
+
task_id=task_id,
|
|
262
|
+
status=outcome.status,
|
|
263
|
+
message=outcome.message,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if outcome.status.is_success:
|
|
268
|
+
newly_ready: List[str] = []
|
|
269
|
+
for dependent in self._graph.dependents(task_id):
|
|
270
|
+
if dependent not in selected:
|
|
271
|
+
continue
|
|
272
|
+
if remaining.get(dependent, 0) > 0:
|
|
273
|
+
remaining[dependent] -= 1
|
|
274
|
+
if (
|
|
275
|
+
remaining.get(dependent) == 0
|
|
276
|
+
and records[dependent].status is TaskStatus.PENDING
|
|
277
|
+
):
|
|
278
|
+
newly_ready.append(dependent)
|
|
279
|
+
if newly_ready:
|
|
280
|
+
ready.extend(newly_ready)
|
|
281
|
+
ready.sort(key=order_index.__getitem__)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Failure (including cancellation/timeout): block transitive dependents.
|
|
285
|
+
self._block_dependents(task_id, records, selected)
|
|
286
|
+
|
|
287
|
+
def _block_dependents(
|
|
288
|
+
self,
|
|
289
|
+
task_id: str,
|
|
290
|
+
records: Dict[str, TaskRecord],
|
|
291
|
+
selected: Set[str],
|
|
292
|
+
) -> None:
|
|
293
|
+
stack = [task_id]
|
|
294
|
+
while stack:
|
|
295
|
+
current = stack.pop()
|
|
296
|
+
for dependent in self._graph.dependents(current):
|
|
297
|
+
if dependent not in selected:
|
|
298
|
+
continue
|
|
299
|
+
record = records[dependent]
|
|
300
|
+
if record.status in (TaskStatus.PENDING, TaskStatus.READY):
|
|
301
|
+
record.transition(
|
|
302
|
+
TaskStatus.BLOCKED,
|
|
303
|
+
message="upstream task failed",
|
|
304
|
+
)
|
|
305
|
+
self._events.emit(
|
|
306
|
+
TaskStateChanged(
|
|
307
|
+
task_id=dependent,
|
|
308
|
+
status=TaskStatus.BLOCKED,
|
|
309
|
+
message="upstream task failed",
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
stack.append(dependent)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Task execution state and the task state machine.
|
|
2
|
+
|
|
3
|
+
Illegal transitions are rejected explicitly so that scheduler bugs surface as
|
|
4
|
+
loud errors instead of corrupting run state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict, FrozenSet, Optional, Set
|
|
12
|
+
|
|
13
|
+
from pytaskwirecheck.diagnostics.errors import ExecutionError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TaskStatus(str, enum.Enum):
|
|
17
|
+
"""Lifecycle states for a single task within a run."""
|
|
18
|
+
|
|
19
|
+
PENDING = "pending"
|
|
20
|
+
READY = "ready"
|
|
21
|
+
RUNNING = "running"
|
|
22
|
+
SUCCEEDED = "succeeded"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
SKIPPED = "skipped"
|
|
25
|
+
CACHED = "cached"
|
|
26
|
+
CANCELLED = "cancelled"
|
|
27
|
+
TIMED_OUT = "timed_out"
|
|
28
|
+
BLOCKED = "blocked"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_terminal(self) -> bool:
|
|
32
|
+
return self in _TERMINAL
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_success(self) -> bool:
|
|
36
|
+
return self in (TaskStatus.SUCCEEDED, TaskStatus.CACHED)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_failure(self) -> bool:
|
|
40
|
+
return self in (
|
|
41
|
+
TaskStatus.FAILED,
|
|
42
|
+
TaskStatus.TIMED_OUT,
|
|
43
|
+
TaskStatus.CANCELLED,
|
|
44
|
+
TaskStatus.BLOCKED,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_TERMINAL: FrozenSet[TaskStatus] = frozenset(
|
|
49
|
+
{
|
|
50
|
+
TaskStatus.SUCCEEDED,
|
|
51
|
+
TaskStatus.FAILED,
|
|
52
|
+
TaskStatus.SKIPPED,
|
|
53
|
+
TaskStatus.CACHED,
|
|
54
|
+
TaskStatus.CANCELLED,
|
|
55
|
+
TaskStatus.TIMED_OUT,
|
|
56
|
+
TaskStatus.BLOCKED,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_ALLOWED_TRANSITIONS: Dict[TaskStatus, Set[TaskStatus]] = {
|
|
61
|
+
TaskStatus.PENDING: {
|
|
62
|
+
TaskStatus.READY,
|
|
63
|
+
TaskStatus.BLOCKED,
|
|
64
|
+
TaskStatus.SKIPPED,
|
|
65
|
+
TaskStatus.CANCELLED,
|
|
66
|
+
},
|
|
67
|
+
TaskStatus.READY: {
|
|
68
|
+
TaskStatus.RUNNING,
|
|
69
|
+
TaskStatus.CACHED,
|
|
70
|
+
TaskStatus.SKIPPED,
|
|
71
|
+
TaskStatus.CANCELLED,
|
|
72
|
+
},
|
|
73
|
+
TaskStatus.RUNNING: {
|
|
74
|
+
TaskStatus.SUCCEEDED,
|
|
75
|
+
TaskStatus.FAILED,
|
|
76
|
+
TaskStatus.TIMED_OUT,
|
|
77
|
+
TaskStatus.CANCELLED,
|
|
78
|
+
TaskStatus.SKIPPED,
|
|
79
|
+
TaskStatus.CACHED,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_transition_allowed(old: TaskStatus, new: TaskStatus) -> bool:
|
|
85
|
+
if old is new:
|
|
86
|
+
return True
|
|
87
|
+
if old.is_terminal:
|
|
88
|
+
return False
|
|
89
|
+
return new in _ALLOWED_TRANSITIONS.get(old, set())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ensure_transition(task_id: str, old: TaskStatus, new: TaskStatus) -> None:
|
|
93
|
+
"""Raise :class:`ExecutionError` if the transition is illegal."""
|
|
94
|
+
if not is_transition_allowed(old, new):
|
|
95
|
+
raise ExecutionError(
|
|
96
|
+
"illegal state transition %s -> %s" % (old.value, new.value),
|
|
97
|
+
task=task_id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class TaskRecord:
|
|
103
|
+
"""Mutable per-task runtime state owned by the scheduler."""
|
|
104
|
+
|
|
105
|
+
task_id: str
|
|
106
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
107
|
+
attempt: int = 0
|
|
108
|
+
max_attempts: int = 1
|
|
109
|
+
exit_code: Optional[int] = None
|
|
110
|
+
duration_seconds: float = 0.0
|
|
111
|
+
cache_hit: bool = False
|
|
112
|
+
cache_key: Optional[str] = None
|
|
113
|
+
message: str = ""
|
|
114
|
+
started_at: Optional[float] = None
|
|
115
|
+
finished_at: Optional[float] = None
|
|
116
|
+
output_bytes: int = 0
|
|
117
|
+
history: list = field(default_factory=list)
|
|
118
|
+
|
|
119
|
+
def transition(self, new: TaskStatus, *, message: str = "") -> None:
|
|
120
|
+
ensure_transition(self.task_id, self.status, new)
|
|
121
|
+
self.status = new
|
|
122
|
+
if message:
|
|
123
|
+
self.message = message
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Construction of a :class:`~pytaskwirecheck.graph.model.TaskGraph` from config.
|
|
2
|
+
|
|
3
|
+
Building is a pure transformation. Missing dependency references are recorded
|
|
4
|
+
faithfully (as edges to absent nodes) so that
|
|
5
|
+
:mod:`pytaskwirecheck.graph.validator` can report them precisely.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List, Set
|
|
11
|
+
|
|
12
|
+
from pytaskwirecheck.config.schema import WorkflowConfig
|
|
13
|
+
from pytaskwirecheck.graph.model import TaskGraph, TaskNode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_graph(config: WorkflowConfig) -> TaskGraph:
|
|
17
|
+
"""Build the task graph from a validated workflow configuration."""
|
|
18
|
+
known: Set[str] = set(config.tasks.keys())
|
|
19
|
+
dependents: Dict[str, List[str]] = {task_id: [] for task_id in known}
|
|
20
|
+
|
|
21
|
+
for task_id, task in config.tasks.items():
|
|
22
|
+
for dependency in task.depends_on:
|
|
23
|
+
if dependency in known:
|
|
24
|
+
dependents[dependency].append(task_id)
|
|
25
|
+
|
|
26
|
+
nodes: Dict[str, TaskNode] = {}
|
|
27
|
+
for task_id, task in config.tasks.items():
|
|
28
|
+
nodes[task_id] = TaskNode(
|
|
29
|
+
config=task,
|
|
30
|
+
depends_on=tuple(task.depends_on),
|
|
31
|
+
dependents=tuple(dependents[task_id]),
|
|
32
|
+
)
|
|
33
|
+
return TaskGraph(nodes)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The task DAG model.
|
|
2
|
+
|
|
3
|
+
The graph is an immutable structure describing which tasks exist and how they
|
|
4
|
+
depend on one another. It contains no scheduling or execution logic; see
|
|
5
|
+
:mod:`pytaskwirecheck.graph.planner` for ordering and :mod:`pytaskwirecheck.engine` for
|
|
6
|
+
execution.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Dict, Iterator, List, Mapping, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from pytaskwirecheck.config.schema import TaskConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TaskNode:
|
|
19
|
+
"""A single node in the task graph."""
|
|
20
|
+
|
|
21
|
+
config: TaskConfig
|
|
22
|
+
depends_on: Tuple[str, ...]
|
|
23
|
+
dependents: Tuple[str, ...]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def id(self) -> str:
|
|
27
|
+
return self.config.id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TaskGraph:
|
|
31
|
+
"""An immutable directed acyclic graph of tasks.
|
|
32
|
+
|
|
33
|
+
Edges point from a dependency to its dependents (i.e. ``depends_on`` edges
|
|
34
|
+
are stored reversed for efficient ready-task computation).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, nodes: Mapping[str, TaskNode]) -> None:
|
|
38
|
+
self._nodes: Dict[str, TaskNode] = dict(nodes)
|
|
39
|
+
|
|
40
|
+
def __contains__(self, task_id: object) -> bool:
|
|
41
|
+
return task_id in self._nodes
|
|
42
|
+
|
|
43
|
+
def __iter__(self) -> Iterator[TaskNode]:
|
|
44
|
+
return iter(self._nodes.values())
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self._nodes)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def nodes(self) -> Mapping[str, TaskNode]:
|
|
51
|
+
return self._nodes
|
|
52
|
+
|
|
53
|
+
def get(self, task_id: str) -> TaskNode:
|
|
54
|
+
return self._nodes[task_id]
|
|
55
|
+
|
|
56
|
+
def dependencies(self, task_id: str) -> Tuple[str, ...]:
|
|
57
|
+
return self._nodes[task_id].depends_on
|
|
58
|
+
|
|
59
|
+
def dependents(self, task_id: str) -> Tuple[str, ...]:
|
|
60
|
+
return self._nodes[task_id].dependents
|
|
61
|
+
|
|
62
|
+
def roots(self) -> Tuple[str, ...]:
|
|
63
|
+
"""Tasks with no dependencies, in stable declaration order."""
|
|
64
|
+
return tuple(node.id for node in self._nodes.values() if not node.depends_on)
|
|
65
|
+
|
|
66
|
+
def leaves(self) -> Tuple[str, ...]:
|
|
67
|
+
"""Tasks that nothing depends on, in stable declaration order."""
|
|
68
|
+
return tuple(node.id for node in self._nodes.values() if not node.dependents)
|
|
69
|
+
|
|
70
|
+
def closure(self, targets: List[str]) -> Set[str]:
|
|
71
|
+
"""Return ``targets`` plus every transitive dependency.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
KeyError: if a target is not present in the graph.
|
|
75
|
+
"""
|
|
76
|
+
seen: Set[str] = set()
|
|
77
|
+
stack: List[str] = []
|
|
78
|
+
for target in targets:
|
|
79
|
+
if target not in self._nodes:
|
|
80
|
+
raise KeyError(target)
|
|
81
|
+
stack.append(target)
|
|
82
|
+
while stack:
|
|
83
|
+
current = stack.pop()
|
|
84
|
+
if current in seen:
|
|
85
|
+
continue
|
|
86
|
+
seen.add(current)
|
|
87
|
+
for dep in self._nodes[current].depends_on:
|
|
88
|
+
if dep in self._nodes and dep not in seen:
|
|
89
|
+
stack.append(dep)
|
|
90
|
+
return seen
|
|
91
|
+
|
|
92
|
+
def reverse_closure(self, targets: List[str]) -> Set[str]:
|
|
93
|
+
"""Return ``targets`` plus every transitive dependent."""
|
|
94
|
+
seen: Set[str] = set()
|
|
95
|
+
stack: List[str] = [t for t in targets if t in self._nodes]
|
|
96
|
+
while stack:
|
|
97
|
+
current = stack.pop()
|
|
98
|
+
if current in seen:
|
|
99
|
+
continue
|
|
100
|
+
seen.add(current)
|
|
101
|
+
for dep in self._nodes[current].dependents:
|
|
102
|
+
if dep not in seen:
|
|
103
|
+
stack.append(dep)
|
|
104
|
+
return seen
|