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.
Files changed (45) hide show
  1. pytaskwirecheck/__init__.py +32 -0
  2. pytaskwirecheck/api.py +314 -0
  3. pytaskwirecheck/cache/__init__.py +0 -0
  4. pytaskwirecheck/cache/fingerprint.py +176 -0
  5. pytaskwirecheck/cache/policy.py +49 -0
  6. pytaskwirecheck/cache/store.py +367 -0
  7. pytaskwirecheck/cli.py +690 -0
  8. pytaskwirecheck/config/__init__.py +0 -0
  9. pytaskwirecheck/config/loader.py +72 -0
  10. pytaskwirecheck/config/schema.py +170 -0
  11. pytaskwirecheck/config/validation.py +590 -0
  12. pytaskwirecheck/diagnostics/__init__.py +31 -0
  13. pytaskwirecheck/diagnostics/errors.py +86 -0
  14. pytaskwirecheck/engine/__init__.py +0 -0
  15. pytaskwirecheck/engine/cancellation.py +38 -0
  16. pytaskwirecheck/engine/events.py +93 -0
  17. pytaskwirecheck/engine/executor.py +301 -0
  18. pytaskwirecheck/engine/scheduler.py +312 -0
  19. pytaskwirecheck/engine/state.py +123 -0
  20. pytaskwirecheck/graph/__init__.py +0 -0
  21. pytaskwirecheck/graph/builder.py +33 -0
  22. pytaskwirecheck/graph/model.py +104 -0
  23. pytaskwirecheck/graph/planner.py +155 -0
  24. pytaskwirecheck/graph/validator.py +92 -0
  25. pytaskwirecheck/history/__init__.py +0 -0
  26. pytaskwirecheck/history/store.py +164 -0
  27. pytaskwirecheck/logging/__init__.py +0 -0
  28. pytaskwirecheck/logging/artifacts.py +95 -0
  29. pytaskwirecheck/logging/structured.py +83 -0
  30. pytaskwirecheck/notifications/__init__.py +0 -0
  31. pytaskwirecheck/notifications/base.py +42 -0
  32. pytaskwirecheck/notifications/webhook.py +71 -0
  33. pytaskwirecheck/processes/__init__.py +0 -0
  34. pytaskwirecheck/processes/runner.py +265 -0
  35. pytaskwirecheck/processes/signals.py +65 -0
  36. pytaskwirecheck/py.typed +0 -0
  37. pytaskwirecheck/tui/__init__.py +0 -0
  38. pytaskwirecheck/tui/app.py +204 -0
  39. pytaskwirecheck/tui/renderer.py +143 -0
  40. pytaskwirecheck-0.1.0.dist-info/METADATA +316 -0
  41. pytaskwirecheck-0.1.0.dist-info/RECORD +45 -0
  42. pytaskwirecheck-0.1.0.dist-info/WHEEL +5 -0
  43. pytaskwirecheck-0.1.0.dist-info/entry_points.txt +2 -0
  44. pytaskwirecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. 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